diff --git a/.gitignore b/.gitignore index 47b3c74..e134c59 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ publish/iching.js .prov/ logs/ .research/ +# parked corpus-acquisition residue (scraping intermediates + one-off backfill +# scripts); not part of the shipped packages — kept locally, never committed +data-acquisition/ +scripts/data-acquisition/ diff --git a/apps/cli/src/__tests__/config-command.test.ts b/apps/cli/src/__tests__/config-command.test.ts index db57b58..39c9466 100644 --- a/apps/cli/src/__tests__/config-command.test.ts +++ b/apps/cli/src/__tests__/config-command.test.ts @@ -9,7 +9,7 @@ // (coin|yarrow) × castMode (auto|manual); both must be reachable. import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -53,13 +53,14 @@ describe("config command", () => { await rm(dataDir, { recursive: true, force: true }); }); - test("list shows every persisted key, including castMethod, castMode, and taijituStyle", async () => { + test("list shows every persisted key, including language and cast settings", async () => { const { exitCode, stdout } = await runCli(dataDir, ["config", "list"]); expect(exitCode).toBe(0); // Every field that JsonConfigStore.DEFAULT_CONFIG persists must be visible. for (const key of [ "theme", "motion", + "language", "color", "timezone", "glyphAnim", @@ -78,6 +79,12 @@ describe("config command", () => { expect(stdout.trim()).toBe("auto"); }, 20_000); + test("get language returns the default value", async () => { + const { exitCode, stdout } = await runCli(dataDir, ["config", "get", "language"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe("en"); + }, 20_000); + test("get taijituStyle returns the default value", async () => { const { exitCode, stdout } = await runCli(dataDir, ["config", "get", "taijituStyle"]); expect(exitCode).toBe(0); @@ -105,6 +112,39 @@ describe("config command", () => { expect(getResult.stdout.trim()).toBe("yarrow"); }, 20_000); + // Round-trip a NON-default value (en is DEFAULT_CONFIG.language, so a silent + // no-op write would still read back "en" — this would pass green even broken). + test("set language zh-Hant persists, reload reads zh-Hant", async () => { + const setResult = await runCli(dataDir, ["config", "set", "language", "zh-Hant"]); + expect(setResult.exitCode).toBe(0); + const getResult = await runCli(dataDir, ["config", "get", "language"]); + expect(getResult.exitCode).toBe(0); + expect(getResult.stdout.trim()).toBe("zh-Hant"); + }, 20_000); + + // `set language` accepts the labels the Settings UI displays (繁/简/EN) and + // case variants, normalizing to the canonical value the file loader also accepts. + test("set language accepts UI labels + case variants, persists canonical", async () => { + for (const [input, want] of [ + ["繁", "zh-Hant"], + ["简", "zh-Hans"], + ["EN", "en"], + ["zh-hant", "zh-Hant"], + ] as const) { + const setResult = await runCli(dataDir, ["config", "set", "language", input]); + expect(setResult.exitCode).toBe(0); + expect(setResult.stdout.trim()).toBe(`language = ${want}`); // canonical, not the raw label + const getResult = await runCli(dataDir, ["config", "get", "language"]); + expect(getResult.stdout.trim()).toBe(want); + } + }, 20_000); + + test("set language still rejects genuinely invalid values", async () => { + const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "language", "klingon"]); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("invalid value"); + }, 20_000); + test("set castMode rejects yarrow (now out of castMode's domain)", async () => { const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "castMode", "yarrow"]); expect(exitCode).not.toBe(0); @@ -136,9 +176,83 @@ describe("config command", () => { expect(stderr.toLowerCase()).toContain("invalid value"); }, 20_000); + test("set language rejects invalid value", async () => { + const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "language", "latin"]); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("invalid value"); + }, 20_000); + test("get unknown key reports error", async () => { const { exitCode, stderr } = await runCli(dataDir, ["config", "get", "notARealKey"]); expect(exitCode).not.toBe(0); expect(stderr.toLowerCase()).toContain("unknown key"); }, 20_000); + + // Regression: `key in CONFIG_SCHEMA` walked the prototype chain, so inherited + // Object.prototype names slipped past the unknown-key guard — `get` printed the + // inherited function and `set` crashed ("schema.set is not a function"). The + // guard now uses Object.hasOwn; both must report a clean "unknown key" error. + test("get inherited prototype key (toString) reports unknown key", async () => { + const { exitCode, stderr, stdout } = await runCli(dataDir, ["config", "get", "toString"]); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("unknown key"); + expect(stdout).not.toContain("function"); + }, 20_000); + + test("set inherited prototype key (constructor) reports unknown key, does not crash", async () => { + const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "constructor", "x"]); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("unknown key"); + expect(stderr.toLowerCase()).not.toContain("is not a function"); + }, 20_000); + + // Regression (Codex P2): `iching dict` is an interactive entry point and must + // seed+freeze the display language on first boot like the main TUI — not use a + // pure load() that launches English. It seeds before the (non-TTY) render exits. + test("`iching dict` seeds the display language on first boot", async () => { + const proc = Bun.spawn(["bun", MAIN_TS, "--data-dir", dataDir, "dict"], { + cwd: REPO_ROOT, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + // clear inherited LANGUAGE/LC_MESSAGES so LC_ALL deterministically wins + env: { ...process.env, NO_COLOR: "1", LC_ALL: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8", LC_MESSAGES: "", LANGUAGE: "" }, + }); + proc.stdin.end(); + await proc.exited; // exits on its own (no TTY) after loadOrSeed has persisted + const cfg = JSON.parse(await readFile(join(dataDir, "config.json"), "utf-8")); + expect(cfg.language).toBe("zh-Hans"); // seeded from the locale, not default "en" + }, 20_000); + + // Regression (review P2): `config set` WRITES, so on first boot it must seed + // the language — not persist the defaulted "en" and permanently freeze the seed. + test("`config set` on first boot seeds the language (does not freeze en)", async () => { + const proc = Bun.spawn(["bun", MAIN_TS, "--data-dir", dataDir, "config", "set", "theme", "ink"], { + cwd: REPO_ROOT, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1", LC_ALL: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8", LC_MESSAGES: "", LANGUAGE: "" }, + }); + proc.stdin.end(); + await proc.exited; + const cfg = JSON.parse(await readFile(join(dataDir, "config.json"), "utf-8")); + expect(cfg.theme).toBe("ink"); // the set applied + expect(cfg.language).toBe("zh-Hans"); // …AND the locale was seeded, not frozen to en + }, 20_000); + + // Regression (Codex P2): a REJECTED `config set` must not write anything — the + // old order ran loadOrSeed() (which persists a seeded config on first boot) + // before validation, so `config set language klingon` created config.json and + // froze the locale seed even though the command failed. + test("failed `config set` on first boot leaves no config file behind", async () => { + for (const args of [ + ["config", "set", "language", "klingon"], // invalid value + ["config", "set", "notARealKey", "x"], // unknown key + ]) { + const { exitCode } = await runCli(dataDir, args); + expect(exitCode).not.toBe(0); + } + expect(await Bun.file(join(dataDir, "config.json")).exists()).toBe(false); + }, 20_000); }); diff --git a/apps/cli/src/__tests__/reading-flow.test.ts b/apps/cli/src/__tests__/reading-flow.test.ts index fd815b3..577e250 100644 --- a/apps/cli/src/__tests__/reading-flow.test.ts +++ b/apps/cli/src/__tests__/reading-flow.test.ts @@ -27,6 +27,7 @@ function makeDeps(dataDir: string, run: RunImpl): ReadingFlowDeps { today: "2026-05-20", session: { cols: 80, rows: 24 }, glyphConfig: { glyphAnim: "dots", glyphFont: "kaiti" }, + language: "zh-Hant", motion: "default", }; } diff --git a/apps/cli/src/app/reading-flow.ts b/apps/cli/src/app/reading-flow.ts index 8d80597..8293395 100644 --- a/apps/cli/src/app/reading-flow.ts +++ b/apps/cli/src/app/reading-flow.ts @@ -11,6 +11,7 @@ import { castHexagram, type Cast, CryptoRandomSource, + type DisplayLanguage, SeededRandomSource, } from "@iching/core"; import { @@ -57,6 +58,7 @@ export interface ReadingFlowDeps { today: string; session: SessionDims; glyphConfig: CastGlyphInput; + language: DisplayLanguage; motion: MotionPreset; } @@ -96,13 +98,13 @@ export async function runReadingFlow( if (tossSignal?.type !== "tossCompleted") return { shouldExit: false }; // user quit before 6 lines cast = tossSignal.cast; } else if (opts.source.type === "yarrow") { - const yarrowScene = new YarrowScene(deps.motion); + const yarrowScene = new YarrowScene(deps.motion, undefined, deps.language); const yarrowSignal = await deps.run(yarrowScene); if (yarrowSignal?.type === "exit") return { shouldExit: true }; if (yarrowSignal?.type !== "yarrowCompleted") return { shouldExit: false }; // user quit mid-ritual cast = yarrowSignal.cast; } else if (opts.source.type === "yarrow-manual") { - const yarrowManualScene = new YarrowManualScene(deps.motion); + const yarrowManualScene = new YarrowManualScene(deps.motion, undefined, deps.language); const yarrowSignal = await deps.run(yarrowManualScene); if (yarrowSignal?.type === "exit") return { shouldExit: true }; if (yarrowSignal?.type !== "yarrowCompleted") return { shouldExit: false }; // user quit mid-ritual @@ -152,7 +154,7 @@ export async function runReadingFlow( deps.glyphConfig, deps.session.rows, intention, - { skipLineDrawing: drewOwnLines }, + { skipLineDrawing: drewOwnLines, language: deps.language }, ); if (isReplay) castScene.skipToComplete(false); const castSignal = await deps.run(castScene); @@ -182,6 +184,7 @@ async function runPostCastNavigation( const entries = await loadJournalEntries(journal); const factoryDeps = { glyphConfig: deps.glyphConfig, + language: deps.language, journal, entries, session: deps.session, @@ -194,7 +197,11 @@ async function runPostCastNavigation( } if (signal.type === "openDictionary" || signal.type === "openDetail") { const journal = new JsonlJournalStore(deps.paths.state); - const factoryDeps = { glyphConfig: deps.glyphConfig, journal }; + const factoryDeps = { + glyphConfig: deps.glyphConfig, + language: deps.language, + journal, + }; const startScene: Scene = signal.type === "openDetail" ? makeDetailScene(signal.kw, factoryDeps) : new BrowseScene(); diff --git a/apps/cli/src/app/scene-factories.ts b/apps/cli/src/app/scene-factories.ts index 7e6d460..b2f4f3a 100644 --- a/apps/cli/src/app/scene-factories.ts +++ b/apps/cli/src/app/scene-factories.ts @@ -3,7 +3,7 @@ // (DetailScene + getHexagramHistory hydration, plus the SceneRouter // factories used by browse/journal navigation). -import type { HistoryEntry } from "@iching/core"; +import type { DisplayLanguage, HistoryEntry } from "@iching/core"; import { getHexagramHistory, type JsonlJournalStore } from "@iching/storage"; import { BrowseScene, @@ -23,6 +23,7 @@ export interface SessionDims { export interface DetailDeps { /** Optional — interactive home flow passes the saved glyph config; standalone CLI doesn't need it. */ glyphConfig?: CastGlyphInput; + language?: DisplayLanguage; journal: JsonlJournalStore; } @@ -33,7 +34,7 @@ export interface JournalDeps extends DetailDeps { /** Construct DetailScene + kick off async history hydration. */ export function makeDetailScene(kw: number, deps: DetailDeps): DetailScene { - const scene = new DetailScene(kw, deps.glyphConfig); + const scene = new DetailScene(kw, deps.glyphConfig, deps.language); getHexagramHistory(deps.journal, kw).then((h) => scene.setHistory(h.castCount, h.lastCastDate), ); @@ -63,6 +64,7 @@ export function makeJournalFactory(deps: JournalDeps): SceneFactory { deps.glyphConfig, deps.session.rows, entry.intention, + { language: deps.language }, ); cs.skipToComplete(false); return cs; diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index edf98f8..9cf9c93 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -1,21 +1,134 @@ import { Command } from "commander"; -import { resolvePaths, JsonConfigStore } from "@iching/storage"; +import { resolvePaths, JsonConfigStore, canonicalLanguage } from "@iching/storage"; import type { UserConfig } from "@iching/storage"; import { outputJson, configToJson } from "../output/json.js"; -const CONFIG_SCHEMA: Record = { - theme: { values: ["ink", "bone", "cinnabar", "jade", "river"], description: "Color theme" }, - motion: { values: ["default", "brisk", "deep", "reduced"], description: "Casting animation speed" }, - color: { values: ["auto", "always", "never"], description: "ANSI color mode" }, - timezone: { description: "Timezone (\"system\" or IANA name)" }, - glyphAnim: { values: ["noise", "dots", "radial", "sand"], description: "Glyph reveal animation" }, - glyphFont: { values: ["kaiti", "libian", "heiti"], description: "Glyph font" }, - taijituStyle: { values: ["dots", "dense"], description: "Home-screen taijitu style" }, - castMethod: { values: ["coin", "yarrow"], description: "Cast method (coin or yarrow stalk ritual)" }, - castMode: { values: ["auto", "manual"], description: "Cast mode (auto or operator-guided)" }, +type ConfigEntry = { + values?: readonly string[]; + description: string; + /** Canonicalize a raw CLI value before validation (e.g. accept 繁/EN/zh-hant for language). */ + normalize?: (value: string) => string; + set: (cfg: UserConfig, value: string) => boolean; }; -const VALID_KEYS = Object.keys(CONFIG_SCHEMA); +function isOneOf( + options: T, + value: string, +): value is T[number] { + return options.includes(value as T[number]); +} + +const THEME_VALUES = ["ink", "bone", "cinnabar", "jade", "river"] as const; +const MOTION_VALUES = ["default", "brisk", "deep", "reduced"] as const; +const COLOR_VALUES = ["auto", "always", "never"] as const; +const LANGUAGE_VALUES = ["en", "zh-Hant", "zh-Hans"] as const; +const GLYPH_ANIM_VALUES = ["noise", "dots", "radial", "sand"] as const; +const GLYPH_FONT_VALUES = ["kaiti", "libian", "heiti"] as const; +const TAIJITU_STYLE_VALUES = ["dots", "dense"] as const; +const CAST_METHOD_VALUES = ["coin", "yarrow"] as const; +const CAST_MODE_VALUES = ["auto", "manual"] as const; + +const CONFIG_SCHEMA: Record = { + theme: { + values: THEME_VALUES, + description: "Color theme", + set: (cfg, value) => { + if (!isOneOf(THEME_VALUES, value)) return false; + cfg.theme = value; + return true; + }, + }, + motion: { + values: MOTION_VALUES, + description: "Casting animation speed", + set: (cfg, value) => { + if (!isOneOf(MOTION_VALUES, value)) return false; + cfg.motion = value; + return true; + }, + }, + language: { + values: LANGUAGE_VALUES, + description: "Display language (English, 繁, or 简)", + // Accept the UI labels (繁/简/EN) and case variants, matching the file loader. + normalize: (value) => canonicalLanguage(value) ?? value, + set: (cfg, value) => { + if (!isOneOf(LANGUAGE_VALUES, value)) return false; + cfg.language = value; + return true; + }, + }, + color: { + values: COLOR_VALUES, + description: "ANSI color mode", + set: (cfg, value) => { + if (!isOneOf(COLOR_VALUES, value)) return false; + cfg.color = value; + return true; + }, + }, + timezone: { + description: "Timezone (\"system\" or IANA name)", + set: (cfg, value) => { + cfg.timezone = value; + return true; + }, + }, + glyphAnim: { + values: GLYPH_ANIM_VALUES, + description: "Glyph reveal animation", + set: (cfg, value) => { + if (!isOneOf(GLYPH_ANIM_VALUES, value)) return false; + cfg.glyphAnim = value; + return true; + }, + }, + glyphFont: { + values: GLYPH_FONT_VALUES, + description: "Glyph font", + set: (cfg, value) => { + if (!isOneOf(GLYPH_FONT_VALUES, value)) return false; + cfg.glyphFont = value; + return true; + }, + }, + taijituStyle: { + values: TAIJITU_STYLE_VALUES, + description: "Home-screen taijitu style", + set: (cfg, value) => { + if (!isOneOf(TAIJITU_STYLE_VALUES, value)) return false; + cfg.taijituStyle = value; + return true; + }, + }, + castMethod: { + values: CAST_METHOD_VALUES, + description: "Cast method (coin or yarrow stalk ritual)", + set: (cfg, value) => { + if (!isOneOf(CAST_METHOD_VALUES, value)) return false; + cfg.castMethod = value; + return true; + }, + }, + castMode: { + values: CAST_MODE_VALUES, + description: "Cast mode (auto or operator-guided)", + set: (cfg, value) => { + if (!isOneOf(CAST_MODE_VALUES, value)) return false; + cfg.castMode = value; + return true; + }, + }, +}; + +const VALID_KEYS = Object.keys(CONFIG_SCHEMA) as Array; + +function isConfigKey(key: string): key is keyof typeof CONFIG_SCHEMA { + // own-property check, NOT `key in CONFIG_SCHEMA` — the `in` operator walks the + // prototype chain, so inherited names (toString/constructor/valueOf/__proto__) + // would falsely pass the guard and crash `set` / mis-print `get`. + return Object.hasOwn(CONFIG_SCHEMA, key); +} export function registerConfigCommand(program: Command): void { const config = program @@ -37,7 +150,7 @@ export function registerConfigCommand(program: Command): void { outputJson(cfg); } else { for (const key of VALID_KEYS) { - const value = cfg[key as keyof UserConfig]; + const value = cfg[key]; const schema = CONFIG_SCHEMA[key]; const valid = schema.values ? ` (${schema.values.join("|")})` : ""; console.log(` ${key.padEnd(12)} = ${value}${valid}`); @@ -57,12 +170,12 @@ export function registerConfigCommand(program: Command): void { const store = new JsonConfigStore(paths.config); const cfg = await store.load(); - if (!VALID_KEYS.includes(key)) { + if (!isConfigKey(key)) { console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); process.exit(1); } - const value = cfg[key as keyof UserConfig]; + const value = cfg[key]; if (globalOpts.json) { outputJson(configToJson(key, value)); } else { @@ -81,29 +194,47 @@ export function registerConfigCommand(program: Command): void { globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, ); const store = new JsonConfigStore(paths.config); - const cfg = await store.load(); - if (!VALID_KEYS.includes(key)) { + // Validate BEFORE touching the store: loadOrSeed() persists a seeded + // config on first boot, and a rejected command must not leave that side + // effect (or freeze the locale seed) behind. + if (!isConfigKey(key)) { console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); process.exit(1); } - // Validate value against allowed values + // Canonicalize the raw value (e.g. the 繁/简/EN labels the Settings UI + // shows for `language`), then validate against allowed values. const schema = CONFIG_SCHEMA[key]; - if (schema.values && !schema.values.includes(value)) { + const resolved = schema.normalize ? schema.normalize(value) : value; + if (schema.values && !schema.values.includes(resolved)) { console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values.join(", ")}`); process.exit(1); } - // Validated above against CONFIG_SCHEMA + VALID_KEYS, so the assignment is sound; - // the double cast through unknown acknowledges that TS can't track that proof here. - (cfg as unknown as Record)[key] = value; - await store.save(cfg); + // `set` WRITES the config, so on first boot it must seed the display + // language — otherwise persisting the defaulted "en" would freeze the + // locale seed before the user ever launches the TUI. (get/list stay + // pure load() — they don't write.) + const cfg = await store.loadOrSeed(); + + if (!schema.set(cfg, resolved)) { + console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values?.join(", ") ?? "any string"}`); + process.exit(1); + } + try { + await store.save(cfg); + } catch { + // A write command must fail when it can't persist — but cleanly, not + // with a raw EACCES stack trace (cf. the TUI settings-save hardening). + console.error(`Couldn't write config to ${paths.config} (read-only or full data dir?).`); + process.exit(1); + } if (globalOpts.json) { - outputJson(configToJson(key, value)); + outputJson(configToJson(key, resolved)); } else { - console.log(`${key} = ${value}`); + console.log(`${key} = ${resolved}`); } }); diff --git a/apps/cli/src/commands/dict.ts b/apps/cli/src/commands/dict.ts index b70972d..3d58482 100644 --- a/apps/cli/src/commands/dict.ts +++ b/apps/cli/src/commands/dict.ts @@ -15,7 +15,7 @@ export function registerDictCommand(program: Command): void { RealClock, detectColorSupport, } = await import("@iching/terminal"); - const { resolvePaths, JsonlJournalStore } = + const { resolvePaths, JsonConfigStore, JsonlJournalStore } = await import("@iching/storage"); const { makeBrowseFactory, makeDetailScene } = await import("../app/scene-factories.js"); @@ -24,8 +24,12 @@ export function registerDictCommand(program: Command): void { const paths = resolvePaths( globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, ); + // Interactive entry point: seed + freeze the display language on first + // boot (same as the main TUI), so `iching dict` honors the system locale + // rather than launching English. (config subcommands stay pure load().) + const config = await new JsonConfigStore(paths.config).loadOrSeed(); const journal = new JsonlJournalStore(paths.state); - const factoryDeps = { journal }; + const factoryDeps = { journal, language: config.language }; // Determine initial scene let initial; @@ -42,7 +46,9 @@ export function registerDictCommand(program: Command): void { const session = new TerminalSession(); const router = new SceneRouter(initial, makeBrowseFactory(factoryDeps)); - await router.run(session, new RealClock(), detectColorSupport()); + // Thread config.language into the router so the BROWSE scene honors it too + // (not just detail via factoryDeps) — P1-b fix. + await router.run(session, new RealClock(), detectColorSupport(), false, config.language); process.exit(0); }); } diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 9b7f459..bf46fbb 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -40,9 +40,11 @@ async function main() { const cacheStore = new JsonDailyCacheStore(paths.cache); const today = localToday(); - // Load and apply saved theme + // Load and apply saved theme. First boot (no config) seeds the display + // language from the system locale and persists it — so a non-English user + // is greeted in their language, frozen thereafter. const configStore = new JsonConfigStore(paths.config); - const savedConfig = await configStore.load(); + const savedConfig = await configStore.loadOrSeed(); setTheme(savedConfig.theme); let glyphConfig = { glyphAnim: savedConfig.glyphAnim, @@ -51,6 +53,7 @@ async function main() { let taijituStyle = savedConfig.taijituStyle; let castMethod = savedConfig.castMethod ?? "coin"; let castMode = savedConfig.castMode ?? "auto"; + let language = savedConfig.language; const session = new TerminalSession(); const colorSupport = detectColorSupport(); @@ -58,9 +61,9 @@ async function main() { // Bound runners — every call site uses the same session/clock/color/dev const run = (scene: Parameters[0]) => - runScene(scene, session, clock, colorSupport, devMode); + runScene(scene, session, clock, colorSupport, devMode, language); const runRouter = (router: InstanceType) => - router.run(session, clock, colorSupport, devMode); + router.run(session, clock, colorSupport, devMode, language); // Home menu loop — keeps returning to home until exit let running = true; @@ -85,6 +88,7 @@ async function main() { paths, cacheStore, today, session: { cols: session.cols, rows: session.rows }, glyphConfig, + language, motion: savedConfig.motion ?? "default", }; @@ -122,7 +126,7 @@ async function main() { const journal = new JsonlJournalStore(paths.state); const router = new SceneRouter( new BrowseScene(), - makeBrowseFactory({ glyphConfig, journal }), + makeBrowseFactory({ glyphConfig, language, journal }), ); const result = await runRouter(router); if (result.shouldExit) running = false; @@ -136,6 +140,7 @@ async function main() { new JournalScene(entries), makeJournalFactory({ glyphConfig, + language, journal, entries, session: { cols: session.cols, rows: session.rows }, @@ -150,6 +155,7 @@ async function main() { const config = await configStore.load(); const settingsScene = new SettingsScene({ theme: config.theme, + language: config.language, taijituStyle: config.taijituStyle, glyphAnim: config.glyphAnim, glyphFont: config.glyphFont, @@ -162,13 +168,24 @@ async function main() { const updated = settingsScene.getValues(); const newConfig = await configStore.load(); newConfig.theme = updated.theme; + newConfig.language = updated.language; newConfig.taijituStyle = updated.taijituStyle; newConfig.glyphAnim = updated.glyphAnim; newConfig.glyphFont = updated.glyphFont; newConfig.castMethod = updated.castMethod; newConfig.castMode = updated.castMode; - await configStore.save(newConfig); + // Best-effort persist: a read-only / full data dir must not crash + // "save & back" — the deferred-seed session explicitly supports + // reopening Settings. Apply the changes live regardless. + try { + await configStore.save(newConfig); + } catch { + console.error( + "iching: couldn't save settings (read-only data dir?); changes apply for this session only.", + ); + } setTheme(updated.theme); + language = updated.language; taijituStyle = updated.taijituStyle; castMethod = updated.castMethod; castMode = updated.castMode; diff --git a/docs/language-glossary.md b/docs/language-glossary.md new file mode 100644 index 0000000..781493e --- /dev/null +++ b/docs/language-glossary.md @@ -0,0 +1,215 @@ +# I Ching Language Glossary & Source-of-Truth Policy + +Tracked source-of-truth for the language pass (`language-translation-v1`). Verified +by `bun scripts/verify-language-surfaces.ts --glossary`. This artifact decides the +**approved** rendering, **avoided** synonyms, and **exceptions** for high-risk +terms, and fixes the source-layer authority rules so corpus, product UI, machine +tokens, and proper names are never run through one translation path. + +Source layers (authority, strongest first): + +1. **received-text** — Zhouyi line statements (爻辭). Canonical anchor; never paraphrased. +2. **commentary-wing** — the Ten Wings (大象傳/彖傳/說卦/序卦/雜卦). Canonical anchor. +3. **proper-name** — hexagram/trigram names, pinyin. Preserve; not translated. +4. **interpretive-english** — `en`/`te`/`w`/`ename`/`yaoEn`. Wilhelm-*inspired*, NOT quotation. +5. **product-ui** — app labels/footers/prompts. Localized via message catalog. +6. **machine-token** — command names, config keys, enum values, JSON keys, paths, env vars, Unicode symbols. **Preserve.** + +Default language **English**; settings order **EN → 繁 → 简**. + +## High-risk judgment terminology + +Where the data currently renders a term inconsistently, the **approved** column is +the canonical choice; AC-003 harmonizes the corpus to it. + +| Term | Layer | Approved EN | 繁 | 简 | Avoid | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| 君子 | received/wing | the noble one | 君子 | 君子 | gentleman; superior man | HARMONIZED in corpus (C-004): all "superior man" → "the noble one"; "great man" is 大人, not 君子 | +| 小人 | received | the inferior person | 小人 | 小人 | small man; petty man | gender-neutral "person" | +| 大人 | received | the great person | 大人 | 大人 | the great man | Wilhelm uses "great man"; we de-gender | +| 貞 | received | constancy / steadfast correctness | 貞 | 贞 | chastity | C-004: received-text sense = constancy/divinatory correctness; "perseverance" is the interpretive (Wilhelm) corpus form only | +| 亨 | received | success | 亨 | 亨 | offering | "prevalence/success through" | +| 利 | received | furthering | 利 | 利 | profit | the "it furthers one to…" idiom | +| 咎 | received | blame | 咎 | 咎 | fault | pairs with 無咎 = "no blame" | +| 悔 | received | remorse | 悔 | 悔 | regret | distinct from 吝 | +| 厲 | received | danger | 厲 | 厉 | severity | "danger, but no blame" pattern | +| 吝 | received | humiliation | 吝 | 吝 | stinginess | distinct from 悔 | +| 吉 | received | good fortune | 吉 | 吉 | lucky | | +| 凶 | received | misfortune | 凶 | 凶 | unlucky | | +| 元吉 | received | supreme good fortune | 元吉 | 元吉 | great luck | | +| 無咎 / 无咎 | received | no blame | 無咎 | 无咎 | blameless | **normalization exception**: 無 (Traditional) and 无 (variant) BOTH occur in the corpus as-authored — preserve in zh-Hant; both → 无 in zh-Hans | +| 利涉大川 | received | it furthers one to cross the great water | 利涉大川 | 利涉大川 | — | fixed Wilhelm-idiom rendering | +| 征 | received | to campaign / undertake a campaign | 征 | 征 | journey | C-004: martial sense (征伐/征邑國); "to set forth" too gentle for the received text | +| 往 | received | to go forward | 往 | 往 | the past | "there is somewhere to go" | +| 有孚 | received | there is trust | 有孚 | 有孚 | captives | C-004: 孚 = trust/credence between parties; "sincerity" is the interpretive (inward) form | +| 時 | wing | the right time | 時 | 时 | season | timing/timeliness (彖傳 usage) | + +## Trigrams (proper-name + image) + +| 名 | Pinyin | EN image | 简 | Exception | +| --- | --- | --- | --- | --- | +| 乾 | Qián | Heaven | 乾 | **乾 stays 乾, NEVER 干** (canonical exception) | +| 坤 | Kūn | Earth | 坤 | — | +| 震 | Zhèn | Thunder | 震 | — | +| 巽 | Xùn | Wind | 巽 | 風→风 (image) | +| 坎 | Kǎn | Water | 坎 | — | +| 離 | Lí | Fire | 离 | 離→离; 麗→丽 is a DIFFERENT char (do not confuse) | +| 艮 | Gèn | Mountain | 艮 | — | +| 兌 | Duì | Lake | 兑 | 兌→兑; 澤→泽 (image) | + +## Derived-relation labels (interpretive taxonomy — 来知德 tradition) + +| 繁 | EN | 简 | Notes | +| --- | --- | --- | --- | +| 互卦 | Nuclear | 互卦 | hidden within (lines 2-3-4-5) | +| 錯卦 | Polarity | 错卦 | complementary opposite (錯→错) | +| 綜卦 | Mirror | 综卦 | inverted vantage (綜→综) | +| 之卦 | Becoming | 之卦 | where it heads | +| 對角卦 | Diagonal | 对角卦 | 錯+綜 combined (對→对) | +| 自綜 | self-mirroring | 自综 | vertically symmetric | +| 自返 | returns to self | 自返 | locked-pair diagonal | +| 错综同象 | cross-locked image | 错综同象 | locked pair (already Simplified in source — normalize zh-Hant to 錯綜同象) | + +These are **interpretive-tradition / product terminology**, NOT received text or Ten +Wings. `衍卦` ("Derived") is a product label, not a classical category. + +## Ten Wings names + +| 繁 | EN | 简 | Notes | +| --- | --- | --- | --- | +| 大象傳 | Great Image | 大象传 | the `dx` field; "Image" UI label = 大象傳 (傳→传) | +| 彖傳 | Tuan (Commentary on the Decision) | 彖传 | the `tu` field; "Judgment" UI label maps here even though EN renders `te` | +| 說卦 | Shuogua (Discussion of the Trigrams) | 说卦 | not in current data (說→说) | +| 序卦 | Xugua (Sequence of the Hexagrams) | 序卦 | not in current data | +| 雜卦 | Zagua (Miscellaneous Notes) | 杂卦 | not in current data (雜→杂) | + +## Line designations (line-identity — preserve) + +Yang lines: **初九 九二 九三 九四 九五 上九**. Yin lines: **初六 六二 六三 六四 六五 上六**. +EN: "nine/six at the beginning", "… in the second/third/fourth/fifth place", "… at the +top". Array index is bottom→top; renderers may show top→bottom, but the position+yin/yang +identity must never be lost. English "Line N" headers currently drop yin/yang — AC-003 +restores identity. Special statements **用九 / 用六** (Qian/Kun only) are exceptional line +texts not modeled in the current 6-element `yao[]` — documented exclusion (reopen if added). + +## Pinyin + +NFC-normalized romanization; **preserved**, never translated, never regenerated from +Simplified characters. Polyphony locked to canonical hexagram readings: 否 **Pǐ**, 賁 **Bì**, +觀 **Guān**, 蹇 **Jiǎn**, 解 **Xiè**, 說/兌 **Duì**. + +## Machine tokens (preserve — never translate) + +Command names (`cast`, `config`, `hexagram`…), config keys (`language`, `theme`…), enum +**values** (`en`/`zh-Hant`/`zh-Hans`, `coin`/`yarrow`, `kaiti`/`libian`/`heiti`, theme +names, `auto`/`manual`), JSON object keys, file paths, env vars (`NO_COLOR`, `ICHING_HOME`), +and Unicode hexagram/trigram/taijitu symbols. Localized **display labels** for enum values +(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 +advice**, NOT direct quotation of Richard Wilhelm or the Wilhelm/Baynes translation. +The rendered label must not imply verbatim quotation — so the header is rendered as +**"Wilhelm-inspired"** (not bare "Wilhelm"), applied in detail-renderer per the C-005 +consult verdict ("a bare 'Wilhelm' header strongly implies quotation"). `ename`, `en`, +`te`, `yaoEn` likewise carry recognizable Wilhelm/Baynes idiom and are interpretive +English, not licensed quotation. + +## C-005 terminology-consult reconciliation + +The terminology meaning-consult (C-005) proposed less-Wilhelmese academic renderings. +**Decision:** the app is deliberately *Wilhelm-inspired*, so the primary approved renderings +keep the Wilhelm-Baynes register (consistent with the en/te/w/yaoEn corpus voice); the +consult's alternatives are recorded here as accepted-for-future / documented options, not a +mandate to rewrite the corpus (cf. AR-005). Accepted outright: the **"Wilhelm-inspired"** +label (a real honesty fix, applied). Recorded alternatives (academic, optional): +貞 → *constancy* (vs perseverance); 亨 → *fulfillment* (vs success); 利 → *beneficial* +(vs furthering); 悔 → *regret* (vs remorse); 吝 → *shame* (vs humiliation); 元吉 → *great good +fortune* (vs supreme); 利涉大川 → *…cross the great river* (vs great water); 征 → *undertake an +expedition*; 有孚 → *there is trust* (vs sincerity); 小人 → *petty person* (vs inferior person). +These are within a deliberate translation voice, not fidelity defects. + +## C-004 adversarial-audit reconciliation + +GPT-5 Pro adversarially attacked simplify.ts / messages.ts / this glossary. Triage: + +- **ACCEPTED + FIXED:** + - *Simplified:* added `陽→阳` (the audit caught the 陰/陽 asymmetry; 陽 is not in the + current corpus so it was latent, not active). **Enforced** the 乾 exception in + `toSimplified()` (check `SIMPLIFIED_EXCEPTIONS` before the map) so 乾 can never be + converted even if a future merge adds it; 幹→干 stays (distinct char), now spot-checked. + - *Yarrow:* `承策` (invented ritualese for "carry") → **續/续**; `歸奇` (the *action* + 歸奇於扐) → **奇策** (the remainder bundle, which the "set aside N" count labels). + - *Terminology:* glossary approved renderings tightened — 貞→constancy, 征→to campaign, + 有孚→there is trust (the interpretive Wilhelm forms stay in the corpus layer). + - *君子:* **harmonized** — all corpus "superior man" → "the noble one" (19 strings); a + `--core-data` check now fails if "superior man" reappears. +- **DEFERRED (documented follow-up, not v1-blocking):** + - Wilhelm close-paraphrase **phrase audit** of the `w`/`yaoEn` data — the *label* is + qualified ("Wilhelm-inspired", disclaims quotation), but a sentence-level rewrite of any + near-verbatim Baynes diction is a separate editorial pass (reopen if shipped publicly). + - Rare Ext-A/B simplified glyphs (㧑/颙/豮/觌/阒/赍/绂/鲋) — tofu risk in some terminal + fonts; flagged for a glyph-coverage check (AC-008 self-test fixture / font policy). +- **REJECTED with rationale:** + - zh-Hans "Mainland-idiom" suggestion (设置/搜索/导航/滚动/打开 vs current 设定/搜寻/导览/ + 卷动/开启): the app is a classical/contemplative tool; zh-Hans here means *Simplified + script in the same literary register*, not Mainland product-UI idiom. Deliberate, documented. + - 咷→啕 left as standard PRC simplification (audit called it defensible/minor). + +## Corpus-accuracy pass (OpenCC + ctext cross-check) + +Independent pass cross-checking the rendered corpus against OpenCC `t2s` (simplification +oracle) and the per-hexagram ctext source (received-text oracle), beyond the hand-curated +AC-006 lists. Structural invariants (King-Wen order, line-nature designations, pinyin +locks, uniqueness) all hold across 64 hexagrams; every `messages.ts` zhHans equals +OpenCC `t2s(zhHant)`. + +- **received-text fixes:** hex 61 中孚 九二 `鶴鳴`→**`鳴鶴`** (在陰 — transposition vs the + 繫辭-quoted received text); hex 57 巽 九二/上九 `牀`→**`床`** (source + hex 23 use 床). +- **`祐→佑` reviewed and KEPT:** an initial pass removed this mapping, but a second-opinion + cross-check showed simplified 周易 editions conventionally print 自天佑之 and 祐/佑 are + interchangeable for 上天保佑 — so the audited 祐→佑 stays (consistent with the table's + other convention-over-OpenCC deviations 咷→啕 / 遯→遁). zh-Hans renders 自天佑之 (hex 14). +- **Ext-B retention (new exception class):** 纆/餗/繻 keep their Traditional form in + zh-Hans because the only standard simplification is a tofu-prone CJK Ext-B glyph + (𬙊/𫗧/𦈡). Enumerated in `SIMPLIFIED_EXCEPTIONS` so coverage stays mapped-or-excepted. +- **edition variants (kept, not errors):** where gua.ts uses the more orthodox glyph + than the ctext edition — 係/系, 曆/歷, 豐/丰, 機/机, 揜/掩, 藜/蔾 — and interchangeable + classical particles (於/于, 享/亨, 弗/勿, 他/它, 之/也). 損 初九 已/巳 follows the + Legge–Wilhelm reading. These are deliberate, not fidelity defects. 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..1a0d238 --- /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: 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 +--- + +# 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/packages/core/src/__tests__/search.test.ts b/packages/core/src/__tests__/search.test.ts index 59c8339..d252771 100644 --- a/packages/core/src/__tests__/search.test.ts +++ b/packages/core/src/__tests__/search.test.ts @@ -73,4 +73,24 @@ describe("searchHexagrams", () => { expect(gua.ename.length).toBeGreaterThan(0); } }); + + // Regression (Codex P2): the zh-Hans dictionary displays simplified names, so a + // user types the simplified form they see (兑) — which must still match the + // Traditional gua.n (兌). Search now also matches toSimplified(gua.n). + test("search by Simplified name (兑) finds 兌 / KW58", () => { + const results = searchHexagrams("兑"); + expect(results.length).toBeGreaterThan(0); + expect(results[0].n).toBe("兌"); + }); + + test("search by Simplified name (观) finds 觀 / KW20", () => { + const results = searchHexagrams("观"); + expect(results.length).toBeGreaterThan(0); + expect(results[0].n).toBe("觀"); + }); + + test("Traditional name search still matches (兌)", () => { + const results = searchHexagrams("兌"); + expect(results[0].n).toBe("兌"); + }); }); diff --git a/packages/core/src/data/gua.ts b/packages/core/src/data/gua.ts index 8823ad7..a0d6037 100644 --- a/packages/core/src/data/gua.ts +++ b/packages/core/src/data/gua.ts @@ -24,7 +24,7 @@ export const GUA: Hexagram[] = [ yaoEn: [ "Hidden dragon. Do not act.", "Dragon appearing in the field. It furthers one to see the great man.", - "The superior man is active all day; at nightfall his mind is still beset with cares. Danger, but no blame.", + "The noble one is active all day; at nightfall his mind is still beset with cares. Danger, but no blame.", "Wavering flight over the depths. No blame.", "Flying dragon in the heavens. It furthers one to see the great man.", "Arrogant dragon will have cause to repent.", @@ -68,7 +68,7 @@ export const GUA: Hexagram[] = [ tu: "屯,剛柔始交而難生,動乎險中,大亨貞。", en: "Clouds and thunder gather; the noble one brings order from chaos", te: "Difficulty: firm and yielding first meet and hardship arises. Movement amid danger — great success through perseverance.", - w: "The superior man brings order out of confusion. Avoid premature action — to go only when bidden is clarity.", + w: "The noble one brings order out of confusion. Avoid premature action — to go only when bidden is clarity.", yao: [ "初九:磐桓,利居貞,利建侯。", "六二:屯如邅如,乘馬班如。匪寇婚媾,女子貞不字,十年乃字。", @@ -80,7 +80,7 @@ export const GUA: Hexagram[] = [ yaoEn: [ "Hesitation and hindrance. It furthers one to remain persevering. It furthers one to appoint helpers.", "Difficulties pile up. Horse and wagon part. He is not a robber; he wants to woo. The maiden is chaste and does not yield. Ten years, then she yields.", - "Pursuing deer without a forester, one only loses the way in the forest. The superior man senses the situation and prefers to desist. Going on brings humiliation.", + "Pursuing deer without a forester, one only loses the way in the forest. The noble one senses the situation and prefers to desist. Going on brings humiliation.", "Horse and wagon part. Seek union. Going brings good fortune; everything acts to further.", "Difficulties in bestowing his bounty. For small matters, perseverance brings good fortune. For great matters, perseverance brings misfortune.", "Horse and wagon part. Tears of blood flow.", @@ -251,7 +251,7 @@ export const GUA: Hexagram[] = [ "The spokes burst out of the wagon wheel. Husband and wife look away from each other.", "If you are sincere, blood vanishes and fear gives way. No blame.", "If you are sincere and loyally attached, you are rich in your neighbor.", - "The rain comes, there is rest. This is due to the lasting effect of character. Perseverance is dangerous for the woman. The moon is nearly full. If the superior man persists, misfortune comes.", + "The rain comes, there is rest. This is due to the lasting effect of character. Perseverance is dangerous for the woman. The moon is nearly full. If the noble one persists, misfortune comes.", ], }, { @@ -264,7 +264,7 @@ export const GUA: Hexagram[] = [ tu: "履,柔履剛也。說而應乎乾,是以履虎尾,不咥人,亨。", en: "Heaven above, lake below; the noble one distinguishes high and low, settling people's purpose", te: "Treading: the soft steps on the firm. Joyous yet responsive to heaven — thus one treads on the tiger's tail and is not bitten.", - w: "The superior man discriminates between high and low, and thereby fortifies the minds of the people. Tread carefully; even the tiger does not bite one who knows his place.", + w: "The noble one discriminates between high and low, and thereby fortifies the minds of the people. Tread carefully; even the tiger does not bite one who knows his place.", yao: [ "初九:素履往,無咎。", "九二:履道坦坦,幽人貞吉。", @@ -320,7 +320,7 @@ export const GUA: Hexagram[] = [ tu: "否之匪人,不利君子貞。大往小來,則是天地不交而萬物不通也。", en: "Heaven and earth do not unite; the noble one withdraws into frugal virtue to avoid danger", te: "Standstill does not favor the noble; the great departs, the small arrives; heaven and earth do not commune", - w: "Heaven and earth do not commune; the inferior advances. The superior man falls back upon his inner worth and does not let himself be honored with revenue.", + w: "Heaven and earth do not commune; the inferior advances. The noble one falls back upon his inner worth and does not let himself be honored with revenue.", yao: [ "初六:拔茅茹以其彙,貞吉,亨。", "六二:包承,小人吉,大人否亨。", @@ -414,9 +414,9 @@ export const GUA: Hexagram[] = [ "上六:鳴謙,利用行師,征邑國。", ], yaoEn: [ - "A modest superior man may cross the great water. Good fortune.", + "A modest noble one may cross the great water. Good fortune.", "Modesty that comes to expression. Perseverance brings good fortune.", - "A superior man of modesty and merit carries things to conclusion. Good fortune.", + "A noble one of modesty and merit carries things to conclusion. Good fortune.", "Nothing that would not further modesty in movement.", "Not boasting of wealth before one's neighbor. It is favorable to attack with force. Nothing that would not further.", "Modesty that comes to expression. It is favorable to set armies marching to chastise one's own city and country.", @@ -936,7 +936,7 @@ export const GUA: Hexagram[] = [ tu: "大壯,大者壯也。剛以動,故壯。大壯利貞,大者正也。", en: "Thunder in heaven; the noble one does not tread where propriety forbids", te: "Great power: the great is strong, the firm moves — hence strength. Great power benefits through constancy; the great must be correct.", - w: "The inferior man uses his power; this the superior man does not do. Great power must be governed by restraint.", + w: "The inferior man uses his power; this the noble one does not do. Great power must be governed by restraint.", yao: [ "初九,壯于趾,征凶,有孚。", "九二,貞吉。", @@ -1104,7 +1104,7 @@ export const GUA: Hexagram[] = [ tu: "解,險以動,動而免乎險,解。解利西南,往得眾也。", en: "Thunder and rain arise; the noble one pardons mistakes and forgives faults", te: "Deliverance: danger spurs movement; moving to escape peril, this is release. Deliverance favors the southwest — going there wins the multitude.", - w: "Thunder and rain bring release. The superior man pardons mistakes and forgives misdeeds — when deliverance comes, return swiftly to rest.", + w: "Thunder and rain bring release. The noble one pardons mistakes and forgives misdeeds — when deliverance comes, return swiftly to rest.", yao: [ "初六,無咎。", "九二,田獲三狐,得黃矢,貞吉。", @@ -1160,7 +1160,7 @@ export const GUA: Hexagram[] = [ tu: "益,損上益下,民說無疆。自上下下,其道大光。利有攸往,中正有慶。", en: "Wind and thunder increase; the noble one moves toward good and corrects faults", te: "Increase: lessening above to increase below — the people rejoice without limit. Descending from on high, its way shines greatly. Advantage in having somewhere to go; centered and correct brings blessing.", - w: "When the superior man sees good, he moves toward it. When he has faults, he corrects them. To rule truly is to serve — decrease oneself to increase the people.", + w: "When the noble one sees good, he moves toward it. When he has faults, he corrects them. To rule truly is to serve — decrease oneself to increase the people.", yao: [ "初九,利用為大作,元吉,無咎。", "六二,或益之十朋之龜,弗克違,永貞吉。王用享于帝,吉。", @@ -1300,7 +1300,7 @@ export const GUA: Hexagram[] = [ tu: "困,剛揜也。險以說,困而不失其所亨,其唯君子乎。", en: "Lake without water; the noble one risks life to fulfill their purpose", te: "Oppression: the firm is eclipsed. Danger meets joy; exhausted yet not losing what brings success — only the noble one can do this.", - w: "Outer circumstances press inward, yet the heart remains free. The superior man perseveres with inner cheerfulness that cannot be exhausted.", + w: "Outer circumstances press inward, yet the heart remains free. The noble one perseveres with inner cheerfulness that cannot be exhausted.", yao: [ "初六,臀困于株木,入于幽谷,三歲不覿。", "九二,困于酒食,朱紱方來,利用享祀,征凶,無咎。", @@ -1384,7 +1384,7 @@ export const GUA: Hexagram[] = [ tu: "鼎,象也。以木巽火,亨飪也。聖人亨以享上帝,而大亨以養聖賢。", en: "Fire above wood; the noble one rectifies their position and fulfills destiny", te: "The cauldron: a symbol. Wood feeds fire for cooking. The sage offers sacrifice to the supreme; great nourishment to cultivate the wise and worthy.", - w: "The superior man consolidates fate by making position correct. Personal integrity is an active consolidation of life direction.", + w: "The noble one consolidates fate by making position correct. Personal integrity is an active consolidation of life direction.", yao: [ "初六:鼎顛趾,利出否。得妾以其子,無咎。", "九二:鼎有實,我仇有疾,不我能即,吉。", @@ -1412,7 +1412,7 @@ export const GUA: Hexagram[] = [ tu: "震亨,震來虩虩,恐致福也。笑言啞啞,後有則也。", en: "Repeated thunder; the noble one cultivates reverence and self-reflection", te: "Thunder and success: the shock arrives with fear and trembling — fear brings blessing. Laughter and talk follow; afterward comes proper measure.", - w: "Fear brings good fortune; afterward one has a rule. The superior man uses apprehension as a catalyst for establishing proper principles.", + w: "Fear brings good fortune; afterward one has a rule. The noble one uses apprehension as a catalyst for establishing proper principles.", yao: [ "初九:震來虩虩,後笑言啞啞,吉。", "六二:震來厲,億喪貝,躋于九陵,勿逐,七日得。", @@ -1440,7 +1440,7 @@ export const GUA: Hexagram[] = [ tu: "艮,止也。時止則止,時行則行,動靜不失其時,其道光明。", en: "Mountains joined together; the noble one keeps thoughts within their place", te: "Keeping still means stopping. When it is time to stop, stop; when time to act, act. Movement and rest do not miss their moment — this way shines bright.", - w: "The superior man does not permit thoughts to go beyond the situation. Noble-hearted keeping still leads to complete inner development.", + w: "The noble one does not permit thoughts to go beyond the situation. Noble-hearted keeping still leads to complete inner development.", yao: [ "初六:艮其趾,無咎,利永貞。", "六二:艮其腓,不拯其隨,其心不快。", @@ -1468,7 +1468,7 @@ export const GUA: Hexagram[] = [ tu: "漸之進也,女歸吉也。進得位,往有功也。進以正,可以正邦也。", en: "Wood upon the mountain; the noble one dwells in virtue and improves customs", te: "Development: gradual progress. The maiden marrying brings fortune — advancing finds its place, going forward brings achievement. Advancing through rightness, one may set the state in order.", - w: "The wild goose approaches the shore step by step. The superior man abides in dignity and virtue, and thereby improves the customs of the people.", + w: "The wild goose approaches the shore step by step. The noble one abides in dignity and virtue, and thereby improves the customs of the people.", yao: [ "初六:鴻漸于干,小子厲,有言,無咎。", "六二:鴻漸于磐,飲食衎衎,吉。", @@ -1496,7 +1496,7 @@ export const GUA: Hexagram[] = [ tu: "歸妹,天地之大義也。天地不交而萬物不興。歸妹,人之終始也。", en: "Thunder above the lake; the noble one understands flaws by considering the end", te: "The marrying maiden: heaven and earth's great meaning. Without their union, nothing flourishes. The maiden's marriage marks the end and beginning of human life.", - w: "The superior man understands the transitory in light of eternity. Recognize emerging tendencies early; guide them before they become unmanageable.", + w: "The noble one understands the transitory in light of eternity. Recognize emerging tendencies early; guide them before they become unmanageable.", yao: [ "初九:歸妹以娣,跛能履,征吉。", "九二:眇能視,利幽人之貞。", @@ -1524,7 +1524,7 @@ export const GUA: Hexagram[] = [ tu: "豐,大也。明以動,故豐。王假之,尚大也。勿憂宜日中。", en: "Thunder and lightning arrive together; the noble one decides cases and applies consequences", te: "Abundance means greatness: clarity with movement, hence fullness. The king reaches this — exalting what is great. Do not worry; be like the sun at noon.", - w: "Abundance requires maintaining clarity. The superior man rouses will through trustworthiness — sincerity is the foundation for influence.", + w: "Abundance requires maintaining clarity. The noble one rouses will through trustworthiness — sincerity is the foundation for influence.", yao: [ "初九:遇其配主,雖旬無咎,往有尚。", "六二:豐其蔀,日中見斗,往得疑疾。有孚發若,吉。", @@ -1583,11 +1583,11 @@ export const GUA: Hexagram[] = [ w: "Wind follows upon wind. The gentle penetrates as water wears away stone — not through force, but through quiet persistence in a single direction.", yao: [ "初六:進退,利武人之貞。", - "九二:巽在牀下,用史巫紛若,吉,無咎。", + "九二:巽在床下,用史巫紛若,吉,無咎。", "九三:頻巽,吝。", "六四:悔亡,田獲三品。", "九五:貞吉,悔亡,無不利。無初有終。先庚三日,後庚三日,吉。", - "上九:巽在牀下,喪其資斧,貞凶。", + "上九:巽在床下,喪其資斧,貞凶。", ], yaoEn: [ "Advancing and retreating in hesitation. The perseverance of a military person is favorable.", @@ -1695,7 +1695,7 @@ export const GUA: Hexagram[] = [ w: "True power emerges from internal character. Without central transformative force from within, external unity becomes deception.", yao: [ "初九:虞吉,有他不燕。", - "九二:鶴鳴在陰,其子和之。我有好爵,吾與爾靡之。", + "九二:鳴鶴在陰,其子和之。我有好爵,吾與爾靡之。", "六三:得敵,或鼓或罷,或泣或歌。", "六四:月幾望,馬匹亡,無咎。", "九五:有孚攣如,無咎。", @@ -1720,7 +1720,7 @@ export const GUA: Hexagram[] = [ tu: "小過,小者過而亨也。過以利貞,與時行也。柔得中,是以小事吉也。", en: "Thunder above the mountain; the noble one exceeds in reverence", te: "Small exceeding: the small exceeds and succeeds. Exceeding benefits through constancy, moving with the season. The yielding finds center — hence fortune in small matters.", - w: "The superior man fixes eyes more closely on duty than the ordinary person. Genuine strength lies in meticulous attention to conduct.", + w: "The noble one fixes eyes more closely on duty than the ordinary person. Genuine strength lies in meticulous attention to conduct.", yao: [ "初六:飛鳥以凶。", "六二:過其祖,遇其妣。不及其君,遇其臣,無咎。", diff --git a/packages/core/src/data/large-glyphs.ts b/packages/core/src/data/large-glyphs.ts index 382712d..65fa4a2 100644 --- a/packages/core/src/data/large-glyphs.ts +++ b/packages/core/src/data/large-glyphs.ts @@ -1,5 +1,5 @@ // Auto-generated by scripts/generate-glyphs.ts -// 639 glyphs: 71 unique characters × 3 fonts × 3 sizes +// 864 glyphs: 96 unique characters × 3 fonts × 3 sizes // Each glyph is an array of braille row strings for scanline animation. // Keyed by INDIVIDUAL CHARACTER — render multi-char names by looking up each char. // @@ -151,6 +151,274 @@ export const LARGE_GLYPHS: Record Simplified (繁 -> 简) conversion for the I Ching corpus + UI. +// +// Zero runtime dependency (matches the embedded-data ethos of data/large-glyphs.ts). +// The table is AUDITED for the rendered corpus, not a naive partial map: +// - covers every non-identity Traditional character in the 929-char rendered +// corpus (hexagram names + 大象傳/彖傳/爻辭), sourced from Agentify consult +// C-002 (see .loop/language/CONSULTS.md), plus the UI section-label/variant +// characters (傳/辭/記/鎖/…) that the corpus extraction did not include; +// - CANONICAL EXCEPTION: 乾 is deliberately ABSENT — in this corpus 乾 is +// Heaven (乾卦) and must stay 乾, never become 干 (干 is the simplified of the +// distinct char 幹, which IS mapped below). See docs/language-glossary.md. +// - classical false-friends resolved one-directionally (繁->简 only): 後->后, +// 雲->云, 麗->丽 vs 離->离, 係/繫->系, 於->于, 穀->谷, 幾->几. 藉 is intentionally +// NOT converted (易经「藉用白茅」keeps 藉). +// - EXT-B RETENTION: 纆(hex29 徽纆)、餗(hex50 覆公餗)、繻(hex63 繻有衣袽) stay +// Traditional. Their only standard simplification is a CJK Ext-B glyph +// (𬙊/𫗧/𦈡, U+2xxxx) that renders as tofu in terminal fonts, so the readable +// BMP Traditional form is kept deliberately. Listed in SIMPLIFIED_EXCEPTIONS. +// - 祐->佑 (示, 自天祐之 hex14) is KEPT: 祐/佑 are interchangeable for 上天保佑 and +// simplified 周易 editions conventionally print 自天佑之 (cf. 咷->啕, 遯->遁: the +// table deliberately follows PRC convention past OpenCC's stricter t2s here). +// +// Conversion is one-directional (繁->简) and per-character; it is only ever applied +// to known Traditional source text, so the merge ambiguities of the reverse +// direction (简->繁) do not arise. + +/** + * Characters that must NEVER be auto-converted. + * - 乾: semantic-collision exception — Heaven (乾卦) must stay 乾, never 干. + * - 纆/餗/繻: Ext-B-tofu exception — standard simplification is an unreadable + * CJK Ext-B glyph (𬙊/𫗧/𦈡), so the BMP Traditional form is retained. + */ +export const SIMPLIFIED_EXCEPTIONS: readonly string[] = ["乾", "纆", "餗", "繻"]; + +/** Audited Traditional -> Simplified character map (non-identity only). */ +export const SIMPLIFIED_MAP: Readonly> = { + // ── corpus map (Agentify C-002, audited) ── + 並: "并", 亂: "乱", 來: "来", 係: "系", 傾: "倾", 僕: "仆", 儀: "仪", 億: "亿", + 儉: "俭", 兌: "兑", 內: "内", 兩: "两", 則: "则", 剛: "刚", 剝: "剥", 動: "动", + 勝: "胜", 勞: "劳", 勢: "势", 勸: "劝", 厲: "厉", 叢: "丛", 咷: "啕", 問: "问", + 啞: "哑", 喪: "丧", 嚮: "向", 嚴: "严", 國: "国", 園: "园", 執: "执", 堅: "坚", + 塗: "涂", 壯: "壮", 奮: "奋", 婦: "妇", 宮: "宫", 實: "实", 寧: "宁", 寵: "宠", + 對: "对", 屨: "屦", 帥: "帅", 師: "师", 帶: "带", 幹: "干", 幾: "几", 廟: "庙", + 廬: "庐", 張: "张", 強: "强", 彙: "汇", 後: "后", 從: "从", 復: "复", 恆: "恒", + 悶: "闷", 惡: "恶", 惻: "恻", 慍: "愠", 慶: "庆", 憂: "忧", 應: "应", 懲: "惩", + 懷: "怀", 懼: "惧", 戔: "戋", 戰: "战", 戶: "户", 揚: "扬", 損: "损", 撝: "㧑", + 擊: "击", 據: "据", 攣: "挛", 敗: "败", 敵: "敌", 數: "数", 於: "于", 時: "时", + 晉: "晋", 曆: "历", 東: "东", 棄: "弃", 棟: "栋", 楊: "杨", 樂: "乐", 橈: "桡", + 機: "机", 歲: "岁", 歸: "归", 殺: "杀", 氣: "气", 決: "决", 況: "况", 淵: "渊", + 渙: "涣", 滅: "灭", 漣: "涟", 漸: "渐", 潛: "潜", 澤: "泽", 濟: "济", 災: "灾", + 為: "为", 無: "无", 爾: "尔", 牀: "床", 牽: "牵", 獄: "狱", 獨: "独", 獲: "获", + 瑣: "琐", 甕: "瓮", 異: "异", 當: "当", 疇: "畴", 發: "发", 眾: "众", 碩: "硕", + 祐: "佑", 祿: "禄", 禦: "御", 禮: "礼", 稱: "称", 穀: "谷", 積: "积", 窮: "穷", + 窺: "窥", 節: "节", 篤: "笃", 約: "约", 納: "纳", 紛: "纷", 紱: "绂", 終: "终", + 統: "统", 經: "经", 維: "维", 綸: "纶", 緩: "缓", 繫: "系", 繼: "继", 續: "续", + 罰: "罚", 罷: "罢", 義: "义", 習: "习", 聖: "圣", 聞: "闻", 膚: "肤", 臨: "临", + 與: "与", 興: "兴", 舊: "旧", 艱: "艰", 茲: "兹", 莧: "苋", 華: "华", 萬: "万", + 蒞: "莅", 薦: "荐", 藥: "药", 蘇: "苏", 處: "处", 虛: "虚", 號: "号", 虧: "亏", + 蠱: "蛊", 衛: "卫", 見: "见", 視: "视", 親: "亲", 覿: "觌", 觀: "观", 觸: "触", + 訟: "讼", 設: "设", 語: "语", 誡: "诫", 誥: "诰", 說: "说", 諸: "诸", 謀: "谋", + 謂: "谓", 謙: "谦", 講: "讲", 識: "识", 議: "议", 譽: "誉", 變: "变", 豐: "丰", + 豶: "豮", 貝: "贝", 貞: "贞", 負: "负", 財: "财", 貫: "贯", 貳: "贰", 賁: "贲", + 資: "资", 賓: "宾", 賞: "赏", 賢: "贤", 躋: "跻", 躍: "跃", 車: "车", 載: "载", + 輔: "辅", 輝: "辉", 輪: "轮", 輻: "辐", 輿: "舆", 連: "连", 進: "进", 過: "过", + 違: "违", 遠: "远", 遯: "遁", 遲: "迟", 遷: "迁", 遺: "遗", 鄰: "邻", 醜: "丑", + 鉉: "铉", 錫: "锡", 錯: "错", 長: "长", 門: "门", 閉: "闭", 開: "开", 閑: "闲", + 闃: "阒", 闚: "窥", 關: "关", 陰: "阴", 陸: "陆", 階: "阶", 隕: "陨", 隨: "随", + 險: "险", 雖: "虽", 離: "离", 難: "难", 雲: "云", 電: "电", 靈: "灵", 靜: "静", + 鞏: "巩", 頂: "顶", 順: "顺", 須: "须", 預: "预", 頤: "颐", 頰: "颊", 頻: "频", + 顒: "颙", 顛: "颠", 類: "类", 顯: "显", 風: "风", 飛: "飞", 飪: "饪", 飲: "饮", + 養: "养", 饋: "馈", 馬: "马", 馮: "冯", 驅: "驱", 魚: "鱼", 鮒: "鲋", 鳥: "鸟", + 鳴: "鸣", 鴻: "鸿", 鶴: "鹤", 麗: "丽", 黃: "黄", 齎: "赍", 龍: "龙", 龜: "龟", + // ── UI section-label + variant supplements (vetted; not in corpus extraction) ── + 傳: "传", 辭: "辞", 記: "记", 鎖: "锁", 卽: "即", 嘆: "叹", 旣: "既", 矇: "蒙", + 結: "结", 羣: "群", 聽: "听", 趨: "趋", 跡: "迹", 麤: "粗", 縣: "县", 爲: "为", + 衆: "众", 裏: "里", 禍: "祸", 綜: "综", 羅: "罗", 殘: "残", 沒: "没", 樹: "树", + 斷: "断", 會: "会", 極: "极", 盜: "盗", 適: "适", 雜: "杂", 體: "体", 驚: "惊", + 餘: "余", + // C-004 adversarial fix: 陰 was mapped but 陽 was not (asymmetry). 陽 is not in the + // current corpus, but the pair is added for completeness/future-proofing. + 陽: "阳", +}; + +/** + * Convert Traditional Chinese text to Simplified using the audited corpus table. + * Characters not in the map (including the 乾 exception and already-Simplified or + * script-neutral characters) pass through unchanged. + */ +const EXCEPTION_SET = new Set(SIMPLIFIED_EXCEPTIONS); +export function toSimplified(text: string): string { + let out = ""; + for (const ch of text) { + // ENFORCE the exception list before the map — so 乾 can never be converted + // even if a future table merge accidentally adds 乾: "干" (C-004 adversarial fix). + out += EXCEPTION_SET.has(ch) ? ch : (SIMPLIFIED_MAP[ch] ?? ch); + } + return out; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7590b71..7603961 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ // Types export type { Style, + DisplayLanguage, Hexagram, LineValue, Line, @@ -57,6 +58,9 @@ export { } from "./data/trigrams.js"; export { LARGE_GLYPHS, type GlyphFont, type GlyphSize, type GlyphEntry } from "./data/large-glyphs.js"; +// i18n — audited Traditional -> Simplified conversion +export { toSimplified, SIMPLIFIED_MAP, SIMPLIFIED_EXCEPTIONS } from "./i18n/simplify.js"; + // Format export { formatReading, getRandomQuoteStyle } from "./format/reading.js"; export { formatDerived } from "./format/derived.js"; diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts index 60406bf..e142562 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -2,6 +2,7 @@ import type { Hexagram } from "./types.js"; import { GUA } from "./data/gua.js"; +import { toSimplified } from "./i18n/simplify.js"; /** Strip diacritics from a string for accent-insensitive matching */ function normalize(str: string): string { @@ -33,18 +34,23 @@ export function searchHexagrams(query: string): Hexagram[] { const kw = i + 1; const kwStr = String(kw); const chinese = gua.n.toLowerCase(); + // Also match the Simplified rendering of the name: in zh-Hans mode the + // dictionary displays toSimplified(gua.n) (e.g. 兑), so a user types the + // simplified form they see — which would never match the Traditional gua.n. + const chineseSimp = toSimplified(gua.n).toLowerCase(); const pinyin = normalize(gua.p); const english = normalize(gua.ename); let bestScore = Infinity; // Exact match (score 0) - if (chinese === q || pinyin === q || english === q || kwStr === q) { + if (chinese === q || chineseSimp === q || pinyin === q || english === q || kwStr === q) { bestScore = 0; } // Prefix match (score 1) else if ( chinese.startsWith(q) || + chineseSimp.startsWith(q) || pinyin.startsWith(q) || english.startsWith(q) || kwStr.startsWith(q) @@ -54,6 +60,7 @@ export function searchHexagrams(query: string): Hexagram[] { // Contains match (score 2) else if ( chinese.includes(q) || + chineseSimp.includes(q) || pinyin.includes(q) || english.includes(q) ) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c62762b..eb3e589 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -8,6 +8,9 @@ export type Style = "dx" | "tu" | "en" | "te" | "w" | "st"; */ export type QuoteStyle = Exclude; +/** Display language preference. Text coverage is incremental per surface. */ +export type DisplayLanguage = "en" | "zh-Hant" | "zh-Hans"; + /** Hexagram entry in GUA array */ export interface Hexagram { u: string; // Unicode symbol diff --git a/packages/storage/src/__tests__/atomic-write.test.ts b/packages/storage/src/__tests__/atomic-write.test.ts index 6933a75..4d2e8cc 100644 --- a/packages/storage/src/__tests__/atomic-write.test.ts +++ b/packages/storage/src/__tests__/atomic-write.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach } from "bun:test"; -import { mkdtemp, readFile, readdir } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { atomicWriteJson } from "../json/atomic-write.js"; @@ -48,4 +48,15 @@ describe("atomicWriteJson", () => { const raw = await readFile(path, "utf-8"); expect(JSON.parse(raw)).toEqual({ version: 2 }); }); + + test("a failed write does not leak the temp file", async () => { + // The target is an existing DIRECTORY, so the temp write succeeds but the + // final rename fails — the failure path the review traced (ENOSPC/EIO are + // not injectable here; rename-failure exercises the same cleanup). + const path = join(dir, "iam-a-dir"); + await mkdir(path); + await expect(atomicWriteJson(path, { v: 1 })).rejects.toThrow(); + const leftovers = (await readdir(dir)).filter((f) => f.endsWith(".tmp")); + expect(leftovers).toEqual([]); // repeated failed saves must not accumulate temps + }); }); diff --git a/packages/storage/src/__tests__/config-store.test.ts b/packages/storage/src/__tests__/config-store.test.ts index e5a6949..bc27c02 100644 --- a/packages/storage/src/__tests__/config-store.test.ts +++ b/packages/storage/src/__tests__/config-store.test.ts @@ -1,9 +1,25 @@ import { describe, test, expect, beforeEach } from "bun:test"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, writeFile, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { UserConfig } from "../types.js"; -import { JsonConfigStore } from "../json/json-config.js"; +import { JsonConfigStore, detectSystemLanguage } from "../json/json-config.js"; + +const LOCALE_VARS = ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] as const; +/** Set locale env vars deterministically (clearing the rest) and return a restorer. */ +function withLocaleEnv(vars: Partial>): () => void { + const prev = Object.fromEntries(LOCALE_VARS.map((k) => [k, process.env[k]])); + for (const k of LOCALE_VARS) { + if (vars[k] === undefined) delete process.env[k]; + else process.env[k] = vars[k]; + } + return () => { + for (const k of LOCALE_VARS) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + }; +} describe("JsonConfigStore", () => { let dir: string; @@ -19,6 +35,7 @@ describe("JsonConfigStore", () => { expect(config).toEqual({ motion: "default", + language: "en", theme: "bone", color: "auto", timezone: "system", @@ -33,6 +50,7 @@ describe("JsonConfigStore", () => { test("save then load round-trip with non-default cast settings", async () => { const custom: UserConfig = { motion: "brisk", + language: "en", theme: "bone", color: "never", timezone: "America/New_York", @@ -57,6 +75,7 @@ describe("JsonConfigStore", () => { const config = await store.load(); expect(config).toEqual({ motion: "deep", + language: "en", theme: "bone", color: "auto", timezone: "system", @@ -94,4 +113,302 @@ describe("JsonConfigStore", () => { const b = await store.load(); expect(b.taijituStyle).toBe("dense"); }); + + test("load defaults invalid language values", async () => { + await writeFile(join(dir, "config.json"), JSON.stringify({ language: "klingon" }), "utf-8"); + const loaded = await store.load(); + expect(loaded.language).toBe("en"); + }); + + test("load accepts display language aliases from hand-edited config", async () => { + await writeFile(join(dir, "config.json"), JSON.stringify({ language: "简" }), "utf-8"); + const simplified = await store.load(); + expect(simplified.language).toBe("zh-Hans"); + + await writeFile(join(dir, "config.json"), JSON.stringify({ language: "EN" }), "utf-8"); + const english = await store.load(); + expect(english.language).toBe("en"); + }); + + // Regression: alias/legacy lookups walked the prototype chain, so inherited + // names (constructor/__proto__/toString) resolved to functions on the config. + test("inherited prototype names as values do NOT resolve via alias maps", async () => { + for (const key of ["language", "theme", "castMode"]) { + for (const bad of ["constructor", "__proto__", "toString"]) { + await writeFile(join(dir, "config.json"), JSON.stringify({ [key]: bad }), "utf-8"); + const cfg = await store.load(); + expect(typeof cfg.language).toBe("string"); + expect(typeof cfg.theme).toBe("string"); + expect(cfg.castMethod === "coin" || cfg.castMethod === "yarrow").toBe(true); + expect(cfg.castMode === "auto" || cfg.castMode === "manual").toBe(true); + } + } + }); + + test("language matching is case-insensitive (BCP-47)", async () => { + for (const [raw, want] of [["zh-hans", "zh-Hans"], ["ZH-HANT", "zh-Hant"], ["En", "en"]] as const) { + await writeFile(join(dir, "config.json"), JSON.stringify({ language: raw }), "utf-8"); + expect((await store.load()).language).toBe(want); + } + }); + + test("unknown own-keys survive a load→save round-trip (schemas only expand)", async () => { + await writeFile(join(dir, "config.json"), JSON.stringify({ theme: "ink", futureKey: "keepme" }), "utf-8"); + const cfg = await store.load(); + expect((cfg as unknown as Record).futureKey).toBe("keepme"); + await store.save(cfg); + const reloaded = JSON.parse(await readFile(join(dir, "config.json"), "utf-8")); + expect(reloaded.futureKey).toBe("keepme"); // not stripped by the whitelist + }); + + test("a corrupt config is backed up to .corrupt before falling to defaults", async () => { + const path = join(dir, "config.json"); + await writeFile(path, "{ broken json", "utf-8"); + const cfg = await store.load(); + expect(cfg.language).toBe("en"); // degraded to defaults + expect(await readFile(`${path}.corrupt`, "utf-8")).toBe("{ broken json"); // recoverable + }); + + // ── first-boot system-language seed (loadOrSeed) ── + + test("load() stays pure on first boot — defaults, no detection, no file written", async () => { + const cfg = await store.load(); + expect(cfg.language).toBe("en"); // never the detected locale + await expect(readFile(join(dir, "config.json"), "utf-8")).rejects.toThrow(); // no write + }); + + test("loadOrSeed seeds a valid language and PERSISTS it on first boot", async () => { + const seeded = await store.loadOrSeed(); + expect(["en", "zh-Hant", "zh-Hans"]).toContain(seeded.language); + // persisted: the file now exists and a pure load() round-trips the same value + const reloaded = await store.load(); + expect(reloaded).toEqual(seeded); + }); + + test("loadOrSeed freezes the first-boot language — later locale changes are ignored", async () => { + const restore = withLocaleEnv({ LC_ALL: "zh_CN.UTF-8" }); // LANGUAGE/LANG/etc cleared + try { + const first = await store.loadOrSeed(); + expect(first.language).toBe("zh-Hans"); // seeded Simplified from the locale + + process.env.LC_ALL = "en_US.UTF-8"; // user later moves to an English shell + const second = await store.loadOrSeed(); + expect(second.language).toBe("zh-Hans"); // …but the saved choice is frozen + } finally { + restore(); + } + }); + + // ── resilience (gate findings) ── + + test("load() degrades to defaults on a corrupt config file — does not throw", async () => { + await writeFile(join(dir, "config.json"), "{ not valid json", "utf-8"); + const cfg = await store.load(); + expect(cfg.language).toBe("en"); + expect(cfg.theme).toBe("bone"); // full defaults, not a crash + }); + + // PIN FLIP (review P1/P2): the recoverable copy is .corrupt — leaving the live + // file unreadable caused a silent settings-reset at the NEXT save, repeated + // warnings every load, and an English session for zh-locale users. loadOrSeed + // now treats corrupt like a re-seed: heal the file (locale included) once the + // bytes are safely backed up. + test("loadOrSeed() re-seeds and heals a corrupt config (original kept in .corrupt)", async () => { + const restore = withLocaleEnv({ LC_ALL: "zh_TW.UTF-8" }); + try { + const path = join(dir, "config.json"); + await writeFile(path, "{ corrupt", "utf-8"); + const cfg = await store.loadOrSeed(); + expect(cfg.language).toBe("zh-Hant"); // locale honored, not bare defaults + // healed: the live file is valid again and round-trips the seeded language + const healed = JSON.parse(await readFile(path, "utf-8")); + expect(healed.language).toBe("zh-Hant"); + // the unreadable original survives for hand-recovery + expect(await readFile(`${path}.corrupt`, "utf-8")).toBe("{ corrupt"); + } finally { + restore(); + } + }); + + test("a failed save() keeps the live config in memory for session reloads", async () => { + // X1 (cross-model finding): when "save & back" can't persist, a later + // load() in the same session must return the user's edits, not stale disk. + // `blocker` is a FILE, so mkdir/open under it fails with ENOTDIR. + await writeFile(join(dir, "blocker"), "not a dir", "utf-8"); + const store2 = new JsonConfigStore(join(dir, "blocker", "config.json")); + const edited: UserConfig = { ...(await store.load()), theme: "jade", language: "zh-Hant" }; + await expect(store2.save(edited)).rejects.toThrow(); // the write still fails… + const reloaded = await store2.load(); + expect(reloaded.theme).toBe("jade"); // …but the session sees the edits + expect(reloaded.language).toBe("zh-Hant"); + }); + + test("unknown keys that shadow prototype names are preserved (hasOwn, not `in`)", async () => { + const path = join(dir, "config.json"); + await writeFile(path, JSON.stringify({ theme: "jade", toString: "future-value" }), "utf-8"); + const cfg = await store.load(); + expect(cfg.theme).toBe("jade"); + // `"toString" in DEFAULT_CONFIG` is true via the prototype chain — the old + // guard silently dropped a legitimate future key on rewrite. + expect(Object.hasOwn(cfg, "toString")).toBe(true); + expect((cfg as unknown as Record)["toString"]).toBe("future-value"); + }); + + test("prototype-chain keys in the config file are dropped and never pollute", async () => { + const path = join(dir, "config.json"); + await writeFile( + path, + '{"theme":"jade","__proto__":{"polluted":1},"constructor":{"c":1},"prototype":{"p":1}}', + "utf-8", + ); + const cfg = await store.load(); + expect(cfg.theme).toBe("jade"); + expect(Object.getPrototypeOf(cfg)).toBe(Object.prototype); // proto not swapped + expect(({} as Record).polluted).toBeUndefined(); // no global pollution + for (const k of ["__proto__", "constructor", "prototype"]) { + expect(Object.hasOwn(cfg, k)).toBe(false); // explicitly dropped, not carried + } + }); + + test("the .corrupt backup is never clobbered by a later corruption", async () => { + const path = join(dir, "config.json"); + await writeFile(path, "{ original user data", "utf-8"); + await store.load(); // creates the backup + await writeFile(path, "xx", "utf-8"); // a second, worse corruption + await new JsonConfigStore(path).load(); + // the first backup (the recoverable one) survives + expect(await readFile(`${path}.corrupt`, "utf-8")).toBe("{ original user data"); + }); + + test("the corrupt warning prints once per store instance, not per load", async () => { + const path = join(dir, "config.json"); + await writeFile(path, "{ corrupt", "utf-8"); + const calls: unknown[] = []; + const original = console.error; + console.error = (...args: unknown[]) => void calls.push(args); + try { + await store.load(); + await store.load(); // e.g. reopening Settings while the TUI owns the screen + await store.load(); + } finally { + console.error = original; + } + expect(calls.length).toBe(1); + }); + + test("loadOrSeed() does not crash when persisting the first-boot seed fails", async () => { + // First-boot scenario where the file is absent (ENOENT → seed) but the + // persist fails (read-only / full data dir). Stub save() to reject so the + // test is deterministic and permission-model independent (root-proof). + class FailingSaveStore extends JsonConfigStore { + override async save(): Promise { + throw Object.assign(new Error("EACCES: read-only"), { code: "EACCES" }); + } + } + const path = join(dir, "config.json"); + const store2 = new FailingSaveStore(path); // no file → ENOENT → seed → save throws + const cfg = await store2.loadOrSeed(); // must resolve, not reject + expect(["en", "zh-Hant", "zh-Hans"]).toContain(cfg.language); // ran with detected lang + // persist failed, so nothing was frozen to disk this session + await expect(readFile(path, "utf-8")).rejects.toThrow(); + }); + + test("a deferred (unwritable) seed is retained in memory for same-session reloads", async () => { + // Comment-2 fix: when the seed can't be persisted, a later config reload in + // the same session (e.g. opening Settings) must NOT flip back to defaults. + class FailingSaveStore extends JsonConfigStore { + override async save(): Promise { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + } + } + const restore = withLocaleEnv({ LC_ALL: "zh_TW.UTF-8" }); // deterministic detected language + try { + const store2 = new FailingSaveStore(join(dir, "config.json")); + const seeded = await store2.loadOrSeed(); + expect(seeded.language).toBe("zh-Hant"); + const reloaded = await store2.load(); // same store, same session + expect(reloaded.language).toBe("zh-Hant"); // not "en" — seed retained in memory + } finally { + restore(); + } + }); + + test("loadOrSeed seeds the language for a pre-language upgrade config (no language key)", async () => { + const restore = withLocaleEnv({ LC_ALL: "zh_TW.UTF-8" }); + try { + // a config from before the language field existed: settings but no `language` + await writeFile(join(dir, "config.json"), JSON.stringify({ theme: "ink", motion: "brisk" }), "utf-8"); + const cfg = await store.loadOrSeed(); + expect(cfg.language).toBe("zh-Hant"); // seeded from the locale + expect(cfg.theme).toBe("ink"); // existing settings preserved + expect(cfg.motion).toBe("brisk"); + process.env.LC_ALL = "en_US.UTF-8"; // now a language key exists → frozen + expect((await store.loadOrSeed()).language).toBe("zh-Hant"); + } finally { + restore(); + } + }); + + test("loadOrSeed does NOT re-seed when a language key is already present", async () => { + const restore = withLocaleEnv({ LC_ALL: "zh_TW.UTF-8" }); + try { + await writeFile(join(dir, "config.json"), JSON.stringify({ language: "en", theme: "ink" }), "utf-8"); + expect((await store.loadOrSeed()).language).toBe("en"); // honors the stored choice + } finally { + restore(); + } + }); +}); + +describe("detectSystemLanguage", () => { + const cases: Array<[Record, UserConfig["language"]]> = [ + [{ LANG: "en_US.UTF-8" }, "en"], + [{}, "en"], // unset + [{ LANG: "C" }, "en"], + [{ LANG: "POSIX" }, "en"], + [{ LANG: "zh_CN.UTF-8" }, "zh-Hans"], + [{ LANG: "zh_SG.UTF-8" }, "zh-Hans"], + [{ LANG: "zh_TW.UTF-8" }, "zh-Hant"], + [{ LANG: "zh_HK.UTF-8" }, "zh-Hant"], + [{ LANG: "zh_MO.UTF-8" }, "zh-Hant"], + [{ LANG: "zh" }, "zh-Hans"], // bare zh → Simplified + [{ LANG: "zh-Hans" }, "zh-Hans"], // BCP-47 + [{ LANG: "zh-Hant-TW" }, "zh-Hant"], // BCP-47 with script + [{ LANG: "zh-Hant-CN" }, "zh-Hant"], // script subtag wins over region + ]; + for (const [env, expected] of cases) { + test(`${JSON.stringify(env)} → ${expected}`, () => { + expect(detectSystemLanguage(env)).toBe(expected); + }); + } + + test("locale precedence for the effective locale: LC_ALL > LC_MESSAGES > LANG", () => { + expect(detectSystemLanguage({ LANG: "en_US", LC_ALL: "zh_CN" })).toBe("zh-Hans"); + expect(detectSystemLanguage({ LANG: "zh_CN", LC_MESSAGES: "en_US" })).toBe("en"); + }); + + // GNU gettext: LANGUAGE (colon priority list) selects the *display* language + // and outranks LANG/LC_MESSAGES/LC_ALL when a real locale is active. + test("LANGUAGE outranks the locale when localization is on (first supported entry wins)", () => { + // bilingual setup — English locale for formatting, Chinese for messages + expect(detectSystemLanguage({ LANG: "en_US.UTF-8", LANGUAGE: "zh_TW:en" })).toBe("zh-Hant"); + expect(detectSystemLanguage({ LANG: "en_US.UTF-8", LANGUAGE: "zh_Hant:zh_CN" })).toBe("zh-Hant"); + expect(detectSystemLanguage({ LANG: "zh_TW", LANGUAGE: "zh_CN:zh_TW" })).toBe("zh-Hans"); + expect(detectSystemLanguage({ LANG: "zh_TW", LANGUAGE: "en_US" })).toBe("en"); + expect(detectSystemLanguage({ LC_ALL: "en_US", LANGUAGE: "zh_TW" })).toBe("zh-Hant"); + }); + + // LANGUAGE is a fallback list: scan past entries this app can't honor. + test("LANGUAGE scans past unsupported entries to the first supported one", () => { + expect(detectSystemLanguage({ LANG: "en_US.UTF-8", LANGUAGE: "ja:zh_CN" })).toBe("zh-Hans"); // ja unsupported → zh_CN + expect(detectSystemLanguage({ LANG: "en_US", LANGUAGE: "ja:en:zh_TW" })).toBe("en"); // en before zh_TW wins + expect(detectSystemLanguage({ LANG: "zh_CN", LANGUAGE: "ko:fr" })).toBe("zh-Hans"); // none supported → fall to locale + expect(detectSystemLanguage({ LANG: "en_US", LANGUAGE: "ja:ko" })).toBe("en"); // none supported → fall to locale + }); + + test("LANGUAGE is ignored in the C/POSIX/unset locale (gettext rule)", () => { + expect(detectSystemLanguage({ LANG: "C", LANGUAGE: "zh_TW" })).toBe("en"); + expect(detectSystemLanguage({ LANG: "POSIX", LANGUAGE: "zh_TW:en" })).toBe("en"); + expect(detectSystemLanguage({ LANGUAGE: "zh_TW" })).toBe("en"); // no locale → C + }); }); diff --git a/packages/storage/src/__tests__/schema-shape.test.ts b/packages/storage/src/__tests__/schema-shape.test.ts index 2053408..89dfd98 100644 --- a/packages/storage/src/__tests__/schema-shape.test.ts +++ b/packages/storage/src/__tests__/schema-shape.test.ts @@ -67,6 +67,7 @@ describe("schema shape — config", () => { const store = new JsonConfigStore(join(dir, "config.json")); const written: UserConfig = { motion: "brisk", + language: "en", theme: "cinnabar", color: "always", timezone: "America/Los_Angeles", diff --git a/packages/storage/src/config-store.ts b/packages/storage/src/config-store.ts index 46f4e34..d478936 100644 --- a/packages/storage/src/config-store.ts +++ b/packages/storage/src/config-store.ts @@ -5,6 +5,12 @@ export interface ConfigStore { /** Load config, returning defaults for any missing fields */ load(): Promise; + /** + * Load config, or on first boot (no file) seed the display language from the + * system locale and persist it — a one-time seed that freezes the choice. + */ + loadOrSeed(): Promise; + /** Save config (atomic write) */ save(config: UserConfig): Promise; } diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 41016c1..04f9738 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -15,7 +15,7 @@ export type { ConfigStore } from "./config-store.js"; // JSON implementations export { JsonlJournalStore } from "./json/jsonl-journal.js"; export { JsonDailyCacheStore } from "./json/json-daily-cache.js"; -export { JsonConfigStore } from "./json/json-config.js"; +export { JsonConfigStore, detectSystemLanguage, canonicalLanguage } from "./json/json-config.js"; export { atomicWriteJson } from "./json/atomic-write.js"; // Journal query diff --git a/packages/storage/src/json/atomic-write.ts b/packages/storage/src/json/atomic-write.ts index 2da6db7..312f1b5 100644 --- a/packages/storage/src/json/atomic-write.ts +++ b/packages/storage/src/json/atomic-write.ts @@ -1,4 +1,4 @@ -import { writeFile, rename, mkdir, open } from "node:fs/promises"; +import { rename, mkdir, open, unlink } from "node:fs/promises"; import { dirname, join } from "node:path"; import { randomBytes } from "node:crypto"; @@ -9,6 +9,10 @@ import { randomBytes } from "node:crypto"; * 2. Write to a temp file in the same directory * 3. fsync the temp file * 4. rename over the target (atomic on same filesystem) + * + * The parent directory is NOT fsync'd after the rename, so the rename itself + * is not crash-durable — acceptable for a config file (worst case: the + * previous config survives a power loss). */ export async function atomicWriteJson( path: string, @@ -22,15 +26,22 @@ export async function atomicWriteJson( const json = JSON.stringify(data, null, 2) + "\n"; - // Write, fsync, close - const handle = await open(tmp, "w"); try { - await handle.writeFile(json, "utf-8"); - await handle.sync(); - } finally { - await handle.close(); - } + // Write, fsync, close + const handle = await open(tmp, "w"); + try { + await handle.writeFile(json, "utf-8"); + await handle.sync(); + } finally { + await handle.close(); + } - // Atomic rename - await rename(tmp, path); + // Atomic rename + await rename(tmp, path); + } catch (err) { + // Mid-write failure (ENOSPC/EIO) or failed rename: don't leak the temp — + // repeated failed saves would otherwise accumulate orphans in the data dir. + await unlink(tmp).catch(() => {}); + throw err; + } } diff --git a/packages/storage/src/json/json-config.ts b/packages/storage/src/json/json-config.ts index dacc076..65bb56a 100644 --- a/packages/storage/src/json/json-config.ts +++ b/packages/storage/src/json/json-config.ts @@ -1,10 +1,21 @@ -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import type { UserConfig } from "../types.js"; import type { ConfigStore } from "../config-store.js"; import { atomicWriteJson } from "./atomic-write.js"; +const MOTION_OPTIONS = ["default", "brisk", "deep", "reduced"] as const; +const LANGUAGE_OPTIONS = ["en", "zh-Hant", "zh-Hans"] as const; +const THEME_OPTIONS = ["ink", "bone", "cinnabar", "jade", "river"] as const; +const COLOR_OPTIONS = ["auto", "always", "never"] as const; +const GLYPH_ANIM_OPTIONS = ["noise", "dots", "radial", "sand"] as const; +const GLYPH_FONT_OPTIONS = ["kaiti", "libian", "heiti"] as const; +const TAIJITU_STYLE_OPTIONS = ["dots", "dense"] as const; +const CAST_METHOD_OPTIONS = ["coin", "yarrow"] as const; +const CAST_MODE_OPTIONS = ["auto", "manual"] as const; + const DEFAULT_CONFIG: UserConfig = { motion: "default", + language: "en", theme: "bone", color: "auto", timezone: "system", @@ -24,78 +35,300 @@ const LEGACY_CAST_MODE: Record = { +const THEME_ALIASES: Record = { "temple-night": "cinnabar", "lantern": "cinnabar", "dawn": "bone", }; -const VALID_TAIJITU_STYLES = new Set(["dots", "dense"]); -const VALID_CAST_METHODS = new Set(["coin", "yarrow"]); -const VALID_CAST_MODES = new Set(["auto", "manual"]); +// User-visible aliases / UI labels (繁/简/EN, english, …). Accepted on BOTH the +// file-load path and `config set language` (via canonicalLanguage); the persisted +// value is always the canonical BCP-47-ish form. +const LANGUAGE_ALIASES: Record = { + "简": "zh-Hans", + "簡": "zh-Hans", + "simplified": "zh-Hans", + "繁": "zh-Hant", + "traditional": "zh-Hant", + "EN": "en", + "english": "en", +}; + +/** + * Resolve a raw language string to a supported display language, or `undefined` + * if unrecognized. Accepts the canonical values (en / zh-Hant / zh-Hans), any + * case variant (BCP-47 is case-insensitive — "zh-hans", "EN"), and the hand-edit + * aliases / UI labels (繁 / 简 / english). Shared by the config file loader AND + * the `config set language` CLI so both accept the same inputs. + */ +export function canonicalLanguage(raw: string): UserConfig["language"] | undefined { + const folded = raw.trim().toLowerCase(); + const byCase = LANGUAGE_OPTIONS.find((o) => o.toLowerCase() === folded); + if (byCase) return byCase; + // OWN-property lookup only — never the prototype chain. + if (Object.hasOwn(LANGUAGE_ALIASES, raw)) return LANGUAGE_ALIASES[raw]; + return undefined; +} + +/** + * Best-effort system display-language from the POSIX locale environment. Used + * ONLY to seed the first-boot default (see `loadOrSeed`) — it is a one-time + * seed, never a live binding: once a config exists, the saved choice wins and + * the locale is never consulted again. + * + * Precedence follows GNU gettext for the *display/message* language: the + * effective locale comes from LC_ALL > LC_MESSAGES > LANG, but a present + * LANGUAGE (a colon-separated PRIORITY LIST, e.g. "ja:zh_CN") OUTRANKS it — + * EXCEPT in the C/POSIX (or unset) locale, where localization is off and + * LANGUAGE is ignored. LANGUAGE entries are tried in order and the first one + * this app supports (en / zh-Hant / zh-Hans) wins, falling through to the + * locale. Handles POSIX ("zh_CN.UTF-8", "zh_TW") and BCP-47 ("zh-Hant-TW") + * forms; an explicit script subtag wins over region. Anything non-Chinese — + * including empty / "C" / "POSIX" — falls back to English. + */ +/** Map one locale token to a supported display language, or null if unsupported. */ +function mapLocaleToken(token: string): UserConfig["language"] | null { + const parts = token.split(/[.@]/)[0].replace(/_/g, "-").toLowerCase().split("-"); + const lang = parts[0]; + if (lang === "en") return "en"; // app supports English — stop scanning + if (lang !== "zh") return null; // unsupported language → try the next candidate + if (parts.includes("hant")) return "zh-Hant"; // script subtag wins over region + if (parts.includes("hans")) return "zh-Hans"; + const region = parts[1]; + if (region === "tw" || region === "hk" || region === "mo") return "zh-Hant"; + return "zh-Hans"; // zh-CN / zh-SG / zh-MY and bare "zh" +} + +export function detectSystemLanguage( + env: Record = process.env, +): UserConfig["language"] { + // Effective locale (LANGUAGE excluded — it only selects the message language). + const locale = env.LC_ALL || env.LC_MESSAGES || env.LANG || ""; + const localeLang = locale.split(/[.@]/)[0].toLowerCase(); + // Not localized (C / POSIX / unset): no language intent, and GNU LANGUAGE is + // disabled in the C locale → English. + if (localeLang === "" || localeLang === "c" || localeLang === "posix") return "en"; + // GNU gettext: LANGUAGE is a colon-separated priority list tried in order and + // outranks the locale. Honor the first candidate this app supports — LANGUAGE's + // entries first, then the locale itself. + const candidates = [...(env.LANGUAGE ? env.LANGUAGE.split(":") : []), locale]; + for (const token of candidates) { + const mapped = mapLocaleToken(token); + if (mapped) return mapped; + } + return "en"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOneOf( + options: T, + value: unknown, +): value is T[number] { + return typeof value === "string" && options.includes(value as T[number]); +} + +function stringValue( + record: Record, + key: keyof UserConfig, +): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function normalizeConfig(parsed: unknown): UserConfig { + const merged: UserConfig = { ...DEFAULT_CONFIG }; + if (!isRecord(parsed)) return merged; + + if (isOneOf(MOTION_OPTIONS, parsed.motion)) merged.motion = parsed.motion; + + const rawLanguage = stringValue(parsed, "language"); + if (rawLanguage) { + const canon = canonicalLanguage(rawLanguage); + if (canon) merged.language = canon; + } + + const rawTheme = stringValue(parsed, "theme"); + if (isOneOf(THEME_OPTIONS, rawTheme)) { + merged.theme = rawTheme; + } else if (rawTheme && Object.hasOwn(THEME_ALIASES, rawTheme)) { + merged.theme = THEME_ALIASES[rawTheme]; + } + + if (isOneOf(COLOR_OPTIONS, parsed.color)) merged.color = parsed.color; + if (typeof parsed.timezone === "string") merged.timezone = parsed.timezone; + if (isOneOf(GLYPH_ANIM_OPTIONS, parsed.glyphAnim)) merged.glyphAnim = parsed.glyphAnim; + if (isOneOf(GLYPH_FONT_OPTIONS, parsed.glyphFont)) merged.glyphFont = parsed.glyphFont; + + const rawTaijituStyle = stringValue(parsed, "taijituStyle"); + if (isOneOf(TAIJITU_STYLE_OPTIONS, rawTaijituStyle)) { + merged.taijituStyle = rawTaijituStyle; + } else if (rawTaijituStyle) { + merged.taijituStyle = rawTaijituStyle.toLowerCase().includes("dense") ? "dense" : "dots"; + } + + const rawCastMethod = stringValue(parsed, "castMethod"); + const rawCastMode = stringValue(parsed, "castMode"); + if (rawCastMode && rawCastMethod === undefined) { + const split = Object.hasOwn(LEGACY_CAST_MODE, rawCastMode) ? LEGACY_CAST_MODE[rawCastMode] : undefined; + if (split) { + merged.castMethod = split.method; + merged.castMode = split.mode; + } + } else { + if (isOneOf(CAST_METHOD_OPTIONS, rawCastMethod)) merged.castMethod = rawCastMethod; + if (isOneOf(CAST_MODE_OPTIONS, rawCastMode)) { + merged.castMode = rawCastMode; + } else if (rawCastMode) { + const split = Object.hasOwn(LEGACY_CAST_MODE, rawCastMode) ? LEGACY_CAST_MODE[rawCastMode] : undefined; + if (split) { + merged.castMethod = split.method; + merged.castMode = split.mode; + } + } + } + + // Forward-compat (schema-keys: "schemas only expand"): carry through unknown + // OWN keys written by a newer version / parallel install, so a settings save + // from this version doesn't destroy them. Object.hasOwn, NOT `k in` — the `in` + // operator walks the prototype chain, so a legitimate future key that shadows + // a prototype name (toString, valueOf, …) would be silently dropped on rewrite. + // __proto__/constructor/prototype are explicitly refused instead: assigning + // them through a bracket lookup mutates object internals, not data properties. + for (const k of Object.keys(parsed)) { + if (k === "__proto__" || k === "constructor" || k === "prototype") continue; + if (!Object.hasOwn(DEFAULT_CONFIG, k)) { + (merged as unknown as Record)[k] = parsed[k]; + } + } + + return merged; +} export class JsonConfigStore implements ConfigStore { constructor(private readonly path: string) {} - async load(): Promise { + /** + * The session's live config when it could not be persisted (read-only / full + * data dir) — set by a failed save() and by an unpersistable first-boot seed. + * Lets the rest of THIS session — e.g. reopening Settings, which reloads + * config — see the user's actual edits / detected language instead of stale + * disk state. Cleared the moment a write succeeds. + */ + private pendingInMemory: UserConfig | null = null; + + /** The corrupt warning fires once per store instance — loads repeat during a + * TUI session (startup, open-Settings, save&back) while the terminal owns the + * screen in raw mode, and raw stderr would scramble the rendered frame. */ + private warnedCorrupt = false; + + /** Whether the unreadable original is safely copied to .corrupt — healing the + * live file is only allowed when this is true (never destroy the only copy). */ + private corruptBackupOk = false; + + /** + * Read + JSON.parse the config file. Returns the parsed object, `null` when the + * file is genuinely absent (ENOENT), or `"corrupt"` when it exists but won't + * parse — in which case the unreadable file is first copied aside to .corrupt + * (best-effort, with a stderr warning) so a later save can't silently clobber + * it. Real I/O faults (EACCES/EIO) propagate so they stay visible. + */ + private async readRaw(): Promise | null | "corrupt"> { + let raw: string; try { - const raw = await readFile(this.path, "utf-8"); - // Widen castMethod + castMode to plain string for the normalization - // checks below — at runtime these can be anything (legacy values, hand - // edits) even though their UserConfig types are narrow unions. - const partial = JSON.parse(raw) as Omit, "castMethod" | "castMode"> & { - glyphSize?: unknown; - castMethod?: string; - castMode?: string; - }; - // Migrate legacy single-string castMode → castMethod + castMode pair. - // Old configs only had `castMode`; absence of `castMethod` is the tell. - if (partial.castMode && partial.castMethod === undefined) { - const split = LEGACY_CAST_MODE[partial.castMode]; - if (split) { - partial.castMethod = split.method; - partial.castMode = split.mode; - } - } - // Defense-in-depth: a current-shaped file with both fields can still - // carry an out-of-domain value (legacy CLI writes, hand edits, future - // schema drift). Route any unknown castMode back through LEGACY_CAST_MODE - // when possible, or fall back to the default. Same idiom we already use - // for taijituStyle and theme below. - if (partial.castMode && !VALID_CAST_MODES.has(partial.castMode)) { - const split = LEGACY_CAST_MODE[partial.castMode]; - if (split) { - partial.castMethod = split.method; - partial.castMode = split.mode; - } else { - partial.castMode = DEFAULT_CONFIG.castMode; - } - } - if (partial.castMethod && !VALID_CAST_METHODS.has(partial.castMethod)) { - partial.castMethod = DEFAULT_CONFIG.castMethod; - } - const merged = { ...DEFAULT_CONFIG, ...partial } as UserConfig; - // Drop removed glyphSize key from older configs. - delete (merged as { glyphSize?: unknown }).glyphSize; - // Migrate legacy taijituStyle values (yangDots/yinDots → dots, yangDense/yinDense → dense). - const style = merged.taijituStyle as string; - if (!VALID_TAIJITU_STYLES.has(style)) { - merged.taijituStyle = style.toLowerCase().includes("dense") ? "dense" : "dots"; - } - // Migrate legacy theme names. - const aliased = THEME_ALIASES[merged.theme as string]; - if (aliased) { - merged.theme = aliased as UserConfig["theme"]; - } - return merged; + raw = await readFile(this.path, "utf-8"); } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") - return { ...DEFAULT_CONFIG }; + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw err; } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + try { + // wx: never clobber an existing backup — the FIRST backup is the + // recoverable one (a later corruption is usually garbage-on-garbage). + await writeFile(`${this.path}.corrupt`, raw, { encoding: "utf-8", flag: "wx" }); + this.corruptBackupOk = true; + } catch (err: unknown) { + // EEXIST means a backup is already safe; anything else (read-only / + // full) means it isn't — and healing must not run. + this.corruptBackupOk = (err as NodeJS.ErrnoException).code === "EEXIST"; + } + if (!this.warnedCorrupt) { + this.warnedCorrupt = true; + console.error( + `iching: config at ${this.path} is unreadable — using defaults. ` + + `Your old settings are saved at ${this.path}.corrupt; restore them by fixing the JSON and renaming the file back.`, + ); + } + return "corrupt"; + } + return isRecord(parsed) ? parsed : {}; + } + + async load(): Promise { + // Unpersisted live state (failed save, or a first-boot seed that couldn't be + // written) is authoritative for the session, so a same-session reload sees + // the user's actual edits / detected language — not stale disk. + if (this.pendingInMemory) return { ...this.pendingInMemory }; + const raw = await this.readRaw(); + if (raw === null || raw === "corrupt") return { ...DEFAULT_CONFIG }; + return normalizeConfig(raw); + } + + /** + * Like `load()`, but seeds the display language from the system locale and + * PERSISTS it when the user has NOT chosen one — first boot (no file) OR an + * upgrade from a pre-language version (a config that exists but has never had a + * `language` key). After that the saved choice wins and the locale is never + * re-consulted. Used at interactive startup; `load()` stays a pure read so + * config subcommands and test fixtures don't write files. + */ + async loadOrSeed(): Promise { + const raw = await this.readRaw(); + // The user already has a stored language choice → honor it, don't re-seed. + // The gate is key PRESENCE, deliberately not value validity: an unrecognized + // value (e.g. written by a future version) coerces to "en" in memory but + // stays untouched on disk — re-seeding here would PERSIST over it and + // destroy the stored choice on a version downgrade. + if (raw !== null && raw !== "corrupt" && Object.hasOwn(raw, "language")) { + return normalizeConfig(raw); + } + // Seed cases: first boot (no file), pre-language upgrade (no language key), + // or an unreadable config whose bytes are already safe in .corrupt. Seed + // from the locale and persist — for the corrupt case this HEALS the live + // file (an unreadable config is effectively no config), so the reset is + // announced once by readRaw's warning instead of happening silently at a + // later save, and every subsequent load parses cleanly. + const base = raw === null || raw === "corrupt" ? DEFAULT_CONFIG : normalizeConfig(raw); + const seeded: UserConfig = { ...base, language: detectSystemLanguage() }; + if (raw === "corrupt" && !this.corruptBackupOk) { + // The backup itself failed (read-only/full dir): never overwrite the only + // copy of the user's bytes. Live for the session, disk untouched. + this.pendingInMemory = seeded; + return seeded; + } + try { + await this.save(seeded); + } catch { + this.pendingInMemory = seeded; // deferred: keep it for this session's reloads + } + return seeded; } async save(config: UserConfig): Promise { - await atomicWriteJson(this.path, config); + try { + await atomicWriteJson(this.path, config); + } catch (err) { + // The write failed (read-only/full data dir): keep the config as the + // session's live state so a later reload (e.g. reopening Settings) + // returns the user's edits instead of silently reverting them. + this.pendingInMemory = { ...config }; + throw err; + } + this.pendingInMemory = null; // persisted — the file is now the source of truth } } diff --git a/packages/storage/src/schema-keys.ts b/packages/storage/src/schema-keys.ts index 2d17368..92a5df6 100644 --- a/packages/storage/src/schema-keys.ts +++ b/packages/storage/src/schema-keys.ts @@ -15,6 +15,7 @@ export const SCHEMA_KEYS = { config: { required: [ "motion", + "language", "theme", "color", "timezone", diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index 4ac72e1..22424a7 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -1,4 +1,5 @@ import type { DailyCache } from "@iching/core"; +import type { DisplayLanguage } from "@iching/core"; /** Re-export DailyCache as DailyCacheRecord for storage-layer naming */ export type DailyCacheRecord = DailyCache; @@ -6,6 +7,7 @@ export type DailyCacheRecord = DailyCache; /** User-facing configuration */ export interface UserConfig { motion: "default" | "brisk" | "deep" | "reduced"; + language: DisplayLanguage; theme: "ink" | "bone" | "cinnabar" | "jade" | "river"; color: "auto" | "always" | "never"; timezone: "system" | string; diff --git a/packages/terminal/src/__tests__/detail-renderer.test.ts b/packages/terminal/src/__tests__/detail-renderer.test.ts new file mode 100644 index 0000000..fc87d01 --- /dev/null +++ b/packages/terminal/src/__tests__/detail-renderer.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { DetailModel } from "../scenes/dict/detail-model.ts"; +import { buildContentLines } from "../scenes/dict/detail-renderer.ts"; + +function textFor(language: "zh-Hans" | "zh-Hant" | "en", kw = 1): string { + return buildContentLines(new DetailModel(kw), 100, { language }) + .map((line) => line.text) + .join("\n"); +} + +describe("DetailRenderer language policy", () => { + test("traditional Chinese mode hides English commentary and line translations", () => { + const text = textFor("zh-Hant"); + expect(text).toContain("大象傳"); + expect(text).toContain("彖傳"); + expect(text).toContain("爻辭"); + expect(text).not.toContain("Image"); + expect(text).not.toContain("Judgment"); + expect(text).not.toContain("Wilhelm"); + expect(text).not.toContain("Line Texts"); + }); + + test("simplified Chinese mode converts visible Chinese text and hides English", () => { + const text = textFor("zh-Hans"); + expect(text).toContain("大象传"); + expect(text).toContain("爻辞"); + expect(text).toContain("龙"); + expect(text).not.toContain("大象傳"); + expect(text).not.toContain("Line Texts"); + }); + + test("English mode hides Chinese commentary and line blocks", () => { + const text = textFor("en"); + expect(text).toContain("The Creative"); + expect(text).toContain("Image"); + expect(text).toContain("Judgment"); + expect(text).toContain("Wilhelm"); + expect(text).toContain("Line Texts"); + expect(text).toContain("Line 6"); + expect(text).not.toContain("大象傳"); + expect(text).not.toContain("彖傳"); + expect(text).not.toContain("爻辭"); + expect(text).not.toContain("亢龍"); + }); +}); 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 new file mode 100644 index 0000000..7cf2283 --- /dev/null +++ b/packages/terminal/src/__tests__/scene-language.test.ts @@ -0,0 +1,552 @@ +// AC-004 — scenes honor DisplayLanguage with NO bilingual stacking. +// English mode must not leak Chinese product-ui labels; Chinese modes must not +// leak the English label they replace. Grows one describe-block per wired scene. +import { describe, expect, test } from "bun:test"; +import { SettingsScene, type SettingsValues } from "../scenes/settings/settings-scene.ts"; +import { HomeScene } from "../scenes/home/home-scene.ts"; +import { IntentionScene } from "../scenes/intention/intention-scene.ts"; +import { YarrowScene } from "../scenes/yarrow/yarrow-scene.ts"; +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"; +import type { SceneContext } from "../scene/types.ts"; +import type { Cast, Line, HistoryEntry, DisplayLanguage } from "@iching/core"; + +function makeLine(value: 7 | 8): Line { + return { value, isYang: value === 7, isChanging: false }; +} + +const ctx: SceneContext = { cols: 80, rows: 24, done: false, colorSupport: "none" }; +function ctxFor(language: DisplayLanguage): SceneContext { + return { cols: 80, rows: 24, done: false, colorSupport: "none", language }; +} + +function bufferText(buf: CellBuffer): string { + return Array.from({ length: buf.height }, (_, row) => + buf.getRow(row).map((cell) => cell.char).join(""), + ).join("\n"); +} + +function renderScene( + scene: { render: (buf: CellBuffer, ctx: SceneContext) => void }, + language: DisplayLanguage, +): string { + const buf = CellBuffer.create(80, 24); + scene.render(buf, ctxFor(language)); + return bufferText(buf); +} + +function settingsText(language: DisplayLanguage): string { + const values: SettingsValues = { + theme: "bone", + language, + taijituStyle: "dots", + glyphAnim: "dots", + glyphFont: "kaiti", + castMethod: "coin", + castMode: "auto", + }; + const scene = new SettingsScene(values); + const buf = CellBuffer.create(ctx.cols, ctx.rows); + scene.render(buf, ctx); + return bufferText(buf); +} + +describe("SettingsScene — no bilingual stacking", () => { + test("English mode shows English labels, no Chinese product-ui labels", () => { + const text = settingsText("en"); + expect(text).toContain("Settings"); + expect(text).toContain("Theme"); + expect(text).toContain("Language"); + // no Chinese label leakage in English mode + expect(text).not.toContain("設定"); + expect(text).not.toContain("語言"); + expect(text).not.toContain("主題"); + }); + + test("Traditional mode shows 繁體 labels, no stray English labels", () => { + const text = settingsText("zh-Hant"); + expect(text).toContain("設定"); // title / Settings + expect(text).toContain("語言"); // Language + expect(text).toContain("主題"); // Theme + expect(text).not.toContain("Theme"); + expect(text).not.toContain("Language"); + // 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("繁"); + expect(text).toContain("ink"); // theme labels deferred → canonical fallback + }); + + test("Simplified mode shows 简体 labels, no stray English or Traditional residue", () => { + const text = settingsText("zh-Hans"); + expect(text).toContain("设定"); // title + expect(text).toContain("语言"); // Language + expect(text).toContain("主题"); // Theme + expect(text).not.toContain("Theme"); + expect(text).not.toContain("設定"); // no Traditional residue + expect(text).toContain("简"); + }); + + // Regression (Codex P3): moving the Language row with ←/→ must re-localize the + // scene immediately. render() previously read this.values.language (the saved + // snapshot, refreshed only on Escape), so the UI stayed in the old language + // until save + reopen. It now reads the live getValues().language. + test("changing the Language row re-localizes the scene live, 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 row + 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("設定"); // live-localized title (zh-Hant), not the saved "en" + 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 + }); + + // 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. + test("yarrow preview in Settings localizes its captions (zh-Hant, no English leak)", () => { + const values: SettingsValues = { + theme: "bone", + language: "zh-Hant", + taijituStyle: "dots", + glyphAnim: "dots", + glyphFont: "kaiti", + castMethod: "yarrow", + castMode: "auto", + }; + const scene = new SettingsScene(values); + // focus the Cast Method row (index 5) so the preview becomes the yarrow strip + for (let i = 0; i < 5; i++) scene.handleKey({ type: "arrow", direction: "down" }, ctx); + // tall buffer so the preview pane actually has room to render (it's skipped + // when previewAvailRows < MIN_PREVIEW_ROWS, e.g. at 80x24) + const buf = CellBuffer.create(80, 44); + let text = ""; + // sample densely across several preview phases (gather / cut / narrate) + for (let ms = 0; ms < 6000; ms += 100) { + scene.update(ms, 100, ctx); + scene.render(buf, ctx); + text += bufferText(buf) + "\n"; + } + expect(text).toContain("策"); // localized yarrow caption (策 / 奇策 / 餘策) + expect(text).not.toContain("stalks"); // no English caption leak in zh-Hant Settings + expect(text).not.toContain("set aside"); + }); +}); + +describe("HomeScene — no bilingual stacking", () => { + const home = () => new HomeScene({ todayCast: null, taijituStyle: "dots" }); + + test("English mode shows English menu labels", () => { + const text = renderScene(home(), "en"); + expect(text).toContain("Cast"); + expect(text).toContain("Dictionary"); + expect(text).toContain("No cast today"); + expect(text).not.toContain("起卦"); + expect(text).not.toContain("卦典"); + }); + + test("Traditional mode shows 繁體 menu labels, no stray English", () => { + const text = renderScene(home(), "zh-Hant"); + expect(text).toContain("起卦"); // Cast + expect(text).toContain("卦典"); // Dictionary + expect(text).toContain("今日未占"); // No cast today + expect(text).not.toContain("Cast"); + expect(text).not.toContain("Dictionary"); + }); + + test("Simplified mode shows 简体 menu labels", () => { + const text = renderScene(home(), "zh-Hans"); + expect(text).toContain("起卦"); + expect(text).toContain("卦典"); + expect(text).not.toContain("Dictionary"); + }); +}); + +describe("IntentionScene — localized hint, canonical 問 in all languages", () => { + test("問 prompt shows in every language (canonical anchor)", () => { + for (const lang of ["en", "zh-Hant", "zh-Hans"] as const) { + expect(renderScene(new IntentionScene(), lang)).toContain("問"); + } + }); + test("English hint, no Chinese verbs", () => { + const text = renderScene(new IntentionScene(), "en"); + expect(text).toContain("confirm"); + expect(text).toContain("back"); + expect(text).not.toContain("確認"); + }); + test("Traditional hint localizes verbs, no stray English", () => { + const text = renderScene(new IntentionScene(), "zh-Hant"); + expect(text).toContain("確認"); + expect(text).toContain("返回"); + expect(text).not.toContain("confirm"); + }); +}); + +describe("YarrowScene — localized ritual captions + footer (teach-once)", () => { + // Advance the auto ritual through its narrated beats, collecting captions. + function captionsFor(language: DisplayLanguage): string { + const scene = new YarrowScene("default", undefined, language); + const seen: string[] = []; + for (let ms = 0; ms < 6000; ms += 200) { + scene.update(ms, 200, ctxFor(language)); + const buf = CellBuffer.create(80, 24); + scene.render(buf, ctxFor(language)); + const cap = (scene as unknown as { model: { caption: string } }).model.caption; + if (cap) seen.push(cap); + } + return seen.join("\n"); + } + + test("English captions use English ritual words", () => { + const text = captionsFor("en"); + expect(text).toContain("stalks"); + expect(text).toContain("Round"); + expect(text).not.toContain("策"); + expect(text).not.toContain("變"); + }); + + test("Traditional captions use 繁體 ritual terms, no stray English", () => { + const text = captionsFor("zh-Hant"); + expect(text).toContain("策"); // stalks + expect(text).toContain("變"); // Round / 變 + expect(text).not.toContain("stalks"); + expect(text).not.toContain("Round"); + }); + + test("Simplified captions use 简体 ritual terms", () => { + const text = captionsFor("zh-Hans"); + expect(text).toContain("策"); + expect(text).toContain("变"); // simplified 變 + expect(text).not.toContain("變"); // no Traditional residue + expect(text).not.toContain("stalks"); + }); +}); + +describe("CastScene reveal — hexagram name honors language (KW58 兌)", () => { + // Hexagram 58 (兌 / The Joyous): both trigrams Lake, no becoming → centered + // reveal. This is the P1-a path the structural oracle missed. + const cast58: Cast = { + lines: [makeLine(7), makeLine(7), makeLine(8), makeLine(7), makeLine(7), makeLine(8)], + primary: 58, + becoming: null, + changingPositions: [], + nuclear: 1, + polarity: 1, + mirror: 1, + diagonal: 1, + }; + + function revealText(language: DisplayLanguage): string { + const scene = new CastScene(cast58, "reduced", 80); + scene.skipToComplete(false); // fast-forward to the fully revealed title + const buf = CellBuffer.create(80, 24); + scene.render(buf, ctxFor(language)); + return bufferText(buf); + } + + test("English reveal: Chinese name anchor + English gloss + 'above' connective", () => { + const text = revealText("en"); + expect(text).toContain("兌"); // canonical name anchor (shown in all modes) + expect(text).toContain("above"); // structure connective (en) + expect(text).toContain("Lakes joined"); // English gloss present in en mode + }); + + test("Traditional reveal: 兌 + 上 connective, no English gloss/connective", () => { + const text = revealText("zh-Hant"); + expect(text).toContain("兌"); + expect(text).toContain("上"); // catalog cast.trigramConnective → 上 + expect(text).not.toContain("兑"); // no Simplified residue + expect(text).not.toContain("above"); // no English connective + expect(text).not.toContain("Joyous"); // English gloss dropped + }); + + test("Simplified reveal: 兌→兑, no Traditional residue, no English", () => { + const text = revealText("zh-Hans"); + expect(text).toContain("兑"); + expect(text).toContain("上"); + expect(text).not.toContain("兌"); // converted, no Traditional residue + expect(text).not.toContain("above"); + expect(text).not.toContain("Joyous"); + }); +}); + +describe("BrowseScene rows — name conversion + no English in Chinese modes (KW58)", () => { + // KW58 (兌) lives below the default viewport; scroll it into view. + function browseAtKw58(language: DisplayLanguage): string { + const scene = new BrowseScene(); + const model = scene.getModel(); + model.cursor = 57; // KW58 → index 57 + model.scrollOffset = 50; + const buf = CellBuffer.create(80, 24); + scene.render(buf, ctxFor(language)); + return bufferText(buf); + } + + test("English rows show Chinese name + English ename", () => { + const text = browseAtKw58("en"); + expect(text).toContain("兌"); + expect(text).toContain("The Joyous"); + }); + + test("Simplified rows convert the name and drop the English ename", () => { + const text = browseAtKw58("zh-Hans"); + expect(text).toContain("兑"); + expect(text).not.toContain("兌"); // converted + expect(text).not.toContain("The Joyous"); // no stray English in 简 mode + }); +}); + +describe("JournalScene rows — name conversion (KW20 觀)", () => { + const entry: HistoryEntry = { + date: "2026-06-02", + cast: { + lines: [makeLine(8), makeLine(8), makeLine(8), makeLine(8), makeLine(7), makeLine(7)], + primary: 20, // 觀 / Contemplation + becoming: null, + changingPositions: [], + nuclear: 1, + polarity: 1, + mirror: 1, + diagonal: 1, + }, + }; + + function journalText(language: DisplayLanguage): string { + const scene = new JournalScene([entry]); + scene.enter(ctxFor(language)); + const buf = CellBuffer.create(80, 24); + scene.render(buf, ctxFor(language)); + return bufferText(buf); + } + + test("English/Traditional journal shows 觀", () => { + expect(journalText("en")).toContain("觀"); + expect(journalText("zh-Hant")).toContain("觀"); + }); + + test("Simplified journal converts 觀→观, no Traditional residue", () => { + const text = journalText("zh-Hans"); + expect(text).toContain("观"); + expect(text).not.toContain("觀"); + }); + + test("English footer uses English nav verbs", () => { + const text = journalText("en"); + expect(text).toContain("navigate"); + expect(text).toContain("view"); + expect(text).toContain("dictionary"); + }); + + test("Simplified footer localizes nav verbs, no English leak", () => { + const text = journalText("zh-Hans"); + expect(text).toContain("导览"); // navigate + expect(text).toContain("检视"); // view + expect(text).toContain("卦典"); // dictionary + expect(text).toContain("返回"); // back + expect(text).not.toContain("navigate"); + expect(text).not.toContain("view"); + expect(text).not.toContain("dictionary"); + }); +}); + +describe("DetailScene footer — nav verbs honor language", () => { + function detailText(language: DisplayLanguage): string { + const model = new DetailModel(20); // 觀 / Contemplation + const buf = CellBuffer.create(80, 24); + renderDetail(buf, model, ctxFor(language), { language }); + return bufferText(buf); + } + + test("English footer: scroll/derived/open/back", () => { + const text = detailText("en"); + expect(text).toContain("scroll"); + expect(text).toContain("derived"); + expect(text).toContain("open"); + expect(text).toContain("back"); + }); + + test("Simplified footer localizes, no English leak", () => { + const text = detailText("zh-Hans"); + expect(text).toContain("卷动"); // scroll + expect(text).toContain("衍卦"); // derived + expect(text).toContain("开启"); // open + expect(text).toContain("返回"); // back + expect(text).not.toContain("scroll"); + expect(text).not.toContain("derived"); + expect(text).not.toContain("open"); + }); + + test("Traditional footer localizes, no Simplified residue", () => { + const text = detailText("zh-Hant"); + expect(text).toContain("捲動"); // scroll (Traditional) + expect(text).toContain("衍卦"); // derived + expect(text).not.toContain("scroll"); + expect(text).not.toContain("卷动"); // no Simplified residue + }); +}); + +// Regression (Codex P2 ×2): the large braille glyph was composed from the +// Traditional name even in zh-Hans, so a Traditional 兌 rendered above Simplified +// 兑 text. The atlas now carries Simplified name glyphs, and both the dictionary +// detail (DetailScene.enter) and the cast reveal (timeline buildGlyphReveal) +// compose from the Simplified name in zh-Hans. KW58 兌→兑 has a distinct glyph. +describe("Glyph composition honors language — Simplified glyphs in zh-Hans", () => { + const glyphCfg = { glyphAnim: "dots", glyphFont: "kaiti" } as const; + + function detailGlyph(language: DisplayLanguage): string[] | undefined { + const s = new DetailScene(58, glyphCfg, language); + s.enter(ctxFor(language)); + return (s as unknown as { model: { glyphEntry: { rows: string[] } | null } }).model + .glyphEntry?.rows; + } + + test("dictionary detail glyph in zh-Hans differs from zh-Hant (兑 not 兌)", () => { + const hant = detailGlyph("zh-Hant"); + const hans = detailGlyph("zh-Hans"); + expect(hant).toBeDefined(); + expect(hans).toBeDefined(); + expect(hans).not.toEqual(hant); + }); + + const cast58: Cast = { + lines: [makeLine(7), makeLine(7), makeLine(8), makeLine(7), makeLine(7), makeLine(8)], + primary: 58, + becoming: null, + changingPositions: [], + nuclear: 1, + polarity: 1, + mirror: 1, + diagonal: 1, + }; + function castGlyph(language: DisplayLanguage): string[] | undefined { + const s = new CastScene(cast58, "reduced", 80, glyphCfg, 24, undefined, { language }); + s.skipToComplete(false); + return (s as unknown as { model: { primaryGlyphEntry: { rows: string[] } | null } }).model + .primaryGlyphEntry?.rows; + } + + test("cast reveal glyph in zh-Hans differs from zh-Hant", () => { + const hant = castGlyph("zh-Hant"); + const hans = castGlyph("zh-Hans"); + expect(hant).toBeDefined(); + expect(hans).toBeDefined(); + expect(hans).not.toEqual(hant); + }); +}); diff --git a/packages/terminal/src/__tests__/scroll.test.ts b/packages/terminal/src/__tests__/scroll.test.ts new file mode 100644 index 0000000..8dde298 --- /dev/null +++ b/packages/terminal/src/__tests__/scroll.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from "bun:test"; +import { clampOffset, offsetToShow, windowFor, pageIndicator } from "../widgets/scroll.ts"; + +describe("clampOffset", () => { + test("clamps into [0, contentLength - viewport]", () => { + expect(clampOffset(-3, 10, 4)).toBe(0); + expect(clampOffset(99, 10, 4)).toBe(6); // max = 10 - 4 + expect(clampOffset(2, 10, 4)).toBe(2); + }); + test("content shorter than viewport → 0", () => { + expect(clampOffset(5, 3, 10)).toBe(0); + }); +}); + +describe("offsetToShow (cursor-into-view, stateful)", () => { + test("keeps a visible cursor's offset unchanged", () => { + expect(offsetToShow(3, 2, 5)).toBe(2); // 3 in [2,7) + }); + test("scrolls up when cursor is above the window", () => { + expect(offsetToShow(1, 4, 5)).toBe(1); + }); + test("scrolls down so the cursor sits at the bottom of the window", () => { + expect(offsetToShow(9, 2, 5)).toBe(5); // 9 - 5 + 1 + }); +}); + +describe("windowFor (stateless window)", () => { + test("everything fits → full range", () => { + expect(windowFor(0, 10, 7)).toEqual({ start: 0, end: 7 }); + }); + test("focused near the top stays at 0", () => { + expect(windowFor(1, 5, 7)).toEqual({ start: 0, end: 5 }); + }); + test("focused at the end scrolls to keep it visible", () => { + expect(windowFor(6, 5, 7)).toEqual({ start: 2, end: 7 }); + }); + test("clamps so the window never exceeds total", () => { + const w = windowFor(6, 5, 7); + expect(w.end - w.start).toBe(5); + expect(w.end).toBeLessThanOrEqual(7); + }); +}); + +describe("pageIndicator", () => { + test("'1/1' when content fits", () => { + expect(pageIndicator(0, 5, 20)).toBe("1/1"); + }); + test("page math over multiple viewports", () => { + expect(pageIndicator(0, 42, 20)).toBe("1/3"); + expect(pageIndicator(20, 42, 20)).toBe("2/3"); + expect(pageIndicator(40, 42, 20)).toBe("3/3"); + }); +}); diff --git a/packages/terminal/src/__tests__/settings-scene.test.ts b/packages/terminal/src/__tests__/settings-scene.test.ts new file mode 100644 index 0000000..3fc5277 --- /dev/null +++ b/packages/terminal/src/__tests__/settings-scene.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { SettingsScene } from "../scenes/settings/settings-scene.ts"; +import { CellBuffer } from "../render/buffer.ts"; +import type { SceneContext } from "../scene/types.ts"; + +function makeCtx(cols = 80, rows = 24): SceneContext { + return { cols, rows, done: false, colorSupport: "none" }; +} + +function makeScene(language: "zh-Hans" | "zh-Hant" | "en" = "en"): SettingsScene { + return new SettingsScene({ + theme: "bone", + language, + taijituStyle: "dots", + glyphAnim: "dots", + glyphFont: "kaiti", + castMethod: "coin", + castMode: "auto", + }); +} + +function bufferText(buf: CellBuffer): string { + return Array.from({ length: buf.height }, (_, row) => + buf.getRow(row).map((cell) => cell.char).join(""), + ).join("\n"); +} + +describe("SettingsScene language", () => { + test("preserves the initial language value", () => { + const scene = makeScene("en"); + expect(scene.getValues().language).toBe("en"); + }); + + test("renders compact language choices", () => { + const scene = makeScene(); + const ctx = makeCtx(); + const buf = CellBuffer.create(ctx.cols, ctx.rows); + scene.render(buf, ctx); + const text = bufferText(buf); + expect(text).toContain("Language"); + expect(text).toContain("[EN] 繁 简"); + }); + + test("left/right changes the language row", () => { + const scene = makeScene("zh-Hant"); + const ctx = makeCtx(); + scene.handleKey({ type: "arrow", direction: "down" }, ctx); + scene.handleKey({ type: "arrow", direction: "right" }, ctx); + expect(scene.getValues().language).toBe("zh-Hans"); + }); +}); + +describe("SettingsScene layout", () => { + // Regression (review P3): at short heights the 7th row (Cast Mode) was pushed + // under the bottom-anchored footer and edited blind. The scene now scrolls a + // window of settings so the focused row stays visible above the footer. + test("focused last row stays visible (and editable) at h=20", () => { + const scene = makeScene("en"); + const ctx = makeCtx(80, 20); + for (let i = 0; i < 6; i++) scene.handleKey({ type: "arrow", direction: "down" }, ctx); // focus Cast Mode + const buf = CellBuffer.create(80, 20); + scene.render(buf, ctx); + const rows = bufferText(buf).split("\n"); + const footerSepRow = 20 - 3; + const labelRow = rows.findIndex((l) => l.includes("Cast Mode")); + expect(labelRow).toBeGreaterThanOrEqual(0); // focused row rendered… + expect(labelRow + 1).toBeLessThan(footerSepRow); // …above the footer separator… + expect(rows[labelRow + 1]).toContain("[auto]"); // …options visible, not blind + }); + + test("renders all 7 rows without scrolling at h=24", () => { + const scene = makeScene("en"); + const ctx = makeCtx(80, 24); + const buf = CellBuffer.create(80, 24); + scene.render(buf, ctx); + const text = bufferText(buf); + for (const label of ["Theme", "Language", "Taijitu", "Font", "Cast Method", "Cast Mode"]) { + expect(text).toContain(label); + } + }); +}); + +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/__tests__/yarrow-manual-scene.test.ts b/packages/terminal/src/__tests__/yarrow-manual-scene.test.ts index 823b122..db981eb 100644 --- a/packages/terminal/src/__tests__/yarrow-manual-scene.test.ts +++ b/packages/terminal/src/__tests__/yarrow-manual-scene.test.ts @@ -174,4 +174,29 @@ describe("YarrowManualScene — 18-cut full manual", () => { pumpThroughSnap(s); expect(() => s.render(buf, ctx)).not.toThrow(); // playing }); + + // Regression (Codex P3): the line's fuse beat omitted `language`, so the final + // fuse caption defaulted to English ("Remaining … old yin") even in zh-Hant/ + // zh-Hans manual mode while the round beats were localized. The call now + // forwards this.language. Drive line 0's three rounds and capture captions. + test("fuse caption is localized in zh-Hant manual mode, no English leak", () => { + const s = new YarrowManualScene("brisk", new SeededRandomSource(1), "zh-Hant"); + s.enter(ctx); + const caps: string[] = []; + for (let round = 0; round < 3; round++) { + s.handleKey(space, ctx); // gathering → sweeping + pumpSweep(s, 4); + s.handleKey(space, ctx); // sweeping → snapping + pumpThroughSnap(s); // snapping → playing + for (let i = 0; i < 4000 && s.getPhase() === "playing"; i++) { + s.update(0, 100, ctx); + const cap = (s as unknown as { model: { caption: string } }).model.caption; + if (cap) caps.push(cap); + } + } + const all = caps.join("\n"); + expect(all).toContain("餘策"); // localized "Remaining" from the fuse caption + expect(all).not.toContain("Remaining"); // no English fuse-caption leak + expect(all).not.toContain("old yin"); + }); }); diff --git a/packages/terminal/src/i18n/messages.ts b/packages/terminal/src/i18n/messages.ts new file mode 100644 index 0000000..74d2aa9 --- /dev/null +++ b/packages/terminal/src/i18n/messages.ts @@ -0,0 +1,113 @@ +// Terminal UI message catalog — localizes EXISTING product-ui surfaces across +// EN / 繁 (zh-Hant) / 简 (zh-Hans). This is translation of strings that already +// exist in the scenes (menu labels, footer verbs, settings labels, empty states); +// it does NOT add new explanatory content or new UI. Classical-corpus text and +// machine tokens are NOT routed through here (see docs/language-glossary.md). +// +// zh-Hans is authored explicitly (not derived from zh-Hant) because UI vocabulary +// uses characters outside the corpus conversion table in @iching/core. +import type { DisplayLanguage } from "@iching/core"; + +export interface Message { + en: string; + zhHant: string; + zhHans: string; +} + +/** Catalog keyed by stable dotted id. Values are the existing UI strings localized. */ +export const MESSAGES = { + // ── home menu ── + "menu.cast": { en: "Cast", zhHant: "起卦", zhHans: "起卦" }, + "menu.play": { en: "Play", zhHant: "演練", zhHans: "演练" }, + "menu.dictionary": { en: "Dictionary", zhHant: "卦典", zhHans: "卦典" }, + "menu.journal": { en: "Journal", zhHant: "占記", zhHans: "占记" }, + "menu.settings": { en: "Settings", zhHant: "設定", zhHans: "设定" }, + "menu.quit": { en: "Quit", zhHant: "離開", zhHans: "离开" }, + "home.today": { en: "Today:", zhHant: "今日:", zhHans: "今日:" }, + "home.noCast": { en: "No cast today", zhHant: "今日未占", zhHans: "今日未占" }, + + // ── shared footer / keybinding verbs ── + "verb.confirm": { en: "confirm", zhHant: "確認", zhHans: "确认" }, + "verb.back": { en: "back", zhHant: "返回", zhHans: "返回" }, + "verb.toss": { en: "toss", zhHant: "擲", zhHans: "掷" }, + "verb.reveal": { en: "reveal", zhHant: "顯示", zhHans: "显示" }, + "verb.discard": { en: "discard", zhHant: "捨棄", zhHans: "舍弃" }, + "verb.explore": { en: "explore", zhHant: "探看", zhHans: "探看" }, + "verb.switch": { en: "switch", zhHant: "切換", zhHans: "切换" }, + "verb.detail": { en: "detail", zhHant: "詳情", zhHans: "详情" }, + "verb.navigate": { en: "navigate", zhHant: "導覽", zhHans: "导览" }, + "verb.open": { en: "open", zhHant: "開啟", zhHans: "开启" }, + "verb.search": { en: "search", zhHant: "搜尋", zhHans: "搜寻" }, + "verb.clearSearch": { en: "clear search", zhHant: "清除搜尋", zhHans: "清除搜寻" }, + "verb.scroll": { en: "scroll", zhHant: "捲動", zhHans: "卷动" }, + "verb.derived": { en: "derived", zhHant: "衍卦", zhHans: "衍卦" }, + "verb.select": { en: "select", zhHant: "選擇", zhHans: "选择" }, + "verb.view": { en: "view", zhHant: "檢視", zhHans: "检视" }, + "verb.dictionary": { en: "dictionary", zhHant: "卦典", zhHans: "卦典" }, + "verb.setting": { en: "setting", zhHant: "設定", zhHans: "设定" }, + "verb.option": { en: "option", zhHant: "選項", zhHans: "选项" }, + "verb.saveBack": { en: "save & back", zhHant: "儲存並返回", zhHans: "储存并返回" }, + "verb.pause": { en: "pause", zhHant: "暫停", zhHans: "暂停" }, + "verb.resume": { en: "resume", zhHant: "繼續", zhHans: "继续" }, + "verb.step": { en: "step", zhHant: "單步", zhHans: "单步" }, + "verb.skip": { en: "skip", zhHant: "略過", zhHans: "略过" }, + "verb.speed": { en: "speed", zhHant: "速度", zhHans: "速度" }, + "verb.receiveReading": { en: "receive the reading", zhHant: "領受卦象", zhHans: "领受卦象" }, + "verb.beginCutting": { en: "begin cutting", zhHant: "開始分蓍", zhHans: "开始分蓍" }, + "verb.cut": { en: "cut", zhHant: "分蓍", zhHans: "分蓍" }, + "verb.cutAroundHere": { en: "cut around here", zhHant: "約此處分", zhHans: "约此处分" }, + + // ── counters / structure (ritual chrome) ── + "chrome.line": { en: "line", zhHant: "爻", zhHans: "爻" }, + "chrome.round": { en: "round", zhHant: "輪", zhHans: "轮" }, + // Connective joining the upper/lower trigram in the structure line ("X above Y"). + "cast.trigramConnective": { en: "above", zhHant: "上", zhHans: "上" }, + + // ── dictionary chrome ── + "dict.title": { en: "I Ching Dictionary", zhHant: "易經卦典", zhHans: "易经卦典" }, + "dict.searchPrompt": { en: "Search: ", zhHant: "搜尋:", zhHans: "搜寻:" }, + "dict.countSuffix": { en: "hexagrams", zhHant: "卦", zhHans: "卦" }, + + // ── journal chrome ── + "journal.title": { en: "Journal", zhHant: "占記", zhHans: "占记" }, + "journal.countSuffix": { en: "readings", zhHant: "則", zhHans: "则" }, + "journal.empty": { en: "No readings yet", zhHant: "尚無占記", zhHans: "尚无占记" }, + + // ── settings labels ── + "settings.title": { en: "Settings", zhHant: "設定", zhHans: "设定" }, + "settings.theme": { en: "Theme", zhHant: "主題", zhHans: "主题" }, + "settings.language": { en: "Language", zhHant: "語言", zhHans: "语言" }, + "settings.taijitu": { en: "Taijitu", zhHant: "太極圖", zhHans: "太极图" }, + "settings.glyphAnimation": { en: "Glyph Animation", zhHant: "字形動畫", zhHans: "字形动画" }, + "settings.font": { en: "Font", zhHant: "字體", zhHans: "字体" }, + "settings.castMethod": { en: "Cast Method", zhHant: "起卦法", zhHans: "起卦法" }, + "settings.castMode": { en: "Cast Mode", zhHant: "起卦模式", zhHans: "起卦模式" }, + "settings.preview": { en: "Preview:", zhHant: "預覽:", zhHans: "预览:" }, + + // ── yarrow ritual captions + line-values (classical terms, Agentify C-003) ── + "yarrow.roundTitle": { en: "Round", zhHant: "變", zhHans: "变" }, + "yarrow.round": { en: "round", zhHant: "變", zhHans: "变" }, + "yarrow.stalks": { en: "stalks", zhHant: "策", zhHans: "策" }, + "yarrow.cutAt": { en: "Cut at k=", zhHant: "分於k=", zhHans: "分于k=" }, + "yarrow.heaps": { en: "heaps", zhHant: "二分", zhHans: "二分" }, + "yarrow.oneAside": { en: "One aside", zhHant: "掛一", zhHans: "挂一" }, + "yarrow.countByFours": { en: "Count each heap by fours.", zhHant: "每堆揲四", zhHans: "每堆揲四" }, + "yarrow.few": { en: "few", zhHant: "少", zhHans: "少" }, + "yarrow.many": { en: "many", zhHant: "多", zhHans: "多" }, + "yarrow.fuse": { en: "fuse", zhHant: "成爻", zhHans: "成爻" }, + "yarrow.carry": { en: "Carry", zhHant: "續", zhHans: "续" }, // C-004: 承策 was invented ritualese -> plain 續/续 (continue) + "yarrow.remaining": { en: "Remaining", zhHant: "餘策", zhHans: "余策" }, + "yarrow.setAside": { en: "set aside", zhHant: "奇策", zhHans: "奇策" }, // C-004: 歸奇 (the action 歸奇於扐) -> 奇策 (the remainder bundle, what the count labels) + "yarrow.lineValue.6": { en: "old yin", zhHant: "老陰", zhHans: "老阴" }, + "yarrow.lineValue.7": { en: "young yang", zhHant: "少陽", zhHans: "少阳" }, + "yarrow.lineValue.8": { en: "young yin", zhHant: "少陰", zhHans: "少阴" }, + "yarrow.lineValue.9": { en: "old yang", zhHant: "老陽", zhHans: "老阳" }, +} as const satisfies Record; + +export type MessageKey = keyof typeof MESSAGES; + +/** Localize a UI message key for the active display language. */ +export function tr(language: DisplayLanguage, key: MessageKey): string { + const m = MESSAGES[key]; + return language === "en" ? m.en : language === "zh-Hant" ? m.zhHant : m.zhHans; +} diff --git a/packages/terminal/src/i18n/option-labels.ts b/packages/terminal/src/i18n/option-labels.ts new file mode 100644 index 0000000..3aaddef --- /dev/null +++ b/packages/terminal/src/i18n/option-labels.ts @@ -0,0 +1,60 @@ +// 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([ + // 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: "黑体" }], + // 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: "沙化" }], +]); + +/** + * 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/scene/loop.ts b/packages/terminal/src/scene/loop.ts index eb54a0b..af4a9bf 100644 --- a/packages/terminal/src/scene/loop.ts +++ b/packages/terminal/src/scene/loop.ts @@ -3,6 +3,7 @@ import type { Clock } from "../clock.ts"; import type { Scene, SceneContext, SceneSignal } from "./types.ts"; import type { ColorSupport } from "../color/detect.ts"; +import type { DisplayLanguage } from "@iching/core"; import { TerminalSession } from "../session/terminal-session.ts"; import type { KeyEvent } from "../input/key-parser.ts"; import { KeyParser } from "../input/key-parser.ts"; @@ -28,6 +29,7 @@ export async function runScene( clock: Clock, colorSupport: ColorSupport, devMode = false, + language: DisplayLanguage = "en", ): Promise { // Enter alt screen, raw mode, hide cursor session.enter(); @@ -38,6 +40,7 @@ export async function runScene( cols: session.cols, rows: session.rows, colorSupport, + language, done: false, }; diff --git a/packages/terminal/src/scene/router.ts b/packages/terminal/src/scene/router.ts index 3a302fe..2f0158e 100644 --- a/packages/terminal/src/scene/router.ts +++ b/packages/terminal/src/scene/router.ts @@ -11,6 +11,7 @@ import type { Scene, SceneSignal } from "./types.ts"; import type { TerminalSession } from "../session/terminal-session.ts"; import type { Clock } from "../clock.ts"; import type { ColorSupport } from "../color/detect.ts"; +import type { DisplayLanguage } from "@iching/core"; import { runScene } from "./loop.ts"; export type SceneFactory = (signal: SceneSignal) => Scene | null; @@ -63,10 +64,11 @@ export class SceneRouter { clock: Clock, colorSupport: ColorSupport, devMode = false, + language: DisplayLanguage = "en", ): Promise<{ shouldExit: boolean }> { while (this.stack.length > 0) { const scene = this.current(); - const signal = await runScene(scene, session, clock, colorSupport, devMode); + const signal = await runScene(scene, session, clock, colorSupport, devMode, language); if (!signal) return { shouldExit: false }; // scene exited normally diff --git a/packages/terminal/src/scene/types.ts b/packages/terminal/src/scene/types.ts index 4bd6589..9451e38 100644 --- a/packages/terminal/src/scene/types.ts +++ b/packages/terminal/src/scene/types.ts @@ -1,6 +1,6 @@ // Scene interface — lifecycle contract for terminal scenes -import type { Cast } from "@iching/core"; +import type { Cast, DisplayLanguage } from "@iching/core"; import type { CellBuffer } from "../render/buffer.ts"; import type { KeyEvent } from "../input/key-parser.ts"; import type { ColorSupport } from "../color/detect.ts"; @@ -9,6 +9,12 @@ export interface SceneContext { cols: number; rows: number; colorSupport: ColorSupport; + /** + * Active display language for UI text. Set once from config when the scene + * loop starts (defaults to "en" when unset, e.g. in tests). Scenes route + * product-ui strings through the message catalog with this value. + */ + language?: DisplayLanguage; /** * Runner-managed exit flag. Production scenes should return a SceneSignal * instead; this is exposed primarily for test scenes that need to terminate diff --git a/packages/terminal/src/scenes/cast/cast-scene.ts b/packages/terminal/src/scenes/cast/cast-scene.ts index db2ab4e..2526b6f 100644 --- a/packages/terminal/src/scenes/cast/cast-scene.ts +++ b/packages/terminal/src/scenes/cast/cast-scene.ts @@ -21,6 +21,8 @@ import { SPLIT_ARROW } from "../../glyphs.ts"; import { stringWidth } from "../../layout/measure.ts"; import { createGlyphAnimator } from "../../glyph-anim/factory.ts"; import { autoGlyphSize } from "../../glyph-anim/auto-size.ts"; +import { tr } from "../../i18n/messages.ts"; +import type { DisplayLanguage } from "@iching/core"; export type CastGlyphInput = Omit; @@ -38,7 +40,7 @@ export class CastScene implements Scene { glyphConfig?: CastGlyphInput, termRows: number = 24, intention?: string, - opts?: { skipLineDrawing?: boolean }, + opts?: { skipLineDrawing?: boolean; language?: DisplayLanguage }, ) { this.model = new CastModel(cast); this.model.intention = intention; @@ -58,6 +60,7 @@ export class CastScene implements Scene { this.glyphConfig = { ...glyphConfig, glyphSize: autoGlyphSize(availRows, termWidth, maxChars), + language: opts?.language, }; } const timing = getPreset(preset); @@ -81,8 +84,9 @@ export class CastScene implements Scene { } } - render(frame: CellBuffer, _ctx: SceneContext): void { + render(frame: CellBuffer, ctx: SceneContext): void { const model = this.model; + const lang = ctx.language ?? "en"; const isSplit = model.layout !== "centered"; const leftOffset = hexColOffset(isSplit ? "left" : "center", model.splitProgress); const rightOffset = hexColOffset("right", model.splitProgress); @@ -119,11 +123,11 @@ export class CastScene implements Scene { if (hasGlyph) { renderLargeGlyph(frame, model); // Single centered title for the focused hexagram (no split offset) - renderTitle(frame, model, 0); + renderTitle(frame, model, 0, lang); } else { // No glyph: use split title layout as before - renderTitle(frame, model, leftOffset); - renderBecomingTitle(frame, model, isSplit ? rightOffset : 0); + renderTitle(frame, model, leftOffset, lang); + renderBecomingTitle(frame, model, isSplit ? rightOffset : 0, lang); } // Render intention (after reveal) @@ -133,7 +137,7 @@ export class CastScene implements Scene { // Render prompt if (model.showPrompt) { - renderPrompt(frame, model); + renderPrompt(frame, model, lang); } } @@ -143,7 +147,7 @@ export class CastScene implements Scene { return { type: "home" }; } - // Exploration mode: arrow keys switch focus, scroll commentary + // Exploration mode: left/right arrows switch focus if (this.model.explorationMode && key.type === "arrow") { if (key.direction === "left" && this.model.focusedHex === "becoming") { this.setFocusedHex("primary"); @@ -153,14 +157,6 @@ export class CastScene implements Scene { this.setFocusedHex("becoming"); return; } - if (key.direction === "up") { - this.model.commentaryScrollOffset = Math.max(0, this.model.commentaryScrollOffset - 1); - return; - } - if (key.direction === "down") { - this.model.commentaryScrollOffset++; - return; - } } // Enter in exploration mode opens dictionary for focused hex @@ -195,11 +191,10 @@ export class CastScene implements Scene { /** * Switch focus to a hexagram — updates focusedHex, creates matching - * glyph animator, resets scroll. Single method for all focus changes. + * glyph animator. Single method for all focus changes. */ private setFocusedHex(hex: "primary" | "becoming"): void { this.model.focusedHex = hex; - this.model.commentaryScrollOffset = 0; const entry = hex === "primary" ? this.model.primaryGlyphEntry : this.model.becomingGlyphEntry; @@ -263,15 +258,15 @@ function renderSplitArrow(buf: CellBuffer, model: CastModel): void { } /** Render the prompt bar at the bottom of the hexagram area. */ -function renderPrompt(buf: CellBuffer, model: CastModel): void { +function renderPrompt(buf: CellBuffer, model: CastModel, language: DisplayLanguage): void { const t = getTheme(); // Footer only shows contextual actions. j/d still work as silent shortcuts // to journal/dictionary — they live on the home menu, accessible via esc. const text = model.explorationMode ? (model.cast.becoming !== null - ? "[←→] switch · [enter] detail · [esc] back" - : "[enter] detail · [esc] back") - : "[enter] explore · [esc] back"; + ? `[←→] ${tr(language, "verb.switch")} · [enter] ${tr(language, "verb.detail")} · [esc] ${tr(language, "verb.back")}` + : `[enter] ${tr(language, "verb.detail")} · [esc] ${tr(language, "verb.back")}`) + : `[enter] ${tr(language, "verb.explore")} · [esc] ${tr(language, "verb.back")}`; const row = buf.height - 2; if (row < 0) return; const w = stringWidth(text); diff --git a/packages/terminal/src/scenes/cast/model.ts b/packages/terminal/src/scenes/cast/model.ts index e1951ca..ba3f932 100644 --- a/packages/terminal/src/scenes/cast/model.ts +++ b/packages/terminal/src/scenes/cast/model.ts @@ -57,7 +57,6 @@ export class CastModel { // Interactive exploration (after becoming reveal) explorationMode: boolean; focusedHex: "primary" | "becoming"; - commentaryScrollOffset: number; // Intention text for this cast intention?: string; @@ -107,6 +106,5 @@ export class CastModel { this.explorationMode = false; this.focusedHex = "primary"; - this.commentaryScrollOffset = 0; } } diff --git a/packages/terminal/src/scenes/cast/reveal-renderer.ts b/packages/terminal/src/scenes/cast/reveal-renderer.ts index dfa106a..a0b492a 100644 --- a/packages/terminal/src/scenes/cast/reveal-renderer.ts +++ b/packages/terminal/src/scenes/cast/reveal-renderer.ts @@ -2,10 +2,12 @@ import type { CellBuffer } from "../../render/buffer.ts"; import type { CastModel } from "./model.ts"; -import { GUA, getStructure } from "@iching/core"; +import { GUA, getStructure, toSimplified } from "@iching/core"; +import type { DisplayLanguage } from "@iching/core"; import { getTheme } from "../../color/theme.ts"; import { stringWidth } from "../../layout/measure.ts"; import { anchorRow, TITLE_ROW_OFFSET } from "./hexagram-renderer.ts"; +import { tr } from "../../i18n/messages.ts"; /** * Render the title block: Chinese name, pinyin, English, trigram meta. @@ -15,6 +17,7 @@ export function renderTitle( buf: CellBuffer, model: CastModel, xOffset: number = 0, + language: DisplayLanguage = "en", ): void { if (model.titleProgress <= 0) return; @@ -35,28 +38,35 @@ export function renderTitle( const structure = getStructure(focusedKw); const isSplit = model.layout !== "centered"; + const english = language === "en"; + const cn = (s: string): string => (language === "zh-Hans" ? toSimplified(s) : s); + // Structure connective: "above" (en) / "上" (zh) — catalog cast.trigramConnective. + const structLine = `${structure.upper.sym} ${tr(language, "cast.trigramConnective")} ${structure.lower.sym}`; - // When glyph is present: skip Chinese name (the glyph IS the name) - // Show only pinyin, English, trigram + // When glyph is present: skip Chinese name (the glyph IS the name). In Chinese + // modes, omit the English ename/image (no bilingual stacking) and convert names. let lines: string[]; if (hasGlyph) { if (isSplit) { lines = [gua.p]; - } else { + } else if (english) { const maxWidth = Math.max(20, buf.width - 8); const enLine = stringWidth(gua.en) > maxWidth ? gua.en.slice(0, maxWidth - 1) + "…" : gua.en; - lines = [gua.p, gua.ename ?? "", enLine, `${structure.upper.sym} above ${structure.lower.sym}`]; + lines = [gua.p, gua.ename ?? "", enLine, structLine]; + } else { + lines = [gua.p, structLine]; } } else { - const line1 = `${gua.u} ${gua.n}`; + const line1 = `${gua.u} ${cn(gua.n)}`; const line2 = gua.p; if (isSplit) { lines = [line1, line2]; - } else { + } else if (english) { const maxWidth = Math.max(20, buf.width - 8); const line3 = stringWidth(gua.en) > maxWidth ? gua.en.slice(0, maxWidth - 1) + "…" : gua.en; - const line4 = `${structure.upper.sym} above ${structure.lower.sym}`; - lines = [line1, line2, line3, line4]; + lines = [line1, line2, line3, structLine]; + } else { + lines = [line1, line2, structLine]; } } const progress = model.titleProgress; @@ -105,6 +115,7 @@ export function renderBecomingTitle( buf: CellBuffer, model: CastModel, xOffset: number = 0, + language: DisplayLanguage = "en", ): void { if (model.becomingTitleProgress <= 0 || model.cast.becoming === null) return; @@ -120,8 +131,9 @@ export function renderBecomingTitle( const hexNum = model.cast.becoming; const gua = GUA[hexNum - 1]; - // In split mode, drop the arrow prefix - const line1 = isSplit ? `${gua.u} ${gua.n}` : `\u2192 ${gua.u} ${gua.n}`; + // In split mode, drop the arrow prefix. Convert the name in zh-Hans. + const name = language === "zh-Hans" ? toSimplified(gua.n) : gua.n; + const line1 = isSplit ? `${gua.u} ${name}` : `\u2192 ${gua.u} ${name}`; const line2 = gua.p; const progress = model.becomingTitleProgress; diff --git a/packages/terminal/src/scenes/cast/ritual-chrome.ts b/packages/terminal/src/scenes/cast/ritual-chrome.ts index b38bedf..dd66f26 100644 --- a/packages/terminal/src/scenes/cast/ritual-chrome.ts +++ b/packages/terminal/src/scenes/cast/ritual-chrome.ts @@ -12,8 +12,10 @@ // (e.g. CastScene's reveal) simply don't call these helpers. import type { CellBuffer } from "../../render/buffer.ts"; +import type { DisplayLanguage } from "@iching/core"; import { getTheme } from "../../color/theme.ts"; import { stringWidth } from "../../layout/measure.ts"; +import { tr } from "../../i18n/messages.ts"; const HEADER_ROW = 1; const FOOTER_ROW_FROM_BOTTOM = 2; @@ -27,10 +29,11 @@ export function formatLineCounter( lineIdx: number, totalLines: number, round?: { idx: number; total: number }, + language: DisplayLanguage = "en", ): string { - const base = `line ${lineIdx + 1}/${totalLines}`; + const base = `${tr(language, "chrome.line")} ${lineIdx + 1}/${totalLines}`; if (!round || round.total <= 1) return base; - return `${base} · round ${round.idx + 1}/${round.total}`; + return `${base} · ${tr(language, "chrome.round")} ${round.idx + 1}/${round.total}`; } /** Place the position counter at row 1, centered, dim tertiary color. */ diff --git a/packages/terminal/src/scenes/cast/timeline-builder.ts b/packages/terminal/src/scenes/cast/timeline-builder.ts index d675cc0..df2de85 100644 --- a/packages/terminal/src/scenes/cast/timeline-builder.ts +++ b/packages/terminal/src/scenes/cast/timeline-builder.ts @@ -1,6 +1,6 @@ // timeline-builder.ts — compose the full ritual timeline from DSL primitives -import { type Cast, type GlyphFont, type GlyphSize, GUA } from "@iching/core"; +import { type Cast, type DisplayLanguage, type GlyphFont, type GlyphSize, GUA, toSimplified } from "@iching/core"; import type { RitualTiming } from "../../animation/presets.ts"; import type { Step } from "../../animation/timeline.ts"; import { seq, par, wait, call, tween } from "../../animation/timeline.ts"; @@ -15,6 +15,8 @@ export interface CastGlyphConfig { glyphAnim: GlyphAnimStyle; glyphFont: GlyphFont; glyphSize: GlyphSize; + /** zh-Hans composes the glyph from the Simplified name (e.g. 觀→观). */ + language?: DisplayLanguage; } export interface BuildCastTimelineOpts { @@ -430,7 +432,9 @@ function buildGlyphReveal( ): Step[] { return [ call(() => { - const glyph = composeGlyph(hexName, glyphConfig.glyphFont, glyphConfig.glyphSize); + // zh-Hans renders the Simplified glyph so it matches the Simplified text. + const name = glyphConfig.language === "zh-Hans" ? toSimplified(hexName) : hexName; + const glyph = composeGlyph(name, glyphConfig.glyphFont, glyphConfig.glyphSize); if (glyph) { if (target === "primary") { model.primaryGlyphEntry = glyph; diff --git a/packages/terminal/src/scenes/dict/browse-model.ts b/packages/terminal/src/scenes/dict/browse-model.ts index aaf3985..0b5046e 100644 --- a/packages/terminal/src/scenes/dict/browse-model.ts +++ b/packages/terminal/src/scenes/dict/browse-model.ts @@ -2,6 +2,7 @@ import type { Hexagram } from "@iching/core"; import { GUA, searchHexagrams } from "@iching/core"; +import { offsetToShow } from "../../widgets/scroll.ts"; export class BrowseModel { /** All 64 hexagrams */ @@ -92,10 +93,6 @@ export class BrowseModel { /** Ensure cursor is within visible scroll window */ private ensureCursorVisible(): void { - if (this.cursor < this.scrollOffset) { - this.scrollOffset = this.cursor; - } else if (this.cursor >= this.scrollOffset + this.viewportHeight) { - this.scrollOffset = this.cursor - this.viewportHeight + 1; - } + this.scrollOffset = offsetToShow(this.cursor, this.scrollOffset, this.viewportHeight); } } diff --git a/packages/terminal/src/scenes/dict/browse-renderer.ts b/packages/terminal/src/scenes/dict/browse-renderer.ts index 1659e14..c06df0d 100644 --- a/packages/terminal/src/scenes/dict/browse-renderer.ts +++ b/packages/terminal/src/scenes/dict/browse-renderer.ts @@ -4,9 +4,11 @@ import type { CellBuffer } from "../../render/buffer.ts"; import type { SceneContext } from "../../scene/types.ts"; import type { BrowseModel } from "./browse-model.ts"; import type { TextInput } from "../../widgets/text-input.ts"; -import { GUA } from "@iching/core"; +import { GUA, toSimplified } from "@iching/core"; +import type { DisplayLanguage } from "@iching/core"; import { getTheme } from "../../color/theme.ts"; import { stringWidth } from "../../layout/measure.ts"; +import { tr } from "../../i18n/messages.ts"; const HEADER_ROWS = 2; // header + separator const FOOTER_ROWS = 2; // separator + footer @@ -35,23 +37,25 @@ function renderHeader( ctx: SceneContext, ): void { const t = getTheme(); + const lang = ctx.language ?? "en"; if (model.searchActive) { // Search mode header - const label = "Search: "; + const label = tr(lang, "dict.searchPrompt"); + const labelW = stringWidth(label); frame.writeText(0, 1, label, { fg: t.accent }); textInput.render( frame, 0, - 1 + label.length, - ctx.cols - 2 - label.length, + 1 + labelW, + ctx.cols - 2 - labelW, { fg: t.primary }, ); } else { // Normal header - const title = "I Ching Dictionary"; - const hint = "[/] search"; + const title = tr(lang, "dict.title"); + const hint = `[/] ${tr(lang, "verb.search")}`; frame.writeText(0, 1, title, { fg: t.primary, bold: true }); - frame.writeText(0, ctx.cols - hint.length - 1, hint, { + frame.writeText(0, ctx.cols - stringWidth(hint) - 1, hint, { fg: t.secondary, }); } @@ -81,7 +85,7 @@ function renderList( const kw = GUA.indexOf(hex) + 1; const isSelected = i === model.cursor; - renderRow(frame, row, kw, hex, isSelected, ctx.cols); + renderRow(frame, row, kw, hex, isSelected, ctx.cols, ctx.language ?? "en"); } } @@ -92,11 +96,13 @@ function renderRow( hex: { u: string; n: string; p: string; ename: string }, isSelected: boolean, width: number, + language: DisplayLanguage, ): void { const t = getTheme(); const marker = isSelected ? ">" : " "; const kwStr = String(kw).padStart(3, " "); - const chinese = hex.n; + // Convert the hexagram name for zh-Hans; drop the English name in Chinese modes. + const chinese = language === "zh-Hans" ? toSimplified(hex.n) : hex.n; const pinyin = hex.p; // Fixed column layout: @@ -136,7 +142,8 @@ function renderRow( fg: isSelected ? t.accent : t.tertiary, ...bgStyle, }); - if (enWidth > 0) { + // English name column only in English mode (no stray English in Chinese modes). + if (enWidth > 0 && language === "en") { frame.writeText(row, enStart, ename, { fg, ...bgStyle }); } } @@ -155,13 +162,14 @@ function renderFooter( frame.writeText(sepRow, 0, sep, { fg: t.tertiary }); // Footer keybindings - const count = `${model.filtered.length} hexagrams`; + const lang = ctx.language ?? "en"; + const count = `${model.filtered.length} ${tr(lang, "dict.countSuffix")}`; const keys = model.searchActive - ? "[↑↓] navigate · [enter] open · [esc] clear search" - : "[↑↓] navigate · [enter] open · [/] search · [esc] back"; + ? `[↑↓] ${tr(lang, "verb.navigate")} · [enter] ${tr(lang, "verb.open")} · [esc] ${tr(lang, "verb.clearSearch")}` + : `[↑↓] ${tr(lang, "verb.navigate")} · [enter] ${tr(lang, "verb.open")} · [/] ${tr(lang, "verb.search")} · [esc] ${tr(lang, "verb.back")}`; frame.writeText(footerRow, 1, keys, { fg: t.secondary }); - frame.writeText(footerRow, ctx.cols - count.length - 1, count, { + frame.writeText(footerRow, ctx.cols - stringWidth(count) - 1, count, { fg: t.tertiary, }); } diff --git a/packages/terminal/src/scenes/dict/detail-model.ts b/packages/terminal/src/scenes/dict/detail-model.ts index f15099f..f39cf38 100644 --- a/packages/terminal/src/scenes/dict/detail-model.ts +++ b/packages/terminal/src/scenes/dict/detail-model.ts @@ -2,6 +2,7 @@ import { type HexagramDetail, type GlyphEntry, buildHexagramDetail } from "@iching/core"; import type { GlyphAnimator } from "../../glyph-anim/types.ts"; +import { clampOffset } from "../../widgets/scroll.ts"; export type DetailFocus = "content" | "derived"; @@ -94,12 +95,11 @@ export class DetailModel { } scrollUp(n = 1): void { - this.scrollOffset = Math.max(0, this.scrollOffset - n); + this.scrollOffset = clampOffset(this.scrollOffset - n, this.contentHeight, this.viewportHeight); } scrollDown(n = 1): void { - const max = Math.max(0, this.contentHeight - this.viewportHeight); - this.scrollOffset = Math.min(max, this.scrollOffset + n); + this.scrollOffset = clampOffset(this.scrollOffset + n, this.contentHeight, this.viewportHeight); } pageUp(): void { diff --git a/packages/terminal/src/scenes/dict/detail-renderer.ts b/packages/terminal/src/scenes/dict/detail-renderer.ts index bd46833..fa096dd 100644 --- a/packages/terminal/src/scenes/dict/detail-renderer.ts +++ b/packages/terminal/src/scenes/dict/detail-renderer.ts @@ -3,10 +3,14 @@ import type { CellBuffer } from "../../render/buffer.ts"; import type { SceneContext } from "../../scene/types.ts"; import type { DetailModel, DerivedLink } from "./detail-model.ts"; +import type { DisplayLanguage } from "@iching/core"; +import { toSimplified } from "@iching/core"; import { getTheme } from "../../color/theme.ts"; import { stringWidth, centerPad } from "../../layout/measure.ts"; import { wordWrap } from "./word-wrap.ts"; import { GLYPHS } from "../../glyphs.ts"; +import { tr } from "../../i18n/messages.ts"; +import { pageIndicator } from "../../widgets/scroll.ts"; const FOOTER_ROWS = 2; const PADDING = 2; @@ -19,12 +23,57 @@ export interface ContentLine { dim?: boolean; } +export interface DetailRenderOptions { + language?: DisplayLanguage; +} + +const DEFAULT_LANGUAGE: DisplayLanguage = "en"; + +const TRIGRAM_IMAGE_ZH: Record = { + "乾": "天", + "坤": "地", + "震": "雷", + "坎": "水", + "艮": "山", + "巽": "風", + "離": "火", + "兌": "澤", +}; + +function activeLanguage(options?: DetailRenderOptions): DisplayLanguage { + return options?.language ?? DEFAULT_LANGUAGE; +} + +function zh(text: string, language: DisplayLanguage): string { + // Delegate to the audited core Traditional->Simplified converter (no naive + // local map). zh-Hant returns text unchanged; only zh-Hans converts. + if (language !== "zh-Hans") return text; + return toSimplified(text); +} + +function pushWrapped( + lines: ContentLine[], + text: string, + width: number, + style: Omit, +): void { + for (const wl of wordWrap(text, width)) { + lines.push({ text: wl, ...style }); + } +} + /** Build all content lines for the detail view */ -export function buildContentLines(model: DetailModel, width: number): ContentLine[] { +export function buildContentLines( + model: DetailModel, + width: number, + options?: DetailRenderOptions, +): ContentLine[] { const t = getTheme(); const lines: ContentLine[] = []; const gua = model.detail.gua; const textWidth = width - PADDING * 2; + const language = activeLanguage(options); + const english = language === "en"; // Large glyph placeholder rows (scrolls with content) if (model.glyphEntry) { @@ -37,17 +86,19 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin // Header — centered name (omit Unicode symbol when glyph present) const headerText = model.glyphEntry - ? `${gua.n} ${gua.p}` - : `${gua.u} ${gua.n} ${gua.p}`; + ? english ? gua.ename : `${zh(gua.n, language)} ${gua.p}` + : english ? `${gua.u} ${gua.ename}` : `${gua.u} ${zh(gua.n, language)} ${gua.p}`; lines.push({ text: centerPad(headerText, textWidth), fg: t.primary, bold: true, }); - lines.push({ - text: centerPad(gua.ename, textWidth), - fg: t.accent, - }); + if (english) { + lines.push({ + text: centerPad(`${zh(gua.n, "zh-Hant")} ${gua.p}`, textWidth), + fg: t.accent, + }); + } lines.push({ text: "" }); // Line diagram (top to bottom: line 6 down to line 1) @@ -63,7 +114,9 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin // Trigram info const s = model.detail.structure; - const trigramLine = `${s.upper.sym} ${s.upper.n} ${s.upper.img} above ${s.lower.sym} ${s.lower.n} ${s.lower.img}`; + const trigramLine = english + ? `${s.upper.sym} ${s.upper.img} above ${s.lower.sym} ${s.lower.img}` + : `${s.upper.sym} ${zh(s.upper.n, language)} ${zh(TRIGRAM_IMAGE_ZH[s.upper.n] ?? s.upper.img, language)} 上 ${s.lower.sym} ${zh(s.lower.n, language)} ${zh(TRIGRAM_IMAGE_ZH[s.lower.n] ?? s.lower.img, language)} 下`; lines.push({ text: centerPad(trigramLine, textWidth), fg: t.secondary, @@ -75,20 +128,22 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin lines.push({ text: "" }); // Commentary sections - const sections: [string, string][] = [ - ["大象傳", gua.dx], - ["彖傳", gua.tu], - ["Image", gua.en], - ["Judgment", gua.te], - ["Wilhelm", gua.w], - ]; + const sections: [string, string][] = english + ? [ + ["Image", gua.en], + ["Judgment", gua.te], + // "Wilhelm-inspired" (not bare "Wilhelm"): gua.w is interpretive advice + // after Wilhelm, NOT a direct quotation (AC-010 attribution policy; C-005). + ["Wilhelm-inspired", gua.w], + ] + : [ + [zh("大象傳", language), zh(gua.dx, language)], + [zh("彖傳", language), zh(gua.tu, language)], + ]; for (const [label, text] of sections) { lines.push({ text: label, fg: t.accent, bold: true }); - const wrapped = wordWrap(text, textWidth); - for (const wl of wrapped) { - lines.push({ text: wl, fg: t.secondary }); - } + pushWrapped(lines, text, textWidth, { fg: t.secondary }); lines.push({ text: "" }); } @@ -96,22 +151,18 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin if (gua.yao && gua.yao.length === 6) { lines.push({ text: "─".repeat(textWidth), fg: t.tertiary }); lines.push({ text: "" }); - lines.push({ text: "爻辭 Line Texts", fg: t.accent, bold: true }); + lines.push({ text: english ? "Line Texts" : zh("爻辭", language), fg: t.accent, bold: true }); lines.push({ text: "" }); // Display top-to-bottom (line 6 down to line 1) to match visual diagram for (let i = 5; i >= 0; i--) { - // Chinese line text - const wrapped = wordWrap(gua.yao[i], textWidth); - for (const wl of wrapped) { - lines.push({ text: wl, fg: t.secondary }); - } - // English translation - if (gua.yaoEn && gua.yaoEn[i]) { - const wrappedEn = wordWrap(gua.yaoEn[i], textWidth); - for (const wl of wrappedEn) { - lines.push({ text: wl, fg: t.tertiary }); + if (english) { + lines.push({ text: `Line ${i + 1}`, fg: t.secondary, bold: true }); + if (gua.yaoEn?.[i]) { + pushWrapped(lines, gua.yaoEn[i], textWidth, { fg: t.tertiary }); } + } else { + pushWrapped(lines, zh(gua.yao[i], language), textWidth, { fg: t.secondary }); } lines.push({ text: "" }); } @@ -122,13 +173,16 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin lines.push({ text: "" }); // Derived hexagrams - lines.push({ text: "Derived", fg: t.accent, bold: true }); + lines.push({ text: english ? "Derived" : zh("衍卦", language), fg: t.accent, bold: true }); for (let i = 0; i < model.derivedLinks.length; i++) { const link = model.derivedLinks[i]; const isSelected = model.focus === "derived" && model.derivedCursor === i; const marker = isSelected ? ">" : " "; + const text = english + ? `${marker} ${link.label.padEnd(10)} ${link.symbol} ${link.ename}` + : `${marker} ${zh(link.labelCn, language)} ${link.symbol} ${zh(link.name, language)}`; lines.push({ - text: `${marker} ${link.labelCn} ${link.label.padEnd(10)} ${link.symbol} ${link.name} ${link.ename}`, + text, fg: isSelected ? t.primary : t.secondary, }); } @@ -136,8 +190,11 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin // Locked pair if (model.detail.isLocked && model.detail.lockedPartner) { lines.push({ text: "" }); + const partner = model.detail.lockedPartner.gua; lines.push({ - text: `🔒 Locked pair: ${model.detail.lockedPartner.gua.n}`, + text: english + ? `Locked pair: ${partner.ename}` + : `${zh("鎖定對卦", language)}: ${zh(partner.n, language)}`, fg: t.tertiary, }); } @@ -150,8 +207,10 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin // History const historyText = model.castCount > 0 - ? `Cast ${model.castCount} time${model.castCount !== 1 ? "s" : ""} (last: ${model.lastCastDate})` - : "No history"; + ? english + ? `Cast ${model.castCount} time${model.castCount !== 1 ? "s" : ""} (last: ${model.lastCastDate})` + : `${zh("已占", language)} ${model.castCount} ${zh("次", language)} (${zh("最近", language)}: ${model.lastCastDate})` + : english ? "No history" : zh("未有占記", language); lines.push({ text: historyText, fg: t.tertiary, dim: true }); return lines; @@ -162,8 +221,9 @@ export function renderDetail( frame: CellBuffer, model: DetailModel, ctx: SceneContext, + options?: DetailRenderOptions, ): void { - const contentLines = buildContentLines(model, ctx.cols); + const contentLines = buildContentLines(model, ctx.cols, options); model.contentHeight = contentLines.length; const visibleRows = ctx.rows - FOOTER_ROWS; @@ -222,13 +282,14 @@ export function renderDetail( } // Footer - renderFooter(frame, model, ctx); + renderFooter(frame, model, ctx, activeLanguage(options)); } function renderFooter( frame: CellBuffer, model: DetailModel, ctx: SceneContext, + language: DisplayLanguage, ): void { const t = getTheme(); const sepRow = ctx.rows - 2; @@ -238,12 +299,13 @@ function renderFooter( const keys = model.focus === "derived" - ? "[↑↓] select · [enter] open · [tab] scroll · [esc] back" - : "[↑↓] scroll · [tab] derived · [enter] open · [esc] back"; + ? `[↑↓] ${tr(language, "verb.select")} · [enter] ${tr(language, "verb.open")} · [tab] ${tr(language, "verb.scroll")} · [esc] ${tr(language, "verb.back")}` + : `[↑↓] ${tr(language, "verb.scroll")} · [tab] ${tr(language, "verb.derived")} · [enter] ${tr(language, "verb.open")} · [esc] ${tr(language, "verb.back")}`; + // Hidden when content fits (vs the region's "1/1"); shows the page otherwise. const indicator = model.contentHeight > model.viewportHeight - ? `${Math.floor(model.scrollOffset / model.viewportHeight) + 1}/${Math.ceil(model.contentHeight / model.viewportHeight)}` + ? pageIndicator(model.scrollOffset, model.contentHeight, model.viewportHeight) : ""; frame.writeText(footerRow, 1, keys, { fg: t.secondary }); diff --git a/packages/terminal/src/scenes/dict/detail-scene.ts b/packages/terminal/src/scenes/dict/detail-scene.ts index 773efee..aa82bf9 100644 --- a/packages/terminal/src/scenes/dict/detail-scene.ts +++ b/packages/terminal/src/scenes/dict/detail-scene.ts @@ -3,7 +3,8 @@ import type { Scene, SceneContext, SceneSignal } from "../../scene/types.ts"; import type { CellBuffer } from "../../render/buffer.ts"; import type { KeyEvent } from "../../input/key-parser.ts"; -import type { GlyphFont } from "@iching/core"; +import type { DisplayLanguage, GlyphFont } from "@iching/core"; +import { toSimplified } from "@iching/core"; import type { GlyphAnimStyle } from "../../glyph-anim/types.ts"; import { composeGlyph } from "../../glyph-anim/compose.ts"; import { createGlyphAnimator } from "../../glyph-anim/factory.ts"; @@ -21,10 +22,16 @@ const FOOTER_ROWS = 2; export class DetailScene implements Scene { private model: DetailModel; private glyphConfig?: DetailGlyphConfig; + private language: DisplayLanguage; - constructor(kw: number, glyphConfig?: DetailGlyphConfig) { + constructor( + kw: number, + glyphConfig?: DetailGlyphConfig, + language: DisplayLanguage = "en", + ) { this.model = new DetailModel(kw); this.glyphConfig = glyphConfig; + this.language = language; } enter(ctx: SceneContext): void { @@ -32,7 +39,11 @@ export class DetailScene implements Scene { // Create glyph animator on entry (skip if already completed from prior visit) if (this.glyphConfig && !this.model.glyphAnimDone) { - const name = this.model.detail.gua.n; + // zh-Hans composes the glyph from the Simplified name so it matches the + // Simplified header text (e.g. 兑 above "兑 Duì"), not the Traditional 兌. + const name = this.language === "zh-Hans" + ? toSimplified(this.model.detail.gua.n) + : this.model.detail.gua.n; const charCount = Math.max(1, [...name].length); const size = autoGlyphSize(ctx.rows - FOOTER_ROWS, ctx.cols, charCount); const glyph = composeGlyph( @@ -50,7 +61,9 @@ export class DetailScene implements Scene { } // Pre-compute content height so scroll bounds work before first render - this.model.contentHeight = buildContentLines(this.model, ctx.cols).length; + this.model.contentHeight = buildContentLines(this.model, ctx.cols, { + language: this.language, + }).length; } update(elapsed: number, _dt: number, _ctx: SceneContext): void { @@ -64,7 +77,7 @@ export class DetailScene implements Scene { } render(frame: CellBuffer, ctx: SceneContext): void { - renderDetail(frame, this.model, ctx); + renderDetail(frame, this.model, ctx, { language: this.language }); } resize(_cols: number, rows: number): void { diff --git a/packages/terminal/src/scenes/home/home-scene.ts b/packages/terminal/src/scenes/home/home-scene.ts index 13dd679..043e882 100644 --- a/packages/terminal/src/scenes/home/home-scene.ts +++ b/packages/terminal/src/scenes/home/home-scene.ts @@ -4,10 +4,11 @@ import type { Scene, SceneContext, SceneSignal } from "../../scene/types.ts"; import type { CellBuffer } from "../../render/buffer.ts"; import type { KeyEvent } from "../../input/key-parser.ts"; import type { DailyCache } from "@iching/core"; -import { GUA } from "@iching/core"; +import { GUA, toSimplified } from "@iching/core"; import { getTheme } from "../../color/theme.ts"; import { stringWidth } from "../../layout/measure.ts"; import { renderTaijitu, type TaijituStyle } from "./taijitu-render.ts"; +import { tr, type MessageKey } from "../../i18n/messages.ts"; export interface HomeState { todayCast: DailyCache | null; @@ -33,8 +34,10 @@ export class HomeScene implements Scene { this.elapsed = elapsed; } - render(frame: CellBuffer, _ctx: SceneContext): void { + render(frame: CellBuffer, ctx: SceneContext): void { const t = getTheme(); + const lang = ctx.language ?? "en"; + const cn = (s: string): string => (lang === "zh-Hans" ? toSimplified(s) : s); const cx = Math.floor(frame.width / 2); const titleRow = Math.floor(frame.height / 2) - 6; let row = titleRow; @@ -55,20 +58,21 @@ export class HomeScene implements Scene { row += 3; // Menu items - const items = [ - { key: "c", label: "Cast", fg: t.accent }, - ...(this.state.devMode ? [{ key: "p", label: "Play", fg: t.secondary }] : []), - { key: "d", label: "Dictionary", fg: t.primary }, - { key: "j", label: "Journal", fg: t.secondary }, - { key: "s", label: "Settings", fg: t.secondary }, - { key: "q", label: "Quit", fg: t.tertiary }, + const items: { key: string; msgKey: MessageKey; fg: string }[] = [ + { key: "c", msgKey: "menu.cast", fg: t.accent }, + ...(this.state.devMode ? [{ key: "p", msgKey: "menu.play" as MessageKey, fg: t.secondary }] : []), + { key: "d", msgKey: "menu.dictionary", fg: t.primary }, + { key: "j", msgKey: "menu.journal", fg: t.secondary }, + { key: "s", msgKey: "menu.settings", fg: t.secondary }, + { key: "q", msgKey: "menu.quit", fg: t.tertiary }, ]; for (const item of items) { - const text = `[${item.key}] ${item.label}`; + const label = tr(lang, item.msgKey); + const text = `[${item.key}] ${label}`; const col = cx - Math.floor(stringWidth(text) / 2); frame.writeText(row, col, `[${item.key}]`, { fg: t.tertiary }); - frame.writeText(row, col + stringWidth(`[${item.key}]`) + 1, ` ${item.label}`, { fg: item.fg }); + frame.writeText(row, col + stringWidth(`[${item.key}]`) + 1, ` ${label}`, { fg: item.fg }); row += 2; } @@ -76,19 +80,19 @@ export class HomeScene implements Scene { row += 1; if (this.state.todayCast) { const gua = GUA[this.state.todayCast.cast.primary - 1]; - const status = `Today: ${gua.u} ${gua.n} (${gua.p})`; + const status = `${tr(lang, "home.today")} ${gua.u} ${cn(gua.n)} (${gua.p})`; const statusCol = cx - Math.floor(stringWidth(status) / 2); frame.writeText(row, statusCol, status, { fg: t.secondary, dim: true }); if (this.state.todayCast.cast.becoming !== null) { row += 1; const bg = GUA[this.state.todayCast.cast.becoming - 1]; - const becoming = `→ ${bg.u} ${bg.n}`; + const becoming = `→ ${bg.u} ${cn(bg.n)}`; const bCol = cx - Math.floor(stringWidth(becoming) / 2); frame.writeText(row, bCol, becoming, { fg: t.tertiary, dim: true }); } } else { - const nocast = "No cast today"; + const nocast = tr(lang, "home.noCast"); const ncCol = cx - Math.floor(stringWidth(nocast) / 2); frame.writeText(row, ncCol, nocast, { fg: t.tertiary, dim: true }); } diff --git a/packages/terminal/src/scenes/intention/intention-scene.ts b/packages/terminal/src/scenes/intention/intention-scene.ts index b4e4498..558d878 100644 --- a/packages/terminal/src/scenes/intention/intention-scene.ts +++ b/packages/terminal/src/scenes/intention/intention-scene.ts @@ -6,6 +6,7 @@ import type { KeyEvent } from "../../input/key-parser.ts"; import { TextInput } from "../../widgets/text-input.ts"; import { getTheme } from "../../color/theme.ts"; import { stringWidth } from "../../layout/measure.ts"; +import { tr } from "../../i18n/messages.ts"; export class IntentionScene implements Scene { private textInput: TextInput; @@ -23,8 +24,9 @@ export class IntentionScene implements Scene { update(_elapsed: number, _dt: number, _ctx: SceneContext): void {} - render(frame: CellBuffer, _ctx: SceneContext): void { + render(frame: CellBuffer, ctx: SceneContext): void { const t = getTheme(); + const lang = ctx.language ?? "en"; const cx = Math.floor(frame.width / 2); const fieldWidth = Math.min(frame.width - 8, 60); @@ -42,7 +44,7 @@ export class IntentionScene implements Scene { const inputRow = promptRow + 2; const hintRow = inputRow + inputRows + 1; - // Prompt + // Prompt — 問 is a canonical anchor, shown in all languages (Policy Matrix) const prompt = "問"; const promptCol = cx - Math.floor(stringWidth(prompt) / 2); frame.writeText(promptRow, promptCol, prompt, { fg: t.primary }); @@ -55,7 +57,7 @@ export class IntentionScene implements Scene { // Hint (only render if it fits) if (hintRow < frame.height) { - const hint = "[enter] confirm · [esc] back"; + const hint = `[enter] ${tr(lang, "verb.confirm")} · [esc] ${tr(lang, "verb.back")}`; const hintCol = cx - Math.floor(stringWidth(hint) / 2); frame.writeText(hintRow, hintCol, hint, { fg: t.tertiary, dim: true }); } diff --git a/packages/terminal/src/scenes/journal/journal-scene.ts b/packages/terminal/src/scenes/journal/journal-scene.ts index 8fe3c6c..323c23f 100644 --- a/packages/terminal/src/scenes/journal/journal-scene.ts +++ b/packages/terminal/src/scenes/journal/journal-scene.ts @@ -4,10 +4,11 @@ import type { Scene, SceneContext, SceneSignal } from "../../scene/types.ts"; import type { CellBuffer } from "../../render/buffer.ts"; import type { KeyEvent } from "../../input/key-parser.ts"; import type { HistoryEntry } from "@iching/core"; -import { GUA, buildStructure } from "@iching/core"; +import { GUA, buildStructure, toSimplified } from "@iching/core"; import { getTheme } from "../../color/theme.ts"; import { stringWidth } from "../../layout/measure.ts"; import { ScrollableRegion } from "../../widgets/scrollable.ts"; +import { tr } from "../../i18n/messages.ts"; export class JournalScene implements Scene { private entries: HistoryEntry[]; @@ -33,14 +34,15 @@ export class JournalScene implements Scene { render(frame: CellBuffer, ctx: SceneContext): void { const t = getTheme(); + const lang = ctx.language ?? "en"; const maxW = ctx.cols; // Header - const title = "Journal"; + const title = tr(lang, "journal.title"); const titleCol = Math.max(0, Math.floor((maxW - stringWidth(title)) / 2)); frame.writeText(0, titleCol, title, { fg: t.primary, bold: true }); - const countText = `${this.entries.length} readings`; + const countText = `${this.entries.length} ${tr(lang, "journal.countSuffix")}`; frame.writeText(0, maxW - stringWidth(countText) - 1, countText, { fg: t.tertiary }); // Separator @@ -49,7 +51,7 @@ export class JournalScene implements Scene { frame.writeText(1, sepCol, sep, { fg: t.tertiary, dim: true }); if (this.entries.length === 0) { - const empty = "No readings yet"; + const empty = tr(lang, "journal.empty"); const emptyCol = Math.max(0, Math.floor((maxW - stringWidth(empty)) / 2)); frame.writeText(Math.floor(ctx.rows / 2), emptyCol, empty, { fg: t.secondary }); return; @@ -74,13 +76,14 @@ export class JournalScene implements Scene { const time = entry.timestamp ? formatTime(entry.timestamp) : ""; const dateCol = time ? `${date} ${time}` : date; - // Hexagram info - let line = `${dateCol} ${gua.u} ${gua.n} (${gua.p})`; + // Hexagram info — convert names for zh-Hans (no English in Chinese modes). + const cn = (s: string): string => (lang === "zh-Hans" ? toSimplified(s) : s); + let line = `${dateCol} ${gua.u} ${cn(gua.n)} (${gua.p})`; // Becoming if (entry.cast.becoming !== null) { const bg = GUA[entry.cast.becoming - 1]; - line += ` → ${bg.u} ${bg.n}`; + line += ` → ${bg.u} ${cn(bg.n)}`; if (entry.cast.changingPositions?.length) { line += ` [${entry.cast.changingPositions.join(",")}]`; } @@ -117,7 +120,7 @@ export class JournalScene implements Scene { } // Footer - const footer = "[↑↓] navigate · [enter] view · [d] dictionary · [esc] back"; + const footer = `[↑↓] ${tr(lang, "verb.navigate")} · [enter] ${tr(lang, "verb.view")} · [d] ${tr(lang, "verb.dictionary")} · [esc] ${tr(lang, "verb.back")}`; const footerCol = Math.max(0, Math.floor((maxW - stringWidth(footer)) / 2)); frame.writeText(ctx.rows - 1, footerCol, footer, { fg: t.tertiary }); @@ -127,9 +130,9 @@ export class JournalScene implements Scene { const gua = GUA[selected.cast.primary - 1]; const structure = buildStructure(selected.cast); - // Show English image below the list separator + // Image preview: English image in en mode; the 大象傳 (converted for 简) in zh modes const detailRow = ctx.rows - 2; - const detail = gua.en; + const detail = lang === "en" ? gua.en : lang === "zh-Hans" ? toSimplified(gua.dx) : gua.dx; if (stringWidth(detail) <= maxW - 4) { frame.writeText(detailRow, 2, detail, { fg: t.tertiary, dim: true }); } @@ -182,12 +185,9 @@ export class JournalScene implements Scene { } private ensureCursorVisible(): void { - const viewportH = this.scroll.viewportHeight; - if (this.cursor < this.scroll.scrollOffset) { - this.scroll.scrollOffset = this.cursor; - } else if (this.cursor >= this.scroll.scrollOffset + viewportH) { - this.scroll.scrollOffset = this.cursor - viewportH + 1; - } + // Same cursor-into-view math the dict browse list uses; the ScrollableRegion + // this scene already owns exposes it (offsetToShow), so don't re-derive it. + this.scroll.ensureVisible(this.cursor); } } diff --git a/packages/terminal/src/scenes/settings/settings-scene.ts b/packages/terminal/src/scenes/settings/settings-scene.ts index cd302b9..df658d0 100644 --- a/packages/terminal/src/scenes/settings/settings-scene.ts +++ b/packages/terminal/src/scenes/settings/settings-scene.ts @@ -4,7 +4,7 @@ import type { Scene, SceneContext, SceneSignal } from "../../scene/types.ts"; import type { CellBuffer } from "../../render/buffer.ts"; import type { KeyEvent } from "../../input/key-parser.ts"; import type { GlyphAnimator, GlyphAnimStyle } from "../../glyph-anim/types.ts"; -import type { GlyphFont, GlyphSize } from "@iching/core"; +import type { DisplayLanguage, GlyphFont, GlyphSize } from "@iching/core"; import type { TaijituStyle } from "../home/taijitu-render.ts"; import { renderTaijitu } from "../home/taijitu-render.ts"; import { createGlyphAnimator } from "../../glyph-anim/factory.ts"; @@ -17,23 +17,53 @@ import { renderCoinSet, CoinAutoPreview } from "../cast/coin-renderer.ts"; import { renderYarrowFieldStrip, drawApertureCursor } from "../yarrow/field-renderer.ts"; 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 ────────────────────────────────────────────── const ANIM_OPTIONS: GlyphAnimStyle[] = ["dots", "noise", "radial", "sand"]; const FONT_OPTIONS: GlyphFont[] = ["kaiti", "libian", "heiti"]; +const LANGUAGE_OPTIONS: DisplayLanguage[] = ["en", "zh-Hant", "zh-Hans"]; +const LANGUAGE_LABELS: Record = { + en: "EN", + "zh-Hans": "简", + "zh-Hant": "繁", +}; const TAIJITU_OPTIONS: TaijituStyle[] = ["dots", "dense"]; const CAST_METHOD_OPTIONS = ["coin", "yarrow"] as const; const CAST_MODE_OPTIONS = ["auto", "manual"] as const; interface SettingRow { - label: string; - options: string[]; + /** Stable message-catalog key; the visible label is localized at render time. */ + key: MessageKey; + /** + * 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; glyphAnim: GlyphAnimStyle; glyphFont: GlyphFont; taijituStyle: TaijituStyle; @@ -93,27 +123,42 @@ export class SettingsScene implements Scene { constructor(initial: SettingsValues) { this.values = { ...initial }; this.rows = [ - { label: "Theme", options: [...THEME_NAMES], selected: Math.max(0, THEME_NAMES.indexOf(initial.theme)) }, - { label: "Taijitu", options: [...TAIJITU_OPTIONS], selected: Math.max(0, TAIJITU_OPTIONS.indexOf(initial.taijituStyle)) }, - { label: "Glyph Animation", options: [...ANIM_OPTIONS], selected: Math.max(0, ANIM_OPTIONS.indexOf(initial.glyphAnim)) }, - { label: "Font", options: [...FONT_OPTIONS], selected: Math.max(0, FONT_OPTIONS.indexOf(initial.glyphFont)) }, - { label: "Cast Method", options: [...CAST_METHOD_OPTIONS], selected: Math.max(0, CAST_METHOD_OPTIONS.indexOf(initial.castMethod)) }, - { label: "Cast Mode", 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.previewKindForLabel(this.rows[0]?.label); + 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. 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: THEME_NAMES[this.rows[0].selected] ?? "bone", - taijituStyle: TAIJITU_OPTIONS[this.rows[1].selected] ?? "dots", - glyphAnim: ANIM_OPTIONS[this.rows[2].selected] ?? "dots", - glyphFont: FONT_OPTIONS[this.rows[3].selected] ?? "kaiti", - castMethod: CAST_METHOD_OPTIONS[this.rows[4].selected] ?? "coin", - castMode: CAST_MODE_OPTIONS[this.rows[5].selected] ?? "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 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; + } + enter(_ctx: SceneContext): void {} update(elapsed: number, dt: number, _ctx: SceneContext): void { @@ -172,8 +217,12 @@ export class SettingsScene implements Scene { const cx = Math.floor(frame.width / 2); let row = 2; - // Title - const title = "Settings"; + // Title. Localize from the LIVE selection (getValues), not the saved + // snapshot (this.values, refreshed only on Escape) — so moving the Language + // row with ←/→ re-localizes the scene immediately, matching how Theme already + // previews live via onOptionChanged. + const lang = this.getValues().language; + const title = tr(lang, "settings.title"); frame.writeText(row, cx - Math.floor(stringWidth(title) / 2), title, { fg: t.primary, bold: true }); row += 1; @@ -186,21 +235,42 @@ export class SettingsScene implements Scene { // Setting rows const left = Math.max(2, cx - 24); - // Adaptive spacing: wide layout (3 rows per setting — label + options + - // gap) needs `frame.height >= 3N + 7` to keep the last setting clear of - // the footer separator at `height - 3`. At 80x24 with 6 settings, that - // threshold is 25 — we have 24, so use the compact 2-rows-per-setting - // form. Without this guard, Cast Mode options at row 21 got silently - // overwritten by the footer separator, leaving users to change the - // most important new setting blind. + // Adaptive spacing: wide layout is 3 rows per setting (label + options + + // gap), compact is 2. When even compact can't fit every row above the + // footer (very short terminals, e.g. h<22 with 7 settings), scroll a window + // of settings so the FOCUSED row is always visible — otherwise the last rows + // render under the bottom-anchored footer and the user edits a setting blind. const compact = frame.height < this.rows.length * 3 + 7; const interRowGap = compact ? 1 : 2; + // Label-to-label row delta: the label row, then the options row, then the + // inter-row gap → 1 + interRowGap (the options row is the "+1"). + const rowsPerSetting = 1 + interRowGap; - for (let i = 0; i < this.rows.length; i++) { + // Footer is anchored to the bottom; settings + preview share the space above. + const footerRow = frame.height - 2; + const footerSepRow = footerRow - 1; + + const settingsStartRow = row; + const availSettingRows = footerSepRow - settingsStartRow; + const maxVisible = Math.max(1, Math.floor(availSettingRows / rowsPerSetting)); + // Window that keeps the focused row in view, scrolling no more than needed. + const { start: scrollStart, end: scrollEnd } = windowFor( + this.focusedRow, + maxVisible, + this.rows.length, + ); + + for (let i = scrollStart; i < scrollEnd; i++) { const setting = this.rows[i]; const focused = i === this.focusedRow; - frame.writeText(row, left, setting.label, { + // Edge hint when more settings exist above/below the visible window. + const hint = + i === scrollStart && scrollStart > 0 ? " ↑" + : i === scrollEnd - 1 && scrollEnd < this.rows.length ? " ↓" + : ""; + + frame.writeText(row, left, tr(lang, setting.key) + hint, { fg: focused ? t.primary : t.secondary, bold: focused, }); @@ -210,8 +280,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}]`; @@ -221,16 +291,13 @@ 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; } - // Footer is anchored to the bottom; preview lives in whatever space is left. - const footerRow = frame.height - 2; - const footerSepRow = footerRow - 1; - const footer = "[↑↓] setting · [←→] option · [esc] save & back"; + const footer = `[↑↓] ${tr(lang, "verb.setting")} · [←→] ${tr(lang, "verb.option")} · [esc] ${tr(lang, "verb.saveBack")}`; const sectionSepRow = row; const previewLabelRow = row + 2; @@ -240,7 +307,7 @@ export class SettingsScene implements Scene { if (previewAvailRows >= MIN_PREVIEW_ROWS) { frame.writeText(sectionSepRow, sepCol, sep, { fg: t.border }); - frame.writeText(previewLabelRow, left, "Preview:", { fg: t.secondary }); + frame.writeText(previewLabelRow, left, tr(lang, "settings.preview"), { fg: t.secondary }); this.renderPreview(frame, cx, previewContentRow, previewAvailRows); } @@ -318,14 +385,14 @@ export class SettingsScene implements Scene { case "cast-yarrow": { if (!this.yarrowAuto) this.yarrowAuto = new YarrowAutoPreview(); const fieldRow = startRow + yarrowFieldOffset(availRows); - renderYarrowFieldStrip(frame, this.yarrowAuto.model, fieldRow); + renderYarrowFieldStrip(frame, this.yarrowAuto.model, fieldRow, vals.language); break; } case "cast-yarrow-manual": { if (!this.yarrowManual) this.yarrowManual = new YarrowManualPreview(); const fieldRow = startRow + yarrowFieldOffset(availRows); - renderYarrowFieldStrip(frame, this.yarrowManual.model, fieldRow); + renderYarrowFieldStrip(frame, this.yarrowManual.model, fieldRow, vals.language); // Aperture overlay only during sweep/snap — during play the runner // mutates the bar, and the aperture would smear over the action. if (this.yarrowManual.phase !== "playing") { @@ -358,13 +425,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; } @@ -376,9 +443,9 @@ export class SettingsScene implements Scene { } } - private previewKindForLabel(label: string | undefined): PreviewKind { - if (label === "Taijitu") return "taijitu"; - if (label === "Cast Method" || label === "Cast Mode") { + private previewKindForKey(key: MessageKey | undefined): PreviewKind { + if (key === "settings.taijitu") return "taijitu"; + if (key === "settings.castMethod" || key === "settings.castMode") { const { castMethod, castMode } = this.getValues(); if (castMethod === "yarrow") { return castMode === "manual" ? "cast-yarrow-manual" : "cast-yarrow"; @@ -397,22 +464,22 @@ export class SettingsScene implements Scene { } private onFocusChanged(): void { - const label = this.rows[this.focusedRow]?.label; + const key = this.rows[this.focusedRow]?.key; this.resetCastPreview(); this.previewActive = false; - this.previewKind = this.previewKindForLabel(label); - if (label === "Glyph Animation" || label === "Font") this.startPreview(); + this.previewKind = this.previewKindForKey(key); + if (key === "settings.glyphAnimation" || key === "settings.font") this.startPreview(); } private onOptionChanged(): void { const vals = this.getValues(); setTheme(vals.theme); - const label = this.rows[this.focusedRow]?.label; - if (label === "Glyph Animation" || label === "Font") { + const key = this.rows[this.focusedRow]?.key; + if (key === "settings.glyphAnimation" || key === "settings.font") { this.startPreview(); - } else if (label === "Cast Method" || label === "Cast Mode") { + } else if (key === "settings.castMethod" || key === "settings.castMode") { this.resetCastPreview(); - this.previewKind = this.previewKindForLabel(label); + this.previewKind = this.previewKindForKey(key); } } diff --git a/packages/terminal/src/scenes/toss/toss-scene.ts b/packages/terminal/src/scenes/toss/toss-scene.ts index 2579e09..abde15c 100644 --- a/packages/terminal/src/scenes/toss/toss-scene.ts +++ b/packages/terminal/src/scenes/toss/toss-scene.ts @@ -17,6 +17,7 @@ import { writeChromeHeader, writeChromeFooter, } from "../cast/ritual-chrome.ts"; +import { tr } from "../../i18n/messages.ts"; import { type CoinState, INITIAL_VY, @@ -87,18 +88,19 @@ export class TossScene implements Scene { } } - render(frame: CellBuffer, _ctx: SceneContext): void { + render(frame: CellBuffer, ctx: SceneContext): void { const t = getTheme(); + const lang = ctx.language ?? "en"; const h = frame.height; const anchor = anchorRow(h); if (this.phase === "waiting") { // Coin = 1 toss per line, so no sub-counter — formatLineCounter // omits it when round.total <= 1. - writeChromeHeader(frame, formatLineCounter(this.round, 6)); - writeChromeFooter(frame, "[space] toss · [esc] back"); + writeChromeHeader(frame, formatLineCounter(this.round, 6, undefined, lang)); + writeChromeFooter(frame, `[space] ${tr(lang, "verb.toss")} · [esc] ${tr(lang, "verb.back")}`); } else if (this.phase === "complete") { - writeChromeFooter(frame, "[space] reveal · [esc] discard"); + writeChromeFooter(frame, `[space] ${tr(lang, "verb.reveal")} · [esc] ${tr(lang, "verb.discard")}`); } // Physics coins diff --git a/packages/terminal/src/scenes/yarrow/field-renderer.ts b/packages/terminal/src/scenes/yarrow/field-renderer.ts index e5292eb..8a9275d 100644 --- a/packages/terminal/src/scenes/yarrow/field-renderer.ts +++ b/packages/terminal/src/scenes/yarrow/field-renderer.ts @@ -15,6 +15,8 @@ import { stringWidth } from "../../layout/measure.ts"; import { renderLine } from "../cast/line-renderer.ts"; import { anchorRow, LINE_ROW_OFFSETS } from "../cast/hexagram-renderer.ts"; import { formatLineCounter, writeChromeHeader } from "../cast/ritual-chrome.ts"; +import { tr } from "../../i18n/messages.ts"; +import type { DisplayLanguage } from "@iching/core"; import { LINE_WIDTH } from "../../glyphs.ts"; import type { YarrowModel } from "./model.ts"; @@ -327,7 +329,12 @@ function renderHexagramLines(buf: CellBuffer, model: YarrowModel): void { * The fuse beat references `LINE_ROW_OFFSETS[activeLine]` from the * hexagram band — preview callers must not reach `beat === "fuse"`. */ -export function renderYarrowFieldStrip(buf: CellBuffer, model: YarrowModel, row: number): void { +export function renderYarrowFieldStrip( + buf: CellBuffer, + model: YarrowModel, + row: number, + language: DisplayLanguage = "en", +): void { const t = getTheme(); const center = Math.floor(buf.width / 2); const areaStart = barAreaStartCol(buf); @@ -335,7 +342,7 @@ export function renderYarrowFieldStrip(buf: CellBuffer, model: YarrowModel, row: // Narrow fallback — bar area is 52 cells; below ~56 the bar overflows. if (buf.width < 56) { if (model.beat !== "fuse" && model.beat !== "done") { - writeCentered(buf, row, `${model.fieldCount} stalks`, center, t.primary); + writeCentered(buf, row, `${model.fieldCount} ${tr(language, "yarrow.stalks")}`, center, t.primary); } return; } @@ -421,7 +428,7 @@ export function renderYarrowFieldStrip(buf: CellBuffer, model: YarrowModel, row: drawTempFours(buf, row, areaStart, numQuartets, t.secondary); const inTray = 1 + round.leftRemainder * leftP + round.rightRemainder * rightP; drawTray(buf, row, areaStart, inTray, t.accent); - writeCentered(buf, row + 1, `set aside ${round.setAside}`, center, t.tertiary, true); + writeCentered(buf, row + 1, `${tr(language, "yarrow.setAside")} ${round.setAside}`, center, t.tertiary, true); break; } @@ -561,7 +568,7 @@ function applyOperatorCursor( // ── Chrome (caption + progress label) ──────────────────────────────────────── -function renderChrome(buf: CellBuffer, model: YarrowModel): void { +function renderChrome(buf: CellBuffer, model: YarrowModel, language: DisplayLanguage): void { const t = getTheme(); const center = Math.floor(buf.width / 2); @@ -572,15 +579,19 @@ function renderChrome(buf: CellBuffer, model: YarrowModel): void { if (model.activeLine >= 0 && model.activeLine < 6 && !model.hexagramComplete) { writeChromeHeader( buf, - formatLineCounter(model.activeLine, 6, { idx: model.activeRound, total: 3 }), + formatLineCounter(model.activeLine, 6, { idx: model.activeRound, total: 3 }, language), ); } } // ── Entry ──────────────────────────────────────────────────────────────────── -export function renderYarrowField(buf: CellBuffer, model: YarrowModel): void { +export function renderYarrowField( + buf: CellBuffer, + model: YarrowModel, + language: DisplayLanguage = "en", +): void { renderHexagramLines(buf, model); - renderYarrowFieldStrip(buf, model, fieldRow(buf)); - renderChrome(buf, model); + renderYarrowFieldStrip(buf, model, fieldRow(buf), language); + renderChrome(buf, model, language); } diff --git a/packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts b/packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts index f8d8b8d..21ac8b9 100644 --- a/packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts +++ b/packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts @@ -36,6 +36,8 @@ import { bounceAperture, } from "./field-renderer.ts"; import { writeChromeFooter } from "../cast/ritual-chrome.ts"; +import { tr } from "../../i18n/messages.ts"; +import type { DisplayLanguage } from "@iching/core"; import { buildYarrowRoundBeats, buildYarrowFuseBeat, @@ -75,9 +77,12 @@ export class YarrowManualScene implements Scene { private subRunner: TimelineRunner | null = null; private subElapsed = 0; - constructor(motion: MotionPreset = "default", source?: RandomSource) { + private readonly language: DisplayLanguage; + + constructor(motion: MotionPreset = "default", source?: RandomSource, language: DisplayLanguage = "en") { this.model = new YarrowModel(null); this.source = source ?? new CryptoRandomSource(); + this.language = language; const resolved = getYarrowTiming(motion); this.timing = resolved.timing; this.detail = resolved.detail; @@ -109,7 +114,7 @@ export class YarrowManualScene implements Scene { } render(frame: CellBuffer, _ctx: SceneContext): void { - renderYarrowField(frame, this.model); + renderYarrowField(frame, this.model, this.language); if (this.phase === "sweeping" || this.phase === "snapping") { const g = yarrowFieldGeometry(frame); drawApertureCursor( @@ -117,7 +122,7 @@ export class YarrowManualScene implements Scene { APERTURE_WIDTH, this.currentStartCount(), ); } - this.renderFooter(frame); + this.renderFooter(frame, this.language); } handleKey(key: KeyEvent, _ctx: SceneContext): SceneSignal | void { @@ -196,7 +201,7 @@ export class YarrowManualScene implements Scene { // arithmetic self-evident. const beats: Step[] = buildYarrowRoundBeats( this.model, this.timing, this.detail, lineIdx, roundIdx, round, - { narrating: true, revealCut: false }, + { narrating: true, revealCut: false, language: this.language }, ); if (roundIdx === ROUNDS_PER_LINE - 1) { @@ -205,7 +210,7 @@ export class YarrowManualScene implements Scene { const line = lineFromValue(toLineValue(round.remaining / 4)); this.model.transcript[lineIdx].line = line; this.currentLineRounds = []; - beats.push(buildYarrowFuseBeat(this.model, this.timing, lineIdx, { narrating: true })); + beats.push(buildYarrowFuseBeat(this.model, this.timing, lineIdx, { narrating: true, language: this.language })); } this.subRunner = new TimelineRunner(seq(...beats)); @@ -274,24 +279,24 @@ export class YarrowManualScene implements Scene { this.snapHoldMs = 0; } - private renderFooter(frame: CellBuffer): void { + private renderFooter(frame: CellBuffer, lang: DisplayLanguage): void { // The line/round counter already lives in the chrome header (field- // renderer's renderChrome at row 1). The footer carries only the // phase-appropriate action prompt — duplicating the position info - // here is dead text. + // here is dead text. Key-hint [key] verb form, normalized across languages. let text: string; switch (this.phase) { case "complete": - text = "[space] receive the reading · [esc] discard"; + text = `[space] ${tr(lang, "verb.receiveReading")} · [esc] ${tr(lang, "verb.discard")}`; break; case "gathering": - text = "press [space] to begin cutting · [esc] back"; + text = `[space] ${tr(lang, "verb.beginCutting")} · [esc] ${tr(lang, "verb.back")}`; break; case "sweeping": - text = "press [space] to cut"; + text = `[space] ${tr(lang, "verb.cut")}`; break; case "snapping": - text = "cut around here"; + text = tr(lang, "verb.cutAroundHere"); break; case "playing": text = ""; diff --git a/packages/terminal/src/scenes/yarrow/yarrow-scene.ts b/packages/terminal/src/scenes/yarrow/yarrow-scene.ts index c632e62..047fde2 100644 --- a/packages/terminal/src/scenes/yarrow/yarrow-scene.ts +++ b/packages/terminal/src/scenes/yarrow/yarrow-scene.ts @@ -19,6 +19,8 @@ import { YarrowModel } from "./model.ts"; import { buildYarrowTimeline } from "./yarrow-timeline.ts"; import { renderYarrowField } from "./field-renderer.ts"; import { writeChromeFooter } from "../cast/ritual-chrome.ts"; +import { tr } from "../../i18n/messages.ts"; +import type { DisplayLanguage } from "@iching/core"; const SPEEDS = [1, 2, 4]; @@ -29,12 +31,14 @@ export class YarrowScene implements Scene { /** Scene-controlled clock — pace control modulates how fast this advances. */ private virtualElapsed = 0; private complete = false; + private readonly language: DisplayLanguage; - constructor(motion: MotionPreset = "default", source?: RandomSource) { + constructor(motion: MotionPreset = "default", source?: RandomSource, language: DisplayLanguage = "en") { // The cast is committed once, here — the moment the ritual begins. + this.language = language; this.model = new YarrowModel(castYarrowHexagram(source ?? new CryptoRandomSource())); const { timing, detail } = getYarrowTiming(motion); - const built = buildYarrowTimeline(this.model, timing, detail); + const built = buildYarrowTimeline(this.model, timing, detail, language); this.timeline = new TimelineRunner(built.timeline); this.beatOffsets = built.beatOffsets; } @@ -48,8 +52,10 @@ export class YarrowScene implements Scene { } render(frame: CellBuffer, _ctx: SceneContext): void { - renderYarrowField(frame, this.model); - this.renderFooter(frame); + // Captions are baked into the timeline at construction with this.language; + // use the same language for the live field + footer to stay consistent. + renderYarrowField(frame, this.model, this.language); + this.renderFooter(frame, this.language); } handleKey(key: KeyEvent, _ctx: SceneContext): SceneSignal | void { @@ -92,15 +98,15 @@ export class YarrowScene implements Scene { this.complete = true; } - private renderFooter(frame: CellBuffer): void { + private renderFooter(frame: CellBuffer, lang: DisplayLanguage): void { let text: string; if (this.model.hexagramComplete) { - text = "[space] receive the reading · [esc] discard"; + text = `[space] ${tr(lang, "verb.receiveReading")} · [esc] ${tr(lang, "verb.discard")}`; } else if (this.model.paused) { - text = "[space] resume · [→] step · [s] skip · [esc] back"; + text = `[space] ${tr(lang, "verb.resume")} · [→] ${tr(lang, "verb.step")} · [s] ${tr(lang, "verb.skip")} · [esc] ${tr(lang, "verb.back")}`; } else { const speed = this.model.speed > 1 ? ` · ${this.model.speed}×` : ""; - text = `[space] pause · [f] speed · [s] skip · [esc] back${speed}`; + text = `[space] ${tr(lang, "verb.pause")} · [f] ${tr(lang, "verb.speed")} · [s] ${tr(lang, "verb.skip")} · [esc] ${tr(lang, "verb.back")}${speed}`; } writeChromeFooter(frame, text); } diff --git a/packages/terminal/src/scenes/yarrow/yarrow-timeline.ts b/packages/terminal/src/scenes/yarrow/yarrow-timeline.ts index 46139df..d75fae0 100644 --- a/packages/terminal/src/scenes/yarrow/yarrow-timeline.ts +++ b/packages/terminal/src/scenes/yarrow/yarrow-timeline.ts @@ -8,8 +8,9 @@ import { type Step, seq, call, tween, wait, stepDuration } from "../../animation/timeline.ts"; import { type EasingFn, linear, easeOut, easeInOut } from "../../animation/easing.ts"; import type { YarrowTiming, RitualDetail } from "../../animation/yarrow-presets.ts"; -import type { YarrowRound } from "@iching/core"; +import type { YarrowRound, DisplayLanguage } from "@iching/core"; import type { YarrowModel } from "./model.ts"; +import { tr } from "../../i18n/messages.ts"; /** Stepwise easing — the `expanded` count peels groups in discrete jumps. */ const staircase: EasingFn = (t) => Math.min(1, Math.ceil(t * 10) / 10); @@ -28,37 +29,38 @@ interface RoundCaptions { carry: string; } -const LINE_VALUE_NAMES: Record<6 | 7 | 8 | 9, string> = { - 6: "old yin", - 7: "young yang", - 8: "young yin", - 9: "old yang", -}; +/** Localized line-value name (6/7/8/9 -> old yin / young yang / …). */ +function lineValueName(value: 6 | 7 | 8 | 9, language: DisplayLanguage): string { + return tr(language, `yarrow.lineValue.${value}` as + | "yarrow.lineValue.6" | "yarrow.lineValue.7" | "yarrow.lineValue.8" | "yarrow.lineValue.9"); +} function buildRoundCaptions( roundIdx: number, round: { startCount: number; splitAt: number; leftRemainder: number; rightRemainder: number; setAside: number; remaining: number }, + language: DisplayLanguage, ): RoundCaptions { const left = round.splitAt; const right = round.startCount - round.splitAt; + const t = (k: Parameters[1]) => tr(language, k); // Round 1 setAside is 5 ("few", counts 3) or 9 ("many", counts 2); - // rounds 2 & 3 are 4 ("few", 3) or 8 ("many", 2). + // rounds 2 & 3 are 4 ("few", 3) or 8 ("many", 2). Numbers/operators stay verbatim. const fewValue = roundIdx === 0 ? 5 : 4; - const meaning = round.setAside === fewValue ? "few = 3" : "many = 2"; - const nextLabel = roundIdx === 2 ? "fuse" : `round ${roundIdx + 2}`; + const meaning = round.setAside === fewValue ? `${t("yarrow.few")} = 3` : `${t("yarrow.many")} = 2`; + const nextLabel = roundIdx === 2 ? t("yarrow.fuse") : `${t("yarrow.round")} ${roundIdx + 2}`; return { - gather: `Round ${roundIdx + 1} · ${round.startCount} stalks`, - divide: `Cut at k=${left} · heaps ${left} | ${right}`, - takeOne: `One aside · heaps ${left} | ${right - 1}`, - count: "Count each heap by fours.", + gather: `${t("yarrow.roundTitle")} ${roundIdx + 1} · ${round.startCount} ${t("yarrow.stalks")}`, + divide: `${t("yarrow.cutAt")}${left} · ${t("yarrow.heaps")} ${left} | ${right}`, + takeOne: `${t("yarrow.oneAside")} · ${t("yarrow.heaps")} ${left} | ${right - 1}`, + count: t("yarrow.countByFours"), tally: `1 + ${round.leftRemainder} + ${round.rightRemainder} = ${round.setAside} (${meaning})`, - carry: `Carry ${round.remaining} → ${nextLabel}`, + carry: `${t("yarrow.carry")} ${round.remaining} → ${nextLabel}`, }; } -function buildFuseCaption(remaining: number): string { +function buildFuseCaption(remaining: number, language: DisplayLanguage): string { const value = (remaining / 4) as 6 | 7 | 8 | 9; - return `Remaining ${remaining} ÷ 4 = ${value} · ${LINE_VALUE_NAMES[value]}`; + return `${tr(language, "yarrow.remaining")} ${remaining} ÷ 4 = ${value} · ${lineValueName(value, language)}`; } export interface YarrowTimeline { @@ -90,7 +92,7 @@ export function buildYarrowRoundBeats( lineIdx: number, roundIdx: number, round: YarrowRound, - opts?: { narrating?: boolean; revealCut?: boolean }, + opts?: { narrating?: boolean; revealCut?: boolean; language?: DisplayLanguage }, ): Step[] { const beats: Step[] = []; const hold = (caption: string): Step[] => (caption ? [wait(timing.captionHoldMs)] : []); @@ -101,7 +103,7 @@ export function buildYarrowRoundBeats( // captions narrate. Auto mode passes revealCut: true so the cut k is named // alongside the divide animation. const revealCut = opts?.revealCut ?? true; - const caps = buildRoundCaptions(roundIdx, round); + const caps = buildRoundCaptions(roundIdx, round, opts?.language ?? "en"); const caption = (key: keyof RoundCaptions): string => { if (!narrating) return ""; if (key === "divide" && !revealCut) return ""; @@ -217,12 +219,12 @@ export function buildYarrowFuseBeat( model: YarrowModel, timing: YarrowTiming, lineIdx: number, - opts?: { narrating?: boolean }, + opts?: { narrating?: boolean; language?: DisplayLanguage }, ): Step { const line = model.transcript[lineIdx].line; const narrating = opts?.narrating ?? false; const hold = (caption: string): Step[] => (caption ? [wait(timing.captionHoldMs)] : []); - const fuseCap = narrating ? buildFuseCaption(model.transcript[lineIdx].rounds[2].remaining) : ""; + const fuseCap = narrating ? buildFuseCaption(model.transcript[lineIdx].rounds[2].remaining, opts?.language ?? "en") : ""; return seq( call(() => { model.beat = "fuse"; @@ -251,7 +253,7 @@ export function buildYarrowFullLineBeats( timing: YarrowTiming, detail: RitualDetail, lineIdx: number, - opts?: { narrating?: boolean }, + opts?: { narrating?: boolean; language?: DisplayLanguage }, ): Step[] { const rounds = model.transcript[lineIdx].rounds; return [ @@ -270,6 +272,7 @@ export function buildYarrowTimeline( model: YarrowModel, timing: YarrowTiming, detail: RitualDetail, + language: DisplayLanguage = "en", ): YarrowTimeline { const beats: Step[] = []; @@ -279,7 +282,7 @@ export function buildYarrowTimeline( const effectiveDetail: RitualDetail = teach ? "expanded" : detail; const narrating = lineIdx === 0; - beats.push(...buildYarrowFullLineBeats(model, timing, effectiveDetail, lineIdx, { narrating })); + beats.push(...buildYarrowFullLineBeats(model, timing, effectiveDetail, lineIdx, { narrating, language })); // Trigram-recognition pause once the lower trigram (lines 1-3) is complete. if (lineIdx === 2) beats.push(wait(timing.afterTrigramMs)); diff --git a/packages/terminal/src/widgets/scroll.ts b/packages/terminal/src/widgets/scroll.ts new file mode 100644 index 0000000..3160b34 --- /dev/null +++ b/packages/terminal/src/widgets/scroll.ts @@ -0,0 +1,43 @@ +// Scroll / viewport math shared by list and content views. +// +// Two patterns live here: +// - free-scroll: a scroll offset over `contentLength` lines (ScrollableRegion, +// detail content) — clamp the offset, render a page indicator. +// - cursor-into-view: a focused index that must stay within a viewport window +// (browse list, settings rows) — derive the offset/window from the cursor. + +/** Clamp a scroll offset into the valid range `[0, max(0, contentLength - viewport)]`. */ +export function clampOffset(offset: number, contentLength: number, viewport: number): number { + return Math.max(0, Math.min(offset, Math.max(0, contentLength - viewport))); +} + +/** + * New scroll offset that keeps `cursor` within a `viewport`-sized window, + * scrolling only when the cursor leaves the window (stateful list navigation). + */ +export function offsetToShow(cursor: number, offset: number, viewport: number): number { + if (cursor < offset) return cursor; + if (cursor >= offset + viewport) return cursor - viewport + 1; + return offset; +} + +/** + * Stateless visible window `[start, end)` of at most `viewport` items over + * `total`, positioned to include `cursor` with minimal scrolling. Used where the + * offset isn't retained between renders (e.g. the settings rows). + */ +export function windowFor( + cursor: number, + viewport: number, + total: number, +): { start: number; end: number } { + if (viewport >= total) return { start: 0, end: total }; + const start = Math.max(0, Math.min(cursor - viewport + 1, total - viewport)); + return { start, end: start + viewport }; +} + +/** Page indicator like `"2/5"` for a free-scroll region; `"1/1"` when it all fits. */ +export function pageIndicator(offset: number, contentLength: number, viewport: number): string { + if (contentLength <= viewport) return "1/1"; + return `${Math.floor(offset / viewport) + 1}/${Math.ceil(contentLength / viewport)}`; +} diff --git a/packages/terminal/src/widgets/scrollable.ts b/packages/terminal/src/widgets/scrollable.ts index 115260c..fe1dafc 100644 --- a/packages/terminal/src/widgets/scrollable.ts +++ b/packages/terminal/src/widgets/scrollable.ts @@ -1,4 +1,7 @@ -// ScrollableRegion — virtual content with viewport windowing +// ScrollableRegion — virtual content with viewport windowing. +// Scroll math lives in ./scroll.ts so list views (browse/settings) share it. + +import { clampOffset, offsetToShow, pageIndicator } from "./scroll.ts"; export class ScrollableRegion { contentLines: string[]; @@ -13,12 +16,12 @@ export class ScrollableRegion { /** Scroll up by n lines (default 1) */ scrollUp(n = 1): void { - this.scrollOffset = Math.max(0, this.scrollOffset - n); + this.scrollOffset = clampOffset(this.scrollOffset - n, this.contentLines.length, this.viewportHeight); } /** Scroll down by n lines (default 1) */ scrollDown(n = 1): void { - this.scrollOffset = Math.min(this.maxOffset(), this.scrollOffset + n); + this.scrollOffset = clampOffset(this.scrollOffset + n, this.contentLines.length, this.viewportHeight); } /** Scroll up by one page */ @@ -38,7 +41,12 @@ export class ScrollableRegion { /** Scroll to the last page */ scrollToBottom(): void { - this.scrollOffset = this.maxOffset(); + this.scrollOffset = clampOffset(Infinity, this.contentLines.length, this.viewportHeight); + } + + /** Adjust the offset to keep a cursor index within the viewport (list navigation). */ + ensureVisible(cursor: number): void { + this.scrollOffset = offsetToShow(cursor, this.scrollOffset, this.viewportHeight); } /** Return the visible slice of content */ @@ -51,15 +59,6 @@ export class ScrollableRegion { /** Scroll position indicator, e.g. "3/42" */ scrollIndicator(): string { - const total = this.contentLines.length; - if (total <= this.viewportHeight) return "1/1"; - const page = Math.floor(this.scrollOffset / this.viewportHeight) + 1; - const totalPages = Math.ceil(total / this.viewportHeight); - return `${page}/${totalPages}`; - } - - /** Maximum valid scroll offset */ - private maxOffset(): number { - return Math.max(0, this.contentLines.length - this.viewportHeight); + return pageIndicator(this.scrollOffset, this.contentLines.length, this.viewportHeight); } } diff --git a/scripts/generate-glyphs.ts b/scripts/generate-glyphs.ts index beb3e41..99eaa0e 100644 --- a/scripts/generate-glyphs.ts +++ b/scripts/generate-glyphs.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun /** - * Pre-render all 64 hexagram Chinese names as braille terminal art. - * 3 fonts × 3 sizes × 64 characters = 576 renders. + * Pre-render all 64 hexagram Chinese names as braille terminal art — + * Traditional name chars plus their Simplified forms (for zh-Hans), across + * 3 fonts × 3 sizes. * Output: packages/core/src/data/large-glyphs.ts * * Each glyph is stored as an array of row strings (braille characters), @@ -10,6 +11,7 @@ import { spawnSync } from "child_process"; import { GUA } from "../packages/core/src/data/gua.ts"; +import { toSimplified } from "../packages/core/src/i18n/simplify.ts"; // ── Fonts ── @@ -110,10 +112,13 @@ for row in range(h): type GlyphData = Record>>; -// Collect all unique characters from hexagram names +// Collect all unique characters from hexagram names — Traditional AND their +// Simplified forms, so zh-Hans renders a Simplified glyph (e.g. 觀→观, 兌→兑) +// instead of falling back to the Traditional emblem above Simplified text. const allChars = new Set(); for (const gua of GUA) { for (const ch of gua.n) allChars.add(ch); + for (const ch of toSimplified(gua.n)) allChars.add(ch); } const charList = [...allChars].sort(); diff --git a/scripts/verify-language-surfaces.ts b/scripts/verify-language-surfaces.ts new file mode 100644 index 0000000..34f7b5a --- /dev/null +++ b/scripts/verify-language-surfaces.ts @@ -0,0 +1,1149 @@ +#!/usr/bin/env bun +/** + * Language-surface verifier — the deterministic oracle for the I Ching language + * translation work. Its default inputs (inventory + consults) are committed + * under tests/fixtures/language/ so it runs reproducibly from a fresh clone. + * + * Modes (one per acceptance criterion). Only --inventory-only is implemented in + * this iteration; every other mode (and the no-flag run-all / final-verify mode) + * intentionally FAILS with "not yet implemented" so it cannot produce a false + * green before its criterion is genuinely built. + * + * --inventory-only AC-001: zero unclassified user-facing text surfaces. + * --policy AC-002 | --core-data AC-003 | --terminal AC-004 + * --cli AC-005 | --simplified AC-006 | --consults AC-007 + * --self-test AC-008 | --glossary AC-010 | (no flag) AC-009 run-all. + * + * Inventory source: tests/fixtures/language/TEXT_SURFACES.md (override --inventory ). + * + * AC-001 oracle = three honest checks: + * 1. SOURCE -> INVENTORY string-sink: extract user-facing candidate literals + * from in-scope UI/CLI source; every significant literal text fragment must + * appear in the inventory. Catches a new surface added to code but not + * inventoried. Template ${...} interpolations are split out; each literal + * fragment around them is matched independently. + * 2. CONSUMER-SIDE sentinels: known load-bearing strings must exist BOTH in + * their source file (the surface really renders) AND in the inventory. + * 3. INTEGRITY: corpus-data files covered at field-class altitude; every + * scanned file represented (brace-glob aware); enrichment fields absent + * from core Hexagram (AR-001 rollback guard). + * + * Usage: bun scripts/verify-language-surfaces.ts --inventory-only [--inventory ] + */ +import { readFileSync, existsSync, writeFileSync, mkdtempSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; + +const ROOT = process.cwd(); +const argv = process.argv.slice(2); +const flag = (name: string): boolean => argv.includes(name); +const optValue = (name: string): string | undefined => { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : undefined; +}; +const INVENTORY_REL = optValue("--inventory") ?? "tests/fixtures/language/TEXT_SURFACES.md"; + +// --------------------------------------------------------------------------- +// Scope +// --------------------------------------------------------------------------- +const STRING_SINK_FILES: string[] = [ + "apps/cli/src/program.ts", + "apps/cli/src/main.ts", + "apps/cli/src/commands/cast.ts", + "apps/cli/src/commands/config.ts", + "apps/cli/src/commands/dict.ts", + "apps/cli/src/commands/doctor.ts", + "apps/cli/src/commands/hexagram.ts", + "apps/cli/src/commands/journal.ts", + "apps/cli/src/commands/paths.ts", + "apps/cli/src/output/plain.ts", + "packages/core/src/format/reading.ts", + "packages/core/src/format/derived.ts", + "packages/core/src/identify/structure.ts", + "packages/terminal/src/scenes/home/home-scene.ts", + "packages/terminal/src/scenes/intention/intention-scene.ts", + "packages/terminal/src/scenes/toss/toss-scene.ts", + "packages/terminal/src/scenes/cast/cast-scene.ts", + "packages/terminal/src/scenes/cast/ritual-chrome.ts", + "packages/terminal/src/scenes/cast/reveal-renderer.ts", + "packages/terminal/src/scenes/yarrow/yarrow-scene.ts", + "packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts", + "packages/terminal/src/scenes/yarrow/yarrow-timeline.ts", + "packages/terminal/src/scenes/yarrow/field-renderer.ts", + "packages/terminal/src/scenes/dict/browse-renderer.ts", + "packages/terminal/src/scenes/dict/detail-renderer.ts", + "packages/terminal/src/scenes/dict/detail-model.ts", + "packages/terminal/src/scenes/journal/journal-scene.ts", + "packages/terminal/src/scenes/settings/settings-scene.ts", +]; + +const CORPUS_DATA_FILES: string[] = [ + "packages/core/src/data/gua.ts", + "packages/core/src/data/trigrams.ts", + "packages/core/src/data/large-glyphs.ts", +]; + +const REQUIRED_FIELD_IDS: string[] = [ + "core-gua-u", + "core-gua-name", + "core-gua-pinyin", + "core-gua-ename", + "core-gua-dx", + "core-gua-tu", + "core-gua-en", + "core-gua-te", + "core-gua-w", + "core-gua-yao", + "core-gua-yaoEn", + "core-trigram-name", + "core-trigram-img", + "core-trigram-sym", + "core-derived-labels-en", + "core-derived-labels-cn", + "core-large-glyphs", +]; + +/** Consumer-side sentinels: [literal, sourceFile]. Must exist in BOTH. */ +const SENTINELS: Array<[string, string]> = [ + ["問", "packages/terminal/src/scenes/intention/intention-scene.ts"], + ["乾", "packages/terminal/src/scenes/settings/settings-scene.ts"], + ["上", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["下", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["已占", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["最近", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["未有占記", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["大象傳", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["彖傳", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["爻辭", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["衍卦", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["鎖定對卦", "packages/terminal/src/scenes/dict/detail-renderer.ts"], + ["自綜", "packages/core/src/format/derived.ts"], + ["错综同象", "packages/core/src/format/derived.ts"], + ["自返", "packages/core/src/format/derived.ts"], // C-001 audit sentinel + ["對角卦", "packages/core/src/format/derived.ts"], // C-001 audit sentinel + ["大象", "apps/cli/src/output/plain.ts"], + ["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"], + // These moved into the message catalog when their scenes were localized (AC-004): + ["No readings yet", "packages/terminal/src/i18n/messages.ts"], + ["receive the reading", "packages/terminal/src/i18n/messages.ts"], + ["old yang", "apps/cli/src/output/plain.ts"], + [ + "Hexagram number must be an integer from 1 to 64.", + "apps/cli/src/commands/dict.ts", + ], + ["Unknown key", "apps/cli/src/commands/config.ts"], + ["Invalid value", "apps/cli/src/commands/config.ts"], + ["I Ching Doctor", "apps/cli/src/commands/doctor.ts"], +]; + +/** Machine-token literals the heuristic flags but which are NOT user-facing. */ +const EXEMPT: Set = new Set(); + +// --------------------------------------------------------------------------- +// Tokenizer: extract string/template literals; decode escapes; ${...} ->  +// --------------------------------------------------------------------------- +const INTERP = ""; // placeholder marking a template ${...} interpolation + +function decodeEscape(src: string, i: number): { text: string; next: number } { + // src[i] === "\\"; decode the escape starting at i. + const c = src[i + 1] ?? ""; + if (c === "u") { + if (src[i + 2] === "{") { + const end = src.indexOf("}", i + 3); + if (end > 0) { + const cp = parseInt(src.slice(i + 3, end), 16); + return { text: Number.isNaN(cp) ? "" : String.fromCodePoint(cp), next: end + 1 }; + } + } + const hex = src.slice(i + 2, i + 6); + if (/^[0-9a-fA-F]{4}$/.test(hex)) { + return { text: String.fromCharCode(parseInt(hex, 16)), next: i + 6 }; + } + } + if (c === "x") { + const hex = src.slice(i + 2, i + 4); + if (/^[0-9a-fA-F]{2}$/.test(hex)) { + return { text: String.fromCharCode(parseInt(hex, 16)), next: i + 4 }; + } + } + if (c === "n" || c === "t" || c === "r") return { text: " ", next: i + 2 }; + return { text: c, next: i + 2 }; +} + +function extractLiterals(src: string): string[] { + const out: string[] = []; + let i = 0; + const n = src.length; + while (i < n) { + const c = src[i]; + if (c === "/" && src[i + 1] === "/") { + i += 2; + while (i < n && src[i] !== "\n") i++; + continue; + } + if (c === "/" && src[i + 1] === "*") { + i += 2; + while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++; + i += 2; + continue; + } + if (c === '"' || c === "'") { + const q = c; + i++; + let buf = ""; + while (i < n && src[i] !== q) { + if (src[i] === "\\") { + const d = decodeEscape(src, i); + buf += d.text; + i = d.next; + continue; + } + if (src[i] === "\n") break; + buf += src[i]; + i++; + } + i++; + out.push(buf); + continue; + } + if (c === "`") { + i++; + let buf = ""; + while (i < n) { + if (src[i] === "\\") { + const d = decodeEscape(src, i); + buf += d.text; + i = d.next; + continue; + } + if (src[i] === "$" && src[i + 1] === "{") { + buf += INTERP; + i += 2; + let depth = 1; + while (i < n && depth > 0) { + if (src[i] === "{") depth++; + else if (src[i] === "}") depth--; + i++; + } + continue; + } + if (src[i] === "`") { + i++; + break; + } + buf += src[i]; + i++; + } + out.push(buf); + continue; + } + i++; + } + return out; +} + +// --------------------------------------------------------------------------- +// Classification & matching +// --------------------------------------------------------------------------- +const CJK_RE = /[㐀-鿿豈-﫿]/g; +const cjkCount = (s: string): number => (s.match(CJK_RE) ?? []).length; +const norm = (s: string): string => s.replace(/\s+/g, " ").trim(); +/** literal text with interpolations turned into spaces, for candidacy analysis. */ +const plain = (raw: string): string => norm(raw.split(INTERP).join(" ")); + +function isCandidate(raw: string): boolean { + const s = plain(raw); + if (!s) return false; + if (s.startsWith("./") || s.startsWith("../") || s.startsWith("@")) return false; + if (/^(node:|bun:)/.test(s)) return false; // module specifier + if (/^#[0-9A-Fa-f]{3,8}$/.test(s)) return false; // hex color + if (/^[a-z]{2,3}(-[A-Za-z]{2,8})+$/.test(s)) return false; // locale code zh-Hant + if (/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(s)) return false; // kebab token cast-auto + if (/^[a-z][A-Za-z0-9]*(\.[A-Za-z0-9]*)+$/.test(s)) return false; // message-catalog key menu.cast / yarrow.lineValue. + if (/^[A-Z0-9]+_[A-Z0-9_]*$/.test(s)) return false; // SCREAMING_SNAKE + if (/^[\d.\-/x:|%]+$/.test(s)) return false; // numeric/format + if (cjkCount(s) >= 2) return true; + if (cjkCount(s) === 1) return false; // single CJK char -> SENTINELS + // Latin + const words = s.replace(/[^A-Za-z]+/g, " ").trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return false; + if (words.length >= 2 && words.join("").length >= 3) return true; + if (/^[A-Z][a-z]{2,}$/.test(words[0]!)) return true; // Title-case + if (/^[A-Z]{2,}$/.test(words[0]!)) return true; // ALL-CAPS label + return false; +} + +/** trim leading/trailing non-letter (non-CJK) chars from a fragment. */ +function fragCore(frag: string): string { + return norm(frag.replace(/^[^\p{L}]+/u, "").replace(/[^\p{L}]+$/u, "")); +} +function isSignificant(core: string): boolean { + return cjkCount(core) >= 2 || /[A-Za-z]{3,}/.test(core); +} + +// --------------------------------------------------------------------------- +// Brace-glob expansion for inventory file representation +// --------------------------------------------------------------------------- +function expandBraces(inv: string): Set { + const set = new Set(); + const re = /([\w./-]+)\{([^}]+)\}([\w./-]*)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(inv)) !== null) { + const [, pre, alts, post] = m; + for (const a of alts!.split(",")) set.add(`${pre}${a.trim()}${post}`); + } + return set; +} + +// --------------------------------------------------------------------------- +// Reporting / IO +// --------------------------------------------------------------------------- +const failures: string[] = []; +const fail = (msg: string): void => { + failures.push(msg); +}; +function readMaybe(rel: string): string | null { + const p = resolve(ROOT, rel); + return existsSync(p) ? readFileSync(p, "utf8") : null; +} + +// --------------------------------------------------------------------------- +// AC-001 +// --------------------------------------------------------------------------- +function runInventoryOnly(): void { + const invRaw = readMaybe(INVENTORY_REL); + if (invRaw === null) { + fail(`inventory not found: ${resolve(ROOT, INVENTORY_REL)}`); + return; + } + const invNorm = norm(invRaw); + const braced = expandBraces(invRaw); + const represented = (rel: string): boolean => invRaw.includes(rel) || braced.has(rel); + + // 1. SOURCE -> INVENTORY string-sink (fragment coverage) + for (const rel of STRING_SINK_FILES) { + const src = readMaybe(rel); + if (src === null) { + fail(`scanned source file missing: ${rel}`); + continue; + } + if (!represented(rel)) fail(`source file has no inventory row: ${rel}`); + const seen = new Set(); + for (const lit of extractLiterals(src)) { + const key = plain(lit); + if (!key || seen.has(key)) continue; + seen.add(key); + if (EXEMPT.has(key)) continue; + if (!isCandidate(lit)) continue; + for (const frag of lit.split(INTERP)) { + const core = fragCore(frag); + if (!isSignificant(core)) continue; + if (!invNorm.includes(core)) { + fail( + `UNCLASSIFIED surface fragment in ${rel}: ${JSON.stringify(core)} (from ${JSON.stringify(key)})`, + ); + } + } + } + } + + // 2. Corpus-data field-class coverage + for (const rel of CORPUS_DATA_FILES) { + if (readMaybe(rel) === null) fail(`corpus data file missing: ${rel}`); + else if (!represented(rel)) fail(`corpus data file not represented: ${rel}`); + } + for (const id of REQUIRED_FIELD_IDS) { + // precise line match so e.g. core-gua-yao is not masked by core-gua-yaoEn + const re = new RegExp(`surface_id:\\s*${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m"); + if (!re.test(invRaw)) fail(`missing required field-class row: ${id}`); + } + + // 3. Consumer-side sentinels + for (const [str, file] of SENTINELS) { + const src = readMaybe(file); + if (src === null) { + fail(`sentinel source file missing: ${file} (for ${JSON.stringify(str)})`); + continue; + } + if (!src.includes(str)) + fail(`sentinel surface GONE from source ${file}: ${JSON.stringify(str)}`); + if (!invNorm.includes(norm(str))) + fail(`sentinel not in inventory: ${JSON.stringify(str)} (source ${file})`); + } + + // 4. AR-001 guard: no enrichment fields on core Hexagram. + const types = readMaybe("packages/core/src/types.ts"); + if (types) { + const start = types.indexOf("interface Hexagram"); + const hexBlock = start >= 0 ? types.slice(start, types.indexOf("}", start)) : ""; + for (const field of ["gc:", "gcEn:", "yaoXiao:", "legge:"]) { + if (hexBlock.includes(field)) + fail(`enrichment field "${field}" on Hexagram — AC-001 must REOPEN (AR-001)`); + } + } else { + fail("packages/core/src/types.ts not found (cannot verify enrichment absence)"); + } +} + +// --------------------------------------------------------------------------- +// AC-002: policy matrix +// --------------------------------------------------------------------------- +const REQUIRED_POLICY_FIELDS = [ + "language_policy", + "en_source", + "zh_hant_source", + "zh_hans_strategy", + "render_context", +]; +// A value is a real decision unless it is empty or an explicit deferral. +const DEFERRAL = /\bTBD\b|\bTODO\b|\bdecide (in|later)\b|decision in AC|AC-0\d\d (decision|decides)\b/i; + +function fieldValue(block: string, name: string): string { + const m = block.match(new RegExp(`(?:^|\\n)\\s*${name}:[ \\t]*(.*)`)); + return m ? m[1].trim() : ""; +} + +function runPolicy(): void { + const invRaw = readMaybe(INVENTORY_REL); + if (invRaw === null) { + fail(`inventory not found: ${resolve(ROOT, INVENTORY_REL)}`); + return; + } + const ids = [...invRaw.matchAll(/^- surface_id:\s*(\S+)\s*$/gm)].map((m) => m[1]!); + const mi = invRaw.indexOf("## Policy Matrix"); + if (mi < 0) { + fail("no '## Policy Matrix (AC-002)' section in inventory"); + return; + } + const matrix = invRaw.slice(mi); + const entries = new Map(); + for (const raw of matrix.split(/\n(?=- id:)/)) { + const m = raw.match(/^- id:\s*(\S+)/m); + if (m) entries.set(m[1]!, raw); + } + for (const id of ids) { + const block = entries.get(id); + if (!block) { + fail(`policy matrix missing entry: ${id}`); + continue; + } + for (const f of REQUIRED_POLICY_FIELDS) { + const v = fieldValue(block, f); + if (!v) fail(`policy field empty: ${id}.${f}`); + else if (DEFERRAL.test(v)) + fail(`policy field deferred (not a real decision): ${id}.${f} = ${JSON.stringify(v)}`); + } + } + // Source-side default + order (independent of the test suite). + const cfg = readMaybe("packages/storage/src/json/json-config.ts") ?? ""; + if (!/DEFAULT_CONFIG[\s\S]{0,400}language:\s*"en"/.test(cfg)) + fail('DEFAULT_CONFIG.language default is not "en"'); + const settings = + readMaybe("packages/terminal/src/scenes/settings/settings-scene.ts") ?? ""; + if (!/LANGUAGE_OPTIONS[^=]*=\s*\[\s*"en"\s*,\s*"zh-Hant"\s*,\s*"zh-Hans"\s*\]/.test(settings)) + fail("settings LANGUAGE_OPTIONS order is not [en, zh-Hant, zh-Hans]"); + // "English default/order asserted by tests" (pass_evidence). + const stest = + readMaybe("packages/terminal/src/__tests__/settings-scene.test.ts") ?? ""; + if (!stest.includes("[EN] 繁 简")) + fail("settings-scene.test.ts does not assert the EN->繁->简 order"); + const ctest = + readMaybe("packages/storage/src/__tests__/config-store.test.ts") ?? ""; + if (!/language:\s*"en"/.test(ctest)) + fail("config-store.test.ts does not assert default language en"); +} + +// --------------------------------------------------------------------------- +// AC-010: glossary / source-of-truth policy +// --------------------------------------------------------------------------- +const GLOSSARY_REL = optValue("--glossary-file") ?? "docs/language-glossary.md"; +const REQUIRED_GLOSSARY_TERMS = [ + // high-risk judgment terminology + "君子", "小人", "大人", "貞", "亨", "利", "咎", "悔", "厲", "吝", "吉", "凶", + "元吉", "無咎", "无咎", "利涉大川", "征", "往", "有孚", "時", + // trigrams + "乾", "坤", "震", "巽", "坎", "離", "艮", "兌", + // derived labels + "互卦", "錯卦", "綜卦", "之卦", "對角卦", + // Ten Wings + "大象傳", "彖傳", "說卦", "序卦", "雜卦", + // line designations + "初九", "六二", "九三", "上九", "初六", "上六", +]; +// English policy markers + canonical exceptions (case-insensitive contains). +const REQUIRED_GLOSSARY_MARKERS = [ + "干", "用九", "用六", "Wilhelm", "inspired", "NFC", "preserve", + "machine token", "Avoid", "superior man", "gentleman", "Pǐ", +]; + +function runGlossary(): void { + const g = readMaybe(GLOSSARY_REL); + if (g === null) { + fail(`glossary artifact not found (tracked): ${GLOSSARY_REL}`); + return; + } + const gl = g.toLowerCase(); + for (const t of REQUIRED_GLOSSARY_TERMS) + if (!g.includes(t)) fail(`glossary missing required term: ${t}`); + for (const m of REQUIRED_GLOSSARY_MARKERS) + if (!gl.includes(m.toLowerCase())) + fail(`glossary missing required policy marker: ${JSON.stringify(m)}`); + if (!(/乾 stays 乾/.test(g) || /乾[^\n]*(never|not)[^\n]*干/i.test(g))) + fail("glossary lacks the 乾≠干 canonical exception"); + if (!/(not)[^\n]*(direct )?quotation/i.test(g)) + fail("glossary lacks the Wilhelm 'not direct quotation' attribution policy"); +} + +// --------------------------------------------------------------------------- +// AC-006: simplified conversion (no naive partial map) +// --------------------------------------------------------------------------- +// Confirmed Traditional-ONLY characters (Simplified form differs). If any of +// these survive the conversion of the rendered corpus, the conversion is +// incomplete (residue). 乾 is deliberately ABSENT — it must stay 乾, not become 干. +const TRAD_ONLY = new Set( + // 陽 added per C-004 adversarial audit (was missing from the map; 陰 was present). + Array.from( + "傳與無學萬龍風澤離錯綜對歸師謙來時開關東車馬鳥魚為義樂處觀見興養業從國圖後復陽陰險隨雜難雲電順飛餘體麗龜貞賁蠱節記過進遠違適鎖嚴喪應損敗斷會極樹殘沒災牽獲當發盜終結維縣羅聽虛號衆裏訟貫趨跡輔辭驚", + ), +); +// Spot-check mappings the conversion MUST get right. +const MUST_CONVERT: Record = { + 傳: "传", 與: "与", 無: "无", 萬: "万", 龍: "龙", 風: "风", 澤: "泽", 離: "离", + 錯: "错", 綜: "综", 對: "对", 歸: "归", 師: "师", 謙: "谦", 後: "后", 雲: "云", + 麗: "丽", 餘: "余", 辭: "辞", 觀: "观", + 幹: "干", // the DISTINCT char that DOES become 干 (vs 乾 which stays 乾) — C-004 +}; + +async function runSimplified(): Promise { + let mod: { + toSimplified?: (s: string) => string; + SIMPLIFIED_MAP?: Record; + }; + try { + mod = (await import(resolve(ROOT, "packages/core/src/i18n/simplify.ts"))) as { + toSimplified?: (s: string) => string; + SIMPLIFIED_MAP?: Record; + }; + } catch { + fail( + "conversion module packages/core/src/i18n/simplify.ts missing — the naive 96-char detail-renderer map is not yet replaced by a proven path", + ); + return; + } + const toS = mod.toSimplified; + if (typeof toS !== "function") { + fail("simplify.ts must export toSimplified(text: string): string"); + return; + } + // Map-integrity invariant: the conversion must be character-IDENTITY-preserving + // and single-pass-safe. toSimplified relies on each entry being a 1:1 + // single-codepoint mapping (so character count is preserved) and on no value + // also being a key (so the transform is idempotent — converting already-Simplified + // text is a no-op). Both hold today by hand-authoring; this locks them so a future + // table edit can't silently break length-preservation or introduce double-conversion. + const SIMPLIFIED_MAP = mod.SIMPLIFIED_MAP; + if (!SIMPLIFIED_MAP || typeof SIMPLIFIED_MAP !== "object") { + fail("simplify.ts must export SIMPLIFIED_MAP (the audited Traditional->Simplified table)"); + } else { + const mapKeys = new Set(Object.keys(SIMPLIFIED_MAP)); + for (const [k, v] of Object.entries(SIMPLIFIED_MAP)) { + if ([...k].length !== 1 || [...v].length !== 1) + fail(`SIMPLIFIED_MAP entry not single-codepoint 1:1: "${k}" -> "${v}" (breaks length-preservation)`); + if (v !== k && mapKeys.has(v)) + fail(`SIMPLIFIED_MAP value is also a key: "${k}" -> "${v}" (double-conversion / non-idempotent)`); + } + } + // Canonical exception: 乾 stays 乾 (NOT 干). + if (toS("乾") !== "乾") fail(`canonical exception violated: 乾 -> ${toS("乾")} (must stay 乾)`); + if (toS("乾坤") !== "乾坤") fail(`乾 must not convert inside 乾坤 -> ${toS("乾坤")}`); + // Known-correct conversions. + for (const [t, s] of Object.entries(MUST_CONVERT)) + if (toS(t) !== s) fail(`wrong conversion: ${t} -> ${toS(t)} (expected ${s})`); + // Residue scan over the ACTUAL rendered corpus (consumer-side oracle). + let gmod: { GUA?: Array> }; + let tmod: { TRIGRAMS?: Array> }; + try { + gmod = (await import(resolve(ROOT, "packages/core/src/data/gua.ts"))) as { + GUA: Array>; + }; + tmod = (await import(resolve(ROOT, "packages/core/src/data/trigrams.ts"))) as { + TRIGRAMS: Array>; + }; + } catch { + fail("cannot load corpus for residue scan"); + return; + } + const strings: string[] = []; + for (const g of gmod.GUA ?? []) { + strings.push(String(g.n), String(g.dx), String(g.tu)); + for (const y of (g.yao as string[]) ?? []) strings.push(y); + } + for (const t of tmod.TRIGRAMS ?? []) strings.push(String(t.n)); + const residue = new Set(); + for (const s of strings) for (const ch of toS(s)) if (TRAD_ONLY.has(ch)) residue.add(ch); + if (residue.size > 0) + fail(`Traditional residue after conversion (${residue.size} chars): ${[...residue].join("")}`); + // Exception documented in the tracked glossary. + const gloss = readMaybe(GLOSSARY_REL) ?? ""; + if (!/乾 stays 乾/.test(gloss)) fail("乾 exception not documented in glossary"); + // Consumer-side: the detail renderer must USE the audited core converter, not + // a re-introduced naive local map. + const dr = readMaybe("packages/terminal/src/scenes/dict/detail-renderer.ts") ?? ""; + if (!/toSimplified/.test(dr)) + fail("detail-renderer does not use core toSimplified (consumer-side gap)"); + if (/const SIMPLIFIED_CHARS\b/.test(dr)) + fail("detail-renderer still defines a local naive SIMPLIFIED_CHARS map"); +} + +// --------------------------------------------------------------------------- +// AC-004: terminal scenes honor language (no bilingual stacking) +// --------------------------------------------------------------------------- +// Scene/renderer files that carry translatable product-ui text and must consume +// the message catalog (or otherwise honor DisplayLanguage). detail-renderer/ +// detail-model already honor language and are excluded. +const SCENE_CONSUMERS = [ + "packages/terminal/src/scenes/home/home-scene.ts", + "packages/terminal/src/scenes/intention/intention-scene.ts", + "packages/terminal/src/scenes/toss/toss-scene.ts", + "packages/terminal/src/scenes/cast/cast-scene.ts", + "packages/terminal/src/scenes/cast/reveal-renderer.ts", + "packages/terminal/src/scenes/cast/ritual-chrome.ts", + "packages/terminal/src/scenes/yarrow/yarrow-scene.ts", + "packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts", + "packages/terminal/src/scenes/yarrow/yarrow-timeline.ts", + "packages/terminal/src/scenes/yarrow/field-renderer.ts", + "packages/terminal/src/scenes/dict/browse-renderer.ts", + "packages/terminal/src/scenes/journal/journal-scene.ts", + "packages/terminal/src/scenes/settings/settings-scene.ts", +]; + +async function runTerminal(): Promise { + // 1. Catalog completeness. + let cat: { MESSAGES?: Record; tr?: (l: string, k: string) => string }; + try { + cat = (await import(resolve(ROOT, "packages/terminal/src/i18n/messages.ts"))) as typeof cat; + } catch { + fail("message catalog packages/terminal/src/i18n/messages.ts missing"); + return; + } + const MESSAGES = cat.MESSAGES; + const tr = cat.tr; + if (!MESSAGES || typeof tr !== "function") { + fail("messages.ts must export MESSAGES + tr(language, key)"); + return; + } + const REQUIRED_KEYS = [ + "menu.cast", "menu.dictionary", "menu.journal", "menu.settings", "menu.quit", + "home.noCast", "verb.back", "journal.empty", "dict.title", + "settings.theme", "settings.language", "settings.castMode", + ]; + for (const k of REQUIRED_KEYS) + if (!(k in MESSAGES)) fail(`catalog missing required key: ${k}`); + for (const [k, m] of Object.entries(MESSAGES)) { + if (!m.en?.trim()) fail(`catalog ${k}.en empty`); + if (!m.zhHant?.trim()) fail(`catalog ${k}.zhHant empty`); + if (!m.zhHans?.trim()) fail(`catalog ${k}.zhHans empty`); + } + // tr() must distinguish languages. + if ("menu.settings" in MESSAGES) { + const en = tr("en", "menu.settings"); + 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); + if (src === null) { + fail(`scene file missing: ${rel}`); + continue; + } + const consumes = + /from "[^"]*i18n\/messages/.test(src) || /\btr?\(\s*(this\.)?language/.test(src); + if (!consumes) fail(`scene does not honor language via catalog: ${rel}`); + } + // 2b. NAME-RENDERING paths. The import-presence check above is structural; it + // cannot see that a renderer prints a hexagram NAME in the wrong language. Any + // renderer that emits gua.n must convert for zh-Hans (toSimplified) and must + // not stack English. These three were the AC-004 blind spots (reveal title, + // browse rows, journal rows) the structural check missed. + const NAME_RENDERERS = [ + "packages/terminal/src/scenes/cast/reveal-renderer.ts", + "packages/terminal/src/scenes/dict/browse-renderer.ts", + "packages/terminal/src/scenes/journal/journal-scene.ts", + ]; + for (const rel of NAME_RENDERERS) { + const src = readMaybe(rel) ?? ""; + if (!src) { + fail(`name renderer missing: ${rel}`); + continue; + } + if (!/toSimplified/.test(src)) + fail(`${rel} renders hexagram names but does not convert for zh-Hans (toSimplified)`); + } + // 2c. Cast reveal specifically: the structure connective must come from the + // catalog (cast.trigramConnective), never a hardcoded English " above ", and the + // title functions must accept a DisplayLanguage (P1-a regression guard). + const reveal = readMaybe("packages/terminal/src/scenes/cast/reveal-renderer.ts") ?? ""; + if (reveal) { + // Strip line/block comments so a comment mentioning the key can't satisfy + // a code-usage check (the mutation-test that fooled the first cut). + const revealCode = reveal + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); + // The connective must be an actual catalog CALL, not just a mention. + if (!/\btr?\(\s*language\s*,\s*"cast\.trigramConnective"\s*\)/.test(revealCode)) + fail('reveal-renderer does not call the catalog for the structure connective (tr(language, "cast.trigramConnective"))'); + // And no English connective literal may survive anywhere in the code — catches + // both ` above ` template forms and a bare "above"/`above` string literal. + if (/ above |["'`]above["'`]/.test(revealCode)) + fail('reveal-renderer hardcodes the English connective "above" (must use cast.trigramConnective)'); + if (!/language:\s*DisplayLanguage/.test(revealCode)) + fail("reveal-renderer title functions do not accept a DisplayLanguage param"); + } + // 2d. FOOTER LEAK GUARD. Scene footers are built as `[key] verb` segments. A + // hardcoded English nav verb after a keycap (not routed through tr()) is exactly + // the leak the structural consumer check at (2) cannot see — and detail-renderer + // is even EXCLUDED from SCENE_CONSUMERS as "already honors language", so its + // footer leaked undetected. Scan every footer-bearing renderer (incl. detail). + const FOOTER_RENDERERS = [ + ...SCENE_CONSUMERS, + "packages/terminal/src/scenes/dict/detail-renderer.ts", + ]; + const FOOTER_VERBS = + "navigate|view|dictionary|scroll|derived|select|open|back|explore|switch|" + + "detail|confirm|toss|reveal|discard|search|pause|resume|step|skip"; + // `]` keycap, a little whitespace, then an English nav verb as a bare literal + // (the `[^"'`]` window stops at a string boundary, so a `tr(.,"verb.x")` call — + // where the word sits inside quotes — never matches). + const footerLeak = new RegExp(`\\][^"'\`\\n]{0,4}\\b(?:${FOOTER_VERBS})\\b`); + for (const rel of FOOTER_RENDERERS) { + const src = readMaybe(rel); + if (src === null) continue; + for (const line of src.split("\n")) { + if (/^\s*(\/\/|\*|\/\*)/.test(line)) continue; // skip comments + if (/\btr\(/.test(line)) continue; // routed through the catalog — fine + if (footerLeak.test(line)) + fail(`footer leak (hardcoded English keycap verb, not via tr()) in ${rel}: ${line.trim().slice(0, 72)}`); + } + } + // 3. A no-bilingual-stacking behavioral test must exist, and it must exercise + // the name-rendering paths in 简 (not just the menu/chrome surfaces): cast + // reveal (兌→兑), journal (觀→观), and the journal/detail FOOTERS. + const sceneTest = readMaybe("packages/terminal/src/__tests__/scene-language.test.ts"); + if (sceneTest === null) { + fail("missing scene-language.test.ts (no-bilingual-stacking assertions)"); + } else { + if (!/zh-Hans|zh-Hant/.test(sceneTest)) + fail("scene-language.test.ts does not assert per-language rendering"); + if (!/CastScene/.test(sceneTest)) + fail("scene-language.test.ts does not exercise CastScene (cast reveal name path)"); + if (!/兑/.test(sceneTest) || !/兌/.test(sceneTest)) + fail("scene-language.test.ts does not assert 兌→兑 conversion (KW58 reveal/browse)"); + if (!/观/.test(sceneTest)) + fail("scene-language.test.ts does not assert 觀→观 conversion (KW20 journal)"); + // Footer coverage in Chinese modes (the leaks the structural check missed): + // journal nav verbs (导览/检视) + detail nav verbs (卷动/衍卦). + if (!/导览|檢視|检视/.test(sceneTest)) + fail("scene-language.test.ts does not assert the journal footer localizes (导览/检视)"); + if (!/卷动|衍卦|捲動/.test(sceneTest)) + fail("scene-language.test.ts does not assert the detail footer localizes (卷动/衍卦)"); + } +} + +// --------------------------------------------------------------------------- +// AC-005: CLI localized or intentionally developer-only (with rationale) +// --------------------------------------------------------------------------- +const CLI_EXEMPT_SURFACES = [ + "cli-program-meta", + "cli-command-descriptions", + "cli-config-key-descriptions", + "cli-config-output", + "cli-range-errors", + "cli-journal-errors-empty", + "cli-doctor-output", + "cli-paths-output", + "cli-commander-framework", + "cli-plain-labels", + "cli-hook-output", +]; + +function runCli(): void { + // (a) JSON output must stay locale-neutral / API-stable. + const json = readMaybe("apps/cli/src/output/json.ts") ?? ""; + if (!json) { + fail("apps/cli/src/output/json.ts missing"); + } else { + for (const s of ["dx:", "tu:", "en:", "te:", "w:"]) + if (!json.includes(s)) fail(`JSON commentary missing style "${s}" (must emit all 5)`); + if (/config\.language|JsonConfigStore|toSimplified|DisplayLanguage/.test(json)) + fail("JSON output is not locale-neutral (it references a language mechanism)"); + } + // (b) Invalid-path messages exist (the every-exit(1) surface). + const cfg = readMaybe("apps/cli/src/commands/config.ts") ?? ""; + if (!cfg.includes("Unknown key")) fail("config Unknown-key error missing"); + if (!cfg.includes("Invalid value")) fail("config Invalid-value error missing"); + if (!(readMaybe("apps/cli/src/commands/dict.ts") ?? "").includes("integer from 1 to 64")) + fail("dict range error missing"); + if (!(readMaybe("apps/cli/src/commands/hexagram.ts") ?? "").includes("Invalid style")) + fail("hexagram invalid-style error missing"); + // (c) Config token stability: language values are the canonical en/zh-Hant/zh-Hans. + if (!/LANGUAGE_VALUES[\s\S]{0,80}"en"[\s\S]{0,40}"zh-Hant"[\s\S]{0,40}"zh-Hans"/.test(cfg)) + fail("config LANGUAGE_VALUES is not [en, zh-Hant, zh-Hans]"); + // (d) A test asserts the config language round-trip (token stability, consumer-side). + const ctest = readMaybe("apps/cli/src/__tests__/config-command.test.ts") ?? ""; + if (!/set", "language"|set language/.test(ctest) && !ctest.includes('"language"')) + fail("config-command.test.ts does not exercise the language key"); + // (e) The developer-only exemption must be DOCUMENTED with rationale (AC-005 clause). + const inv = readMaybe(INVENTORY_REL) ?? ""; + const marker = "## AC-005 CLI disposition"; + const i = inv.indexOf(marker); + if (i < 0) { + fail(`${marker} registry missing from TEXT_SURFACES (AC-005 exemption must be documented)`); + } else { + const sec = inv.slice(i); + for (const s of CLI_EXEMPT_SURFACES) + if (!sec.includes(s)) fail(`CLI disposition does not classify surface: ${s}`); + if (!/developer-only|exempt/i.test(sec)) fail("CLI disposition lacks the exemption rationale"); + } + // (f) The localized launch path (the `dict` command opens the browse scene) MUST + // thread the configured language into the router, or the browse scene silently + // renders in the default language regardless of config (the P1-b defect). + const dictCmd = readMaybe("apps/cli/src/commands/dict.ts") ?? ""; + if (!/config\.language/.test(dictCmd)) + fail("dict command does not load config.language"); + // Match to the statement terminator (`;`), not the first `)` — the call has + // nested parens (new RealClock(), detectColorSupport()). + else if (/router\.run\(/.test(dictCmd) && !/router\.run\([^;]*config\.language/.test(dictCmd)) + fail("dict command does not thread config.language into router.run (browse ignores language)"); +} + +// --------------------------------------------------------------------------- +// AC-003: core corpus audit (Unicode index/order, pinyin, line-identity) +// --------------------------------------------------------------------------- +async function runCoreData(): Promise { + let gmod: { GUA?: Array<{ u: string; p: string; l: number[]; yao: string[]; n: string }> }; + let tmod: { TRIGRAMS?: Array<{ sym: string; n: string }> }; + try { + gmod = (await import(resolve(ROOT, "packages/core/src/data/gua.ts"))) as typeof gmod; + tmod = (await import(resolve(ROOT, "packages/core/src/data/trigrams.ts"))) as typeof tmod; + } catch { + fail("cannot load core corpus data"); + return; + } + const GUA = gmod.GUA ?? []; + const TRIGRAMS = tmod.TRIGRAMS ?? []; + if (GUA.length !== 64) fail(`GUA must have 64 entries, has ${GUA.length}`); + + // Unicode hexagram symbols: King-Wen order, U+4DC0 (䷀) .. U+4DFF (䷿). + for (let i = 0; i < GUA.length; i++) { + const want = String.fromCodePoint(0x4dc0 + i); + if (GUA[i]!.u !== want) + fail(`gua[${i}] (KW${i + 1}) symbol ${JSON.stringify(GUA[i]!.u)} != King-Wen codepoint ${want}`); + } + // Trigram glyphs: binary index 000..111 -> U+2637..U+2630 set. + const TRIG_SYMS = ["☷", "☳", "☵", "☱", "☶", "☲", "☴", "☰"]; + for (let i = 0; i < 8; i++) + if (TRIGRAMS[i]?.sym !== TRIG_SYMS[i]) + fail(`trigram[${i}] sym ${JSON.stringify(TRIGRAMS[i]?.sym)} != ${TRIG_SYMS[i]}`); + + // Pinyin: NFC-normalized + locked polyphony for ambiguous readings. + for (const g of GUA) if (g.p !== g.p.normalize("NFC")) fail(`pinyin not NFC-normalized: ${JSON.stringify(g.p)}`); + const POLY: Record = { 12: "Pǐ", 22: "Bì", 20: "Guān", 39: "Jiǎn", 40: "Xiè" }; + for (const [kw, p] of Object.entries(POLY)) { + const g = GUA[Number(kw) - 1]; + if (g && g.p !== p) fail(`pinyin polyphony KW${kw}: ${JSON.stringify(g.p)} != ${JSON.stringify(p)}`); + } + + // Line-identity: every yao[i] must begin with its position+nature token + // (初九/六二/九三/… encoding both line position AND yin-yang from gua.l[i]). + const MID = ["", "二", "三", "四", "五", ""]; + for (let k = 0; k < GUA.length; k++) { + const g = GUA[k]!; + if (!Array.isArray(g.yao) || g.yao.length !== 6) { + fail(`gua KW${k + 1} yao is not 6 entries (用九/用六 are a documented exclusion, not extra entries)`); + continue; + } + for (let i = 0; i < 6; i++) { + const nature = g.l[i] === 1 ? "九" : "六"; + const tok = i === 0 ? `初${nature}` : i === 5 ? `上${nature}` : `${nature}${MID[i]}`; + if (!g.yao[i]!.startsWith(tok)) + fail(`line-identity KW${k + 1} line${i + 1}: ${JSON.stringify(g.yao[i]!.slice(0, 4))} lacks token ${tok}`); + } + } + + // 用九/用六 documented exclusion in the glossary. + const gloss = readMaybe("docs/language-glossary.md") ?? ""; + if (!gloss.includes("用九") || !gloss.includes("用六")) + fail("glossary lacks the 用九/用六 special-statement exclusion note"); + + // Corpus field-class rows present in the inventory (source-layer/policy coverage). + const inv = readMaybe(INVENTORY_REL) ?? ""; + for (const id of ["core-gua-name", "core-gua-pinyin", "core-gua-yao", "core-gua-yaoEn", "core-trigram-name"]) + if (!inv.includes(id)) fail(`inventory missing corpus field-class row: ${id}`); + + // 君子 harmonization (C-004): the English corpus must render 君子 consistently as + // "the noble one", never "superior man" ("great man" stays — that is 大人). + const guaSrc = readMaybe("packages/core/src/data/gua.ts") ?? ""; + if (/superior man/.test(guaSrc)) + fail('君子 inconsistency: "superior man" still in corpus EN — harmonize to "the noble one" (C-004)'); +} + +// --------------------------------------------------------------------------- +// AC-007: high-risk groups have reconciled meaning + adversarial consults +// --------------------------------------------------------------------------- +function runConsults(): void { + const c = readMaybe("tests/fixtures/language/CONSULTS.md") ?? ""; + if (!c) { + fail("CONSULTS.md missing"); + return; + } + // Required consults: a MEANING consult per high-risk group + a comprehensive + // ADVERSARIAL audit. Each must be status:complete and reconciled. + const required: Array<{ id: string; label: string }> = [ + { id: "C-002", label: "simplified meaning" }, + { id: "C-003", label: "yarrow meaning" }, + { id: "C-005", label: "terminology/Wilhelm meaning" }, + { id: "C-004", label: "high-risk adversarial audit" }, + ]; + const blockFor = (id: string): string | null => { + const m = c.match(new RegExp(`consult_id:\\s*${id}\\b[\\s\\S]*?(?=\\n- consult_id:|\\n\\u0060\\u0060\\u0060|$)`)); + return m ? m[0] : null; + }; + for (const r of required) { + const b = blockFor(r.id); + if (!b) { + fail(`AC-007: consult ${r.id} (${r.label}) missing from ledger`); + continue; + } + if (!/status:\s*complete/.test(b)) fail(`AC-007: consult ${r.id} (${r.label}) not status:complete`); + if (!/reconcil/i.test(b)) fail(`AC-007: consult ${r.id} (${r.label}) has no reconciliation`); + } + // The adversarial audit (C-004) must cover each high-risk group. + const adv = blockFor("C-004") ?? ""; + for (const g of ["simplified", "yarrow", "terminolog"]) { + if (!new RegExp(g, "i").test(adv)) fail(`AC-007: adversarial consult C-004 does not cover group "${g}"`); + } +} + +// --------------------------------------------------------------------------- +// AC-008: verifier self-test (meta-oracle — proves the verifier reds on mutations) +// --------------------------------------------------------------------------- +/** Run this verifier as a subprocess with `args`; return true if it exited NON-ZERO (red). */ +function verifierRedsOn(args: string[]): boolean { + try { + execFileSync("bun", [resolve(ROOT, "scripts/verify-language-surfaces.ts"), ...args], { + stdio: "ignore", + }); + return false; // exit 0 = green = mutation NOT caught + } catch { + return true; // non-zero = red = mutation caught + } +} + +async function runSelfTest(): Promise { + const dir = mkdtempSync(join(tmpdir(), "vlang-selftest-")); + const inv = readMaybe(INVENTORY_REL) ?? ""; + const gloss = readMaybe(GLOSSARY_REL) ?? ""; + if (!inv || !gloss) { + fail("self-test: cannot read inventory/glossary baseline"); + return; + } + // Craft mutated temp inputs (no real source is touched). + const empty = join(dir, "empty.md"); + writeFileSync(empty, ""); + const noPolicy = join(dir, "nopolicy.md"); + writeFileSync(noPolicy, inv.slice(0, inv.indexOf("## Policy Matrix"))); + const noSentinel = join(dir, "nosentinel.md"); + writeFileSync(noSentinel, inv.split("問").join("")); // drop the 問 sentinel + const noQian = join(dir, "noqian.md"); + writeFileSync( + noQian, + gloss + .split("\n") + .filter((l) => !/乾 stays 乾/.test(l) && !/乾.*(never|not).*干/i.test(l)) + .join("\n"), + ); + + // 1. Subprocess red-proofs: each mutation MUST make the verifier exit non-zero. + const scenarios: Array<{ cls: string; args: string[] }> = [ + { cls: "missing inventory", args: ["--inventory-only", "--inventory", empty] }, + { cls: "dropped sentinel surface (問)", args: ["--inventory-only", "--inventory", noSentinel] }, + { cls: "missing policy matrix", args: ["--policy", "--inventory", noPolicy] }, + { cls: "canonical exception drift (乾)", args: ["--glossary", "--glossary-file", noQian] }, + ]; + for (const s of scenarios) + if (!verifierRedsOn(s.args)) + fail(`self-test: verifier is FALSE-GREEN on mutation [${s.cls}] — oracle theater`); + + // 2. In-process predicate discrimination (string-sink classifier). + if (!isCandidate("No history")) fail("self-test: string-sink misses a real user-facing surface"); + if (isCandidate("menu.cast")) fail("self-test: string-sink flags a catalog KEY (catalog-as-home regression)"); + if (isCandidate("startCast")) fail("self-test: string-sink flags an internal enum token"); + + // 3. JSON locale-neutral predicate (must flag a language mechanism in JSON output). + const jsonGate = /config\.language|JsonConfigStore|toSimplified|DisplayLanguage/; + if (!jsonGate.test('import type { DisplayLanguage } from "@iching/core";')) + fail("self-test: JSON-locale-neutral predicate would miss a language reference"); + + // 4. Line-identity token builder (mirrors --core-data) discriminates position+nature. + const tok = (i: number, yang: boolean): string => { + const nature = yang ? "九" : "六"; + const MID = ["", "二", "三", "四", "五", ""]; + return i === 0 ? `初${nature}` : i === 5 ? `上${nature}` : `${nature}${MID[i]}`; + }; + if (tok(0, true) !== "初九" || tok(1, false) !== "六二" || tok(5, true) !== "上九") + fail("self-test: line-identity token builder is wrong"); + + // 5. Conversion teeth + enforced 乾 exception (core toSimplified). + let toS: (s: string) => string; + try { + const m = (await import(resolve(ROOT, "packages/core/src/i18n/simplify.ts"))) as { + toSimplified: (s: string) => string; + }; + toS = m.toSimplified; + } catch { + fail("self-test: cannot load simplify.ts"); + return; + } + if (toS("乾") !== "乾") fail("self-test: 乾 canonical exception not holding"); + if (toS("乾坤") !== "乾坤") fail("self-test: 乾 exception not enforced inside a string"); + if (toS("傳") !== "传") fail("self-test: conversion has no teeth (傳 not converted)"); + // unmapped Traditional char passes through unchanged — this is WHY the residue scan can + // catch an incomplete map (proves the scan is meaningful, not a no-op). + if (toS("颱") !== "颱") fail("self-test: pass-through invariant broken (residue scan would be a no-op)"); +} + +// --------------------------------------------------------------------------- +// Not-yet-implemented modes +// --------------------------------------------------------------------------- +const notImplemented = (mode: string, crit: string): void => { + fail(`${mode} (${crit}) not yet implemented — criterion is not passable yet`); +}; + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- +let mode = "run-all (AC-009 final)"; +async function dispatch(): Promise { + if (flag("--inventory-only")) { + mode = "--inventory-only (AC-001)"; + runInventoryOnly(); + } else if (flag("--policy")) { + mode = "--policy (AC-002)"; + runPolicy(); + } else if (flag("--core-data")) { + mode = "--core-data (AC-003)"; + await runCoreData(); + } else if (flag("--terminal")) { + mode = "--terminal (AC-004)"; + await runTerminal(); + } else if (flag("--cli")) { + mode = "--cli (AC-005)"; + runCli(); + } else if (flag("--simplified")) { + mode = "--simplified (AC-006)"; + await runSimplified(); + } else if (flag("--consults")) { + mode = "--consults (AC-007)"; + runConsults(); + } else if (flag("--self-test")) { + mode = "--self-test (AC-008)"; + await runSelfTest(); + } else if (flag("--glossary")) { + mode = "--glossary (AC-010)"; + runGlossary(); + } else { + // AC-009 final-verify: run EVERY criterion's check in one repo state. (Was + // scaffolded to fail until all criteria were genuinely built — now wired.) + mode = "run-all (AC-009 final)"; + runInventoryOnly(); + runPolicy(); + runGlossary(); + await runSimplified(); + await runTerminal(); + runCli(); + await runCoreData(); + runConsults(); + await runSelfTest(); + } +} + +// --------------------------------------------------------------------------- +// Result +// --------------------------------------------------------------------------- +void dispatch().then(() => { + console.log(`verify-language-surfaces :: ${mode}`); + if (failures.length === 0) { + console.log("PASS — 0 issues"); + process.exit(0); + } else { + console.error(`FAIL — ${failures.length} issue(s):`); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); + } +}); diff --git a/tests/fixtures/language/CONSULTS.md b/tests/fixtures/language/CONSULTS.md new file mode 100644 index 0000000..e9d36d6 --- /dev/null +++ b/tests/fixtures/language/CONSULTS.md @@ -0,0 +1,240 @@ +# Agentify Consult Ledger + +Status: seeded. The first live consult was fired by the authoring pass: + +```yaml +- consult_id: C-000 + surface_group: loop-design + purpose: audit + key: iching-language-loop-review + prompt_summary: > + Review planned autonomous translation loop; identify missed surfaces, + I Ching-specific translation risks, acceptance criteria, verifier ideas, + and false-confidence traps. + context_paths: + - packages/core/src/types.ts + - packages/core/src/data/gua.ts + - packages/core/src/data/trigrams.ts + - packages/terminal/src/scenes/dict/detail-renderer.ts + - apps/cli/src/commands/config.ts + status: complete + finished_at: 2026-06-02T07:05:21Z + duration_ms: 444843 + conversation_url: https://chatgpt.com/g/g-p-69c9d0b3c3b88191872d6b59cb5adfb8-agentify/c/6a1e7f1d-4050-83e8-8c1c-e83ffa31f765 + findings: > + Make AC-001 a hard gate; include Commander help/descriptions/arguments, + invalid-path errors, TUI footers/keybinding verbs, synthetic labels, + plural/date strings, JSON display policy, config token stability, data + constants, Unicode symbol/index checks, pinyin normalization, source-layer + separation, glossary-driven terminology, line-identity preservation, and + context-aware Simplified conversion with exceptions such as 乾 not becoming + 干. + reconciled_into: + - .loop/language/PROMPT.md initial audit findings, bootstrap inventory, + language policy, high-risk terminology, dependency topology + - .loop/language/ACCEPTANCE.md AC-003, AC-004, AC-005, AC-006, AC-008, + AC-009, AC-010 + - .loop/language/TEXT_SURFACES.md row contract and candidate surface groups +``` + +```yaml +- consult_id: C-001 + surface_group: bootstrap-inventory (all groups) + purpose: audit + key: iching-language-missed-surface-audit + prompt_summary: > + Missed-surface audit (required cadence step 1). Given the completed + TEXT_SURFACES.md inventory + the language-aware/leakage source files + (detail-renderer.ts, settings-scene.ts, format/derived.ts, display-select.ts, + config.ts), identify: (a) any user-facing text surface CLASS missing from the + inventory, (b) misclassifications (machine-token marked translate, or + user-facing marked not-user-facing/exempted), (c) I Ching-specific risks not + captured, (d) anything the AC-001 string-sink verifier would systematically + miss. + context_paths: + - .loop/language/TEXT_SURFACES.md + - packages/terminal/src/scenes/dict/detail-renderer.ts + - packages/terminal/src/scenes/settings/settings-scene.ts + - packages/core/src/format/derived.ts + - packages/core/src/service/display-select.ts + - apps/cli/src/commands/config.ts + modeIntent: extended-pro + status: complete + finished_at: 2026-06-02T08:00Z + runs: 2 # MCP transport returned "fetch failed" twice but the desktop completed BOTH sends; + # two provider runs under one key (counts as 2 toward the 48/invocation cap). Findings agree. + conversation_url: https://chatgpt.com/g/g-p-69c9d0b3c3b88191872d6b59cb5adfb8-agentify/c/6a1e8bb9-f1a0-83e8-a774-4e60cb7296ba + findings: > + GPT-5 Pro (extended-pro) returned 15 missed-surface, 12 misclassification, 15 I-Ching-risk, + and 15 verifier-blind-spot items. Key actionable items below; full text in conversation. + reconciliation: + accepted_into_ac001: + - "MISSED 1.2/1.3: Commander FRAMEWORK-generated text (Usage:/Options:/Commands:/unknown-command/ + missing-argument) is user-facing but lives in node_modules, invisible to repo literal extraction. + -> ADDED inventory row cli-commander-framework (policy: dependency-English; localize-vs-exempt is AC-005)." + - "MISSED 1.9/1.15: locale-sensitive number/date/plural formatting is a surface CLASS not yet rowed. + -> ADDED inventory row format-locale-numerics (locale-format policy decided in AC-002)." + - "MISCLASS 2.1/2.2/2.3: core-gua-u, core-trigram-sym, core-large-glyphs were language_policy: + not-user-facing — wrong ('not translated' != 'not user-facing'). -> RECLASSIFIED to canonical-anchor + (user-facing, preserve, not a translation target)." + - "Strengthened AC-001 verifier sentinels: added 自返 and 對角卦 (derived special-case templates)." + rejected_or_noted_already_covered: + - "MISSED 1.14/1.20 (home/cast/toss/yarrow/browse/journal scene footers/empty states): the model only + saw detail/settings/derived/config; the FULL TEXT_SURFACES.md already rows all these scenes. No gap." + - "MISSED 1.1/1.5/1.6/1.8 (config help, settings labels, detail footer): already inventoried + (cli-command-descriptions, term-settings-*, term-dict-detail-footer)." + routed_to_later_criteria: + - "AC-002 (policy): segment composite rows (core-reading-headline/becoming-suffix, core-derived-labels-en) + into preserve|canonical|localize parts; split token-vs-display-label for enum chips (theme/EN-繁-简/ + coin/yarrow/kaiti...); JSON per-field policy (corpus dx/tu/yao = locale-neutral in JSON, NOT + localized-display)." + - "AC-010 (glossary): 君子/小人/大人/貞/亨/利/咎/厲/悔/吝/征/孚 term decisions; wing-title precision + (Image=大象傳 vs 象傳; Judgment=彖傳 vs 卦辭); 衍卦 = product term not received-text; derived-relation + terms (互/錯/綜/之/對角/自綜/自返); ritual terms 蓍草/筮/銅錢/手動-自動; Wilhelm label = inspired/advice, + must not imply quotation." + - "AC-003 (core-data): corpus omits 用九/用六 (Qian/Kun special line statements) — document exclusion or + model them; preserve line-identity (初九…上九) in English headers; lock audited pinyin polyphony + (否 Pǐ, 賁 Bì, 蹇 Jiǎn, 解 Xiè) — never regenerate pinyin from simplified." + - "AC-006 (simplified): per-char map is unsafe for classical text — needs context-aware/table-driven + conversion + audited exceptions: 後≠后, 於≠于, 雲/云 (cloud vs 'says'), 麗/離, 餘/余, 里/裏, 係/系, + 發/髮, 征/徵, and the canonical 乾≠干; hexagram-name conversion should be a per-name table." + - "AC-008 (verifier self-test): add runtime/snapshot sentinels for Commander --help & error paths, + seeded-RandomSource tests for every selectDisplay threshold + formatDerived branch, special-cast + fixtures (self-mirror/locked-pair/no-change/diagonal), terminal-size×focus snapshot matrix, + stringWidth visual-width tests, SEMANTIC simplified fixtures (not just residue scan), JSON + per-field policy assertions, config alias round-trip, and a 'no-display'(null) behavioral test." +``` + +```yaml +- consult_id: C-002 + surface_group: simplified-conversion (HIGH-RISK group; AC-006 + AC-007 meaning/research) + purpose: meaning + key: iching-language-simplified-table + prompt_summary: > + Produce an audited Traditional->Simplified character map for the exact 929 unique Han + characters of the rendered I Ching corpus (hexagram names + 大象傳/彖傳/爻辭). Return ONLY + chars whose Simplified form DIFFERS, as a compact JSON object. EXCLUDE 乾 (must stay 乾 here, + NOT 干). Separately flag any context-dependent char (後/后, 雲/云, 麗/離, 餘/余, 係/系, 著, 幾, + etc.) where a blind char-swap could corrupt classical meaning. + context: inline 929-char list (/tmp/corpus-han.txt) + modeIntent: thinking + status: complete + finished_at: 2026-06-02T09:26Z (thinking, 3m12s) + conversation_url: https://chatgpt.com/g/g-p-69c9d0b3c3b88191872d6b59cb5adfb8-agentify/c/6a1ea0e9-a968-83e8-835d-017fe7471e58 + findings: > + Returned a ~250-entry T->S JSON map for the corpus + a context-sensitive flags section. + Correctly EXCLUDED 乾 (kept 乾); mapped the DISTINCT 幹->干; resolved classical false-friends + one-directionally: 後->后, 雲->云, 麗->丽 vs 離->离, 係/繫->系, 於->于, 穀->谷, 幾->几; and + explicitly kept 藉 (易经「藉用白茅」). Flagged a few Ext-B/C simplified codepoints (㧑/𦈡/𬙊/𫗧) + for obscure line-text chars. + reconciliation: + accepted: > + Built packages/core/src/i18n/simplify.ts (SIMPLIFIED_MAP + toSimplified + SIMPLIFIED_EXCEPTIONS), + merging C-002's corpus map with the previously-vetted UI-label/variant chars (傳/辭/記/鎖/…) the + 929-char extraction didn't include. Exported from @iching/core. Rewired detail-renderer zh() to + delegate to core toSimplified and DELETED the naive local 96-char SIMPLIFIED_CHARS map. + verified: > + --simplified PASS (乾 stays 乾 / no 干, spot-checks 傳->传 … 餘->余, residue scan over corpus, + consumer-side: detail-renderer uses toSimplified + no local map). End-to-end: 坤 in zh-Hans + renders 万/无/龙/载/黄 with no Traditional residue; 乾卦 keeps 乾. typecheck PASS; detail tests 15/15. + serves_AC007: > + This is the high-risk Simplified group's MEANING/research consult. Its ADVERSARIAL audit (review + the converted corpus for residue/errors against the embedded table) remains for AC-007. +``` + +```yaml +- consult_id: C-003 + surface_group: yarrow-ritual (HIGH-RISK; AC-004 timeline captions + AC-007 meaning) + purpose: meaning + key: iching-language-yarrow-terms + prompt_summary: > + Translate the yarrow-stalk ritual UI terms to 繁體 + 简体 for an I Ching TUI, keeping classical + accuracy: line-values old/young yin-yang (老陰/少陽/少陰/老陽), and ritual nouns/verbs — stalks, + heaps, set aside / one aside, count by fours, carry, fuse (product-coined line-crystallization + beat), Round N, Remaining, few/many, "Cut at k". Asked for compact JSON {term: {zhHant, zhHans}} + + notes on any non-standard coinage. + context: inline (exact yarrow-timeline caption strings + field-renderer "stalks"/"set aside") + modeIntent: thinking + status: complete + finished_at: 2026-06-02T~10:10Z + conversation_url: agentify key iching-language-yarrow-terms + findings: > + JSON term map: line-values 老陰/少陽/少陰/老陽 (繁) / 老阴/少阳/少阴/老阳 (简) — confirmed standard; + stalks 策, heaps 二分, set aside 歸奇/归奇, one aside 掛一/挂一, count-by-fours 揲四, carry 承策, + Remaining 餘策/余策, Round 變/变, fuse 成爻 ("forms a line", chosen over 凝爻), few/many 少/多, + Cut-at-k 分於k/分于k. Notes flagged carry/heaps/few-many/fuse as UI coinages (not fixed classical + labels) — kept short, no "fake-ancient cosplay". + reconciliation: + accepted: > + Added yarrow.* keys to messages.ts (en = exact existing words, zhHant/zhHans from C-003). + Threaded `language` through buildYarrowTimeline -> buildYarrowFullLineBeats -> buildYarrowRoundBeats/ + buildYarrowFuseBeat opts -> buildRoundCaptions/buildFuseCaption/lineValueName; ctor-threaded into + YarrowScene + YarrowManualScene (from reading-flow deps.language); field-renderer renderYarrowField/ + renderChrome/strip localize stalks/set-aside + counter. + verified: > + End-to-end: en captions byte-identical ("Round 1 · 49 stalks", "Count each heap by fours."), + zh-Hant "變 1 · 49 策 / 分於k=… 二分 / 掛一 / 每堆揲四", zh-Hans "变…/分于k=…/挂一". Numbers/operators + (·,k=,|,÷,→,N/M) verbatim. --terminal GREEN; scene-language 12/12; full suite 501/501. + serves_AC007: yarrow group MEANING consult done; ADVERSARIAL audit still pending for AC-007. +``` + +```yaml +- consult_id: C-005 + surface_group: core-terminology + Wilhelm (HIGH-RISK; AC-007 meaning) + purpose: meaning + key: iching-language-terminology-meaning + prompt_summary: > + Validate the glossary's approved EN renderings for the high-risk judgment terms (君子/小人/大人/貞/ + 亨/利/咎/悔/厲/吝/吉/凶/元吉/無咎/利涉大川/征/往/有孚/時) + the Wilhelm-label attribution policy. + modeIntent: thinking + status: complete + finished_at: 2026-06-02T~10:50Z + findings: > + 君子(noble one)/大人/咎/厲/吉/凶/無咎/往/時 = ok. Preferred (academic) alternatives: 小人→petty person, + 貞→constancy, 亨→fulfillment, 利→beneficial, 悔→regret, 吝→shame, 元吉→great good fortune, + 利涉大川→cross the great river, 征→undertake an expedition, 有孚→there is trust. Wilhelm verdict: + bare "Wilhelm" implies quotation — MUST say "Wilhelm-inspired"/"after Wilhelm". + reconciliation: + accepted: > + Wilhelm honesty fix APPLIED — detail-renderer EN section header "Wilhelm" -> "Wilhelm-inspired" + (AC-010 attribution policy); inventory + glossary updated. + noted_kept_voice: > + The de-Wilhelmized term alternatives are RECORDED in docs/language-glossary.md as documented + options but NOT adopted as primary, because the app is deliberately Wilhelm-inspired and the + en/te/w/yaoEn corpus voice is Wilhelm-Baynes (consistent + intentional, not a fidelity defect; cf. + AR-005). Not a corpus rewrite. +- consult_id: C-004 + surface_group: ALL high-risk (simplified + yarrow + terminology + Wilhelm) (AC-007 adversarial) + purpose: adversarial + key: iching-language-adversarial-audit + prompt_summary: > + Adversarial audit of the IMPLEMENTED decisions: attack the embedded Simplified table (residue/wrong + conversions/乾 exception), the yarrow terms (C-003), the glossary terminology + Wilhelm label, and the + EN 君子 divergence (AR-005). Find fidelity defects. + context_paths: + - docs/language-glossary.md + - packages/core/src/i18n/simplify.ts + - packages/terminal/src/i18n/messages.ts + modeIntent: extended-pro + status: complete + finished_at: 2026-06-02T~11:00Z (extended-pro, 9m) + conversation_url: agentify key iching-language-adversarial-audit + findings: > + Verdict "not fit to ship until fixed": (blocker→latent) missing 陽→阳 (corpus has none, but 陰/陽 + asymmetry); 乾 exception not ENFORCED (only works by absence); 承策 invented ritualese; 歸奇 is the + action not the bundle; 貞/征 too gentle as received-text approved; Wilhelm label needs to not imply + quotation; 君子 inconsistency (noble one vs superior man) is a real defect; rare Ext glyph tofu risk; + zh-Hans halfway between literary-register and Mainland-idiom. + reconciliation: > + Full triage in docs/language-glossary.md "## C-004 adversarial-audit reconciliation". + ACCEPTED+FIXED: 陽→阳 added; 乾 exception enforced in toSimplified() (EXCEPTION_SET before map) + + 幹→干 spot-check; 承策→續/续; 歸奇→奇策; glossary 貞→constancy/征→to campaign/有孚→there is trust; + 君子 harmonized in corpus (superior man→the noble one, 19 strings) + --core-data guard. + DEFERRED: Wilhelm close-paraphrase phrase audit; rare-glyph font policy (-> AC-008). REJECTED: + zh-Hans Mainland-idiom (literary register is deliberate); 咷→啕 (standard PRC, minor). + Covers all high-risk groups (simplified + yarrow + terminology/Wilhelm) adversarially. +``` + +Before each future Agentify query, append a `planned` row here. After completion, +summarize findings and either cite the affected acceptance row or record why the +finding was rejected. diff --git a/tests/fixtures/language/TEXT_SURFACES.md b/tests/fixtures/language/TEXT_SURFACES.md new file mode 100644 index 0000000..b142911 --- /dev/null +++ b/tests/fixtures/language/TEXT_SURFACES.md @@ -0,0 +1,2161 @@ +# Text Surface Inventory + +Goal version: `language-translation-v1`. Status: **populated** (iteration 1). + +This is the AC-001 anchor inventory. Rows are at the right altitude: per-string for +UI/CLI labels and errors; per **field-class** for the 64-entry corpus data files +(`gua.ts`, `trigrams.ts`, `large-glyphs.ts`) where enumerating every cell is neither +useful nor tractable. The tracked verifier `scripts/verify-language-surfaces.ts +--inventory-only` proves zero unclassified user-facing surfaces against this file. + +Row contract (fields, in order): + +```yaml +surface_id: # stable kebab id +file: +code_locator: +current_text: # exact literal (UI/CLI) or representative example(s)+count (corpus) +surface_class: +render_context: +language_policy: # translate | canonical-anchor | developer-only | not-user-facing +en_source: +zh_hant_source: +zh_hans_strategy: +source_layer: # received-text | commentary-wing | product-ui | interpretive-english | machine-token | proper-name | docs +token_policy: # translate | preserve | alias-only +json_policy: # not-json | stable-key | localized-display | locale-neutral-display +script_exception_policy: +locale_test: +risk: # low | medium | high +agentify_required: # yes | no +status: # open | translated | exempted | audited | verified +verifier: +notes: +``` + +For brevity, rows below populate the load-bearing fields; unset policy fields +(`en_source`/`zh_hant_source`/`zh_hans_strategy`/`locale_test`) are finalized in +AC-002 (policy matrix) and the per-surface criteria. `status: open` = enumerated, +translation decision pending. + +--- + +## Cross-cutting architectural findings (AC-001 evidence) + +1. **`DisplayLanguage` reaches almost nothing today.** Only `DetailScene` / + `detail-renderer.ts` consumes the `language` setting. The core display pipeline + (`selectDisplay`/`formatReading`/`formatDerived`), all CLI plain/JSON/hook output, + and every other TUI scene (home, cast, yarrow, toss, browse, journal, settings) + are hardcoded — English, or hardcoded Chinese (`問`, `自綜`, `错综同象`, wing titles). +2. **Simplified is a naive partial char map.** zh-Hans is implemented as a 96-entry + `SIMPLIFIED_CHARS` per-character substitution in `detail-renderer.ts`; any char not + in the map passes through as Traditional. `乾` is correctly absent (stays `乾`, never + `干`) — but coverage is partial. Directly relevant to AC-006. +3. **Config schema is correct and stable.** `language` ∈ {`en`,`zh-Hant`,`zh-Hans`}, + default `en`, settings order `EN 繁 简`, aliases (`简/簡/simplified`→zh-Hans, + `繁/traditional`→zh-Hant, `EN/english`→en). Machine tokens (keys, enum values) stable. +4. **JSON is locale-neutral-by-accident and should stay so.** `cast --json`/`hexagram + --json` emit all five commentary styles regardless of `language`; keys are stable. The + only localized-display leak is `doctor --json` `name`/`detail`. +5. **Wilhelm attribution risk.** `ename`, `w`, `yaoEn`, and the synthetic `"Wilhelm"` + detail header carry recognizable Wilhelm/Baynes idiom; the data is Wilhelm-*inspired*, + not direct quotation. The label must not imply direct quotation (AC-010). +6. **Line-identity tokens** (`初九 六二 九三 六四 九五 上九` / `初六…上六`) are embedded in + `yao[]` and must be preserved verbatim across display order. + +--- + +## Group: core-data-gua (`packages/core/src/data/gua.ts`) + +Field-class altitude. 64 entries × fields. Verifier uses field-class coverage for this file. + +```yaml +- surface_id: core-gua-u + file: packages/core/src/data/gua.ts + code_locator: "field u ×64 (e.g. L6 \"䷀\")" + current_text: '"䷀" "䷁" … (64 Unicode hexagram symbols, U+4DC0–U+4DFF)' + surface_class: runtime-symbols + render_context: "hexagram glyph in titles, lists, reading headlines" + language_policy: canonical-anchor + source_layer: machine-token + token_policy: preserve + json_policy: locale-neutral-display + script_exception_policy: "n/a — codepoint" + risk: low + agentify_required: no + status: open + verifier: "--core-data unicode index/order check; --inventory-only field-class coverage" + notes: "King-Wen ordered codepoints. USER-FACING (visibly rendered) but NOT a translation target — preserve. (C-001 fix: was not-user-facing; 'not translated' != 'not user-facing'.) JSON value is locale-neutral canonical." + +- surface_id: core-gua-name + file: packages/core/src/data/gua.ts + code_locator: "field n ×64 (e.g. L7 \"乾\")" + current_text: '"乾" "坤" "屯" … "噬嗑" … (64 Traditional names, 1–2 chars)' + surface_class: core-data-gua + render_context: "primary hexagram title everywhere (detail, lists, readings, derived refs)" + language_policy: canonical-anchor + source_layer: proper-name + token_policy: preserve + json_policy: localized-display + script_exception_policy: "Traditional source; zh-Hans via audited conversion; 乾 must NOT become 干" + risk: medium + agentify_required: yes + status: open + verifier: "--core-data; --simplified canonical-exception (乾)" + notes: "Canonical hexagram names. English equivalent is the separate ename field." + +- surface_id: core-gua-pinyin + file: packages/core/src/data/gua.ts + code_locator: "field p ×64 (e.g. L8 \"Qián\")" + current_text: '"Qián" "Kūn" … "Shì Kè" … (Hanyu Pinyin with tone diacritics)' + surface_class: core-data-gua + render_context: "romanization beside the Chinese name" + language_policy: canonical-anchor + source_layer: proper-name + token_policy: preserve + json_policy: locale-neutral-display + script_exception_policy: "NFC-normalized romanization; preserved in all languages" + risk: medium + agentify_required: no + status: open + verifier: "--core-data pinyin NFC-normalization check" + notes: "Romanization, not translated. Title-cased; two-syllable names space-separated." + +- surface_id: core-gua-ename + file: packages/core/src/data/gua.ts + code_locator: "field ename ×64 (e.g. L9 \"The Creative\")" + current_text: '"The Creative" "The Receptive" … "Darkening of the Light" …' + surface_class: core-data-gua + render_context: "English hexagram name/title" + language_policy: translate + source_layer: interpretive-english + token_policy: translate + json_policy: localized-display + risk: medium + agentify_required: yes + status: open + verifier: "--core-data; --glossary Wilhelm-attribution check" + notes: "Wilhelm/Baynes-style English titles; high overlap with canonical Wilhelm names — attribution risk (AC-010)." + +- surface_id: core-gua-dx + file: packages/core/src/data/gua.ts + code_locator: "field dx ×64 (e.g. L11)" + current_text: '"天行健,君子以自強不息" "地勢坤,君子以厚德載物" … (大象傳)' + surface_class: core-data-gua + render_context: "detail view 大象傳 / Image section; reading style \"dx\"" + language_policy: canonical-anchor + source_layer: commentary-wing + token_policy: preserve + json_policy: localized-display + script_exception_policy: "Traditional; fullwidth punctuation 「,;」; zh-Hans via audited conversion" + risk: high + agentify_required: yes + status: open + verifier: "--core-data; --terminal English-mode-no-CJK; --simplified residue" + notes: "象傳 (Ten Wings). Must NOT show in English mode unless canonical-anchor. English mirror = en." + +- surface_id: core-gua-tu + file: packages/core/src/data/gua.ts + code_locator: "field tu ×64 (e.g. L12)" + current_text: '"大哉乾元,萬物資始,乃統天。…" … (彖傳)' + surface_class: core-data-gua + render_context: "detail view 彖傳 section; reading style \"tu\"" + language_policy: canonical-anchor + source_layer: commentary-wing + token_policy: preserve + json_policy: localized-display + script_exception_policy: "Traditional; fullwidth 「,。」" + risk: high + agentify_required: yes + status: open + verifier: "--core-data; --simplified residue" + notes: "彖傳 (Ten Wings). English mirror = te." + +- surface_id: core-gua-en + file: packages/core/src/data/gua.ts + code_locator: "field en ×64 (e.g. L13)" + current_text: '"Heaven moves with vigor; the noble one strives ceaselessly" …' + surface_class: core-data-gua + render_context: "detail view English Image; reading style \"en\"; journal preview" + language_policy: translate + source_layer: interpretive-english + token_policy: translate + json_policy: localized-display + risk: high + agentify_required: yes + status: open + verifier: "--core-data; --glossary terminology (君子→noble one etc.)" + notes: "English rendering of dx. Classical-corpus translation = high fidelity risk." + +- surface_id: core-gua-te + file: packages/core/src/data/gua.ts + code_locator: "field te ×64 (e.g. L14)" + current_text: '"Vast is the primal creative — all things owe their beginning to it. …" …' + surface_class: core-data-gua + render_context: "detail view English Judgment; reading style \"te\"" + language_policy: translate + source_layer: interpretive-english + token_policy: translate + json_policy: localized-display + risk: high + agentify_required: yes + status: open + verifier: "--core-data; --glossary" + notes: "English rendering of tu. NOTE field-divergence: EN 'Judgment' = te, but ZH 彖傳 section = tu." + +- surface_id: core-gua-w + file: packages/core/src/data/gua.ts + code_locator: "field w ×64 (e.g. L15)" + current_text: '"The movement of heaven is full of power. Know when to persist…" …' + surface_class: core-data-gua + render_context: "detail view \"Wilhelm\" section (EN only); reading style \"w\"" + language_policy: translate + source_layer: interpretive-english + token_policy: translate + json_policy: localized-display + risk: high + agentify_required: yes + status: open + verifier: "--glossary Wilhelm-attribution; --core-data" + notes: "Wilhelm-INSPIRED advice register. Some entries (否 #12, 觀 #20) echo Wilhelm closely — attribution risk. No ZH counterpart." + +- surface_id: core-gua-yao + file: packages/core/src/data/gua.ts + code_locator: "field yao[6] ×64 = 384 strings (e.g. L17–22)" + current_text: '"初九:潛龍勿用。" … "上六:龍戰于野,其血玄黃。" …' + surface_class: core-data-gua + render_context: "detail view 爻辭 / Line Texts (Chinese mode)" + language_policy: canonical-anchor + source_layer: received-text + token_policy: preserve + json_policy: localized-display + script_exception_policy: "Traditional; delimiter inconsistency 「:」 vs 「,」; 无/無 both occur; preserve line-identity tokens" + risk: high + agentify_required: yes + status: open + verifier: "--core-data line-identity preservation (初九…上九 / 初六…上六)" + notes: "爻辭 = received-text (Zhouyi line statements). Each prefixed with a line-identity token encoding position+yin/yang — load-bearing." + +- surface_id: core-gua-yaoEn + file: packages/core/src/data/gua.ts + code_locator: "field yaoEn[6] ×64 = 384 strings (e.g. L25–30)" + current_text: '"Hidden dragon. Do not act." "Dragon appearing in the field. It furthers one to see the great man." …' + surface_class: core-data-gua + render_context: "detail view English Line Texts (English mode, under \"Line N\" headers)" + language_policy: translate + source_layer: interpretive-english + token_policy: translate + json_policy: localized-display + risk: high + agentify_required: yes + status: open + verifier: "--core-data; --glossary Wilhelm idiom" + notes: "English of yao. Line-identity token dropped (position via array index). Heavy Wilhelm idiom ('It furthers one to…','the great man','no blame')." + +- surface_id: core-gua-doc-comment + file: packages/core/src/data/gua.ts + code_locator: "L3 /** 64 hexagrams with commentary in 5 styles */" + current_text: "64 hexagrams with commentary in 5 styles" + surface_class: core-data-gua + render_context: "source comment only" + language_policy: developer-only + source_layer: docs + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "Not rendered. NOTE STYLES lists 6 incl 'st' but GUA has 5 commentary fields; 'st' is synthetic structure style." +``` + +## Group: core-data-trigrams (`packages/core/src/data/trigrams.ts`) + +```yaml +- surface_id: core-trigram-name + file: packages/core/src/data/trigrams.ts + code_locator: "TRIGRAMS[].n L4–11 (8)" + current_text: '"坤" "震" "坎" "兌" "艮" "離" "巽" "乾"' + surface_class: core-data-trigrams + render_context: "trigram name in structure breakdown; reading style \"st\"" + language_policy: canonical-anchor + source_layer: proper-name + token_policy: preserve + script_exception_policy: "Traditional; 兌→兑, 離→离 in zh-Hans; 乾 stays 乾" + risk: medium + agentify_required: yes + status: open + verifier: "--core-data; --simplified" + notes: "Bagua names, indexed 000→111 (not King Wen). English = img." + +- surface_id: core-trigram-img + file: packages/core/src/data/trigrams.ts + code_locator: "TRIGRAMS[].img L4–11 (8)" + current_text: '"earth" "thunder" "water" "lake" "mountain" "fire" "wind" "heaven"' + surface_class: core-data-trigrams + render_context: "trigram natural-image label in structure breakdown" + language_policy: translate + source_layer: interpretive-english + token_policy: translate + risk: low + agentify_required: no + status: open + verifier: "--core-data; --terminal (zh uses TRIGRAM_IMAGE_ZH 天/地/雷/水/山/風/火/澤)" + notes: "Bagua attributes. zh trigram structure line maps to Chinese image via detail-renderer TRIGRAM_IMAGE_ZH." + +- surface_id: core-trigram-sym + file: packages/core/src/data/trigrams.ts + code_locator: "TRIGRAMS[].sym L4–11 (8)" + current_text: '"☷" "☳" "☵" "☱" "☶" "☲" "☴" "☰" (U+2630–U+2637)' + surface_class: runtime-symbols + render_context: "trigram glyph in structure breakdown" + language_policy: canonical-anchor + source_layer: machine-token + token_policy: preserve + risk: low + agentify_required: no + status: open + verifier: "--core-data unicode check" + notes: "Trigram codepoints. USER-FACING visible symbol, preserve (C-001 fix: was not-user-facing)." + +- surface_id: core-derived-labels-en + file: packages/core/src/data/trigrams.ts + code_locator: "DERIVED_LABELS L17–23" + current_text: '"互卦 (hidden within)" "錯卦 (polarity)" "綜卦 (mirror)" "之卦 (becoming)" "對角卦 (diagonal)"' + surface_class: core-format + render_context: "derived-hexagram label (default branch of formatDerived ~50%)" + language_policy: translate + source_layer: interpretive-english + token_policy: translate + script_exception_policy: "MIXED: Traditional 卦-name + English gloss; no Chinese-free path exists" + risk: high + agentify_required: yes + status: open + verifier: "--core-data; --terminal English-mode-no-CJK" + notes: "Even the 'English' set leads with Traditional 互卦/錯卦/綜卦/之卦/對角卦. Bilingual leakage." + +- surface_id: core-derived-labels-cn + file: packages/core/src/data/trigrams.ts + code_locator: "DERIVED_LABELS_CN L25–32 (+ comment L25)" + current_text: '"互卦 (潜藏轨迹)" "錯卦 (矛盾调和)" "綜卦 (表里)" "之卦 (所往)" "對角卦 (極反)"' + surface_class: core-format + render_context: "derived-hexagram label, Chinese variant (~50% via random byte)" + language_policy: translate + source_layer: commentary-wing + token_policy: preserve + script_exception_policy: "INCONSISTENT scripts: Traditional terms; Simplified parentheticals (潜/轨/迹, 调; 里) except 極反 Traditional, 所往 neutral" + risk: high + agentify_required: yes + status: open + verifier: "--simplified script-consistency; --core-data" + notes: "来知德 framework labels. Selected by random byte, NOT by language — bilingual leakage. Mixed Trad/Simp within strings." + +- surface_id: core-styles-tokens + file: packages/core/src/data/trigrams.ts + code_locator: "STYLES L14, QUOTE_STYLES L15" + current_text: '["dx","tu","en","te","w","st"] / ["dx","tu","en","te","w"]' + surface_class: core-format + render_context: "internal style enum keys" + language_policy: developer-only + source_layer: machine-token + token_policy: preserve + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "Not display text. 'st' has no backing GUA field (synthetic structure style)." +``` + +## Group: runtime-symbols (large glyphs) (`packages/core/src/data/large-glyphs.ts`) + +```yaml +- surface_id: core-large-glyphs + file: packages/core/src/data/large-glyphs.ts + code_locator: "LARGE_GLYPHS (71 char keys × 3 fonts × 3 sizes); banner L1–7" + current_text: "braille-pattern bitmap art (U+2800 block); char keys are the 71 unique CJK chars of the 64 names" + surface_class: runtime-symbols + render_context: "large braille-rendered hexagram-name glyph (cast reveal, detail header)" + language_policy: canonical-anchor + source_layer: machine-token + token_policy: preserve + script_exception_policy: "auto-generated; keys include simplified-style 无 matching gua.ts" + risk: low + agentify_required: no + status: exempted + verifier: "--inventory-only field-class coverage (file represented)" + notes: "Auto-generated by scripts/generate-glyphs.ts. Font glosses 楷体/隶变/黑体 are Simplified, dev-only. Bitmap of the Chinese name, not translatable text." + +- surface_id: core-simplify-map + file: packages/core/src/i18n/simplify.ts + code_locator: "SIMPLIFIED_MAP (~270 entries) + toSimplified() + SIMPLIFIED_EXCEPTIONS" + current_text: 'audited Traditional→Simplified table (傳→传 … 龍→龙); 乾 deliberately ABSENT (stays 乾, not 干)' + surface_class: runtime-symbols + render_context: "the zh-Hans conversion mechanism; consumed by detail-renderer zh() (AC-006). Values reach screen." + language_policy: developer-only + source_layer: machine-token + token_policy: preserve + json_policy: not-json + script_exception_policy: "AC-006 proven path (Agentify C-002 audited); 乾 canonical exception enforced; classical false-friends resolved (後→后, 雲→云, 麗/離 distinct, 係/繫→系, 於→于, 穀→谷); 藉 not converted" + locale_test: "scripts/verify-language-surfaces.ts --simplified (residue + exceptions + consumer-side)" + risk: high + agentify_required: yes + status: audited + verifier: "--simplified" + notes: "NEW source file (AC-001 reopen: added). Replaces the naive 96-char detail-renderer map. Rare Ext-B/C simplified codepoints (㧑/𦈡/𬙊/𫗧) for obscure line-text chars may render as tofu in some fonts — correctness over rendering." +``` + +## Group: core-format / service (`format/*.ts`, `service/display-select.ts`, `identify/structure.ts`) + +```yaml +- surface_id: core-reading-headline + file: packages/core/src/format/reading.ts + code_locator: "L34 formatReading template" + current_text: '`${g.u} ${g.n} (${g.p}) — ${middle}`' + surface_class: core-format + render_context: "reading headline (daily/non-derived) and hook output" + language_policy: translate + source_layer: product-ui + token_policy: translate + risk: high + agentify_required: yes + status: open + verifier: "--terminal; --core-data" + notes: "g.n (Chinese name) ALWAYS shown regardless of language; ename never used. middle=g[style]; style dx/tu => Chinese. No DisplayLanguage param. Em-dash glue." + +- surface_id: core-reading-becoming-suffix + file: packages/core/src/format/reading.ts + code_locator: "L36–39" + current_text: '` → ${t.u} ${t.n} [${cast.changingPositions.join(",")}]`' + surface_class: core-format + render_context: "reading transformation suffix" + language_policy: translate + source_layer: product-ui + risk: medium + agentify_required: no + status: open + verifier: "--terminal" + notes: "t.n Chinese name unconditional. Arrow/brackets/comma = machine glue." + +- surface_id: core-getrandomquotestyle + file: packages/core/src/format/reading.ts + code_locator: "L8–14 getRandomQuoteStyle" + current_text: "(no literal; returns QUOTE_STYLES[byte%5])" + surface_class: runtime-symbols + render_context: "selects derived-quote style (dx/tu/en/te/w)" + language_policy: not-user-facing + source_layer: machine-token + risk: high + agentify_required: no + status: open + verifier: "--terminal bilingual-leakage" + notes: "40% chance Chinese (dx/tu) per derived quote, no language gate — primary leakage engine." + +- surface_id: core-derived-templates + file: packages/core/src/format/derived.ts + code_locator: "L17–49 formatDerived" + current_text: '"自綜" / "self-mirroring" / "綜卦 (…)" / "错综同象 …" / "對角卦 = 錯卦 (自綜) …" / "對角卦 = ${g.n} (自返) …" / "${label} ${g.u} ${g.n} (${g.p}) — …"' + surface_class: core-format + render_context: "derived-hexagram reading lines" + language_policy: translate + source_layer: product-ui + token_policy: preserve + script_exception_policy: "hardcoded Traditional/Simplified terms; 错综同象 is Simplified, 自綜/自返/對角卦/錯卦 Traditional" + risk: high + agentify_required: yes + status: open + verifier: "--terminal English-mode-no-CJK; --simplified" + notes: "Multiple hardcoded Chinese terms with no English variant and no language gate. 50% CN coin flip for tag (自綜 vs self-mirroring). g.n unconditional. Quote 40% Chinese." + +- surface_id: core-displayselect + file: packages/core/src/service/display-select.ts + code_locator: "L18–47 selectDisplay; style literals L25,30–44" + current_text: '"dx","tu","en","te","w","st","nuclear","polarity","mirror","becoming","diagonal"' + surface_class: runtime-symbols + render_context: "top-level cascade deciding what to display" + language_policy: developer-only + source_layer: machine-token + token_policy: preserve + risk: high + agentify_required: no + status: open + verifier: "--terminal; --core-data" + notes: "NO DisplayLanguage param. First-of-day ALWAYS 'dx' (Chinese). Random cascade surfaces Chinese ~2/6 reading branches + all derived. Style keys decide corpus language." + +- surface_id: core-structure-formattrigrams + file: packages/core/src/identify/structure.ts + code_locator: "L30–36 formatTrigrams" + current_text: '`${s.upper.sym} ${s.upper.n} ${s.upper.img} / ${s.lower.sym} ${s.lower.n} ${s.lower.img}`' + surface_class: core-data-trigrams + render_context: "'st' structure style body, e.g. '☰ 乾 heaven / ☲ 離 fire'" + language_policy: translate + source_layer: product-ui + risk: high + agentify_required: yes + status: open + verifier: "--terminal; --core-data" + notes: "Inherently bilingual: Chinese trigram name + English image, no language gate. ' / ' joiner neutral." + +- surface_id: core-random-tape-error + file: packages/core/src/random.ts + code_locator: "L60–63 TapeRandomSource.nextBytes" + current_text: '`TapeRandomSource exhausted: requested ${count} bytes at offset ${this.offset}, tape length ${this.tape.length}`' + surface_class: runtime-symbols + render_context: "thrown Error (test/replay only)" + language_policy: developer-only + source_layer: machine-token + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "Test double; never in production. English OK." + +- surface_id: core-derivation-docs + file: packages/core/src/derivation/{diagonal,locked-pairs,mirror,nuclear,polarity}.ts + code_locator: "JSDoc only" + current_text: '對角卦 / 错综同象 / 綜卦 / 互卦 / 錯卦 (in comments)' + surface_class: runtime-symbols + render_context: "developer doc comments; pure math modules emit no text" + language_policy: developer-only + source_layer: docs + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "No runtime literals reach render." + +- surface_id: core-search-assumptions + file: packages/core/src/search.ts + code_locator: "L25–71 searchHexagrams/normalize" + current_text: '(regex /[̀-ͯ]/g, "NFD"; no UI literals)' + surface_class: runtime-symbols + render_context: "search engine; returns Hexagram[]; caller picks display field" + language_policy: not-user-facing + source_layer: machine-token + risk: medium + agentify_required: no + status: open + verifier: "--core-data pinyin/accent normalization" + notes: "Matches over n/p/ename/kw. Display-language of results decided by caller. NFD accent-insensitive." +``` + +--- + +## Group: terminal-home (`scenes/home/*`) + +```yaml +- surface_id: term-home-title + file: packages/terminal/src/scenes/home/home-scene.ts + code_locator: "L52" + current_text: "☯ I Ching" + surface_class: terminal-home + render_context: "app title above menu" + language_policy: canonical-anchor + source_layer: proper-name + token_policy: preserve + risk: low + agentify_required: no + status: open + verifier: "--terminal" + notes: "Brand name + ☯ glyph." + +- surface_id: term-home-menu + file: packages/terminal/src/scenes/home/home-scene.ts + code_locator: "L59–64 items[].label" + current_text: '"Cast" "Play"(dev) "Dictionary" "Journal" "Settings" "Quit"' + surface_class: terminal-home + render_context: "home menu items '[k] Label'; key letters are accelerators" + language_policy: translate + source_layer: product-ui + token_policy: translate + risk: medium + agentify_required: no + status: open + verifier: "--terminal" + notes: "English, hardcoded. No language branch. 'Play' is dev-mode-only." + +- surface_id: term-home-status + file: packages/terminal/src/scenes/home/home-scene.ts + code_locator: "L79,86,91" + current_text: '"Today: ${gua.u} ${gua.n} (${gua.p})" / "→ ${bg.u} ${bg.n}" / "No cast today"' + surface_class: terminal-home + render_context: "today's-cast status lines + empty state" + language_policy: translate + source_layer: product-ui + risk: medium + agentify_required: no + status: open + verifier: "--terminal" + notes: "'Today:' / 'No cast today' English; gua.n always Chinese (not language-switched)." + +- surface_id: term-home-taijitu + file: packages/terminal/src/scenes/home/taijitu-render.ts + code_locator: "L82" + current_text: "braille glyph U+2800..U+28FF (computed)" + surface_class: runtime-symbols + render_context: "rotating yin-yang figure" + language_policy: not-user-facing + source_layer: machine-token + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "Graphical. 'dots'/'dense' style tokens dev-only." +``` + +## Group: terminal-cast (`scenes/cast/*`, `scenes/intention/*`, `scenes/toss/*`) + +```yaml +- surface_id: term-intention-prompt + file: packages/terminal/src/scenes/intention/intention-scene.ts + code_locator: "L46" + current_text: "問" + surface_class: terminal-intention + render_context: "large prompt glyph above intention input" + language_policy: canonical-anchor + source_layer: product-ui + token_policy: preserve + script_exception_policy: "問 is identical Trad/Simp; hardcoded regardless of language" + risk: high + agentify_required: yes + status: open + verifier: "--terminal English-mode policy (is 問 an intended canonical anchor in EN?)" + notes: "The ONLY intention prompt. English users see 問. Decide: canonical-anchor vs localized." + +- surface_id: term-intention-hint + file: packages/terminal/src/scenes/intention/intention-scene.ts + code_locator: "L58" + current_text: "[enter] confirm · [esc] back" + surface_class: terminal-intention + render_context: "footer hint" + language_policy: translate + source_layer: product-ui + risk: medium + agentify_required: no + status: open + verifier: "--terminal" + notes: "English. [esc] back verb recurs across scenes (not a shared constant)." + +- surface_id: term-toss-footers + file: packages/terminal/src/scenes/toss/toss-scene.ts + code_locator: "L99,101" + current_text: '"[space] toss · [esc] back" / "[space] reveal · [esc] discard"' + surface_class: terminal-toss + render_context: "toss footers (waiting / complete)" + language_policy: translate + source_layer: product-ui + risk: medium + agentify_required: no + status: open + verifier: "--terminal" + notes: "English. Ritual verbs toss/reveal/discard." + +- surface_id: term-toss-coin-glyphs + file: packages/terminal/src/scenes/toss/coin-physics.ts + code_locator: "L9,10,84" + current_text: '"◉" "○" "◑" "│" "◐" (FLIP/SPIN frames, settled faces)' + surface_class: runtime-symbols + render_context: "physics coins flying/landing" + language_policy: not-user-facing + source_layer: machine-token + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "Graphical. ◉ heads / ○ tails." + +- surface_id: term-cast-prompts + file: packages/terminal/src/scenes/cast/cast-scene.ts + code_locator: "L270–274" + current_text: '"[enter] explore · [esc] back" / "[←→] switch · [enter] detail · [esc] back" / "[enter] detail · [esc] back"' + surface_class: terminal-cast + render_context: "cast footer prompts (pre/with/without becoming)" + language_policy: translate + source_layer: product-ui + risk: medium + agentify_required: no + status: open + verifier: "--terminal" + notes: "English. Verbs explore/switch/detail/back." + +- surface_id: term-cast-chrome-counters + file: packages/terminal/src/scenes/cast/ritual-chrome.ts + code_locator: "L31,33 formatLineCounter" + current_text: '"line ${i+1}/${total}" / "${base} · round ${r+1}/${total}"' + surface_class: terminal-global-chrome + render_context: "shared position counter (cast/toss/yarrow), e.g. 'line 1/6 · round 2/3'" + language_policy: translate + source_layer: product-ui + risk: high + agentify_required: no + status: open + verifier: "--terminal" + notes: "Words 'line'/'round' hardcoded English. SHARED single source of truth. Yarrow is the only round-counter caller." + +- surface_id: term-cast-reveal-title + file: packages/terminal/src/scenes/cast/reveal-renderer.ts + code_locator: "L46–58,124–125" + current_text: '"${gua.u} ${gua.n}" / "${gua.p}" / "${gua.ename}" / "${structure.upper.sym} above ${structure.lower.sym}" / "→ ${gua.u} ${gua.n}"' + surface_class: terminal-cast + render_context: "reveal title block (primary + becoming)" + language_policy: translate + source_layer: product-ui + risk: high + agentify_required: yes + status: open + verifier: "--terminal; --core-data" + notes: "Hardcodes English ' above ' connective; gua.n Chinese always shown. ename only in glyph mode. '…' ellipsis truncation." + +- surface_id: term-cast-line-glyphs + file: packages/terminal/src/scenes/cast/{line,hexagram,morph,coin,glyph,right-hex}-renderer.ts + code_locator: "GLYPHS.* references" + current_text: '"━━━…" yang/yin frames; "○"/"×" gutter markers; "◉"/"◎" inline; coin/morph frames; braille name glyph' + surface_class: runtime-symbols + render_context: "hexagram line/coin/glyph rendering" + language_policy: not-user-facing + source_layer: machine-token + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "All from shared glyphs.ts. Encode ritual meaning (old yang ○, old yin ×) as symbols, no words. Large glyph built from gua.n (Chinese)." + +- surface_id: term-cast-internal-tokens + file: packages/terminal/src/scenes/cast/model.ts, toss-scene.ts, home-scene.ts + code_locator: "phase/layout/focus unions; SceneSignal type tags" + current_text: '"idle"/"spin"/"land"/"collapse"/"done"/"centered"/"splitting"/"side-by-side"/"primary"/"becoming"; "startCast"/"openDictionary"/… ' + surface_class: runtime-symbols + render_context: "internal state-machine + signal discriminants, never rendered" + language_policy: not-user-facing + source_layer: machine-token + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "Internal enums/signals." +``` + +## Group: terminal-cast (yarrow) (`scenes/yarrow/*`, `animation/yarrow-presets.ts`) + +```yaml +- surface_id: term-yarrow-footers + file: packages/terminal/src/scenes/yarrow/yarrow-scene.ts + code_locator: "L98–103" + current_text: '"[space] receive the reading · [esc] discard" / "[space] resume · [→] step · [s] skip · [esc] back" / "[space] pause · [f] speed · [s] skip · [esc] back" / " · ${speed}×"' + surface_class: terminal-cast + render_context: "auto/guided yarrow footers + speed badge" + language_policy: translate + source_layer: product-ui + risk: medium + agentify_required: no + status: open + verifier: "--terminal" + notes: "English. Verbs receive/discard/resume/step/skip/pause/speed. '×' (U+00D7) speed multiplier." + +- surface_id: term-yarrow-manual-prompts + file: packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts + code_locator: "L286–298" + current_text: '"[space] receive the reading · [esc] discard" / "press [space] to begin cutting · [esc] back" / "press [space] to cut" / "cut around here" / ""' + surface_class: terminal-cast + render_context: "manual 18-cut footer prompts by phase" + language_policy: translate + source_layer: product-ui + risk: medium + agentify_required: yes + status: open + verifier: "--terminal" + notes: "English ritual prompts. 'cut around here' deliberately approximate (user authors window, RNG picks k) — preserve nuance in translation." + +- surface_id: term-yarrow-line-values + file: packages/terminal/src/scenes/yarrow/yarrow-timeline.ts + code_locator: "L32–35 LINE_VALUE_NAMES" + current_text: '"old yin"(6) "young yang"(7) "young yin"(8) "old yang"(9)' + surface_class: terminal-cast + render_context: "fuse caption line-value name" + language_policy: canonical-anchor + source_layer: interpretive-english + token_policy: translate + risk: high + agentify_required: yes + status: open + verifier: "--glossary ritual line-value terms; --terminal" + notes: "HIGH: old/young vs moving/greater-lesser not standardized. CN canonical 老陰/少陽/少陰/老陽. Decide anchor + gloss; keep all 4 consistent." + +- surface_id: term-yarrow-captions + file: packages/terminal/src/scenes/yarrow/yarrow-timeline.ts + code_locator: "L47–62 RoundCaptions + buildFuseCaption + nextLabel + meaning" + current_text: '"Round ${n} · ${count} stalks" / "Cut at k=${l} · heaps ${l} | ${r}" / "One aside · heaps ${l} | ${r}" / "Count each heap by fours." / "1 + ${a} + ${b} = ${s} (${meaning})" / "Carry ${rem} → ${nextLabel}" / "Remaining ${rem} ÷ 4 = ${v} · ${name}" / "few = 3" / "many = 2" / "fuse" / "round ${n}"' + surface_class: terminal-cast + render_context: "teach-once per-beat captions" + language_policy: translate + source_layer: product-ui + token_policy: translate + risk: high + agentify_required: yes + status: open + verifier: "--terminal; --glossary ritual terms (stalks/heaps/set-aside/carry/fuse)" + notes: "English ritual narration. Casing/punctuation/middot-spacing drift noted. 'k=' exposes code var. Numbers raw-interpolated (no locale grouping/plural)." + +- surface_id: term-yarrow-field-labels + file: packages/terminal/src/scenes/yarrow/field-renderer.ts + code_locator: "L327,338,413,430" + current_text: '"${count} stalks" / String(count) / "set aside ${n}" / "│" "╞" "╡" "▌"' + surface_class: terminal-cast + render_context: "bar/count/remainder/set-aside display + ritual glyphs" + language_policy: translate + source_layer: product-ui + risk: high + agentify_required: yes + status: open + verifier: "--terminal; --glossary (set aside / stalks)" + notes: "'stalks'/'set aside' English; bare numbers via String(n) not locale-aware. Glyphs │╞╡▌ language-neutral." + +- surface_id: term-yarrow-internal + file: packages/terminal/src/scenes/yarrow/model.ts, yarrow-manual-scene.ts, animation/yarrow-presets.ts + code_locator: "YarrowBeat/Phase enums; throw Error guards; RitualDetail/MotionPreset keys" + current_text: '"idle"/"gather"/"divide"/"takeOne"/"count"/"tally"/"carry"/"fuse"/"done"; "appendLine: transcript already complete (6 lines)" etc.; "expanded"/"summarized"/"stepped"; "default"/"deep"/"brisk"/"reduced"' + surface_class: runtime-symbols + render_context: "internal beat/phase enums + dev-only invariant errors + timing config" + language_policy: developer-only + source_layer: machine-token + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "yarrow-presets.ts has zero user-facing strings. Model errors are invariant guards. MotionPreset NOT a settings chip currently." +``` + +## Group: terminal-dict (`scenes/dict/*`) + +```yaml +- surface_id: term-dict-browse-chrome + file: packages/terminal/src/scenes/dict/browse-renderer.ts + code_locator: "L41,51,52,158,160,161" + current_text: '"Search: " / "I Ching Dictionary" / "[/] search" / "${n} hexagrams" / "[↑↓] navigate · [enter] open · [esc] clear search" / "[↑↓] navigate · [enter] open · [/] search · [esc] back"' + surface_class: terminal-dict + render_context: "browse header/footer/count" + language_policy: translate + source_layer: product-ui + risk: low + agentify_required: no + status: open + verifier: "--terminal" + notes: "English, NO language branch. Rows always show CN name + pinyin + EN name simultaneously. '…' truncation; '#1A2030' hardcoded selected-row bg." + +- surface_id: term-dict-detail-sections-en + file: packages/terminal/src/scenes/dict/detail-renderer.ts + code_locator: "L210–211,226–228,245,251,267,286–287,302,304" + current_text: '"above" / "Image" / "Judgment" / "Wilhelm-inspired" / "Line Texts" / "Line ${n}" / "Derived" / "Locked pair: ${ename}" / "Cast ${n} time(s) (last: ${date})" / "No history"' + surface_class: terminal-dict + render_context: "SYNTHETIC detail section/line labels (English mode, language===\"en\")" + language_policy: translate + source_layer: interpretive-english + token_policy: translate + risk: medium + agentify_required: yes + status: open + verifier: "--terminal English-mode coverage; --glossary (Wilhelm)" + notes: "Generated labels, not corpus. 'Wilhelm' proper name — attribution. Plural 'time(s)' handled inline." + +- surface_id: term-dict-detail-sections-zh + file: packages/terminal/src/scenes/dict/detail-renderer.ts + code_locator: "L212,231,232,245,267,288,303,304" + current_text: '"上"/"下" / "大象傳" / "彖傳" / "爻辭" / "衍卦" / "鎖定對卦" / "已占 ${n} 次 (最近: ${date})" / "未有占記"' + surface_class: terminal-dict + render_context: "SYNTHETIC detail labels (Chinese mode); passed through zh()" + language_policy: canonical-anchor + source_layer: commentary-wing + token_policy: preserve + script_exception_policy: "Traditional source; zh-Hans via SIMPLIFIED_CHARS (傳→传, 辭→辞, 鎖→锁, 對→对, 記→记); 衍卦 unchanged" + risk: medium + agentify_required: yes + status: open + verifier: "--terminal; --simplified residue + canonical-exception" + notes: "Wilhelm section OMITTED in zh. 'Line N' header has NO zh equivalent. Field-divergence: EN Judgment=te, ZH 彖傳=tu." + +- surface_id: term-dict-detail-footer + file: packages/terminal/src/scenes/dict/detail-renderer.ts + code_locator: "L392,393,396–398" + current_text: '"[↑↓] select · [enter] open · [tab] scroll · [esc] back" / "[↑↓] scroll · [tab] derived · [enter] open · [esc] back" / "${page}/${total}"' + surface_class: terminal-dict + render_context: "detail footer keybindings (NOT language-branched — always English)" + language_policy: translate + source_layer: product-ui + risk: medium + agentify_required: no + status: open + verifier: "--terminal (footer not localized even in zh — bug)" + notes: "renderFooter ignores language. Always English even in zh modes." + +- surface_id: term-dict-derived-labels + file: packages/terminal/src/scenes/dict/detail-model.ts + code_locator: "L62–87 derivedLinks label/labelCn" + current_text: '"Nuclear"/"互卦" "Polarity"/"錯卦" "Mirror"/"綜卦" "Diagonal"/"對角"' + surface_class: terminal-dict + render_context: "derived-link row labels (EN label vs CN labelCn)" + language_policy: translate + source_layer: interpretive-english + token_policy: translate + script_exception_policy: "labelCn Traditional; zh-Hans 錯→错, 綜→综, 對→对; 互卦 unchanged" + risk: low + agentify_required: no + status: open + verifier: "--terminal; --simplified" + notes: "Already has EN+CN pair; chosen by language. Good model to generalize." + +- surface_id: term-dict-zh-maps + file: packages/terminal/src/scenes/dict/detail-renderer.ts + code_locator: "L29–125 SIMPLIFIED_CHARS (96); L127–136 TRIGRAM_IMAGE_ZH" + current_text: 'SIMPLIFIED_CHARS {兌:兑,…,龜:龟}; TRIGRAM_IMAGE_ZH {乾:天,坤:地,震:雷,坎:水,艮:山,巽:風,離:火,兌:澤}' + surface_class: runtime-symbols + render_context: "zh() conversion table + trigram image map; values reach screen" + language_policy: developer-only + source_layer: machine-token + token_policy: preserve + script_exception_policy: "THE zh-Hans mechanism — partial 96-char map; non-mapped chars leak Traditional. AC-006 core concern." + risk: high + agentify_required: yes + status: open + verifier: "--simplified coverage + leakage; --self-test" + notes: "Naive per-char substitution, NOT real translation. 乾 correctly absent (no 乾→干). Coverage gaps = Traditional residue in zh-Hans." +``` + +## Group: terminal-journal (`scenes/journal/*`) + +```yaml +- surface_id: term-journal-chrome + file: packages/terminal/src/scenes/journal/journal-scene.ts + code_locator: "L39,43,52,120" + current_text: '"Journal" / "${n} readings" / "No readings yet" / "[↑↓] navigate · [enter] view · [d] dictionary · [esc] back"' + surface_class: terminal-journal + render_context: "journal title/count/empty-state/footer" + language_policy: translate + source_layer: product-ui + risk: low + agentify_required: no + status: open + verifier: "--terminal" + notes: "English, NO language branch. Distinct empty state from detail 'No history'." + +- surface_id: term-journal-row-chrome + file: packages/terminal/src/scenes/journal/journal-scene.ts + code_locator: "L83,85,92–104,114" + current_text: '" → " / " [${positions}]" / "“…”" curly quotes / "…" / " > " cursor / "${i}/${n} (${pct}%)"' + surface_class: runtime-symbols + render_context: "row glue (arrow/brackets/quotes/cursor/scroll indicator)" + language_policy: not-user-facing + source_layer: machine-token + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "Language-neutral chrome wrapping data + user intention." + +- surface_id: term-journal-detail-preview + file: packages/terminal/src/scenes/journal/journal-scene.ts + code_locator: "L131–134" + current_text: "gua.en (English Image of selected entry)" + surface_class: terminal-journal + render_context: "dimmed preview above footer" + language_policy: translate + source_layer: interpretive-english + risk: medium + agentify_required: no + status: open + verifier: "--terminal (hardcoded English regardless of language)" + notes: "Comment: 'Show English image'. No DisplayLanguage — always EN." +``` + +## Group: terminal-settings (`scenes/settings/*`) + +```yaml +- surface_id: term-settings-chrome + file: packages/terminal/src/scenes/settings/settings-scene.ts + code_locator: "L185,242,252; row prefix L218; chip wrap L226" + current_text: '"Settings" / "[↑↓] setting · [←→] option · [esc] save & back" / "Preview:" / "> " / "[${opt}]"' + surface_class: terminal-settings + render_context: "settings title/footer/preview label" + language_policy: translate + source_layer: product-ui + risk: low + agentify_required: no + status: open + verifier: "--terminal" + notes: "Settings UI itself always English even though it controls language." + +- surface_id: term-settings-row-labels + file: packages/terminal/src/scenes/settings/settings-scene.ts + code_locator: "L103–109" + current_text: '"Theme" "Language" "Taijitu" "Glyph Animation" "Font" "Cast Method" "Cast Mode"' + surface_class: terminal-settings + render_context: "setting row labels" + language_policy: translate + source_layer: product-ui + risk: low + agentify_required: no + status: open + verifier: "--terminal" + 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 + code_locator: "L25–30 LANGUAGE_OPTIONS/LANGUAGE_LABELS; L104" + current_text: 'LANGUAGE_OPTIONS=["en","zh-Hant","zh-Hans"]; LANGUAGE_LABELS={en:"EN","zh-Hant":"繁","zh-Hans":"简"} → chips render "EN 繁 简"' + surface_class: terminal-settings + render_context: "language selector option chips" + language_policy: canonical-anchor + source_layer: product-ui + token_policy: alias-only + risk: high + agentify_required: no + status: open + 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" + current_text: "乾" + surface_class: runtime-symbols + render_context: "fixed sample glyph for glyph/font/anim preview" + language_policy: canonical-anchor + source_layer: received-text + token_policy: preserve + risk: low + agentify_required: no + status: open + verifier: "--terminal" + notes: "Always Traditional 乾; not passed through zh()." +``` + +## Group: terminal-global-chrome / shared (`glyphs.ts`, widgets, theme, scene infra) + +```yaml +- surface_id: term-glyphs-shared + file: packages/terminal/src/glyphs.ts + code_locator: "L4–61 GLYPHS + SPLIT_ARROW" + current_text: '◌ coinIdle; ◴◷◶◵ spin; ● heads/○ tails; ━ line frames; ○/× changing markers; ◉/◎ inline; ⇒ SPLIT_ARROW' + surface_class: runtime-symbols + render_context: "shared glyph set across cast/toss/yarrow" + language_policy: not-user-facing + source_layer: machine-token + token_policy: preserve + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "Symbols carry meaning (becomes ⇒, old yang ○, old yin ×) but no translatable text." + +- surface_id: term-theme-names + file: packages/terminal/src/color/theme.ts + code_locator: "THEME_NAMES L139 (ink/bone/cinnabar/jade/river)" + current_text: '"ink" "bone" "cinnabar" "jade" "river"' + surface_class: terminal-settings + render_context: "Theme settings-row option chips (shown verbatim)" + language_policy: canonical-anchor + source_layer: machine-token + token_policy: preserve + risk: medium + 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. 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 + code_locator: "L24 MotionPreset; L112–117 PRESETS" + current_text: '"default" "brisk" "deep" "reduced"' + surface_class: terminal-settings + render_context: "config 'motion' value; NOT a settings chip currently" + language_policy: developer-only + source_layer: machine-token + token_policy: preserve + risk: medium + agentify_required: no + status: open + verifier: "--policy" + notes: "Would become user-facing if a Motion settings row is added (reopen)." + +- surface_id: term-scroll-indicator + file: packages/terminal/src/widgets/scrollable.ts + code_locator: "L52–59" + current_text: '"1/1" / "${page}/${totalPages}"' + surface_class: terminal-global-chrome + render_context: "shared scroll page indicator across scenes" + language_policy: canonical-anchor + source_layer: product-ui + token_policy: preserve + risk: low + agentify_required: no + status: open + verifier: "--terminal" + notes: "Numeric, language-neutral. Western digits assumed." + +- surface_id: term-textinput-widget + file: packages/terminal/src/widgets/text-input.ts + code_locator: "L87–160" + current_text: '" " cursor fill; "#C8A96B"/"#0D1117" fallback colors' + surface_class: terminal-global-chrome + render_context: "shared text input (intention); no placeholder, reverse-video block cursor" + language_policy: not-user-facing + source_layer: machine-token + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "No placeholder string. Nothing to translate." + +- surface_id: term-shared-internal + file: packages/terminal/src/{scene/types.ts,scene/loop.ts,session/terminal-session.ts,color/detect.ts,color/themes/cinnabar.ts,clock.ts,index.ts} + code_locator: "various" + current_text: 'SceneSignal tags; ColorSupport "truecolor"/"256"/"16"/"none"; env names NO_COLOR/COLORTERM/TERM/WT_SESSION; SIG* names; cinnabar palette keys; devMode row numbers' + surface_class: runtime-symbols + render_context: "internal infra; not rendered (devMode overlay numbers gated)" + language_policy: developer-only + source_layer: machine-token + risk: low + agentify_required: no + status: exempted + verifier: "n/a" + notes: "No user-facing prose in shared infra. clock.ts has no string literals." +``` + +--- + +## Group: cli-commands / cli-invalid-paths (`apps/cli/src/**`) + +```yaml +- surface_id: cli-program-meta + file: apps/cli/src/program.ts + code_locator: "L13–18" + current_text: 'name "iching" (pkg fallback); version "0.1.0" fallback; "--json" "structured JSON output"; "--seed " "deterministic RNG seed (cast command)"; "--data-dir " "override data directory"; "--dev" "enable dev mode (coin toss playground, etc.)"' + surface_class: cli-commands + render_context: "--help global options" + language_policy: translate + source_layer: product-ui + json_policy: not-json + risk: low + agentify_required: no + status: open + verifier: "--cli" + notes: "Commander help is static English unless localized. Flags/name = machine tokens (preserve)." + +- surface_id: cli-command-descriptions + file: apps/cli/src/commands/{cast,config,dict,doctor,hexagram,journal,paths}.ts + code_locator: ".description()/.argument()/.option() across commands" + current_text: '"Perform an I Ching casting" / "question for the oracle" / "Browse the I Ching dictionary" / "hexagram number (1-64) to view directly" / "Verify environment and configuration" / "Look up hexagram by King Wen number (1-64)" / "commentary style: dx|tu|en|te|w" / "--style