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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user