From 9576340391aaa2987dbb6b52903cc898d49196c1 Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Fri, 26 Jun 2026 15:10:36 -0700
Subject: [PATCH 1/2] 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 ; 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
---
BUILD_PLAN.md | 33 ++++
web/src/App.tsx | 7 +-
web/src/components/Companion/tips.ts | 15 ++
web/src/components/Companion/useCompanion.ts | 19 +-
web/src/effects/PetalFall.tsx | 194 +++++++++++++++++--
web/src/hooks/useNightMode.ts | 25 +++
web/src/index.css | 19 ++
web/src/lib/night.ts | 11 ++
8 files changed, 303 insertions(+), 20 deletions(-)
create mode 100644 web/src/hooks/useNightMode.ts
create mode 100644 web/src/lib/night.ts
diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md
index e2d4c2d..24af513 100644
--- a/BUILD_PLAN.md
+++ b/BUILD_PLAN.md
@@ -105,6 +105,35 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- [x] **English phonetic (pivot from pinyin)** — for a native-Mandarin English learner the useful pronunciation aid is the English IPA, not pinyin (she reads Chinese fluently). `scripts/build_phonetic.py` extracts ECDICT's `phonetic` column (same source/freq-gate as the gloss); `phonetic.json.gz` embedded + lazily loaded; `Result.Phonetic` resolved via the same de-inflecting candidate walk; shown as `/ˈrɪvər/` in the WordCard. **Full dataset built from ECDICT: 46,579 words (361KB gz)**, in line with the other lexicon assets. The script also has a `--seed` mode (71 curated common words) that ships as a fallback / works without the csv. Curated seed entries (clean IPA) override the ECDICT form where both exist.
- Verified: go build/vet/test (incl. new `TestExportAll`), tsc, vite build all clean; live smoke vs throwaway binaries — `/word/river|running|serendipity|rivers` all return phonetic (`rivers`→`river` de-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with `/{id}`.
+### Phase 12 — Collocation coach 🔜 (planned 2026-06-26)
+**Why:** ESL writers nail grammar but miss *which words go together* — "do a decision" → "make a decision", "strong rain" → "heavy rain". These aren't *wrong*, so the grammar pass won't flag them; they're just non-native. Gentle "natives usually say…" hints are the single highest-leverage upgrade for making her writing sound native. **Build this first** — it's small and de-risks the migration-rebuild pattern Phase 13 also needs.
+
+**Key insight:** the suggestion pipeline is already generic over a `pass` + a `pendingScope` "family" (`runPass` in `internal/suggestions/handlers.go`; grammar + voice already prove it). Collocation drops in as a **third family** and reuses the entire accept/dismiss/re-anchoring/rail/Mandarin-explanation machinery — no new frontend rendering layer.
+- [ ] `internal/llm/collocation.go` — `CollocationInterval` (~25s) + `RunCollocation(...)`, reusing the existing `ParseCheckpoint` parser (same as `RunVoice`). No new parsing.
+- [ ] `internal/llm/prompts.go` — `CollocationMessages(contentText, tone)`. **This prompt is the whole feature**: flag only non-wrong-but-non-native word pairings; explicitly defer real grammar errors to the grammar family; phrase every explanation as warm "natives usually say…" with a Mandarin gloss — never "error/wrong".
+- [ ] `internal/db/models.go` + migration `0005` — new suggestion type `collocation`. ⚠️ The `type` column has a `CHECK(...)` and **SQLite can't `ALTER` a CHECK** → migration must **rebuild** the `suggestions` table (create new w/ extended CHECK, copy rows, drop, rename, recreate `idx_suggestions_doc_id`). Not a one-line ALTER.
+- [ ] `internal/suggestions/handlers.go` — add `collocationScope` (`deleteWhere: "type = 'collocation'"`, `forceType: collocation`), a `CollocationLimit` on `Handler` (+ wire in `New`), register `POST /{id}/collocation`. Mirrors `voice`, ~15 lines. Also extend `normalizeType` to accept the new type.
+- [ ] Frontend: `suggestionMeta.ts` collocation entry (💡 icon + its own warm color so cards read as friendly tips); `client.ts` `collocation(docId)`; a gentle trigger like the existing "Check my voice" pill — **"Make it sound natural 🌸"** — rendering straight into `SuggestionRail`/`SuggestionCard`.
+- [ ] Verify via the millenia UI-test harness (real-browser editor check) + go/tsc/vite clean. **Est. ~½ day** (mostly the new prompt + the table-rebuild migration).
+
+### Phase 13 — Vocabulary garden (spaced repetition) 🔜 (planned 2026-06-26)
+**Why:** the lexicon (`internal/lexicon`) is a stateless static-dataset lookup — **nothing records which words she's looked up.** Capturing them turns passive lookups into real vocabulary, and the review surface ties straight into the "petal garden" delight idea (words become blossoms; the sleeping kitten naps among them). Build **after** Phase 12.
+- [ ] New `internal/vocab` package + migration `0005`/`0006` — `vocab_words` table: `word, gloss, phonetic, example` (sentence captured at lookup for context), `doc_id`, + SM-2-lite scheduling: `due_at, interval_days, ease, reps, lapses, last_reviewed`; `UNIQUE(user_id, word)`.
+- [ ] Auto-capture: when `WordCard` calls `lookupWord`, also fire `POST /api/vocab` (upsert; new word → `due_at = now + 1 day`). Looking words up *is* the data source — zero extra effort from her.
+- [ ] Endpoints: `POST /api/vocab` (record/upsert), `GET /api/vocab/due` (cards due now), `POST /api/vocab/{id}/review` (grade again/good/easy → reschedule), `GET /api/vocab` (full garden), `DELETE /api/vocab/{id}`.
+- [ ] SR scheduler: gentle SM-2-lite / Leitner intervals (1d → 3d → 7d → 16d → 35d). No streak-shaming. This is the only genuinely new logic.
+- [ ] Frontend Garden panel (sibling to `HistoryPanel`/`DocList`): each learned word a blossom, more reps → more bloomed; the `PetalCompanion` kitten naps among them. Flashcard review: captured sentence with the word blanked → recall → EN↔中文 flip → again/good/easy (direction flips for recognition *and* production). A 🤍 "save to garden" on `WordCard` for explicit saves alongside auto-capture.
+- [ ] Verify via the millenia UI-test harness + go/tsc/vite clean. **Est. ~1½–2 days** (new package + table + panel/review UI; reuses existing panel/companion patterns).
+
+### Phase 14 — Companion warmth + bedtime nag + night mode ✅
+**Why:** the companion kitten is the heart of Petal's "built for her" feel. Three additions: (1) a wider, fresher pool of **encouraging phrases** so cheers don't repeat as quickly; (2) when she's still writing **late at night (≥11pm)**, the kitten gently nags her to go to bed; (3) at the same hour the whole app drifts into a calm **night mode** — dark moonlit theme + the falling petals become **falling stars**. Caring, never scolding — the sleepy-cat gag makes "you should be sleeping too 🐱💤" land perfectly. Self-contained, frontend-only.
+- [x] `web/src/components/Companion/tips.ts` — `ENCOURAGEMENTS` grown from 5→10 bilingual zh-first lines so cheers rotate fresher. New `BEDTIME: Line[]` array — four warm/playful lines (user-supplied English wit: "I bet your bed is missing you right now", "A tired writer is a bad writer", "Sleep is a wondrous enabler", "Hear that? No… everyone is sleeping and you should be too") with gentle Mandarin leads.
+- [x] `web/src/components/Companion/useCompanion.ts` — bedtime check folded into the existing 10s heartbeat (after the idle-return + break check, before the generic tip): `isBedtime()` = local hour ≥ 23 or < 4 (`new Date().getHours()`, the writer's machine clock). Only fires while actively writing (idle branch returns first). Own `lastBedtime` ref + `BEDTIME_GAP` 30min cooldown; respects `PROACTIVE_GAP`. New `BubbleTone` `'bedtime'` paces `readBubbleMs` (BUBBLE_MS + 4s lingers a touch longer for a wind-down read). Window knobs `BEDTIME_FROM`/`BEDTIME_TO` so the 11pm–4am range is one edit to retune.
+- [x] No tone-based bubble styling exists, so no CSS needed; the tone is metadata for pacing only. Sound stays the rotating pop.
+- [x] **Night mode** — `web/src/lib/night.ts` centralizes `isBedtime()` + the `BEDTIME_FROM`/`BEDTIME_TO` window (shared with the companion nag so they always agree). `web/src/hooks/useNightMode.ts` re-checks every 60s and toggles a `petal-night` class on ``. `index.css` adds an `html.petal-night` block that **only re-points the palette tokens** (`--color-bg/surface/border/plum/muted/accent…`) to a dark moonlit set — every Tailwind color utility reads them via `var()`, so the whole UI flips with zero component changes (verified: `.text-plum{color:var(--color-plum)}`). Accent/type colors kept (they pop on dark); 600ms bg/color fade for a gentle dusk transition; print stays white (the `#fff` override is inside `@media print`).
+- [x] **Falling stars** — `PetalFall` gains a `night` prop. Particles are **chunky cartoon power stars** (`makeCartoonStar`, Mario/Kirby-style: fat 5-point shape, glossy radial fill, puffy round-join colored outline, corner shine + soft glow halo so they pop off the dark sky) in 5 candy colors (`CARTOON_COLORS`), mixed ~70/30 with small four-point twinkle sparkles (`makeStarSprite`/`STAR_PALETTE`) for depth. Every star clearly **spins** (random direction, ~0.5–2.2 rad/s so the quick ones really whirl while slow ones drift for contrast; guaranteed min speed since a 5-point star is symmetric every 72°), **falls straight down** (no sway — that's a petal thing), and shimmers via a shallow alpha pulse (not blink). Effect re-inits on the day↔night flip. App wires `const night = useNightMode()` → ``. All sprites are canvas-drawn (offline, no asset files) — `sprites[]` is an image array, so a real PNG/SVG star could drop in later without restructuring.
+- [x] Verified: tsc clean, vite build OK, companion vitest 45/45. **Real-browser screenshots** (local Playwright + Chromium, clock mocked to 23:30): day = warm cream + pink sakura petals; night = dark plum-indigo + twinkling stars + glowing sleepy kitten. Both pretty (acceptance criterion).
+
### Deferred (post-v1-local)
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
- [ ] Copyleaks Tier-2 + webhook HMAC
@@ -113,8 +142,12 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
### Next-up (post-v1 product, agreed with user 2026-06-26)
- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
+- [ ] **Phase 12 — collocation coach**: gentle "natives usually say…" hints for non-native word pairings, as a third suggestion family. 🔜 (see Phase 12 above; build first)
+- [ ] **Phase 13 — vocabulary garden**: spaced-repetition review built from looked-up words, surfaced as a blooming garden. 🔜 (see Phase 13 above)
+- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
## Session log
+- 2026-06-26: **Phase 14 complete** (companion warmth + bedtime nag + night mode). `tips.ts`: `ENCOURAGEMENTS` 5→10 lines; new `BEDTIME` array (4 lines, user-supplied English wit + gentle Mandarin leads). `useCompanion.ts`: bedtime branch in the 10s heartbeat (after idle-return + break, before the generic tip); only nudges while actively writing; own `lastBedtime` ref + 30min `BEDTIME_GAP`, respects `PROACTIVE_GAP`; new `'bedtime'` `BubbleTone` lingers ~4s longer. **Night mode** (added same session, user request): `lib/night.ts` centralizes `isBedtime()` + window (now shared by the nag too); `hooks/useNightMode.ts` toggles `petal-night` on `` (60s re-check); `index.css` `html.petal-night` re-points only the palette tokens → whole UI flips via `var()` (no component edits), 600ms dusk fade, print stays white; `PetalFall` gains a `night` prop → chunky cartoon power stars (`makeCartoonStar`, Mario/Kirby-style, 5 candy colors) mixed ~70/30 with small twinkle sparkles, gentle spin + shallow shimmer, effect re-inits on flip; App: `useNightMode()` → ``. tsc + vite clean, companion vitest 45/45; verified with real-browser Playwright screenshots (clock mocked to 23:30) — day petals/cream vs night stars/dark-plum, both pretty. Bedtime window is `BEDTIME_FROM`/`BEDTIME_TO` (local clock) for easy retune.
- 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & Replace** — `SearchHighlight` decoration extension + `FindReplace` bar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) **read-aloud** Web Speech util + 🔊 in WordCard & selection bubble; (3) **keyboard/touch access** — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactored `handleContextMenu` → shared `openWordLookup(pos)`); (4) **export-all** backup zip (`GET /api/docs/export-all`, `TestExportAll`, sidebar download links); (5) **smart typography** input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) **duplicate doc + sidebar sort + outline popover**; (7) **English phonetic** (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) via `scripts/build_phonetic.py` + embedded `phonetic.json.gz` + `Result.Phonetic` + WordCard `/ˈrɪvər/` line — **full 46,579-word dataset built from ECDICT** (the csv re-download worked; `--seed` mode kept as a csv-free fallback). Also folded in this session: the **selection-bubble vs copy/paste fix** (bubble deferred to pointer-up + container `pointer-events:none` so it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user.
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
diff --git a/web/src/App.tsx b/web/src/App.tsx
index ed2e715..9e43da5 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -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 ; we pass the flag to the ambient
+ // layer so the petals become stars.
+ const night = useNightMode()
const [docs, setDocs] = useState([])
const [currentDoc, setCurrentDoc] = useState(null)
const [title, setTitle] = useState('')
@@ -370,7 +375,7 @@ export default function App() {
return (
-
+
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) {
diff --git a/web/src/effects/PetalFall.tsx b/web/src/effects/PetalFall.tsx
index d1adb45..8d01d92 100644
--- a/web/src/effects/PetalFall.tsx
+++ b/web/src/effects/PetalFall.tsx
@@ -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(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 (