From 8bcbec8b2ee48ecafe0fca16d5801cdf160d0813 Mon Sep 17 00:00:00 2001 From: provi Date: Mon, 1 Jun 2026 23:03:10 -0700 Subject: [PATCH 01/26] feat(config): add display language setting Persist a display language preference with canonical values for simplified Chinese, traditional Chinese, and English. Expose the setting through the CLI config command and the terminal settings scene while keeping current text rendering unchanged. --- apps/cli/src/__tests__/config-command.test.ts | 23 ++- apps/cli/src/commands/config.ts | 143 +++++++++++++--- apps/cli/src/main.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/types.ts | 3 + .../src/__tests__/config-store.test.ts | 19 +++ .../src/__tests__/schema-shape.test.ts | 1 + packages/storage/src/json/json-config.ts | 152 ++++++++++++------ packages/storage/src/schema-keys.ts | 1 + packages/storage/src/types.ts | 2 + .../src/__tests__/settings-scene.test.ts | 53 ++++++ .../src/scenes/settings/settings-scene.ts | 21 ++- 12 files changed, 344 insertions(+), 77 deletions(-) create mode 100644 packages/terminal/src/__tests__/settings-scene.test.ts diff --git a/apps/cli/src/__tests__/config-command.test.ts b/apps/cli/src/__tests__/config-command.test.ts index db57b58..c71caa1 100644 --- a/apps/cli/src/__tests__/config-command.test.ts +++ b/apps/cli/src/__tests__/config-command.test.ts @@ -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("zh-Hant"); + }, 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,14 @@ describe("config command", () => { expect(getResult.stdout.trim()).toBe("yarrow"); }, 20_000); + test("set language en persists, reload reads en", async () => { + const setResult = await runCli(dataDir, ["config", "set", "language", "en"]); + expect(setResult.exitCode).toBe(0); + const getResult = await runCli(dataDir, ["config", "get", "language"]); + expect(getResult.exitCode).toBe(0); + expect(getResult.stdout.trim()).toBe("en"); + }, 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,6 +151,12 @@ 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); diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index edf98f8..6903b1c 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -3,19 +3,125 @@ import { resolvePaths, JsonConfigStore } 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; + 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 = ["zh-Hans", "zh-Hant", "en"] 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 (简, 繁, or English)", + 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 { + return key in CONFIG_SCHEMA; +} export function registerConfigCommand(program: Command): void { const config = program @@ -37,7 +143,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 +163,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 { @@ -83,7 +189,7 @@ 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); } @@ -95,9 +201,10 @@ export function registerConfigCommand(program: Command): void { 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; + if (!schema.set(cfg, value)) { + console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values?.join(", ") ?? "any string"}`); + process.exit(1); + } await store.save(cfg); if (globalOpts.json) { diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 9b7f459..b541ff3 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -150,6 +150,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,6 +163,7 @@ 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; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7590b71..5af84e8 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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c62762b..d23556e 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 = "zh-Hans" | "zh-Hant" | "en"; + /** Hexagram entry in GUA array */ export interface Hexagram { u: string; // Unicode symbol diff --git a/packages/storage/src/__tests__/config-store.test.ts b/packages/storage/src/__tests__/config-store.test.ts index e5a6949..a4bb101 100644 --- a/packages/storage/src/__tests__/config-store.test.ts +++ b/packages/storage/src/__tests__/config-store.test.ts @@ -19,6 +19,7 @@ describe("JsonConfigStore", () => { expect(config).toEqual({ motion: "default", + language: "zh-Hant", theme: "bone", color: "auto", timezone: "system", @@ -33,6 +34,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 +59,7 @@ describe("JsonConfigStore", () => { const config = await store.load(); expect(config).toEqual({ motion: "deep", + language: "zh-Hant", theme: "bone", color: "auto", timezone: "system", @@ -94,4 +97,20 @@ 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("zh-Hant"); + }); + + 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"); + }); }); 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/json/json-config.ts b/packages/storage/src/json/json-config.ts index dacc076..fb3ea94 100644 --- a/packages/storage/src/json/json-config.ts +++ b/packages/storage/src/json/json-config.ts @@ -3,8 +3,19 @@ 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 = ["zh-Hans", "zh-Hant", "en"] 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: "zh-Hant", theme: "bone", color: "auto", timezone: "system", @@ -24,15 +35,98 @@ 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 are accepted for hand-edited config files; the CLI +// writes stable BCP-47-ish values. +const LANGUAGE_ALIASES: Record = { + "简": "zh-Hans", + "簡": "zh-Hans", + "simplified": "zh-Hans", + "繁": "zh-Hant", + "traditional": "zh-Hant", + "EN": "en", + "english": "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 (isOneOf(LANGUAGE_OPTIONS, rawLanguage)) { + merged.language = rawLanguage; + } else if (rawLanguage && LANGUAGE_ALIASES[rawLanguage]) { + merged.language = LANGUAGE_ALIASES[rawLanguage]; + } + + const rawTheme = stringValue(parsed, "theme"); + if (isOneOf(THEME_OPTIONS, rawTheme)) { + merged.theme = rawTheme; + } else if (rawTheme && 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 = LEGACY_CAST_MODE[rawCastMode]; + 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 = LEGACY_CAST_MODE[rawCastMode]; + if (split) { + merged.castMethod = split.method; + merged.castMode = split.mode; + } + } + } + + return merged; +} export class JsonConfigStore implements ConfigStore { constructor(private readonly path: string) {} @@ -40,54 +134,8 @@ export class JsonConfigStore implements ConfigStore { async load(): Promise { 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; + const parsed: unknown = JSON.parse(raw); + return normalizeConfig(parsed); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return { ...DEFAULT_CONFIG }; 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__/settings-scene.test.ts b/packages/terminal/src/__tests__/settings-scene.test.ts new file mode 100644 index 0000000..abd4ab5 --- /dev/null +++ b/packages/terminal/src/__tests__/settings-scene.test.ts @@ -0,0 +1,53 @@ +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" = "zh-Hant"): 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("简"); + expect(text).toContain("繁"); + 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("en"); + }); +}); diff --git a/packages/terminal/src/scenes/settings/settings-scene.ts b/packages/terminal/src/scenes/settings/settings-scene.ts index cd302b9..a56e20d 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"; @@ -22,6 +22,12 @@ import { LINE_WIDTH } from "../../glyphs.ts"; const ANIM_OPTIONS: GlyphAnimStyle[] = ["dots", "noise", "radial", "sand"]; const FONT_OPTIONS: GlyphFont[] = ["kaiti", "libian", "heiti"]; +const LANGUAGE_OPTIONS: DisplayLanguage[] = ["zh-Hans", "zh-Hant", "en"]; +const LANGUAGE_LABELS: Record = { + "zh-Hans": "简", + "zh-Hant": "繁", + en: "EN", +}; const TAIJITU_OPTIONS: TaijituStyle[] = ["dots", "dense"]; const CAST_METHOD_OPTIONS = ["coin", "yarrow"] as const; const CAST_MODE_OPTIONS = ["auto", "manual"] as const; @@ -34,6 +40,7 @@ interface SettingRow { export interface SettingsValues { theme: ThemeName; + language: DisplayLanguage; glyphAnim: GlyphAnimStyle; glyphFont: GlyphFont; taijituStyle: TaijituStyle; @@ -94,6 +101,7 @@ export class SettingsScene implements Scene { this.values = { ...initial }; this.rows = [ { label: "Theme", options: [...THEME_NAMES], selected: Math.max(0, THEME_NAMES.indexOf(initial.theme)) }, + { label: "Language", options: LANGUAGE_OPTIONS.map((lang) => LANGUAGE_LABELS[lang]), selected: Math.max(0, LANGUAGE_OPTIONS.indexOf(initial.language)) }, { 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)) }, @@ -106,11 +114,12 @@ export class SettingsScene implements Scene { 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", + language: LANGUAGE_OPTIONS[this.rows[1].selected] ?? "zh-Hant", + taijituStyle: TAIJITU_OPTIONS[this.rows[2].selected] ?? "dots", + glyphAnim: ANIM_OPTIONS[this.rows[3].selected] ?? "dots", + glyphFont: FONT_OPTIONS[this.rows[4].selected] ?? "kaiti", + castMethod: CAST_METHOD_OPTIONS[this.rows[5].selected] ?? "coin", + castMode: CAST_MODE_OPTIONS[this.rows[6].selected] ?? "auto", }; } From c3a80495fc617b1384506cca8eadb3452df938df Mon Sep 17 00:00:00 2001 From: provi Date: Mon, 1 Jun 2026 23:23:18 -0700 Subject: [PATCH 02/26] feat(terminal): apply language setting to detail view Use the persisted display language when constructing hexagram detail scenes. Chinese modes now render Chinese commentary and line texts without English translations; English mode renders English sections and line translations without the Chinese commentary block. Wire the saved preference through interactive, journal, post-cast, and standalone dict navigation. --- apps/cli/src/__tests__/reading-flow.test.ts | 1 + apps/cli/src/app/reading-flow.ts | 9 +- apps/cli/src/app/scene-factories.ts | 5 +- apps/cli/src/commands/dict.ts | 5 +- apps/cli/src/main.ts | 6 +- .../src/__tests__/detail-renderer.test.ts | 45 ++++ .../src/scenes/dict/detail-renderer.ts | 223 +++++++++++++++--- .../terminal/src/scenes/dict/detail-scene.ts | 16 +- 8 files changed, 264 insertions(+), 46 deletions(-) create mode 100644 packages/terminal/src/__tests__/detail-renderer.test.ts 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..26e7cc6 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; } @@ -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..88023e1 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), ); diff --git a/apps/cli/src/commands/dict.ts b/apps/cli/src/commands/dict.ts index b70972d..9d8f35b 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,9 @@ export function registerDictCommand(program: Command): void { const paths = resolvePaths( globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, ); + const config = await new JsonConfigStore(paths.config).load(); const journal = new JsonlJournalStore(paths.state); - const factoryDeps = { journal }; + const factoryDeps = { journal, language: config.language }; // Determine initial scene let initial; diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index b541ff3..da9320a 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -51,6 +51,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(); @@ -85,6 +86,7 @@ async function main() { paths, cacheStore, today, session: { cols: session.cols, rows: session.rows }, glyphConfig, + language, motion: savedConfig.motion ?? "default", }; @@ -122,7 +124,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 +138,7 @@ async function main() { new JournalScene(entries), makeJournalFactory({ glyphConfig, + language, journal, entries, session: { cols: session.cols, rows: session.rows }, @@ -171,6 +174,7 @@ async function main() { newConfig.castMode = updated.castMode; await configStore.save(newConfig); setTheme(updated.theme); + language = updated.language; taijituStyle = updated.taijituStyle; castMethod = updated.castMethod; castMode = updated.castMode; 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/scenes/dict/detail-renderer.ts b/packages/terminal/src/scenes/dict/detail-renderer.ts index bd46833..b36139f 100644 --- a/packages/terminal/src/scenes/dict/detail-renderer.ts +++ b/packages/terminal/src/scenes/dict/detail-renderer.ts @@ -3,6 +3,7 @@ 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 { getTheme } from "../../color/theme.ts"; import { stringWidth, centerPad } from "../../layout/measure.ts"; import { wordWrap } from "./word-wrap.ts"; @@ -19,12 +20,153 @@ export interface ContentLine { dim?: boolean; } +export interface DetailRenderOptions { + language?: DisplayLanguage; +} + +const DEFAULT_LANGUAGE: DisplayLanguage = "zh-Hant"; + +const SIMPLIFIED_CHARS: Record = { + "兌": "兑", + "剛": "刚", + "剝": "剥", + "勞": "劳", + "勝": "胜", + "勢": "势", + "化": "化", + "卽": "即", + "厲": "厉", + "嘆": "叹", + "喪": "丧", + "嚴": "严", + "壯": "壮", + "復": "复", + "恆": "恒", + "懼": "惧", + "應": "应", + "損": "损", + "敗": "败", + "斷": "断", + "旣": "既", + "時": "时", + "會": "会", + "極": "极", + "樂": "乐", + "傳": "传", + "樹": "树", + "歸": "归", + "殘": "残", + "沒": "没", + "澤": "泽", + "災": "灾", + "爲": "为", + "牽": "牵", + "獲": "获", + "當": "当", + "發": "发", + "盜": "盗", + "對": "对", + "矇": "蒙", + "禍": "祸", + "節": "节", + "終": "终", + "結": "结", + "維": "维", + "縣": "县", + "綜": "综", + "羅": "罗", + "義": "义", + "羣": "群", + "聽": "听", + "與": "与", + "處": "处", + "虛": "虚", + "號": "号", + "蠱": "蛊", + "衆": "众", + "裏": "里", + "見": "见", + "觀": "观", + "記": "记", + "訟": "讼", + "貞": "贞", + "貫": "贯", + "賁": "贲", + "趨": "趋", + "跡": "迹", + "輔": "辅", + "辭": "辞", + "過": "过", + "進": "进", + "遠": "远", + "違": "违", + "遯": "遁", + "適": "适", + "錯": "错", + "鎖": "锁", + "雜": "杂", + "離": "离", + "難": "难", + "電": "电", + "靈": "灵", + "順": "顺", + "頤": "颐", + "風": "风", + "飛": "飞", + "餘": "余", + "驚": "惊", + "體": "体", + "魚": "鱼", + "鳥": "鸟", + "麗": "丽", + "麤": "粗", + "龍": "龙", + "龜": "龟", +}; + +const TRIGRAM_IMAGE_ZH: Record = { + "乾": "天", + "坤": "地", + "震": "雷", + "坎": "水", + "艮": "山", + "巽": "風", + "離": "火", + "兌": "澤", +}; + +function activeLanguage(options?: DetailRenderOptions): DisplayLanguage { + return options?.language ?? DEFAULT_LANGUAGE; +} + +function zh(text: string, language: DisplayLanguage): string { + if (language !== "zh-Hans") return text; + return Array.from(text, (ch) => SIMPLIFIED_CHARS[ch] ?? ch).join(""); +} + +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 +179,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 +207,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 +221,20 @@ 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", 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 +242,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 +264,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 +281,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 +298,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 +312,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; diff --git a/packages/terminal/src/scenes/dict/detail-scene.ts b/packages/terminal/src/scenes/dict/detail-scene.ts index 773efee..bce1265 100644 --- a/packages/terminal/src/scenes/dict/detail-scene.ts +++ b/packages/terminal/src/scenes/dict/detail-scene.ts @@ -3,7 +3,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 { GlyphFont } from "@iching/core"; +import type { DisplayLanguage, GlyphFont } 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 +21,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 = "zh-Hant", + ) { this.model = new DetailModel(kw); this.glyphConfig = glyphConfig; + this.language = language; } enter(ctx: SceneContext): void { @@ -50,7 +56,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 +72,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 { From 2e89bd9ef7cb48896805ec5df7708b3a6e4cc099 Mon Sep 17 00:00:00 2001 From: provi Date: Mon, 1 Jun 2026 23:52:49 -0700 Subject: [PATCH 03/26] feat(config): default language to English Make English the default display language and present language choices in EN, traditional Chinese, simplified Chinese order across config and terminal settings. --- apps/cli/src/__tests__/config-command.test.ts | 2 +- apps/cli/src/commands/config.ts | 4 ++-- packages/core/src/types.ts | 2 +- packages/storage/src/__tests__/config-store.test.ts | 6 +++--- packages/storage/src/json/json-config.ts | 4 ++-- packages/terminal/src/__tests__/settings-scene.test.ts | 8 +++----- packages/terminal/src/scenes/dict/detail-renderer.ts | 2 +- packages/terminal/src/scenes/dict/detail-scene.ts | 2 +- packages/terminal/src/scenes/settings/settings-scene.ts | 6 +++--- 9 files changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/__tests__/config-command.test.ts b/apps/cli/src/__tests__/config-command.test.ts index c71caa1..431c4f4 100644 --- a/apps/cli/src/__tests__/config-command.test.ts +++ b/apps/cli/src/__tests__/config-command.test.ts @@ -82,7 +82,7 @@ describe("config command", () => { 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("zh-Hant"); + expect(stdout.trim()).toBe("en"); }, 20_000); test("get taijituStyle returns the default value", async () => { diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index 6903b1c..1cd7ba7 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -19,7 +19,7 @@ function isOneOf( 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 = ["zh-Hans", "zh-Hant", "en"] 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; @@ -47,7 +47,7 @@ const CONFIG_SCHEMA: Record = { }, language: { values: LANGUAGE_VALUES, - description: "Display language (简, 繁, or English)", + description: "Display language (English, 繁, or 简)", set: (cfg, value) => { if (!isOneOf(LANGUAGE_VALUES, value)) return false; cfg.language = value; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d23556e..eb3e589 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,7 +9,7 @@ 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 = "zh-Hans" | "zh-Hant" | "en"; +export type DisplayLanguage = "en" | "zh-Hant" | "zh-Hans"; /** Hexagram entry in GUA array */ export interface Hexagram { diff --git a/packages/storage/src/__tests__/config-store.test.ts b/packages/storage/src/__tests__/config-store.test.ts index a4bb101..35612a9 100644 --- a/packages/storage/src/__tests__/config-store.test.ts +++ b/packages/storage/src/__tests__/config-store.test.ts @@ -19,7 +19,7 @@ describe("JsonConfigStore", () => { expect(config).toEqual({ motion: "default", - language: "zh-Hant", + language: "en", theme: "bone", color: "auto", timezone: "system", @@ -59,7 +59,7 @@ describe("JsonConfigStore", () => { const config = await store.load(); expect(config).toEqual({ motion: "deep", - language: "zh-Hant", + language: "en", theme: "bone", color: "auto", timezone: "system", @@ -101,7 +101,7 @@ describe("JsonConfigStore", () => { 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("zh-Hant"); + expect(loaded.language).toBe("en"); }); test("load accepts display language aliases from hand-edited config", async () => { diff --git a/packages/storage/src/json/json-config.ts b/packages/storage/src/json/json-config.ts index fb3ea94..ea5a20c 100644 --- a/packages/storage/src/json/json-config.ts +++ b/packages/storage/src/json/json-config.ts @@ -4,7 +4,7 @@ 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 = ["zh-Hans", "zh-Hant", "en"] 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; @@ -15,7 +15,7 @@ const CAST_MODE_OPTIONS = ["auto", "manual"] as const; const DEFAULT_CONFIG: UserConfig = { motion: "default", - language: "zh-Hant", + language: "en", theme: "bone", color: "auto", timezone: "system", diff --git a/packages/terminal/src/__tests__/settings-scene.test.ts b/packages/terminal/src/__tests__/settings-scene.test.ts index abd4ab5..544573f 100644 --- a/packages/terminal/src/__tests__/settings-scene.test.ts +++ b/packages/terminal/src/__tests__/settings-scene.test.ts @@ -7,7 +7,7 @@ function makeCtx(cols = 80, rows = 24): SceneContext { return { cols, rows, done: false, colorSupport: "none" }; } -function makeScene(language: "zh-Hans" | "zh-Hant" | "en" = "zh-Hant"): SettingsScene { +function makeScene(language: "zh-Hans" | "zh-Hant" | "en" = "en"): SettingsScene { return new SettingsScene({ theme: "bone", language, @@ -38,9 +38,7 @@ describe("SettingsScene language", () => { scene.render(buf, ctx); const text = bufferText(buf); expect(text).toContain("Language"); - expect(text).toContain("简"); - expect(text).toContain("繁"); - expect(text).toContain("EN"); + expect(text).toContain("[EN] 繁 简"); }); test("left/right changes the language row", () => { @@ -48,6 +46,6 @@ describe("SettingsScene language", () => { const ctx = makeCtx(); scene.handleKey({ type: "arrow", direction: "down" }, ctx); scene.handleKey({ type: "arrow", direction: "right" }, ctx); - expect(scene.getValues().language).toBe("en"); + expect(scene.getValues().language).toBe("zh-Hans"); }); }); diff --git a/packages/terminal/src/scenes/dict/detail-renderer.ts b/packages/terminal/src/scenes/dict/detail-renderer.ts index b36139f..63c3931 100644 --- a/packages/terminal/src/scenes/dict/detail-renderer.ts +++ b/packages/terminal/src/scenes/dict/detail-renderer.ts @@ -24,7 +24,7 @@ export interface DetailRenderOptions { language?: DisplayLanguage; } -const DEFAULT_LANGUAGE: DisplayLanguage = "zh-Hant"; +const DEFAULT_LANGUAGE: DisplayLanguage = "en"; const SIMPLIFIED_CHARS: Record = { "兌": "兑", diff --git a/packages/terminal/src/scenes/dict/detail-scene.ts b/packages/terminal/src/scenes/dict/detail-scene.ts index bce1265..25be8c6 100644 --- a/packages/terminal/src/scenes/dict/detail-scene.ts +++ b/packages/terminal/src/scenes/dict/detail-scene.ts @@ -26,7 +26,7 @@ export class DetailScene implements Scene { constructor( kw: number, glyphConfig?: DetailGlyphConfig, - language: DisplayLanguage = "zh-Hant", + language: DisplayLanguage = "en", ) { this.model = new DetailModel(kw); this.glyphConfig = glyphConfig; diff --git a/packages/terminal/src/scenes/settings/settings-scene.ts b/packages/terminal/src/scenes/settings/settings-scene.ts index a56e20d..fb13dd9 100644 --- a/packages/terminal/src/scenes/settings/settings-scene.ts +++ b/packages/terminal/src/scenes/settings/settings-scene.ts @@ -22,11 +22,11 @@ import { LINE_WIDTH } from "../../glyphs.ts"; const ANIM_OPTIONS: GlyphAnimStyle[] = ["dots", "noise", "radial", "sand"]; const FONT_OPTIONS: GlyphFont[] = ["kaiti", "libian", "heiti"]; -const LANGUAGE_OPTIONS: DisplayLanguage[] = ["zh-Hans", "zh-Hant", "en"]; +const LANGUAGE_OPTIONS: DisplayLanguage[] = ["en", "zh-Hant", "zh-Hans"]; const LANGUAGE_LABELS: Record = { + en: "EN", "zh-Hans": "简", "zh-Hant": "繁", - en: "EN", }; const TAIJITU_OPTIONS: TaijituStyle[] = ["dots", "dense"]; const CAST_METHOD_OPTIONS = ["coin", "yarrow"] as const; @@ -114,7 +114,7 @@ export class SettingsScene implements Scene { getValues(): SettingsValues { return { theme: THEME_NAMES[this.rows[0].selected] ?? "bone", - language: LANGUAGE_OPTIONS[this.rows[1].selected] ?? "zh-Hant", + language: LANGUAGE_OPTIONS[this.rows[1].selected] ?? "en", taijituStyle: TAIJITU_OPTIONS[this.rows[2].selected] ?? "dots", glyphAnim: ANIM_OPTIONS[this.rows[3].selected] ?? "dots", glyphFont: FONT_OPTIONS[this.rows[4].selected] ?? "kaiti", From f975a881b6667f3df307d7e25c3f6691f74132df Mon Sep 17 00:00:00 2001 From: provi Date: Tue, 2 Jun 2026 23:57:10 -0700 Subject: [PATCH 04/26] =?UTF-8?q?feat(i18n):=20localize=20TUI=20+=20CLI=20?= =?UTF-8?q?across=20EN=20/=20=E7=B9=81=20/=20=E7=AE=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full display-language pass over the app: a terminal message catalog, an audited Traditional→Simplified conversion in core, language threading through every scene, and a deterministic verifier oracle. Core: - packages/core/src/i18n/simplify.ts — audited 314-entry T→S table + toSimplified() (1:1 single-codepoint, idempotent, 乾 stays 乾 not 干); exported from @iching/core. Terminal: - i18n/messages.ts catalog + tr(language, key); MessageKey = keyof typeof MESSAGES, shape enforced via `satisfies Record`. - DisplayLanguage threaded via SceneContext + yarrow constructors; all scenes localized (home/cast/reveal/browse/detail/journal/intention/toss/settings/yarrow + footers); hexagram names converted for 简, pinyin invariant, no bilingual stacking. CLI: - dict command threads config.language into the browse router. Tooling / docs: - scripts/verify-language-surfaces.ts — 10-mode oracle (inventory/policy/glossary/ simplified/terminal/cli/core-data/consults/self-test/run-all), each proven red→green, incl. name-rendering, footer-leak, and map-integrity guards. - docs/language-glossary.md — source-of-truth term/exception policy. - gitignore parked corpus-acquisition residue (data-acquisition/, scripts/data-acquisition/). Verified in one repo state: typecheck + 513 tests + bundle + verifier run-all all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + apps/cli/src/app/reading-flow.ts | 4 +- apps/cli/src/commands/dict.ts | 4 +- apps/cli/src/main.ts | 4 +- docs/language-glossary.md | 159 +++ packages/core/src/data/gua.ts | 38 +- packages/core/src/i18n/simplify.ts | 86 ++ packages/core/src/index.ts | 3 + .../src/__tests__/scene-language.test.ts | 342 ++++++ packages/terminal/src/i18n/messages.ts | 113 ++ packages/terminal/src/scene/loop.ts | 3 + packages/terminal/src/scene/router.ts | 4 +- packages/terminal/src/scene/types.ts | 8 +- .../terminal/src/scenes/cast/cast-scene.ts | 21 +- .../src/scenes/cast/reveal-renderer.ts | 34 +- .../terminal/src/scenes/cast/ritual-chrome.ts | 7 +- .../src/scenes/dict/browse-renderer.ts | 36 +- .../src/scenes/dict/detail-renderer.ts | 115 +- .../terminal/src/scenes/home/home-scene.ts | 32 +- .../src/scenes/intention/intention-scene.ts | 8 +- .../src/scenes/journal/journal-scene.ts | 23 +- .../src/scenes/settings/settings-scene.ts | 49 +- .../terminal/src/scenes/toss/toss-scene.ts | 10 +- .../src/scenes/yarrow/field-renderer.ts | 27 +- .../src/scenes/yarrow/yarrow-manual-scene.ts | 25 +- .../src/scenes/yarrow/yarrow-scene.ts | 22 +- .../src/scenes/yarrow/yarrow-timeline.ts | 49 +- scripts/verify-language-surfaces.ts | 1089 +++++++++++++++++ 28 files changed, 2051 insertions(+), 268 deletions(-) create mode 100644 docs/language-glossary.md create mode 100644 packages/core/src/i18n/simplify.ts create mode 100644 packages/terminal/src/__tests__/scene-language.test.ts create mode 100644 packages/terminal/src/i18n/messages.ts create mode 100644 scripts/verify-language-surfaces.ts 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/app/reading-flow.ts b/apps/cli/src/app/reading-flow.ts index 26e7cc6..5cfbe8c 100644 --- a/apps/cli/src/app/reading-flow.ts +++ b/apps/cli/src/app/reading-flow.ts @@ -98,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 diff --git a/apps/cli/src/commands/dict.ts b/apps/cli/src/commands/dict.ts index 9d8f35b..ee075bf 100644 --- a/apps/cli/src/commands/dict.ts +++ b/apps/cli/src/commands/dict.ts @@ -43,7 +43,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 da9320a..17d4334 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -59,9 +59,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; diff --git a/docs/language-glossary.md b/docs/language-glossary.md new file mode 100644 index 0000000..229130f --- /dev/null +++ b/docs/language-glossary.md @@ -0,0 +1,159 @@ +# 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). + +## 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). diff --git a/packages/core/src/data/gua.ts b/packages/core/src/data/gua.ts index 8823ad7..4c5e959 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: [ "初九:遇其配主,雖旬無咎,往有尚。", "六二:豐其蔀,日中見斗,往得疑疾。有孚發若,吉。", @@ -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/i18n/simplify.ts b/packages/core/src/i18n/simplify.ts new file mode 100644 index 0000000..7930f96 --- /dev/null +++ b/packages/core/src/i18n/simplify.ts @@ -0,0 +1,86 @@ +// Traditional -> 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 藉). +// +// 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 (context exceptions). */ +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 5af84e8..7603961 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,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/terminal/src/__tests__/scene-language.test.ts b/packages/terminal/src/__tests__/scene-language.test.ts new file mode 100644 index 0000000..61ae2e4 --- /dev/null +++ b/packages/terminal/src/__tests__/scene-language.test.ts @@ -0,0 +1,342 @@ +// 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 { 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 badges remain verbatim (canonical anchors per glossary) + expect(text).toContain("繁"); + }); + + 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("简"); + }); +}); + +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 + }); +}); diff --git a/packages/terminal/src/i18n/messages.ts b/packages/terminal/src/i18n/messages.ts new file mode 100644 index 0000000..cefccb4 --- /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"; + +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/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..f137d22 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; @@ -81,8 +83,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 +122,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 +136,7 @@ export class CastScene implements Scene { // Render prompt if (model.showPrompt) { - renderPrompt(frame, model); + renderPrompt(frame, model, lang); } } @@ -263,15 +266,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/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/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-renderer.ts b/packages/terminal/src/scenes/dict/detail-renderer.ts index 63c3931..8be6301 100644 --- a/packages/terminal/src/scenes/dict/detail-renderer.ts +++ b/packages/terminal/src/scenes/dict/detail-renderer.ts @@ -4,10 +4,12 @@ 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"; const FOOTER_ROWS = 2; const PADDING = 2; @@ -26,104 +28,6 @@ export interface DetailRenderOptions { const DEFAULT_LANGUAGE: DisplayLanguage = "en"; -const SIMPLIFIED_CHARS: Record = { - "兌": "兑", - "剛": "刚", - "剝": "剥", - "勞": "劳", - "勝": "胜", - "勢": "势", - "化": "化", - "卽": "即", - "厲": "厉", - "嘆": "叹", - "喪": "丧", - "嚴": "严", - "壯": "壮", - "復": "复", - "恆": "恒", - "懼": "惧", - "應": "应", - "損": "损", - "敗": "败", - "斷": "断", - "旣": "既", - "時": "时", - "會": "会", - "極": "极", - "樂": "乐", - "傳": "传", - "樹": "树", - "歸": "归", - "殘": "残", - "沒": "没", - "澤": "泽", - "災": "灾", - "爲": "为", - "牽": "牵", - "獲": "获", - "當": "当", - "發": "发", - "盜": "盗", - "對": "对", - "矇": "蒙", - "禍": "祸", - "節": "节", - "終": "终", - "結": "结", - "維": "维", - "縣": "县", - "綜": "综", - "羅": "罗", - "義": "义", - "羣": "群", - "聽": "听", - "與": "与", - "處": "处", - "虛": "虚", - "號": "号", - "蠱": "蛊", - "衆": "众", - "裏": "里", - "見": "见", - "觀": "观", - "記": "记", - "訟": "讼", - "貞": "贞", - "貫": "贯", - "賁": "贲", - "趨": "趋", - "跡": "迹", - "輔": "辅", - "辭": "辞", - "過": "过", - "進": "进", - "遠": "远", - "違": "违", - "遯": "遁", - "適": "适", - "錯": "错", - "鎖": "锁", - "雜": "杂", - "離": "离", - "難": "难", - "電": "电", - "靈": "灵", - "順": "顺", - "頤": "颐", - "風": "风", - "飛": "飞", - "餘": "余", - "驚": "惊", - "體": "体", - "魚": "鱼", - "鳥": "鸟", - "麗": "丽", - "麤": "粗", - "龍": "龙", - "龜": "龟", -}; - const TRIGRAM_IMAGE_ZH: Record = { "乾": "天", "坤": "地", @@ -140,8 +44,10 @@ function activeLanguage(options?: DetailRenderOptions): DisplayLanguage { } 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 Array.from(text, (ch) => SIMPLIFIED_CHARS[ch] ?? ch).join(""); + return toSimplified(text); } function pushWrapped( @@ -225,7 +131,9 @@ export function buildContentLines( ? [ ["Image", gua.en], ["Judgment", gua.te], - ["Wilhelm", gua.w], + // "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)], @@ -373,13 +281,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; @@ -389,8 +298,8 @@ 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")}`; const indicator = model.contentHeight > model.viewportHeight 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..5ccbfe3 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 }); } diff --git a/packages/terminal/src/scenes/settings/settings-scene.ts b/packages/terminal/src/scenes/settings/settings-scene.ts index fb13dd9..09e38ad 100644 --- a/packages/terminal/src/scenes/settings/settings-scene.ts +++ b/packages/terminal/src/scenes/settings/settings-scene.ts @@ -17,6 +17,7 @@ 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"; // ── Setting definitions ────────────────────────────────────────────── @@ -33,7 +34,8 @@ const CAST_METHOD_OPTIONS = ["coin", "yarrow"] as const; const CAST_MODE_OPTIONS = ["auto", "manual"] as const; interface SettingRow { - label: string; + /** Stable message-catalog key; the visible label is localized at render time. */ + key: MessageKey; options: string[]; selected: number; } @@ -100,15 +102,15 @@ 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: "Language", options: LANGUAGE_OPTIONS.map((lang) => LANGUAGE_LABELS[lang]), selected: Math.max(0, LANGUAGE_OPTIONS.indexOf(initial.language)) }, - { 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", options: [...THEME_NAMES], selected: Math.max(0, THEME_NAMES.indexOf(initial.theme)) }, + { key: "settings.language", options: LANGUAGE_OPTIONS.map((lang) => LANGUAGE_LABELS[lang]), selected: Math.max(0, LANGUAGE_OPTIONS.indexOf(initial.language)) }, + { key: "settings.taijitu", options: [...TAIJITU_OPTIONS], selected: Math.max(0, TAIJITU_OPTIONS.indexOf(initial.taijituStyle)) }, + { key: "settings.glyphAnimation", options: [...ANIM_OPTIONS], selected: Math.max(0, ANIM_OPTIONS.indexOf(initial.glyphAnim)) }, + { key: "settings.font", options: [...FONT_OPTIONS], selected: Math.max(0, FONT_OPTIONS.indexOf(initial.glyphFont)) }, + { key: "settings.castMethod", options: [...CAST_METHOD_OPTIONS], selected: Math.max(0, CAST_METHOD_OPTIONS.indexOf(initial.castMethod)) }, + { key: "settings.castMode", options: [...CAST_MODE_OPTIONS], selected: Math.max(0, CAST_MODE_OPTIONS.indexOf(initial.castMode)) }, ]; - this.previewKind = this.previewKindForLabel(this.rows[0]?.label); + this.previewKind = this.previewKindForKey(this.rows[0]?.key); } getValues(): SettingsValues { @@ -182,7 +184,8 @@ export class SettingsScene implements Scene { let row = 2; // Title - const title = "Settings"; + const lang = this.values.language; + const title = tr(lang, "settings.title"); frame.writeText(row, cx - Math.floor(stringWidth(title) / 2), title, { fg: t.primary, bold: true }); row += 1; @@ -209,7 +212,7 @@ export class SettingsScene implements Scene { const setting = this.rows[i]; const focused = i === this.focusedRow; - frame.writeText(row, left, setting.label, { + frame.writeText(row, left, tr(lang, setting.key), { fg: focused ? t.primary : t.secondary, bold: focused, }); @@ -239,7 +242,7 @@ export class SettingsScene implements Scene { // 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; @@ -249,7 +252,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); } @@ -385,9 +388,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"; @@ -406,22 +409,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..d701de1 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) { @@ -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/scripts/verify-language-surfaces.ts b/scripts/verify-language-surfaces.ts new file mode 100644 index 0000000..d2d63d5 --- /dev/null +++ b/scripts/verify-language-surfaces.ts @@ -0,0 +1,1089 @@ +#!/usr/bin/env bun +/** + * Language-surface verifier — the deterministic oracle for the I Ching language + * translation loop (.loop/language/). + * + * 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: .loop/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") ?? ".loop/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"], + ["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"); + } + // 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(".loop/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); + } +}); From 0b61d8f88ffac745334a546233b07efa28ffadb2 Mon Sep 17 00:00:00 2001 From: provi Date: Thu, 4 Jun 2026 00:48:05 -0700 Subject: [PATCH 05/26] =?UTF-8?q?fix(corpus):=20correct=20received-text=20?= =?UTF-8?q?glyphs=20and=20=E7=A5=90=20over-simplification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent accuracy pass (OpenCC t2s + per-hexagram ctext source diff + structural invariants) over the EN / 繁 / 简 corpus: - 中孚 九二: 鶴鳴 → 鳴鶴 (在陰) — transposition vs the received text quoted verbatim in the 繫辭 - 巽 九二/上九: 牀 → 床 — match the ctext source + hex 23 剝; resolves an internal orthographic inconsistency - simplify: drop 祐→佑 — 祐 (自天祐之, hex 14) has no official simplification; 示→亻 to 佑 is non-standard and degrades the divine-blessing sense - Ext-B retention: 纆/餗/繻 added to SIMPLIFIED_EXCEPTIONS — their only standard simplification is a tofu CJK Ext-B glyph (𬙊/𫗧/𦈡); kept Traditional so the mapped-or-excepted coverage invariant stays honest Structural invariants, UI-message simplification (every zhHans == OpenCC t2s), all verifier modes, typecheck, and 517 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/language-glossary.md | 20 ++++++++++++++++++++ packages/core/src/data/gua.ts | 6 +++--- packages/core/src/i18n/simplify.ts | 18 +++++++++++++++--- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/language-glossary.md b/docs/language-glossary.md index 229130f..fe401e0 100644 --- a/docs/language-glossary.md +++ b/docs/language-glossary.md @@ -157,3 +157,23 @@ GPT-5 Pro adversarially attacked simplify.ts / messages.ts / this glossary. Tria 卷动/开启): 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 床). +- **simplification fix:** dropped `祐→佑` — 祐 has no official simplification; 自天祐之 + (hex 14) keeps 祐 in both scripts (示 divine-blessing sense, not 亻 human-help 佑). +- **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/packages/core/src/data/gua.ts b/packages/core/src/data/gua.ts index 4c5e959..a0d6037 100644 --- a/packages/core/src/data/gua.ts +++ b/packages/core/src/data/gua.ts @@ -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: [ "初九:虞吉,有他不燕。", - "九二:鶴鳴在陰,其子和之。我有好爵,吾與爾靡之。", + "九二:鳴鶴在陰,其子和之。我有好爵,吾與爾靡之。", "六三:得敵,或鼓或罷,或泣或歌。", "六四:月幾望,馬匹亡,無咎。", "九五:有孚攣如,無咎。", diff --git a/packages/core/src/i18n/simplify.ts b/packages/core/src/i18n/simplify.ts index 7930f96..8a409bc 100644 --- a/packages/core/src/i18n/simplify.ts +++ b/packages/core/src/i18n/simplify.ts @@ -12,13 +12,25 @@ // - 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 NOT mapped: it has no official simplification +// (示->亻 to 佑 would be non-standard and degrade the divine-blessing sense); +// it passes through unchanged in both scripts. // // 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 (context exceptions). */ -export const SIMPLIFIED_EXCEPTIONS: readonly string[] = ["乾"]; +/** + * 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> = { @@ -38,7 +50,7 @@ export const SIMPLIFIED_MAP: Readonly> = { 渙: "涣", 滅: "灭", 漣: "涟", 漸: "渐", 潛: "潜", 澤: "泽", 濟: "济", 災: "灾", 為: "为", 無: "无", 爾: "尔", 牀: "床", 牽: "牵", 獄: "狱", 獨: "独", 獲: "获", 瑣: "琐", 甕: "瓮", 異: "异", 當: "当", 疇: "畴", 發: "发", 眾: "众", 碩: "硕", - 祐: "佑", 祿: "禄", 禦: "御", 禮: "礼", 稱: "称", 穀: "谷", 積: "积", 窮: "穷", + 祿: "禄", 禦: "御", 禮: "礼", 稱: "称", 穀: "谷", 積: "积", 窮: "穷", 窺: "窥", 節: "节", 篤: "笃", 約: "约", 納: "纳", 紛: "纷", 紱: "绂", 終: "终", 統: "统", 經: "经", 維: "维", 綸: "纶", 緩: "缓", 繫: "系", 繼: "继", 續: "续", 罰: "罚", 罷: "罢", 義: "义", 習: "习", 聖: "圣", 聞: "闻", 膚: "肤", 臨: "临", From b36c603cb6b52cdc4667cb0a6d61bb1588feddc0 Mon Sep 17 00:00:00 2001 From: provi Date: Thu, 4 Jun 2026 01:11:29 -0700 Subject: [PATCH 06/26] =?UTF-8?q?fix(corpus):=20keep=20audited=20=E7=A5=90?= =?UTF-8?q?=E2=86=92=E4=BD=91=20(second-opinion=20correction)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the 祐→佑 simplification mapping that the prior commit removed. A cross-check (ctext.org + frontier second opinion) confirmed the 中孚 九二 鳴鶴 and 巽 牀→床 received-text fixes are correct, but found the 祐→佑 removal unwarranted: simplified 周易 editions conventionally print 自天佑之, and 祐/佑 are interchangeable for 上天保佑. Consistent with the table's other deliberate convention-over-OpenCC entries (咷→啕, 遯→遁). zh-Hans renders 自天佑之. Verifier modes, typecheck, and 517 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/language-glossary.md | 6 ++++-- packages/core/src/i18n/simplify.ts | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/language-glossary.md b/docs/language-glossary.md index fe401e0..b84cde7 100644 --- a/docs/language-glossary.md +++ b/docs/language-glossary.md @@ -168,8 +168,10 @@ OpenCC `t2s(zhHant)`. - **received-text fixes:** hex 61 中孚 九二 `鶴鳴`→**`鳴鶴`** (在陰 — transposition vs the 繫辭-quoted received text); hex 57 巽 九二/上九 `牀`→**`床`** (source + hex 23 use 床). -- **simplification fix:** dropped `祐→佑` — 祐 has no official simplification; 自天祐之 - (hex 14) keeps 祐 in both scripts (示 divine-blessing sense, not 亻 human-help 佑). +- **`祐→佑` 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. diff --git a/packages/core/src/i18n/simplify.ts b/packages/core/src/i18n/simplify.ts index 8a409bc..18aacea 100644 --- a/packages/core/src/i18n/simplify.ts +++ b/packages/core/src/i18n/simplify.ts @@ -16,9 +16,9 @@ // 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 NOT mapped: it has no official simplification -// (示->亻 to 佑 would be non-standard and degrade the divine-blessing sense); -// it passes through unchanged in both scripts. +// - 祐->佑 (示, 自天祐之 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 @@ -50,7 +50,7 @@ export const SIMPLIFIED_MAP: Readonly> = { 渙: "涣", 滅: "灭", 漣: "涟", 漸: "渐", 潛: "潜", 澤: "泽", 濟: "济", 災: "灾", 為: "为", 無: "无", 爾: "尔", 牀: "床", 牽: "牵", 獄: "狱", 獨: "独", 獲: "获", 瑣: "琐", 甕: "瓮", 異: "异", 當: "当", 疇: "畴", 發: "发", 眾: "众", 碩: "硕", - 祿: "禄", 禦: "御", 禮: "礼", 稱: "称", 穀: "谷", 積: "积", 窮: "穷", + 祐: "佑", 祿: "禄", 禦: "御", 禮: "礼", 稱: "称", 穀: "谷", 積: "积", 窮: "穷", 窺: "窥", 節: "节", 篤: "笃", 約: "约", 納: "纳", 紛: "纷", 紱: "绂", 終: "终", 統: "统", 經: "经", 維: "维", 綸: "纶", 緩: "缓", 繫: "系", 繼: "继", 續: "续", 罰: "罚", 罷: "罢", 義: "义", 習: "习", 聖: "圣", 聞: "闻", 膚: "肤", 臨: "临", From 7935bf5a590385de2f9ae55c1c71eafef320c916 Mon Sep 17 00:00:00 2001 From: provi Date: Thu, 4 Jun 2026 01:22:40 -0700 Subject: [PATCH 07/26] fix(cli): reject inherited prototype names as config keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isConfigKey` used `key in CONFIG_SCHEMA`, which walks the prototype chain — so `config get toString` printed the inherited function and `config set constructor x` crashed ("schema.set is not a function") instead of reporting an unknown-key error. Switch the guard to Object.hasOwn (own-property check) and add regression tests for get/set on inherited names. Addresses Codex P2 review feedback on PR #5. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/__tests__/config-command.test.ts | 18 ++++++++++++++++++ apps/cli/src/commands/config.ts | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/__tests__/config-command.test.ts b/apps/cli/src/__tests__/config-command.test.ts index 431c4f4..2725635 100644 --- a/apps/cli/src/__tests__/config-command.test.ts +++ b/apps/cli/src/__tests__/config-command.test.ts @@ -162,4 +162,22 @@ describe("config command", () => { 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); }); diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index 1cd7ba7..7665e0d 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -120,7 +120,10 @@ const CONFIG_SCHEMA: Record = { const VALID_KEYS = Object.keys(CONFIG_SCHEMA) as Array; function isConfigKey(key: string): key is keyof typeof CONFIG_SCHEMA { - return key in 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 { From 97ff85d05d07b110429e04d366052a15e336ac6d Mon Sep 17 00:00:00 2001 From: provi Date: Thu, 4 Jun 2026 01:29:12 -0700 Subject: [PATCH 08/26] fix(settings): localize the scene from the live language selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the Language row with ←/→ left the settings scene's own title, labels, and footer in the previously-saved language until Escape + reopen, because render() localized from this.values.language (the saved snapshot, refreshed only on save) instead of the live selection. Theme already previews live via onOptionChanged; language now matches — render() reads getValues().language. Adds a live-switch regression test. Addresses Codex P3 review feedback on PR #5. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/scene-language.test.ts | 24 +++++++++++++++++++ .../src/scenes/settings/settings-scene.ts | 7 ++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/terminal/src/__tests__/scene-language.test.ts b/packages/terminal/src/__tests__/scene-language.test.ts index 61ae2e4..ab2eccc 100644 --- a/packages/terminal/src/__tests__/scene-language.test.ts +++ b/packages/terminal/src/__tests__/scene-language.test.ts @@ -87,6 +87,30 @@ describe("SettingsScene — no bilingual stacking", () => { 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 + }); }); describe("HomeScene — no bilingual stacking", () => { diff --git a/packages/terminal/src/scenes/settings/settings-scene.ts b/packages/terminal/src/scenes/settings/settings-scene.ts index 09e38ad..260bdc3 100644 --- a/packages/terminal/src/scenes/settings/settings-scene.ts +++ b/packages/terminal/src/scenes/settings/settings-scene.ts @@ -183,8 +183,11 @@ export class SettingsScene implements Scene { const cx = Math.floor(frame.width / 2); let row = 2; - // Title - const lang = this.values.language; + // 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; From 97f6a87378cec25d10b5d3823ecfa86ee79a21f4 Mon Sep 17 00:00:00 2001 From: provi Date: Thu, 4 Jun 2026 01:40:58 -0700 Subject: [PATCH 09/26] fix(i18n): match simplified names in search; localize manual yarrow fuse caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two localization gaps from Codex review of PR #5: - search (P2): searchHexagrams matched the query only against the Traditional gua.n, so in zh-Hans mode — where the dictionary displays simplified names — typing the displayed name (e.g. 兑) returned zero matches. Now also matches toSimplified(gua.n); 兌/兑 and 觀/观 both resolve. - yarrow (P3): the manual-mode line fuse beat omitted `language`, so the final fuse caption defaulted to English ("Remaining … old yin") while the round beats were localized. Forward this.language, matching the round-beats call. Regression tests added for both. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/__tests__/search.test.ts | 20 +++++++++++++++ packages/core/src/search.ts | 9 ++++++- .../src/__tests__/yarrow-manual-scene.test.ts | 25 +++++++++++++++++++ .../src/scenes/yarrow/yarrow-manual-scene.ts | 2 +- 4 files changed, 54 insertions(+), 2 deletions(-) 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/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/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/scenes/yarrow/yarrow-manual-scene.ts b/packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts index d701de1..21ac8b9 100644 --- a/packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts +++ b/packages/terminal/src/scenes/yarrow/yarrow-manual-scene.ts @@ -210,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)); From a4eb62d78b4c20ef6e9bdf105cb57a40165783e1 Mon Sep 17 00:00:00 2001 From: provi Date: Thu, 4 Jun 2026 02:03:08 -0700 Subject: [PATCH 10/26] feat(i18n): render Simplified hexagram glyphs in zh-Hans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The large braille glyph was composed from the Traditional name even in zh-Hans, so a Traditional 兌 rendered above Simplified 兑 text in cast reveal and dictionary detail (Codex P2 ×2). - generate-glyphs.ts now also rasterizes the Simplified form of each name; the atlas gains 25 chars (讼师谦随蛊临观贲剥复颐过离恒遁壮晋损渐归丰兑涣节济). Regen is deterministic — the 71 existing Traditional glyphs are byte-identical. - cast reveal composes from the Simplified name via CastGlyphConfig.language (threaded through CastScene opts); dictionary detail composes from this.language. Glyph sizing is unchanged (same char count). Regression tests assert the zh-Hans glyph differs from zh-Hant for KW58 (兑/兌) in both the detail and cast paths. Addresses Codex P2 review feedback on PR #5. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/app/reading-flow.ts | 2 +- apps/cli/src/app/scene-factories.ts | 1 + packages/core/src/data/large-glyphs.ts | 3698 ++++++++++++++++- .../src/__tests__/scene-language.test.ts | 50 + .../terminal/src/scenes/cast/cast-scene.ts | 3 +- .../src/scenes/cast/timeline-builder.ts | 8 +- .../terminal/src/scenes/dict/detail-scene.ts | 7 +- scripts/generate-glyphs.ts | 11 +- 8 files changed, 3598 insertions(+), 182 deletions(-) diff --git a/apps/cli/src/app/reading-flow.ts b/apps/cli/src/app/reading-flow.ts index 5cfbe8c..8293395 100644 --- a/apps/cli/src/app/reading-flow.ts +++ b/apps/cli/src/app/reading-flow.ts @@ -154,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); diff --git a/apps/cli/src/app/scene-factories.ts b/apps/cli/src/app/scene-factories.ts index 88023e1..b2f4f3a 100644 --- a/apps/cli/src/app/scene-factories.ts +++ b/apps/cli/src/app/scene-factories.ts @@ -64,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/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 { 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/scenes/cast/cast-scene.ts b/packages/terminal/src/scenes/cast/cast-scene.ts index f137d22..c3c0172 100644 --- a/packages/terminal/src/scenes/cast/cast-scene.ts +++ b/packages/terminal/src/scenes/cast/cast-scene.ts @@ -40,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; @@ -60,6 +60,7 @@ export class CastScene implements Scene { this.glyphConfig = { ...glyphConfig, glyphSize: autoGlyphSize(availRows, termWidth, maxChars), + language: opts?.language, }; } const timing = getPreset(preset); 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/detail-scene.ts b/packages/terminal/src/scenes/dict/detail-scene.ts index 25be8c6..aa82bf9 100644 --- a/packages/terminal/src/scenes/dict/detail-scene.ts +++ b/packages/terminal/src/scenes/dict/detail-scene.ts @@ -4,6 +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 { 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"; @@ -38,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( 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(); From e284c0676f925bd33ccae2355c15ab25245cc875 Mon Sep 17 00:00:00 2001 From: provi Date: Mon, 8 Jun 2026 03:08:10 -0700 Subject: [PATCH 11/26] feat(config): seed display language from the system locale on first boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On first boot (no config file), detect the display language from the POSIX locale environment and persist it, so a non-English user is greeted in their own language. It is a one-time SEED, not a live binding: once the config exists the saved choice is frozen and the locale is never consulted again — a later move to an English shell won't flip it. - detectSystemLanguage(): LC_ALL > LC_MESSAGES > LANG > LANGUAGE; maps zh_CN/zh_SG/zh_MY + bare zh → 简, zh_TW/zh_HK/zh_MO → 繁, script subtags (zh-Hant-*) win over region; anything non-Chinese (incl. C/POSIX/unset) → en. - loadOrSeed(): seeds + persists on ENOENT; wired into interactive TUI startup. load() stays a pure read so config subcommands and test fixtures never write. Tests: detectSystemLanguage mapping + precedence table; loadOrSeed seeds, persists, and stays frozen across a locale change; load() stays pure. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/main.ts | 6 +- .../src/__tests__/config-store.test.ts | 66 ++++++++++++++++++- packages/storage/src/config-store.ts | 6 ++ packages/storage/src/index.ts | 2 +- packages/storage/src/json/json-config.ts | 49 ++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 17d4334..d5d27cc 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, diff --git a/packages/storage/src/__tests__/config-store.test.ts b/packages/storage/src/__tests__/config-store.test.ts index 35612a9..f59d6ec 100644 --- a/packages/storage/src/__tests__/config-store.test.ts +++ b/packages/storage/src/__tests__/config-store.test.ts @@ -1,9 +1,9 @@ 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"; describe("JsonConfigStore", () => { let dir: string; @@ -113,4 +113,66 @@ describe("JsonConfigStore", () => { const english = await store.load(); expect(english.language).toBe("en"); }); + + // ── 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 orig = process.env.LC_ALL; + try { + process.env.LC_ALL = "zh_CN.UTF-8"; // highest-precedence locale var + 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 { + if (orig === undefined) delete process.env.LC_ALL; + else process.env.LC_ALL = orig; + } + }); +}); + +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("respects precedence: LC_ALL > LC_MESSAGES > LANG > LANGUAGE", () => { + expect(detectSystemLanguage({ LANG: "en_US", LC_ALL: "zh_CN" })).toBe("zh-Hans"); + expect(detectSystemLanguage({ LANG: "zh_CN", LC_MESSAGES: "en_US" })).toBe("en"); + expect(detectSystemLanguage({ LANGUAGE: "zh_TW" })).toBe("zh-Hant"); + expect(detectSystemLanguage({ LANG: "zh_TW", LANGUAGE: "en_US" })).toBe("zh-Hant"); + }); }); 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..0b093aa 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 } from "./json/json-config.js"; export { atomicWriteJson } from "./json/atomic-write.js"; // Journal query diff --git a/packages/storage/src/json/json-config.ts b/packages/storage/src/json/json-config.ts index ea5a20c..51b4713 100644 --- a/packages/storage/src/json/json-config.ts +++ b/packages/storage/src/json/json-config.ts @@ -53,6 +53,32 @@ const LANGUAGE_ALIASES: Record = { "english": "en", }; +/** + * 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. + * + * Reads the standard precedence LC_ALL > LC_MESSAGES > LANG > LANGUAGE and maps + * to the three supported languages. Handles both 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. + */ +export function detectSystemLanguage( + env: Record = process.env, +): UserConfig["language"] { + const raw = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE || ""; + // "zh_CN.UTF-8@modifier" / "zh-Hant-TW" → normalized parts ["zh","hant","tw"] + const parts = raw.split(/[.@]/)[0].replace(/_/g, "-").toLowerCase().split("-"); + if (parts[0] !== "zh") return "en"; + 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"; + // zh-CN / zh-SG / zh-MY and bare "zh" → Simplified (ICU resolves zh→zh-Hans). + return "zh-Hans"; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -143,6 +169,29 @@ export class JsonConfigStore implements ConfigStore { } } + /** + * Like `load()`, but on first boot (no config file yet) it seeds the display + * language from the system locale and PERSISTS the config — freezing the + * choice so later launches read the saved value and never re-consult the + * locale. Used at interactive startup; `load()` stays a pure read so config + * subcommands and test fixtures don't write files. + */ + async loadOrSeed(): Promise { + try { + const raw = await readFile(this.path, "utf-8"); + const parsed: unknown = JSON.parse(raw); + return normalizeConfig(parsed); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + const seeded: UserConfig = { + ...DEFAULT_CONFIG, + language: detectSystemLanguage(), + }; + await this.save(seeded); + return seeded; + } + } + async save(config: UserConfig): Promise { await atomicWriteJson(this.path, config); } From 0742a835a5718e70eb268519e765bd3e08a7c562 Mon Sep 17 00:00:00 2001 From: provi Date: Mon, 8 Jun 2026 03:31:50 -0700 Subject: [PATCH 12/26] =?UTF-8?q?fix(config):=20harden=20first-boot=20seed?= =?UTF-8?q?=20=E2=80=94=20resilience=20gate=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review gate (5-lens, adversarially verified) on the first-boot language seed surfaced four issues; all fixed: - MUST: first-boot save() was unguarded, so a read-only / full data dir crashed the TUI at startup (regression — the old load() never wrote). loadOrSeed now best-effort-persists: a failed seed-write degrades to the detected language in memory for the session and freezes on a later writable launch. - MUST: a corrupt / empty config.json threw a SyntaxError that crashed the TUI AND the recovery CLI (config get/set couldn't self-heal). load()/loadOrSeed now share readExisting(), which tolerates an unparseable file → defaults (non-destructive: a corrupt file is not clobbered) while still rethrowing genuine I/O faults (EACCES/EIO) so they stay visible. - SHOULD: GNU LANGUAGE is a colon-separated priority list; the parser didn't split on ":", misrouting e.g. LANGUAGE=zh_TW:en (繁) to 简. Split on [.@:]. - SHOULD: the `set language` round-trip only exercised "en" (the default), so a no-op write would pass green — now round-trips zh-Hant. Tests added: corrupt-config degradation (load + loadOrSeed, non-clobber), seed-write-failure degradation (stubbed save), LANGUAGE colon-list mapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/__tests__/config-command.test.ts | 8 ++- .../src/__tests__/config-store.test.ts | 43 +++++++++++++++ packages/storage/src/json/json-config.ts | 55 +++++++++++++------ 3 files changed, 85 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/__tests__/config-command.test.ts b/apps/cli/src/__tests__/config-command.test.ts index 2725635..7098df4 100644 --- a/apps/cli/src/__tests__/config-command.test.ts +++ b/apps/cli/src/__tests__/config-command.test.ts @@ -112,12 +112,14 @@ describe("config command", () => { expect(getResult.stdout.trim()).toBe("yarrow"); }, 20_000); - test("set language en persists, reload reads en", async () => { - const setResult = await runCli(dataDir, ["config", "set", "language", "en"]); + // 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("en"); + expect(getResult.stdout.trim()).toBe("zh-Hant"); }, 20_000); test("set castMode rejects yarrow (now out of castMode's domain)", async () => { diff --git a/packages/storage/src/__tests__/config-store.test.ts b/packages/storage/src/__tests__/config-store.test.ts index f59d6ec..01fd736 100644 --- a/packages/storage/src/__tests__/config-store.test.ts +++ b/packages/storage/src/__tests__/config-store.test.ts @@ -145,6 +145,41 @@ describe("JsonConfigStore", () => { else process.env.LC_ALL = orig; } }); + + // ── 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 + }); + + test("loadOrSeed() degrades to defaults on a corrupt config — no throw, no clobber", async () => { + const path = join(dir, "config.json"); + await writeFile(path, "{ corrupt", "utf-8"); + const cfg = await store.loadOrSeed(); + expect(cfg.language).toBe("en"); // ran with defaults instead of crashing + // non-destructive: a possibly hand-fixable corrupt file is NOT overwritten + expect(await readFile(path, "utf-8")).toBe("{ corrupt"); + }); + + 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(); + }); }); describe("detectSystemLanguage", () => { @@ -175,4 +210,12 @@ describe("detectSystemLanguage", () => { expect(detectSystemLanguage({ LANGUAGE: "zh_TW" })).toBe("zh-Hant"); expect(detectSystemLanguage({ LANG: "zh_TW", LANGUAGE: "en_US" })).toBe("zh-Hant"); }); + + // GNU LANGUAGE is a colon-separated priority list — only the first entry counts. + test("handles LANGUAGE colon-separated priority lists (first entry wins)", () => { + expect(detectSystemLanguage({ LANGUAGE: "zh_TW:en" })).toBe("zh-Hant"); + expect(detectSystemLanguage({ LANGUAGE: "zh_Hant:zh_CN" })).toBe("zh-Hant"); + expect(detectSystemLanguage({ LANGUAGE: "en:zh_CN" })).toBe("en"); + expect(detectSystemLanguage({ LANGUAGE: "zh_CN:zh_TW" })).toBe("zh-Hans"); + }); }); diff --git a/packages/storage/src/json/json-config.ts b/packages/storage/src/json/json-config.ts index 51b4713..ac16acd 100644 --- a/packages/storage/src/json/json-config.ts +++ b/packages/storage/src/json/json-config.ts @@ -68,8 +68,10 @@ export function detectSystemLanguage( env: Record = process.env, ): UserConfig["language"] { const raw = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE || ""; - // "zh_CN.UTF-8@modifier" / "zh-Hant-TW" → normalized parts ["zh","hant","tw"] - const parts = raw.split(/[.@]/)[0].replace(/_/g, "-").toLowerCase().split("-"); + // Strip codeset/modifier and, for GNU LANGUAGE's colon-separated priority + // list ("zh_TW:en"), take the first entry. "zh_CN.UTF-8@modifier" / + // "zh-Hant-TW" / "zh_TW:en" → normalized parts ["zh","hant","tw"] etc. + const parts = raw.split(/[.@:]/)[0].replace(/_/g, "-").toLowerCase().split("-"); if (parts[0] !== "zh") return "en"; if (parts.includes("hant")) return "zh-Hant"; // script subtag wins over region if (parts.includes("hans")) return "zh-Hans"; @@ -157,16 +159,31 @@ function normalizeConfig(parsed: unknown): UserConfig { export class JsonConfigStore implements ConfigStore { constructor(private readonly path: string) {} - async load(): Promise { + /** + * Read + parse the config file, tolerating a present-but-corrupt file the + * same way `normalizeConfig` tolerates a structurally-invalid one: a + * `JSON.parse` failure (truncated / hand-edited / empty) falls back to + * defaults rather than crashing every entry point. Returns `null` only when + * the file is genuinely absent (ENOENT); real I/O faults (EACCES/EIO) still + * propagate so they stay visible. + */ + private async readExisting(): Promise { + let raw: string; try { - const raw = await readFile(this.path, "utf-8"); - const parsed: unknown = JSON.parse(raw); - return normalizeConfig(parsed); + 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; } + try { + return normalizeConfig(JSON.parse(raw) as unknown); + } catch { + return { ...DEFAULT_CONFIG }; // unparseable on disk → tolerant defaults + } + } + + async load(): Promise { + return (await this.readExisting()) ?? { ...DEFAULT_CONFIG }; } /** @@ -177,19 +194,21 @@ export class JsonConfigStore implements ConfigStore { * subcommands and test fixtures don't write files. */ async loadOrSeed(): Promise { + const existing = await this.readExisting(); + if (existing) return existing; + const seeded: UserConfig = { + ...DEFAULT_CONFIG, + language: detectSystemLanguage(), + }; + // Best-effort persist: a read-only / full data dir must NOT block startup — + // run with the detected language in memory this session and persist (freeze) + // on a later launch when the write succeeds. try { - const raw = await readFile(this.path, "utf-8"); - const parsed: unknown = JSON.parse(raw); - return normalizeConfig(parsed); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; - const seeded: UserConfig = { - ...DEFAULT_CONFIG, - language: detectSystemLanguage(), - }; await this.save(seeded); - return seeded; + } catch { + /* deferred: not writable this session */ } + return seeded; } async save(config: UserConfig): Promise { From f7fa23f0e7a1f5c900449ec6c2386e72415661b3 Mon Sep 17 00:00:00 2001 From: provi Date: Mon, 8 Jun 2026 03:59:50 -0700 Subject: [PATCH 13/26] =?UTF-8?q?fix(config):=20honor=20LANGUAGE=20precede?= =?UTF-8?q?nce=20+=20retain=20deferred=20seed=20(Codex=20P2=20=C3=972)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LANGUAGE precedence: GNU gettext treats LANGUAGE (a colon priority list) as the *display/message* language selector that outranks LANG/LC_MESSAGES/LC_ALL when a real locale is active (and is ignored in the C/POSIX locale). The seed previously read LANGUAGE last, so a bilingual user (LANG=en_US, LANGUAGE=zh_TW:en) was wrongly seeded English. Detection now derives the effective locale from LC_ALL>LC_MESSAGES>LANG, applies the C-locale guard, then lets a present LANGUAGE's first entry win. Verified end-to-end: the real TUI now boots Traditional under LANG=en_US LANGUAGE=zh_TW:en. - Deferred seed: when first-boot persist fails (read-only/full dir), the swallowed error left no file, so a later configStore.load() in the same session (opening Settings) flipped back to defaults. The store now retains the unpersisted seed in memory (shadowed once a real file exists) so the session stays in its detected language. Tests: corrected LANGUAGE-precedence + C-locale-guard cases; deferred-seed in-memory retention. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/config-store.test.ts | 47 +++++++++++++++---- packages/storage/src/json/json-config.ts | 40 ++++++++++++---- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/packages/storage/src/__tests__/config-store.test.ts b/packages/storage/src/__tests__/config-store.test.ts index 01fd736..717a587 100644 --- a/packages/storage/src/__tests__/config-store.test.ts +++ b/packages/storage/src/__tests__/config-store.test.ts @@ -180,6 +180,28 @@ describe("JsonConfigStore", () => { // 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 orig = process.env.LC_ALL; + try { + process.env.LC_ALL = "zh_TW.UTF-8"; // deterministic detected language + 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 { + if (orig === undefined) delete process.env.LC_ALL; + else process.env.LC_ALL = orig; + } + }); }); describe("detectSystemLanguage", () => { @@ -204,18 +226,25 @@ describe("detectSystemLanguage", () => { }); } - test("respects precedence: LC_ALL > LC_MESSAGES > LANG > LANGUAGE", () => { + 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"); - expect(detectSystemLanguage({ LANGUAGE: "zh_TW" })).toBe("zh-Hant"); - expect(detectSystemLanguage({ LANG: "zh_TW", LANGUAGE: "en_US" })).toBe("zh-Hant"); }); - // GNU LANGUAGE is a colon-separated priority list — only the first entry counts. - test("handles LANGUAGE colon-separated priority lists (first entry wins)", () => { - expect(detectSystemLanguage({ LANGUAGE: "zh_TW:en" })).toBe("zh-Hant"); - expect(detectSystemLanguage({ LANGUAGE: "zh_Hant:zh_CN" })).toBe("zh-Hant"); - expect(detectSystemLanguage({ LANGUAGE: "en:zh_CN" })).toBe("en"); - expect(detectSystemLanguage({ LANGUAGE: "zh_CN:zh_TW" })).toBe("zh-Hans"); + // 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 list 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"); + }); + + 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/json/json-config.ts b/packages/storage/src/json/json-config.ts index ac16acd..cd83570 100644 --- a/packages/storage/src/json/json-config.ts +++ b/packages/storage/src/json/json-config.ts @@ -59,18 +59,27 @@ const LANGUAGE_ALIASES: Record = { * seed, never a live binding: once a config exists, the saved choice wins and * the locale is never consulted again. * - * Reads the standard precedence LC_ALL > LC_MESSAGES > LANG > LANGUAGE and maps - * to the three supported languages. Handles both 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. + * 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. "zh_TW:en") OUTRANKS it — + * EXCEPT in the C/POSIX (or unset) locale, where localization is off and + * LANGUAGE is ignored. 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. */ export function detectSystemLanguage( env: Record = process.env, ): UserConfig["language"] { - const raw = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE || ""; - // Strip codeset/modifier and, for GNU LANGUAGE's colon-separated priority - // list ("zh_TW:en"), take the first entry. "zh_CN.UTF-8@modifier" / + // 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"; + // LANGUAGE (colon priority list) chooses the display language and outranks the + // locale when localization is on; take its first entry. Strip codeset/modifier; // "zh-Hant-TW" / "zh_TW:en" → normalized parts ["zh","hant","tw"] etc. + const raw = env.LANGUAGE || locale; const parts = raw.split(/[.@:]/)[0].replace(/_/g, "-").toLowerCase().split("-"); if (parts[0] !== "zh") return "en"; if (parts.includes("hant")) return "zh-Hant"; // script subtag wins over region @@ -159,6 +168,14 @@ function normalizeConfig(parsed: unknown): UserConfig { export class JsonConfigStore implements ConfigStore { constructor(private readonly path: string) {} + /** + * First-boot seed retained in memory when it could not be persisted (read-only + * / full data dir). Lets the rest of THIS session — e.g. reopening Settings, + * which reloads config — see the detected language instead of flipping back to + * defaults. Shadowed the moment a real config file exists. + */ + private seededInMemory: UserConfig | null = null; + /** * Read + parse the config file, tolerating a present-but-corrupt file the * same way `normalizeConfig` tolerates a structurally-invalid one: a @@ -183,7 +200,11 @@ export class JsonConfigStore implements ConfigStore { } async load(): Promise { - return (await this.readExisting()) ?? { ...DEFAULT_CONFIG }; + const existing = await this.readExisting(); + if (existing) return existing; + // No file on disk: fall back to a deferred in-memory seed if first boot + // couldn't persist (so the session stays in its detected language), else defaults. + return this.seededInMemory ? { ...this.seededInMemory } : { ...DEFAULT_CONFIG }; } /** @@ -206,12 +227,13 @@ export class JsonConfigStore implements ConfigStore { try { await this.save(seeded); } catch { - /* deferred: not writable this session */ + this.seededInMemory = seeded; // deferred: keep it for this session's reloads } return seeded; } async save(config: UserConfig): Promise { await atomicWriteJson(this.path, config); + this.seededInMemory = null; // persisted — the file is now the source of truth } } From 2a457d2cd983f52df3809e1bb98d2444267e45ab Mon Sep 17 00:00:00 2001 From: provi Date: Mon, 8 Jun 2026 04:08:47 -0700 Subject: [PATCH 14/26] fix(config): walk the LANGUAGE priority list to a supported entry (Codex P2) GNU LANGUAGE is a colon-separated fallback list tried in order; the seed only parsed the first entry, so LANG=en_US LANGUAGE=ja:zh_CN seeded English even though the user listed a supported Chinese fallback. Detection now scans LANGUAGE entries (then the locale) and honors the first that maps to a supported display language (en / zh-Hant / zh-Hans), via a mapLocaleToken helper. All prior precedence/C-locale behavior is preserved. Verified end-to-end: the real TUI boots Simplified under LANG=en_US LANGUAGE=ja:zh_CN. Tests added for the fallback-scan cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/config-store.test.ts | 10 ++++- packages/storage/src/json/json-config.ts | 44 ++++++++++++------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/storage/src/__tests__/config-store.test.ts b/packages/storage/src/__tests__/config-store.test.ts index 717a587..cf709ce 100644 --- a/packages/storage/src/__tests__/config-store.test.ts +++ b/packages/storage/src/__tests__/config-store.test.ts @@ -233,7 +233,7 @@ describe("detectSystemLanguage", () => { // 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 list entry wins)", () => { + 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"); @@ -242,6 +242,14 @@ describe("detectSystemLanguage", () => { 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"); diff --git a/packages/storage/src/json/json-config.ts b/packages/storage/src/json/json-config.ts index cd83570..19b59ad 100644 --- a/packages/storage/src/json/json-config.ts +++ b/packages/storage/src/json/json-config.ts @@ -61,12 +61,27 @@ const LANGUAGE_ALIASES: Record = { * * 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. "zh_TW:en") OUTRANKS it — + * 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. 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. + * 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"] { @@ -76,18 +91,15 @@ export function detectSystemLanguage( // 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"; - // LANGUAGE (colon priority list) chooses the display language and outranks the - // locale when localization is on; take its first entry. Strip codeset/modifier; - // "zh-Hant-TW" / "zh_TW:en" → normalized parts ["zh","hant","tw"] etc. - const raw = env.LANGUAGE || locale; - const parts = raw.split(/[.@:]/)[0].replace(/_/g, "-").toLowerCase().split("-"); - if (parts[0] !== "zh") return "en"; - 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"; - // zh-CN / zh-SG / zh-MY and bare "zh" → Simplified (ICU resolves zh→zh-Hans). - return "zh-Hans"; + // 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 { From 24de4aad6afa3b466ff4222b3f0f757c6a1e154e Mon Sep 17 00:00:00 2001 From: provi Date: Mon, 8 Jun 2026 04:20:19 -0700 Subject: [PATCH 15/26] fix(settings): localize the yarrow preview captions (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderPreview() called renderYarrowFieldStrip without the selected language, so the yarrow preview strip in a localized Settings screen rendered its captions (策 / 奇策 / 餘策) in English. Pass vals.language through in both the auto and manual yarrow preview branches. Regression test drives the Settings yarrow preview in zh-Hant and asserts no English caption leak. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/scene-language.test.ts | 31 +++++++++++++++++++ .../src/scenes/settings/settings-scene.ts | 4 +-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/terminal/src/__tests__/scene-language.test.ts b/packages/terminal/src/__tests__/scene-language.test.ts index 5089e13..33698db 100644 --- a/packages/terminal/src/__tests__/scene-language.test.ts +++ b/packages/terminal/src/__tests__/scene-language.test.ts @@ -112,6 +112,37 @@ describe("SettingsScene — no bilingual stacking", () => { expect(text).toContain("設定"); // live-localized title (zh-Hant), not the saved "en" expect(text).not.toContain("Settings"); // old language no longer shown }); + + // 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", () => { diff --git a/packages/terminal/src/scenes/settings/settings-scene.ts b/packages/terminal/src/scenes/settings/settings-scene.ts index 260bdc3..28ecaee 100644 --- a/packages/terminal/src/scenes/settings/settings-scene.ts +++ b/packages/terminal/src/scenes/settings/settings-scene.ts @@ -333,14 +333,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") { From 50bf7318cfe5a1cbfa147c159a3e41d738d97b03 Mon Sep 17 00:00:00 2001 From: provi Date: Mon, 8 Jun 2026 15:50:50 -0700 Subject: [PATCH 16/26] fix(verify): commit the language-surface inventory as a test fixture (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-language-surfaces.ts defaulted its inventory/consults inputs to .loop/language/*, which is gitignored — so the committed, glossary-advertised "deterministic check" hard-failed on a fresh clone / CI. Commit the two required inputs (TEXT_SURFACES.md, CONSULTS.md) under tests/fixtures/language/ and point the verifier's defaults there. (No npm impact — the package ships only the bundled iching.js + README + LICENSE.) Proven reproducible: every mode passes with .loop/ removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/verify-language-surfaces.ts | 9 +- tests/fixtures/language/CONSULTS.md | 240 +++ tests/fixtures/language/TEXT_SURFACES.md | 2098 ++++++++++++++++++++++ 3 files changed, 2343 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/language/CONSULTS.md create mode 100644 tests/fixtures/language/TEXT_SURFACES.md diff --git a/scripts/verify-language-surfaces.ts b/scripts/verify-language-surfaces.ts index d2d63d5..be9aab9 100644 --- a/scripts/verify-language-surfaces.ts +++ b/scripts/verify-language-surfaces.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun /** * Language-surface verifier — the deterministic oracle for the I Ching language - * translation loop (.loop/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) @@ -13,7 +14,7 @@ * --cli AC-005 | --simplified AC-006 | --consults AC-007 * --self-test AC-008 | --glossary AC-010 | (no flag) AC-009 run-all. * - * Inventory source: .loop/language/TEXT_SURFACES.md (override --inventory ). + * 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 @@ -41,7 +42,7 @@ const optValue = (name: string): string | undefined => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; }; -const INVENTORY_REL = optValue("--inventory") ?? ".loop/language/TEXT_SURFACES.md"; +const INVENTORY_REL = optValue("--inventory") ?? "tests/fixtures/language/TEXT_SURFACES.md"; // --------------------------------------------------------------------------- // Scope @@ -897,7 +898,7 @@ async function runCoreData(): Promise { // AC-007: high-risk groups have reconciled meaning + adversarial consults // --------------------------------------------------------------------------- function runConsults(): void { - const c = readMaybe(".loop/language/CONSULTS.md") ?? ""; + const c = readMaybe("tests/fixtures/language/CONSULTS.md") ?? ""; if (!c) { fail("CONSULTS.md missing"); return; 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..5be25b0 --- /dev/null +++ b/tests/fixtures/language/TEXT_SURFACES.md @@ -0,0 +1,2098 @@ +# 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. Options are enum chips shown verbatim (theme names, dots/dense, kaiti/libian/heiti, coin/yarrow, auto/manual)." + +- 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-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. Localization needs separate display-label map." + +- 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