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

@@ -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