5 Commits

Author SHA1 Message Date
prosolis
631279bd3a TTS: auto-detect Chinese vs English so callers can pass the selection
speak()'s lang now defaults to detectLang(text) (any Han/kana -> zh-CN,
else en-US) instead of always en-US, so reading a Chinese passage picks the
Piper zh voice rather than spelling out characters with the English voice.
Explicit lang still overrides.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 15:10:36 -07:00
prosolis
9576340391 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
2026-06-26 15:10:36 -07:00
prosolis
aebdc2679a Merge fix/checkpoint-token-truncation: stop 502s on long-doc checkpoints
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 14:16:13 -07:00
prosolis
8bd2509bc2 Checkpoint: raise output cap to 4096 + salvage truncated JSON
The grammar checkpoint capped num_predict at 1024, but qwen3.5:9b ignores
the prompt's "up to 5 issues" and emits ~17-20 suggestions (~2000 tokens)
on a 300+ word doc. The output hit the cap mid-array (done_reason=length),
the JSON never closed, and ParseCheckpoint found no parseable object -> a
502 in ~20s on every long doc (1024 tok @ ~50 tok/s, not a network
timeout). The repeated failures made the writing-assist helper look
permanently asleep.

Fix:
- Raise the cap to checkpointMaxTokens=4096. It is a ceiling, not a
  target -- the model stops at its JSON close, so shorter docs are
  unaffected; only genuinely long outputs use the headroom.
- Make ParseCheckpoint salvage the completed {...} suggestion objects
  from a truncated array (refactor extractJSONObject onto a shared
  firstBalancedObject scanner), so an over-long doc degrades to partial
  feedback instead of a hard 502.

Verified live on millenia: 'The Missing Key' (382 words) now returns 200
with 17 suggestions in ~38s, previously 502 every time.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 14:16:07 -07:00
prosolis
0f2f753efa Merge fix/checkpoint-paste-retry: auto-retry failed checkpoints, release slot on failure
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 13:11:16 -07:00
11 changed files with 412 additions and 38 deletions

View File

@@ -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. - [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}`. - 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 11pm4am 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 `<html>`. `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.52.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()``<PetalFall night={night} />`. 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) ### Deferred (post-v1-local)
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first** - [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
- [ ] Copyleaks Tier-2 + webhook HMAC - [ ] 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) ### 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 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) - [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 ## 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 `<html>` (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()``<PetalFall night={night}/>`. 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 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 (07) + post-v1 product (810) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), 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 (07) + post-v1 product (810) 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).** - 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).**

View File

