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

@@ -14,10 +14,15 @@ import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
import { useVersionWatch } from './hooks/useVersionWatch'
import { PetalFall } from './effects/PetalFall'
import { useNightMode } from './hooks/useNightMode'
import { playSuggestionSound } from './audio/sounds'
export default function App() {
const updateAvailable = useVersionWatch()
// Late-night calm mode: dark theme + falling stars instead of petals. The hook
// toggles the `petal-night` class on <html>; we pass the flag to the ambient
// layer so the petals become stars.
const night = useNightMode()
const [docs, setDocs] = useState<DocSummary[]>([])
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
const [title, setTitle] = useState('')
@@ -370,7 +375,7 @@ export default function App() {
return (
<div className="flex h-full flex-col">
<PetalFall />
<PetalFall night={night} />
<header
onMouseDown={handleChromeDown}
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"

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) {

View File

@@ -17,6 +17,23 @@ const PALETTE: { r: number; g: number; b: number }[] = [
{ r: 255, g: 122, b: 172 }, // deep rose
]
// Night-mode sparkle palette: soft starlight tones for the small twinkle accents
// scattered between the chunky cartoon stars (moonlit white, warm gold).
const STAR_PALETTE: { r: number; g: number; b: number }[] = [
{ r: 255, g: 255, b: 255 }, // white
{ r: 255, g: 240, b: 200 }, // warm gold
]
// Chunky cartoon-star colors — bold, candy-bright fills à la a Mario power star
// or a Kirby warp star. Each is drawn with a glossy gradient + a puffy outline.
const CARTOON_COLORS: { r: number; g: number; b: number }[] = [
{ r: 255, g: 201, b: 56 }, // golden power-star
{ r: 255, g: 150, b: 190 }, // bubblegum pink
{ r: 130, g: 195, b: 255 }, // sky blue
{ r: 150, g: 225, b: 190 }, // mint
{ r: 200, g: 175, b: 255 }, // lavender
]
const SPRITE_PX = 160 // off-screen render resolution per petal sprite
function rgba(c: { r: number; g: number; b: number }, a: number): string {
@@ -67,6 +84,118 @@ function makeSprite(color: { r: number; g: number; b: number }): HTMLCanvasEleme
return cv
}
// Draw one star sprite: a soft radial glow with a crisp four-point sparkle on
// top, filled brightest at the core so it reads as a twinkling star.
function makeStarSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement {
const s = SPRITE_PX
const cv = document.createElement('canvas')
cv.width = s
cv.height = s
const g = cv.getContext('2d')!
g.translate(s / 2, s / 2)
// Soft halo behind the sparkle.
const glow = g.createRadialGradient(0, 0, 0, 0, 0, s * 0.5)
glow.addColorStop(0, rgba(mixWhite(color, 0.5), 0.5))
glow.addColorStop(0.35, rgba(color, 0.16))
glow.addColorStop(1, rgba(color, 0))
g.fillStyle = glow
g.beginPath()
g.arc(0, 0, s * 0.5, 0, Math.PI * 2)
g.fill()
// Four-point sparkle: alternate a long outer radius with a small inner one so
// the points pinch in sharply.
const R = s * 0.46
const r = s * 0.08
g.beginPath()
for (let i = 0; i < 8; i++) {
const ang = (i * Math.PI) / 4 - Math.PI / 2
const rad = i % 2 === 0 ? R : r
const x = Math.cos(ang) * rad
const y = Math.sin(ang) * rad
if (i === 0) g.moveTo(x, y)
else g.lineTo(x, y)
}
g.closePath()
const core = g.createRadialGradient(0, 0, 0, 0, 0, R)
core.addColorStop(0, rgba(mixWhite(color, 0.7), 0.98))
core.addColorStop(0.5, rgba(color, 0.9))
core.addColorStop(1, rgba(color, 0.2))
g.fillStyle = core
g.fill()
return cv
}
// Draw one chunky cartoon star — a fat five-point star with a glossy radial
// fill, a soft outer glow so it pops off the dark sky, a puffy rounded outline
// (round line joins → Kirby-ish soft points), and a little corner shine. Reads
// like a game power-up rather than a delicate sparkle.
function makeCartoonStar(base: { r: number; g: number; b: number }): HTMLCanvasElement {
const s = SPRITE_PX
const cv = document.createElement('canvas')
cv.width = s
cv.height = s
const g = cv.getContext('2d')!
g.translate(s / 2, s / 2)
const R = s * 0.4 // outer point radius
const r = R * 0.46 // inner radius — small ⇒ chunky, defined points
// Soft outer glow so the star separates from the night background.
const glow = g.createRadialGradient(0, 0, R * 0.3, 0, 0, R * 1.35)
glow.addColorStop(0, rgba(base, 0.35))
glow.addColorStop(1, rgba(base, 0))
g.fillStyle = glow
g.beginPath()
g.arc(0, 0, R * 1.35, 0, Math.PI * 2)
g.fill()
// Five-point star path (start at the top point).
const star = () => {
g.beginPath()
for (let i = 0; i < 10; i++) {
const ang = -Math.PI / 2 + (i * Math.PI) / 5
const rad = i % 2 === 0 ? R : r
const x = Math.cos(ang) * rad
const y = Math.sin(ang) * rad
if (i === 0) g.moveTo(x, y)
else g.lineTo(x, y)
}
g.closePath()
}
// Candy fill: bright near the top-left, deepening toward the lower edge.
const fill = g.createRadialGradient(-R * 0.25, -R * 0.3, R * 0.1, 0, 0, R * 1.1)
fill.addColorStop(0, rgba(mixWhite(base, 0.7), 1))
fill.addColorStop(0.5, rgba(base, 1))
fill.addColorStop(1, rgba(scale(base, 0.82), 1))
star()
g.fillStyle = fill
g.fill()
// Puffy outline in a deeper shade of the same hue (round joins soften the
// points so it feels hand-drawn, not spiky).
g.lineJoin = 'round'
g.lineCap = 'round'
g.lineWidth = s * 0.05
g.strokeStyle = rgba(scale(base, 0.5), 0.95)
star()
g.stroke()
// Glossy shine: a small soft white blob up in the top-left lobe.
const shine = g.createRadialGradient(-R * 0.22, -R * 0.32, 0, -R * 0.22, -R * 0.32, R * 0.3)
shine.addColorStop(0, 'rgba(255,255,255,0.85)')
shine.addColorStop(1, 'rgba(255,255,255,0)')
g.fillStyle = shine
g.beginPath()
g.ellipse(-R * 0.22, -R * 0.32, R * 0.26, R * 0.18, -0.5, 0, Math.PI * 2)
g.fill()
return cv
}
interface Petal {
baseX: number
y: number
@@ -81,7 +210,7 @@ interface Petal {
sprite: number
}
export function PetalFall() {
export function PetalFall({ night = false }: { night?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
@@ -98,7 +227,12 @@ export function PetalFall() {
const canvas = el
const ctx = c2d
const sprites = PALETTE.map(makeSprite)
// Night sky = chunky cartoon stars (the stars of the show) with a couple of
// small twinkle sparkles mixed in for depth. Random sprite pick weights it
// ~70% chunky stars.
const sprites = night
? [...CARTOON_COLORS.map(makeCartoonStar), ...STAR_PALETTE.map(makeStarSprite)]
: PALETTE.map(makeSprite)
const dpr = Math.min(window.devicePixelRatio || 1, 2)
let W = window.innerWidth
let H = window.innerHeight
@@ -107,19 +241,39 @@ export function PetalFall() {
const count = Math.round(Math.min(46, Math.max(16, (W * H) / 46000)))
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
const spawn = (initial: boolean): Petal => ({
baseX: rnd(0, W),
y: initial ? rnd(-H, H) : rnd(-60, -20),
size: rnd(16, 38),
vy: rnd(22, 52),
angle: rnd(0, Math.PI * 2),
spin: rnd(-0.9, 0.9),
swayAmp: rnd(14, 42),
swayFreq: rnd(0.3, 0.85),
swayPhase: rnd(0, Math.PI * 2),
alpha: rnd(0.5, 0.82),
sprite: Math.floor(Math.random() * sprites.length),
})
// Stars are smaller, drift down a touch slower, sway less, and barely spin
// (they twinkle in place instead of tumbling like petals).
const spawn = (initial: boolean): Petal =>
night
? {
baseX: rnd(0, W),
y: initial ? rnd(-H, H) : rnd(-60, -20),
size: rnd(13, 32),
vy: rnd(14, 36),
angle: rnd(0, Math.PI * 2),
// Every star visibly spins — random direction, and a guaranteed
// minimum speed (a 5-point star is symmetric every 72°, so a slow
// rate reads as motionless). Varied so no two turn quite alike.
spin: (Math.random() < 0.5 ? -1 : 1) * rnd(0.5, 2.2),
swayAmp: 0, // stars fall straight down — no petal-like drift
swayFreq: rnd(0.4, 1.1), // still drives the twinkle shimmer below
swayPhase: rnd(0, Math.PI * 2),
alpha: rnd(0.55, 0.9),
sprite: Math.floor(Math.random() * sprites.length),
}
: {
baseX: rnd(0, W),
y: initial ? rnd(-H, H) : rnd(-60, -20),
size: rnd(16, 38),
vy: rnd(22, 52),
angle: rnd(0, Math.PI * 2),
spin: rnd(-0.9, 0.9),
swayAmp: rnd(14, 42),
swayFreq: rnd(0.3, 0.85),
swayPhase: rnd(0, Math.PI * 2),
alpha: rnd(0.5, 0.82),
sprite: Math.floor(Math.random() * sprites.length),
}
let petals = Array.from({ length: count }, () => spawn(true))
@@ -157,10 +311,16 @@ export function PetalFall() {
continue
}
// Stars shimmer gently (shallow pulse so the chunky ones glow rather
// than blink); petals hold a steady alpha.
const alpha = night
? p.alpha * (0.8 + 0.2 * Math.sin(t * p.swayFreq * 2.2 + p.swayPhase))
: p.alpha
ctx.save()
ctx.translate(x, p.y)
ctx.rotate(p.angle)
ctx.globalAlpha = p.alpha
ctx.globalAlpha = alpha
ctx.drawImage(sprites[p.sprite], -p.size / 2, -p.size / 2, p.size, p.size)
ctx.restore()
}
@@ -187,7 +347,7 @@ export function PetalFall() {
document.removeEventListener('visibilitychange', onVisibility)
petals = []
}
}, [])
}, [night])
return (
<canvas

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react'
import { isBedtime } from '../lib/night'
// Tracks whether it's late enough to shift Petal into its calm night mode (dark
// theme + falling stars instead of petals). Re-checks once a minute so the swap
// happens on its own as the clock rolls past 11pm — no reload needed. Returns
// the boolean and also toggles a `petal-night` class on <html> so the CSS token
// overrides cascade to everything (body background, panels, portals included).
export function useNightMode(): boolean {
const [night, setNight] = useState(() => isBedtime())
useEffect(() => {
const tick = () => setNight(isBedtime())
tick()
const id = setInterval(tick, 60_000)
return () => clearInterval(id)
}, [])
useEffect(() => {
document.documentElement.classList.toggle('petal-night', night)
return () => document.documentElement.classList.remove('petal-night')
}, [night])
return night
}

View File

@@ -37,6 +37,23 @@
--shadow-soft: 0 4px 20px rgba(180, 130, 160, 0.12);
}
/* Night mode — toggled by adding `petal-night` to <html> (useNightMode) once the
clock passes ~11pm. We only re-point the palette tokens; every Tailwind color
utility reads them via var(), so the whole UI shifts to a calm, dim, moonlit
theme without touching component markup. Pastel accent/type colors are kept —
they read beautifully on the dark plum ground. Falling petals become stars. */
html.petal-night {
--color-bg: #14111E; /* deep night plum-indigo */
--color-surface: #211C30; /* raised panels */
--color-surface-alt: #2A2440; /* alt surface / hover wash */
--color-border: #38304E; /* dim lavender border */
--color-plum: #ECE3F2; /* ink → soft moonlit lavender-white */
--color-muted: #9F93B8; /* muted lavender-grey, lifted for contrast */
--color-accent: #E8A0BF; /* rose still primary — pops on dark */
--color-accent-hover: #F2B7D2;/* lighter on hover against the dark */
--shadow-soft: 0 6px 28px rgba(0, 0, 0, 0.45);
}
html, body, #root {
height: 100%;
}
@@ -47,6 +64,8 @@ body {
color: var(--color-plum);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
/* Gentle dusk/dawn fade when night mode flips. */
transition: background 600ms ease, color 600ms ease;
}
/* Gentle, consistent motion across interactive elements */

11
web/src/lib/night.ts Normal file
View File

@@ -0,0 +1,11 @@
// Shared "is it late?" definition, used by both the companion's bedtime nag and
// the night-mode theme/starfall switch so they always agree. Local clock — the
// writer's own machine (millenia's timezone in deployment).
export const BEDTIME_FROM = 23 // local hour the night window opens (11pm)…
export const BEDTIME_TO = 4 // …and closes (4am); past this we assume an early start
export function isBedtime(d: Date = new Date()): boolean {
const h = d.getHours()
return h >= BEDTIME_FROM || h < BEDTIME_TO
}