Compare commits
10 Commits
feat/tts-p
...
7824b68ea1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7824b68ea1 | ||
|
|
631279bd3a | ||
|
|
9576340391 | ||
|
|
aebdc2679a | ||
|
|
8bd2509bc2 | ||
|
|
0f2f753efa | ||
|
|
46db0a3e16 | ||
|
|
1bd38b20e8 | ||
|
|
82e2bcc777 | ||
|
|
035dcf5d25 |
@@ -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 `<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.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()` → `<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)
|
||||
- [ ] 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 `<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 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).**
|
||||
|
||||
@@ -14,6 +14,16 @@ import (
|
||||
// that protects the inference endpoint from rapid repeat checks.
|
||||
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
|
||||
// server-side from Original; the frontend re-anchors by string at render time.
|
||||
type RawSuggestion struct {
|
||||
@@ -34,7 +44,7 @@ type checkpointResponse struct {
|
||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
||||
MaxTokens: 1024,
|
||||
MaxTokens: checkpointMaxTokens,
|
||||
Temperature: 0.3,
|
||||
RepetitionPenalty: 1.15,
|
||||
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
|
||||
// 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) {
|
||||
jsonText := extractJSONObject(raw)
|
||||
if jsonText == "" {
|
||||
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw))
|
||||
var suggestions []RawSuggestion
|
||||
parsed := false
|
||||
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 err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("checkpoint: parse JSON: %w (got %q)", err, truncateForError(jsonText))
|
||||
if !parsed {
|
||||
// No closeable object, or it failed to decode (most often a truncated
|
||||
// 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
|
||||
// anchored — and normalize whitespace the model may have echoed.
|
||||
out := parsed.Suggestions[:0]
|
||||
for _, s := range parsed.Suggestions {
|
||||
out := suggestions[:0]
|
||||
for _, s := range suggestions {
|
||||
s.Original = strings.TrimSpace(s.Original)
|
||||
s.Replacement = strings.TrimSpace(s.Replacement)
|
||||
if s.Original == "" {
|
||||
@@ -72,12 +94,55 @@ func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
|
||||
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
|
||||
// closing '}', or "" if none. Tolerates fences/preamble around the object.
|
||||
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, '{')
|
||||
if start < 0 {
|
||||
return ""
|
||||
return "", -1
|
||||
}
|
||||
depth := 0
|
||||
inString := false
|
||||
@@ -98,11 +163,11 @@ func extractJSONObject(s string) string {
|
||||
case c == '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return s[start : i+1]
|
||||
return s[start : i+1], i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return "", -1
|
||||
}
|
||||
|
||||
func truncateForError(s string) string {
|
||||
@@ -127,17 +192,33 @@ func NewRateLimiter(interval time.Duration) *RateLimiter {
|
||||
}
|
||||
|
||||
// Allow reports whether a checkpoint may run for docID now. When allowed it
|
||||
// records the time and returns (true, 0); when throttled it returns
|
||||
// (false, retryAfter) where retryAfter is the wait until the next allowed run.
|
||||
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration) {
|
||||
// records the time and returns (true, 0, recordedAt); when throttled it returns
|
||||
// (false, retryAfter, zero) where retryAfter is the wait until the next allowed
|
||||
// run. recordedAt lets a caller whose pass fails hand the exact timestamp to
|
||||
// Release so it rolls back only its own slot.
|
||||
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration, time.Time) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
now := time.Now()
|
||||
if last, ok := rl.last[docID]; ok {
|
||||
if elapsed := now.Sub(last); elapsed < rl.interval {
|
||||
return false, rl.interval - elapsed
|
||||
return false, rl.interval - elapsed, time.Time{}
|
||||
}
|
||||
}
|
||||
rl.last[docID] = now
|
||||
return true, 0
|
||||
return true, 0, now
|
||||
}
|
||||
|
||||
// Release rolls back the slot a prior Allow recorded for docID, but only if no
|
||||
// newer Allow has since claimed it. Called when an LLM pass fails: Allow runs
|
||||
// before the model call, so without this a failed checkpoint would hold the
|
||||
// per-document slot for the full interval and a retry (or the frontend's
|
||||
// auto-retry) would hit the throttle path and get the stale/empty set back
|
||||
// instead of re-running. `at` is the time the failed Allow returned.
|
||||
func (rl *RateLimiter) Release(docID string, at time.Time) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
if last, ok := rl.last[docID]; ok && last.Equal(at) {
|
||||
delete(rl.last, docID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,21 @@ func TestParseCheckpoint(t *testing.T) {
|
||||
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -65,16 +80,31 @@ func TestParseCheckpoint(t *testing.T) {
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
rl := NewRateLimiter(30 * time.Second)
|
||||
|
||||
if ok, _ := rl.Allow("doc1"); !ok {
|
||||
ok, _, at := rl.Allow("doc1")
|
||||
if !ok {
|
||||
t.Fatal("first call should be allowed")
|
||||
}
|
||||
if ok, retry := rl.Allow("doc1"); ok || retry <= 0 {
|
||||
if ok, retry, _ := rl.Allow("doc1"); ok || retry <= 0 {
|
||||
t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry)
|
||||
}
|
||||
// A different document is independent.
|
||||
if ok, _ := rl.Allow("doc2"); !ok {
|
||||
if ok, _, _ := rl.Allow("doc2"); !ok {
|
||||
t.Fatal("different doc should be allowed")
|
||||
}
|
||||
|
||||
// Releasing doc1's slot (as a failed pass does) lets the next check re-run
|
||||
// immediately instead of waiting out the interval.
|
||||
rl.Release("doc1", at)
|
||||
if ok, _, _ := rl.Allow("doc1"); !ok {
|
||||
t.Fatal("after Release the next call should be allowed again")
|
||||
}
|
||||
// Release only rolls back its own slot: a stale timestamp must not evict the
|
||||
// slot a newer Allow holds (so a late failure can't cancel a fresh in-flight
|
||||
// check). doc2 still holds its slot from above; a zero-time Release is a no-op.
|
||||
rl.Release("doc2", time.Time{})
|
||||
if ok, _, _ := rl.Allow("doc2"); ok {
|
||||
t.Fatal("stale Release must not free doc2's current slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateDoc(t *testing.T) {
|
||||
|
||||
@@ -165,3 +165,24 @@ func RewriteMessages(text, style string) []Message {
|
||||
{Role: "user", Content: text},
|
||||
}
|
||||
}
|
||||
|
||||
// translateSystemPrompt drives the explanation translator: it renders a
|
||||
// suggestion's English explanation into Simplified Chinese so an ESL reader sees
|
||||
// the "why" in her first language. Strict about returning ONLY the translation
|
||||
// (no quotes, no pinyin, no English echo) so it can drop straight into the chat
|
||||
// bubble. Kept warm and plain — these are short, friendly one-liners.
|
||||
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` +
|
||||
`sends into natural, friendly Simplified Chinese (Mandarin). It is a short explanation of a writing ` +
|
||||
`suggestion, written for a native Chinese speaker learning English.
|
||||
|
||||
Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` +
|
||||
`just the translated sentence.`
|
||||
|
||||
// TranslateMessages builds the message array for translating one short English
|
||||
// explanation into Simplified Chinese.
|
||||
func TranslateMessages(text string) []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: translateSystemPrompt},
|
||||
{Role: "user", Content: text},
|
||||
}
|
||||
}
|
||||
|
||||
23
internal/llm/translate.go
Normal file
23
internal/llm/translate.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// RunTranslate renders a short English explanation into Simplified Chinese. It
|
||||
// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low
|
||||
// temperature so the translation is faithful rather than creative. Output is
|
||||
// trimmed of any stray surrounding quotes the model may add.
|
||||
func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) {
|
||||
out, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: TranslateMessages(text),
|
||||
MaxTokens: 512,
|
||||
Temperature: 0.2,
|
||||
TopP: 0.9,
|
||||
RepetitionPenalty: 1.1,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cleanRewrite(out), nil
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func (h *Handler) Routes() chi.Router {
|
||||
r.Post("/{id}/accept", h.accept)
|
||||
r.Post("/{id}/dismiss", h.dismiss)
|
||||
r.Post("/{id}/chat", h.chat)
|
||||
r.Post("/{id}/translate", h.translate)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -101,7 +102,8 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := limiter.Allow(docID); !ok {
|
||||
ok, _, slotAt := limiter.Allow(docID)
|
||||
if !ok {
|
||||
// Throttled: return the existing pending set unchanged rather than an
|
||||
// error, so the frontend keeps showing current suggestions.
|
||||
existing, err := h.fetchPending(docID)
|
||||
@@ -115,6 +117,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
|
||||
raw, err := run(r.Context(), h.Client, contentText, tone)
|
||||
if err != nil {
|
||||
// Allow ran before the model call, so a failed pass would otherwise hold
|
||||
// the per-document slot for the full interval — stranding the frontend's
|
||||
// auto-retry on the throttle path. Release it so a retry can re-run.
|
||||
limiter.Release(docID, slotAt)
|
||||
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
57
internal/suggestions/translate.go
Normal file
57
internal/suggestions/translate.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
type translateResponse struct {
|
||||
Translation string `json:"translation"`
|
||||
}
|
||||
|
||||
// translate renders a suggestion's English explanation into Simplified Chinese
|
||||
// for the Ask Petal bubble, so the ESL reader sees the "why" in her first
|
||||
// language instead of a second copy of the same English text. The explanation is
|
||||
// loaded server-side from the suggestion id (scoped to the local user) and never
|
||||
// trusted from the client, mirroring chat (spec Note #10).
|
||||
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
||||
sugID := chi.URLParam(r, "id")
|
||||
|
||||
var explanation string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT s.explanation
|
||||
FROM suggestions s
|
||||
JOIN documents d ON d.id = s.doc_id
|
||||
WHERE s.id = ? AND d.user_id = ?`,
|
||||
sugID, db.LocalUserID,
|
||||
).Scan(&explanation)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
explanation = strings.TrimSpace(explanation)
|
||||
if explanation == "" {
|
||||
writeJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
||||
return
|
||||
}
|
||||
|
||||
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
|
||||
if err != nil {
|
||||
errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, translateResponse{Translation: out})
|
||||
}
|
||||
@@ -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 <html>; we pass the flag to the ambient
|
||||
// layer so the petals become stars.
|
||||
const night = useNightMode()
|
||||
const [docs, setDocs] = useState<DocSummary[]>([])
|
||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -370,7 +375,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PetalFall />
|
||||
<PetalFall night={night} />
|
||||
<header
|
||||
onMouseDown={handleChromeDown}
|
||||
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
|
||||
@@ -147,6 +147,10 @@ export const api = {
|
||||
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||||
dismissSuggestion: (id: string) =>
|
||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||
// Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal
|
||||
// opening bubble (the explanation itself stays English in the card body).
|
||||
translateSuggestion: (id: string) =>
|
||||
req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }),
|
||||
|
||||
// Version history. listVersions returns metadata only (no bodies); getVersion
|
||||
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
||||
|
||||
@@ -59,12 +59,23 @@ function speakWebSpeech(text: string, lang: string): void {
|
||||
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
|
||||
// don't queue up. `lang` defaults to US English (the language she's learning);
|
||||
// pass a zh locale to hear a Chinese passage. It tries the server's neural voice
|
||||
// first and silently falls back to the browser voice if that's unavailable (route
|
||||
// off, network error, or a 404 for a language with no configured voice).
|
||||
export function speak(text: string, lang = 'en-US'): void {
|
||||
// don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
|
||||
// so callers can just pass the selection; pass an explicit locale to override.
|
||||
// It tries the server's neural voice first and silently falls back to the browser
|
||||
// voice if that's unavailable (route off, network error, or a 404 for a language
|
||||
// with no configured voice).
|
||||
export function speak(text: string, lang = detectLang(text)): void {
|
||||
if (!text.trim()) return
|
||||
stopCurrent()
|
||||
const seq = ++requestSeq
|
||||
|
||||
@@ -15,6 +15,11 @@ export const ENCOURAGEMENTS: Line[] = [
|
||||
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
|
||||
{ zh: '继续保持,加油!', en: 'Keep going — you’ve got this!' },
|
||||
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
||||
{ zh: '这个词用得真好 🌷', en: 'That’s 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
|
||||
@@ -36,6 +41,16 @@ export const BREAKS: Line[] = [
|
||||
{ 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.
|
||||
export const GREETING: Line = {
|
||||
zh: '嗨~我在这儿陪你写作哦 🐱',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import {
|
||||
BEDTIME,
|
||||
BREAKS,
|
||||
ENCOURAGEMENTS,
|
||||
ERRORS,
|
||||
@@ -14,12 +15,13 @@ import {
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
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,
|
||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||
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 {
|
||||
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 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 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 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).
|
||||
@@ -58,7 +63,7 @@ const now = () => 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) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||
|
||||
interface Props {
|
||||
suggestionId: string
|
||||
// Pre-populates Petal's first bubble so the conversation opens with context.
|
||||
// The English explanation (shown in the card body). Petal's opening bubble is
|
||||
// its Simplified-Chinese translation, fetched on open — so the panel doesn't
|
||||
// just repeat the same English text twice. Falls back to this on failure.
|
||||
explanation: string
|
||||
}
|
||||
|
||||
@@ -17,9 +19,10 @@ const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC
|
||||
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
||||
// token into the latest assistant bubble.
|
||||
export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{ role: 'assistant', content: explanation },
|
||||
])
|
||||
// Opening bubble starts empty (caret-only) and fills with the Mandarin
|
||||
// translation once it lands; `seeding` drives that loading caret.
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
||||
const [seeding, setSeeding] = useState(true)
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -36,6 +39,31 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Fetch the Chinese translation of the explanation to seed the first bubble.
|
||||
// Only replaces the seed bubble if the user hasn't started chatting yet (the
|
||||
// conversation always opens with this one assistant turn). Falls back to the
|
||||
// English explanation if the translation can't be fetched.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api
|
||||
.translateSuggestion(suggestionId)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const text = res.translation.trim() || explanation
|
||||
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev))
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSeeding(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [suggestionId, explanation])
|
||||
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
@@ -86,7 +114,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
style={{ maxHeight: 220 }}
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} />
|
||||
<Bubble
|
||||
key={i}
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
streaming={(streaming && i === messages.length - 1) || (seeding && i === 0)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import type { EditorView } from '@tiptap/pm/view'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Toolbar } from '../Toolbar/Toolbar'
|
||||
import { SuggestionCard } from './SuggestionCard'
|
||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||
import { SuggestionRail, type RailItem } from './SuggestionRail'
|
||||
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
|
||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||
import { MisspellCard } from './MisspellCard'
|
||||
import { WordCard } from './WordCard'
|
||||
@@ -219,6 +220,18 @@ export function EditorCore({
|
||||
const rewriteReqRef = useRef(0)
|
||||
// The in-document Find & Replace bar (Ctrl/Cmd+F).
|
||||
const [findOpen, setFindOpen] = useState(false)
|
||||
// The right-margin comment rail. `railItems` carries each anchored suggestion's
|
||||
// vertical offset; `railEnabled` is true only when the viewport has room for the
|
||||
// column beside the editor (otherwise we fall back to the inline hover cards).
|
||||
// `railExpandedId` is the card showing its full explanation + Ask Petal, and
|
||||
// `activeId` is the suggestion currently emphasized (hovered text or card).
|
||||
const [railItems, setRailItems] = useState<RailItem[]>([])
|
||||
const [railEnabled, setRailEnabled] = useState(false)
|
||||
const [railExpandedId, setRailExpandedId] = useState<string | null>(null)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
// A stable handle to the latest recompute so the editor's onUpdate (captured
|
||||
// once at construction) can trigger a re-measure without stale closures.
|
||||
const recomputeRailRef = useRef<() => void>(() => {})
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -282,6 +295,8 @@ export function EditorCore({
|
||||
content_text: editor.getText(),
|
||||
word_count: editor.storage.characterCount.words(),
|
||||
})
|
||||
// Edits reflow the text, so the rail anchors need re-measuring.
|
||||
recomputeRailRef.current()
|
||||
},
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
// A selection supersedes the hover gloss; an empty one clears the bubble.
|
||||
@@ -333,8 +348,87 @@ export function EditorCore({
|
||||
setSuggestions(editor.state, editor.view.dispatch, suggestions)
|
||||
// Close the card if its suggestion is gone.
|
||||
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
|
||||
// Drop any rail expand/emphasis that points at a now-removed suggestion.
|
||||
setRailExpandedId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
|
||||
setActiveId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
|
||||
}, [editor, suggestions])
|
||||
|
||||
// Re-measure the margin rail: whether there's room for the column beside the
|
||||
// editor, and where each suggestion's highlight sits vertically. Anchors are
|
||||
// taken from the live decoration DOM (keyed by data-suggestion-id) relative to
|
||||
// the wrapper — stable under scroll since text and wrapper scroll together.
|
||||
// Deferred a frame so decoration DOM and reflow have settled.
|
||||
const recomputeRail = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus
|
||||
// a little breathing space to the viewport edge.
|
||||
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
|
||||
const seen = new Set<string>()
|
||||
const items: RailItem[] = []
|
||||
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
|
||||
const id = el.getAttribute('data-suggestion-id')
|
||||
if (!id || seen.has(id)) return
|
||||
const s = suggestions.find((x) => x.id === id)
|
||||
if (!s) return
|
||||
seen.add(id)
|
||||
items.push({ suggestion: s, anchorTop: el.getBoundingClientRect().top - wrapRect.top })
|
||||
})
|
||||
setRailItems(items)
|
||||
})
|
||||
}, [suggestions])
|
||||
|
||||
useEffect(() => {
|
||||
recomputeRailRef.current = recomputeRail
|
||||
}, [recomputeRail])
|
||||
|
||||
// Re-anchor when the suggestion set changes (after the decorations repaint),
|
||||
// and keep the rail in sync with viewport/editor width changes (room + reflow).
|
||||
useEffect(() => {
|
||||
recomputeRail()
|
||||
const wrapper = wrapperRef.current
|
||||
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
|
||||
if (wrapper && ro) ro.observe(wrapper)
|
||||
window.addEventListener('resize', recomputeRail)
|
||||
return () => {
|
||||
ro?.disconnect()
|
||||
window.removeEventListener('resize', recomputeRail)
|
||||
}
|
||||
}, [recomputeRail])
|
||||
|
||||
// Emphasize the flagged text for the active suggestion, mirroring the rail
|
||||
// card ↔ text link both ways. Driven through the decoration plugin (not an
|
||||
// imperative DOM class) so it survives the repaints that fire on every edit.
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
setActiveSuggestion(editor.state, editor.view.dispatch, railEnabled ? activeId : null)
|
||||
}, [editor, activeId, railEnabled])
|
||||
|
||||
// Scroll a suggestion's highlight to the middle of the viewport (clicking its
|
||||
// rail card jumps the editor to the flagged text).
|
||||
const scrollToSuggestion = useCallback((id: string) => {
|
||||
const el = wrapperRef.current?.querySelector(
|
||||
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
|
||||
)
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
// Clicking a rail card's body jumps to the text and toggles its detail panel.
|
||||
const activateRailCard = useCallback(
|
||||
(id: string) => {
|
||||
setActiveId(id)
|
||||
scrollToSuggestion(id)
|
||||
setRailExpandedId((cur) => (cur === id ? null : id))
|
||||
},
|
||||
[scrollToSuggestion],
|
||||
)
|
||||
|
||||
const toggleRailExpand = useCallback((id: string) => {
|
||||
setRailExpandedId((cur) => (cur === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const openCardFor = useCallback(
|
||||
(id: string, el: HTMLElement) => {
|
||||
const wrapper = wrapperRef.current
|
||||
@@ -366,10 +460,17 @@ export function EditorCore({
|
||||
if (!target) return
|
||||
const id = target.getAttribute('data-suggestion-id')
|
||||
if (!id) return
|
||||
// With the rail open the card already lives in the margin — hovering the
|
||||
// text just emphasizes its card (and the highlight) rather than popping a
|
||||
// second, redundant floating card.
|
||||
if (railEnabled) {
|
||||
setActiveId(id)
|
||||
return
|
||||
}
|
||||
clearTimeout(closeTimer.current)
|
||||
openCardFor(id, target)
|
||||
},
|
||||
[openCardFor],
|
||||
[openCardFor, railEnabled],
|
||||
)
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
@@ -389,35 +490,53 @@ export function EditorCore({
|
||||
// pointer can bridge the small gap from text to card.
|
||||
const handleMouseOut = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose()
|
||||
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
|
||||
if (railEnabled) {
|
||||
setActiveId(null)
|
||||
return
|
||||
}
|
||||
scheduleClose()
|
||||
},
|
||||
[scheduleClose],
|
||||
[scheduleClose, railEnabled],
|
||||
)
|
||||
|
||||
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||
|
||||
// Accept applies the replacement to the document, plays a little confetti
|
||||
// burst where the card sat, then notifies the parent.
|
||||
// burst over the flagged text, then notifies the parent. The confetti is
|
||||
// anchored to the highlight itself (captured before the replacement removes it),
|
||||
// so it fires in the right spot whether the accept came from the hover card or
|
||||
// the margin rail.
|
||||
const handleAccept = useCallback(
|
||||
(s: Suggestion) => {
|
||||
const wrapper = wrapperRef.current
|
||||
const el = wrapper?.querySelector(
|
||||
`.petal-suggestion[data-suggestion-id="${CSS.escape(s.id)}"]`,
|
||||
) as HTMLElement | null
|
||||
let burst: { top: number; left: number } | null = null
|
||||
if (wrapper && el) {
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const elRect = el.getBoundingClientRect()
|
||||
burst = { top: elRect.top - wrapRect.top, left: elRect.right - wrapRect.left }
|
||||
}
|
||||
if (editor && s.replacement.trim() !== '') {
|
||||
const range = findRange(editor.state.doc, s.original)
|
||||
if (range) {
|
||||
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
||||
}
|
||||
}
|
||||
setHover((h) => {
|
||||
if (h) {
|
||||
setConfetti({ top: h.top, left: h.left + 16 })
|
||||
clearTimeout(confettiTimer.current)
|
||||
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
||||
}
|
||||
return h
|
||||
})
|
||||
// Fall back to the hover card's position if the highlight wasn't found.
|
||||
if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
|
||||
if (burst) {
|
||||
setConfetti(burst)
|
||||
clearTimeout(confettiTimer.current)
|
||||
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
||||
}
|
||||
closeCard()
|
||||
setRailExpandedId(null)
|
||||
onAccept(s)
|
||||
},
|
||||
[editor, onAccept, closeCard],
|
||||
[editor, onAccept, closeCard, hover],
|
||||
)
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
@@ -439,7 +558,16 @@ export function EditorCore({
|
||||
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||
if (suggestionEl) {
|
||||
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||
if (id) openCardFor(id, suggestionEl)
|
||||
if (id) {
|
||||
// With the rail open, a tap emphasizes and expands its margin card
|
||||
// instead of opening a floating one.
|
||||
if (railEnabled) {
|
||||
setActiveId(id)
|
||||
setRailExpandedId(id)
|
||||
} else {
|
||||
openCardFor(id, suggestionEl)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!editor || !spellChecker) return
|
||||
@@ -461,7 +589,7 @@ export function EditorCore({
|
||||
setWordInfo(null)
|
||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||
},
|
||||
[editor, spellChecker, closeCard, openCardFor],
|
||||
[editor, spellChecker, closeCard, openCardFor, railEnabled],
|
||||
)
|
||||
|
||||
const replaceMisspelling = useCallback(
|
||||
@@ -854,7 +982,7 @@ export function EditorCore({
|
||||
onAdd={addMisspellingToDict}
|
||||
/>
|
||||
)}
|
||||
{hover && (
|
||||
{hover && !railEnabled && (
|
||||
<SuggestionCard
|
||||
suggestion={hover.suggestion}
|
||||
style={{ top: hover.top, left: hover.left }}
|
||||
@@ -865,6 +993,18 @@ export function EditorCore({
|
||||
onExpandChange={setPinned}
|
||||
/>
|
||||
)}
|
||||
{railEnabled && railItems.length > 0 && (
|
||||
<SuggestionRail
|
||||
items={railItems}
|
||||
activeId={activeId}
|
||||
expandedId={railExpandedId}
|
||||
onAccept={handleAccept}
|
||||
onDismiss={handleDismiss}
|
||||
onHover={setActiveId}
|
||||
onActivate={activateRailCard}
|
||||
onToggleExpand={toggleRailExpand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { Suggestion, SuggestionType } from '../../api/client'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
import { AskPetal } from './AskPetal'
|
||||
|
||||
// Per-type accent color + human label, mirroring the design tokens.
|
||||
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
||||
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
||||
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
||||
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
||||
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||
}
|
||||
import { TYPE_META } from './suggestionMeta'
|
||||
|
||||
interface Props {
|
||||
suggestion: Suggestion
|
||||
|
||||
@@ -16,9 +16,17 @@ export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions'
|
||||
|
||||
interface PluginState {
|
||||
suggestions: Suggestion[]
|
||||
// The suggestion currently emphasized from its margin card / a hover, or null.
|
||||
// Carried in plugin state (not an imperative DOM class) so it survives the
|
||||
// decoration repaints that fire on every document change.
|
||||
activeId: string | null
|
||||
decorations: DecorationSet
|
||||
}
|
||||
|
||||
// Meta carried on a transaction to update the plugin: either a fresh suggestion
|
||||
// list or a change to which suggestion is emphasized.
|
||||
type SuggestionMeta = { suggestions: Suggestion[] } | { activeId: string | null }
|
||||
|
||||
// mapOffset converts a character offset within a textblock's flattened text into
|
||||
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||||
// breaks) that occupy a position but contribute no text.
|
||||
@@ -130,14 +138,15 @@ export function findRange(doc: PMNode, search: string): { from: number; to: numb
|
||||
return result
|
||||
}
|
||||
|
||||
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet {
|
||||
function buildDecorations(doc: PMNode, suggestions: Suggestion[], activeId: string | null): DecorationSet {
|
||||
const decos: Decoration[] = []
|
||||
for (const s of suggestions) {
|
||||
const range = findRange(doc, s.original)
|
||||
if (!range) continue
|
||||
const active = s.id === activeId ? ' petal-suggestion-active' : ''
|
||||
decos.push(
|
||||
Decoration.inline(range.from, range.to, {
|
||||
class: `petal-suggestion petal-suggestion-${s.type}`,
|
||||
class: `petal-suggestion petal-suggestion-${s.type}${active}`,
|
||||
'data-suggestion-id': s.id,
|
||||
}),
|
||||
)
|
||||
@@ -148,10 +157,22 @@ function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet
|
||||
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
|
||||
// rebuilt against the current document immediately.
|
||||
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
|
||||
const tr = state.tr.setMeta(suggestionPluginKey, suggestions)
|
||||
const tr = state.tr.setMeta(suggestionPluginKey, { suggestions } satisfies SuggestionMeta)
|
||||
dispatch(tr)
|
||||
}
|
||||
|
||||
// setActiveSuggestion marks one suggestion (or none) as emphasized, rebuilding
|
||||
// the decorations so the flagged text gets the active wash. Driven by the margin
|
||||
// rail's hover/selection so the card↔text link reads both ways.
|
||||
export function setActiveSuggestion(
|
||||
state: EditorState,
|
||||
dispatch: (tr: Transaction) => void,
|
||||
activeId: string | null,
|
||||
) {
|
||||
if (suggestionPluginKey.getState(state)?.activeId === activeId) return
|
||||
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
|
||||
}
|
||||
|
||||
export const SuggestionHighlight = Extension.create({
|
||||
name: 'suggestionHighlight',
|
||||
|
||||
@@ -160,17 +181,29 @@ export const SuggestionHighlight = Extension.create({
|
||||
new Plugin<PluginState>({
|
||||
key: suggestionPluginKey,
|
||||
state: {
|
||||
init: () => ({ suggestions: [], decorations: DecorationSet.empty }),
|
||||
init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty }),
|
||||
apply(tr, value, _oldState, newState) {
|
||||
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined
|
||||
if (meta) {
|
||||
return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) }
|
||||
const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
|
||||
if (meta && 'suggestions' in meta) {
|
||||
return {
|
||||
suggestions: meta.suggestions,
|
||||
activeId: value.activeId,
|
||||
decorations: buildDecorations(newState.doc, meta.suggestions, value.activeId),
|
||||
}
|
||||
}
|
||||
if (meta && 'activeId' in meta) {
|
||||
return {
|
||||
suggestions: value.suggestions,
|
||||
activeId: meta.activeId,
|
||||
decorations: buildDecorations(newState.doc, value.suggestions, meta.activeId),
|
||||
}
|
||||
}
|
||||
// On any document change, re-anchor by string against the new doc.
|
||||
if (tr.docChanged) {
|
||||
return {
|
||||
suggestions: value.suggestions,
|
||||
decorations: buildDecorations(newState.doc, value.suggestions),
|
||||
activeId: value.activeId,
|
||||
decorations: buildDecorations(newState.doc, value.suggestions, value.activeId),
|
||||
}
|
||||
}
|
||||
return value
|
||||
|
||||
211
web/src/components/Editor/SuggestionRail.tsx
Normal file
211
web/src/components/Editor/SuggestionRail.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
import { AskPetal } from './AskPetal'
|
||||
import { TYPE_META } from './suggestionMeta'
|
||||
|
||||
// Vertical breathing room kept between stacked cards when their natural anchors
|
||||
// would otherwise collide.
|
||||
const CARD_GAP = 12
|
||||
|
||||
export interface RailItem {
|
||||
suggestion: Suggestion
|
||||
// Vertical offset (px) of the suggestion's highlight, relative to the editor
|
||||
// wrapper — the position the card wants to sit beside.
|
||||
anchorTop: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: RailItem[]
|
||||
// The suggestion currently emphasized (its highlight hovered, or this card
|
||||
// hovered) — gets a lifted, ring-accented treatment so the text↔card link reads.
|
||||
activeId: string | null
|
||||
// The card expanded to show the full explanation + Ask Petal, or null.
|
||||
expandedId: string | null
|
||||
onAccept: (s: Suggestion) => void
|
||||
onDismiss: (s: Suggestion) => void
|
||||
// Pointer entering/leaving a card, so the matching highlight can light up.
|
||||
onHover: (id: string | null) => void
|
||||
// A card's body was clicked — scroll its highlight into view and toggle expand.
|
||||
onActivate: (id: string) => void
|
||||
onToggleExpand: (id: string) => void
|
||||
}
|
||||
|
||||
// SuggestionRail is the right-margin "comment column": every outstanding
|
||||
// suggestion as a card, vertically aligned to the text it flags, so the writer
|
||||
// sees the whole queue at once instead of hunting highlight by highlight. Cards
|
||||
// are anchored to their highlight's vertical position and pushed down only as far
|
||||
// as needed to avoid overlapping their neighbor above (a la doc-editor comments).
|
||||
export function SuggestionRail({
|
||||
items,
|
||||
activeId,
|
||||
expandedId,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onHover,
|
||||
onActivate,
|
||||
onToggleExpand,
|
||||
}: Props) {
|
||||
// Measured resolved tops keyed by suggestion id (after collision avoidance).
|
||||
const [tops, setTops] = useState<Record<string, number>>({})
|
||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
// Bumped whenever a card's own height changes (Ask Petal streaming in, wrapping
|
||||
// text) so the stack re-packs and cards below don't get overlapped.
|
||||
const [measureTick, setMeasureTick] = useState(0)
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
if (!observerRef.current && typeof ResizeObserver !== 'undefined') {
|
||||
observerRef.current = new ResizeObserver(() => setMeasureTick((t) => t + 1))
|
||||
}
|
||||
useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
|
||||
|
||||
// Sort by anchor, then greedily stack: each card sits at its anchor unless that
|
||||
// would overlap the previous card, in which case it slides down just enough.
|
||||
// Re-runs whenever the anchors or the expanded card (which changes a height)
|
||||
// shift. Reads live offsetHeight so variable-length explanations pack tightly.
|
||||
const ordered = [...items].sort((a, b) => a.anchorTop - b.anchorTop)
|
||||
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
|
||||
useLayoutEffect(() => {
|
||||
let cursor = -Infinity
|
||||
const next: Record<string, number> = {}
|
||||
for (const { suggestion, anchorTop } of ordered) {
|
||||
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
|
||||
const top = Math.max(anchorTop, cursor)
|
||||
next[suggestion.id] = top
|
||||
cursor = top + h + CARD_GAP
|
||||
}
|
||||
setTops((prev) => {
|
||||
const ids = Object.keys(next)
|
||||
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev
|
||||
return next
|
||||
})
|
||||
// layoutKey captures anchor positions; expandedId/measureTick capture height
|
||||
// changes (toggling detail, Ask Petal streaming in).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layoutKey, expandedId, measureTick])
|
||||
|
||||
return (
|
||||
<div className="petal-rail petal-no-print" aria-label="Suggestions">
|
||||
{ordered.map(({ suggestion }) => (
|
||||
<RailCard
|
||||
key={suggestion.id}
|
||||
ref={(el) => {
|
||||
const prev = cardRefs.current.get(suggestion.id)
|
||||
if (prev && prev !== el) observerRef.current?.unobserve(prev)
|
||||
if (el) {
|
||||
cardRefs.current.set(suggestion.id, el)
|
||||
observerRef.current?.observe(el)
|
||||
} else {
|
||||
cardRefs.current.delete(suggestion.id)
|
||||
}
|
||||
}}
|
||||
suggestion={suggestion}
|
||||
top={tops[suggestion.id] ?? 0}
|
||||
active={activeId === suggestion.id}
|
||||
expanded={expandedId === suggestion.id}
|
||||
onAccept={onAccept}
|
||||
onDismiss={onDismiss}
|
||||
onHover={onHover}
|
||||
onActivate={onActivate}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
suggestion: Suggestion
|
||||
top: number
|
||||
active: boolean
|
||||
expanded: boolean
|
||||
onAccept: (s: Suggestion) => void
|
||||
onDismiss: (s: Suggestion) => void
|
||||
onHover: (id: string | null) => void
|
||||
onActivate: (id: string) => void
|
||||
onToggleExpand: (id: string) => void
|
||||
}
|
||||
|
||||
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
|
||||
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
|
||||
ref,
|
||||
) {
|
||||
const meta = TYPE_META[suggestion.type]
|
||||
const hasReplacement = suggestion.replacement.trim() !== ''
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-label={`${meta.label} suggestion`}
|
||||
onMouseEnter={() => onHover(suggestion.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
|
||||
style={{ top, borderLeftColor: meta.color }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
|
||||
style={{ background: meta.color, color: 'var(--color-plum)' }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss suggestion"
|
||||
onClick={() => onDismiss(suggestion)}
|
||||
className="petal-rail-x"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The body toggles the expanded explanation + Ask Petal and points the
|
||||
editor at the flagged text. */}
|
||||
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
|
||||
{hasReplacement && (
|
||||
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
|
||||
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
|
||||
{suggestion.original}
|
||||
</span>
|
||||
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
|
||||
{suggestion.replacement}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`mt-1.5 block text-[0.82rem] leading-snug${expanded ? '' : ' line-clamp-2'}`}
|
||||
style={{ color: 'var(--color-plum)' }}
|
||||
>
|
||||
{suggestion.explanation}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-2">
|
||||
{hasReplacement && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAccept(suggestion)}
|
||||
className="rounded-full px-3 py-1 text-xs font-bold text-white"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleExpand(suggestion.id)}
|
||||
className="rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||
style={{
|
||||
background: expanded ? 'var(--color-surface-alt)' : 'transparent',
|
||||
color: 'var(--color-accent-hover)',
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
12
web/src/components/Editor/suggestionMeta.ts
Normal file
12
web/src/components/Editor/suggestionMeta.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { SuggestionType } from '../../api/client'
|
||||
|
||||
// Per-type accent color + human label, mirroring the design tokens. Shared by the
|
||||
// inline hover SuggestionCard and the margin SuggestionRail so a given suggestion
|
||||
// type reads the same color/name wherever it surfaces.
|
||||
export const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
||||
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
||||
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
||||
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
||||
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||
}
|
||||
@@ -17,6 +17,23 @@ const PALETTE: { r: number; g: number; b: number }[] = [
|
||||
{ r: 255, g: 122, b: 172 }, // deep rose
|
||||
]
|
||||
|
||||
// Night-mode sparkle palette: soft starlight tones for the small twinkle accents
|
||||
// scattered between the chunky cartoon stars (moonlit white, warm gold).
|
||||
const STAR_PALETTE: { r: number; g: number; b: number }[] = [
|
||||
{ r: 255, g: 255, b: 255 }, // white
|
||||
{ r: 255, g: 240, b: 200 }, // warm gold
|
||||
]
|
||||
|
||||
// Chunky cartoon-star colors — bold, candy-bright fills à la a Mario power star
|
||||
// or a Kirby warp star. Each is drawn with a glossy gradient + a puffy outline.
|
||||
const CARTOON_COLORS: { r: number; g: number; b: number }[] = [
|
||||
{ r: 255, g: 201, b: 56 }, // golden power-star
|
||||
{ r: 255, g: 150, b: 190 }, // bubblegum pink
|
||||
{ r: 130, g: 195, b: 255 }, // sky blue
|
||||
{ r: 150, g: 225, b: 190 }, // mint
|
||||
{ r: 200, g: 175, b: 255 }, // lavender
|
||||
]
|
||||
|
||||
const SPRITE_PX = 160 // off-screen render resolution per petal sprite
|
||||
|
||||
function rgba(c: { r: number; g: number; b: number }, a: number): string {
|
||||
@@ -67,6 +84,118 @@ function makeSprite(color: { r: number; g: number; b: number }): HTMLCanvasEleme
|
||||
return cv
|
||||
}
|
||||
|
||||
// Draw one star sprite: a soft radial glow with a crisp four-point sparkle on
|
||||
// top, filled brightest at the core so it reads as a twinkling star.
|
||||
function makeStarSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement {
|
||||
const s = SPRITE_PX
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = s
|
||||
cv.height = s
|
||||
const g = cv.getContext('2d')!
|
||||
g.translate(s / 2, s / 2)
|
||||
|
||||
// Soft halo behind the sparkle.
|
||||
const glow = g.createRadialGradient(0, 0, 0, 0, 0, s * 0.5)
|
||||
glow.addColorStop(0, rgba(mixWhite(color, 0.5), 0.5))
|
||||
glow.addColorStop(0.35, rgba(color, 0.16))
|
||||
glow.addColorStop(1, rgba(color, 0))
|
||||
g.fillStyle = glow
|
||||
g.beginPath()
|
||||
g.arc(0, 0, s * 0.5, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
|
||||
// Four-point sparkle: alternate a long outer radius with a small inner one so
|
||||
// the points pinch in sharply.
|
||||
const R = s * 0.46
|
||||
const r = s * 0.08
|
||||
g.beginPath()
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const ang = (i * Math.PI) / 4 - Math.PI / 2
|
||||
const rad = i % 2 === 0 ? R : r
|
||||
const x = Math.cos(ang) * rad
|
||||
const y = Math.sin(ang) * rad
|
||||
if (i === 0) g.moveTo(x, y)
|
||||
else g.lineTo(x, y)
|
||||
}
|
||||
g.closePath()
|
||||
const core = g.createRadialGradient(0, 0, 0, 0, 0, R)
|
||||
core.addColorStop(0, rgba(mixWhite(color, 0.7), 0.98))
|
||||
core.addColorStop(0.5, rgba(color, 0.9))
|
||||
core.addColorStop(1, rgba(color, 0.2))
|
||||
g.fillStyle = core
|
||||
g.fill()
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
// Draw one chunky cartoon star — a fat five-point star with a glossy radial
|
||||
// fill, a soft outer glow so it pops off the dark sky, a puffy rounded outline
|
||||
// (round line joins → Kirby-ish soft points), and a little corner shine. Reads
|
||||
// like a game power-up rather than a delicate sparkle.
|
||||
function makeCartoonStar(base: { r: number; g: number; b: number }): HTMLCanvasElement {
|
||||
const s = SPRITE_PX
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = s
|
||||
cv.height = s
|
||||
const g = cv.getContext('2d')!
|
||||
g.translate(s / 2, s / 2)
|
||||
|
||||
const R = s * 0.4 // outer point radius
|
||||
const r = R * 0.46 // inner radius — small ⇒ chunky, defined points
|
||||
|
||||
// Soft outer glow so the star separates from the night background.
|
||||
const glow = g.createRadialGradient(0, 0, R * 0.3, 0, 0, R * 1.35)
|
||||
glow.addColorStop(0, rgba(base, 0.35))
|
||||
glow.addColorStop(1, rgba(base, 0))
|
||||
g.fillStyle = glow
|
||||
g.beginPath()
|
||||
g.arc(0, 0, R * 1.35, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
|
||||
// Five-point star path (start at the top point).
|
||||
const star = () => {
|
||||
g.beginPath()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const ang = -Math.PI / 2 + (i * Math.PI) / 5
|
||||
const rad = i % 2 === 0 ? R : r
|
||||
const x = Math.cos(ang) * rad
|
||||
const y = Math.sin(ang) * rad
|
||||
if (i === 0) g.moveTo(x, y)
|
||||
else g.lineTo(x, y)
|
||||
}
|
||||
g.closePath()
|
||||
}
|
||||
|
||||
// Candy fill: bright near the top-left, deepening toward the lower edge.
|
||||
const fill = g.createRadialGradient(-R * 0.25, -R * 0.3, R * 0.1, 0, 0, R * 1.1)
|
||||
fill.addColorStop(0, rgba(mixWhite(base, 0.7), 1))
|
||||
fill.addColorStop(0.5, rgba(base, 1))
|
||||
fill.addColorStop(1, rgba(scale(base, 0.82), 1))
|
||||
star()
|
||||
g.fillStyle = fill
|
||||
g.fill()
|
||||
|
||||
// Puffy outline in a deeper shade of the same hue (round joins soften the
|
||||
// points so it feels hand-drawn, not spiky).
|
||||
g.lineJoin = 'round'
|
||||
g.lineCap = 'round'
|
||||
g.lineWidth = s * 0.05
|
||||
g.strokeStyle = rgba(scale(base, 0.5), 0.95)
|
||||
star()
|
||||
g.stroke()
|
||||
|
||||
// Glossy shine: a small soft white blob up in the top-left lobe.
|
||||
const shine = g.createRadialGradient(-R * 0.22, -R * 0.32, 0, -R * 0.22, -R * 0.32, R * 0.3)
|
||||
shine.addColorStop(0, 'rgba(255,255,255,0.85)')
|
||||
shine.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
g.fillStyle = shine
|
||||
g.beginPath()
|
||||
g.ellipse(-R * 0.22, -R * 0.32, R * 0.26, R * 0.18, -0.5, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
interface Petal {
|
||||
baseX: number
|
||||
y: number
|
||||
@@ -81,7 +210,7 @@ interface Petal {
|
||||
sprite: number
|
||||
}
|
||||
|
||||
export function PetalFall() {
|
||||
export function PetalFall({ night = false }: { night?: boolean }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,7 +227,12 @@ export function PetalFall() {
|
||||
const canvas = el
|
||||
const ctx = c2d
|
||||
|
||||
const sprites = PALETTE.map(makeSprite)
|
||||
// Night sky = chunky cartoon stars (the stars of the show) with a couple of
|
||||
// small twinkle sparkles mixed in for depth. Random sprite pick weights it
|
||||
// ~70% chunky stars.
|
||||
const sprites = night
|
||||
? [...CARTOON_COLORS.map(makeCartoonStar), ...STAR_PALETTE.map(makeStarSprite)]
|
||||
: PALETTE.map(makeSprite)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
let W = window.innerWidth
|
||||
let H = window.innerHeight
|
||||
@@ -107,19 +241,39 @@ export function PetalFall() {
|
||||
const count = Math.round(Math.min(46, Math.max(16, (W * H) / 46000)))
|
||||
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
|
||||
|
||||
const spawn = (initial: boolean): Petal => ({
|
||||
baseX: rnd(0, W),
|
||||
y: initial ? rnd(-H, H) : rnd(-60, -20),
|
||||
size: rnd(16, 38),
|
||||
vy: rnd(22, 52),
|
||||
angle: rnd(0, Math.PI * 2),
|
||||
spin: rnd(-0.9, 0.9),
|
||||
swayAmp: rnd(14, 42),
|
||||
swayFreq: rnd(0.3, 0.85),
|
||||
swayPhase: rnd(0, Math.PI * 2),
|
||||
alpha: rnd(0.5, 0.82),
|
||||
sprite: Math.floor(Math.random() * sprites.length),
|
||||
})
|
||||
// Stars are smaller, drift down a touch slower, sway less, and barely spin
|
||||
// (they twinkle in place instead of tumbling like petals).
|
||||
const spawn = (initial: boolean): Petal =>
|
||||
night
|
||||
? {
|
||||
baseX: rnd(0, W),
|
||||
y: initial ? rnd(-H, H) : rnd(-60, -20),
|
||||
size: rnd(13, 32),
|
||||
vy: rnd(14, 36),
|
||||
angle: rnd(0, Math.PI * 2),
|
||||
// Every star visibly spins — random direction, and a guaranteed
|
||||
// minimum speed (a 5-point star is symmetric every 72°, so a slow
|
||||
// rate reads as motionless). Varied so no two turn quite alike.
|
||||
spin: (Math.random() < 0.5 ? -1 : 1) * rnd(0.5, 2.2),
|
||||
swayAmp: 0, // stars fall straight down — no petal-like drift
|
||||
swayFreq: rnd(0.4, 1.1), // still drives the twinkle shimmer below
|
||||
swayPhase: rnd(0, Math.PI * 2),
|
||||
alpha: rnd(0.55, 0.9),
|
||||
sprite: Math.floor(Math.random() * sprites.length),
|
||||
}
|
||||
: {
|
||||
baseX: rnd(0, W),
|
||||
y: initial ? rnd(-H, H) : rnd(-60, -20),
|
||||
size: rnd(16, 38),
|
||||
vy: rnd(22, 52),
|
||||
angle: rnd(0, Math.PI * 2),
|
||||
spin: rnd(-0.9, 0.9),
|
||||
swayAmp: rnd(14, 42),
|
||||
swayFreq: rnd(0.3, 0.85),
|
||||
swayPhase: rnd(0, Math.PI * 2),
|
||||
alpha: rnd(0.5, 0.82),
|
||||
sprite: Math.floor(Math.random() * sprites.length),
|
||||
}
|
||||
|
||||
let petals = Array.from({ length: count }, () => spawn(true))
|
||||
|
||||
@@ -157,10 +311,16 @@ export function PetalFall() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Stars shimmer gently (shallow pulse so the chunky ones glow rather
|
||||
// than blink); petals hold a steady alpha.
|
||||
const alpha = night
|
||||
? p.alpha * (0.8 + 0.2 * Math.sin(t * p.swayFreq * 2.2 + p.swayPhase))
|
||||
: p.alpha
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, p.y)
|
||||
ctx.rotate(p.angle)
|
||||
ctx.globalAlpha = p.alpha
|
||||
ctx.globalAlpha = alpha
|
||||
ctx.drawImage(sprites[p.sprite], -p.size / 2, -p.size / 2, p.size, p.size)
|
||||
ctx.restore()
|
||||
}
|
||||
@@ -187,7 +347,7 @@ export function PetalFall() {
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
petals = []
|
||||
}
|
||||
}, [])
|
||||
}, [night])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@@ -20,17 +20,29 @@ export function useCheckpoint(docId: string | null) {
|
||||
const [llmDown, setLlmDown] = useState(false)
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
// Pending auto-retry timer for a failed checkpoint (see runCheck).
|
||||
const retryRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const docIdRef = useRef(docId)
|
||||
docIdRef.current = docId
|
||||
|
||||
// Token to discard responses from a doc we've since navigated away from.
|
||||
const runRef = useRef(0)
|
||||
|
||||
const runCheck = useCallback(async () => {
|
||||
// Backoff schedule for re-running a checkpoint that failed. A paste fires
|
||||
// exactly ONE checkpoint, and nothing re-fires until the next keystroke — so a
|
||||
// single transient failure (a momentary stall on the shared inference box)
|
||||
// would otherwise strand the user on "resting" indefinitely after a paste.
|
||||
// These delays clear the server's 30s per-doc floor by the later attempts, and
|
||||
// a failed pass now releases its slot server-side so a retry can truly re-run.
|
||||
const RETRY_DELAYS_MS = [3000, 12000, 35000]
|
||||
|
||||
const runCheck = useCallback(async (attempt = 0) => {
|
||||
const id = docIdRef.current
|
||||
if (!id) return
|
||||
clearTimeout(retryRef.current)
|
||||
const run = ++runRef.current
|
||||
setChecking(true)
|
||||
let retrying = false
|
||||
try {
|
||||
const fresh = await api.checkDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
@@ -39,9 +51,18 @@ export function useCheckpoint(docId: string | null) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('checkpoint failed', err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
if (run !== runRef.current) return
|
||||
if (attempt < RETRY_DELAYS_MS.length) {
|
||||
// Keep trying quietly — don't flag "resting" until retries are exhausted.
|
||||
retrying = true
|
||||
retryRef.current = setTimeout(() => void runCheck(attempt + 1), RETRY_DELAYS_MS[attempt])
|
||||
} else {
|
||||
setLlmDown(true)
|
||||
}
|
||||
} finally {
|
||||
if (run === runRef.current) setChecking(false)
|
||||
// Stay in the "checking" state while a retry is queued so the breathing dot
|
||||
// keeps reassuring rather than flickering off between attempts.
|
||||
if (run === runRef.current && !retrying) setChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -51,6 +72,7 @@ export function useCheckpoint(docId: string | null) {
|
||||
const runVoice = useCallback(async () => {
|
||||
const id = docIdRef.current
|
||||
if (!id) return
|
||||
clearTimeout(retryRef.current) // a voice pass supersedes a queued grammar retry
|
||||
const run = ++runRef.current
|
||||
setVoicing(true)
|
||||
try {
|
||||
@@ -70,13 +92,15 @@ export function useCheckpoint(docId: string | null) {
|
||||
// Call on every edit; schedules a check 4s after typing settles.
|
||||
const schedule = useCallback(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(runCheck, DEBOUNCE_MS)
|
||||
clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
|
||||
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
|
||||
}, [runCheck])
|
||||
|
||||
// Load existing pending suggestions whenever the document changes, and cancel
|
||||
// any in-flight debounce from the previous doc.
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
clearTimeout(retryRef.current)
|
||||
runRef.current++
|
||||
setSuggestions([])
|
||||
setChecking(false)
|
||||
@@ -97,7 +121,13 @@ export function useCheckpoint(docId: string | null) {
|
||||
}
|
||||
}, [docId])
|
||||
|
||||
useEffect(() => () => clearTimeout(debounceRef.current), [])
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(debounceRef.current)
|
||||
clearTimeout(retryRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Drop one suggestion locally (after accept/dismiss) without a refetch.
|
||||
const removeSuggestion = useCallback((id: string) => {
|
||||
|
||||
25
web/src/hooks/useNightMode.ts
Normal file
25
web/src/hooks/useNightMode.ts
Normal 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
|
||||
}
|
||||
@@ -37,6 +37,23 @@
|
||||
--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 {
|
||||
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 */
|
||||
@@ -223,6 +242,67 @@ button, a, input {
|
||||
animation: petal-suggestion-in 200ms ease both;
|
||||
}
|
||||
|
||||
/* Text emphasized from its margin card (or a hover) — a soft wash so the
|
||||
card↔text link reads at a glance, both directions. */
|
||||
.petal-suggestion-active {
|
||||
background: var(--color-surface-alt);
|
||||
box-shadow: 0 0 0 3px var(--color-surface-alt);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* --- Suggestion margin rail -------------------------------------------------
|
||||
The right-hand "comment column": every outstanding suggestion as a card,
|
||||
vertically aligned to the text it flags, so the whole queue is visible at a
|
||||
glance instead of one-highlight-at-a-time on hover. It floats in the whitespace
|
||||
just right of the editor column and is only mounted when there's room for it
|
||||
(EditorCore measures the gap). Cards are absolutely positioned by a resolved
|
||||
top (anchor + collision avoidance), so the container only anchors the column. */
|
||||
.petal-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
margin-left: 32px;
|
||||
width: 300px;
|
||||
/* Below the companion mascot (z-40): where a stacked card reaches the
|
||||
bottom-right corner it simply tucks behind the sleeping cat. */
|
||||
z-index: 10;
|
||||
}
|
||||
.petal-rail-card {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-border); /* type color set inline */
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 0.7rem 0.85rem;
|
||||
/* `top` eases so re-stacking (accept/dismiss/edit) glides instead of jumping.
|
||||
The entrance uses fill `backwards` so its translateY doesn't linger and fight
|
||||
the active-state transform once it's done. */
|
||||
transition: top 240ms cubic-bezier(0.2, 0.7, 0.3, 1), box-shadow 200ms ease,
|
||||
transform 200ms ease, border-color 200ms ease;
|
||||
animation: petal-suggestion-in 220ms ease backwards;
|
||||
}
|
||||
.petal-rail-card-active {
|
||||
transform: translateX(-5px);
|
||||
box-shadow: 0 8px 26px rgba(180, 130, 160, 0.24);
|
||||
border-top-color: var(--color-accent);
|
||||
border-right-color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
.petal-rail-x {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.petal-rail-x:hover {
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-plum);
|
||||
}
|
||||
|
||||
/* --- Find & Replace ---------------------------------------------------------
|
||||
In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the
|
||||
current match is brighter with a rose ring so it stands out as you step
|
||||
|
||||
11
web/src/lib/night.ts
Normal file
11
web/src/lib/night.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user