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] 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 ( 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 +} diff --git a/web/src/index.css b/web/src/index.css index d33fedd..a436d27 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -37,6 +37,23 @@ --shadow-soft: 0 4px 20px rgba(180, 130, 160, 0.12); } +/* Night mode — toggled by adding `petal-night` to (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 */ diff --git a/web/src/lib/night.ts b/web/src/lib/night.ts new file mode 100644 index 0000000..3d3d88e --- /dev/null +++ b/web/src/lib/night.ts @@ -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 +}