Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8bcbec8
feat(config): add display language setting
pro-vi Jun 2, 2026
c3a8049
feat(terminal): apply language setting to detail view
pro-vi Jun 2, 2026
2e89bd9
feat(config): default language to English
pro-vi Jun 2, 2026
f975a88
feat(i18n): localize TUI + CLI across EN / 繁 / 简
pro-vi Jun 3, 2026
0b61d8f
fix(corpus): correct received-text glyphs and 祐 over-simplification
pro-vi Jun 4, 2026
b36c603
fix(corpus): keep audited 祐→佑 (second-opinion correction)
pro-vi Jun 4, 2026
7935bf5
fix(cli): reject inherited prototype names as config keys
pro-vi Jun 4, 2026
97ff85d
fix(settings): localize the scene from the live language selection
pro-vi Jun 4, 2026
97f6a87
fix(i18n): match simplified names in search; localize manual yarrow f…
pro-vi Jun 4, 2026
a4eb62d
feat(i18n): render Simplified hexagram glyphs in zh-Hans
pro-vi Jun 4, 2026
e284c06
feat(config): seed display language from the system locale on first boot
pro-vi Jun 8, 2026
0742a83
fix(config): harden first-boot seed — resilience gate findings
pro-vi Jun 8, 2026
f7fa23f
fix(config): honor LANGUAGE precedence + retain deferred seed (Codex …
pro-vi Jun 8, 2026
2a457d2
fix(config): walk the LANGUAGE priority list to a supported entry (Co…
pro-vi Jun 8, 2026
24de4aa
fix(settings): localize the yarrow preview captions (Codex P2)
pro-vi Jun 8, 2026
50bf731
fix(verify): commit the language-surface inventory as a test fixture …
pro-vi Jun 8, 2026
672cffb
fix(dict): seed the display language when launching the standalone di…
pro-vi Jun 8, 2026
7128c52
fix(config): harden config persistence — ultrareview findings
pro-vi Jun 10, 2026
751eabc
feat(config,settings): seed upgraders + scroll settings rows (ultrare…
pro-vi Jun 10, 2026
5131f4f
fix(cli): config set language accepts the UI labels (繁/简/EN), matchin…
pro-vi Jun 10, 2026
edd87b8
fix(cli): config set fails cleanly when the data dir is unwritable (g…
pro-vi Jun 10, 2026
5c0d07e
fix(cli): validate config set before seeding, so failed sets write no…
pro-vi Jun 10, 2026
a8773a8
docs(language): classify the config-set write-failure diagnostic (Cod…
pro-vi Jun 10, 2026
47f2343
refactor(terminal): consolidate scroll math into one helper (#7)
pro-vi Jun 10, 2026
5e8af78
feat(terminal): localize settings option chips via display-label cata…
pro-vi Jun 10, 2026
fb1f819
fix(storage): config error paths now agree with the running app's liv…
pro-vi Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
118 changes: 116 additions & 2 deletions apps/cli/src/__tests__/config-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// (coin|yarrow) × castMode (auto|manual); both must be reachable.

import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

Expand Down Expand Up @@ -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",
Expand All @@ -78,6 +79,12 @@ describe("config command", () => {
expect(stdout.trim()).toBe("auto");
}, 20_000);

test("get language returns the default value", async () => {
const { exitCode, stdout } = await runCli(dataDir, ["config", "get", "language"]);
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe("en");
}, 20_000);

test("get taijituStyle returns the default value", async () => {
const { exitCode, stdout } = await runCli(dataDir, ["config", "get", "taijituStyle"]);
expect(exitCode).toBe(0);
Expand Down Expand Up @@ -105,6 +112,39 @@ describe("config command", () => {
expect(getResult.stdout.trim()).toBe("yarrow");
}, 20_000);

// Round-trip a NON-default value (en is DEFAULT_CONFIG.language, so a silent
// no-op write would still read back "en" — this would pass green even broken).
test("set language zh-Hant persists, reload reads zh-Hant", async () => {
const setResult = await runCli(dataDir, ["config", "set", "language", "zh-Hant"]);
expect(setResult.exitCode).toBe(0);
const getResult = await runCli(dataDir, ["config", "get", "language"]);
expect(getResult.exitCode).toBe(0);
expect(getResult.stdout.trim()).toBe("zh-Hant");
}, 20_000);

// `set language` accepts the labels the Settings UI displays (繁/简/EN) and
// case variants, normalizing to the canonical value the file loader also accepts.
test("set language accepts UI labels + case variants, persists canonical", async () => {
for (const [input, want] of [
["繁", "zh-Hant"],
["简", "zh-Hans"],
["EN", "en"],
["zh-hant", "zh-Hant"],
] as const) {
const setResult = await runCli(dataDir, ["config", "set", "language", input]);
expect(setResult.exitCode).toBe(0);
expect(setResult.stdout.trim()).toBe(`language = ${want}`); // canonical, not the raw label
const getResult = await runCli(dataDir, ["config", "get", "language"]);
expect(getResult.stdout.trim()).toBe(want);
}
}, 20_000);

test("set language still rejects genuinely invalid values", async () => {
const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "language", "klingon"]);
expect(exitCode).not.toBe(0);
expect(stderr.toLowerCase()).toContain("invalid value");
}, 20_000);

test("set castMode rejects yarrow (now out of castMode's domain)", async () => {
const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "castMode", "yarrow"]);
expect(exitCode).not.toBe(0);
Expand Down Expand Up @@ -136,9 +176,83 @@ describe("config command", () => {
expect(stderr.toLowerCase()).toContain("invalid value");
}, 20_000);

test("set language rejects invalid value", async () => {
const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "language", "latin"]);
expect(exitCode).not.toBe(0);
expect(stderr.toLowerCase()).toContain("invalid value");
}, 20_000);

test("get unknown key reports error", async () => {
const { exitCode, stderr } = await runCli(dataDir, ["config", "get", "notARealKey"]);
expect(exitCode).not.toBe(0);
expect(stderr.toLowerCase()).toContain("unknown key");
}, 20_000);

// Regression: `key in CONFIG_SCHEMA` walked the prototype chain, so inherited
// Object.prototype names slipped past the unknown-key guard — `get` printed the
// inherited function and `set` crashed ("schema.set is not a function"). The
// guard now uses Object.hasOwn; both must report a clean "unknown key" error.
test("get inherited prototype key (toString) reports unknown key", async () => {
const { exitCode, stderr, stdout } = await runCli(dataDir, ["config", "get", "toString"]);
expect(exitCode).not.toBe(0);
expect(stderr.toLowerCase()).toContain("unknown key");
expect(stdout).not.toContain("function");
}, 20_000);

test("set inherited prototype key (constructor) reports unknown key, does not crash", async () => {
const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "constructor", "x"]);
expect(exitCode).not.toBe(0);
expect(stderr.toLowerCase()).toContain("unknown key");
expect(stderr.toLowerCase()).not.toContain("is not a function");
}, 20_000);

// Regression (Codex P2): `iching dict` is an interactive entry point and must
// seed+freeze the display language on first boot like the main TUI — not use a
// pure load() that launches English. It seeds before the (non-TTY) render exits.
test("`iching dict` seeds the display language on first boot", async () => {
const proc = Bun.spawn(["bun", MAIN_TS, "--data-dir", dataDir, "dict"], {
cwd: REPO_ROOT,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
// clear inherited LANGUAGE/LC_MESSAGES so LC_ALL deterministically wins
env: { ...process.env, NO_COLOR: "1", LC_ALL: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8", LC_MESSAGES: "", LANGUAGE: "" },
});
proc.stdin.end();
await proc.exited; // exits on its own (no TTY) after loadOrSeed has persisted
const cfg = JSON.parse(await readFile(join(dataDir, "config.json"), "utf-8"));
expect(cfg.language).toBe("zh-Hans"); // seeded from the locale, not default "en"
}, 20_000);

// Regression (review P2): `config set` WRITES, so on first boot it must seed
// the language — not persist the defaulted "en" and permanently freeze the seed.
test("`config set` on first boot seeds the language (does not freeze en)", async () => {
const proc = Bun.spawn(["bun", MAIN_TS, "--data-dir", dataDir, "config", "set", "theme", "ink"], {
cwd: REPO_ROOT,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, NO_COLOR: "1", LC_ALL: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8", LC_MESSAGES: "", LANGUAGE: "" },
});
proc.stdin.end();
await proc.exited;
const cfg = JSON.parse(await readFile(join(dataDir, "config.json"), "utf-8"));
expect(cfg.theme).toBe("ink"); // the set applied
expect(cfg.language).toBe("zh-Hans"); // …AND the locale was seeded, not frozen to en
}, 20_000);

// Regression (Codex P2): a REJECTED `config set` must not write anything — the
// old order ran loadOrSeed() (which persists a seeded config on first boot)
// before validation, so `config set language klingon` created config.json and
// froze the locale seed even though the command failed.
test("failed `config set` on first boot leaves no config file behind", async () => {
for (const args of [
["config", "set", "language", "klingon"], // invalid value
["config", "set", "notARealKey", "x"], // unknown key
]) {
const { exitCode } = await runCli(dataDir, args);
expect(exitCode).not.toBe(0);
}
expect(await Bun.file(join(dataDir, "config.json")).exists()).toBe(false);
}, 20_000);
});
1 change: 1 addition & 0 deletions apps/cli/src/__tests__/reading-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
}
Expand Down
15 changes: 11 additions & 4 deletions apps/cli/src/app/reading-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
castHexagram,
type Cast,
CryptoRandomSource,
type DisplayLanguage,
SeededRandomSource,
} from "@iching/core";
import {
Expand Down Expand Up @@ -57,6 +58,7 @@ export interface ReadingFlowDeps {
today: string;
session: SessionDims;
glyphConfig: CastGlyphInput;
language: DisplayLanguage;
motion: MotionPreset;
}

Expand Down Expand Up @@ -96,13 +98,13 @@ export async function runReadingFlow(
if (tossSignal?.type !== "tossCompleted") return { shouldExit: false }; // user quit before 6 lines
cast = tossSignal.cast;
} else if (opts.source.type === "yarrow") {
const yarrowScene = new YarrowScene(deps.motion);
const yarrowScene = new YarrowScene(deps.motion, undefined, deps.language);
const yarrowSignal = await deps.run(yarrowScene);
if (yarrowSignal?.type === "exit") return { shouldExit: true };
if (yarrowSignal?.type !== "yarrowCompleted") return { shouldExit: false }; // user quit mid-ritual
cast = yarrowSignal.cast;
} else if (opts.source.type === "yarrow-manual") {
const yarrowManualScene = new YarrowManualScene(deps.motion);
const yarrowManualScene = new YarrowManualScene(deps.motion, undefined, deps.language);
const yarrowSignal = await deps.run(yarrowManualScene);
if (yarrowSignal?.type === "exit") return { shouldExit: true };
if (yarrowSignal?.type !== "yarrowCompleted") return { shouldExit: false }; // user quit mid-ritual
Expand Down Expand Up @@ -152,7 +154,7 @@ export async function runReadingFlow(
deps.glyphConfig,
deps.session.rows,
intention,
{ skipLineDrawing: drewOwnLines },
{ skipLineDrawing: drewOwnLines, language: deps.language },
);
if (isReplay) castScene.skipToComplete(false);
const castSignal = await deps.run(castScene);
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/src/app/scene-factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}

Expand All @@ -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),
);
Expand Down Expand Up @@ -63,6 +64,7 @@ export function makeJournalFactory(deps: JournalDeps): SceneFactory {
deps.glyphConfig,
deps.session.rows,
entry.intention,
{ language: deps.language },
);
cs.skipToComplete(false);
return cs;
Expand Down
Loading
Loading