From 6a4b85f6097a0699bb4f13675c166d9e9dcad4fa Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 00:31:16 -0700 Subject: [PATCH 1/7] docs(i18n): ratify option-chip display labels in glossary + policy matrix (U1) Token-keyed renderings for font/castMethod/castMode/taijitu/anim chips (zh-Hant + zh-Hans authored explicitly). Theme labels recorded as deferred pending brand-vs-literal ruling. Input-alias policy: none. New surface row term-settings-option-chips + matching policy-matrix entry. --- docs/language-glossary.md | 34 +++ ...settings-option-label-localization-plan.md | 253 ++++++++++++++++++ tests/fixtures/language/TEXT_SURFACES.md | 29 +- 3 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-06-10-001-feat-settings-option-label-localization-plan.md diff --git a/docs/language-glossary.md b/docs/language-glossary.md index b84cde7..781493e 100644 --- a/docs/language-glossary.md +++ b/docs/language-glossary.md @@ -107,6 +107,40 @@ and Unicode hexagram/trigram/taijitu symbols. Localized **display labels** for e (e.g. a Chinese chip for `coin`) are a SEPARATE catalog layer that never mutates the stored token. JSON output stays locale-neutral (stable keys; all five commentary styles emitted). +## Settings option-chip display labels (separate catalog layer) + +The display-label layer sanctioned above, ratified per token. Lives in +`packages/terminal/src/i18n/option-labels.ts`; consumed only by the Settings render +path. The table is **token-keyed** — renderings attach to tokens, never to list +positions. zh-Hans labels are authored explicitly in the same literary register, +never derived via `toSimplified()`. + +**Input-alias policy: none.** These labels are display-only. `config set` and +hand-edited config files accept canonical tokens only; `LANGUAGE_ALIASES` is a +language-row-specific bridge, not a precedent to extend. Revisit only on observed +user friction, as a designed opt-in per enum. + +| Token (key) | Setting | EN chip | 繁 | 简 | Class | +| --- | --- | --- | --- | --- | --- | +| `kaiti` | font | kaiti | 楷體 | 楷体 | literal — token is the pinyin of the label | +| `libian` | font | libian | 隸變 | 隶变 | literal — pinyin pair | +| `heiti` | font | heiti | 黑體 | 黑体 | literal — pinyin pair | +| `coin` | castMethod | coin | 銅錢 (coin) | 铜钱 (coin) | domain term — canonical hint kept in label (no transliteration bridge to the CLI token) | +| `yarrow` | castMethod | yarrow | 蓍草 (yarrow) | 蓍草 (yarrow) | domain term — canonical hint kept in label | +| `auto` | castMode | auto | 自動 | 自动 | literal | +| `manual` | castMode | manual | 手動 | 手动 | literal | +| `dots` | taijitu + glyphAnim | dots | 點陣 | 点阵 | literal — ratified ONCE, cross-referenced to BOTH `taijitu.dots` and `anim.dots` | +| `dense` | taijitu | dense | 密實 | 密实 | literal | +| `noise` | glyphAnim | noise | 噪點 | 噪点 | literal — 噪點 = `noise`, NOT `dots` (transposition hazard) | +| `radial` | glyphAnim | radial | 放射 | 放射 | literal | +| `sand` | glyphAnim | sand | 沙化 | 沙化 | literal | + +**Theme chips (`ink`/`bone`/`cinnabar`/`jade`/`river`): labels deferred.** Whether the +names are literal material/color words (墨/骨/硃砂/玉/河 would follow) or semi-branded +palette names is an unresolved semantics question; until this glossary records that +ruling, theme chips render the canonical tokens verbatim. The candidates here are +noted for the future pass, **not ratified**. + ## The "Wilhelm" label (attribution) The `w` field and the detail-section header are **Wilhelm-inspired / interpretive diff --git a/docs/plans/2026-06-10-001-feat-settings-option-label-localization-plan.md b/docs/plans/2026-06-10-001-feat-settings-option-label-localization-plan.md new file mode 100644 index 0000000..b9a693f --- /dev/null +++ b/docs/plans/2026-06-10-001-feat-settings-option-label-localization-plan.md @@ -0,0 +1,253 @@ +--- +title: Settings Option-Label Localization +type: feat +status: active +date: 2026-06-10 +origin: PR #5 code-review session — settings-chips analysis (3-analyst workflow) + GPT-Pro second opinion; chat blueprint approved with U2 amendment +--- + +# Settings Option-Label Localization + +Follow-up to PR #5 (`feat: add language-aware detail view`). Localizes the Settings +scene's option-value chips through a display-label catalog layer while keeping stored +enum tokens, CLI stdout, and the config parse surface untouched. Implements the policy +already recorded in `docs/language-glossary.md:106-107`: localized display labels for +enum values "are a SEPARATE catalog layer that never mutates the stored token." + +## Requirements + +- **R1** — zh-Hant/zh-Hans Settings render localized display labels for font, cast + method, cast mode, taijitu, and glyph-animation chips; `en` rendering unchanged. +- **R2** — Persisted config tokens, CLI stdout, and hand-edit acceptance unchanged; + **no new config aliases**. +- **R3** — Labels resolve from the *live* language selection (mid-screen flip updates + same frame, like the title already does). +- **R4** — Missing label entry falls back to the canonical token: never crash, never + persist a label. +- **R5** — Theme chips stay verbatim until the glossary records what theme names *are* + (literal words vs branded palette names). +- **R6** — Policy artifacts (glossary, TEXT_SURFACES matrix, pinned tests, verifier) + re-pinned to say "stored tokens canonical; labels via catalog" — not "chips are + English." + +## Architecture Decision + +**Approach:** A sibling option-label catalog module (`option-labels.ts`) with its own +typed accessor `optionLabel(lang, settingKey, token)` — *not* routed through +`tr()`/`MESSAGES` — plus a value-bound refactor of `SettingRow` so labels are derived +at render time from canonical tokens, and `getValues()` reads by **row identity** +rather than row position. + +**Rationale (consistency + integration shape):** The catalog *shape* mirrors +`messages.ts` (`{en, zhHant, zhHans}` records, zh-Hans authored explicitly per the +file's stated convention). But the integration-shape check fails for reusing `tr()` +directly: `tr(lang, "option.castMethod." + token)` does not typecheck — `MessageKey` +is a literal union (`packages/terminal/src/i18n/messages.ts:107`), and widening it to +template-literal keys would break the exhaustive catalog contract. The bridge is +therefore a named module, not a footnote. Fallback semantics (token when no entry) +also belong in the accessor, which `tr()` deliberately lacks. + +**Two label mechanisms coexist, on purpose:** `LANGUAGE_LABELS` +(`settings-scene.ts:27-31`) stays untouched — language chips are *endonyms*, +deliberately invariant across display languages, and they're pinned by high-risk +fixture rows, the `"[EN] 繁 简"` test assertion, and verifier sentinels requiring +those literals in `settings-scene.ts`. `OPTION_LABELS` is the new, +per-display-language layer for the other rows. Semantically different things; don't +unify them. + +**Trade-offs accepted:** TUI↔CLI vocabulary divergence (銅錢 vs `coin`) mitigated by +canonical hints in the label strings themselves, not by parser widening. Theme row +stays mixed-register until the glossary decision — a documented gap, not an accident. + +## Implementation Units + +### U1. Glossary + policy-matrix groundwork + +- **Goal:** Approved zh-Hant/zh-Hans renderings for every chip token entering the + catalog; theme-name semantics decision recorded; input-alias policy recorded as + **none**. +- **Requirements:** R5, R6 +- **Dependencies:** None +- **Files:** + - Modify: `docs/language-glossary.md` + - Modify: `tests/fixtures/language/TEXT_SURFACES.md` (rows `term-theme-names`, + `term-settings-row-labels`; add/update rows for font/castMethod/castMode/ + taijitu/anim chips) +- **Approach:** Glossary table is **token-keyed from the start** (token → en · + zh-Hant · zh-Hans · literal-vs-brand · accepted-as-input: no) so ordering mistakes + cannot survive into code. Candidate renderings to ratify or replace: font 楷體/隸變/ + 黑體; castMethod 銅錢 (coin)/蓍草 (yarrow); castMode 自動/手動; taijitu dots→點陣, + dense→密實; anim noise→噪點, dots→點陣, radial→放射, sand→沙化. **Note the + cross-token hazard:** 噪點 = `noise`, 點陣 = `dots` — and 點陣 is used by BOTH + `taijitu.dots` and `anim.dots`; ratify the rendering once with both usages + cross-referenced. Theme row: record "semantics undecided (brand vs literal); labels + deferred" explicitly. +- **Patterns to follow:** glossary "Machine tokens" section structure + (`docs/language-glossary.md:101-108`); existing TEXT_SURFACES row schema. +- **Test expectation:** none — docs/fixture only; verification is + `bun scripts/verify-language-surfaces.ts --policy --glossary` still PASS (no + deferral-placeholder fields introduced). +- **Verification:** Every token in U3/U4 scope has a ratified rendering keyed by + token; theme deferral and no-alias policy are quotable lines. + +### U2. Option-label resolver + identity-bound row refactor + +- **Goal:** The mechanism: `optionLabel()` with token fallback; `SettingRow` carries + canonical values with labels derived at render; `getValues()` derives from **row + identity, not position**; pinned tests rephrased. +- **Requirements:** R3, R4, R6 +- **Dependencies:** U1 +- **Files:** + - Create: `packages/terminal/src/i18n/option-labels.ts` + - Modify: `packages/terminal/src/scenes/settings/settings-scene.ts` + - Modify: `packages/terminal/src/i18n/messages.ts` (export the `Message` interface + only) + - Test: `packages/terminal/src/__tests__/settings-scene.test.ts`, + `packages/terminal/src/__tests__/scene-language.test.ts` +- **Approach:** `OPTION_LABELS` built as `Map` (or null-prototype record + + `Object.hasOwn`) — the PR #5 prototype-chain lesson applied at birth. + `SettingRow.options: string[]` → canonical token array (rename to `values`); render + loop calls `optionLabel(lang, setting.key, value)`. **Amendment (reviewer-verified): + `getValues()` is currently coupled two ways — display-string→canonical by index AND + field→row by hardcoded position (`this.rows[0]`…`this.rows[6]`, + `settings-scene.ts:117-127`); reordering rows today would silently swap persisted + values. Derive `getValues()` from row identity: look up each row by its `key`, read + `row.values[row.selected]`. Kills the whole drift class, not half of it.** Language + row untouched. Ship with an **empty catalog** — behavior identical before/after, + proving R4 fallback. +- **Patterns to follow:** `tr()` shape (`messages.ts:110-113`); live-language render + derivation (lang from live selection in `render()`); `LANGUAGE_LABELS` as the + display≠stored precedent. +- **Test scenarios:** + - *Happy path:* empty catalog → en and zh renders byte-identical to pre-refactor; + persistence writes canonical tokens. + - *Edge case:* token with no catalog entry in zh-Hant → renders the token itself + (R4). + - *Edge case:* prototype-chain key (`"constructor"` as token) → falls back to token + string, never resolves an inherited member. + - *Edge case (amendment):* construct rows in a shuffled order → `getValues()` still + maps each field to the correct row by key (locks the identity-bound contract). + - *Integration:* flip Language row ←/→ with a seeded label entry → chip text changes + same frame, `getValues()` unchanged. + - *Re-pin:* scene-language.test comment/assertions updated from "canonical anchors" + to "labels per policy matrix; stored tokens canonical"; `"[EN] 繁 简"` assertion + preserved verbatim. +- **Verification:** Refactor is behavior-neutral with empty catalog; no positional + `this.rows[N]` field mapping remains; no display array exists that `getValues()` + reads positionally against a different array. + +### U3. Rollout wave 1 — font row + +- **Goal:** First real labels: `kaiti/libian/heiti` → 楷體/隸變/黑體 (楷体/隶变/黑体). +- **Requirements:** R1 +- **Dependencies:** U2 +- **Files:** + - Modify: `packages/terminal/src/i18n/option-labels.ts` + - Test: `packages/terminal/src/__tests__/scene-language.test.ts` +- **Approach:** Safest row first — tokens are pinyin of the labels, so the CLI mental + bridge survives without hints. +- **Test scenarios:** + - *Happy path:* zh-Hant shows 楷體, en shows `kaiti`, saved config still `"kaiti"`. + - *Regression pin (not a risk mitigation):* CJK double-width — selected-chip + brackets land on correct cells at 80 cols. The render loop already positions by + `stringWidth`, not `.length`, so this passes immediately; the test pins it against + future render-loop changes. +- **Verification:** Font row fully localized in both zh modes; persistence round-trip + unchanged. + +### U4. Rollout wave 2 — cast method (with canonical hint), cast mode, taijitu, glyph anim + +- **Goal:** Remaining sanctioned rows: 銅錢 (coin)/蓍草 (yarrow), 自動/手動, + taijitu 點陣/密實, anim 噪點/點陣/放射/沙化 (+ zh-Hans forms). Catalog entries are + token-keyed (`noise`→噪點, `dots`→點陣 — per U1's cross-referenced ratification). +- **Requirements:** R1, R2 +- **Dependencies:** U2 (U3 independent — can land in either order) +- **Files:** + - Modify: `packages/terminal/src/i18n/option-labels.ts` + - Test: `packages/terminal/src/__tests__/scene-language.test.ts` +- **Approach:** Canonical hint encoded *in the catalog string* (`銅錢 (coin)`) — no + new render machinery; glossary owns hint text. Cast method gets hints (no + transliteration bridge); cast mode/taijitu/anim are literal common words, + label-only. +- **Test scenarios:** + - *Happy path:* per row, zh label renders, en unchanged, token persists. + - *Edge:* widest localized row measured `<` current theme-row width (36 cols) at 80 + cols — no clip, brackets correct. + - *Integration:* `iching config get castMethod` still prints `coin` after a TUI save + made in zh-Hant. +- **Verification:** All sanctioned rows localized; theme row still verbatim; CLI + stdout untouched. + +### U5. Verifier + fixture sync + +- **Goal:** The language verifier *enforces* the new layer instead of merely + tolerating it. +- **Requirements:** R6 +- **Dependencies:** U3, U4 +- **Files:** + - Modify: `scripts/verify-language-surfaces.ts` + - Modify: `tests/fixtures/language/TEXT_SURFACES.md` (status flips for + newly-covered rows) +- **Approach:** Extend `--terminal` to assert option-label coverage for sanctioned + rows (mirroring the existing `settings.theme`/`settings.castMode` label-key checks); + keep sentinels (`EN`/`繁`/`简`/`乾` in settings-scene.ts) valid — they are, since + the language row didn't move. Flip TEXT_SURFACES rows from "shown verbatim" to + "display-label via catalog" where shipped. +- **Test scenarios:** + - *Happy path:* `--policy --terminal --glossary` all PASS. + - *Error path:* deleting one shipped label entry makes `--terminal` FAIL (proves + enforcement, not tolerance). +- **Verification:** A future regression that drops a label is caught by CI, not by a + zh user. + +## Scope Boundaries + +- **No new config-input aliases** — `config set` and hand-edited files accept + canonical tokens only; `LANGUAGE_ALIASES` is not extended. Revisit only on observed + user friction, as a designed opt-in per enum. +- **No CLI stdout changes** — `config list/get/set` stays canonical English + (script-facing contract). +- **Language row untouched** — endonym chips EN/繁/简 are a different mechanism, + already correct, heavily pinned. +- **No new Settings rows** (motion stays CLI-only per its `developer-only` policy + row). + +### Deferred to Follow-Up Work + +- **Theme chip labels** — blocked on the U1 glossary decision (brand vs literal); + separate small PR once decided. +- **Localized validation-error descriptions** in non-script contexts — only if + friction appears. + +## System-Wide Impact + +- **Interaction graph:** render-path only — no other scene ever `writeText`s these + tokens (verified by sweep); CLI and storage packages are untouched by U2–U4. +- **Error propagation:** label resolution is total (fallback to token) — no new + failure path reaches the scene loop. +- **API surface parity:** intentional TUI↔CLI label divergence, bridged by in-label + hints + (existing) error messages listing canonical values. +- **State lifecycle:** not stateful — settings save path byte-identical (canonical + tokens in, canonical tokens out). +- **Unchanged invariants:** persisted schema and values; CLI stdout; + `LANGUAGE_ALIASES`/`THEME_ALIASES` parse surface; 乾 preview char; `"[EN] 繁 简"`. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Branch base: this work builds on `feat/language` (PR #5), which already contains the `Object.hasOwn` alias hardening, `canonicalLanguage`, and the verifier fixtures. Branch from `feat/language`; **merging #5 to main is not a blocker** — but if #5 changes under review, rebase before U2 | Branch from `feat/language`; U2's catalog uses `Map`/own-property discipline from birth regardless | +| Pinned artifacts are pervasive (fixture rows, sentinel literals, exact-string test assertions) — a missed pin breaks CI in a confusing place | Each unit names its pins explicitly; U2 re-pins tests *in the same commit* as the refactor; U5's error-path test proves the verifier's direction | +| Wrong zh renderings ossify (the 承策 lesson — invented ritualese already corrected once in `messages.ts:98`) | U1 glossary ratification before any code; candidate renderings in this plan are *candidates*, not finals | +| zh-Hans accidentally derived via `toSimplified` instead of authored | Follow `messages.ts` header convention: author zh-Hans explicitly per entry | +| Glossary token↔rendering transposition (噪點/點陣 class of error) | U1 table is token-keyed; 點陣 ratified once, cross-referenced to both `taijitu.dots` and `anim.dots` | + +**Confidence cross-check (session findings → contract):** mixed-register defect → +U3/U4; test-ossification amendment → U2 re-pin; aliases-by-osmosis → U1 policy line + +scope boundary; parallel-array + positional-row drift → U2 identity-bound refactor; +theme ambiguity → U1 decision + deferred section; prototype-chain class → U2 edge-case +test. No upstream item dropped. + +> Note on line references: anchors cited (e.g. `settings-scene.ts:27-31`, +> `messages.ts:107`) were verified against `feat/language` as of 2026-06-10; a few +> drifted slightly after the PR #7 squash but all resolve. Treat them as directional. diff --git a/tests/fixtures/language/TEXT_SURFACES.md b/tests/fixtures/language/TEXT_SURFACES.md index be96d47..e9bf338 100644 --- a/tests/fixtures/language/TEXT_SURFACES.md +++ b/tests/fixtures/language/TEXT_SURFACES.md @@ -992,7 +992,7 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f agentify_required: no status: open verifier: "--terminal" - notes: "English. 'Taijitu' romanized proper name. Options are enum chips shown verbatim (theme names, dots/dense, kaiti/libian/heiti, coin/yarrow, auto/manual)." + notes: "English. 'Taijitu' romanized proper name. Option chips: display-label catalog layer per term-settings-option-chips (font/castMethod/castMode/taijitu/anim); theme chips verbatim per term-theme-names deferral; language chips are endonym badges per term-settings-lang-options. Stored tokens preserved everywhere." - surface_id: term-settings-lang-options file: packages/terminal/src/scenes/settings/settings-scene.ts @@ -1009,6 +1009,21 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f verifier: "--policy default-order EN->繁->简; --terminal" notes: "Order matches spec EN->繁->简. Labels are abbreviations EN/繁/简 (not English/繁體/简体). Drives DisplayLanguage everywhere." +- surface_id: term-settings-option-chips + file: packages/terminal/src/i18n/option-labels.ts + code_locator: "OPTION_LABELS (token-keyed); settings-scene render loop derives labels per token" + current_text: 'font kaiti/libian/heiti → 楷體/隸變/黑體 (楷体/隶变/黑体); castMethod coin/yarrow → 銅錢 (coin)/蓍草 (yarrow) (铜钱 (coin)); castMode auto/manual → 自動/手動 (自动/手动); taijitu dots/dense → 點陣/密實 (点阵/密实); anim noise/dots/radial/sand → 噪點/點陣/放射/沙化 (噪点/点阵/放射/沙化)' + surface_class: terminal-settings + render_context: "settings option-value chips — display labels derived at render; stored tokens preserved" + language_policy: translate + source_layer: product-ui + token_policy: preserve + risk: medium + agentify_required: no + status: open + verifier: "--terminal (option-label coverage); --policy" + notes: "Display-label catalog layer per glossary §Settings option-chip display labels — labels never mutate stored tokens; NO input aliases (config set + hand-edit stay canonical-only). 點陣 ratified once for BOTH taijitu.dots and anim.dots; 噪點 = noise (transposition hazard). Theme chips excluded — deferred per term-theme-names. Language chips excluded — endonym badges per term-settings-lang-options. zh-Hans authored explicitly, not derived." + - surface_id: term-settings-preview-char file: packages/terminal/src/scenes/settings/settings-scene.ts code_locator: "L53 PREVIEW_CHAR" @@ -1056,7 +1071,7 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f agentify_required: no status: open verifier: "--terminal; --policy (enum-as-chip)" - notes: "Machine tokens shown as chips + persisted config keys; translating in place would break setTheme(). 'bone' default. Localization needs separate display-label map." + notes: "Machine tokens shown as chips + persisted config keys; translating in place would break setTheme(). 'bone' default. Display labels DEFERRED (plan 2026-06-10-001): theme-name semantics unresolved (brand vs literal — 墨/骨/硃砂/玉/河 candidates not ratified); chips stay verbatim until the glossary records the ruling. No input aliases." - surface_id: term-motion-preset file: packages/terminal/src/animation/presets.ts @@ -1953,6 +1968,12 @@ Default language **en**; settings order **EN → 繁 → 简** (asserted by zh_hant_source: 繁 zh_hans_strategy: 简 render_context: language selector chips (order EN→繁→简) +- id: term-settings-option-chips + language_policy: translate + en_source: canonical enum tokens shown as-is (kaiti/coin/auto/dots…) + zh_hant_source: option-label catalog 楷體/隸變/黑體/銅錢 (coin)/蓍草 (yarrow)/自動/手動/點陣/密實/噪點/放射/沙化 — stored tokens preserved + zh_hans_strategy: authored explicitly 楷体/隶变/黑体/铜钱 (coin)/自动/手动/点阵/密实/噪点 — never derived via toSimplified + render_context: settings option-value chips (theme + language rows excluded) - id: term-settings-preview-char language_policy: canonical-anchor en_source: 乾 (preserve) @@ -1968,8 +1989,8 @@ Default language **en**; settings order **EN → 繁 → 简** (asserted by - id: term-theme-names language_policy: canonical-anchor en_source: enum chip values ink/bone/cinnabar/jade/river (preserve stored tokens) - zh_hant_source: optional display-label map (catalog) — stored token preserved - zh_hans_strategy: convert display labels only + zh_hant_source: display-label map deferred (semantics ruling pending, brand vs literal) — chips render stored tokens + zh_hans_strategy: deferred with zh-Hant ruling; stored tokens render meanwhile render_context: Theme settings chips - id: term-motion-preset language_policy: developer-only From e207d1a10b28e62874349e3d85ac6a8ed1795a7d Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 00:35:33 -0700 Subject: [PATCH 2/7] feat(terminal): option-label resolver + identity-bound settings rows (U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OPTION_LABELS Map catalog (ships empty — behavior-neutral) with total optionLabel() fallback to the canonical token; SettingRow now carries canonical values, chip labels derived per render via chipLabel(); getValues() maps field→row by key, killing the positional-index drift (reordering rows can no longer swap persisted values). Language row's endonym badges untouched. --- .../src/__tests__/option-labels.test.ts | 24 +++++++ .../src/__tests__/scene-language.test.ts | 5 +- .../src/__tests__/settings-scene.test.ts | 24 +++++++ packages/terminal/src/i18n/messages.ts | 2 +- packages/terminal/src/i18n/option-labels.ts | 40 +++++++++++ .../src/scenes/settings/settings-scene.ts | 72 +++++++++++++------ 6 files changed, 145 insertions(+), 22 deletions(-) create mode 100644 packages/terminal/src/__tests__/option-labels.test.ts create mode 100644 packages/terminal/src/i18n/option-labels.ts diff --git a/packages/terminal/src/__tests__/option-labels.test.ts b/packages/terminal/src/__tests__/option-labels.test.ts new file mode 100644 index 0000000..086d085 --- /dev/null +++ b/packages/terminal/src/__tests__/option-labels.test.ts @@ -0,0 +1,24 @@ +// optionLabel() contract: total resolution with canonical-token fallback, and +// hardened against prototype-chain key reaches (the PR #5 alias-lookup lesson — +// a hand-edited "constructor" must miss, not resolve an inherited member). +import { describe, expect, test } from "bun:test"; +import { optionLabel } from "../i18n/option-labels.ts"; + +describe("optionLabel", () => { + test("unknown (settingKey, token) falls back to the canonical token", () => { + expect(optionLabel("zh-Hant", "settings.theme", "ink")).toBe("ink"); + expect(optionLabel("zh-Hans", "settings.nonsense", "whatever")).toBe("whatever"); + }); + + test("prototype-chain keys miss instead of resolving inherited members", () => { + for (const token of ["constructor", "__proto__", "toString", "valueOf", "hasOwnProperty"]) { + expect(optionLabel("zh-Hant", "settings.font", token)).toBe(token); + } + }); + + test("en mode returns the token-shaped label even when an entry exists", () => { + // With the catalog empty this is the fallback; once entries land (U3/U4) + // their en field must still equal the canonical token — locked there. + expect(optionLabel("en", "settings.font", "kaiti")).toBe("kaiti"); + }); +}); diff --git a/packages/terminal/src/__tests__/scene-language.test.ts b/packages/terminal/src/__tests__/scene-language.test.ts index 33698db..0df1ce5 100644 --- a/packages/terminal/src/__tests__/scene-language.test.ts +++ b/packages/terminal/src/__tests__/scene-language.test.ts @@ -75,7 +75,10 @@ describe("SettingsScene — no bilingual stacking", () => { expect(text).toContain("主題"); // Theme expect(text).not.toContain("Theme"); expect(text).not.toContain("Language"); - // option-value badges remain verbatim (canonical anchors per glossary) + // Option-value chips: STORED tokens stay canonical; display labels come from + // the option-labels catalog and fall back to the canonical token where no + // label is ratified (glossary §Settings option-chip display labels). The + // language chips are endonym badges, invariant across display languages. expect(text).toContain("繁"); }); diff --git a/packages/terminal/src/__tests__/settings-scene.test.ts b/packages/terminal/src/__tests__/settings-scene.test.ts index b3cae49..3fc5277 100644 --- a/packages/terminal/src/__tests__/settings-scene.test.ts +++ b/packages/terminal/src/__tests__/settings-scene.test.ts @@ -79,3 +79,27 @@ describe("SettingsScene layout", () => { } }); }); + +describe("SettingsScene getValues — identity-bound, not positional", () => { + // Contract (plan U2 amendment): field↔row mapping goes through the row KEY. + // Pre-refactor, getValues() read this.rows[0]…this.rows[6] positionally, so + // reordering rows silently swapped persisted values. White-box: shuffle the + // private rows array and assert the mapping still holds. + test("reversing row order does not swap persisted values", () => { + const scene = makeScene("zh-Hant"); + const before = scene.getValues(); + (scene as unknown as { rows: unknown[] }).rows.reverse(); + expect(scene.getValues()).toEqual(before); + }); + + test("selections made after a reorder land on the right field", () => { + const scene = makeScene("en"); + (scene as unknown as { rows: unknown[] }).rows.reverse(); + // After reversal, focused row 0 is Cast Mode (was Theme). Toggle it. + const ctx = makeCtx(); + scene.handleKey({ type: "arrow", direction: "right" }, ctx); + const vals = scene.getValues(); + expect(vals.castMode).toBe("manual"); // the toggled row's field moved… + expect(vals.theme).toBe("bone"); // …and theme (old position 0) did not. + }); +}); diff --git a/packages/terminal/src/i18n/messages.ts b/packages/terminal/src/i18n/messages.ts index cefccb4..74d2aa9 100644 --- a/packages/terminal/src/i18n/messages.ts +++ b/packages/terminal/src/i18n/messages.ts @@ -8,7 +8,7 @@ // uses characters outside the corpus conversion table in @iching/core. import type { DisplayLanguage } from "@iching/core"; -interface Message { +export interface Message { en: string; zhHant: string; zhHans: string; diff --git a/packages/terminal/src/i18n/option-labels.ts b/packages/terminal/src/i18n/option-labels.ts new file mode 100644 index 0000000..4a4aea2 --- /dev/null +++ b/packages/terminal/src/i18n/option-labels.ts @@ -0,0 +1,40 @@ +// Settings option-chip display labels — the SEPARATE catalog layer sanctioned by +// docs/language-glossary.md §Settings option-chip display labels. Labels localize +// the *presentation* of canonical enum tokens; they never mutate the stored token, +// and no label is ever accepted as config input (no aliases — config set and +// hand-edited files stay canonical-only). zh-Hans is authored explicitly in the +// same literary register, never derived via toSimplified(). +// +// The Language row's EN/繁/简 endonym badges are NOT in this catalog — they are +// deliberately invariant across display languages (see LANGUAGE_LABELS in +// settings-scene.ts). Theme names are absent on purpose: their semantics ruling +// (brand vs literal) is deferred; chips render the stored tokens meanwhile. +import type { DisplayLanguage } from "@iching/core"; +import type { Message } from "./messages.ts"; + +/** + * Token-keyed entries, namespaced by the row's catalog key: + * `${settingKey}.${token}` (e.g. "settings.font.kaiti"). + * A Map so a hand-reached token like "constructor" can never resolve an + * inherited Object.prototype member — unknown keys miss, they don't alias. + */ +const OPTION_LABELS = new Map([ + // Populated per glossary ratification (U3/U4); ships empty so the refactor + // is behavior-neutral — every chip falls back to its canonical token. +]); + +/** + * Display label for a canonical option token. Total by design: an unknown + * (settingKey, token) pair returns the token itself, so rendering degrades to + * the canonical value — it never throws, and nothing resolved here is ever + * written back to config. + */ +export function optionLabel( + language: DisplayLanguage, + settingKey: string, + token: string, +): string { + const m = OPTION_LABELS.get(`${settingKey}.${token}`); + if (!m) return token; + return language === "en" ? m.en : language === "zh-Hant" ? m.zhHant : m.zhHans; +} diff --git a/packages/terminal/src/scenes/settings/settings-scene.ts b/packages/terminal/src/scenes/settings/settings-scene.ts index 7ad13f6..60578da 100644 --- a/packages/terminal/src/scenes/settings/settings-scene.ts +++ b/packages/terminal/src/scenes/settings/settings-scene.ts @@ -18,6 +18,7 @@ import { renderYarrowFieldStrip, drawApertureCursor } from "../yarrow/field-rend import { YarrowAutoPreview, YarrowManualPreview } from "../yarrow/yarrow-previews.ts"; import { LINE_WIDTH } from "../../glyphs.ts"; import { tr, type MessageKey } from "../../i18n/messages.ts"; +import { optionLabel } from "../../i18n/option-labels.ts"; import { windowFor } from "../../widgets/scroll.ts"; // ── Setting definitions ────────────────────────────────────────────── @@ -37,10 +38,29 @@ const CAST_MODE_OPTIONS = ["auto", "manual"] as const; interface SettingRow { /** Stable message-catalog key; the visible label is localized at render time. */ key: MessageKey; - options: string[]; + /** + * Canonical option tokens — what getValues()/persistence sees. The displayed + * chip text is derived per-render via chipLabel(); labels are never stored on + * the row, so they can never round-trip into config. + */ + values: readonly string[]; selected: number; } +/** + * Display label for an option chip. The Language row uses endonym badges + * (EN/繁/简) that are deliberately invariant across display languages; every + * other row resolves through the option-label catalog and falls back to the + * canonical token when no label is ratified (e.g. theme names, deferred). + */ +function chipLabel(lang: DisplayLanguage, key: MessageKey, value: string): string { + if (key === "settings.language") { + for (const l of LANGUAGE_OPTIONS) if (l === value) return LANGUAGE_LABELS[l]; + return value; + } + return optionLabel(lang, key, value); +} + export interface SettingsValues { theme: ThemeName; language: DisplayLanguage; @@ -103,29 +123,41 @@ export class SettingsScene implements Scene { constructor(initial: SettingsValues) { this.values = { ...initial }; this.rows = [ - { key: "settings.theme", options: [...THEME_NAMES], selected: Math.max(0, THEME_NAMES.indexOf(initial.theme)) }, - { key: "settings.language", options: LANGUAGE_OPTIONS.map((lang) => LANGUAGE_LABELS[lang]), selected: Math.max(0, LANGUAGE_OPTIONS.indexOf(initial.language)) }, - { key: "settings.taijitu", options: [...TAIJITU_OPTIONS], selected: Math.max(0, TAIJITU_OPTIONS.indexOf(initial.taijituStyle)) }, - { key: "settings.glyphAnimation", options: [...ANIM_OPTIONS], selected: Math.max(0, ANIM_OPTIONS.indexOf(initial.glyphAnim)) }, - { key: "settings.font", options: [...FONT_OPTIONS], selected: Math.max(0, FONT_OPTIONS.indexOf(initial.glyphFont)) }, - { key: "settings.castMethod", options: [...CAST_METHOD_OPTIONS], selected: Math.max(0, CAST_METHOD_OPTIONS.indexOf(initial.castMethod)) }, - { key: "settings.castMode", options: [...CAST_MODE_OPTIONS], selected: Math.max(0, CAST_MODE_OPTIONS.indexOf(initial.castMode)) }, + { key: "settings.theme", values: THEME_NAMES, selected: Math.max(0, THEME_NAMES.indexOf(initial.theme)) }, + { key: "settings.language", values: LANGUAGE_OPTIONS, selected: Math.max(0, LANGUAGE_OPTIONS.indexOf(initial.language)) }, + { key: "settings.taijitu", values: TAIJITU_OPTIONS, selected: Math.max(0, TAIJITU_OPTIONS.indexOf(initial.taijituStyle)) }, + { key: "settings.glyphAnimation", values: ANIM_OPTIONS, selected: Math.max(0, ANIM_OPTIONS.indexOf(initial.glyphAnim)) }, + { key: "settings.font", values: FONT_OPTIONS, selected: Math.max(0, FONT_OPTIONS.indexOf(initial.glyphFont)) }, + { key: "settings.castMethod", values: CAST_METHOD_OPTIONS, selected: Math.max(0, CAST_METHOD_OPTIONS.indexOf(initial.castMethod)) }, + { key: "settings.castMode", values: CAST_MODE_OPTIONS, selected: Math.max(0, CAST_MODE_OPTIONS.indexOf(initial.castMode)) }, ]; this.previewKind = this.previewKindForKey(this.rows[0]?.key); } + /** + * Current selections as canonical tokens. Field↔row mapping is by row KEY, + * not row position — reordering or inserting rows cannot silently swap + * persisted values. pick() narrows the stored string back to each field's + * union without casts; an unknown value falls back to the field default. + */ getValues(): SettingsValues { return { - theme: THEME_NAMES[this.rows[0].selected] ?? "bone", - language: LANGUAGE_OPTIONS[this.rows[1].selected] ?? "en", - taijituStyle: TAIJITU_OPTIONS[this.rows[2].selected] ?? "dots", - glyphAnim: ANIM_OPTIONS[this.rows[3].selected] ?? "dots", - glyphFont: FONT_OPTIONS[this.rows[4].selected] ?? "kaiti", - castMethod: CAST_METHOD_OPTIONS[this.rows[5].selected] ?? "coin", - castMode: CAST_MODE_OPTIONS[this.rows[6].selected] ?? "auto", + theme: this.pick(THEME_NAMES, "settings.theme", "bone"), + language: this.pick(LANGUAGE_OPTIONS, "settings.language", "en"), + taijituStyle: this.pick(TAIJITU_OPTIONS, "settings.taijitu", "dots"), + glyphAnim: this.pick(ANIM_OPTIONS, "settings.glyphAnimation", "dots"), + glyphFont: this.pick(FONT_OPTIONS, "settings.font", "kaiti"), + castMethod: this.pick(CAST_METHOD_OPTIONS, "settings.castMethod", "coin"), + castMode: this.pick(CAST_MODE_OPTIONS, "settings.castMode", "auto"), }; } + private pick(allowed: readonly T[], key: MessageKey, fallback: T): T { + const row = this.rows.find((r) => r.key === key); + const raw = row ? row.values[row.selected] : undefined; + return allowed.find((v) => v === raw) ?? fallback; + } + enter(_ctx: SceneContext): void {} update(elapsed: number, dt: number, _ctx: SceneContext): void { @@ -247,8 +279,8 @@ export class SettingsScene implements Scene { frame.writeText(row, left, prefix, { fg: t.tertiary }); let col = left + stringWidth(prefix); - for (let j = 0; j < setting.options.length; j++) { - const opt = setting.options[j]; + for (let j = 0; j < setting.values.length; j++) { + const opt = chipLabel(lang, setting.key, setting.values[j] ?? ""); const sel = j === setting.selected; if (sel) { const text = `[${opt}]`; @@ -258,7 +290,7 @@ export class SettingsScene implements Scene { frame.writeText(row, col, opt, { fg: t.tertiary }); col += stringWidth(opt); } - if (j < setting.options.length - 1) col += 2; + if (j < setting.values.length - 1) col += 2; } row += interRowGap; @@ -392,13 +424,13 @@ export class SettingsScene implements Scene { break; case "left": { const r = this.rows[this.focusedRow]; - r.selected = (r.selected - 1 + r.options.length) % r.options.length; + r.selected = (r.selected - 1 + r.values.length) % r.values.length; this.onOptionChanged(); break; } case "right": { const r = this.rows[this.focusedRow]; - r.selected = (r.selected + 1) % r.options.length; + r.selected = (r.selected + 1) % r.values.length; this.onOptionChanged(); break; } From f45dfddea343fe198024a61a227425c1913d1bba Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 00:36:38 -0700 Subject: [PATCH 3/7] =?UTF-8?q?feat(terminal):=20localize=20font=20chips?= =?UTF-8?q?=20=E2=80=94=20=E6=A5=B7=E9=AB=94/=E9=9A=B8=E8=AE=8A/=E9=BB=91?= =?UTF-8?q?=E9=AB=94=20(U3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First ratified wave of the option-label catalog. zh-Hant/zh-Hans authored per glossary; en keeps canonical pinyin tokens; persistence and live language flip locked by tests. --- .../src/__tests__/scene-language.test.ts | 52 +++++++++++++++++++ packages/terminal/src/i18n/option-labels.ts | 9 +++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/terminal/src/__tests__/scene-language.test.ts b/packages/terminal/src/__tests__/scene-language.test.ts index 0df1ce5..98bb3fe 100644 --- a/packages/terminal/src/__tests__/scene-language.test.ts +++ b/packages/terminal/src/__tests__/scene-language.test.ts @@ -80,6 +80,7 @@ describe("SettingsScene — no bilingual stacking", () => { // label is ratified (glossary §Settings option-chip display labels). The // language chips are endonym badges, invariant across display languages. expect(text).toContain("繁"); + expect(text).toContain("ink"); // theme labels deferred → canonical fallback }); test("Simplified mode shows 简体 labels, no stray English or Traditional residue", () => { @@ -116,6 +117,57 @@ describe("SettingsScene — no bilingual stacking", () => { expect(text).not.toContain("Settings"); // old language no longer shown }); + // U3 — font row display labels (glossary-ratified): zh modes show 楷體/隸變/ + // 黑體 chips while the STORED token stays the pinyin (kaiti/libian/heiti). + test("font chips localize in zh-Hant; persisted token stays canonical", () => { + const text = settingsText("zh-Hant"); + // Selected kaiti chip with double-width CJK label, 2-space chip gaps — + // contiguous on one row pins bracket/width placement (cf. "[EN] 繁 简"). + expect(text).toContain("[楷體] 隸變 黑體"); + expect(text).not.toContain("kaiti"); // label replaces the token in zh + + const values: SettingsValues = { + theme: "bone", language: "zh-Hant", taijituStyle: "dots", glyphAnim: "dots", + glyphFont: "kaiti", castMethod: "coin", castMode: "auto", + }; + const scene = new SettingsScene(values); + expect(scene.getValues().glyphFont).toBe("kaiti"); + // Toggle the Font row: persisted value is the next TOKEN, never a label. + for (let i = 0; i < 4; i++) scene.handleKey({ type: "arrow", direction: "down" }, ctx); + scene.handleKey({ type: "arrow", direction: "right" }, ctx); + expect(scene.getValues().glyphFont).toBe("libian"); + }); + + test("font chips localize in zh-Hans with Simplified forms", () => { + const text = settingsText("zh-Hans"); + expect(text).toContain("[楷体] 隶变 黑体"); + expect(text).not.toContain("楷體"); // no Traditional residue + }); + + test("en mode keeps canonical font tokens", () => { + const text = settingsText("en"); + expect(text).toContain("[kaiti] libian heiti"); + expect(text).not.toContain("楷體"); + }); + + // Live re-localization extends to chips: flipping the Language row swaps the + // font labels in the same frame (labels are derived at render, not stored). + test("flipping Language re-labels font chips immediately, before save", () => { + const values: SettingsValues = { + theme: "bone", language: "en", taijituStyle: "dots", glyphAnim: "dots", + glyphFont: "kaiti", castMethod: "coin", castMode: "auto", + }; + const scene = new SettingsScene(values); + scene.handleKey({ type: "arrow", direction: "down" }, ctx); // focus Language + scene.handleKey({ type: "arrow", direction: "right" }, ctx); // en → zh-Hant + const buf = CellBuffer.create(ctx.cols, ctx.rows); + scene.render(buf, ctx); + const text = bufferText(buf); + expect(text).toContain("楷體"); // chip re-labeled live + expect(text).not.toContain("kaiti"); + expect(scene.getValues().glyphFont).toBe("kaiti"); // value untouched + }); + // Regression (Codex P2): the yarrow preview strip in Settings rendered its // captions in English because renderPreview() called renderYarrowFieldStrip // without the selected language. It now passes vals.language. diff --git a/packages/terminal/src/i18n/option-labels.ts b/packages/terminal/src/i18n/option-labels.ts index 4a4aea2..58546fe 100644 --- a/packages/terminal/src/i18n/option-labels.ts +++ b/packages/terminal/src/i18n/option-labels.ts @@ -19,8 +19,13 @@ import type { Message } from "./messages.ts"; * inherited Object.prototype member — unknown keys miss, they don't alias. */ const OPTION_LABELS = new Map([ - // Populated per glossary ratification (U3/U4); ships empty so the refactor - // is behavior-neutral — every chip falls back to its canonical token. + // Entries per glossary ratification — en is ALWAYS the canonical token + // (the en UI shows tokens; labels exist for the zh modes). + // Font: tokens are the pinyin of the labels, so the CLI mental bridge + // (config set glyphFont kaiti) survives without a canonical hint. + ["settings.font.kaiti", { en: "kaiti", zhHant: "楷體", zhHans: "楷体" }], + ["settings.font.libian", { en: "libian", zhHant: "隸變", zhHans: "隶变" }], + ["settings.font.heiti", { en: "heiti", zhHant: "黑體", zhHans: "黑体" }], ]); /** From e1851581dce13a93006839c28a6196d932014a04 Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 00:38:06 -0700 Subject: [PATCH 4/7] =?UTF-8?q?feat(terminal):=20localize=20cast/taijitu/a?= =?UTF-8?q?nim=20chips=20=E2=80=94=20wave=202=20(U4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 銅錢 (coin)/蓍草 (yarrow) carry the canonical token as an in-label hint (no transliteration bridge to the CLI value); 自動/手動, 點陣/密實, and 噪點/點陣/放射/沙化 are literal label-only renderings. Token-keyed per glossary (噪點 = noise, 點陣 = dots, ratified once for both usages). Width budget pinned under the en theme row. --- .../src/__tests__/scene-language.test.ts | 50 +++++++++++++++++++ packages/terminal/src/i18n/option-labels.ts | 15 ++++++ 2 files changed, 65 insertions(+) diff --git a/packages/terminal/src/__tests__/scene-language.test.ts b/packages/terminal/src/__tests__/scene-language.test.ts index 98bb3fe..7cf2283 100644 --- a/packages/terminal/src/__tests__/scene-language.test.ts +++ b/packages/terminal/src/__tests__/scene-language.test.ts @@ -10,6 +10,7 @@ import { CastScene } from "../scenes/cast/cast-scene.ts"; import { BrowseScene } from "../scenes/dict/browse-scene.ts"; import { JournalScene } from "../scenes/journal/journal-scene.ts"; import { renderDetail } from "../scenes/dict/detail-renderer.ts"; +import { stringWidth } from "../layout/measure.ts"; import { DetailScene } from "../scenes/dict/detail-scene.ts"; import { DetailModel } from "../scenes/dict/detail-model.ts"; import { CellBuffer } from "../render/buffer.ts"; @@ -168,6 +169,55 @@ describe("SettingsScene — no bilingual stacking", () => { expect(scene.getValues().glyphFont).toBe("kaiti"); // value untouched }); + // U4 — wave-2 chips: castMethod keeps the canonical token as an in-label + // hint (no transliteration bridge to the CLI value); castMode/taijitu/anim + // are literal words, label-only. Token-keyed: 噪點 = noise, 點陣 = dots. + test("wave-2 chips localize in zh-Hant; castMethod carries canonical hint", () => { + const text = settingsText("zh-Hant"); + expect(text).toContain("[銅錢 (coin)] 蓍草 (yarrow)"); + expect(text).toContain("[自動] 手動"); + expect(text).toContain("[點陣] 密實"); // taijitu + expect(text).toContain("[點陣] 噪點 放射 沙化"); // anim (order = ANIM_OPTIONS) + }); + + test("wave-2 chips localize in zh-Hans with Simplified forms", () => { + const text = settingsText("zh-Hans"); + expect(text).toContain("[铜钱 (coin)] 蓍草 (yarrow)"); + expect(text).toContain("[自动] 手动"); + expect(text).toContain("[点阵] 密实"); + expect(text).toContain("[点阵] 噪点 放射 沙化"); + expect(text).not.toContain("銅錢"); // no Traditional residue + }); + + test("en mode keeps canonical wave-2 tokens", () => { + const text = settingsText("en"); + expect(text).toContain("[coin] yarrow"); + expect(text).toContain("[auto] manual"); + expect(text).toContain("[dots] noise radial sand"); + expect(text).not.toContain("銅錢"); + }); + + // Width budget: the widest localized chip row must stay under the en theme + // row (36 cols), the proven-to-fit baseline at 80 cols (plan U4 scenario). + test("widest zh chip row stays inside the en theme-row width budget", () => { + const castMethodRow = "[銅錢 (coin)] 蓍草 (yarrow)"; + expect(stringWidth(castMethodRow)).toBeLessThan(36); + }); + + // Persistence: zh-mode selections still write canonical tokens (the chain + // CLI tests pin from the other side: `config get castMethod` prints "coin"). + test("zh-Hant selections persist canonical wave-2 tokens", () => { + const values: SettingsValues = { + theme: "bone", language: "zh-Hant", taijituStyle: "dots", glyphAnim: "dots", + glyphFont: "kaiti", castMethod: "coin", castMode: "auto", + }; + const scene = new SettingsScene(values); + for (let i = 0; i < 5; i++) scene.handleKey({ type: "arrow", direction: "down" }, ctx); // Cast Method + scene.handleKey({ type: "arrow", direction: "right" }, ctx); + expect(scene.getValues().castMethod).toBe("yarrow"); + expect(scene.getValues().castMode).toBe("auto"); + }); + // Regression (Codex P2): the yarrow preview strip in Settings rendered its // captions in English because renderPreview() called renderYarrowFieldStrip // without the selected language. It now passes vals.language. diff --git a/packages/terminal/src/i18n/option-labels.ts b/packages/terminal/src/i18n/option-labels.ts index 58546fe..3aaddef 100644 --- a/packages/terminal/src/i18n/option-labels.ts +++ b/packages/terminal/src/i18n/option-labels.ts @@ -26,6 +26,21 @@ const OPTION_LABELS = new Map([ ["settings.font.kaiti", { en: "kaiti", zhHant: "楷體", zhHans: "楷体" }], ["settings.font.libian", { en: "libian", zhHant: "隸變", zhHans: "隶变" }], ["settings.font.heiti", { en: "heiti", zhHant: "黑體", zhHans: "黑体" }], + // Cast method: domain terms with no transliteration bridge — the canonical + // token rides in the label as a hint back to the CLI value (glossary). + ["settings.castMethod.coin", { en: "coin", zhHant: "銅錢 (coin)", zhHans: "铜钱 (coin)" }], + ["settings.castMethod.yarrow", { en: "yarrow", zhHant: "蓍草 (yarrow)", zhHans: "蓍草 (yarrow)" }], + // Cast mode / taijitu / glyph anim: literal common words, label-only. + ["settings.castMode.auto", { en: "auto", zhHant: "自動", zhHans: "自动" }], + ["settings.castMode.manual", { en: "manual", zhHant: "手動", zhHans: "手动" }], + ["settings.taijitu.dots", { en: "dots", zhHant: "點陣", zhHans: "点阵" }], + ["settings.taijitu.dense", { en: "dense", zhHant: "密實", zhHans: "密实" }], + // Token-keyed on purpose: 噪點 = noise, 點陣 = dots (transposition hazard — + // 點陣 is ratified once for BOTH taijitu.dots and glyphAnimation.dots). + ["settings.glyphAnimation.dots", { en: "dots", zhHant: "點陣", zhHans: "点阵" }], + ["settings.glyphAnimation.noise", { en: "noise", zhHant: "噪點", zhHans: "噪点" }], + ["settings.glyphAnimation.radial", { en: "radial", zhHant: "放射", zhHans: "放射" }], + ["settings.glyphAnimation.sand", { en: "sand", zhHant: "沙化", zhHans: "沙化" }], ]); /** From 54948ec33faa8e7d28bc445b695a658c8bc0a2cd Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 00:40:36 -0700 Subject: [PATCH 5/7] test(verify): enforce option-label catalog coverage (U5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --terminal now asserts the full ratified (settingKey, token) → 繁/简 contract behaviorally via optionLabel(), pins en = canonical token, theme verbatim fallback (deferred), and prototype-chain misses; adds representative label sentinels wired to the inventory row. Deleting a shipped entry reds the verifier (proven by mutation). --- scripts/verify-language-surfaces.ts | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/scripts/verify-language-surfaces.ts b/scripts/verify-language-surfaces.ts index be9aab9..34f7b5a 100644 --- a/scripts/verify-language-surfaces.ts +++ b/scripts/verify-language-surfaces.ts @@ -126,6 +126,13 @@ const SENTINELS: Array<[string, string]> = [ ["EN", "packages/terminal/src/scenes/settings/settings-scene.ts"], ["繁", "packages/terminal/src/scenes/settings/settings-scene.ts"], ["简", "packages/terminal/src/scenes/settings/settings-scene.ts"], + // Option-chip display labels (representative set; full contract in --terminal): + ["楷體", "packages/terminal/src/i18n/option-labels.ts"], + ["楷体", "packages/terminal/src/i18n/option-labels.ts"], + ["銅錢", "packages/terminal/src/i18n/option-labels.ts"], + ["铜钱", "packages/terminal/src/i18n/option-labels.ts"], + ["噪點", "packages/terminal/src/i18n/option-labels.ts"], + ["噪点", "packages/terminal/src/i18n/option-labels.ts"], ["Cast", "packages/terminal/src/scenes/home/home-scene.ts"], ["Settings", "packages/terminal/src/scenes/home/home-scene.ts"], ["No history", "packages/terminal/src/scenes/dict/detail-renderer.ts"], @@ -656,6 +663,58 @@ async function runTerminal(): Promise { const ht = tr("zh-Hant", "menu.settings"); if (en === ht) fail("tr() does not distinguish en vs zh-Hant for menu.settings"); } + // 1b. Option-label catalog — the SEPARATE display-label layer for enum chips + // (glossary §Settings option-chip display labels). Behavioral contract: every + // ratified (settingKey, token) resolves to the glossary renderings, en stays + // the canonical token, theme tokens fall through verbatim (labels deferred), + // and prototype-chain reaches miss. Deleting or mangling an entry fails here. + const OPTION_LABEL_CONTRACT: Array<[string, string, string, string]> = [ + ["settings.font", "kaiti", "楷體", "楷体"], + ["settings.font", "libian", "隸變", "隶变"], + ["settings.font", "heiti", "黑體", "黑体"], + ["settings.castMethod", "coin", "銅錢 (coin)", "铜钱 (coin)"], + ["settings.castMethod", "yarrow", "蓍草 (yarrow)", "蓍草 (yarrow)"], + ["settings.castMode", "auto", "自動", "自动"], + ["settings.castMode", "manual", "手動", "手动"], + ["settings.taijitu", "dots", "點陣", "点阵"], + ["settings.taijitu", "dense", "密實", "密实"], + ["settings.glyphAnimation", "dots", "點陣", "点阵"], + ["settings.glyphAnimation", "noise", "噪點", "噪点"], + ["settings.glyphAnimation", "radial", "放射", "放射"], + ["settings.glyphAnimation", "sand", "沙化", "沙化"], + ]; + let optMod: { optionLabel?: (l: string, s: string, t: string) => string }; + try { + optMod = (await import( + resolve(ROOT, "packages/terminal/src/i18n/option-labels.ts") + )) as typeof optMod; + } catch { + fail("option-label catalog packages/terminal/src/i18n/option-labels.ts missing"); + optMod = {}; + } + const optionLabel = optMod.optionLabel; + if (typeof optionLabel !== "function") { + fail("option-labels.ts must export optionLabel(language, settingKey, token)"); + } else { + for (const [sk, token, hant, hans] of OPTION_LABEL_CONTRACT) { + if (optionLabel("en", sk, token) !== token) + fail(`option label ${sk}.${token}: en must equal the canonical token`); + if (optionLabel("zh-Hant", sk, token) !== hant) + fail(`option label ${sk}.${token}: zh-Hant != ratified ${JSON.stringify(hant)}`); + if (optionLabel("zh-Hans", sk, token) !== hans) + fail(`option label ${sk}.${token}: zh-Hans != ratified ${JSON.stringify(hans)}`); + } + for (const theme of ["ink", "bone", "cinnabar", "jade", "river"]) + if (optionLabel("zh-Hant", "settings.theme", theme) !== theme) + fail(`theme chip "${theme}" must stay verbatim (labels deferred per glossary)`); + if (optionLabel("zh-Hant", "settings.font", "constructor") !== "constructor") + fail("optionLabel resolves prototype-chain keys (must fall back to the token)"); + } + // The settings scene must actually derive chips through the catalog. + const settingsSrc = + readMaybe("packages/terminal/src/scenes/settings/settings-scene.ts") ?? ""; + if (!/from "[^"]*i18n\/option-labels/.test(settingsSrc)) + fail("settings-scene does not consume the option-label catalog"); // 2. Consumer-side: every translatable scene must consume the catalog (or language). for (const rel of SCENE_CONSUMERS) { const src = readMaybe(rel); From fd7212b93025795933501982278149387e033bdf Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 00:42:20 -0700 Subject: [PATCH 6/7] refactor(terminal): rename pick() to selectedValue() (gate /naming) Name the role (find the row owning the key, narrow its selected token), not the category. --- .../src/scenes/settings/settings-scene.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/terminal/src/scenes/settings/settings-scene.ts b/packages/terminal/src/scenes/settings/settings-scene.ts index 60578da..df658d0 100644 --- a/packages/terminal/src/scenes/settings/settings-scene.ts +++ b/packages/terminal/src/scenes/settings/settings-scene.ts @@ -137,22 +137,23 @@ export class SettingsScene implements Scene { /** * Current selections as canonical tokens. Field↔row mapping is by row KEY, * not row position — reordering or inserting rows cannot silently swap - * persisted values. pick() narrows the stored string back to each field's - * union without casts; an unknown value falls back to the field default. + * persisted values. selectedValue() finds the row that owns the key and + * narrows its selected token back to the field's union without casts; an + * unknown value falls back to the field default. */ getValues(): SettingsValues { return { - theme: this.pick(THEME_NAMES, "settings.theme", "bone"), - language: this.pick(LANGUAGE_OPTIONS, "settings.language", "en"), - taijituStyle: this.pick(TAIJITU_OPTIONS, "settings.taijitu", "dots"), - glyphAnim: this.pick(ANIM_OPTIONS, "settings.glyphAnimation", "dots"), - glyphFont: this.pick(FONT_OPTIONS, "settings.font", "kaiti"), - castMethod: this.pick(CAST_METHOD_OPTIONS, "settings.castMethod", "coin"), - castMode: this.pick(CAST_MODE_OPTIONS, "settings.castMode", "auto"), + theme: this.selectedValue(THEME_NAMES, "settings.theme", "bone"), + language: this.selectedValue(LANGUAGE_OPTIONS, "settings.language", "en"), + taijituStyle: this.selectedValue(TAIJITU_OPTIONS, "settings.taijitu", "dots"), + glyphAnim: this.selectedValue(ANIM_OPTIONS, "settings.glyphAnimation", "dots"), + glyphFont: this.selectedValue(FONT_OPTIONS, "settings.font", "kaiti"), + castMethod: this.selectedValue(CAST_METHOD_OPTIONS, "settings.castMethod", "coin"), + castMode: this.selectedValue(CAST_MODE_OPTIONS, "settings.castMode", "auto"), }; } - private pick(allowed: readonly T[], key: MessageKey, fallback: T): T { + private selectedValue(allowed: readonly T[], key: MessageKey, fallback: T): T { const row = this.rows.find((r) => r.key === key); const raw = row ? row.values[row.selected] : undefined; return allowed.find((v) => v === raw) ?? fallback; From ff0ffd8feb3240be137ec2846d539e59bf956c84 Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 00:42:30 -0700 Subject: [PATCH 7/7] docs(plans): mark settings option-label plan completed --- ...26-06-10-001-feat-settings-option-label-localization-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-06-10-001-feat-settings-option-label-localization-plan.md b/docs/plans/2026-06-10-001-feat-settings-option-label-localization-plan.md index b9a693f..1a0d238 100644 --- a/docs/plans/2026-06-10-001-feat-settings-option-label-localization-plan.md +++ b/docs/plans/2026-06-10-001-feat-settings-option-label-localization-plan.md @@ -1,7 +1,7 @@ --- title: Settings Option-Label Localization type: feat -status: active +status: completed date: 2026-06-10 origin: PR #5 code-review session — settings-chips analysis (3-analyst workflow) + GPT-Pro second opinion; chat blueprint approved with U2 amendment ---