@@ -14,6 +14,16 @@ import (
// that protects the inference endpoint from rapid repeat checks. // that protects the inference endpoint from rapid repeat checks.
const CheckpointInterval = 30 * time.Second const CheckpointInterval = 30 * time.Second
// checkpointMaxTokens caps the checkpoint's generated output. Each suggestion
// echoes its original sentence, a replacement, and a friendly explanation, so
// the full JSON for the prompt's "up to 5 issues" runs ~2,000 tokens on a long
// document (measured ~2,007 for a 380-word doc). The old 1,024 cap truncated
// the JSON mid-array — done_reason "length" — leaving an object that never
// closed, so ParseCheckpoint found no parseable JSON and the pass 502'd on
// every long doc. This is a ceiling, not a target: the model stops at its JSON
// close well before it, so the headroom costs nothing on shorter docs.
const checkpointMaxTokens = 4096
// RawSuggestion is one item as the model emits it. Positions are resolved // RawSuggestion is one item as the model emits it. Positions are resolved
// server-side from Original; the frontend re-anchors by string at render time. // server-side from Original; the frontend re-anchors by string at render time.
type RawSuggestion struct { type RawSuggestion struct {
@@ -34,7 +44,7 @@ type checkpointResponse struct {
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) { func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{ raw, err := client.Complete(ctx, CompletionRequest{
Messages: CheckpointMessages(TruncateDoc(contentText), tone), Messages: CheckpointMessages(TruncateDoc(contentText), tone),
MaxTokens: 1024, MaxTokens: checkpointMaxTokens,
Temperature: 0.3, Temperature: 0.3,
RepetitionPenalty: 1.15, RepetitionPenalty: 1.15,
TopP: 0.9, TopP: 0.9,
@@ -48,20 +58,32 @@ func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone stri
// ParseCheckpoint extracts the suggestions array from a model response. Smaller // ParseCheckpoint extracts the suggestions array from a model response. Smaller
// models sometimes wrap JSON in prose or markdown fences despite instructions, // models sometimes wrap JSON in prose or markdown fences despite instructions,
// so we salvage the outermost {...} object before decoding. // so we salvage the outermost {...} object before decoding. When the model
// truncates mid-array (it ran out of output budget) the outer object never
// closes and won't parse — we then recover the suggestion objects that *did*
// complete, so a long document still gets feedback instead of a hard failure.
func ParseCheckpoint(raw string) ([]RawSuggestion, error) { func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
jsonText := extractJSONObject(raw) var suggestions []RawSuggestion
if jsonText == "" { parsed := false
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw)) if jsonText := extractJSONObject(raw); jsonText != "" {
var resp checkpointResponse
if err := json.Unmarshal([]byte(jsonText), &resp); err == nil {
suggestions = resp.Suggestions
parsed = true
}
} }
var parsed checkpointResponse if !parsed {
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil { // No closeable object, or it failed to decode (most often a truncated
return nil, fmt.Errorf("checkpoint: parse JSON: %w (got %q)", err, truncateForError(jsonText)) // array). Salvage the individual suggestion objects that completed.
suggestions = salvageSuggestions(raw)
if suggestions == nil {
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw))
}
} }
// Drop items the model returned with an empty original — they can't be // Drop items the model returned with an empty original — they can't be
// anchored — and normalize whitespace the model may have echoed. // anchored — and normalize whitespace the model may have echoed.
out := parsed.Suggestions[:0] out := suggestions[:0]
for _, s := range parsed.Suggestions { for _, s := range suggestions {
s.Original = strings.TrimSpace(s.Original) s.Original = strings.TrimSpace(s.Original)
s.Replacement = strings.TrimSpace(s.Replacement) s.Replacement = strings.TrimSpace(s.Replacement)
if s.Original == "" { if s.Original == "" {
@@ -72,12 +94,55 @@ func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
return out, nil return out, nil
} }
// salvageSuggestions recovers as many complete suggestion objects as it can from
// a response whose top-level JSON didn't parse — typically one truncated mid
// "suggestions" array. It scans the array region for brace-balanced {...}
// objects and keeps each that decodes into a RawSuggestion with a non-empty
// original; the final, cut-off object simply never balances and is dropped.
// Returns nil when there is no salvageable array (so the caller can error).
func salvageSuggestions(raw string) []RawSuggestion {
key := strings.Index(raw, `"suggestions"`)
if key < 0 {
return nil
}
lb := strings.IndexByte(raw[key:], '[')
if lb < 0 {
return nil
}
rest := raw[key+lb+1:]
out := []RawSuggestion{}
for {
obj, end := firstBalancedObject(rest)
if end < 0 {
break
}
var s RawSuggestion
if err := json.Unmarshal([]byte(obj), &s); err == nil && strings.TrimSpace(s.Original) != "" {
out = append(out, s)
}
rest = rest[end:]
}
if len(out) == 0 {
return nil
}
return out
}
// extractJSONObject returns the substring from the first '{' to its matching // extractJSONObject returns the substring from the first '{' to its matching
// closing '}', or "" if none. Tolerates fences/preamble around the object. // closing '}', or "" if none. Tolerates fences/preamble around the object.
func extractJSONObject(s string) string { func extractJSONObject(s string) string {
obj, _ := firstBalancedObject(s)
return obj
}
// firstBalancedObject returns the first brace-balanced {...} substring in s and
// the index in s just past its closing brace, or ("", -1) if no object closes
// (none present, or the only one is truncated). String contents — including
// braces inside quoted values — are skipped so they can't unbalance the count.
func firstBalancedObject(s string) (string, int) {
start := strings.IndexByte(s, '{') start := strings.IndexByte(s, '{')
if start < 0 { if start < 0 {
return "" return "", -1
} }
depth := 0 depth := 0
inString := false inString := false
@@ -98,11 +163,11 @@ func extractJSONObject(s string) string {
case c == '}': case c == '}':
depth-- depth--
if depth == 0 { if depth == 0 {
return s[start : i+1] return s[start : i+1], i + 1
} }
} }
} }
return "" return "", -1
} }
func truncateForError(s string) string { func truncateForError(s string) string {

View File

@@ -42,6 +42,21 @@ func TestParseCheckpoint(t *testing.T) {
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`, raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,
want: 1, want: 1,
}, },
{
// The bug that 502'd every long doc: the model hit num_predict and the
// JSON never closed. Two suggestions completed; the third is cut off.
// Salvage keeps the two that completed instead of failing outright.
name: "truncated mid-array salvages completed objects",
raw: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},{"original":"a apple","replacement":"an apple","explanation":"use an before a vowel","type":"grammar"},{"original":"she relised","replacement":"she real`,
want: 2,
},
{
// A truncated array where even the first object is incomplete has
// nothing to salvage → still an error (the caller releases + retries).
name: "truncated before any object closes",
raw: `{"suggestions":[{"original":"It was a thursdy morning when Clara`,
wantErr: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View File

@@ -14,10 +14,15 @@ import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner' import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
import { useVersionWatch } from './hooks/useVersionWatch' import { useVersionWatch } from './hooks/useVersionWatch'
import { PetalFall } from './effects/PetalFall' import { PetalFall } from './effects/PetalFall'
import { useNightMode } from './hooks/useNightMode'
import { playSuggestionSound } from './audio/sounds' import { playSuggestionSound } from './audio/sounds'
export default function App() { export default function App() {
const updateAvailable = useVersionWatch() 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 [docs, setDocs] = useState<DocSummary[]>([])
const [currentDoc, setCurrentDoc] = useState<Document | null>(null) const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
@@ -370,7 +375,7 @@ export default function App() {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PetalFall /> <PetalFall night={night} />
<header <header
onMouseDown={handleChromeDown} onMouseDown={handleChromeDown}
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5" className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"

View File

@@ -59,12 +59,23 @@ function speakWebSpeech(text: string, lang: string): void {
synth.speak(utterance) synth.speak(utterance)
} }
// detectLang guesses a BCP-47 locale from the text so the right Piper voice is
// chosen. Any Han/kana character routes to Chinese (zh-CN) — a selection is
// essentially never mixed at the level that matters, and reading a Chinese
// passage with the English voice spells each character out ("Chinese letter…").
// Everything else defaults to US English, the language she's learning.
const CJK = /[぀-ヿ㐀-䶿一-鿿豈-﫿ヲ-゚]/
export function detectLang(text: string): string {
return CJK.test(text) ? 'zh-CN' : 'en-US'
}
// speak reads `text` aloud, cancelling anything already in flight so rapid taps // speak reads `text` aloud, cancelling anything already in flight so rapid taps
// don't queue up. `lang` defaults to US English (the language she's learning); // don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
// pass a zh locale to hear a Chinese passage. It tries the server's neural voice // so callers can just pass the selection; pass an explicit locale to override.
// first and silently falls back to the browser voice if that's unavailable (route // It tries the server's neural voice first and silently falls back to the browser
// off, network error, or a 404 for a language with no configured voice). // voice if that's unavailable (route off, network error, or a 404 for a language
export function speak(text: string, lang = 'en-US'): void { // with no configured voice).
export function speak(text: string, lang = detectLang(text)): void {
if (!text.trim()) return if (!text.trim()) return
stopCurrent() stopCurrent()
const seq = ++requestSeq const seq = ++requestSeq

View File

@@ -15,6 +15,11 @@ export const ENCOURAGEMENTS: Line[] = [
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' }, { zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
{ zh: '继续保持,加油!', en: 'Keep going — youve got this!' }, { zh: '继续保持,加油!', en: 'Keep going — youve got this!' },
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, thats much clearer.' }, { 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 // 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. 🌿' }, { 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. // First hello when the app opens.
export const GREETING: Line = { export const GREETING: Line = {
zh: '嗨~我在这儿陪你写作哦 🐱', zh: '嗨~我在这儿陪你写作哦 🐱',

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave' import type { SaveStatus } from '../../hooks/useAutoSave'
import { import {
BEDTIME,
BREAKS, BREAKS,
ENCOURAGEMENTS, ENCOURAGEMENTS,
ERRORS, ERRORS,
@@ -14,12 +15,13 @@ import {
} from './tips' } from './tips'
import { analyzeProse } from './prose' import { analyzeProse } from './prose'
import { playPop, playSound, type SoundName } from '../../audio/sounds' 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, // The kitten's expression. Maps to a Lottie animation when assets are present,
// otherwise to an emoji placeholder (see PetalCompanion). // otherwise to an emoji placeholder (see PetalCompanion).
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate' 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 { export interface Bubble extends Line {
tone: BubbleTone 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 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 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 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 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 // 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). // (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 // length of the Mandarin + English lines so denser advice stays up long enough
// to actually finish reading. // to actually finish reading.
function readBubbleMs(b: Bubble): number { 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 const chars = b.zh.length + b.en.length
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR) 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 lastProactive = useRef(0)
const lastTip = useRef(0) const lastTip = useRef(0)
const lastBreak = useRef(0) const lastBreak = useRef(0)
const lastBedtime = useRef(0)
const nextMilestone = useRef(0) // index into MILESTONES const nextMilestone = useRef(0) // index into MILESTONES
const sleeping = useRef(false) const sleeping = useRef(false)
@@ -262,6 +268,15 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
return 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 // Otherwise an occasional tip — context-aware when her text gives us
// something concrete to gently point at, generic warmth otherwise. // something concrete to gently point at, generic warmth otherwise.
if (t - lastTip.current > TIP_MIN_GAP) { 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 { 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 const SPRITE_PX = 160 // off-screen render resolution per petal sprite
function rgba(c: { r: number; g: number; b: number }, a: number): string { 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 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 { interface Petal {
baseX: number baseX: number
y: number y: number
@@ -81,7 +210,7 @@ interface Petal {
sprite: number sprite: number
} }
export function PetalFall() { export function PetalFall({ night = false }: { night?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => { useEffect(() => {
@@ -98,7 +227,12 @@ export function PetalFall() {
const canvas = el const canvas = el
const ctx = c2d 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) const dpr = Math.min(window.devicePixelRatio || 1, 2)
let W = window.innerWidth let W = window.innerWidth
let H = window.innerHeight 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 count = Math.round(Math.min(46, Math.max(16, (W * H) / 46000)))
const rnd = (a: number, b: number) => a + Math.random() * (b - a) const rnd = (a: number, b: number) => a + Math.random() * (b - a)
const spawn = (initial: boolean): Petal => ({ // Stars are smaller, drift down a touch slower, sway less, and barely spin
baseX: rnd(0, W), // (they twinkle in place instead of tumbling like petals).
y: initial ? rnd(-H, H) : rnd(-60, -20), const spawn = (initial: boolean): Petal =>
size: rnd(16, 38), night
vy: rnd(22, 52), ? {
angle: rnd(0, Math.PI * 2), baseX: rnd(0, W),
spin: rnd(-0.9, 0.9), y: initial ? rnd(-H, H) : rnd(-60, -20),
swayAmp: rnd(14, 42), size: rnd(13, 32),
swayFreq: rnd(0.3, 0.85), vy: rnd(14, 36),
swayPhase: rnd(0, Math.PI * 2), angle: rnd(0, Math.PI * 2),
alpha: rnd(0.5, 0.82), // Every star visibly spins — random direction, and a guaranteed
sprite: Math.floor(Math.random() * sprites.length), // 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)) let petals = Array.from({ length: count }, () => spawn(true))
@@ -157,10 +311,16 @@ export function PetalFall() {
continue 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.save()
ctx.translate(x, p.y) ctx.translate(x, p.y)
ctx.rotate(p.angle) 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.drawImage(sprites[p.sprite], -p.size / 2, -p.size / 2, p.size, p.size)
ctx.restore() ctx.restore()
} }
@@ -187,7 +347,7 @@ export function PetalFall() {
document.removeEventListener('visibilitychange', onVisibility) document.removeEventListener('visibilitychange', onVisibility)
petals = [] petals = []
} }
}, []) }, [night])
return ( return (
<canvas <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); --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 { html, body, #root {
height: 100%; height: 100%;
} }
@@ -47,6 +64,8 @@ body {
color: var(--color-plum); color: var(--color-plum);
font-family: var(--font-ui); font-family: var(--font-ui);
-webkit-font-smoothing: antialiased; -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 */ /* 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
}