Phase 19: the copy stops being hardcoded Mandarin
Every `中文 · English` string moves out of ~29 components into web/src/i18n: one Pack type, a verbatim zh pack, and two ways to read it — usePack() for components, pack() for the modules that build a line when something happens rather than when something renders. Anything with a value in it is a function on the pack rather than a template at the call site, English pluralisation included: word order isn't universal, and a pack author has to be able to move the number. The roster constants (tones, rewrite styles, export formats, companions) keep only value + emoji, so a label can't drift from its key. On the server, internal/llm/lang.go replaces "Simplified Chinese" in the three prompts that actually name her language. pt-PT is spelled "European Portuguese (pt-PT, never Brazilian Portuguese)" in the prompt itself, and each Lang carries her word for "why" so the tutor prompt still recognises the question when she asks it her way. pair_lang reaches the model through the row-scoped query each handler already ran — the one that proves she owns the document — rather than a second lookup that could disagree with it. Also records Phase 18's deploy: migration 0011 rehearsed against a copy of the live VPS database, then applied for real.
This commit is contained in:
+13
-5
@@ -196,13 +196,20 @@ Script, app stopped, backup first (OPEN #4). **The "she logs in once first" depe
|
|||||||
- [x] `useSpellChecker` is server-backed: nspell loads, then the word list arrives from her account and is replayed in. A browser still holding the Phase-7 `petal.spell.personal` key hands it over on first load — but **only lets go of it once the server has accepted it**, so a failed request costs nothing. `addWord` takes effect in the editor immediately and persists in the background: the underline goes away the instant she asks, whatever the network is doing. A word list that fails to load costs correct words being flagged, never writing.
|
- [x] `useSpellChecker` is server-backed: nspell loads, then the word list arrives from her account and is replayed in. A browser still holding the Phase-7 `petal.spell.personal` key hands it over on first load — but **only lets go of it once the server has accepted it**, so a failed request costs nothing. `addWord` takes effect in the editor immediately and persists in the background: the underline goes away the instant she asks, whatever the network is doing. A word list that fails to load costs correct words being flagged, never writing.
|
||||||
- [x] Tests: `internal/spell/handlers_test.go` — lifecycle (idempotent add, bulk add, repeat delete, `[]` not `null`), languages-don't-merge incl. the case-normalisation case, junk rejection, and the standing-rule **two-user isolation** (mount twice behind two resolvers over one DB: Bob sees none of Alice's words, his identical word is his own row, his delete doesn't reach hers, deleting the account takes the dictionary with it). `web/src/lib/prefs.test.ts` — legacy adoption, move-not-copy, two accounts on one browser, never-overwrite, listener fires once per real change, storage-throws safety.
|
- [x] Tests: `internal/spell/handlers_test.go` — lifecycle (idempotent add, bulk add, repeat delete, `[]` not `null`), languages-don't-merge incl. the case-normalisation case, junk rejection, and the standing-rule **two-user isolation** (mount twice behind two resolvers over one DB: Bob sees none of Alice's words, his identical word is his own row, his delete doesn't reach hers, deleting the account takes the dictionary with it). `web/src/lib/prefs.test.ts` — legacy adoption, move-not-copy, two accounts on one browser, never-overwrite, listener fires once per real change, storage-throws safety.
|
||||||
- Verified: go build/vet/test, tsc, vite build, vitest 82/82 all clean; live smoke on a throwaway DB (:8073) — add/bulk-add/list/delete, pt-PT list independent of en, CJK word accepted, 400 on empty.
|
- Verified: go build/vet/test, tsc, vite build, vitest 82/82 all clean; live smoke on a throwaway DB (:8073) — add/bulk-add/list/delete, pt-PT list independent of en, CJK word accepted, 400 on empty.
|
||||||
- ⚠️ **Not yet deployed, and the migration rehearsal against a copy of the live VPS database did not run** — the snapshot command was blocked by this session's permission classifier. `0011` is a plain `CREATE TABLE` with no table rebuild and no dependency on existing rows (unlike `0005`/`0010`), so the risk is low, but the convention is worth honouring before the container is rebuilt.
|
- [x] **Rehearsed, then deployed** (2026-07-27). The rehearsal the previous session couldn't run: `VACUUM INTO` snapshot of the live VPS database, pulled down, migrated by the Phase-18 binary — 11 migrations apply, every count unchanged (2 users, 8 documents, 33 versions, 103 suggestions, 3 vocab words, 1 image), FTS still matching, `integrity_check` and `foreign_key_check` both clean, `personal_words` present and empty. Then the deploy: off-box encrypted backup first, `git pull` + `docker compose up -d --build`, all three containers healthy, `0011` applied to the live DB with her writing untouched. Verified over public HTTPS: `/api/health` 200, `/api/docs` and the new `/api/spell/words` **401 without a session**.
|
||||||
|
- ⚠️ The authenticated live probe of `/api/spell/words` (hand-inserted session row, as Phases 16/17 used) was **blocked by this session's permission classifier** — minting a session token reads as credential fabrication. Not worked around. The endpoint's full lifecycle is covered by `internal/spell/handlers_test.go` and was smoke-tested end to end on a throwaway DB when it was built; what remains unproven in production is only that it answers 200 for a real cookie, which the shared middleware already governs for every other route.
|
||||||
|
|
||||||
### Phase 19 — Langpack extraction (the copy chore)
|
### Phase 19 — Langpack extraction (the copy chore) ✅ (2026-07-27)
|
||||||
Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS §2, Q2 settled).
|
Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS §2, Q2 settled).
|
||||||
- [ ] Extract the `中文 · English` strings from the ~29 frontend files + companion `tips.ts` into a langpack copy module keyed by the pair's X; today's strings become the `zh` pack **verbatim**
|
- [x] Every `中文 · English` string from the ~29 frontend files (plus `tips.ts`, `prose.ts`, `companions.ts`, `stats.ts`) now lives in `web/src/i18n`: `types.ts` (the `Pack` shape), `packs/zh.ts` (today's copy, **verbatim** — sentinel assertions in `i18n.test.ts` guard against a quiet rewording), `index.ts` (the accessor).
|
||||||
- [ ] Parameterize `internal/llm/prompts.go` bilingual copy the same way (explanation language, "natives usually say…" framing, both-directions preamble)
|
- [x] Two access paths, matching where copy is built: `usePack()` for components (a `useSyncExternalStore` subscription, so a pack arriving after first paint re-renders), and `pack()` for the modules that compose a line when something *happens* rather than when something renders — the companion and the prose checker read it at call time, never at import time.
|
||||||
- [ ] Wire pack selection to `users.pair_lang`; acceptance: pixel-identical UI for the zh pair, vitest snapshots unchanged
|
- [x] **Anything with a value in it is a function on the pack**, not a template assembled at the call site (`reviewDue(n)`, `daysAgo(n)`, `duplicateTitle(title)`, every prose rule). Word order isn't universal; a pack author must be able to move the number. English pluralisation moved into the pack with it.
|
||||||
|
- [x] `Line` renamed its Mandarin half `zh` → `native` throughout (companion bubbles, tone/style pills, history badges, stat rows). `gradeBand` now returns a band *name* rather than a label, and the roster constants (`TONES`, `REWRITE_STYLES`, export formats, companions) keep only value + emoji — the label is a pack lookup keyed by the same value, with a test asserting no roster entry is unlabelled.
|
||||||
|
- [x] `internal/llm/lang.go`: the three prompts that *name* the writer's language — the collocation gloss, Ask Petal's "answer in her language", the explanation translator — take a `Lang` instead of saying "Simplified Chinese" outright. pt-PT is spelled **"European Portuguese (pt-PT, never Brazilian Portuguese)"** in the prompt itself, since a model that has read far more pt-BR needs telling. `Why` carries her word for "why" (为什么 / porquê / …) so the tutor prompt still recognises the question. An unknown code falls back rather than erroring — a prompt is the wrong place to discover a config problem.
|
||||||
|
- [x] Wired to `users.pair_lang` on both sides: `useSession` calls `setPackLang` the moment `/api/me` answers, and each LLM handler reads the column **in the row-scoped query it already ran** (the one that proves she owns the document) rather than in a second lookup that could disagree with it.
|
||||||
|
- Tests: `internal/llm/lang_test.go` (fallback matrix; each prompt names the writer's language and *not* Chinese; the zh pair reads exactly as before), `internal/suggestions/pairlang_test.go` (the column reaches the model for collocation + translate, zh unchanged — **verified to fail when the join is removed**), `web/src/i18n/i18n.test.ts` (default before `/api/me`, fallback for an unshipped pair, no spurious notifications, verbatim sentinels, interpolation incl. plurals, no empty string anywhere in the pack, every companion/tone/style labelled).
|
||||||
|
- Verified: go build/vet/test, tsc, vite build, vitest 90/90 clean; live smoke on a throwaway DB (:8074) — doc create/save, search, md export, spell add, warm 502 from the collocation pass with the LLM down; the built bundle still carries the zh strings.
|
||||||
|
- ⚠️ **Not yet deployed.** Pure refactor with no migration, so the deploy is a rebuild; still unshipped at the end of the session.
|
||||||
|
|
||||||
### Phase 20 — DreamDict as a lexicon provider
|
### Phase 20 — DreamDict as a lexicon provider
|
||||||
Option 3 ratified (import package, read-only `dict.db`). **Prerequisite in the dreamdict repo:** rename its module path (or add a `replace` for dev).
|
Option 3 ratified (import package, read-only `dict.db`). **Prerequisite in the dreamdict repo:** rename its module path (or add a `replace` for dev).
|
||||||
@@ -244,6 +251,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6).
|
|||||||
- [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)
|
- [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-07-27: **Phase 18 deployed, and Phase 19 — the copy stops being hardcoded Mandarin** (user: "let's continue the build plan"; sequencing confirmed: rehearse + deploy 18, then start 19). The rehearsal the previous session was blocked from running went first: a `VACUUM INTO` snapshot of the live VPS database, migrated locally by the Phase-18 binary, every count unchanged and FTS/integrity/foreign keys clean, `personal_words` created empty — then the deploy itself (off-box encrypted backup, rebuild, all three containers healthy, `0011` applied to the live DB with her writing untouched, `/api/spell/words` 401 without a session over public HTTPS). **One check was refused and not worked around**: minting a probe session row to see the endpoint answer 200 for a real cookie reads as credential fabrication to this session's classifier; the endpoint's lifecycle is covered by tests and the shared middleware governs that last step for every other route. **Phase 19** then lifted every `中文 · English` literal out of ~29 files into `web/src/i18n` — one `Pack` type, a verbatim `zh` pack, and two access paths chosen by *when* copy is built: `usePack()` for components, `pack()` for the companion and prose checker, which compose a line when something happens rather than when something renders. The interesting decisions were about what a pack must be allowed to control: **every string with a value in it is a function** (`reviewDue(n)`, `daysAgo(n)`, even English pluralisation) because word order isn't universal; the roster constants keep only value + emoji so a label can never drift from its key; and `gradeBand` returns a band *name* rather than a label. On the server, `internal/llm/lang.go` replaces "Simplified Chinese" in the three prompts that name her language — with pt-PT spelled **"European Portuguese (pt-PT, never Brazilian Portuguese)"** in the prompt itself, and her word for "why" carried alongside so the tutor still recognises the question. `pair_lang` is read **in the row-scoped query each handler already ran**, not a second lookup that could disagree with it — and the test for that was checked by breaking the join and watching it fail. go build/vet/test, tsc, vite, vitest 90/90 clean; live smoke on a throwaway DB. **Phase 19 is not deployed** — no migration, so it's a rebuild whenever the user wants it.
|
||||||
- 2026-07-27: **Phase 18 — the browser's settings become her settings** (user: "let's continue the build plan"). Two scope calls taken with the user: the personal spell dictionary goes **server-side** rather than being namespaced in place, and the pre-account `localStorage` keys are **adopted then rescoped** by the first writer to sign in. New `web/src/lib/prefs.ts` namespaces `petal.sound`/`petal.petals`/`petal.companion` by user id; the interesting part is timing — those modules read their value at *import* time, before `/api/me` can possibly have answered, so a pre-scope read deliberately sees the legacy key (the right value on a single-writer browser) and `setPrefsScope`, called from `useSession`, adopts it and notifies every reader. Adoption **moves** rather than copies, so account two starts from Petal's defaults instead of inheriting a stranger's mascot. New `internal/spell` package + migration `0011`: `personal_words` keyed `(user_id, lang, word)`, where `lang` is the **dictionary's** language, not the writer's — an English exception must not silence a pt-PT flag when the second pair ships. `useSpellChecker` now replays her list from her account, hands over any browser-held Phase-7 list on first load (releasing it only once the server has taken it), and persists an added word in the background so the underline vanishes the instant she asks. **The reason for the server table over cheaper namespacing**: keying the existing list by user in `localStorage` would have *fragmented* the words she already has across her laptop and tablet — the "cheap" fix was the one that made things worse. Tests: full lifecycle + languages-don't-merge + junk + the standing-rule two-user isolation suite in Go, and legacy-adoption/move-not-copy/two-accounts/storage-throws in vitest. go build/vet/test, tsc, vite, vitest 82/82 clean; live smoke on a throwaway DB. **Not deployed** — and the customary rehearsal of `0011` against a copy of the live VPS database was blocked by the session's permission classifier, so that check is outstanding (it is a plain `CREATE TABLE`, so lower-risk than `0005`/`0010`, but the convention exists for a reason).
|
- 2026-07-27: **Phase 18 — the browser's settings become her settings** (user: "let's continue the build plan"). Two scope calls taken with the user: the personal spell dictionary goes **server-side** rather than being namespaced in place, and the pre-account `localStorage` keys are **adopted then rescoped** by the first writer to sign in. New `web/src/lib/prefs.ts` namespaces `petal.sound`/`petal.petals`/`petal.companion` by user id; the interesting part is timing — those modules read their value at *import* time, before `/api/me` can possibly have answered, so a pre-scope read deliberately sees the legacy key (the right value on a single-writer browser) and `setPrefsScope`, called from `useSession`, adopts it and notifies every reader. Adoption **moves** rather than copies, so account two starts from Petal's defaults instead of inheriting a stranger's mascot. New `internal/spell` package + migration `0011`: `personal_words` keyed `(user_id, lang, word)`, where `lang` is the **dictionary's** language, not the writer's — an English exception must not silence a pt-PT flag when the second pair ships. `useSpellChecker` now replays her list from her account, hands over any browser-held Phase-7 list on first load (releasing it only once the server has taken it), and persists an added word in the background so the underline vanishes the instant she asks. **The reason for the server table over cheaper namespacing**: keying the existing list by user in `localStorage` would have *fragmented* the words she already has across her laptop and tablet — the "cheap" fix was the one that made things worse. Tests: full lifecycle + languages-don't-merge + junk + the standing-rule two-user isolation suite in Go, and legacy-adoption/move-not-copy/two-accounts/storage-throws in vitest. go build/vet/test, tsc, vite, vitest 82/82 clean; live smoke on a throwaway DB. **Not deployed** — and the customary rehearsal of `0011` against a copy of the live VPS database was blocked by the session's permission classifier, so that check is outstanding (it is a plain `CREATE TABLE`, so lower-risk than `0005`/`0010`, but the convention exists for a reason).
|
||||||
- 2026-07-27: **Phase 17 — Claire's writing moved onto her real account** (user: "claire is local user today in Petal. let's make sure to migrate existing data to her account"). `scripts/migrate_local_user.py`: dry-run by default, own `VACUUM INTO` backup, one transaction with foreign keys off, re-points `documents`/`tags`/`vocab_words`/`images`, verifies every expected row moved before committing. **The plan's stated prerequisite — "she logs in once so her sub exists" — turned out to be false**: authentik's `hashed_user_id` sub is `User.uid`, derived from her id and the instance secret, so it is readable in advance and the data could move *first*; she signs in to find her writing already there instead of to an empty Petal. Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image now belong to `5f47d955…`, verified end to end over public HTTPS. Per the user's call the VPS is now canonical and millenia was left running and untouched as a frozen fallback (it diverges the moment either is written to — retire it rather than sync it). **Three bugs, each found by a different kind of contact with reality**: (1) the image backfill claims files for `local`, which stops existing after a migration — a foreign-key error inside `images.New`, which `main.go` treats as fatal, so Petal would have crash-looped on first start against a migrated database; (2) `BEGIN EXCLUSIVE` was the wrong liveness check, since in WAL mode it only conflicts with another *writer* and sails past a running-but-idle Petal — exactly the case the guard exists for; (3) the replacement, `PRAGMA locking_mode = EXCLUSIVE`, holds its lock past being reset to `NORMAL`, so on a real WAL database the script locked itself out of its own backup — invisible locally because the test file had come from `VACUUM INTO` and wasn't in WAL mode. Same shape as Phase 16's trailing-slash issuer: the fixture didn't look like production.
|
- 2026-07-27: **Phase 17 — Claire's writing moved onto her real account** (user: "claire is local user today in Petal. let's make sure to migrate existing data to her account"). `scripts/migrate_local_user.py`: dry-run by default, own `VACUUM INTO` backup, one transaction with foreign keys off, re-points `documents`/`tags`/`vocab_words`/`images`, verifies every expected row moved before committing. **The plan's stated prerequisite — "she logs in once so her sub exists" — turned out to be false**: authentik's `hashed_user_id` sub is `User.uid`, derived from her id and the instance secret, so it is readable in advance and the data could move *first*; she signs in to find her writing already there instead of to an empty Petal. Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image now belong to `5f47d955…`, verified end to end over public HTTPS. Per the user's call the VPS is now canonical and millenia was left running and untouched as a frozen fallback (it diverges the moment either is written to — retire it rather than sync it). **Three bugs, each found by a different kind of contact with reality**: (1) the image backfill claims files for `local`, which stops existing after a migration — a foreign-key error inside `images.New`, which `main.go` treats as fatal, so Petal would have crash-looped on first start against a migrated database; (2) `BEGIN EXCLUSIVE` was the wrong liveness check, since in WAL mode it only conflicts with another *writer* and sails past a running-but-idle Petal — exactly the case the guard exists for; (3) the replacement, `PRAGMA locking_mode = EXCLUSIVE`, holds its lock past being reset to `NORMAL`, so on a real WAL database the script locked itself out of its own backup — invisible locally because the test file had come from `VACUUM INTO` and wasn't in WAL mode. Same shape as Phase 16's trailing-slash issuer: the fixture didn't look like production.
|
||||||
- 2026-07-27: **Phase 16 built — Petal authenticates for itself** (user: "let's continue the build plan"; box access granted mid-session). New `internal/auth` surface on top of the Phase-0 `Resolver` seam: `session.go` (opaque cookie, **SHA-256-at-rest**, 30-day sliding expiry throttled to one write an hour, revoke/revoke-all/prune), `oidc.go` (login/callback/logout with state + nonce + PKCE, **lazy retried discovery** so an IdP outage can't stop Petal booting or invalidate live sessions), `users.go` (provisioning upsert keyed on `sub`, `/api/me`, allowlist). Migration `0010` lands `sessions`, `images` and `users.pair_lang` together. `main.go` picks the resolver from config, so a laptop build is unchanged. **Image ownership** closes the capability-URL hole flagged in the Phase-0 audit — one row per owner keeps dedup, a stranger gets 404 not 403, `Cache-Control` dropped to `private`, and pre-existing files are claimed at startup or they'd all 404. Frontend: a single 401 interceptor, a warm bilingual sign-in overlay over a still-visible editor, and a **draft rescue** to localStorage so an expired session can't cost writing — the auto-save stashes the body it couldn't send and reclaims it after re-login. **Three deliberate deviations from the plan**, all noted above: the allowlist matches emails as well as subject ids (a subject doesn't exist until first login, so a subject-only list is unusable in advance); `SESSION_SECRET` was dropped from config rather than left unused (nothing signs anything — sessions are opaque and server-side); and image rows are keyed `(name, user_id)` rather than owned singly, which is what preserves deduplication. **A real bug caught by writing the round-trip test rather than by reading the code**: the one-shot state/nonce/PKCE cookies were cleared in a `defer`, i.e. after the redirect had already written the header, so the clearing `Set-Cookie` was silently dropped. Verified: full go/tsc/vite/vitest suites, migration `0010` against a `VACUUM INTO` copy of the live millenia DB (counts intact, FTS still matching, image claimed), and a live smoke against the binary in both auth-off and auth-on modes including a hand-inserted session (valid → 200; absent/forged/expired → 401). **Then deployed** (user: "do it! register it!"): provider + application registered in Authentik via `ak shell`, `.env` filled in, image rebuilt, and the **Traefik basic-auth gate removed** — Petal holds its own door now. Deploying immediately found two things no test could: the issuer's **trailing slash is significant** (Authentik's has one, OIDC compares byte-for-byte, and my normalising it away broke discovery while the slashless stub kept passing — now a knob with a regression test), and a provider created through the shell rather than the admin UI comes up with **empty `grant_types`**, which authentik answers with `invalid_request` before the login page renders. Verified over public HTTPS: health 200, `/api/docs` 401 with no basic-auth challenge, `/auth/login` → Authentik with state+nonce+PKCE, following it lands on the real sign-in page. Also swapped the emoji favicon for a **drawn sakura** (`web/public/petal.svg`) that renders in Petal's own rose palette everywhere instead of at each platform's discretion, and doubles as the Authentik app tile (inlined as a data URI, since this authentik doesn't serve `/media`). **The allowlist is `prosolis@proton.me` only** — that IdP fronts ~40 accounts, so empty was not an option and guessing her account would either lock her out or let a stranger in; adding her is one `.env` line and a restart.
|
- 2026-07-27: **Phase 16 built — Petal authenticates for itself** (user: "let's continue the build plan"; box access granted mid-session). New `internal/auth` surface on top of the Phase-0 `Resolver` seam: `session.go` (opaque cookie, **SHA-256-at-rest**, 30-day sliding expiry throttled to one write an hour, revoke/revoke-all/prune), `oidc.go` (login/callback/logout with state + nonce + PKCE, **lazy retried discovery** so an IdP outage can't stop Petal booting or invalidate live sessions), `users.go` (provisioning upsert keyed on `sub`, `/api/me`, allowlist). Migration `0010` lands `sessions`, `images` and `users.pair_lang` together. `main.go` picks the resolver from config, so a laptop build is unchanged. **Image ownership** closes the capability-URL hole flagged in the Phase-0 audit — one row per owner keeps dedup, a stranger gets 404 not 403, `Cache-Control` dropped to `private`, and pre-existing files are claimed at startup or they'd all 404. Frontend: a single 401 interceptor, a warm bilingual sign-in overlay over a still-visible editor, and a **draft rescue** to localStorage so an expired session can't cost writing — the auto-save stashes the body it couldn't send and reclaims it after re-login. **Three deliberate deviations from the plan**, all noted above: the allowlist matches emails as well as subject ids (a subject doesn't exist until first login, so a subject-only list is unusable in advance); `SESSION_SECRET` was dropped from config rather than left unused (nothing signs anything — sessions are opaque and server-side); and image rows are keyed `(name, user_id)` rather than owned singly, which is what preserves deduplication. **A real bug caught by writing the round-trip test rather than by reading the code**: the one-shot state/nonce/PKCE cookies were cleared in a `defer`, i.e. after the redirect had already written the header, so the clearing `Set-Cookie` was silently dropped. Verified: full go/tsc/vite/vitest suites, migration `0010` against a `VACUUM INTO` copy of the live millenia DB (counts intact, FTS still matching, image claimed), and a live smoke against the binary in both auth-off and auth-on modes including a hand-inserted session (valid → 200; absent/forged/expired → 401). **Then deployed** (user: "do it! register it!"): provider + application registered in Authentik via `ak shell`, `.env` filled in, image rebuilt, and the **Traefik basic-auth gate removed** — Petal holds its own door now. Deploying immediately found two things no test could: the issuer's **trailing slash is significant** (Authentik's has one, OIDC compares byte-for-byte, and my normalising it away broke discovery while the slashless stub kept passing — now a knob with a regression test), and a provider created through the shell rather than the admin UI comes up with **empty `grant_types`**, which authentik answers with `invalid_request` before the login page renders. Verified over public HTTPS: health 200, `/api/docs` 401 with no basic-auth challenge, `/auth/login` → Authentik with state+nonce+PKCE, following it lands on the real sign-in page. Also swapped the emoji favicon for a **drawn sakura** (`web/public/petal.svg`) that renders in Petal's own rose palette everywhere instead of at each platform's discretion, and doubles as the Authentik app tile (inlined as a data URI, since this authentik doesn't serve `/media`). **The allowlist is `prosolis@proton.me` only** — that IdP fronts ~40 accounts, so empty was not an option and guessing her account would either lock her out or let a stranger in; adding her is one `.env` line and a restart.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type checkpointResponse struct {
|
|||||||
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
|
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
|
||||||
// applies the latency-guard truncation and the checkpoint sampling parameters
|
// applies the latency-guard truncation and the checkpoint sampling parameters
|
||||||
// from the spec.
|
// from the spec.
|
||||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string, _ Lang) ([]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: checkpointMaxTokens,
|
MaxTokens: checkpointMaxTokens,
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ const CollocationInterval = 25 * time.Second
|
|||||||
// reflect the full piece. Each flag carries a native replacement to apply.
|
// reflect the full piece. Each flag carries a native replacement to apply.
|
||||||
//
|
//
|
||||||
// The tone argument is accepted for a uniform pass signature and passed through
|
// The tone argument is accepted for a uniform pass signature and passed through
|
||||||
// to the prompt so a hint can prefer a register-appropriate pairing.
|
// to the prompt so a hint can prefer a register-appropriate pairing. `lang` is
|
||||||
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
// the writer's pair language — the one each hint's short gloss is written in.
|
||||||
|
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string, lang Lang) ([]RawSuggestion, error) {
|
||||||
raw, err := client.Complete(ctx, CompletionRequest{
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
Messages: CollocationMessages(contentText, tone),
|
Messages: CollocationMessages(contentText, tone, lang),
|
||||||
MaxTokens: 2048,
|
MaxTokens: 2048,
|
||||||
Temperature: 0.3,
|
Temperature: 0.3,
|
||||||
RepetitionPenalty: 1.15,
|
RepetitionPenalty: 1.15,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// The pair language, as the prompts need to talk about it.
|
||||||
|
//
|
||||||
|
// Three of Petal's prompts name the writer's first language rather than merely
|
||||||
|
// being written in English: the collocation coach asks for a gloss in it, Ask
|
||||||
|
// Petal offers to answer in it, and the explanation translator renders into it.
|
||||||
|
// Before Phase 19 all three said "Simplified Chinese" outright, which made the
|
||||||
|
// zh pair the only one that could ever work.
|
||||||
|
//
|
||||||
|
// A Lang is not a translation of the prompt — the instructions stay in English,
|
||||||
|
// which is what the model follows best. It is the name the model should use for
|
||||||
|
// her language, plus the one word it should watch for when she writes in it.
|
||||||
|
type Lang struct {
|
||||||
|
// Code matches users.pair_lang.
|
||||||
|
Code string
|
||||||
|
// Name is how the prompt refers to the language, spelled the way a model
|
||||||
|
// recognises it. Regional precision matters here: "European Portuguese" is
|
||||||
|
// not "Portuguese" to a model that has read far more pt-BR than pt-PT.
|
||||||
|
Name string
|
||||||
|
// Why asks the same thing she would ask in her own language. It goes into
|
||||||
|
// the Ask Petal prompt as an example, so a model that answers only to
|
||||||
|
// English "why" still recognises the question when she types it her way.
|
||||||
|
Why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// langs holds every pair Petal can currently be a partner in. A language with a
|
||||||
|
// frontend langpack but no entry here still works — it falls back to zh's
|
||||||
|
// behaviour of the prompts, which is wrong but not broken — so keep the two in
|
||||||
|
// step when a pair ships.
|
||||||
|
var langs = map[string]Lang{
|
||||||
|
"zh": {Code: "zh", Name: "Simplified Chinese (Mandarin)", Why: "为什么"},
|
||||||
|
"pt-PT": {Code: "pt-PT", Name: "European Portuguese (pt-PT, never Brazilian Portuguese)", Why: "porquê"},
|
||||||
|
"fr": {Code: "fr", Name: "French", Why: "pourquoi"},
|
||||||
|
"es": {Code: "es", Name: "Spanish", Why: "por qué"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLang is the pair assumed when none is known — the column's default, and
|
||||||
|
// the only pair that existed before Phase 19.
|
||||||
|
var DefaultLang = langs["zh"]
|
||||||
|
|
||||||
|
// LangFor resolves a users.pair_lang value. An empty or unrecognised code falls
|
||||||
|
// back to the default rather than erroring: a prompt is not the place to
|
||||||
|
// discover a configuration problem, and the writing still has to be checked.
|
||||||
|
func LangFor(code string) Lang {
|
||||||
|
if l, ok := langs[strings.TrimSpace(code)]; ok {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return DefaultLang
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLangForFallsBackToDefault(t *testing.T) {
|
||||||
|
if got := LangFor("zh"); got.Code != "zh" {
|
||||||
|
t.Fatalf("LangFor(zh) = %+v", got)
|
||||||
|
}
|
||||||
|
if got := LangFor("pt-PT"); got.Code != "pt-PT" {
|
||||||
|
t.Fatalf("LangFor(pt-PT) = %+v", got)
|
||||||
|
}
|
||||||
|
// A blank column, a stray value, and stray whitespace all resolve rather
|
||||||
|
// than erroring — a prompt is the wrong place to discover a config problem.
|
||||||
|
for _, in := range []string{"", " ", "klingon", "ZH"} {
|
||||||
|
if got := LangFor(in); got.Code != DefaultLang.Code {
|
||||||
|
t.Fatalf("LangFor(%q) = %q, want the default %q", in, got.Code, DefaultLang.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := LangFor(" zh "); got.Code != "zh" {
|
||||||
|
t.Fatalf("LangFor with padding = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three prompts that name the writer's language must actually name *hers*.
|
||||||
|
// Before Phase 19 all three said "Simplified Chinese" outright, which is the
|
||||||
|
// bug this guards: a pt-PT writer asking "porquê" would have been answered in
|
||||||
|
// Mandarin.
|
||||||
|
func TestPromptsNameTheWritersLanguage(t *testing.T) {
|
||||||
|
pt := LangFor("pt-PT")
|
||||||
|
|
||||||
|
collocation := CollocationMessages("The rain was strong.", "casual", pt)[0].Content
|
||||||
|
if !strings.Contains(collocation, "European Portuguese") {
|
||||||
|
t.Fatalf("collocation prompt doesn't ask for a pt-PT gloss:\n%s", collocation)
|
||||||
|
}
|
||||||
|
if strings.Contains(collocation, "Simplified Chinese") {
|
||||||
|
t.Fatalf("collocation prompt still hardcodes Chinese:\n%s", collocation)
|
||||||
|
}
|
||||||
|
// The tone steering must survive alongside the language — they share one
|
||||||
|
// format string, and getting the verbs in the wrong order silently drops one.
|
||||||
|
if !strings.Contains(collocation, "relaxed, friendly, and conversational") {
|
||||||
|
t.Fatalf("collocation prompt lost its tone guidance:\n%s", collocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
translate := TranslateMessages("Try a shorter sentence here.", pt)[0].Content
|
||||||
|
if !strings.Contains(translate, "European Portuguese") || strings.Contains(translate, "Chinese") {
|
||||||
|
t.Fatalf("translate prompt targets the wrong language:\n%s", translate)
|
||||||
|
}
|
||||||
|
|
||||||
|
ask := AskPetalSystemPrompt("origin", "replacement", "grammar", "explanation", "paragraph", pt)
|
||||||
|
if !strings.Contains(ask, "European Portuguese") || strings.Contains(ask, "Mandarin") {
|
||||||
|
t.Fatalf("ask-petal prompt targets the wrong language:\n%s", ask)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ask, "porquê") {
|
||||||
|
t.Fatalf("ask-petal prompt doesn't recognise her word for \"why\":\n%s", ask)
|
||||||
|
}
|
||||||
|
// The suggestion context is positional in that template; a mis-numbered
|
||||||
|
// verb would quietly blank one of these fields.
|
||||||
|
for _, want := range []string{"origin", "replacement", "grammar", "explanation", "paragraph"} {
|
||||||
|
if !strings.Contains(ask, want) {
|
||||||
|
t.Fatalf("ask-petal prompt dropped %q:\n%s", want, ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(ask, "%!") {
|
||||||
|
t.Fatalf("ask-petal prompt has a formatting error:\n%s", ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zh pair is in daily use and must be untouched by the extraction: its
|
||||||
|
// prompts should read exactly as they did when they were hardcoded.
|
||||||
|
func TestDefaultPairStillReadsAsBefore(t *testing.T) {
|
||||||
|
zh := LangFor("zh")
|
||||||
|
|
||||||
|
if got := CollocationMessages("x", "", zh)[0].Content; !strings.Contains(got, "Simplified Chinese (Mandarin) gloss in parentheses") {
|
||||||
|
t.Fatalf("zh collocation gloss changed:\n%s", got)
|
||||||
|
}
|
||||||
|
if got := TranslateMessages("x", zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") {
|
||||||
|
t.Fatalf("zh translate target changed:\n%s", got)
|
||||||
|
}
|
||||||
|
if got := AskPetalSystemPrompt("a", "b", "c", "d", "e", zh); !strings.Contains(got, "为什么") {
|
||||||
|
t.Fatalf("zh ask-petal lost its Mandarin \"why\":\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-27
@@ -97,8 +97,8 @@ func VoiceMessages(contentText string) []Message {
|
|||||||
// just non-native ("do a decision" → "make a decision", "strong rain" → "heavy
|
// just non-native ("do a decision" → "make a decision", "strong rain" → "heavy
|
||||||
// rain"), and explicitly DEFERS real grammar/spelling errors to the grammar
|
// rain"), and explicitly DEFERS real grammar/spelling errors to the grammar
|
||||||
// checkpoint so the two families don't overlap. Every explanation is framed as a
|
// checkpoint so the two families don't overlap. Every explanation is framed as a
|
||||||
// warm "natives usually say…" note with a short Mandarin gloss — never
|
// warm "natives usually say…" note with a short gloss in the writer's own
|
||||||
// "error/wrong" — because these are stylistic, not mistakes. It is a distinct
|
// language — never "error/wrong" — because these are stylistic, not mistakes. It is a distinct
|
||||||
// pass from the grammar checkpoint (do not bundle them). `replacement` carries
|
// pass from the grammar checkpoint (do not bundle them). `replacement` carries
|
||||||
// the natural pairing the writer can accept in one tap.
|
// the natural pairing the writer can accept in one tap.
|
||||||
const collocationSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
|
const collocationSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
|
||||||
@@ -113,7 +113,7 @@ Identify up to 5 such non-native word pairings. For each, give the natural pairi
|
|||||||
`Be gentle and specific. Do NOT flag grammar errors, spelling mistakes, or unclear sentences — those are handled ` +
|
`Be gentle and specific. Do NOT flag grammar errors, spelling mistakes, or unclear sentences — those are handled ` +
|
||||||
`elsewhere. Only flag word pairings that are correct but sound non-native.%s
|
`elsewhere. Only flag word pairings that are correct but sound non-native.%s
|
||||||
|
|
||||||
Phrase every explanation warmly as "Natives usually say…" and include a brief Simplified Chinese gloss in parentheses. ` +
|
Phrase every explanation warmly as "Natives usually say…" and include a brief %s gloss in parentheses. ` +
|
||||||
`Never use the words "error", "wrong", or "mistake" — these are friendly polish, not corrections.
|
`Never use the words "error", "wrong", or "mistake" — these are friendly polish, not corrections.
|
||||||
|
|
||||||
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||||
@@ -132,10 +132,11 @@ If every pairing already sounds natural, return: {"suggestions": []}`
|
|||||||
|
|
||||||
// CollocationMessages builds the message array for a collocation pass over the
|
// CollocationMessages builds the message array for a collocation pass over the
|
||||||
// WHOLE document (no truncation), gently steered toward the document's tone so a
|
// WHOLE document (no truncation), gently steered toward the document's tone so a
|
||||||
// hint can prefer a register-appropriate pairing.
|
// hint can prefer a register-appropriate pairing. The parenthetical gloss is
|
||||||
func CollocationMessages(contentText, tone string) []Message {
|
// written in the writer's own language.
|
||||||
|
func CollocationMessages(contentText, tone string, lang Lang) []Message {
|
||||||
return []Message{
|
return []Message{
|
||||||
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone))},
|
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone), lang.Name)},
|
||||||
{Role: "user", Content: contentText},
|
{Role: "user", Content: contentText},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,26 +148,27 @@ const askPetalSystemTemplate = `You are Petal, a warm and patient English writin
|
|||||||
`as a second language. You are currently discussing a specific writing suggestion.
|
`as a second language. You are currently discussing a specific writing suggestion.
|
||||||
|
|
||||||
Suggestion context:
|
Suggestion context:
|
||||||
- Original text: "%s"
|
- Original text: "%[1]s"
|
||||||
- Suggested replacement: "%s"
|
- Suggested replacement: "%[2]s"
|
||||||
- Issue type: %s
|
- Issue type: %[3]s
|
||||||
- Initial explanation: "%s"
|
- Initial explanation: "%[4]s"
|
||||||
- Surrounding paragraph: "%s"
|
- Surrounding paragraph: "%[5]s"
|
||||||
|
|
||||||
The user wants to understand this suggestion better. Detect the language of the user's message ` +
|
The user wants to understand this suggestion better. Detect the language of the user's message ` +
|
||||||
`and respond in that same language. If they write in Mandarin Chinese, respond entirely in ` +
|
`and respond in that same language. If they write in %[6]s, respond entirely in ` +
|
||||||
`Mandarin. If they write in English, respond in English. Never mix languages in a single response.
|
`%[6]s. If they write in English, respond in English. Never mix languages in a single response.
|
||||||
|
|
||||||
Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples ` +
|
Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples ` +
|
||||||
`when helpful. If they ask "why" (or "为什么"), explain the grammar rule or idiom behind it. ` +
|
`when helpful. If they ask "why" (or "%[7]s"), explain the grammar rule or idiom behind it. ` +
|
||||||
`If they suggest an alternative phrasing, evaluate it honestly.
|
`If they suggest an alternative phrasing, evaluate it honestly.
|
||||||
|
|
||||||
Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging — ` +
|
Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging — ` +
|
||||||
`learning a language is hard and they're doing great.`
|
`learning a language is hard and they're doing great.`
|
||||||
|
|
||||||
// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context.
|
// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context and
|
||||||
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
|
// the writer's pair language, which is the one she may ask her question in.
|
||||||
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
|
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string, lang Lang) string {
|
||||||
|
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph, lang.Name, lang.Why)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
|
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
|
||||||
@@ -215,22 +217,23 @@ func RewriteMessages(text, style string) []Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// translateSystemPrompt drives the explanation translator: it renders a
|
// translateSystemPrompt drives the explanation translator: it renders a
|
||||||
// suggestion's English explanation into Simplified Chinese so an ESL reader sees
|
// suggestion's English explanation into the writer's own language so an ESL
|
||||||
// the "why" in her first language. Strict about returning ONLY the translation
|
// reader sees the "why" in her first language. Strict about returning ONLY the
|
||||||
// (no quotes, no pinyin, no English echo) so it can drop straight into the chat
|
// translation (no quotes, no romanisation, no English echo) so it can drop
|
||||||
// bubble. Kept warm and plain — these are short, friendly one-liners.
|
// 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 ` +
|
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 ` +
|
`sends into natural, friendly %[1]s. It is a short explanation of a writing ` +
|
||||||
`suggestion, written for a native Chinese speaker learning English.
|
`suggestion, written for a native %[1]s speaker learning English.
|
||||||
|
|
||||||
Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` +
|
Respond with ONLY the %[1]s translation. No quotation marks, no romanisation, no English, no preamble — ` +
|
||||||
`just the translated sentence.`
|
`just the translated sentence.`
|
||||||
|
|
||||||
// TranslateMessages builds the message array for translating one short English
|
// TranslateMessages builds the message array for translating one short English
|
||||||
// explanation into Simplified Chinese.
|
// explanation into the writer's own language.
|
||||||
func TranslateMessages(text string) []Message {
|
func TranslateMessages(text string, lang Lang) []Message {
|
||||||
return []Message{
|
return []Message{
|
||||||
{Role: "system", Content: translateSystemPrompt},
|
{Role: "system", Content: fmt.Sprintf(translateSystemPrompt, lang.Name)},
|
||||||
{Role: "user", Content: text},
|
{Role: "user", Content: text},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunTranslate renders a short English explanation into Simplified Chinese. It
|
// RunTranslate renders a short English explanation into the writer's own
|
||||||
// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low
|
// language. It is a one-shot Complete (the result seeds the Ask Petal bubble),
|
||||||
// temperature so the translation is faithful rather than creative. Output is
|
// 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.
|
// trimmed of any stray surrounding quotes the model may add.
|
||||||
func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) {
|
func RunTranslate(ctx context.Context, client LLMClient, text string, lang Lang) (string, error) {
|
||||||
out, err := client.Complete(ctx, CompletionRequest{
|
out, err := client.Complete(ctx, CompletionRequest{
|
||||||
Messages: TranslateMessages(text),
|
Messages: TranslateMessages(text, lang),
|
||||||
MaxTokens: 512,
|
MaxTokens: 512,
|
||||||
Temperature: 0.2,
|
Temperature: 0.2,
|
||||||
TopP: 0.9,
|
TopP: 0.9,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const VoiceInterval = 20 * time.Second
|
|||||||
// The tone argument is accepted for a uniform pass signature but ignored: voice
|
// The tone argument is accepted for a uniform pass signature but ignored: voice
|
||||||
// consistency is judged against the document's own established voice, not an
|
// consistency is judged against the document's own established voice, not an
|
||||||
// externally-chosen register.
|
// externally-chosen register.
|
||||||
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string) ([]RawSuggestion, error) {
|
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string, _ Lang) ([]RawSuggestion, error) {
|
||||||
raw, err := client.Complete(ctx, CompletionRequest{
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
Messages: VoiceMessages(contentText),
|
Messages: VoiceMessages(contentText),
|
||||||
MaxTokens: 2048,
|
MaxTokens: 2048,
|
||||||
|
|||||||
@@ -40,14 +40,17 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
original, replacement, explanation, typ string
|
original, replacement, explanation, typ string
|
||||||
fromPos int
|
fromPos int
|
||||||
contentText string
|
contentText string
|
||||||
|
pairLang string
|
||||||
)
|
)
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text
|
`SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text,
|
||||||
|
COALESCE(u.pair_lang, '')
|
||||||
FROM suggestions s
|
FROM suggestions s
|
||||||
JOIN documents d ON d.id = s.doc_id
|
JOIN documents d ON d.id = s.doc_id
|
||||||
|
JOIN users u ON u.id = d.user_id
|
||||||
WHERE s.id = ? AND d.user_id = ?`,
|
WHERE s.id = ? AND d.user_id = ?`,
|
||||||
sugID, auth.UserID(r.Context()),
|
sugID, auth.UserID(r.Context()),
|
||||||
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
|
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText, &pairLang)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||||
return
|
return
|
||||||
@@ -58,7 +61,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
paragraph := surroundingParagraph(contentText, fromPos)
|
paragraph := surroundingParagraph(contentText, fromPos)
|
||||||
systemPrompt := llm.AskPetalSystemPrompt(original, replacement, typ, explanation, paragraph)
|
systemPrompt := llm.AskPetalSystemPrompt(original, replacement, typ, explanation, paragraph, llm.LangFor(pairLang))
|
||||||
|
|
||||||
// SSE requires an unbuffered, flushable writer. chi's middleware writers pass
|
// SSE requires an unbuffered, flushable writer. chi's middleware writers pass
|
||||||
// Flush through; bail with a plain error if somehow they don't.
|
// Flush through; bail with a plain error if somehow they don't.
|
||||||
|
|||||||
@@ -194,9 +194,12 @@ func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||||
// given the document text and the document's tone it returns the model's raw
|
// given the document text, the document's tone and the writer's pair language it
|
||||||
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
// returns the model's raw suggestions. The voice pass ignores both extras (see
|
||||||
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string) ([]llm.RawSuggestion, error)
|
// llm.RunVoice) and the checkpoint ignores the language — only the collocation
|
||||||
|
// coach writes a word of it — but one signature keeps runPass free of special
|
||||||
|
// cases.
|
||||||
|
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, lang llm.Lang) ([]llm.RawSuggestion, error)
|
||||||
|
|
||||||
// runPass is the shared body for both LLM passes. It loads the document text,
|
// runPass is the shared body for both LLM passes. It loads the document text,
|
||||||
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
||||||
@@ -206,11 +209,17 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
userID := auth.UserID(r.Context())
|
userID := auth.UserID(r.Context())
|
||||||
|
|
||||||
var contentText, tone string
|
// The writer's pair language rides along with the document rather than in a
|
||||||
|
// second query: it is read from the same row-scoped lookup that already
|
||||||
|
// proves she owns this document.
|
||||||
|
var contentText, tone, pairLang string
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`,
|
`SELECT d.content_text, d.tone, COALESCE(u.pair_lang, '')
|
||||||
|
FROM documents d
|
||||||
|
JOIN users u ON u.id = d.user_id
|
||||||
|
WHERE d.id = ? AND d.user_id = ?`,
|
||||||
docID, userID,
|
docID, userID,
|
||||||
).Scan(&contentText, &tone)
|
).Scan(&contentText, &tone, &pairLang)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
return
|
return
|
||||||
@@ -239,7 +248,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := run(r.Context(), h.Client, contentText, tone)
|
raw, err := run(r.Context(), h.Client, contentText, tone, llm.LangFor(pairLang))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Allow ran before the model call, so a failed pass would otherwise hold
|
// 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
|
// the per-document slot for the full interval — stranding the frontend's
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package suggestions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The langpack decides what Petal says in the browser; users.pair_lang has to
|
||||||
|
// decide what the *model* says too, or a pt-PT writer gets a Mandarin gloss on
|
||||||
|
// an otherwise Portuguese screen. These tests follow the value from the column
|
||||||
|
// to the system prompt for each pass that names a language.
|
||||||
|
//
|
||||||
|
// This is the same failure mode the standing isolation rule guards against: the
|
||||||
|
// column is read in a query the handler already ran, so nothing fails loudly if
|
||||||
|
// the join is dropped — the prompt just quietly reverts to Mandarin.
|
||||||
|
|
||||||
|
// newPairServer seeds one writer on the given pair with a document of her own.
|
||||||
|
func newPairServer(t *testing.T, client llm.LLMClient, pairLang string) (http.Handler, string, *db.DB) {
|
||||||
|
t.Helper()
|
||||||
|
database, err := db.Open(filepath.Join(t.TempDir(), "pair.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
|
||||||
|
const userID = "writer-pt"
|
||||||
|
if _, err := database.Exec(
|
||||||
|
`INSERT INTO users (id, email, display_name, pair_lang) VALUES (?, ?, ?, ?)`,
|
||||||
|
userID, "w@example.com", "Writer", pairLang,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var docID string
|
||||||
|
if err := database.QueryRow(
|
||||||
|
`INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`,
|
||||||
|
userID, "The rain was strong yesterday.",
|
||||||
|
).Scan(&docID); err != nil {
|
||||||
|
t.Fatalf("seed doc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := New(database, client)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
|
||||||
|
r.Mount("/suggestions", h.Routes())
|
||||||
|
return auth.Middleware(auth.StaticResolver(userID))(r), docID, database
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollocationPromptUsesTheWritersPair(t *testing.T) {
|
||||||
|
client := &recordingClient{response: `{"suggestions":[]}`}
|
||||||
|
srv, docID, _ := newPairServer(t, client, "pt-PT")
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("collocation: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
system := client.last.Messages[0].Content
|
||||||
|
if !strings.Contains(system, "European Portuguese") {
|
||||||
|
t.Fatalf("collocation prompt ignored pair_lang:\n%s", system)
|
||||||
|
}
|
||||||
|
if strings.Contains(system, "Simplified Chinese") {
|
||||||
|
t.Fatalf("collocation prompt fell back to Mandarin:\n%s", system)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslatePromptUsesTheWritersPair(t *testing.T) {
|
||||||
|
client := &recordingClient{response: "Chove muito."}
|
||||||
|
srv, docID, database := newPairServer(t, client, "pt-PT")
|
||||||
|
|
||||||
|
var sugID string
|
||||||
|
if err := database.QueryRow(
|
||||||
|
`INSERT INTO suggestions (doc_id, original, replacement, explanation, type, from_pos, to_pos)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, 5) RETURNING id`,
|
||||||
|
docID, "strong rain", "heavy rain", "Natives usually say heavy rain.", "collocation",
|
||||||
|
).Scan(&sugID); err != nil {
|
||||||
|
t.Fatalf("seed suggestion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/translate", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("translate: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
system := client.last.Messages[0].Content
|
||||||
|
if !strings.Contains(system, "European Portuguese") || strings.Contains(system, "Chinese") {
|
||||||
|
t.Fatalf("translate prompt ignored pair_lang:\n%s", system)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A writer whose column still holds the default — every account today — must be
|
||||||
|
// answered exactly as before.
|
||||||
|
func TestDefaultPairIsUnchanged(t *testing.T) {
|
||||||
|
client := &recordingClient{response: `{"suggestions":[]}`}
|
||||||
|
srv, docID, _ := newPairServer(t, client, "zh")
|
||||||
|
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("collocation: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if system := client.last.Messages[0].Content; !strings.Contains(system, "Simplified Chinese (Mandarin) gloss") {
|
||||||
|
t.Fatalf("zh writer no longer gets a Mandarin gloss:\n%s", system)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,14 +25,15 @@ type translateResponse struct {
|
|||||||
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
||||||
sugID := chi.URLParam(r, "id")
|
sugID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
var explanation string
|
var explanation, pairLang string
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT s.explanation
|
`SELECT s.explanation, COALESCE(u.pair_lang, '')
|
||||||
FROM suggestions s
|
FROM suggestions s
|
||||||
JOIN documents d ON d.id = s.doc_id
|
JOIN documents d ON d.id = s.doc_id
|
||||||
|
JOIN users u ON u.id = d.user_id
|
||||||
WHERE s.id = ? AND d.user_id = ?`,
|
WHERE s.id = ? AND d.user_id = ?`,
|
||||||
sugID, auth.UserID(r.Context()),
|
sugID, auth.UserID(r.Context()),
|
||||||
).Scan(&explanation)
|
).Scan(&explanation, &pairLang)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||||
return
|
return
|
||||||
@@ -48,7 +49,7 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
|
out, err := llm.RunTranslate(r.Context(), h.Client, explanation, llm.LangFor(pairLang))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
+7
-4
@@ -18,6 +18,7 @@ import { useSession } from './hooks/useSession'
|
|||||||
import { takeDraft } from './lib/drafts'
|
import { takeDraft } from './lib/drafts'
|
||||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||||
import { PetalFall } from './effects/PetalFall'
|
import { PetalFall } from './effects/PetalFall'
|
||||||
|
import { usePack } from './i18n'
|
||||||
import { useNightMode } from './hooks/useNightMode'
|
import { useNightMode } from './hooks/useNightMode'
|
||||||
import { playSuggestionSound } from './audio/sounds'
|
import { playSuggestionSound } from './audio/sounds'
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export default function App() {
|
|||||||
// Who's writing, and whether the server still recognises them. `signedOut`
|
// Who's writing, and whether the server still recognises them. `signedOut`
|
||||||
// flips the moment any call comes back 401.
|
// flips the moment any call comes back 401.
|
||||||
const { me, signedOut } = useSession()
|
const { me, signedOut } = useSession()
|
||||||
|
const t = usePack()
|
||||||
// A real account to sign out of, as opposed to the hardcoded local user a
|
// A real account to sign out of, as opposed to the hardcoded local user a
|
||||||
// build without auth configured runs as.
|
// build without auth configured runs as.
|
||||||
const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null
|
const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null
|
||||||
@@ -239,7 +241,8 @@ export default function App() {
|
|||||||
setDocText('')
|
setDocText('')
|
||||||
}, [saveNow, isBlankDraft])
|
}, [saveNow, isBlankDraft])
|
||||||
|
|
||||||
// Duplicate a document: copy its body/tone into a fresh doc titled "… (副本)",
|
// Duplicate a document: copy its body/tone into a fresh doc under the pack's
|
||||||
|
// "copy" title,
|
||||||
// then open the copy. Tags aren't carried over (a fresh start for the copy).
|
// then open the copy. Tags aren't carried over (a fresh start for the copy).
|
||||||
const handleDuplicate = useCallback(
|
const handleDuplicate = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
@@ -247,7 +250,7 @@ export default function App() {
|
|||||||
await saveNow() // flush in case we're duplicating the open doc
|
await saveNow() // flush in case we're duplicating the open doc
|
||||||
const src = await api.getDoc(id)
|
const src = await api.getDoc(id)
|
||||||
const fresh = await api.createDoc()
|
const fresh = await api.createDoc()
|
||||||
const dupTitle = `${src.title?.trim() || 'Untitled'} (副本)`
|
const dupTitle = t.app.duplicateTitle(src.title?.trim() || 'Untitled')
|
||||||
const updated = await api.updateDoc(fresh.id, {
|
const updated = await api.updateDoc(fresh.id, {
|
||||||
title: dupTitle,
|
title: dupTitle,
|
||||||
content: src.content,
|
content: src.content,
|
||||||
@@ -451,7 +454,7 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span aria-hidden>🌷</span>
|
<span aria-hidden>🌷</span>
|
||||||
<span>词汇花园</span>
|
<span>{t.app.garden}</span>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>· Garden</span>
|
<span style={{ color: 'var(--color-muted)' }}>· Garden</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -514,7 +517,7 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span aria-hidden>🕘</span>
|
<span aria-hidden>🕘</span>
|
||||||
<span>历史</span>
|
<span>{t.app.history}</span>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="petal-no-print">
|
<div className="petal-no-print">
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
// again as an errand, not an error. The editor stays visible behind the scrim
|
// again as an errand, not an error. The editor stays visible behind the scrim
|
||||||
// (dimmed, still there) for the same reason: nothing has been taken away.
|
// (dimmed, still there) for the same reason: nothing has been taken away.
|
||||||
|
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// Whether there is unsaved writing waiting on this device, which changes the
|
// Whether there is unsaved writing waiting on this device, which changes the
|
||||||
// reassurance from a promise to a statement of fact.
|
// reassurance from a promise to a statement of fact.
|
||||||
@@ -13,6 +15,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SignInOverlay({ hasDraft }: Props) {
|
export function SignInOverlay({ hasDraft }: Props) {
|
||||||
|
const t = usePack()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -39,21 +42,17 @@ export function SignInOverlay({ hasDraft }: Props) {
|
|||||||
className="mt-3 text-lg font-bold"
|
className="mt-3 text-lg font-bold"
|
||||||
style={{ color: 'var(--color-plum)' }}
|
style={{ color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
请重新登录
|
{t.auth.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm font-semibold" style={{ color: 'var(--color-muted)' }}>
|
<p className="text-sm font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||||
Please sign in again
|
{t.auth.titleEn}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-4 text-sm leading-relaxed" style={{ color: 'var(--color-plum)' }}>
|
<p className="mt-4 text-sm leading-relaxed" style={{ color: 'var(--color-plum)' }}>
|
||||||
{hasDraft
|
{hasDraft ? t.auth.bodyWithDraft : t.auth.bodyPlain}
|
||||||
? '你刚写的内容已经安全地留在这台电脑上,登录后会自动接着保存。'
|
|
||||||
: '登录状态过期了。你的文字都已经保存好了。'}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-muted)' }}>
|
<p className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-muted)' }}>
|
||||||
{hasDraft
|
{hasDraft ? t.auth.bodyWithDraftEn : t.auth.bodyPlainEn}
|
||||||
? "What you just wrote is safe on this device — it'll save itself once you're back in."
|
|
||||||
: 'Your session expired. Everything you wrote is already saved.'}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@@ -63,7 +62,7 @@ export function SignInOverlay({ hasDraft }: Props) {
|
|||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
>
|
>
|
||||||
去登录 · Sign in
|
{t.auth.signIn}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCompanion, type Mood } from './useCompanion'
|
|||||||
import { LottiePlayer } from './LottiePlayer'
|
import { LottiePlayer } from './LottiePlayer'
|
||||||
import { COMPANIONS, DEFAULT_COMPANION } from './companions'
|
import { COMPANIONS, DEFAULT_COMPANION } from './companions'
|
||||||
import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs'
|
import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
wordCount: number
|
wordCount: number
|
||||||
@@ -30,6 +31,7 @@ const STORAGE_KEY = 'petal.companion'
|
|||||||
// break reminders. Clicking the mascot opens a picker to switch companions
|
// break reminders. Clicking the mascot opens a picker to switch companions
|
||||||
// (the choice persists in localStorage).
|
// (the choice persists in localStorage).
|
||||||
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
|
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
||||||
wordCount,
|
wordCount,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
@@ -115,7 +117,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
className="px-2 pb-1 pt-0.5 text-[0.7rem] font-bold"
|
className="px-2 pb-1 pt-0.5 text-[0.7rem] font-bold"
|
||||||
style={{ color: 'var(--color-muted)' }}
|
style={{ color: 'var(--color-muted)' }}
|
||||||
>
|
>
|
||||||
选个小伙伴 · Choose a companion
|
{t.companion.choose}
|
||||||
</p>
|
</p>
|
||||||
{COMPANIONS.map((c) => {
|
{COMPANIONS.map((c) => {
|
||||||
const active = c.id === companion.id
|
const active = c.id === companion.id
|
||||||
@@ -138,7 +140,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
>
|
>
|
||||||
<span style={{ fontSize: 20, lineHeight: 1 }}>{c.emoji}</span>
|
<span style={{ fontSize: 20, lineHeight: 1 }}>{c.emoji}</span>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
<span className="font-bold">{c.zh}</span>{' '}
|
<span className="font-bold">{t.companion.names[c.id] ?? c.name}</span>{' '}
|
||||||
<span style={{ color: 'var(--color-muted)' }}>{c.name}</span>
|
<span style={{ color: 'var(--color-muted)' }}>{c.name}</span>
|
||||||
</span>
|
</span>
|
||||||
{active && <span style={{ color: 'var(--color-accent)' }}>✓</span>}
|
{active && <span style={{ color: 'var(--color-accent)' }}>✓</span>}
|
||||||
@@ -169,7 +171,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
className="font-bold leading-snug"
|
className="font-bold leading-snug"
|
||||||
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
|
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
|
||||||
>
|
>
|
||||||
{bubble.zh}
|
{bubble.native}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className="mt-0.5 leading-snug"
|
className="mt-0.5 leading-snug"
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import parrot from './animations/parrot.json'
|
|||||||
|
|
||||||
export interface Companion {
|
export interface Companion {
|
||||||
id: string
|
id: string
|
||||||
name: string // English label
|
name: string // English label; the writer's-language name lives in the langpack
|
||||||
zh: string // Chinese label (she reads Mandarin — north star Note #17)
|
|
||||||
emoji: string // shown in the picker + used as the per-mood fallback base
|
emoji: string // shown in the picker + used as the per-mood fallback base
|
||||||
// mood → Lottie asset. Unmapped moods fall back to the per-mood emoji.
|
// mood → Lottie asset. Unmapped moods fall back to the per-mood emoji.
|
||||||
animations: Partial<Record<Mood, object>>
|
animations: Partial<Record<Mood, object>>
|
||||||
@@ -26,7 +25,6 @@ export const COMPANIONS: Companion[] = [
|
|||||||
{
|
{
|
||||||
id: 'cat',
|
id: 'cat',
|
||||||
name: 'Sleepy Cat',
|
name: 'Sleepy Cat',
|
||||||
zh: '瞌睡猫',
|
|
||||||
emoji: '😴',
|
emoji: '😴',
|
||||||
alwaysAsleep: true,
|
alwaysAsleep: true,
|
||||||
animations: {
|
animations: {
|
||||||
@@ -40,7 +38,6 @@ export const COMPANIONS: Companion[] = [
|
|||||||
{
|
{
|
||||||
id: 'dog',
|
id: 'dog',
|
||||||
name: 'Happy Dog',
|
name: 'Happy Dog',
|
||||||
zh: '开心狗',
|
|
||||||
emoji: '🐶',
|
emoji: '🐶',
|
||||||
animations: {
|
animations: {
|
||||||
idle: happyDog,
|
idle: happyDog,
|
||||||
@@ -53,7 +50,6 @@ export const COMPANIONS: Companion[] = [
|
|||||||
{
|
{
|
||||||
id: 'wiggle-dog',
|
id: 'wiggle-dog',
|
||||||
name: 'Wiggle Dog',
|
name: 'Wiggle Dog',
|
||||||
zh: '摇尾狗',
|
|
||||||
emoji: '🐕',
|
emoji: '🐕',
|
||||||
animations: {
|
animations: {
|
||||||
idle: wiggleDog,
|
idle: wiggleDog,
|
||||||
@@ -65,7 +61,6 @@ export const COMPANIONS: Companion[] = [
|
|||||||
{
|
{
|
||||||
id: 'butterfly',
|
id: 'butterfly',
|
||||||
name: 'Butterfly',
|
name: 'Butterfly',
|
||||||
zh: '蝴蝶',
|
|
||||||
emoji: '🦋',
|
emoji: '🦋',
|
||||||
animations: {
|
animations: {
|
||||||
idle: butterfly,
|
idle: butterfly,
|
||||||
@@ -77,7 +72,6 @@ export const COMPANIONS: Companion[] = [
|
|||||||
{
|
{
|
||||||
id: 'parrot',
|
id: 'parrot',
|
||||||
name: 'Parrot',
|
name: 'Parrot',
|
||||||
zh: '鹦鹉',
|
|
||||||
emoji: '🦜',
|
emoji: '🦜',
|
||||||
flip: true, // asset faces left; mirror it to face into the page
|
flip: true, // asset faces left; mirror it to face into the page
|
||||||
animations: {
|
animations: {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
// trust far faster than a missed one earns it, so we only speak up when a
|
// trust far faster than a missed one earns it, so we only speak up when a
|
||||||
// pattern is a high-confidence, genuinely-common English mistake.
|
// pattern is a high-confidence, genuinely-common English mistake.
|
||||||
//
|
//
|
||||||
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
|
// Each finding becomes a native-language-first bubble Line (see tips.ts), often
|
||||||
|
// quoting
|
||||||
// a short slice of her own sentence so the advice clearly belongs to *this*
|
// a short slice of her own sentence so the advice clearly belongs to *this*
|
||||||
// paragraph and not a generic tip jar.
|
// paragraph and not a generic tip jar.
|
||||||
//
|
//
|
||||||
@@ -17,8 +18,13 @@
|
|||||||
// splices, …) carry no `fix` and stay companion bubbles. The companion hides
|
// splices, …) carry no `fix` and stay companion bubbles. The companion hides
|
||||||
// fix-bearing hints so the same span never appears as both a bubble and a card.
|
// fix-bearing hints so the same span never appears as both a bubble and a card.
|
||||||
|
|
||||||
|
import { pack } from '../../i18n'
|
||||||
import type { Line } from './tips'
|
import type { Line } from './tips'
|
||||||
|
|
||||||
|
// The pair's prose copy. Read per finding rather than captured once, so a rule
|
||||||
|
// that fires after the writer's language is known speaks the right one.
|
||||||
|
const P = () => pack().prose
|
||||||
|
|
||||||
export interface ProseHint extends Line {
|
export interface ProseHint extends Line {
|
||||||
// Stable, content-derived id so the same untouched sentence isn't re-flagged
|
// Stable, content-derived id so the same untouched sentence isn't re-flagged
|
||||||
// every cadence; the companion remembers recently-shown ids.
|
// every cadence; the companion remembers recently-shown ids.
|
||||||
@@ -108,7 +114,7 @@ function runOns(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `runon:${key(s)}`,
|
id: `runon:${key(s)}`,
|
||||||
rule: 'runon',
|
rule: 'runon',
|
||||||
zh: '这句话有点长啦,分成两三句会更清楚 🌸',
|
native: P().longSentence,
|
||||||
en: `This one runs long — try splitting it: “${anchor(s)}”`,
|
en: `This one runs long — try splitting it: “${anchor(s)}”`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -170,7 +176,7 @@ function commaSplices(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `splice:${key(m[0])}`,
|
id: `splice:${key(m[0])}`,
|
||||||
rule: 'splice',
|
rule: 'splice',
|
||||||
zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
|
native: P().commaSplice,
|
||||||
en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`,
|
en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -195,7 +201,7 @@ function antecedents(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `antecedent:${key(s)}`,
|
id: `antecedent:${key(s)}`,
|
||||||
rule: 'antecedent',
|
rule: 'antecedent',
|
||||||
zh: `“${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`,
|
native: P().vagueThis(m[1]),
|
||||||
en: `“${m[1]}” here is a little vague — name what it refers to.`,
|
en: `“${m[1]}” here is a little vague — name what it refers to.`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -226,7 +232,7 @@ function oxford(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `oxford:${key(m[0])}`,
|
id: `oxford:${key(m[0])}`,
|
||||||
rule: 'oxford',
|
rule: 'oxford',
|
||||||
zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
|
native: P().oxfordComma,
|
||||||
en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`,
|
en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -246,7 +252,7 @@ function introComma(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `introcomma:${m[1].toLowerCase()}`,
|
id: `introcomma:${m[1].toLowerCase()}`,
|
||||||
rule: 'introcomma',
|
rule: 'introcomma',
|
||||||
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
native: P().transitionComma(m[1]),
|
||||||
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
@@ -265,7 +271,7 @@ function sentenceCaps(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: 'cap-sentence',
|
id: 'cap-sentence',
|
||||||
rule: 'cap-sentence',
|
rule: 'cap-sentence',
|
||||||
zh: '每个句子的开头,用大写字母开始吧。',
|
native: P().capitalizeSentence,
|
||||||
en: 'Start each new sentence with a capital letter.',
|
en: 'Start each new sentence with a capital letter.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -289,7 +295,7 @@ function doubles(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `double:${from}:${key(m[0])}`,
|
id: `double:${from}:${key(m[0])}`,
|
||||||
rule: 'double',
|
rule: 'double',
|
||||||
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
native: P().repeatedWord(m[1]),
|
||||||
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
||||||
fix: { from, to, replacement: m[1] },
|
fix: { from, to, replacement: m[1] },
|
||||||
})
|
})
|
||||||
@@ -311,7 +317,7 @@ function pronounI(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: 'cap-i',
|
id: 'cap-i',
|
||||||
rule: 'cap-i',
|
rule: 'cap-i',
|
||||||
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
native: P().capitalizeI,
|
||||||
en: 'In English, “I” is always written as a capital letter.',
|
en: 'In English, “I” is always written as a capital letter.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -328,7 +334,7 @@ function spaceBeforePunct(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `space-punct:${from}`,
|
id: `space-punct:${from}`,
|
||||||
rule: 'space-punct',
|
rule: 'space-punct',
|
||||||
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
native: P().spaceBeforePunct,
|
||||||
en: 'No space before punctuation — it tucks right against the word.',
|
en: 'No space before punctuation — it tucks right against the word.',
|
||||||
fix: { from, to, replacement: m[1] + m[2] },
|
fix: { from, to, replacement: m[1] + m[2] },
|
||||||
})
|
})
|
||||||
@@ -351,7 +357,7 @@ function spaceAfterPunct(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `space-after:${from}`,
|
id: `space-after:${from}`,
|
||||||
rule: 'space-after',
|
rule: 'space-after',
|
||||||
zh: '逗号、句号后面要空一格,再接下一个词。',
|
native: P().spaceAfterPunct,
|
||||||
en: 'Add a space after a comma or period before the next word.',
|
en: 'Add a space after a comma or period before the next word.',
|
||||||
fix: { from, to, replacement: `${m[1]} ${m[2]}` },
|
fix: { from, to, replacement: `${m[1]} ${m[2]}` },
|
||||||
})
|
})
|
||||||
@@ -378,7 +384,7 @@ function articles(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `article-an:${m.index}`,
|
id: `article-an:${m.index}`,
|
||||||
rule: 'article',
|
rule: 'article',
|
||||||
zh: `元音开头的词前用 “an”:“an ${m[2]}”。`,
|
native: P().articleAn(m[2]),
|
||||||
en: `Before a vowel sound, use “an”: “an ${m[2]}”.`,
|
en: `Before a vowel sound, use “an”: “an ${m[2]}”.`,
|
||||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) },
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) },
|
||||||
})
|
})
|
||||||
@@ -389,7 +395,7 @@ function articles(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `article-a:${m.index}`,
|
id: `article-a:${m.index}`,
|
||||||
rule: 'article',
|
rule: 'article',
|
||||||
zh: `辅音开头的词前用 “a”:“a ${m[2]}”。`,
|
native: P().articleA(m[2]),
|
||||||
en: `Before a consonant sound, use “a”: “a ${m[2]}”.`,
|
en: `Before a consonant sound, use “a”: “a ${m[2]}”.`,
|
||||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) },
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) },
|
||||||
})
|
})
|
||||||
@@ -410,7 +416,7 @@ function uncountables(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `uncountable:${m.index}`,
|
id: `uncountable:${m.index}`,
|
||||||
rule: 'uncountable',
|
rule: 'uncountable',
|
||||||
zh: `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
native: P().uncountable(word, singular),
|
||||||
en: `“${word}” is uncountable — drop the “s”: just “${singular}”.`,
|
en: `“${word}” is uncountable — drop the “s”: just “${singular}”.`,
|
||||||
fix: { from: m.index, to: m.index + word.length, replacement: singular },
|
fix: { from: m.index, to: m.index + word.length, replacement: singular },
|
||||||
})
|
})
|
||||||
@@ -439,7 +445,7 @@ function properCaps(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `propercap:${m.index}`,
|
id: `propercap:${m.index}`,
|
||||||
rule: 'propercap',
|
rule: 'propercap',
|
||||||
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
native: P().capitalizeProper(fixed),
|
||||||
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
||||||
fix: { from: m.index, to: m.index + word.length, replacement: fixed },
|
fix: { from: m.index, to: m.index + word.length, replacement: fixed },
|
||||||
})
|
})
|
||||||
@@ -485,7 +491,7 @@ function subjectVerbAgreement(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `sva:${m.index}`,
|
id: `sva:${m.index}`,
|
||||||
rule: 'sva',
|
rule: 'sva',
|
||||||
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
|
native: P().thirdPersonS(m[2], fixed),
|
||||||
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
||||||
fix: { from: m.index, to: m.index + m[0].length, replacement: head + matchCase(verb, fixed) },
|
fix: { from: m.index, to: m.index + m[0].length, replacement: head + matchCase(verb, fixed) },
|
||||||
})
|
})
|
||||||
@@ -515,7 +521,7 @@ function pluralAfterNumber(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `plural:${m.index}`,
|
id: `plural:${m.index}`,
|
||||||
rule: 'plural',
|
rule: 'plural',
|
||||||
zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
|
native: P().pluralAfter(m[1], m[2]),
|
||||||
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
||||||
fix: { from: m.index, to: m.index + m[0].length, replacement: `${m[0]}s` },
|
fix: { from: m.index, to: m.index + m[0].length, replacement: `${m[0]}s` },
|
||||||
})
|
})
|
||||||
@@ -537,7 +543,7 @@ function doubleDeterminer(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `doubledet:${m.index}`,
|
id: `doubledet:${m.index}`,
|
||||||
rule: 'doubledet',
|
rule: 'doubledet',
|
||||||
zh: `“${m[1]} ${m[3]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
native: P().doubleDeterminer(m[1], m[3]),
|
||||||
en: `“${m[1]} ${m[3]}” stacks two determiners — keep just one.`,
|
en: `“${m[1]} ${m[3]}” stacks two determiners — keep just one.`,
|
||||||
// Drop the article (m[1]); keep the second determiner, casing preserved.
|
// Drop the article (m[1]); keep the second determiner, casing preserved.
|
||||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], m[3]) },
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], m[3]) },
|
||||||
@@ -561,7 +567,7 @@ function thereIsPlural(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `thereis:${m.index}`,
|
id: `thereis:${m.index}`,
|
||||||
rule: 'thereis',
|
rule: 'thereis',
|
||||||
zh: `后面是复数时用 “there are”:“there are ${m[2]}…”。`,
|
native: P().thereArePlural(m[2]),
|
||||||
en: `With a plural, use “there are”: “there are ${m[2]}…”.`,
|
en: `With a plural, use “there are”: “there are ${m[2]}…”.`,
|
||||||
fix: { from: m.index, to: m.index + m[0].length, replacement },
|
fix: { from: m.index, to: m.index + m[0].length, replacement },
|
||||||
})
|
})
|
||||||
@@ -581,7 +587,7 @@ function itsConfusion(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `its-own:${m.index}`,
|
id: `its-own:${m.index}`,
|
||||||
rule: 'its',
|
rule: 'its',
|
||||||
zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
native: P().itsOwn,
|
||||||
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
||||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) },
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) },
|
||||||
})
|
})
|
||||||
@@ -592,7 +598,7 @@ function itsConfusion(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `its-article:${m.index}`,
|
id: `its-article:${m.index}`,
|
||||||
rule: 'its',
|
rule: 'its',
|
||||||
zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
native: P().itsIs(m[1]),
|
||||||
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
||||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) },
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) },
|
||||||
})
|
})
|
||||||
@@ -617,7 +623,7 @@ function thanThen(text: string, out: ProseHint[]) {
|
|||||||
out.push({
|
out.push({
|
||||||
id: `than:${m.index}`,
|
id: `than:${m.index}`,
|
||||||
rule: 'than',
|
rule: 'than',
|
||||||
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
|
native: P().thanNotThen(m[1]),
|
||||||
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
||||||
fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') },
|
fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,93 +1,28 @@
|
|||||||
// Bilingual companion copy — Mandarin first (she writes/reads in Mandarin), with
|
// The companion's line *selection*. The lines themselves moved to the langpack
|
||||||
// a warm English subtitle. Kept gentle and encouraging, never scolding. The
|
// in Phase 19 (`src/i18n`) — the kitten speaks whichever pair its writer is in,
|
||||||
// bubble renders zh prominently and en underneath. (Spec north star: CJK is
|
// and the copy for a pair belongs with the rest of that pair's copy rather than
|
||||||
// first-class; Ask Petal & companion answer in Mandarin.)
|
// next to the timing rules that decide when to say it.
|
||||||
|
//
|
||||||
|
// Everything here is read at call time, never at import time, so a bubble
|
||||||
|
// composed after /api/me answers is in the right language.
|
||||||
|
|
||||||
export interface Line {
|
import { pack, type Line } from '../../i18n'
|
||||||
zh: string
|
|
||||||
en: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Played when a suggestion is accepted or a milestone hits — pure warmth.
|
export type { Line }
|
||||||
export const ENCOURAGEMENTS: Line[] = [
|
|
||||||
{ zh: '好棒!这一句更顺了 🌸', en: 'Lovely — that reads so much smoother now.' },
|
|
||||||
{ zh: '你写得越来越好了 ✨', en: "You're getting better and better." },
|
|
||||||
{ 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
|
export const encouragements = (): Line[] => pack().companion.encouragements
|
||||||
// checker (prose.ts) finds nothing concrete to point at in her current text.
|
export const tips = (): Line[] => pack().companion.tips
|
||||||
export const TIPS: Line[] = [
|
export const breaks = (): Line[] => pack().companion.breaks
|
||||||
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
|
export const bedtime = (): Line[] => pack().companion.bedtime
|
||||||
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
|
export const errors = (): Line[] => pack().companion.errors
|
||||||
{ zh: '过去的事情用过去式:go → went。', en: 'For the past, use past tense: go → went.' },
|
export const greeting = (): Line => pack().companion.greeting
|
||||||
{ zh: '读出声音,能帮你发现奇怪的地方。', en: 'Reading aloud helps you catch awkward spots.' },
|
export const welcomeBack = (): Line => pack().companion.welcomeBack
|
||||||
{ zh: '一个段落讲一个想法就好。', en: 'One idea per paragraph keeps it tidy.' },
|
export const milestoneLine = (n: number): Line => pack().companion.milestone(n)
|
||||||
{ zh: '不确定的地方,问问我就好啦 ✨', en: 'Not sure about something? Just ask me. ✨' },
|
|
||||||
{ zh: '复数别忘了加 s:two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Shown after a long stretch of continuous writing.
|
// Word-count milestones worth a little cheer — every 100 words, on up. A count,
|
||||||
export const BREAKS: Line[] = [
|
// not copy: the same in every language.
|
||||||
{ zh: '写了好一会儿啦,起来走走,让眼睛休息一下 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
|
|
||||||
{ zh: '喝口水,休息五分钟好不好?', en: 'Sip some water and take five?' },
|
|
||||||
{ 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." },
|
|
||||||
// A few old Chinese proverbs on sleep (zh = a faithful rendering of the
|
|
||||||
// English sense — the verified classical 原文 isn't recoverable; swap in the
|
|
||||||
// exact source text if you have it). They read a touch wittier/wiser than the
|
|
||||||
// gentle lines above, which suits a late-night nudge.
|
|
||||||
{ zh: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." },
|
|
||||||
{ zh: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', en: 'Think of your own faults the first part of the night when you are awake, and of the faults of others the latter part of the night when you are asleep.' },
|
|
||||||
{ zh: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// First hello when the app opens.
|
|
||||||
export const GREETING: Line = {
|
|
||||||
zh: '嗨~我在这儿陪你写作哦 🐱',
|
|
||||||
en: "Hi! I'm right here keeping you company. 🐱",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Welcome-back nudge after she returns from an idle pause.
|
|
||||||
export const WELCOME_BACK: Line = {
|
|
||||||
zh: '欢迎回来 ✨ 我们继续吧!',
|
|
||||||
en: 'Welcome back ✨ let’s keep going!',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gentle "haiya, something went wrong" lines — paired with the error sound when
|
|
||||||
// the LLM is unreachable or a save fails. Never alarming, always reassuring.
|
|
||||||
export const ERRORS: Line[] = [
|
|
||||||
{ zh: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
|
|
||||||
{ zh: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
|
||||||
{ zh: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Word-count milestones worth a little cheer — every 100 words, on up.
|
|
||||||
export const MILESTONES = Array.from({ length: 100 }, (_, i) => (i + 1) * 100)
|
export const MILESTONES = Array.from({ length: 100 }, (_, i) => (i + 1) * 100)
|
||||||
|
|
||||||
export function milestoneLine(n: number): Line {
|
|
||||||
return {
|
|
||||||
zh: `哇!已经 ${n} 个词了,太厉害了 🎉`,
|
|
||||||
en: `Wow — ${n} words already! Amazing. 🎉`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deterministic-enough random pick (Math.random is fine in the browser runtime).
|
// Deterministic-enough random pick (Math.random is fine in the browser runtime).
|
||||||
export function pick<T>(arr: T[]): T {
|
export function pick<T>(arr: T[]): T {
|
||||||
return arr[Math.floor(Math.random() * arr.length)]
|
return arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
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,
|
|
||||||
ENCOURAGEMENTS,
|
|
||||||
ERRORS,
|
|
||||||
GREETING,
|
|
||||||
MILESTONES,
|
MILESTONES,
|
||||||
TIPS,
|
bedtime,
|
||||||
WELCOME_BACK,
|
breaks,
|
||||||
|
encouragements,
|
||||||
|
errors,
|
||||||
|
greeting,
|
||||||
milestoneLine,
|
milestoneLine,
|
||||||
pick,
|
pick,
|
||||||
|
tips,
|
||||||
|
welcomeBack,
|
||||||
type Line,
|
type Line,
|
||||||
} from './tips'
|
} from './tips'
|
||||||
import { analyzeProse } from './prose'
|
import { analyzeProse } from './prose'
|
||||||
@@ -60,11 +60,11 @@ const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
|||||||
const now = () => Date.now()
|
const now = () => Date.now()
|
||||||
|
|
||||||
// Reading time for a bubble: a base floor by tone, stretched by the combined
|
// Reading time for a bubble: a base floor by tone, stretched by the combined
|
||||||
// length of the Mandarin + English lines so denser advice stays up long enough
|
// length of the native + 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 : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : 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.native.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +138,9 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
|||||||
if (hint) {
|
if (hint) {
|
||||||
lastRule.current = hint.rule
|
lastRule.current = hint.rule
|
||||||
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
|
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
|
||||||
return { zh: hint.zh, en: hint.en, tone: 'tip' }
|
return { native: hint.native, en: hint.en, tone: 'tip' }
|
||||||
}
|
}
|
||||||
return { ...pick(TIPS), tone: 'tip' }
|
return { ...pick(tips()), tone: 'tip' }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
@@ -167,7 +167,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
|||||||
|
|
||||||
// Opening hello (once), after a short beat so it doesn't race the first paint.
|
// Opening hello (once), after a short beat so it doesn't race the first paint.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
|
const id = setTimeout(() => say({ ...greeting(), tone: 'tip' }), 1200)
|
||||||
return () => clearTimeout(id)
|
return () => clearTimeout(id)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
@@ -183,7 +183,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
|||||||
if (sleeping.current) {
|
if (sleeping.current) {
|
||||||
sleeping.current = false
|
sleeping.current = false
|
||||||
sessionStart.current = now() // a fresh stretch starts on return
|
sessionStart.current = now() // a fresh stretch starts on return
|
||||||
say({ ...WELCOME_BACK, tone: 'cheer' })
|
say({ ...welcomeBack(), tone: 'cheer' })
|
||||||
} else if (mood === 'sleeping') {
|
} else if (mood === 'sleeping') {
|
||||||
setMood('idle')
|
setMood('idle')
|
||||||
}
|
}
|
||||||
@@ -197,7 +197,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
|||||||
firstAccept.current = false
|
firstAccept.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true })
|
say({ ...pick(encouragements()), tone: 'cheer' }, { celebrate: true })
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [acceptTick])
|
}, [acceptTick])
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastError.current = t
|
lastError.current = t
|
||||||
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
|
say({ ...pick(errors()), tone: 'error' }, { sound: 'error' })
|
||||||
}, [say])
|
}, [say])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -246,7 +246,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
|||||||
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
||||||
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { proactive: true })
|
say({ ...pick(encouragements()), tone: 'cheer' }, { proactive: true })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [saveStatus])
|
}, [saveStatus])
|
||||||
@@ -267,7 +267,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
|||||||
if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) {
|
if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) {
|
||||||
lastBreak.current = t
|
lastBreak.current = t
|
||||||
sessionStart.current = t
|
sessionStart.current = t
|
||||||
say({ ...pick(BREAKS), tone: 'break' }, { proactive: true })
|
say({ ...pick(breaks()), tone: 'break' }, { proactive: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
|||||||
// returned if she's away/napping).
|
// returned if she's away/napping).
|
||||||
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
|
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
|
||||||
lastBedtime.current = t
|
lastBedtime.current = t
|
||||||
say({ ...pick(BEDTIME), tone: 'bedtime' }, { proactive: true })
|
say({ ...pick(bedtime()), tone: 'bedtime' }, { proactive: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api, type DocSummary, type Tag, type TagColor } from '../../api/client'
|
|||||||
import { DocListItem } from './DocListItem'
|
import { DocListItem } from './DocListItem'
|
||||||
import { SearchBox } from './SearchBox'
|
import { SearchBox } from './SearchBox'
|
||||||
import { TagChip } from './TagChip'
|
import { TagChip } from './TagChip'
|
||||||
|
import { usePack, type Pack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
docs: DocSummary[]
|
docs: DocSummary[]
|
||||||
@@ -21,10 +22,10 @@ interface Props {
|
|||||||
|
|
||||||
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
|
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
|
||||||
type SortMode = 'recent' | 'title' | 'longest'
|
type SortMode = 'recent' | 'title' | 'longest'
|
||||||
const SORTS: { value: SortMode; label: string }[] = [
|
const SORTS: { value: SortMode; label: (t: Pack) => string }[] = [
|
||||||
{ value: 'recent', label: '最近 · Recent' },
|
{ value: 'recent', label: (t) => t.docs.sortRecent },
|
||||||
{ value: 'title', label: '标题 · Title' },
|
{ value: 'title', label: (t) => t.docs.sortTitle },
|
||||||
{ value: 'longest', label: '字数 · Longest' },
|
{ value: 'longest', label: (t) => t.docs.sortLongest },
|
||||||
]
|
]
|
||||||
|
|
||||||
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
||||||
@@ -41,6 +42,7 @@ export function DocList({
|
|||||||
onCreateTag,
|
onCreateTag,
|
||||||
account,
|
account,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = usePack()
|
||||||
// Active tag filter (null = show all). Cleared automatically if the tag
|
// Active tag filter (null = show all). Cleared automatically if the tag
|
||||||
// disappears from the roster.
|
// disappears from the roster.
|
||||||
const [filterId, setFilterId] = useState<string | null>(null)
|
const [filterId, setFilterId] = useState<string | null>(null)
|
||||||
@@ -108,7 +110,7 @@ export function DocList({
|
|||||||
>
|
>
|
||||||
{SORTS.map((s) => (
|
{SORTS.map((s) => (
|
||||||
<option key={s.value} value={s.value}>
|
<option key={s.value} value={s.value}>
|
||||||
{s.label}
|
{s.label(t)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -144,7 +146,7 @@ export function DocList({
|
|||||||
className="flex items-center gap-2 px-1 pt-1 text-xs"
|
className="flex items-center gap-2 px-1 pt-1 text-xs"
|
||||||
style={{ color: 'var(--color-muted)', borderTop: '1px solid var(--color-border)' }}
|
style={{ color: 'var(--color-muted)', borderTop: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<span className="font-semibold">备份 · Back up all:</span>
|
<span className="font-semibold">{t.docs.backUpAll}</span>
|
||||||
<a href={api.exportAllUrl('docx')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
|
<a href={api.exportAllUrl('docx')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
|
||||||
Word
|
Word
|
||||||
</a>
|
</a>
|
||||||
@@ -169,7 +171,7 @@ export function DocList({
|
|||||||
className="shrink-0 font-bold hover:underline"
|
className="shrink-0 font-bold hover:underline"
|
||||||
style={{ color: 'var(--color-accent-hover)' }}
|
style={{ color: 'var(--color-accent-hover)' }}
|
||||||
>
|
>
|
||||||
退出 · Sign out
|
{t.docs.signOut}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||||
import { TagChip } from './TagChip'
|
import { TagChip } from './TagChip'
|
||||||
import { TagPicker } from './TagPicker'
|
import { TagPicker } from './TagPicker'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
doc: DocSummary
|
doc: DocSummary
|
||||||
@@ -27,6 +28,7 @@ export function DocListItem({
|
|||||||
onCreateTag,
|
onCreateTag,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [picking, setPicking] = useState(false)
|
const [picking, setPicking] = useState(false)
|
||||||
|
const t = usePack()
|
||||||
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +72,7 @@ export function DocListItem({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Duplicate document"
|
aria-label="Duplicate document"
|
||||||
title="副本 · Duplicate"
|
title={t.docs.duplicate}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onDuplicate()
|
onDuplicate()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api, splitSnippet, type SearchResult } from '../../api/client'
|
import { api, splitSnippet, type SearchResult } from '../../api/client'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// Called when a result is chosen — opens that document.
|
// Called when a result is chosen — opens that document.
|
||||||
@@ -12,6 +13,7 @@ const DEBOUNCE_MS = 220
|
|||||||
// debounces a full-text query; results drop in below with a highlighted snippet.
|
// debounces a full-text query; results drop in below with a highlighted snippet.
|
||||||
// Clearing (× or empty) returns the sidebar to the normal document list.
|
// Clearing (× or empty) returns the sidebar to the normal document list.
|
||||||
export function SearchBox({ onSelect }: Props) {
|
export function SearchBox({ onSelect }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [results, setResults] = useState<SearchResult[] | null>(null)
|
const [results, setResults] = useState<SearchResult[] | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@@ -60,7 +62,7 @@ export function SearchBox({ onSelect }: Props) {
|
|||||||
<input
|
<input
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
placeholder="搜索 · Search"
|
placeholder={t.docs.searchPlaceholder}
|
||||||
aria-label="Search documents"
|
aria-label="Search documents"
|
||||||
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
|
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
|
||||||
style={{
|
style={{
|
||||||
@@ -88,11 +90,11 @@ export function SearchBox({ onSelect }: Props) {
|
|||||||
<div className="petal-search-results flex flex-col gap-0.5">
|
<div className="petal-search-results flex flex-col gap-0.5">
|
||||||
{busy && results.length === 0 ? (
|
{busy && results.length === 0 ? (
|
||||||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
查找中… · Searching…
|
{t.docs.searching}
|
||||||
</p>
|
</p>
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
没有找到 · No matches
|
{t.docs.noMatches}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
results.map((r) => (
|
results.map((r) => (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
|
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
|
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ interface Props {
|
|||||||
// existing tag to attach/detach it, or type a new name (with a color swatch) to
|
// existing tag to attach/detach it, or type a new name (with a color swatch) to
|
||||||
// create-and-attach. Closes on outside click or Escape.
|
// create-and-attach. Closes on outside click or Escape.
|
||||||
export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }: Props) {
|
export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [color, setColor] = useState<TagColor>('rose')
|
const [color, setColor] = useState<TagColor>('rose')
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
@@ -58,7 +60,7 @@ export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }:
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
<div className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
标签 · Tags
|
{t.docs.tags}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{roster.length > 0 && (
|
{roster.length > 0 && (
|
||||||
@@ -114,7 +116,7 @@ export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }:
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') submit()
|
if (e.key === 'Enter') submit()
|
||||||
}}
|
}}
|
||||||
placeholder="新标签 · New tag"
|
placeholder={t.docs.newTagPlaceholder}
|
||||||
aria-label="New tag name"
|
aria-label="New tag name"
|
||||||
className="petal-tap min-w-0 flex-1 bg-transparent px-2.5 text-sm focus:outline-none"
|
className="petal-tap min-w-0 flex-1 bg-transparent px-2.5 text-sm focus:outline-none"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestionId: string
|
suggestionId: string
|
||||||
@@ -19,6 +20,7 @@ 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-
|
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
||||||
// token into the latest assistant bubble.
|
// token into the latest assistant bubble.
|
||||||
export function AskPetal({ suggestionId, explanation }: Props) {
|
export function AskPetal({ suggestionId, explanation }: Props) {
|
||||||
|
const t = usePack()
|
||||||
// Opening bubble starts empty (caret-only) and fills with the Mandarin
|
// Opening bubble starts empty (caret-only) and fills with the Mandarin
|
||||||
// translation once it lands; `seeding` drives that loading caret.
|
// translation once it lands; `seeding` drives that loading caret.
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
||||||
@@ -137,7 +139,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="Ask why… / 问为什么…"
|
placeholder={t.editor.askPlaceholder}
|
||||||
className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none"
|
className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-surface-alt)',
|
background: 'var(--color-surface-alt)',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { Editor } from '@tiptap/react'
|
import type { Editor } from '@tiptap/react'
|
||||||
import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight'
|
import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
// FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the
|
// FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the
|
||||||
// SearchHighlight decoration layer: typing updates the highlighted matches, the
|
// SearchHighlight decoration layer: typing updates the highlighted matches, the
|
||||||
@@ -26,6 +27,7 @@ function scrollToActive(editor: Editor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FindReplace({ editor, onClose }: Props) {
|
export function FindReplace({ editor, onClose }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [replacement, setReplacement] = useState('')
|
const [replacement, setReplacement] = useState('')
|
||||||
const [caseSensitive, setCaseSensitive] = useState(false)
|
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||||
@@ -140,7 +142,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
|||||||
go(e.shiftKey ? -1 : 1)
|
go(e.shiftKey ? -1 : 1)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="查找 · Find"
|
placeholder={t.editor.findPlaceholder}
|
||||||
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
@@ -148,7 +150,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
|||||||
className="w-14 shrink-0 text-center text-xs tabular-nums"
|
className="w-14 shrink-0 text-center text-xs tabular-nums"
|
||||||
style={{ color: 'var(--color-muted)' }}
|
style={{ color: 'var(--color-muted)' }}
|
||||||
>
|
>
|
||||||
{count ? `${active + 1} / ${count}` : query ? '无 · 0' : ''}
|
{count ? `${active + 1} / ${count}` : query ? t.editor.findNone : ''}
|
||||||
</span>
|
</span>
|
||||||
<FindBtn label="Previous match" disabled={!count} onClick={() => go(-1)}>↑</FindBtn>
|
<FindBtn label="Previous match" disabled={!count} onClick={() => go(-1)}>↑</FindBtn>
|
||||||
<FindBtn label="Next match" disabled={!count} onClick={() => go(1)}>↓</FindBtn>
|
<FindBtn label="Next match" disabled={!count} onClick={() => go(1)}>↓</FindBtn>
|
||||||
@@ -156,11 +158,11 @@ export function FindReplace({ editor, onClose }: Props) {
|
|||||||
label="Match case"
|
label="Match case"
|
||||||
active={caseSensitive}
|
active={caseSensitive}
|
||||||
onClick={() => setCaseSensitive((v) => !v)}
|
onClick={() => setCaseSensitive((v) => !v)}
|
||||||
title="Match case · 区分大小写"
|
title={t.editor.matchCase}
|
||||||
>
|
>
|
||||||
Aa
|
Aa
|
||||||
</FindBtn>
|
</FindBtn>
|
||||||
<FindBtn label="Close" onClick={onClose} title="Close · 关闭">✕</FindBtn>
|
<FindBtn label="Close" onClick={onClose} title={t.editor.close}>✕</FindBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showReplace && (
|
{showReplace && (
|
||||||
@@ -174,7 +176,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
|||||||
replaceActive()
|
replaceActive()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="替换为 · Replace"
|
placeholder={t.editor.replacePlaceholder}
|
||||||
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
@@ -185,7 +187,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
|||||||
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-semibold disabled:opacity-40"
|
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-semibold disabled:opacity-40"
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
替换
|
{t.editor.replace}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -194,7 +196,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
|||||||
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-bold disabled:opacity-40"
|
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-bold disabled:opacity-40"
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: '#fff' }}
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: '#fff' }}
|
||||||
>
|
>
|
||||||
全部
|
{t.editor.replaceAll}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
// bilingual (zh-first, en subtitle) to match the rest of Petal's chrome — the
|
// bilingual (zh-first, en subtitle) to match the rest of Petal's chrome — the
|
||||||
// user writes in Mandarin and English (spec Note #17).
|
// user writes in Mandarin and English (spec Note #17).
|
||||||
|
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
word: string
|
word: string
|
||||||
suggestions: string[]
|
suggestions: string[]
|
||||||
@@ -13,6 +15,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Props) {
|
export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const shown = suggestions.slice(0, 5)
|
const shown = suggestions.slice(0, 5)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -33,7 +36,7 @@ export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Pro
|
|||||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||||
style={{ background: 'var(--color-accent)', color: 'white' }}
|
style={{ background: 'var(--color-accent)', color: 'white' }}
|
||||||
>
|
>
|
||||||
拼写 · Spelling
|
{t.editor.spelling}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold" style={{ color: 'var(--color-muted)' }}>
|
<span className="font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||||
{word}
|
{word}
|
||||||
@@ -58,7 +61,7 @@ export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Pro
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
没有建议 · No suggestions
|
{t.editor.noSuggestions}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -68,7 +71,7 @@ export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Pro
|
|||||||
className="mt-3 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
className="mt-3 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||||
style={{ background: 'transparent', color: 'var(--color-accent-hover)' }}
|
style={{ background: 'transparent', color: 'var(--color-accent-hover)' }}
|
||||||
>
|
>
|
||||||
添加到词典 · Add to dictionary
|
{t.editor.addToDictionary}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { REWRITE_STYLES } from './SelectionBubble'
|
import { REWRITE_STYLES } from './SelectionBubble'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
// RewritePreview shows the result of a tone-rewrite before it touches the
|
// RewritePreview shows the result of a tone-rewrite before it touches the
|
||||||
// document: the writer's original passage, the model's rewrite beneath it, and
|
// document: the writer's original passage, the model's rewrite beneath it, and
|
||||||
@@ -30,6 +31,7 @@ export function RewritePreview({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onRetry,
|
onRetry,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = usePack()
|
||||||
const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0]
|
const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,10 +56,10 @@ export function RewritePreview({
|
|||||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{meta.emoji}</span>
|
<span aria-hidden>{meta.emoji}</span>
|
||||||
{meta.zh} · {meta.en}
|
{t.styles[meta.value].native} · {t.styles[meta.value].en}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-semibold" style={{ color: 'var(--color-muted)' }}>
|
<span className="text-xs font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||||
改写 · Rewrite
|
{t.editor.rewrite}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,14 +78,14 @@ export function RewritePreview({
|
|||||||
style={{ background: 'var(--color-accent)' }}
|
style={{ background: 'var(--color-accent)' }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
改写中… · Rewriting…
|
{t.editor.rewriting}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<p className="leading-snug" style={{ color: 'var(--color-muted)' }}>
|
<p className="leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
改写失败,请再试一次 · Couldn’t rewrite — try again
|
{t.editor.rewriteFailed}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2.5 flex justify-end gap-2">
|
<div className="mt-2.5 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -92,7 +94,7 @@ export function RewritePreview({
|
|||||||
className="rounded-full px-3 py-1 text-xs font-semibold"
|
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
取消 · Cancel
|
{t.editor.cancel}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -100,7 +102,7 @@ export function RewritePreview({
|
|||||||
className="rounded-full px-3 py-1 text-xs font-bold"
|
className="rounded-full px-3 py-1 text-xs font-bold"
|
||||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
重试 · Retry
|
{t.editor.retry}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +120,7 @@ export function RewritePreview({
|
|||||||
className="rounded-full px-3 py-1 text-xs font-semibold"
|
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
取消 · Cancel
|
{t.editor.cancel}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -126,7 +128,7 @@ export function RewritePreview({
|
|||||||
className="rounded-full px-3 py-1 text-xs font-bold"
|
className="rounded-full px-3 py-1 text-xs font-bold"
|
||||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
用这个 · Use this
|
{t.editor.useThis}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
// SelectionBubble floats above a text selection and offers to rewrite it: a
|
// SelectionBubble floats above a text selection and offers to rewrite it: a
|
||||||
// prominent "✨ 更自然 Say it naturally" action plus the tone vocabulary (学术,
|
// prominent "say it naturally" action plus the tone vocabulary mirrored from the
|
||||||
// 轻松, …) mirrored from the document-tone picker. Picking one hands the style up
|
// document-tone picker (labels come from the langpack). Picking one hands the style up
|
||||||
// to EditorCore, which calls the LLM and shows a preview. Buttons use
|
// to EditorCore, which calls the LLM and shows a preview. Buttons use
|
||||||
// onMouseDown→preventDefault so clicking them doesn't collapse the selection
|
// onMouseDown→preventDefault so clicking them doesn't collapse the selection
|
||||||
// before the handler captures its range.
|
// before the handler captures its range.
|
||||||
|
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
export interface RewriteStyle {
|
export interface RewriteStyle {
|
||||||
value: string
|
value: string
|
||||||
emoji: string
|
emoji: string
|
||||||
zh: string
|
|
||||||
en: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'natural' is the default "say it more naturally" rewrite; the rest mirror the
|
// 'natural' is the default "say it more naturally" rewrite; the rest mirror the
|
||||||
// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step.
|
// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step.
|
||||||
export const REWRITE_STYLES: RewriteStyle[] = [
|
export const REWRITE_STYLES: RewriteStyle[] = [
|
||||||
{ value: 'natural', emoji: '✨', zh: '更自然', en: 'Natural' },
|
{ value: 'natural', emoji: '✨' },
|
||||||
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
{ value: 'academic', emoji: '🎓' },
|
||||||
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
{ value: 'professional', emoji: '💼' },
|
||||||
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
{ value: 'casual', emoji: '☕' },
|
||||||
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
{ value: 'humorous', emoji: '😄' },
|
||||||
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
{ value: 'creative', emoji: '🎨' },
|
||||||
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
{ value: 'persuasive', emoji: '📣' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -35,6 +35,7 @@ interface Props {
|
|||||||
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
|
||||||
export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
||||||
|
const pk = usePack()
|
||||||
const [natural, ...tones] = REWRITE_STYLES
|
const [natural, ...tones] = REWRITE_STYLES
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,9 +65,9 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
|||||||
title="Rewrite the selection to sound more natural"
|
title="Rewrite the selection to sound more natural"
|
||||||
>
|
>
|
||||||
<span aria-hidden>{natural.emoji}</span>
|
<span aria-hidden>{natural.emoji}</span>
|
||||||
<span>{natural.zh}</span>
|
<span>{pk.styles[natural.value].native}</span>
|
||||||
<span className="font-semibold" style={{ color: 'var(--color-plum)', opacity: 0.7 }}>
|
<span className="font-semibold" style={{ color: 'var(--color-plum)', opacity: 0.7 }}>
|
||||||
{natural.en}
|
{pk.styles[natural.value].en}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
|||||||
onClick={onSpeak}
|
onClick={onSpeak}
|
||||||
className="inline-flex h-8 items-center justify-center px-2 text-sm"
|
className="inline-flex h-8 items-center justify-center px-2 text-sm"
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||||
title="朗读所选 · Read selection aloud"
|
title={pk.editor.readSelection}
|
||||||
aria-label="Read selection aloud"
|
aria-label="Read selection aloud"
|
||||||
>
|
>
|
||||||
🔊
|
🔊
|
||||||
@@ -96,10 +97,10 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
|||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
|
title={`Rewrite in a ${pk.styles[t.value].en.toLowerCase()} tone`}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{t.emoji}</span>
|
<span aria-hidden>{t.emoji}</span>
|
||||||
<span>{t.zh}</span>
|
<span>{pk.styles[t.value].native}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
// ToneSelect lets the writer set the document's target tone, which steers the
|
// ToneSelect lets the writer set the document's target tone, which steers the
|
||||||
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual
|
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual
|
||||||
// journal). A small custom dropdown (not a native <select>) so it can carry the
|
// journal). A small custom dropdown (not a native <select>) so it can carry the
|
||||||
// bilingual zh·en labels and emoji that match Petal's chrome — the writer uses
|
// bilingual labels and emoji that match Petal's chrome — the writer reads her
|
||||||
// Mandarin and English. The `value` strings mirror the backend's tone keys.
|
// own language and English. The `value` strings mirror the backend's tone keys;
|
||||||
|
// the labels themselves live in the langpack, keyed by the same value.
|
||||||
|
|
||||||
export interface ToneOption {
|
export interface ToneOption {
|
||||||
value: string
|
value: string
|
||||||
emoji: string
|
emoji: string
|
||||||
zh: string
|
|
||||||
en: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep these `value`s in sync with llm.toneGuidance on the server. 'general'
|
// Keep these `value`s in sync with llm.toneGuidance on the server. 'general'
|
||||||
// means no steering (Petal's default friendly ESL advice).
|
// means no steering (Petal's default friendly ESL advice).
|
||||||
export const TONES: ToneOption[] = [
|
export const TONES: ToneOption[] = [
|
||||||
{ value: 'general', emoji: '🌸', zh: '通用', en: 'General' },
|
{ value: 'general', emoji: '🌸' },
|
||||||
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
{ value: 'academic', emoji: '🎓' },
|
||||||
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
{ value: 'professional', emoji: '💼' },
|
||||||
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
{ value: 'casual', emoji: '☕' },
|
||||||
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
{ value: 'humorous', emoji: '😄' },
|
||||||
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
{ value: 'creative', emoji: '🎨' },
|
||||||
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
{ value: 'persuasive', emoji: '📣' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,6 +32,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToneSelect({ value, onChange }: Props) {
|
export function ToneSelect({ value, onChange }: Props) {
|
||||||
|
const pk = usePack()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||||
@@ -63,8 +65,8 @@ export function ToneSelect({ value, onChange }: Props) {
|
|||||||
title="Set the tone — Petal tailors its advice to match"
|
title="Set the tone — Petal tailors its advice to match"
|
||||||
>
|
>
|
||||||
<span aria-hidden>{current.emoji}</span>
|
<span aria-hidden>{current.emoji}</span>
|
||||||
<span>{current.zh}</span>
|
<span>{pk.tones[current.value].native}</span>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>· {current.en}</span>
|
<span style={{ color: 'var(--color-muted)' }}>· {pk.tones[current.value].en}</span>
|
||||||
<span aria-hidden style={{ color: 'var(--color-muted)' }}>
|
<span aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||||
⌄
|
⌄
|
||||||
</span>
|
</span>
|
||||||
@@ -105,9 +107,9 @@ export function ToneSelect({ value, onChange }: Props) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{t.emoji}</span>
|
<span aria-hidden>{t.emoji}</span>
|
||||||
<span>{t.zh}</span>
|
<span>{pk.tones[t.value].native}</span>
|
||||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||||
{t.en}
|
{pk.tones[t.value].en}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { WordInfo } from '../../api/client'
|
import type { WordInfo } from '../../api/client'
|
||||||
import { speak, speechSupported } from '../../audio/speech'
|
import { speak, speechSupported } from '../../audio/speech'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
||||||
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
||||||
@@ -20,6 +21,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WordCard({ word, info, loading, saved, onToggleSave, style, onReplace }: Props) {
|
export function WordCard({ word, info, loading, saved, onToggleSave, style, onReplace }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const definitions = info?.definitions ?? []
|
const definitions = info?.definitions ?? []
|
||||||
const synonyms = info?.synonyms ?? []
|
const synonyms = info?.synonyms ?? []
|
||||||
const gloss = info?.gloss ?? ''
|
const gloss = info?.gloss ?? ''
|
||||||
@@ -47,7 +49,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||||
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
|
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
词语 · Word
|
{t.editor.word}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||||
{word}
|
{word}
|
||||||
@@ -59,7 +61,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
onClick={onToggleSave}
|
onClick={onToggleSave}
|
||||||
aria-label={saved ? 'Remove from vocabulary garden' : 'Save to vocabulary garden'}
|
aria-label={saved ? 'Remove from vocabulary garden' : 'Save to vocabulary garden'}
|
||||||
aria-pressed={saved}
|
aria-pressed={saved}
|
||||||
title={saved ? '已在词汇花园 · In your garden (tap to remove)' : '加入词汇花园 · Save to garden'}
|
title={saved ? t.editor.inGarden : t.editor.saveToGarden}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm transition-transform"
|
className="flex h-7 w-7 items-center justify-center rounded-full text-sm transition-transform"
|
||||||
style={{
|
style={{
|
||||||
background: saved ? 'var(--color-accent)' : 'var(--color-surface-alt)',
|
background: saved ? 'var(--color-accent)' : 'var(--color-surface-alt)',
|
||||||
@@ -73,7 +75,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => speak(word)}
|
onClick={() => speak(word)}
|
||||||
aria-label={`Pronounce ${word}`}
|
aria-label={`Pronounce ${word}`}
|
||||||
title="朗读 · Read aloud"
|
title={t.editor.readAloud}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
@@ -111,14 +113,14 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
style={{ background: 'var(--color-accent)' }}
|
style={{ background: 'var(--color-accent)' }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
查找中… · Looking up…
|
{t.editor.lookingUp}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{definitions.length > 0 && (
|
{definitions.length > 0 && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
释义 · Definition
|
{t.editor.definition}
|
||||||
</p>
|
</p>
|
||||||
<ol className="space-y-1.5">
|
<ol className="space-y-1.5">
|
||||||
{definitions.map((m, i) => (
|
{definitions.map((m, i) => (
|
||||||
@@ -143,7 +145,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
{synonyms.length > 0 && (
|
{synonyms.length > 0 && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
近义词 · Synonyms <span className="font-normal">(点击替换 · tap to swap)</span>
|
{t.editor.synonyms} <span className="font-normal">({t.editor.tapToSwap})</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{synonyms.map((s) => (
|
{synonyms.map((s) => (
|
||||||
@@ -165,7 +167,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
|
|
||||||
{empty && (
|
{empty && (
|
||||||
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
没有找到这个词 · Nothing found for this word
|
{t.editor.nothingFound}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api, type ExportFormat } from '../../api/client'
|
import { api, type ExportFormat } from '../../api/client'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
|
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
|
||||||
// plain <a download> links to the server's export endpoint (which sets the
|
// plain <a download> links to the server's export endpoint (which sets the
|
||||||
// Content-Disposition filename, CJK and all). "Print / Save as PDF" calls the
|
// Content-Disposition filename, CJK and all). "Print / Save as PDF" calls the
|
||||||
// browser print dialog against the print stylesheet, so PDF stays CJK-safe with
|
// browser print dialog against the print stylesheet, so PDF stays CJK-safe with
|
||||||
// no server-side font embedding. Bilingual zh·en labels match Petal's chrome.
|
// no server-side font embedding. Labels come from the writer's langpack.
|
||||||
|
|
||||||
interface FormatOption {
|
interface FormatOption {
|
||||||
format: ExportFormat
|
format: ExportFormat
|
||||||
emoji: string
|
emoji: string
|
||||||
zh: string
|
|
||||||
en: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORMATS: FormatOption[] = [
|
const FORMATS: FormatOption[] = [
|
||||||
{ format: 'docx', emoji: '📄', zh: 'Word 文档', en: 'Word (.docx)' },
|
{ format: 'docx', emoji: '📄' },
|
||||||
{ format: 'md', emoji: '📝', zh: 'Markdown', en: 'Markdown (.md)' },
|
{ format: 'md', emoji: '📝' },
|
||||||
{ format: 'html', emoji: '🌐', zh: '网页', en: 'Web page (.html)' },
|
{ format: 'html', emoji: '🌐' },
|
||||||
{ format: 'txt', emoji: '🧾', zh: '纯文本', en: 'Plain text (.txt)' },
|
{ format: 'txt', emoji: '🧾' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,6 +25,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ExportMenu({ docId }: Props) {
|
export function ExportMenu({ docId }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export function ExportMenu({ docId }: Props) {
|
|||||||
title="Save or print your writing"
|
title="Save or print your writing"
|
||||||
>
|
>
|
||||||
<span aria-hidden>⬇</span>
|
<span aria-hidden>⬇</span>
|
||||||
<span>导出</span>
|
<span>{t.exports.label}</span>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
|
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -85,9 +85,9 @@ export function ExportMenu({ docId }: Props) {
|
|||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{f.emoji}</span>
|
<span aria-hidden>{f.emoji}</span>
|
||||||
<span>{f.zh}</span>
|
<span>{t.exports.formats[f.format].native}</span>
|
||||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||||
{f.en}
|
{t.exports.formats[f.format].en}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -108,7 +108,7 @@ export function ExportMenu({ docId }: Props) {
|
|||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
>
|
>
|
||||||
<span aria-hidden>🖨️</span>
|
<span aria-hidden>🖨️</span>
|
||||||
<span>打印 / PDF</span>
|
<span>{t.exports.print}</span>
|
||||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||||
Print / PDF
|
Print / PDF
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
||||||
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
|
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
|
||||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||||
|
import { usePack, type Line } from '../../i18n'
|
||||||
|
|
||||||
// GardenPanel is the vocabulary garden: every word the writer has looked up,
|
// GardenPanel is the vocabulary garden: every word the writer has looked up,
|
||||||
// grown into a blossom that opens further the more she remembers it, plus a
|
// grown into a blossom that opens further the more she remembers it, plus a
|
||||||
@@ -40,6 +41,7 @@ function blankOut(sentence: string, word: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const [words, setWords] = useState<VocabWord[] | null>(null)
|
const [words, setWords] = useState<VocabWord[] | null>(null)
|
||||||
const [due, setDue] = useState<VocabWord[]>([])
|
const [due, setDue] = useState<VocabWord[]>([])
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
@@ -137,7 +139,7 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
|||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="词汇花园 · Vocabulary Garden"
|
aria-label={t.garden.title}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="relative flex h-full w-full max-w-[420px] flex-col"
|
className="relative flex h-full w-full max-w-[420px] flex-col"
|
||||||
style={{
|
style={{
|
||||||
@@ -151,9 +153,9 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
|||||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base font-extrabold text-plum">🌷 词汇花园 · Vocabulary Garden</div>
|
<div className="text-base font-extrabold text-plum">{t.garden.titleWithFlower}</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
{queue ? '复习中 · Reviewing — recall, then grade yourself' : 'Words you looked up, blooming as you learn them'}
|
{queue ? t.garden.reviewing : t.garden.subtitle}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -222,6 +224,7 @@ function GardenView({
|
|||||||
// Index the due cards once so the per-word "due" check below is O(1), not a
|
// Index the due cards once so the per-word "due" check below is O(1), not a
|
||||||
// linear scan of `due` for every word in the garden.
|
// linear scan of `due` for every word in the garden.
|
||||||
const dueIds = useMemo(() => new Set(due.map((d) => d.id)), [due])
|
const dueIds = useMemo(() => new Set(due.map((d) => d.id)), [due])
|
||||||
|
const t = usePack()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{due.length > 0 && (
|
{due.length > 0 && (
|
||||||
@@ -234,7 +237,7 @@ function GardenView({
|
|||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
>
|
>
|
||||||
复习 {due.length} 个词 · Review {due.length} due 🌸
|
{t.garden.reviewDue(due.length)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -255,8 +258,8 @@ function GardenView({
|
|||||||
<div className="px-3 py-10 text-center" style={{ color: 'var(--color-muted)' }}>
|
<div className="px-3 py-10 text-center" style={{ color: 'var(--color-muted)' }}>
|
||||||
<div className="mb-2 text-4xl">🌱🐱💤</div>
|
<div className="mb-2 text-4xl">🌱🐱💤</div>
|
||||||
<p className="text-sm leading-relaxed">
|
<p className="text-sm leading-relaxed">
|
||||||
你的花园还空着。<br />
|
{t.garden.emptyLead}<br />
|
||||||
右键点一个英文单词查它的意思——它就会在这里发芽。
|
{t.garden.emptyHint}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs">
|
<p className="mt-2 text-xs">
|
||||||
Your garden is empty. Look up an English word (right-click it) and it’ll sprout here.
|
Your garden is empty. Look up an English word (right-click it) and it’ll sprout here.
|
||||||
@@ -302,7 +305,7 @@ function GardenView({
|
|||||||
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||||||
style={{ background: 'var(--color-accent)', color: '#fff' }}
|
style={{ background: 'var(--color-accent)', color: '#fff' }}
|
||||||
>
|
>
|
||||||
待复习 · due
|
{t.garden.due}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -316,7 +319,7 @@ function GardenView({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
<div className="text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||||
复习 {w.reps} 次 · seen {w.reps}× · 间隔 {w.interval_days}d
|
{t.garden.seen(w.reps, w.interval_days)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{speechSupported() && (
|
{speechSupported() && (
|
||||||
@@ -326,7 +329,7 @@ function GardenView({
|
|||||||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||||
style={{ background: 'var(--color-surface-alt)' }}
|
style={{ background: 'var(--color-surface-alt)' }}
|
||||||
>
|
>
|
||||||
🔊 朗读
|
{t.garden.readAloud}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{w.doc_id && onOpenDoc && (
|
{w.doc_id && onOpenDoc && (
|
||||||
@@ -336,7 +339,7 @@ function GardenView({
|
|||||||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||||
style={{ background: 'var(--color-surface-alt)' }}
|
style={{ background: 'var(--color-surface-alt)' }}
|
||||||
>
|
>
|
||||||
📄 出处 · Source
|
{t.garden.source}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -345,7 +348,7 @@ function GardenView({
|
|||||||
className="ml-auto rounded-full px-2.5 py-1 text-xs font-semibold"
|
className="ml-auto rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||||
style={{ color: 'var(--color-muted)' }}
|
style={{ color: 'var(--color-muted)' }}
|
||||||
>
|
>
|
||||||
🗑 移除
|
{t.garden.remove}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,7 +365,7 @@ function GardenView({
|
|||||||
className="shrink-0 px-4 py-2.5 text-center text-[11px]"
|
className="shrink-0 px-4 py-2.5 text-center text-[11px]"
|
||||||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||||
>
|
>
|
||||||
🐱💤 {words.length} 朵花在花园里 · {words.length} blossom{words.length > 1 ? 's' : ''} growing
|
{t.garden.growing(words.length)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -386,6 +389,7 @@ function ReviewSession({
|
|||||||
onGrade: (g: VocabGrade) => void
|
onGrade: (g: VocabGrade) => void
|
||||||
onQuit: () => void
|
onQuit: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const t = usePack()
|
||||||
const card = queue[cursor]
|
const card = queue[cursor]
|
||||||
// The meaning shown/asked is the Chinese gloss, or the English definition when
|
// The meaning shown/asked is the Chinese gloss, or the English definition when
|
||||||
// a word has no gloss — so definition-only words are still reviewable.
|
// a word has no gloss — so definition-only words are still reviewable.
|
||||||
@@ -410,7 +414,7 @@ function ReviewSession({
|
|||||||
{cursor + 1} / {queue.length}
|
{cursor + 1} / {queue.length}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={onQuit} className="font-semibold underline">
|
<button type="button" onClick={onQuit} className="font-semibold underline">
|
||||||
结束 · End
|
{t.garden.end}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -433,7 +437,7 @@ function ReviewSession({
|
|||||||
{prompt}
|
{prompt}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs" style={{ color: 'var(--color-muted)' }}>
|
<div className="mt-1 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
{production ? '这个中文意思的英文单词是?· Which English word?' : '这个词什么意思?· What does this mean?'}
|
{production ? t.garden.promptProduction : t.garden.promptRecognition}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{revealed && (
|
{revealed && (
|
||||||
@@ -492,13 +496,13 @@ function ReviewSession({
|
|||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
>
|
>
|
||||||
翻看答案 · Show answer
|
{t.garden.showAnswer}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<GradeButton color="var(--color-peach)" zh="再来" en="Again" onClick={() => onGrade('again')} />
|
<GradeButton color="var(--color-peach)" label={t.garden.gradeAgain} onClick={() => onGrade('again')} />
|
||||||
<GradeButton color="var(--color-mint)" zh="记得" en="Good" onClick={() => onGrade('good')} />
|
<GradeButton color="var(--color-mint)" label={t.garden.gradeGood} onClick={() => onGrade('good')} />
|
||||||
<GradeButton color="var(--color-honey)" zh="太简单" en="Easy" onClick={() => onGrade('easy')} />
|
<GradeButton color="var(--color-honey)" label={t.garden.gradeEasy} onClick={() => onGrade('easy')} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -506,7 +510,7 @@ function ReviewSession({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en: string; onClick: () => void }) {
|
function GradeButton({ color, label, onClick }: { color: string; label: Line; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -514,8 +518,8 @@ function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en
|
|||||||
className="flex flex-col items-center rounded-2xl py-2.5 text-plum"
|
className="flex flex-col items-center rounded-2xl py-2.5 text-plum"
|
||||||
style={{ background: color }}
|
style={{ background: color }}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-extrabold">{zh}</span>
|
<span className="text-sm font-extrabold">{label.native}</span>
|
||||||
<span className="text-[11px] font-semibold opacity-80">{en}</span>
|
<span className="text-[11px] font-semibold opacity-80">{label.en}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { api, type Document, type DocumentVersion } from '../../api/client'
|
import { api, type Document, type DocumentVersion } from '../../api/client'
|
||||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||||
|
import { usePack, type Pack } from '../../i18n'
|
||||||
|
|
||||||
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
|
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
|
||||||
// document, newest first, with a one-click preview and restore. It's the safety
|
// document, newest first, with a one-click preview and restore. It's the safety
|
||||||
@@ -16,27 +17,29 @@ interface Props {
|
|||||||
onRestored: (doc: Document) => void
|
onRestored: (doc: Document) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// kindLabel maps a snapshot kind to its bilingual badge + accent color.
|
// The accent color of each snapshot kind's badge. Its label is copy and lives in
|
||||||
const KIND: Record<DocumentVersion['kind'], { zh: string; en: string; color: string }> = {
|
// the langpack, keyed by the same kind.
|
||||||
manual: { zh: '保存点', en: 'Saved point', color: 'var(--color-accent)' },
|
const KIND_COLOR: Record<DocumentVersion['kind'], string> = {
|
||||||
auto: { zh: '自动', en: 'Auto', color: 'var(--color-muted)' },
|
manual: 'var(--color-accent)',
|
||||||
pre_restore: { zh: '恢复前', en: 'Before restore', color: 'var(--color-lavender)' },
|
auto: 'var(--color-muted)',
|
||||||
|
pre_restore: 'var(--color-lavender)',
|
||||||
}
|
}
|
||||||
|
|
||||||
// relativeTime renders a UTC timestamp as a gentle "x minutes ago" string.
|
// relativeTime renders a UTC timestamp as a gentle "x minutes ago" string.
|
||||||
function relativeTime(iso: string): string {
|
function relativeTime(iso: string, t: Pack): string {
|
||||||
const then = new Date(iso).getTime()
|
const then = new Date(iso).getTime()
|
||||||
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
||||||
if (secs < 60) return 'just now · 刚刚'
|
if (secs < 60) return t.history.justNow
|
||||||
const mins = Math.round(secs / 60)
|
const mins = Math.round(secs / 60)
|
||||||
if (mins < 60) return `${mins} min ago · ${mins} 分钟前`
|
if (mins < 60) return t.history.minutesAgo(mins)
|
||||||
const hrs = Math.round(mins / 60)
|
const hrs = Math.round(mins / 60)
|
||||||
if (hrs < 24) return `${hrs} hr ago · ${hrs} 小时前`
|
if (hrs < 24) return t.history.hoursAgo(hrs)
|
||||||
const days = Math.round(hrs / 24)
|
const days = Math.round(hrs / 24)
|
||||||
return `${days} day${days > 1 ? 's' : ''} ago · ${days} 天前`
|
return t.history.daysAgo(days)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const [versions, setVersions] = useState<DocumentVersion[] | null>(null)
|
const [versions, setVersions] = useState<DocumentVersion[] | null>(null)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
||||||
@@ -125,7 +128,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="历史 · History"
|
aria-label={t.history.title}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="relative flex h-full w-full max-w-[380px] flex-col"
|
className="relative flex h-full w-full max-w-[380px] flex-col"
|
||||||
style={{
|
style={{
|
||||||
@@ -139,7 +142,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base font-extrabold text-plum">历史 · History</div>
|
<div className="text-base font-extrabold text-plum">{t.history.title}</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
Every saved moment — nothing is ever lost 🌸
|
Every saved moment — nothing is ever lost 🌸
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +179,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-1.5">
|
<ul className="flex flex-col gap-1.5">
|
||||||
{versions.map((v) => {
|
{versions.map((v) => {
|
||||||
const k = KIND[v.kind]
|
const color = KIND_COLOR[v.kind]
|
||||||
const active = selected?.id === v.id
|
const active = selected?.id === v.id
|
||||||
return (
|
return (
|
||||||
<li key={v.id}>
|
<li key={v.id}>
|
||||||
@@ -201,13 +204,13 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
<span className="truncate text-sm font-bold text-plum">{v.title || 'Untitled'}</span>
|
<span className="truncate text-sm font-bold text-plum">{v.title || 'Untitled'}</span>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||||||
style={{ background: k.color, color: '#fff' }}
|
style={{ background: color, color: '#fff' }}
|
||||||
>
|
>
|
||||||
{k.zh} · {k.en}
|
{t.history.kinds[v.kind].native} · {t.history.kinds[v.kind].en}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
|
<div className="flex items-center justify-between gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
<span>{relativeTime(v.created_at)}</span>
|
<span>{relativeTime(v.created_at, t)}</span>
|
||||||
<span>{v.word_count} words</span>
|
<span>{v.word_count} words</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -224,7 +227,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
style={{ borderTop: '1px solid var(--color-border)', background: 'var(--color-surface-alt)' }}
|
style={{ borderTop: '1px solid var(--color-border)', background: 'var(--color-surface-alt)' }}
|
||||||
>
|
>
|
||||||
<div className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
<div className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
预览 · Preview
|
{t.history.preview}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="mb-3 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-xl px-3 py-2 text-sm"
|
className="mb-3 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-xl px-3 py-2 text-sm"
|
||||||
@@ -245,7 +248,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
>
|
>
|
||||||
{busy ? 'Restoring…' : '恢复这个版本 · Restore this version'}
|
{busy ? t.history.restoring : t.history.restoreThis}
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||||
Your current draft is saved first, so this is undoable.
|
Your current draft is saved first, so this is undoable.
|
||||||
@@ -270,7 +273,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
>
|
>
|
||||||
📜 写作证明 · Writing passport
|
{t.history.passport}
|
||||||
</a>
|
</a>
|
||||||
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||||
A report showing how this draft grew, session by session.
|
A report showing how this draft grew, session by session.
|
||||||
@@ -290,7 +293,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||||
保留完整历史 · Keep full history
|
{t.history.keepFullHistory}
|
||||||
</span>
|
</span>
|
||||||
<br />
|
<br />
|
||||||
Never delete old snapshots of this document, so the record stays complete.
|
Never delete old snapshots of this document, so the record stays complete.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { isPetalsEnabled, onPetalsEnabledChange, setPetalsEnabled } from '../../effects/petals'
|
import { isPetalsEnabled, onPetalsEnabledChange, setPetalsEnabled } from '../../effects/petals'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
// A tiny toggle for Petal's ambient falling-blossom layer, sitting just left of
|
// A tiny toggle for Petal's ambient falling-blossom layer, sitting just left of
|
||||||
// the sound toggle in the status bar. Some people find the drifting petals
|
// the sound toggle in the status bar. Some people find the drifting petals
|
||||||
// distracting, so this turns them off entirely. Bilingual tooltip (she reads
|
// distracting, so this turns them off entirely. Bilingual tooltip (she reads
|
||||||
// Mandarin first), and the choice persists across reloads.
|
// Mandarin first), and the choice persists across reloads.
|
||||||
export function PetalsToggle() {
|
export function PetalsToggle() {
|
||||||
|
const t = usePack()
|
||||||
const [on, setOn] = useState(isPetalsEnabled)
|
const [on, setOn] = useState(isPetalsEnabled)
|
||||||
|
|
||||||
// Stay in sync if the setting is flipped elsewhere.
|
// Stay in sync if the setting is flipped elsewhere.
|
||||||
@@ -22,7 +24,7 @@ export function PetalsToggle() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
title={on ? '花瓣开 · Petals on' : '花瓣关 · Petals off'}
|
title={on ? t.status.petalsOn : t.status.petalsOff}
|
||||||
aria-label={on ? 'Hide falling petals' : 'Show falling petals'}
|
aria-label={on ? 'Hide falling petals' : 'Show falling petals'}
|
||||||
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
||||||
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { isSoundEnabled, onSoundEnabledChange, playPop, setSoundEnabled } from '../../audio/sounds'
|
import { isSoundEnabled, onSoundEnabledChange, playPop, setSoundEnabled } from '../../audio/sounds'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
|
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
|
||||||
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
|
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
|
||||||
// when sounds are turned back on so the choice is audible.
|
// when sounds are turned back on so the choice is audible.
|
||||||
export function SoundToggle() {
|
export function SoundToggle() {
|
||||||
|
const t = usePack()
|
||||||
const [on, setOn] = useState(isSoundEnabled)
|
const [on, setOn] = useState(isSoundEnabled)
|
||||||
|
|
||||||
// Stay in sync if the setting is flipped elsewhere.
|
// Stay in sync if the setting is flipped elsewhere.
|
||||||
@@ -22,7 +24,7 @@ export function SoundToggle() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'}
|
title={on ? t.status.soundsOn : t.status.soundsOff}
|
||||||
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
|
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
|
||||||
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
||||||
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { computeStats, gradeBand } from './stats'
|
import { computeStats, gradeBand } from './stats'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
// StatsPanel is the popover that opens above the word count: a small grid of
|
// StatsPanel is the popover that opens above the word count: a small grid of
|
||||||
// writing statistics computed from the live document. Bilingual zh·en labels to
|
// writing statistics computed from the live document. Labels are the writer's
|
||||||
// match Petal's chrome. Reading level shows a friendly band, not just a number.
|
// pair; the reading level shows a friendly band, not just a number.
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string
|
text: string
|
||||||
@@ -11,33 +12,34 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
zh: string
|
native: string
|
||||||
en: string
|
en: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsPanel({ text, wordCount }: Props) {
|
export function StatsPanel({ text, wordCount }: Props) {
|
||||||
|
const t = usePack()
|
||||||
const rows = useMemo<Row[]>(() => {
|
const rows = useMemo<Row[]>(() => {
|
||||||
|
const L = t.status.stats
|
||||||
const s = computeStats(text, wordCount)
|
const s = computeStats(text, wordCount)
|
||||||
const band = gradeBand(s.gradeLevel)
|
const band = gradeBand(s.gradeLevel)
|
||||||
const fmt = (n: number, d = 0) =>
|
const fmt = (n: number, d = 0) =>
|
||||||
n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d })
|
n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d })
|
||||||
return [
|
return [
|
||||||
{ zh: '字数', en: 'Words', value: fmt(s.words) },
|
{ ...L.words, value: fmt(s.words) },
|
||||||
{ zh: '字符', en: 'Characters', value: fmt(s.characters) },
|
{ ...L.characters, value: fmt(s.characters) },
|
||||||
{ zh: '句子', en: 'Sentences', value: fmt(s.sentences) },
|
{ ...L.sentences, value: fmt(s.sentences) },
|
||||||
{ zh: '段落', en: 'Paragraphs', value: fmt(s.paragraphs) },
|
{ ...L.paragraphs, value: fmt(s.paragraphs) },
|
||||||
{ zh: '页数', en: 'Pages', value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
|
{ ...L.pages, value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
|
||||||
{ zh: '阅读时间', en: 'Reading time', value: readingTime(s.readingTimeMin) },
|
{ ...L.readingTime, value: readingTime(s.readingTimeMin) },
|
||||||
{ zh: '平均词长', en: 'Avg word length', value: `${fmt(s.avgWordLength, 1)}` },
|
{ ...L.avgWordLength, value: `${fmt(s.avgWordLength, 1)}` },
|
||||||
{ zh: '词汇丰富度', en: 'Word variety', value: `${fmt(s.variety * 100)}%` },
|
{ ...L.variety, value: `${fmt(s.variety * 100)}%` },
|
||||||
{
|
{
|
||||||
zh: '阅读难度',
|
...L.readability,
|
||||||
en: 'Reading level',
|
value: s.words > 0 ? `${t.status.readability[band].en} · ${fmt(s.gradeLevel, 1)}` : '—',
|
||||||
value: s.words > 0 ? `${band.en} · ${fmt(s.gradeLevel, 1)}` : '—',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [text, wordCount])
|
}, [text, wordCount, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -54,14 +56,14 @@ export function StatsPanel({ text, wordCount }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
<p className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
写作统计 · Writing stats
|
{t.status.statsTitle}
|
||||||
</p>
|
</p>
|
||||||
<dl className="space-y-1.5">
|
<dl className="space-y-1.5">
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<div key={r.en} className="flex items-baseline justify-between gap-3 text-sm">
|
<div key={r.en} className="flex items-baseline justify-between gap-3 text-sm">
|
||||||
<dt style={{ color: 'var(--color-muted)' }}>
|
<dt style={{ color: 'var(--color-muted)' }}>
|
||||||
<span className="font-semibold" style={{ color: 'var(--color-plum)' }}>
|
<span className="font-semibold" style={{ color: 'var(--color-plum)' }}>
|
||||||
{r.zh}
|
{r.native}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
{r.en}
|
{r.en}
|
||||||
</dt>
|
</dt>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
|
|||||||
import { StatsPanel } from './StatsPanel'
|
import { StatsPanel } from './StatsPanel'
|
||||||
import { PetalsToggle } from './PetalsToggle'
|
import { PetalsToggle } from './PetalsToggle'
|
||||||
import { SoundToggle } from './SoundToggle'
|
import { SoundToggle } from './SoundToggle'
|
||||||
|
import { usePack, type Pack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
wordCount: number
|
wordCount: number
|
||||||
@@ -20,7 +21,10 @@ interface Props {
|
|||||||
llmDown: boolean
|
llmDown: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
// Save-state labels. English except for the lapsed-session case, which is the
|
||||||
|
// one a writer reads with her heart in her mouth — that one comes from her pack.
|
||||||
|
const saveLabel = (status: SaveStatus, t: Pack): string =>
|
||||||
|
({
|
||||||
idle: '',
|
idle: '',
|
||||||
pending: 'Editing…',
|
pending: 'Editing…',
|
||||||
saving: 'Saving…',
|
saving: 'Saving…',
|
||||||
@@ -28,8 +32,8 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
|||||||
error: "Couldn't save",
|
error: "Couldn't save",
|
||||||
// The session lapsed. Say where the writing is, not what failed — it's safe
|
// The session lapsed. Say where the writing is, not what failed — it's safe
|
||||||
// on this device and goes up the moment she signs back in.
|
// on this device and goes up the moment she signs back in.
|
||||||
'signed-out': '已保存在本机 · Kept on this device',
|
'signed-out': t.status.savedLocally,
|
||||||
}
|
})[status]
|
||||||
|
|
||||||
// StatusBar is the slim footer: word count on the left, save state and the
|
// StatusBar is the slim footer: word count on the left, save state and the
|
||||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||||
@@ -45,7 +49,8 @@ interface Indicator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
|
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
|
||||||
const label = SAVE_LABEL[saveStatus]
|
const t = usePack()
|
||||||
|
const label = saveLabel(saveStatus, t)
|
||||||
|
|
||||||
const indicators: Indicator[] = [
|
const indicators: Indicator[] = [
|
||||||
{
|
{
|
||||||
@@ -127,8 +132,8 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, coll
|
|||||||
style={{ color: 'var(--color-honey)' }}
|
style={{ color: 'var(--color-honey)' }}
|
||||||
>
|
>
|
||||||
<span aria-hidden>🌙</span>
|
<span aria-hidden>🌙</span>
|
||||||
<span>小助手在休息</span>
|
<span>{t.status.helperRestingNative}</span>
|
||||||
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
|
<span style={{ opacity: 0.75 }}>{t.status.helperRestingEn}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -82,11 +82,14 @@ export function computeStats(text: string, wordCount: number): WritingStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// gradeBand turns a Flesch–Kincaid grade into a friendly bilingual descriptor —
|
// gradeBand turns a Flesch–Kincaid grade into a friendly band — far more useful
|
||||||
// far more useful to an ESL writer than a bare number.
|
// to an ESL writer than a bare number. It returns the band's name, not its
|
||||||
export function gradeBand(grade: number): { zh: string; en: string } {
|
// label: the wording belongs to the writer's langpack.
|
||||||
if (grade <= 5) return { zh: '简单', en: 'Easy' }
|
export type ReadabilityBand = 'easy' | 'standard' | 'fairlyHard' | 'advanced'
|
||||||
if (grade <= 8) return { zh: '标准', en: 'Standard' }
|
|
||||||
if (grade <= 12) return { zh: '偏难', en: 'Fairly hard' }
|
export function gradeBand(grade: number): ReadabilityBand {
|
||||||
return { zh: '较难', en: 'Advanced' }
|
if (grade <= 5) return 'easy'
|
||||||
|
if (grade <= 8) return 'standard'
|
||||||
|
if (grade <= 12) return 'fairlyHard'
|
||||||
|
return 'advanced'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Editor } from '@tiptap/react'
|
|||||||
import { useEditorState } from '@tiptap/react'
|
import { useEditorState } from '@tiptap/react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { uploadImageInto } from '../Editor/EditorCore'
|
import { uploadImageInto } from '../Editor/EditorCore'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
@@ -169,6 +170,7 @@ function Swatch({
|
|||||||
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
||||||
// the current selection without re-rendering the whole tree on every keystroke.
|
// the current selection without re-rendering the whole tree on every keystroke.
|
||||||
export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, collocating }: Props) {
|
export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, collocating }: Props) {
|
||||||
|
const t = usePack()
|
||||||
// Which popover (if any) is open. Only one at a time.
|
// Which popover (if any) is open. Only one at a time.
|
||||||
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
|
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
|
||||||
const [linkUrl, setLinkUrl] = useState('')
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
@@ -231,7 +233,7 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
|
|||||||
if (menu === 'outline') {
|
if (menu === 'outline') {
|
||||||
editor.state.doc.descendants((node, pos) => {
|
editor.state.doc.descendants((node, pos) => {
|
||||||
if (node.type.name === 'heading') {
|
if (node.type.name === 'heading') {
|
||||||
headings.push({ level: (node.attrs.level as number) || 1, text: node.textContent || '(无标题)', pos })
|
headings.push({ level: (node.attrs.level as number) || 1, text: node.textContent || t.toolbar.untitledHeading, pos })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -544,11 +546,11 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
|
|||||||
>
|
>
|
||||||
<div className="max-h-72 overflow-y-auto">
|
<div className="max-h-72 overflow-y-auto">
|
||||||
<p className="mb-1.5 px-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
<p className="mb-1.5 px-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
大纲 · Outline
|
{t.toolbar.outline}
|
||||||
</p>
|
</p>
|
||||||
{headings.length === 0 ? (
|
{headings.length === 0 ? (
|
||||||
<p className="px-1 py-2 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
<p className="px-1 py-2 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
用 H1/H2/H3 添加标题,这里就会出现导航。<br />
|
{t.toolbar.outlineHint}<br />
|
||||||
Add headings to navigate them here.
|
Add headings to navigate them here.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { usePack } from '../../i18n'
|
||||||
|
|
||||||
// UpdateBanner gently floats down from the top when a newer build has been
|
// UpdateBanner gently floats down from the top when a newer build has been
|
||||||
// deployed, inviting a refresh. Mandarin-first copy (north star Note #17), soft
|
// deployed, inviting a refresh. Mandarin-first copy (north star Note #17), soft
|
||||||
// rose styling, and a clear primary action. Dismissable — if she dismisses it,
|
// rose styling, and a clear primary action. Dismissable — if she dismisses it,
|
||||||
// the next deploy (or reload) will surface a fresh one.
|
// the next deploy (or reload) will surface a fresh one.
|
||||||
export function UpdateBanner() {
|
export function UpdateBanner() {
|
||||||
|
const t = usePack()
|
||||||
const [dismissed, setDismissed] = useState(false)
|
const [dismissed, setDismissed] = useState(false)
|
||||||
if (dismissed) return null
|
if (dismissed) return null
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ export function UpdateBanner() {
|
|||||||
className="truncate text-sm font-bold leading-snug"
|
className="truncate text-sm font-bold leading-snug"
|
||||||
style={{ color: 'var(--color-plum)' }}
|
style={{ color: 'var(--color-plum)' }}
|
||||||
>
|
>
|
||||||
有新版本啦
|
{t.update.available}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
<p className="truncate text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
A new version is ready — refresh to update.
|
A new version is ready — refresh to update.
|
||||||
@@ -44,13 +46,13 @@ export function UpdateBanner() {
|
|||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
>
|
>
|
||||||
刷新 · Refresh
|
{t.update.refresh}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDismissed(true)}
|
onClick={() => setDismissed(true)}
|
||||||
aria-label="稍后再说 · Dismiss"
|
aria-label={t.update.dismiss}
|
||||||
title="稍后再说 · Dismiss"
|
title={t.update.dismiss}
|
||||||
className="shrink-0 rounded-full px-1.5 text-lg leading-none transition-colors"
|
className="shrink-0 rounded-full px-1.5 text-lg leading-none transition-colors"
|
||||||
style={{ color: 'var(--color-muted)' }}
|
style={{ color: 'var(--color-muted)' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-plum)')}
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-plum)')}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { api, onUnauthorized, type Me } from '../api/client'
|
import { api, onUnauthorized, type Me } from '../api/client'
|
||||||
import { setPrefsScope } from '../lib/prefs'
|
import { setPrefsScope } from '../lib/prefs'
|
||||||
|
import { setPackLang } from '../i18n'
|
||||||
|
|
||||||
// useSession tracks who is writing, and notices the moment the server stops
|
// useSession tracks who is writing, and notices the moment the server stops
|
||||||
// recognising them.
|
// recognising them.
|
||||||
@@ -25,6 +26,10 @@ export function useSession() {
|
|||||||
// shared — and the first account on this browser inherits whatever was
|
// shared — and the first account on this browser inherits whatever was
|
||||||
// set back when Petal had no accounts at all.
|
// set back when Petal had no accounts at all.
|
||||||
setPrefsScope(user.id)
|
setPrefsScope(user.id)
|
||||||
|
// …and so does the language Petal speaks back. Until this point the app
|
||||||
|
// renders the default pack; a writer on another pair sees her own copy
|
||||||
|
// from here on, without a reload.
|
||||||
|
setPackLang(user.pair_lang)
|
||||||
setMe(user)
|
setMe(user)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
|
||||||
|
import { zh } from './packs/zh'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetPackForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pack selection', () => {
|
||||||
|
it('speaks the default pair before /api/me answers', () => {
|
||||||
|
// Modules that build copy at import time (the companion, the prose checker)
|
||||||
|
// read the pack before the session is known. That read must not be blank.
|
||||||
|
expect(pack()).toBe(zh)
|
||||||
|
expect(pack().code).toBe('zh')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back rather than blanking on a pair with no pack yet', () => {
|
||||||
|
// A pair_lang the deployment has no copy for is a deployment that got ahead
|
||||||
|
// of its translation. She should still get a working editor.
|
||||||
|
setPackLang('pt-PT')
|
||||||
|
expect(pack()).toBe(zh)
|
||||||
|
setPackLang('klingon')
|
||||||
|
expect(pack()).toBe(zh)
|
||||||
|
setPackLang('')
|
||||||
|
expect(pack()).toBe(zh)
|
||||||
|
setPackLang(null)
|
||||||
|
expect(pack()).toBe(zh)
|
||||||
|
setPackLang(undefined)
|
||||||
|
expect(pack()).toBe(zh)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only one pack ships today, so a *real* switch can't be exercised yet; what
|
||||||
|
// can be is the other half of that contract — that a no-op never wakes every
|
||||||
|
// reader in the app. The switching path gets its test when pt-PT lands.
|
||||||
|
it('never notifies readers when nothing actually changed', () => {
|
||||||
|
const seen = vi.fn()
|
||||||
|
onPackChange(seen)
|
||||||
|
|
||||||
|
setPackLang('zh') // already the current pack — nothing changed
|
||||||
|
expect(seen).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Standing in for a second pack until one ships: switching to something
|
||||||
|
// unshipped resolves back to zh, which is also not a change.
|
||||||
|
setPackLang('fr')
|
||||||
|
expect(seen).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops notifying after unsubscribe', () => {
|
||||||
|
const seen = vi.fn()
|
||||||
|
const off = onPackChange(seen)
|
||||||
|
off()
|
||||||
|
setPackLang('zh')
|
||||||
|
expect(seen).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the zh pack', () => {
|
||||||
|
// Sentinels: a handful of strings copied from the pre-Phase-19 source. The
|
||||||
|
// point of the extraction was that nothing she reads changed, and a reworded
|
||||||
|
// label would otherwise be invisible in a diff of this size.
|
||||||
|
it('carries the original copy verbatim', () => {
|
||||||
|
expect(zh.docs.searchPlaceholder).toBe('搜索 · Search')
|
||||||
|
expect(zh.editor.addToDictionary).toBe('添加到词典 · Add to dictionary')
|
||||||
|
expect(zh.status.savedLocally).toBe('已保存在本机 · Kept on this device')
|
||||||
|
expect(zh.garden.titleWithFlower).toBe('🌷 词汇花园 · Vocabulary Garden')
|
||||||
|
expect(zh.companion.greeting).toEqual({
|
||||||
|
native: '嗨~我在这儿陪你写作哦 🐱',
|
||||||
|
en: "Hi! I'm right here keeping you company. 🐱",
|
||||||
|
})
|
||||||
|
expect(zh.prose.itsOwn).toBe('“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders its interpolated lines with the value in place', () => {
|
||||||
|
expect(zh.app.duplicateTitle('Spring')).toBe('Spring (副本)')
|
||||||
|
expect(zh.companion.milestone(300).native).toBe('哇!已经 300 个词了,太厉害了 🎉')
|
||||||
|
expect(zh.prose.articleAn('apple')).toContain('“an apple”')
|
||||||
|
expect(zh.prose.uncountable('informations', 'information')).toContain('“information”')
|
||||||
|
expect(zh.garden.reviewDue(4)).toBe('复习 4 个词 · Review 4 due 🌸')
|
||||||
|
// English pluralisation is the pack's job, not the call site's.
|
||||||
|
expect(zh.garden.growing(1)).toContain('1 blossom growing')
|
||||||
|
expect(zh.garden.growing(2)).toContain('2 blossoms growing')
|
||||||
|
expect(zh.history.daysAgo(1)).toBe('1 day ago · 1 天前')
|
||||||
|
expect(zh.history.daysAgo(3)).toBe('3 days ago · 3 天前')
|
||||||
|
})
|
||||||
|
|
||||||
|
// A pack with a hole in it renders an empty label rather than failing, which
|
||||||
|
// is exactly the kind of thing that reaches production. Types catch a missing
|
||||||
|
// *key*; only this catches an empty *value*.
|
||||||
|
it('has no empty strings anywhere', () => {
|
||||||
|
const empties: string[] = []
|
||||||
|
const walk = (node: unknown, path: string) => {
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
if (node.trim() === '') empties.push(path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof node === 'function') return // exercised above
|
||||||
|
if (node && typeof node === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(zh, '')
|
||||||
|
expect(empties).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels every companion in the roster and every tone the editor offers', async () => {
|
||||||
|
const { COMPANIONS } = await import('../components/Companion/companions')
|
||||||
|
for (const c of COMPANIONS) {
|
||||||
|
expect(zh.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TONES } = await import('../components/Editor/ToneSelect')
|
||||||
|
for (const tone of TONES) {
|
||||||
|
expect(zh.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { REWRITE_STYLES } = await import('../components/Editor/SelectionBubble')
|
||||||
|
for (const style of REWRITE_STYLES) {
|
||||||
|
expect(zh.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Which langpack Petal is speaking, and how the app asks for it.
|
||||||
|
//
|
||||||
|
// The pair language is a property of the *writer* (`users.pair_lang`), so it
|
||||||
|
// isn't known until /api/me answers. That's the same timing problem prefs.ts
|
||||||
|
// solved for the mute toggle, and the answer is the same shape: a module-level
|
||||||
|
// value with a setter and a subscription, rather than blocking startup on the
|
||||||
|
// network before anything can render.
|
||||||
|
//
|
||||||
|
// The difference is what a pre-answer read should see. A preference has a right
|
||||||
|
// answer in localStorage; a langpack does not, and guessing wrong would flash
|
||||||
|
// one language and then swap it. So the default is simply zh — every writer
|
||||||
|
// today is on the zh pair, `users.pair_lang` defaults to 'zh', and a new pair
|
||||||
|
// only ever arrives with an answer from the server behind it.
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
import type { Pack, PairLang } from './types'
|
||||||
|
import { zh } from './packs/zh'
|
||||||
|
|
||||||
|
export type { Pack, PairLang, Line } from './types'
|
||||||
|
|
||||||
|
// Every pack Petal ships. pt-PT, fr and es land here in Phase 21 — adding one
|
||||||
|
// is this line plus the file, and TypeScript then names every string it owes.
|
||||||
|
const PACKS: Partial<Record<PairLang, Pack>> = { zh }
|
||||||
|
|
||||||
|
const DEFAULT_LANG: PairLang = 'zh'
|
||||||
|
|
||||||
|
type Listener = () => void
|
||||||
|
|
||||||
|
let current: Pack = zh
|
||||||
|
const listeners = new Set<Listener>()
|
||||||
|
|
||||||
|
// pack returns the langpack in force right now. For code outside React —
|
||||||
|
// modules that build a line when something happens rather than when something
|
||||||
|
// renders (the companion, the prose checker).
|
||||||
|
export function pack(): Pack {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPackLang names the pair this writer is in. Called once, from useSession,
|
||||||
|
// as soon as /api/me answers.
|
||||||
|
//
|
||||||
|
// An unknown or unshipped language falls back to the default rather than
|
||||||
|
// throwing or rendering blanks: a `pair_lang` Petal has no pack for is a
|
||||||
|
// deployment that got ahead of its copy, and the writer should still see a
|
||||||
|
// working editor in a language she may not prefer.
|
||||||
|
export function setPackLang(lang: string | undefined | null): void {
|
||||||
|
const next = PACKS[(lang || DEFAULT_LANG) as PairLang] ?? PACKS[DEFAULT_LANG] ?? zh
|
||||||
|
if (next === current) return
|
||||||
|
current = next
|
||||||
|
listeners.forEach((fn) => fn())
|
||||||
|
}
|
||||||
|
|
||||||
|
// onPackChange fires when the pair language changes. Returns an unsubscribe.
|
||||||
|
export function onPackChange(fn: Listener): () => void {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => listeners.delete(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// usePack is the React side: a component reads copy with it and re-renders if
|
||||||
|
// the pair language arrives (or changes) later.
|
||||||
|
export function usePack(): Pack {
|
||||||
|
return useSyncExternalStore(onPackChange, pack, pack)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetPackForTests puts the module back to its initial state. Tests only.
|
||||||
|
export function resetPackForTests(): void {
|
||||||
|
current = zh
|
||||||
|
listeners.clear()
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// The Mandarin pack — Petal's original copy, moved here unchanged.
|
||||||
|
//
|
||||||
|
// Every string in this file was a literal somewhere in the app before Phase 19.
|
||||||
|
// It is deliberately verbatim, down to the punctuation: the zh pair is in daily
|
||||||
|
// use, and a langpack extraction that quietly reworded anything would be a
|
||||||
|
// product change wearing a refactor's clothes.
|
||||||
|
//
|
||||||
|
// Mandarin first, English underneath — she reads Chinese faster, and the
|
||||||
|
// English subtitle is what she's here to learn.
|
||||||
|
|
||||||
|
import type { Pack } from '../types'
|
||||||
|
|
||||||
|
export const zh: Pack = {
|
||||||
|
code: 'zh',
|
||||||
|
nativeName: '中文',
|
||||||
|
|
||||||
|
app: {
|
||||||
|
duplicateTitle: (title) => `${title} (副本)`,
|
||||||
|
garden: '词汇花园',
|
||||||
|
history: '历史',
|
||||||
|
},
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
title: '请重新登录',
|
||||||
|
titleEn: 'Please sign in again',
|
||||||
|
bodyWithDraft: '你刚写的内容已经安全地留在这台电脑上,登录后会自动接着保存。',
|
||||||
|
bodyWithDraftEn: "What you just wrote is safe on this device — it'll save itself once you're back in.",
|
||||||
|
bodyPlain: '登录状态过期了。你的文字都已经保存好了。',
|
||||||
|
bodyPlainEn: 'Your session expired. Everything you wrote is already saved.',
|
||||||
|
signIn: '去登录 · Sign in',
|
||||||
|
},
|
||||||
|
|
||||||
|
companion: {
|
||||||
|
choose: '选个小伙伴 · Choose a companion',
|
||||||
|
|
||||||
|
// Played when a suggestion is accepted or a milestone hits — pure warmth.
|
||||||
|
encouragements: [
|
||||||
|
{ native: '好棒!这一句更顺了 🌸', en: 'Lovely — that reads so much smoother now.' },
|
||||||
|
{ native: '你写得越来越好了 ✨', en: "You're getting better and better." },
|
||||||
|
{ native: '我很喜欢这个改法 💕', en: 'I really like that change.' },
|
||||||
|
{ native: '继续保持,加油!', en: 'Keep going — you’ve got this!' },
|
||||||
|
{ native: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
||||||
|
{ native: '这个词用得真好 🌷', en: 'That’s such a good word choice.' },
|
||||||
|
{ native: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' },
|
||||||
|
{ native: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' },
|
||||||
|
{ native: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' },
|
||||||
|
{ native: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
|
||||||
|
// checker finds nothing concrete to point at in her current text.
|
||||||
|
tips: [
|
||||||
|
{ native: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
|
||||||
|
{ native: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
|
||||||
|
{ native: '过去的事情用过去式:go → went。', en: 'For the past, use past tense: go → went.' },
|
||||||
|
{ native: '读出声音,能帮你发现奇怪的地方。', en: 'Reading aloud helps you catch awkward spots.' },
|
||||||
|
{ native: '一个段落讲一个想法就好。', en: 'One idea per paragraph keeps it tidy.' },
|
||||||
|
{ native: '不确定的地方,问问我就好啦 ✨', en: 'Not sure about something? Just ask me. ✨' },
|
||||||
|
{ native: '复数别忘了加 s:two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Shown after a long stretch of continuous writing.
|
||||||
|
breaks: [
|
||||||
|
{ native: '写了好一会儿啦,起来走走,让眼睛休息一下 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
|
||||||
|
{ native: '喝口水,休息五分钟好不好?', en: 'Sip some water and take five?' },
|
||||||
|
{ native: '看看远方,放松一下眼睛 🌿', 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. The native line stays gentle; English carries the wink.
|
||||||
|
bedtime: [
|
||||||
|
{ native: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||||
|
{ native: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||||
|
{ native: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
|
||||||
|
{ native: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||||
|
// A few old Chinese proverbs on sleep (the native line is a faithful
|
||||||
|
// rendering of the English sense — the verified classical 原文 isn't
|
||||||
|
// recoverable; swap in the exact source text if you have it). They read a
|
||||||
|
// touch wittier/wiser than the gentle lines above, which suits a
|
||||||
|
// late-night nudge.
|
||||||
|
{ native: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." },
|
||||||
|
{ native: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', en: 'Think of your own faults the first part of the night when you are awake, and of the faults of others the latter part of the night when you are asleep.' },
|
||||||
|
{ native: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
greeting: { native: '嗨~我在这儿陪你写作哦 🐱', en: "Hi! I'm right here keeping you company. 🐱" },
|
||||||
|
welcomeBack: { native: '欢迎回来 ✨ 我们继续吧!', en: 'Welcome back ✨ let’s keep going!' },
|
||||||
|
|
||||||
|
errors: [
|
||||||
|
{ native: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
|
||||||
|
{ native: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
||||||
|
{ native: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
||||||
|
],
|
||||||
|
|
||||||
|
milestone: (words: number) => ({
|
||||||
|
native: `哇!已经 ${words} 个词了,太厉害了 🎉`,
|
||||||
|
en: `Wow — ${words} words already! Amazing. 🎉`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Keyed by the companion ids in Companion/companions.ts.
|
||||||
|
names: {
|
||||||
|
cat: '瞌睡猫',
|
||||||
|
dog: '开心狗',
|
||||||
|
'wiggle-dog': '摇尾狗',
|
||||||
|
butterfly: '蝴蝶',
|
||||||
|
parrot: '鹦鹉',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
prose: {
|
||||||
|
longSentence: '这句话有点长啦,分成两三句会更清楚 🌸',
|
||||||
|
commaSplice: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
|
||||||
|
vagueThis: (word) => `“${word}” 指代不太清楚,最好点明它指的是什么(比如 “${word} idea / change…”)。`,
|
||||||
|
oxfordComma: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
|
||||||
|
transitionComma: (word) => `开头的过渡词后面加个逗号:“${word}, …”。`,
|
||||||
|
capitalizeSentence: '每个句子的开头,用大写字母开始吧。',
|
||||||
|
repeatedWord: (word) => `“${word}” 好像写了两遍,检查一下哦。`,
|
||||||
|
capitalizeI: '英文里的 “I”(我)任何时候都要大写哦。',
|
||||||
|
spaceBeforePunct: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||||||
|
spaceAfterPunct: '逗号、句号后面要空一格,再接下一个词。',
|
||||||
|
articleAn: (word) => `元音开头的词前用 “an”:“an ${word}”。`,
|
||||||
|
articleA: (word) => `辅音开头的词前用 “a”:“a ${word}”。`,
|
||||||
|
uncountable: (word, singular) => `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||||||
|
capitalizeProper: (fixed) => `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
||||||
|
thirdPersonS: (subject, verb) => `主语是 he/she/it 时,动词要加 -s:“${subject} ${verb}”。`,
|
||||||
|
pluralAfter: (determiner, noun) => `“${determiner}” 后面的名词要用复数:“${determiner} ${noun}s”。`,
|
||||||
|
doubleDeterminer: (first, second) => `“${first} ${second}” 用了两个限定词,留一个就好(比如去掉 “${first}”)。`,
|
||||||
|
thereArePlural: (noun) => `后面是复数时用 “there are”:“there are ${noun}…”。`,
|
||||||
|
itsOwn: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||||||
|
itsIs: (rest) => `这里应该是 “it’s ${rest}”(it is),“its” 是“它的”。`,
|
||||||
|
thanNotThen: (word) => `比较的时候用 “than”,不是 “then”:“${word} than”。`,
|
||||||
|
},
|
||||||
|
|
||||||
|
docs: {
|
||||||
|
sortRecent: '最近 · Recent',
|
||||||
|
sortTitle: '标题 · Title',
|
||||||
|
sortLongest: '字数 · Longest',
|
||||||
|
backUpAll: '备份 · Back up all:',
|
||||||
|
signOut: '退出 · Sign out',
|
||||||
|
duplicate: '副本 · Duplicate',
|
||||||
|
searchPlaceholder: '搜索 · Search',
|
||||||
|
searching: '查找中… · Searching…',
|
||||||
|
noMatches: '没有找到 · No matches',
|
||||||
|
tags: '标签 · Tags',
|
||||||
|
newTagPlaceholder: '新标签 · New tag',
|
||||||
|
},
|
||||||
|
|
||||||
|
editor: {
|
||||||
|
askPlaceholder: 'Ask why… / 问为什么…',
|
||||||
|
findPlaceholder: '查找 · Find',
|
||||||
|
findNone: '无 · 0',
|
||||||
|
matchCase: 'Match case · 区分大小写',
|
||||||
|
close: 'Close · 关闭',
|
||||||
|
replacePlaceholder: '替换为 · Replace',
|
||||||
|
replace: '替换',
|
||||||
|
replaceAll: '全部',
|
||||||
|
spelling: '拼写 · Spelling',
|
||||||
|
noSuggestions: '没有建议 · No suggestions',
|
||||||
|
addToDictionary: '添加到词典 · Add to dictionary',
|
||||||
|
readSelection: '朗读所选 · Read selection aloud',
|
||||||
|
rewrite: '改写 · Rewrite',
|
||||||
|
rewriting: '改写中… · Rewriting…',
|
||||||
|
rewriteFailed: '改写失败,请再试一次 · Couldn’t rewrite — try again',
|
||||||
|
cancel: '取消 · Cancel',
|
||||||
|
retry: '重试 · Retry',
|
||||||
|
useThis: '用这个 · Use this',
|
||||||
|
word: '词语 · Word',
|
||||||
|
inGarden: '已在词汇花园 · In your garden (tap to remove)',
|
||||||
|
saveToGarden: '加入词汇花园 · Save to garden',
|
||||||
|
readAloud: '朗读 · Read aloud',
|
||||||
|
lookingUp: '查找中… · Looking up…',
|
||||||
|
definition: '释义 · Definition',
|
||||||
|
synonyms: '近义词 · Synonyms',
|
||||||
|
tapToSwap: '点击替换 · tap to swap',
|
||||||
|
nothingFound: '没有找到这个词 · Nothing found for this word',
|
||||||
|
},
|
||||||
|
|
||||||
|
styles: {
|
||||||
|
natural: { native: '更自然', en: 'Natural' },
|
||||||
|
academic: { native: '学术', en: 'Academic' },
|
||||||
|
professional: { native: '专业', en: 'Professional' },
|
||||||
|
casual: { native: '轻松', en: 'Casual' },
|
||||||
|
humorous: { native: '幽默', en: 'Humorous' },
|
||||||
|
creative: { native: '创意', en: 'Creative' },
|
||||||
|
persuasive: { native: '说服', en: 'Persuasive' },
|
||||||
|
},
|
||||||
|
|
||||||
|
tones: {
|
||||||
|
general: { native: '通用', en: 'General' },
|
||||||
|
academic: { native: '学术', en: 'Academic' },
|
||||||
|
professional: { native: '专业', en: 'Professional' },
|
||||||
|
casual: { native: '轻松', en: 'Casual' },
|
||||||
|
humorous: { native: '幽默', en: 'Humorous' },
|
||||||
|
creative: { native: '创意', en: 'Creative' },
|
||||||
|
persuasive: { native: '说服', en: 'Persuasive' },
|
||||||
|
},
|
||||||
|
|
||||||
|
exports: {
|
||||||
|
label: '导出',
|
||||||
|
print: '打印 / PDF',
|
||||||
|
formats: {
|
||||||
|
md: { native: 'Markdown', en: 'Markdown (.md)' },
|
||||||
|
docx: { native: 'Word 文档', en: 'Word (.docx)' },
|
||||||
|
html: { native: '网页', en: 'Web page (.html)' },
|
||||||
|
txt: { native: '纯文本', en: 'Plain text (.txt)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
garden: {
|
||||||
|
title: '词汇花园 · Vocabulary Garden',
|
||||||
|
titleWithFlower: '🌷 词汇花园 · Vocabulary Garden',
|
||||||
|
reviewing: '复习中 · Reviewing — recall, then grade yourself',
|
||||||
|
subtitle: 'Words you looked up, blooming as you learn them',
|
||||||
|
reviewDue: (n) => `复习 ${n} 个词 · Review ${n} due 🌸`,
|
||||||
|
emptyLead: '你的花园还空着。',
|
||||||
|
emptyHint: '右键点一个英文单词查它的意思——它就会在这里发芽。',
|
||||||
|
due: '待复习 · due',
|
||||||
|
seen: (reps, intervalDays) => `复习 ${reps} 次 · seen ${reps}× · 间隔 ${intervalDays}d`,
|
||||||
|
readAloud: '🔊 朗读',
|
||||||
|
source: '📄 出处 · Source',
|
||||||
|
remove: '🗑 移除',
|
||||||
|
growing: (n) => `🐱💤 ${n} 朵花在花园里 · ${n} blossom${n > 1 ? 's' : ''} growing`,
|
||||||
|
end: '结束 · End',
|
||||||
|
promptProduction: '这个中文意思的英文单词是?· Which English word?',
|
||||||
|
promptRecognition: '这个词什么意思?· What does this mean?',
|
||||||
|
showAnswer: '翻看答案 · Show answer',
|
||||||
|
gradeAgain: { native: '再来', en: 'Again' },
|
||||||
|
gradeGood: { native: '记得', en: 'Good' },
|
||||||
|
gradeEasy: { native: '太简单', en: 'Easy' },
|
||||||
|
},
|
||||||
|
|
||||||
|
history: {
|
||||||
|
title: '历史 · History',
|
||||||
|
kinds: {
|
||||||
|
manual: { native: '保存点', en: 'Saved point' },
|
||||||
|
auto: { native: '自动', en: 'Auto' },
|
||||||
|
pre_restore: { native: '恢复前', en: 'Before restore' },
|
||||||
|
},
|
||||||
|
justNow: 'just now · 刚刚',
|
||||||
|
minutesAgo: (n) => `${n} min ago · ${n} 分钟前`,
|
||||||
|
hoursAgo: (n) => `${n} hr ago · ${n} 小时前`,
|
||||||
|
daysAgo: (n) => `${n} day${n > 1 ? 's' : ''} ago · ${n} 天前`,
|
||||||
|
preview: '预览 · Preview',
|
||||||
|
restoring: 'Restoring…',
|
||||||
|
restoreThis: '恢复这个版本 · Restore this version',
|
||||||
|
passport: '📜 写作证明 · Writing passport',
|
||||||
|
keepFullHistory: '保留完整历史 · Keep full history',
|
||||||
|
},
|
||||||
|
|
||||||
|
status: {
|
||||||
|
savedLocally: '已保存在本机 · Kept on this device',
|
||||||
|
helperRestingNative: '小助手在休息',
|
||||||
|
helperRestingEn: "· Petal's helper is resting · 文字已保存",
|
||||||
|
soundsOn: '声音开 · Sounds on',
|
||||||
|
soundsOff: '声音关 · Sounds off',
|
||||||
|
petalsOn: '花瓣开 · Petals on',
|
||||||
|
petalsOff: '花瓣关 · Petals off',
|
||||||
|
statsTitle: '写作统计 · Writing stats',
|
||||||
|
stats: {
|
||||||
|
words: { native: '字数', en: 'Words' },
|
||||||
|
characters: { native: '字符', en: 'Characters' },
|
||||||
|
sentences: { native: '句子', en: 'Sentences' },
|
||||||
|
paragraphs: { native: '段落', en: 'Paragraphs' },
|
||||||
|
pages: { native: '页数', en: 'Pages' },
|
||||||
|
readingTime: { native: '阅读时间', en: 'Reading time' },
|
||||||
|
avgWordLength: { native: '平均词长', en: 'Avg word length' },
|
||||||
|
variety: { native: '词汇丰富度', en: 'Word variety' },
|
||||||
|
readability: { native: '阅读难度', en: 'Reading level' },
|
||||||
|
},
|
||||||
|
readability: {
|
||||||
|
easy: { native: '简单', en: 'Easy' },
|
||||||
|
standard: { native: '标准', en: 'Standard' },
|
||||||
|
fairlyHard: { native: '偏难', en: 'Fairly hard' },
|
||||||
|
advanced: { native: '较难', en: 'Advanced' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
toolbar: {
|
||||||
|
untitledHeading: '(无标题)',
|
||||||
|
outline: '大纲 · Outline',
|
||||||
|
outlineHint: '用 H1/H2/H3 添加标题,这里就会出现导航。',
|
||||||
|
},
|
||||||
|
|
||||||
|
update: {
|
||||||
|
available: '有新版本啦',
|
||||||
|
refresh: '刷新 · Refresh',
|
||||||
|
dismiss: '稍后再说 · Dismiss',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// The shape of a langpack.
|
||||||
|
//
|
||||||
|
// Petal gives every writer one (English + X) pair: she reads and writes in
|
||||||
|
// English, and Petal explains itself in both. Until now X was hardcoded as
|
||||||
|
// Mandarin — every label in the app carried its own `中文 · English` string
|
||||||
|
// literal. A pack is that copy lifted out and keyed by X, so adding pt-PT is
|
||||||
|
// writing one file rather than re-editing twenty-nine.
|
||||||
|
//
|
||||||
|
// Two conventions worth keeping when a new pack is written:
|
||||||
|
//
|
||||||
|
// * `native` is the writer's own language, `en` is English. Where a label
|
||||||
|
// shows both in one line the pack holds the *whole* rendered string
|
||||||
|
// (`'搜索 · Search'`), not the two halves — order and separator are a
|
||||||
|
// typographic choice each language gets to make.
|
||||||
|
// * Anything with a value in it is a function, not a template assembled at
|
||||||
|
// the call site. Word order is not universal, and a pack author must be
|
||||||
|
// able to move the number.
|
||||||
|
|
||||||
|
// PairLang is the X in the (English + X) pair. It matches `users.pair_lang`.
|
||||||
|
export type PairLang = 'zh' | 'pt-PT' | 'fr' | 'es'
|
||||||
|
|
||||||
|
// Line is a two-line piece of copy: the writer's language on top, English
|
||||||
|
// underneath. The companion bubble and the small pill labels render both.
|
||||||
|
export interface Line {
|
||||||
|
native: string
|
||||||
|
en: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pack {
|
||||||
|
// code identifies the pack; `nativeName` is how the language names itself,
|
||||||
|
// for anywhere Petal has to say which pair this is.
|
||||||
|
code: PairLang
|
||||||
|
nativeName: string
|
||||||
|
|
||||||
|
app: {
|
||||||
|
// A duplicated document's title. A function, not a suffix: where the marker
|
||||||
|
// goes is the pack's business.
|
||||||
|
duplicateTitle: (title: string) => string
|
||||||
|
garden: string
|
||||||
|
history: string
|
||||||
|
}
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
title: string
|
||||||
|
titleEn: string
|
||||||
|
bodyWithDraft: string
|
||||||
|
bodyWithDraftEn: string
|
||||||
|
bodyPlain: string
|
||||||
|
bodyPlainEn: string
|
||||||
|
signIn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
companion: {
|
||||||
|
choose: string
|
||||||
|
encouragements: Line[]
|
||||||
|
tips: Line[]
|
||||||
|
breaks: Line[]
|
||||||
|
bedtime: Line[]
|
||||||
|
greeting: Line
|
||||||
|
welcomeBack: Line
|
||||||
|
errors: Line[]
|
||||||
|
milestone: (words: number) => Line
|
||||||
|
// Mascot names, keyed by the companion ids in companions.ts.
|
||||||
|
names: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule-based prose notes (see Companion/prose.ts). Detection is English
|
||||||
|
// grammar and stays in the checker; only the explanation belongs to the pair.
|
||||||
|
prose: {
|
||||||
|
longSentence: string
|
||||||
|
commaSplice: string
|
||||||
|
vagueThis: (word: string) => string
|
||||||
|
oxfordComma: string
|
||||||
|
transitionComma: (word: string) => string
|
||||||
|
capitalizeSentence: string
|
||||||
|
repeatedWord: (word: string) => string
|
||||||
|
capitalizeI: string
|
||||||
|
spaceBeforePunct: string
|
||||||
|
spaceAfterPunct: string
|
||||||
|
articleAn: (word: string) => string
|
||||||
|
articleA: (word: string) => string
|
||||||
|
uncountable: (word: string, singular: string) => string
|
||||||
|
capitalizeProper: (fixed: string) => string
|
||||||
|
thirdPersonS: (subject: string, verb: string) => string
|
||||||
|
pluralAfter: (determiner: string, noun: string) => string
|
||||||
|
doubleDeterminer: (first: string, second: string) => string
|
||||||
|
thereArePlural: (noun: string) => string
|
||||||
|
itsOwn: string
|
||||||
|
itsIs: (rest: string) => string
|
||||||
|
thanNotThen: (word: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
docs: {
|
||||||
|
sortRecent: string
|
||||||
|
sortTitle: string
|
||||||
|
sortLongest: string
|
||||||
|
backUpAll: string
|
||||||
|
signOut: string
|
||||||
|
duplicate: string
|
||||||
|
searchPlaceholder: string
|
||||||
|
searching: string
|
||||||
|
noMatches: string
|
||||||
|
tags: string
|
||||||
|
newTagPlaceholder: string
|
||||||
|
}
|
||||||
|
|
||||||
|
editor: {
|
||||||
|
askPlaceholder: string
|
||||||
|
findPlaceholder: string
|
||||||
|
findNone: string
|
||||||
|
matchCase: string
|
||||||
|
close: string
|
||||||
|
replacePlaceholder: string
|
||||||
|
replace: string
|
||||||
|
replaceAll: string
|
||||||
|
spelling: string
|
||||||
|
noSuggestions: string
|
||||||
|
addToDictionary: string
|
||||||
|
readSelection: string
|
||||||
|
// The rewrite preview.
|
||||||
|
rewrite: string
|
||||||
|
rewriting: string
|
||||||
|
rewriteFailed: string
|
||||||
|
cancel: string
|
||||||
|
retry: string
|
||||||
|
useThis: string
|
||||||
|
// Word card.
|
||||||
|
word: string
|
||||||
|
inGarden: string
|
||||||
|
saveToGarden: string
|
||||||
|
readAloud: string
|
||||||
|
lookingUp: string
|
||||||
|
definition: string
|
||||||
|
synonyms: string
|
||||||
|
tapToSwap: string
|
||||||
|
nothingFound: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite styles and document tones share a vocabulary; both are Line-labelled
|
||||||
|
// pills, keyed by the style/tone value.
|
||||||
|
styles: Record<string, Line>
|
||||||
|
tones: Record<string, Line>
|
||||||
|
|
||||||
|
exports: {
|
||||||
|
label: string
|
||||||
|
print: string
|
||||||
|
formats: Record<string, Line> // keyed by export format
|
||||||
|
}
|
||||||
|
|
||||||
|
garden: {
|
||||||
|
title: string
|
||||||
|
titleWithFlower: string
|
||||||
|
reviewing: string
|
||||||
|
subtitle: string
|
||||||
|
reviewDue: (n: number) => string
|
||||||
|
emptyLead: string
|
||||||
|
emptyHint: string
|
||||||
|
due: string
|
||||||
|
seen: (reps: number, intervalDays: number) => string
|
||||||
|
readAloud: string
|
||||||
|
source: string
|
||||||
|
remove: string
|
||||||
|
growing: (n: number) => string
|
||||||
|
end: string
|
||||||
|
promptProduction: string
|
||||||
|
promptRecognition: string
|
||||||
|
showAnswer: string
|
||||||
|
gradeAgain: Line
|
||||||
|
gradeGood: Line
|
||||||
|
gradeEasy: Line
|
||||||
|
}
|
||||||
|
|
||||||
|
history: {
|
||||||
|
title: string
|
||||||
|
kinds: Record<string, Line> // manual | auto | pre_restore
|
||||||
|
justNow: string
|
||||||
|
minutesAgo: (n: number) => string
|
||||||
|
hoursAgo: (n: number) => string
|
||||||
|
daysAgo: (n: number) => string
|
||||||
|
preview: string
|
||||||
|
restoring: string
|
||||||
|
restoreThis: string
|
||||||
|
passport: string
|
||||||
|
keepFullHistory: string
|
||||||
|
}
|
||||||
|
|
||||||
|
status: {
|
||||||
|
savedLocally: string
|
||||||
|
helperRestingNative: string
|
||||||
|
helperRestingEn: string
|
||||||
|
soundsOn: string
|
||||||
|
soundsOff: string
|
||||||
|
petalsOn: string
|
||||||
|
petalsOff: string
|
||||||
|
statsTitle: string
|
||||||
|
stats: Record<string, Line> // stat labels, keyed by the stat id
|
||||||
|
readability: {
|
||||||
|
easy: Line
|
||||||
|
standard: Line
|
||||||
|
fairlyHard: Line
|
||||||
|
advanced: Line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbar: {
|
||||||
|
untitledHeading: string
|
||||||
|
outline: string
|
||||||
|
outlineHint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
update: {
|
||||||
|
available: string
|
||||||
|
refresh: string
|
||||||
|
dismiss: string
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user