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
This commit is contained in:
prosolis
2026-06-26 15:10:36 -07:00
parent aebdc2679a
commit 9576340391
8 changed files with 303 additions and 20 deletions

View File

@@ -15,6 +15,11 @@ export const ENCOURAGEMENTS: Line[] = [
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
{ zh: '继续保持,加油!', en: 'Keep going — youve got this!' },
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, thats much clearer.' },
{ zh: '这个词用得真好 🌷', en: 'Thats such a good word choice.' },
{ zh: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' },
{ zh: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' },
{ zh: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' },
{ zh: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' },
]
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
@@ -36,6 +41,16 @@ export const BREAKS: Line[] = [
{ zh: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
]
// Shown when she's still writing late at night (≥11pm). Caring, a little
// playful — the kitten is always asleep, so "you should be too" lands as a gag,
// never a scold. zh stays gentle; the English subtitle carries the wink.
export const BEDTIME: Line[] = [
{ zh: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' },
{ zh: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
{ zh: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
{ zh: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
]
// First hello when the app opens.
export const GREETING: Line = {
zh: '嗨~我在这儿陪你写作哦 🐱',

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave'
import {
BEDTIME,
BREAKS,
ENCOURAGEMENTS,
ERRORS,
@@ -14,12 +15,13 @@ import {
} 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'
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime'
export interface Bubble extends Line {
tone: BubbleTone
}
@@ -44,6 +46,9 @@ 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).
@@ -58,7 +63,7 @@ const now = () => Date.now()
// 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 : BUBBLE_MS
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)
}
@@ -75,6 +80,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
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)
@@ -262,6 +268,15 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
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) {