Files
petal/web/src/components/Companion/useCompanion.ts
prosolis 9576340391 Phase 14: bedtime nag + night mode (dark theme + falling stars)
Companion warmth:
- ENCOURAGEMENTS grown 5->10; new BEDTIME lines (warm/playful, zh-first)
- useCompanion heartbeat gains a bedtime branch (>=11pm, while actively
  writing) gated by a 30min cooldown; new 'bedtime' BubbleTone paces it

Night mode (auto at ~11pm, local clock):
- lib/night.ts centralizes isBedtime() + the window, shared with the nag
- useNightMode toggles a `petal-night` class on <html>; index.css re-points
  only the palette tokens, so every Tailwind color utility flips via var()
  with no component edits (600ms dusk fade; print stays white)
- PetalFall gains a `night` prop: chunky cartoon power stars (makeCartoonStar,
  5 candy colors) mixed ~70/30 with twinkle sparkles; each star spins at its
  own rate (~0.5-2.2 rad/s, random direction), falls straight down, shimmers

Verified: go vet/test, tsc, vitest 51/51, vite build; real-browser
screenshots/video (clock mocked to 23:30).

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 15:10:36 -07:00

300 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave'
import {
BEDTIME,
BREAKS,
ENCOURAGEMENTS,
ERRORS,
GREETING,
MILESTONES,
TIPS,
WELCOME_BACK,
milestoneLine,
pick,
type Line,
} from './tips'
import { analyzeProse } from './prose'
import { playPop, playSound, type SoundName } from '../../audio/sounds'
import { isBedtime } from '../../lib/night'
// The kitten's expression. Maps to a Lottie animation when assets are present,
// otherwise to an emoji placeholder (see PetalCompanion).
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime'
export interface Bubble extends Line {
tone: BubbleTone
}
interface Signals {
wordCount: number
saveStatus: SaveStatus
// True while the writing-assist LLM is unreachable (timeout / down). A
// false→true flip earns a gentle "haiya".
llmDown: boolean
// Monotonic counters bumped by the app on each editor change / accepted
// suggestion — lets the companion react without a full event bus.
editTick: number
acceptTick: number
// The document's plain text, used by the rules-based prose checker to offer
// context-aware writing notes instead of only generic tips.
text: string
}
// Timing knobs (ms). Tuned to feel present but never naggy.
const IDLE_MS = 75_000 // no edits → kitten naps
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
const BEDTIME_GAP = 30 * 60_000 // at most one "go to bed" nudge per this window
// The late-night window itself (isBedtime) lives in ../../lib/night so the
// companion nag and the night-mode theme/starfall share one definition.
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
// how much there is to read, since she reads both the Mandarin and the English
// (and tips now quote a slice of her own sentence, so they run longer).
const BUBBLE_MS = 14_000 // baseline for a tip / break
const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
const MAX_BUBBLE_MS = 32_000 // cap so a long quote can't pin the bubble forever
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
const now = () => Date.now()
// Reading time for a bubble: a base floor by tone, stretched by the combined
// length of the Mandarin + English lines so denser advice stays up long enough
// to actually finish reading.
function readBubbleMs(b: Bubble): number {
const base = b.tone === 'cheer' ? CHEER_MS : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS
const chars = b.zh.length + b.en.length
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
}
// useCompanion is the behavior engine: it watches writing signals and decides
// when the kitten speaks, what mood it shows, and how to pace itself so the
// companion feels alive without interrupting. UI-agnostic — returns state only.
export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Signals) {
const [mood, setMood] = useState<Mood>('idle')
const [bubble, setBubble] = useState<Bubble | null>(null)
const lastActivity = useRef(now())
const sessionStart = useRef(now())
const lastProactive = useRef(0)
const lastTip = useRef(0)
const lastBreak = useRef(0)
const lastBedtime = useRef(0)
const nextMilestone = useRef(0) // index into MILESTONES
const sleeping = useRef(false)
// Latest text for the prose checker, read lazily by the heartbeat (kept in a
// ref so per-keystroke changes don't re-arm the interval).
const textRef = useRef(text)
textRef.current = text
// Recently-surfaced hint ids, so the same untouched sentence isn't re-flagged
// each cadence. A small ring — old ids age out and may resurface later.
const recentHints = useRef<string[]>([])
const lastRule = useRef<string>('')
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
// Show a bubble + talking mood, then settle back. `proactive` messages respect
// the spacing floor; user-triggered ones (cheers) always go through.
const say = useCallback(
(b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean; sound?: SoundName }) => {
const t = now()
if (opts?.proactive) {
if (bubble) return
if (t - lastProactive.current < PROACTIVE_GAP) return
lastProactive.current = t
}
sleeping.current = false
// A sound to match the bubble: callers can name a specific one (the milestone
// fanfare, the "haiya" on errors); everything else gets a rotating pop so the
// same blip never repeats back to back.
if (opts?.sound) playSound(opts.sound)
else playPop()
setBubble(b)
setMood(opts?.celebrate ? 'celebrate' : 'talking')
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
const dur = readBubbleMs(b)
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
moodTimer.current = setTimeout(() => setMood('idle'), dur)
}, [bubble])
// Choose the next spontaneous tip. Prefer a real, context-aware note from the
// rules-based prose checker (it's actionable and clearly about *her* writing);
// fall back to a generic encouragement-style tip when the text reads clean or
// every finding was shown recently. Avoids repeating a finding or the same
// rule family back-to-back so the companion never feels like a broken record.
const nextTip = useCallback((): Bubble => {
const hints = analyzeProse(textRef.current)
const fresh = hints.find(
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
)
const hint = fresh ?? hints.find((h) => !recentHints.current.includes(h.id))
if (hint) {
lastRule.current = hint.rule
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
return { zh: hint.zh, en: hint.en, tone: 'tip' }
}
return { ...pick(TIPS), tone: 'tip' }
}, [])
const dismiss = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
setBubble(null)
setMood('idle')
}, [])
// Pause the auto-dismiss while she hovers a bubble — so a longer note never
// vanishes mid-read. Releasing leaves it up for a short, fixed grace so it
// doesn't linger forever once she looks away.
const holdBubble = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
}, [])
const releaseBubble = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
bubbleTimer.current = setTimeout(() => setBubble(null), HOVER_GRACE_MS)
moodTimer.current = setTimeout(() => setMood('idle'), HOVER_GRACE_MS)
}, [])
// Opening hello (once), after a short beat so it doesn't race the first paint.
useEffect(() => {
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
return () => clearTimeout(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Edits: record activity and wake from a nap with a warm welcome-back.
const firstEdit = useRef(true)
useEffect(() => {
if (firstEdit.current) {
firstEdit.current = false
return
}
lastActivity.current = now()
if (sleeping.current) {
sleeping.current = false
sessionStart.current = now() // a fresh stretch starts on return
say({ ...WELCOME_BACK, tone: 'cheer' })
} else if (mood === 'sleeping') {
setMood('idle')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editTick])
// Accepts: always cheer (it's a direct response to her action).
const firstAccept = useRef(true)
useEffect(() => {
if (firstAccept.current) {
firstAccept.current = false
return
}
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [acceptTick])
// Word-count milestones: cheer the first time each threshold is crossed.
useEffect(() => {
while (
nextMilestone.current < MILESTONES.length &&
wordCount >= MILESTONES[nextMilestone.current]
) {
const n = MILESTONES[nextMilestone.current]
nextMilestone.current += 1
// Skip silently if we're just loading a long doc (no edits yet).
if (!firstEdit.current)
say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true, sound: 'milestone' })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordCount])
// Trouble — a save failed or the writing-assist LLM went unreachable. Play the
// playful "haiya" and reassure her, but never spam: at most one per gap, and
// only on the *transition* into trouble (not while it lingers).
const lastError = useRef(0)
const ERROR_GAP = 20_000
const wasLlmDown = useRef(false)
const haiya = useCallback(() => {
const t = now()
if (t - lastError.current < ERROR_GAP) {
playSound('error') // still acknowledge it, just without a fresh bubble
return
}
lastError.current = t
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
}, [say])
useEffect(() => {
if (saveStatus === 'error') haiya()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveStatus])
useEffect(() => {
if (llmDown && !wasLlmDown.current) haiya()
wasLlmDown.current = llmDown
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [llmDown])
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
useEffect(() => {
if (saveStatus === 'saved' && Math.random() < 0.18) {
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { proactive: true })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveStatus])
// Heartbeat: nap when idle, suggest breaks, and drop the odd writing tip.
useEffect(() => {
const id = setInterval(() => {
const t = now()
const idleFor = t - lastActivity.current
if (idleFor > IDLE_MS) {
sleeping.current = true
if (!bubble) setMood('sleeping')
return // resting — no nudges while she's away
}
// Active: time for a break?
if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) {
lastBreak.current = t
sessionStart.current = t
say({ ...pick(BREAKS), tone: 'break' }, { proactive: true })
return
}
// Burning the midnight oil? Gently suggest bed — caring, low-frequency,
// and only while she's actually still at it (the idle branch above already
// returned if she's away/napping).
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
lastBedtime.current = t
say({ ...pick(BEDTIME), tone: 'bedtime' }, { proactive: true })
return
}
// Otherwise an occasional tip — context-aware when her text gives us
// something concrete to gently point at, generic warmth otherwise.
if (t - lastTip.current > TIP_MIN_GAP) {
lastTip.current = t
say(nextTip(), { proactive: true })
}
}, 10_000)
return () => clearInterval(id)
}, [bubble, say, nextTip])
useEffect(
() => () => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
},
[],
)
return { mood, bubble, dismiss, holdBubble, releaseBubble }
}