From e9c6780328ca68f6ae8ac5d4c30ef0a2aebc0020 Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 17:10:17 -0700 Subject: [PATCH 001/334] feat(storage,cli): torn-line tolerance, cast-method provenance, journal filters, seed validation, config shorthand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage robustness: - JsonlJournalStore.stream()/latest() skip torn/malformed JSONL lines (per-line try/catch + entry-shape guard) instead of throwing forever; skippedLines counter exposed on JournalStore for callers to surface - JsonDailyCacheStore.read() returns null on corrupt JSON instead of crashing interactive startup; sidecars the bytes to .corrupt (wx — never clobbers the first backup, cf. JsonConfigStore) with a one-shot stderr note Cast-method provenance: - new CastMethod type (coin | coin-manual | yarrow | yarrow-manual) in @iching/core; optional method field on HistoryEntry and DailyCache - reading-flow records the ReadingSource at persist time; hook adapter records coin for fresh casts and preserves the TUI's method on reuse - journal show prints a quiet 'Method:' line; journal list marks only non-coin rituals (· yarrow stalks); JSON carries the raw field - schema-keys expanded (cache.optional += method, history.optional += method) Journal arc queries: - journal list --hexagram filters by primary OR becoming (1-64 validated, exit 1 otherwise) - journal list/show --json enriched with resolved primary/becoming name blocks {kw, n, p, ename, u}; all raw fields preserved (additive) CLI hygiene: - cast --seed rejects non-numeric/empty seeds with stderr + exit 1 (NaN|0 used to collapse the PRNG to a constant cast silently) - config [value] git-style shorthand routed through the same validated schema; bare 'config' lists; list/get/set/path subcommands unchanged — the README's 'iching config theme cinnabar' now works Tests: 595 -> 625 (torn-line, corrupt-cache, provenance, filter, enrichment, seed-validation, shorthand coverage). Co-Authored-By: Claude Fable 5 --- apps/cli/src/__tests__/cast.test.ts | 47 +++- apps/cli/src/__tests__/config-command.test.ts | 52 +++++ apps/cli/src/__tests__/hook-adapter.test.ts | 32 ++- .../cli/src/__tests__/journal-command.test.ts | 186 ++++++++++++++++ apps/cli/src/__tests__/reading-flow.test.ts | 27 ++- apps/cli/src/app/reading-flow.ts | 15 +- apps/cli/src/commands/cast.ts | 13 +- apps/cli/src/commands/config.ts | 202 ++++++++++-------- apps/cli/src/commands/journal.ts | 26 ++- apps/cli/src/hook/adapter.ts | 12 +- apps/cli/src/output/json.ts | 22 +- apps/cli/src/output/plain.ts | 24 ++- packages/core/src/index.ts | 1 + packages/core/src/types.ts | 9 + .../src/__tests__/daily-cache-store.test.ts | 49 +++++ .../src/__tests__/journal-store.test.ts | 98 +++++++++ .../src/__tests__/schema-shape.test.ts | 2 + packages/storage/src/journal-store.ts | 10 +- packages/storage/src/json/json-daily-cache.ts | 38 +++- packages/storage/src/json/jsonl-journal.ts | 47 +++- packages/storage/src/schema-keys.ts | 4 +- 21 files changed, 801 insertions(+), 115 deletions(-) create mode 100644 apps/cli/src/__tests__/journal-command.test.ts diff --git a/apps/cli/src/__tests__/cast.test.ts b/apps/cli/src/__tests__/cast.test.ts index 72adec66..a193a350 100644 --- a/apps/cli/src/__tests__/cast.test.ts +++ b/apps/cli/src/__tests__/cast.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { mkdtemp, rm, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { castHexagram, buildStructure, @@ -112,3 +112,48 @@ describe("cast command", () => { expect(text).toContain("Commentary:"); }); }); + +// Regression: Number("abc") is NaN and NaN|0 collapsed the PRNG to a constant +// state — `cast --seed abc` exited 0 with the same plausible-looking cast +// forever. Non-numeric seeds must fail loudly. +describe("cast --seed validation (subprocess)", () => { + const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", ".."); + const MAIN_TS = resolve(REPO_ROOT, "apps/cli/src/main.ts"); + + async function runCast(seed: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const proc = Bun.spawn(["bun", MAIN_TS, "--seed", seed, "cast"], { + cwd: REPO_ROOT, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1" }, + }); + proc.stdin.end(); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { exitCode, stdout, stderr }; + } + + test("non-numeric seed errors on stderr and exits 1", async () => { + const { exitCode, stdout, stderr } = await runCast("abc"); + expect(exitCode).toBe(1); + expect(stderr).toContain('Invalid --seed "abc"'); + expect(stdout).toBe(""); + }, 20_000); + + test("empty seed is rejected (Number('') would silently become 0)", async () => { + const { exitCode, stderr } = await runCast(""); + expect(exitCode).toBe(1); + expect(stderr).toContain("Invalid --seed"); + }, 20_000); + + test("numeric seed still works and stays deterministic", async () => { + const a = await runCast("42"); + const b = await runCast("42"); + expect(a.exitCode).toBe(0); + expect(a.stdout).toBe(b.stdout); + }, 20_000); +}); diff --git a/apps/cli/src/__tests__/config-command.test.ts b/apps/cli/src/__tests__/config-command.test.ts index 39c9466d..e66244c1 100644 --- a/apps/cli/src/__tests__/config-command.test.ts +++ b/apps/cli/src/__tests__/config-command.test.ts @@ -255,4 +255,56 @@ describe("config command", () => { } expect(await Bun.file(join(dataDir, "config.json")).exists()).toBe(false); }, 20_000); + + // Git-style positional shorthand: `config ` reads, `config + // ` writes — same validated schema as get/set. This is also the + // syntax the README advertises (`iching config theme cinnabar`). + describe("positional shorthand", () => { + test("`config theme cinnabar` sets, `config theme` gets", async () => { + const setResult = await runCli(dataDir, ["config", "theme", "cinnabar"]); + expect(setResult.exitCode).toBe(0); + expect(setResult.stdout.trim()).toBe("theme = cinnabar"); + + const getResult = await runCli(dataDir, ["config", "theme"]); + expect(getResult.exitCode).toBe(0); + expect(getResult.stdout.trim()).toBe("cinnabar"); + + // The explicit subcommand sees the same persisted value + const subResult = await runCli(dataDir, ["config", "get", "theme"]); + expect(subResult.stdout.trim()).toBe("cinnabar"); + }, 20_000); + + test("bare `config` lists all values like `config list`", async () => { + const bare = await runCli(dataDir, ["config"]); + const list = await runCli(dataDir, ["config", "list"]); + expect(bare.exitCode).toBe(0); + expect(bare.stdout).toBe(list.stdout); + }, 20_000); + + test("shorthand rejects unknown keys with exit 1", async () => { + const { exitCode, stderr } = await runCli(dataDir, ["config", "notARealKey"]); + expect(exitCode).toBe(1); + expect(stderr.toLowerCase()).toContain("unknown key"); + }, 20_000); + + test("shorthand set goes through the validated schema", async () => { + const { exitCode, stderr } = await runCli(dataDir, ["config", "theme", "outline"]); + expect(exitCode).toBe(1); + expect(stderr.toLowerCase()).toContain("invalid value"); + // The rejected write must not create a config file (cf. set ordering) + expect(await Bun.file(join(dataDir, "config.json")).exists()).toBe(false); + }, 20_000); + + test("shorthand normalizes language labels like `config set` does", async () => { + const setResult = await runCli(dataDir, ["config", "language", "繁"]); + expect(setResult.exitCode).toBe(0); + expect(setResult.stdout.trim()).toBe("language = zh-Hant"); + }, 20_000); + + test("subcommands still win over shorthand (path)", async () => { + const { exitCode, stdout } = await runCli(dataDir, ["config", "path"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(join(dataDir, "config.json")); + }, 20_000); + }); }); diff --git a/apps/cli/src/__tests__/hook-adapter.test.ts b/apps/cli/src/__tests__/hook-adapter.test.ts index 19f10ab0..ef483e59 100644 --- a/apps/cli/src/__tests__/hook-adapter.test.ts +++ b/apps/cli/src/__tests__/hook-adapter.test.ts @@ -1,7 +1,7 @@ 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 } from "node:path"; +import { join, resolve } from "node:path"; import { castHexagram, buildStructure, @@ -84,4 +84,32 @@ describe("hook adapter", () => { expect(cached!.shown).toBe(true); expect(cached!.cast.primary).toBe(cast.primary); }); + + // End-to-end: bare `iching` with piped stdin enters hook mode (main.ts). + // A fresh hook cast must record coin provenance in the journal and cache. + test("hook mode records method 'coin' in journal and cache", async () => { + const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", ".."); + const MAIN_TS = resolve(REPO_ROOT, "apps/cli/src/main.ts"); + + const proc = Bun.spawn(["bun", MAIN_TS], { + cwd: REPO_ROOT, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + // The adapter resolves paths without a --data-dir override; collapse + // all storage into the temp dir via ICHING_HOME. + env: { ...process.env, NO_COLOR: "1", ICHING_HOME: dataDir }, + }); + proc.stdin.write("{}"); + proc.stdin.end(); + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const journalLine = (await readFile(join(dataDir, "history.jsonl"), "utf-8")).trim(); + const entry = JSON.parse(journalLine); + expect(entry.method).toBe("coin"); + + const cache = JSON.parse(await readFile(join(dataDir, "daily-cache.json"), "utf-8")); + expect(cache.method).toBe("coin"); + }, 20_000); }); diff --git a/apps/cli/src/__tests__/journal-command.test.ts b/apps/cli/src/__tests__/journal-command.test.ts new file mode 100644 index 00000000..205d821c --- /dev/null +++ b/apps/cli/src/__tests__/journal-command.test.ts @@ -0,0 +1,186 @@ +// Subprocess tests for the `iching journal` command — hexagram filtering, +// name-enriched JSON output, method provenance notes, and torn-line survival. + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm, writeFile, appendFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { Cast, HistoryEntry } from "@iching/core"; + +const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", ".."); +const MAIN_TS = resolve(REPO_ROOT, "apps/cli/src/main.ts"); + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +async function runCli(dataDir: string, args: string[]): Promise { + const proc = Bun.spawn( + ["bun", MAIN_TS, "--data-dir", dataDir, ...args], + { + cwd: REPO_ROOT, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1" }, + }, + ); + proc.stdin.end(); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { exitCode, stdout, stderr }; +} + +function makeCast(primary: number, becoming: number | null): Cast { + const isChanging = becoming !== null; + return { + lines: [ + { value: isChanging ? 9 : 7, isYang: true, isChanging }, + { value: 7, isYang: true, isChanging: false }, + { value: 8, isYang: false, isChanging: false }, + { value: 7, isYang: true, isChanging: false }, + { value: 8, isYang: false, isChanging: false }, + { value: 7, isYang: true, isChanging: false }, + ], + primary, + becoming, + changingPositions: isChanging ? [1] : [], + nuclear: 1, + polarity: 2, + mirror: 1, + diagonal: 2, + }; +} + +function makeEntry( + date: string, + primary: number, + becoming: number | null, + method?: HistoryEntry["method"], +): HistoryEntry { + return { date, cast: makeCast(primary, becoming), timestamp: `${date}T09:00:00.000Z`, method }; +} + +async function seedJournal(dataDir: string, entries: HistoryEntry[]): Promise { + const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; + await writeFile(join(dataDir, "history.jsonl"), lines, "utf-8"); +} + +describe("journal command", () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), "iching-journal-cmd-test-")); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + test("list --hexagram matches primary OR becoming", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 39, null), // primary match + makeEntry("2026-01-02", 5, null), // no match + makeEntry("2026-01-03", 3, 39), // becoming match + ]); + + const { exitCode, stdout } = await runCli(dataDir, ["journal", "list", "--hexagram", "39"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("2026-01-01"); + expect(stdout).not.toContain("2026-01-02"); + expect(stdout).toContain("2026-01-03"); + }, 20_000); + + test("list --hexagram rejects non-numeric and out-of-range values", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + for (const bad of ["abc", "0", "65", "3.5"]) { + const { exitCode, stderr } = await runCli(dataDir, ["journal", "list", "--hexagram", bad]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Invalid --hexagram"); + } + }, 20_000); + + test("list --json enriches entries with resolved names, raw fields intact", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-03", 3, 39, "yarrow")]); + + const { exitCode, stdout } = await runCli(dataDir, ["--json", "journal", "list"]); + expect(exitCode).toBe(0); + const entries = JSON.parse(stdout); + expect(entries).toHaveLength(1); + const entry = entries[0]; + // Raw HistoryEntry fields preserved (backward-compatible) + expect(entry.date).toBe("2026-01-03"); + expect(entry.cast.primary).toBe(3); + expect(entry.method).toBe("yarrow"); + // Additive name blocks so scripts don't need the data table + expect(entry.primary.kw).toBe(3); + expect(entry.primary.n).toBe("屯"); + expect(typeof entry.primary.p).toBe("string"); + expect(typeof entry.primary.ename).toBe("string"); + expect(typeof entry.primary.u).toBe("string"); + expect(entry.becoming.kw).toBe(39); + expect(entry.becoming.n).toBe("蹇"); + }, 20_000); + + test("list --json becoming is null for unchanging casts", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + const { stdout } = await runCli(dataDir, ["--json", "journal", "list"]); + const entries = JSON.parse(stdout); + expect(entries[0].becoming).toBeNull(); + }, 20_000); + + test("show --json is enriched too", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-03", 3, 39)]); + const { exitCode, stdout } = await runCli(dataDir, ["--json", "journal", "show", "latest"]); + expect(exitCode).toBe(0); + const entry = JSON.parse(stdout); + expect(entry.primary.kw).toBe(3); + expect(entry.becoming.kw).toBe(39); + }, 20_000); + + test("plain list notes non-coin methods quietly; coin stays unmarked", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 1, null, "coin"), + makeEntry("2026-01-02", 2, null, "yarrow"), + ]); + + const { stdout } = await runCli(dataDir, ["journal", "list"]); + const lines = stdout.trimEnd().split("\n"); + const coinLine = lines.find((l) => l.includes("2026-01-01"))!; + const yarrowLine = lines.find((l) => l.includes("2026-01-02"))!; + expect(coinLine).not.toContain("coins"); + expect(yarrowLine).toContain("· yarrow stalks"); + }, 20_000); + + test("plain show carries a Method line when provenance exists", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-02", 2, null, "yarrow-manual")]); + const { stdout } = await runCli(dataDir, ["journal", "show", "2026-01-02"]); + expect(stdout).toContain("Method: yarrow stalks, by hand"); + }, 20_000); + + test("plain show omits the Method line for legacy entries", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-02", 2, null)]); + const { stdout } = await runCli(dataDir, ["journal", "show", "2026-01-02"]); + expect(stdout).not.toContain("Method:"); + }, 20_000); + + // Regression: a torn line used to make `journal list` and `journal show` + // throw a SyntaxError forever. Readers now skip the damage. + test("list and show survive a torn trailing line", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + await appendFile(join(dataDir, "history.jsonl"), '{"date":"2026-01-0', "utf-8"); + + const list = await runCli(dataDir, ["journal", "list"]); + expect(list.exitCode).toBe(0); + expect(list.stdout).toContain("2026-01-01"); + + const show = await runCli(dataDir, ["journal", "show", "latest"]); + expect(show.exitCode).toBe(0); + expect(show.stdout).toContain("2026-01-01"); + }, 20_000); +}); diff --git a/apps/cli/src/__tests__/reading-flow.test.ts b/apps/cli/src/__tests__/reading-flow.test.ts index 577e2502..f98a0379 100644 --- a/apps/cli/src/__tests__/reading-flow.test.ts +++ b/apps/cli/src/__tests__/reading-flow.test.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Cast } from "@iching/core"; -import { resolvePaths, JsonDailyCacheStore } from "@iching/storage"; +import { resolvePaths, JsonDailyCacheStore, JsonlJournalStore } from "@iching/storage"; import { IntentionScene, CastScene, @@ -79,6 +79,31 @@ describe("runReadingFlow — yarrow source", () => { const cache = await deps.cacheStore.read(); expect(cache?.cast).toEqual(yarrowCast); expect(cache?.shown).toBe(true); + // Cast-method provenance is recorded at persist time + expect(cache?.method).toBe("yarrow"); + const journal = new JsonlJournalStore(deps.paths.state); + const entry = await journal.latest(); + expect(entry?.method).toBe("yarrow"); + }); + + test("auto (coin) casts record method provenance too", async () => { + const run: RunImpl = async (scene) => { + if (scene instanceof IntentionScene) return { type: "intentionConfirmed" }; + if (scene instanceof CastScene) return { type: "home" }; + }; + + const deps = makeDeps(dataDir, run); + const result = await runReadingFlow(deps, { + purpose: "cast", + source: { type: "auto" }, + }); + + expect(result.shouldExit).toBe(false); + const cache = await deps.cacheStore.read(); + expect(cache?.method).toBe("coin"); + const journal = new JsonlJournalStore(deps.paths.state); + const entry = await journal.latest(); + expect(entry?.method).toBe("coin"); }); test("quitting the ritual early persists nothing and does not reveal", async () => { diff --git a/apps/cli/src/app/reading-flow.ts b/apps/cli/src/app/reading-flow.ts index 8293395b..413b508d 100644 --- a/apps/cli/src/app/reading-flow.ts +++ b/apps/cli/src/app/reading-flow.ts @@ -10,6 +10,7 @@ import { buildStructure, castHexagram, type Cast, + type CastMethod, CryptoRandomSource, type DisplayLanguage, SeededRandomSource, @@ -50,6 +51,15 @@ export type ReadingSource = export type ReadingPurpose = "cast" | "play" | "replay"; +// Cast-method provenance recorded at persist time — replays ("existing") +// never persist, so they carry no method. +const METHOD_BY_SOURCE: Record, CastMethod> = { + "auto": "coin", + "manual": "coin-manual", + "yarrow": "yarrow", + "yarrow-manual": "yarrow-manual", +}; + export interface ReadingFlowDeps { run: (scene: Scene) => Promise; runRouter: (router: SceneRouter) => Promise<{ shouldExit: boolean }>; @@ -123,9 +133,11 @@ export async function runReadingFlow( if (!isPlay && !isReplay) { const structure = buildStructure(cast); const timestamp = new Date().toISOString(); + const method = + opts.source.type === "existing" ? undefined : METHOD_BY_SOURCE[opts.source.type]; if (!usedSeed) { const journal = new JsonlJournalStore(deps.paths.state); - await journal.append({ date: deps.today, cast, intention, timestamp }); + await journal.append({ date: deps.today, cast, intention, timestamp, method }); } await deps.cacheStore.write({ date: deps.today, @@ -133,6 +145,7 @@ export async function runReadingFlow( shown: true, structure, intention, + method, }); } diff --git a/apps/cli/src/commands/cast.ts b/apps/cli/src/commands/cast.ts index 2bb5e8e1..d5de3aed 100644 --- a/apps/cli/src/commands/cast.ts +++ b/apps/cli/src/commands/cast.ts @@ -22,7 +22,18 @@ export function registerCastCommand(program: Command): void { .argument("[question]", "question for the oracle") .action(async (question: string | undefined) => { const opts = program.opts(); - const seed = opts.seed ? Number(opts.seed) : undefined; + // Validate --seed: Number("abc") is NaN, and NaN|0 collapses the PRNG + // state to a constant — a typo'd seed would silently yield the same + // "random" cast forever. Reject anything non-numeric loudly instead. + let seed: number | undefined; + const rawSeed = opts.seed as string | undefined; + if (rawSeed !== undefined) { + seed = Number(rawSeed); + if (rawSeed.trim() === "" || !Number.isFinite(seed)) { + console.error(`Invalid --seed "${rawSeed}": expected a number.`); + process.exit(1); + } + } const source = seed !== undefined ? new SeededRandomSource(seed) diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index 9cf9c933..20d19256 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -131,112 +131,136 @@ function isConfigKey(key: string): key is keyof typeof CONFIG_SCHEMA { } export function registerConfigCommand(program: Command): void { + // Shared action bodies — each subcommand AND the git-style positional + // shorthand (`config [value]`) route through these, so both surfaces + // stay behaviorally identical (validation, exit codes, --json shapes). + const runList = async (): Promise => { + const globalOpts = program.opts(); + const paths = resolvePaths( + globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, + ); + const store = new JsonConfigStore(paths.config); + const cfg = await store.load(); + + if (globalOpts.json) { + outputJson(cfg); + } else { + for (const key of VALID_KEYS) { + const value = cfg[key]; + const schema = CONFIG_SCHEMA[key]; + const valid = schema.values ? ` (${schema.values.join("|")})` : ""; + console.log(` ${key.padEnd(12)} = ${value}${valid}`); + } + } + }; + + const runGet = async (key: string): Promise => { + const globalOpts = program.opts(); + const paths = resolvePaths( + globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, + ); + const store = new JsonConfigStore(paths.config); + const cfg = await store.load(); + + if (!isConfigKey(key)) { + console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); + process.exit(1); + } + + const value = cfg[key]; + if (globalOpts.json) { + outputJson(configToJson(key, value)); + } else { + console.log(value); + } + }; + + const runSet = async (key: string, value: string): Promise => { + const globalOpts = program.opts(); + const paths = resolvePaths( + globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, + ); + const store = new JsonConfigStore(paths.config); + + // Validate BEFORE touching the store: loadOrSeed() persists a seeded + // config on first boot, and a rejected command must not leave that side + // effect (or freeze the locale seed) behind. + if (!isConfigKey(key)) { + console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); + process.exit(1); + } + + // Canonicalize the raw value (e.g. the 繁/简/EN labels the Settings UI + // shows for `language`), then validate against allowed values. + const schema = CONFIG_SCHEMA[key]; + const resolved = schema.normalize ? schema.normalize(value) : value; + if (schema.values && !schema.values.includes(resolved)) { + console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values.join(", ")}`); + process.exit(1); + } + + // `set` WRITES the config, so on first boot it must seed the display + // language — otherwise persisting the defaulted "en" would freeze the + // locale seed before the user ever launches the TUI. (get/list stay + // pure load() — they don't write.) + const cfg = await store.loadOrSeed(); + + if (!schema.set(cfg, resolved)) { + console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values?.join(", ") ?? "any string"}`); + process.exit(1); + } + try { + await store.save(cfg); + } catch { + // A write command must fail when it can't persist — but cleanly, not + // with a raw EACCES stack trace (cf. the TUI settings-save hardening). + console.error(`Couldn't write config to ${paths.config} (read-only or full data dir?).`); + process.exit(1); + } + + if (globalOpts.json) { + outputJson(configToJson(key, resolved)); + } else { + console.log(`${key} = ${resolved}`); + } + }; + + // Git-style shorthand: `config` lists, `config ` reads, `config + // ` writes. Subcommand names win — Commander dispatches list / get / + // set / path before this action sees the operands (no config key collides + // with a subcommand name). const config = program .command("config") - .description("Manage configuration"); + .description("Manage configuration") + .argument("[key]", "config key (shorthand for get; with a value, for set)") + .argument("[value]", "config value (shorthand for set)") + .action(async (key: string | undefined, value: string | undefined) => { + if (key === undefined) { + await runList(); + } else if (value === undefined) { + await runGet(key); + } else { + await runSet(key, value); + } + }); config .command("list") .description("Show all configuration values") - .action(async () => { - const globalOpts = program.opts(); - const paths = resolvePaths( - globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, - ); - const store = new JsonConfigStore(paths.config); - const cfg = await store.load(); - - if (globalOpts.json) { - outputJson(cfg); - } else { - for (const key of VALID_KEYS) { - const value = cfg[key]; - const schema = CONFIG_SCHEMA[key]; - const valid = schema.values ? ` (${schema.values.join("|")})` : ""; - console.log(` ${key.padEnd(12)} = ${value}${valid}`); - } - } - }); + .action(runList); config .command("get") .description("Read a config value") .argument("", `config key (${VALID_KEYS.join(", ")})`) - .action(async (key: string) => { - const globalOpts = program.opts(); - const paths = resolvePaths( - globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, - ); - const store = new JsonConfigStore(paths.config); - const cfg = await store.load(); - - if (!isConfigKey(key)) { - console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); - process.exit(1); - } - - const value = cfg[key]; - if (globalOpts.json) { - outputJson(configToJson(key, value)); - } else { - console.log(value); - } - }); + .action(runGet); config .command("set") .description("Write a config value") .argument("", "config key") .argument("", "config value") - .action(async (key: string, value: string) => { - const globalOpts = program.opts(); - const paths = resolvePaths( - globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, - ); - const store = new JsonConfigStore(paths.config); - - // Validate BEFORE touching the store: loadOrSeed() persists a seeded - // config on first boot, and a rejected command must not leave that side - // effect (or freeze the locale seed) behind. - if (!isConfigKey(key)) { - console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); - process.exit(1); - } - - // Canonicalize the raw value (e.g. the 繁/简/EN labels the Settings UI - // shows for `language`), then validate against allowed values. - const schema = CONFIG_SCHEMA[key]; - const resolved = schema.normalize ? schema.normalize(value) : value; - if (schema.values && !schema.values.includes(resolved)) { - console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values.join(", ")}`); - process.exit(1); - } - - // `set` WRITES the config, so on first boot it must seed the display - // language — otherwise persisting the defaulted "en" would freeze the - // locale seed before the user ever launches the TUI. (get/list stay - // pure load() — they don't write.) - const cfg = await store.loadOrSeed(); - - if (!schema.set(cfg, resolved)) { - console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values?.join(", ") ?? "any string"}`); - process.exit(1); - } - try { - await store.save(cfg); - } catch { - // A write command must fail when it can't persist — but cleanly, not - // with a raw EACCES stack trace (cf. the TUI settings-save hardening). - console.error(`Couldn't write config to ${paths.config} (read-only or full data dir?).`); - process.exit(1); - } - - if (globalOpts.json) { - outputJson(configToJson(key, resolved)); - } else { - console.log(`${key} = ${resolved}`); - } - }); + .action(runSet); config .command("path") diff --git a/apps/cli/src/commands/journal.ts b/apps/cli/src/commands/journal.ts index af8c88a5..90c3e429 100644 --- a/apps/cli/src/commands/journal.ts +++ b/apps/cli/src/commands/journal.ts @@ -6,7 +6,7 @@ import { formatJournalListPlain, formatJournalShowPlain, } from "../output/plain.js"; -import { outputJson } from "../output/json.js"; +import { outputJson, journalEntryToJson } from "../output/json.js"; import { localToday } from "../util/today.js"; export function registerJournalCommand(program: Command): void { @@ -20,6 +20,7 @@ export function registerJournalCommand(program: Command): void { .option("--since ", "show readings since date (YYYY-MM-DD)") .option("--limit ", "maximum entries to show", "20") .option("--all", "show all entries (no limit)") + .option("--hexagram ", "only readings where hexagram is primary or becoming") .action(async (cmdOpts) => { const globalOpts = program.opts(); const paths = resolvePaths( @@ -27,10 +28,29 @@ export function registerJournalCommand(program: Command): void { ); const store = new JsonlJournalStore(paths.state); + // Validate the hexagram filter before reading anything. + let hexFilter: number | undefined; + if (cmdOpts.hexagram !== undefined) { + hexFilter = Number(cmdOpts.hexagram); + if (!Number.isInteger(hexFilter) || hexFilter < 1 || hexFilter > GUA.length) { + console.error( + `Invalid --hexagram "${cmdOpts.hexagram}": expected a number 1-${GUA.length}.`, + ); + process.exit(1); + } + } + const allEntries: HistoryEntry[] = []; const query = { since: cmdOpts.since }; for await (const entry of store.stream(query)) { + if ( + hexFilter !== undefined && + entry.cast.primary !== hexFilter && + entry.cast.becoming !== hexFilter + ) { + continue; + } allEntries.push(entry); } @@ -40,7 +60,7 @@ export function registerJournalCommand(program: Command): void { const entries = allEntries.slice(0, limit); if (globalOpts.json) { - outputJson(entries); + outputJson(entries.map(journalEntryToJson)); } else { if (entries.length === 0) { console.log("No readings found."); @@ -90,7 +110,7 @@ export function registerJournalCommand(program: Command): void { } if (globalOpts.json) { - outputJson(found); + outputJson(journalEntryToJson(found)); } else { console.log(formatJournalShowPlain(found)); } diff --git a/apps/cli/src/hook/adapter.ts b/apps/cli/src/hook/adapter.ts index b17cfdd3..ec3f02cd 100644 --- a/apps/cli/src/hook/adapter.ts +++ b/apps/cli/src/hook/adapter.ts @@ -4,7 +4,7 @@ import { selectDisplay, CryptoRandomSource, } from "@iching/core"; -import type { Cast, Structure } from "@iching/core"; +import type { Cast, CastMethod, Structure } from "@iching/core"; import { resolvePaths, JsonDailyCacheStore, @@ -55,6 +55,7 @@ export async function runHookAdapter(): Promise { let structure: Structure; let shown: boolean; let intention: string | undefined; + let method: CastMethod | undefined; // Check cache const cached = await cacheStore.read(); @@ -63,11 +64,13 @@ export async function runHookAdapter(): Promise { structure = cached.structure; shown = cached.shown; intention = cached.intention; + method = cached.method; } else { - // Fresh cast + // Fresh cast — instant coins, recorded as such cast = castHexagram(source); structure = buildStructure(cast); shown = false; + method = "coin"; } // Select display @@ -77,16 +80,17 @@ export async function runHookAdapter(): Promise { // (vs cache-first where journal entry is permanently lost) if (!shown) { const timestamp = new Date().toISOString(); - await journal.append({ date: today, cast, timestamp }); + await journal.append({ date: today, cast, timestamp, method }); } - // Then update cache (preserve intention from TUI if present) + // Then update cache (preserve intention/method from TUI if present) await cacheStore.write({ date: today, cast, shown: true, structure, intention, + method, }); // Output diff --git a/apps/cli/src/output/json.ts b/apps/cli/src/output/json.ts index d12e9329..0094beeb 100644 --- a/apps/cli/src/output/json.ts +++ b/apps/cli/src/output/json.ts @@ -1,4 +1,5 @@ -import type { Cast, Hexagram, Style } from "@iching/core"; +import type { Cast, Hexagram, HistoryEntry, Style } from "@iching/core"; +import { GUA } from "@iching/core"; import type { UserConfig } from "@iching/storage"; /** Output any value as clean JSON (no ANSI) and exit */ @@ -72,6 +73,25 @@ export function hexagramToJson( }; } +/** Resolved name block for one hexagram by KW number */ +function hexagramNames(kw: number): Record { + const hex = GUA[kw - 1]; + return { kw, n: hex.n, p: hex.p, ename: hex.ename, u: hex.u }; +} + +/** + * Structure a journal entry for JSON output — the raw HistoryEntry fields + * plus resolved primary/becoming names, so scripts don't need the data table. + * Additive: every original key is preserved unchanged. + */ +export function journalEntryToJson(entry: HistoryEntry): Record { + return { + ...entry, + primary: hexagramNames(entry.cast.primary), + becoming: entry.cast.becoming !== null ? hexagramNames(entry.cast.becoming) : null, + }; +} + /** Structure config for JSON output */ export function configToJson( key: string, diff --git a/apps/cli/src/output/plain.ts b/apps/cli/src/output/plain.ts index 4b27b01f..541704b9 100644 --- a/apps/cli/src/output/plain.ts +++ b/apps/cli/src/output/plain.ts @@ -1,4 +1,4 @@ -import type { Cast, Hexagram, Style, Structure } from "@iching/core"; +import type { Cast, CastMethod, Hexagram, Style, Structure } from "@iching/core"; import { GUA, STYLES, @@ -106,6 +106,20 @@ export function formatHexagramPlain( return lines.join("\n"); } +/** Quiet human label for cast-method provenance — a note, not a badge */ +function methodLabel(method: CastMethod): string { + switch (method) { + case "coin": + return "coins"; + case "coin-manual": + return "coins, by hand"; + case "yarrow": + return "yarrow stalks"; + case "yarrow-manual": + return "yarrow stalks, by hand"; + } +} + /** Format journal entry list as plain text */ export function formatJournalListPlain( entries: HistoryEntry[], @@ -121,7 +135,10 @@ export function formatJournalListPlain( : ""; const time = entry.timestamp ? ` ${formatTime(entry.timestamp)}` : ""; const intention = entry.intention ? ` "${entry.intention}"` : ""; - lines.push(`${entry.date}${time} ${g.u} ${g.n} (${g.p})${becoming}${intention}`); + // Coins are the ambient default; only the slower rituals earn a quiet note. + const method = + entry.method && entry.method !== "coin" ? ` · ${methodLabel(entry.method)}` : ""; + lines.push(`${entry.date}${time} ${g.u} ${g.n} (${g.p})${becoming}${intention}${method}`); } return lines.join("\n"); } @@ -137,6 +154,9 @@ export function formatJournalShowPlain(entry: HistoryEntry): string { if (entry.intention) { lines.push(`Intention: ${entry.intention}`); } + if (entry.method) { + lines.push(`Method: ${methodLabel(entry.method)}`); + } lines.push(`${g.u} ${g.n} (${g.p})${ename} — Hexagram ${entry.cast.primary}`); lines.push(""); lines.push( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 76039618..718bcfcf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export type { Structure, DailyCache, HistoryEntry, + CastMethod, } from "./types.js"; // RandomSource diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index eb3e589e..c79b82b3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -78,6 +78,13 @@ export interface Structure { becoming: { upper: TrigramInfo; lower: TrigramInfo } | null; } +/** + * How a cast's lines were obtained — instant coins, operator-guided coins, + * or the yarrow-stalk ritual (auto / operator-guided). Provenance only; it + * never changes how a cast is read. + */ +export type CastMethod = "coin" | "coin-manual" | "yarrow" | "yarrow-manual"; + /** Cache structure for daily reading */ export interface DailyCache { date: string; @@ -85,6 +92,7 @@ export interface DailyCache { shown: boolean; structure: Structure; intention?: string; + method?: CastMethod; // absent in records written before provenance landed } /** History entry (one per line in JSONL) */ @@ -93,4 +101,5 @@ export interface HistoryEntry { cast: Cast; intention?: string; timestamp?: string; + method?: CastMethod; // absent in entries written before provenance landed } diff --git a/packages/storage/src/__tests__/daily-cache-store.test.ts b/packages/storage/src/__tests__/daily-cache-store.test.ts index 1b6d8872..48caf7d9 100644 --- a/packages/storage/src/__tests__/daily-cache-store.test.ts +++ b/packages/storage/src/__tests__/daily-cache-store.test.ts @@ -68,4 +68,53 @@ describe("JsonDailyCacheStore", () => { const loaded = await deepStore.read(); expect(loaded).toEqual(record); }); + + // Corrupt-cache tolerance: a torn write or disk fault must not crash + // interactive startup forever — read() treats the file as absent and + // sidecars the bytes (cf. JsonConfigStore's corrupt handling). + describe("corrupt cache file", () => { + test("read returns null on unparseable JSON and sidecars the bytes", async () => { + const { writeFile, readFile } = await import("node:fs/promises"); + const path = join(dir, "daily-cache.json"); + await writeFile(path, '{"date":"2025-01-15","cas', "utf-8"); // torn write + + const result = await store.read(); + expect(result).toBeNull(); + + const backup = await readFile(`${path}.corrupt`, "utf-8"); + expect(backup).toBe('{"date":"2025-01-15","cas'); + }); + + test("a later corruption never clobbers the first backup", async () => { + const { writeFile, readFile } = await import("node:fs/promises"); + const path = join(dir, "daily-cache.json"); + await writeFile(path, "first-garbage", "utf-8"); + expect(await store.read()).toBeNull(); + + await writeFile(path, "second-garbage", "utf-8"); + expect(await store.read()).toBeNull(); + + // wx flag: the FIRST backup is the recoverable one + const backup = await readFile(`${path}.corrupt`, "utf-8"); + expect(backup).toBe("first-garbage"); + }); + + test("read returns null for valid JSON that is not a record", async () => { + const { writeFile } = await import("node:fs/promises"); + const path = join(dir, "daily-cache.json"); + await writeFile(path, "42", "utf-8"); + expect(await store.read()).toBeNull(); + }); + + test("a fresh write after corruption recovers normal round-trips", async () => { + const { writeFile } = await import("node:fs/promises"); + const path = join(dir, "daily-cache.json"); + await writeFile(path, "garbage", "utf-8"); + expect(await store.read()).toBeNull(); + + const record = makeCache("2025-01-16"); + await store.write(record); + expect(await store.read()).toEqual(record); + }); + }); }); diff --git a/packages/storage/src/__tests__/journal-store.test.ts b/packages/storage/src/__tests__/journal-store.test.ts index 9efb002e..fa45de1a 100644 --- a/packages/storage/src/__tests__/journal-store.test.ts +++ b/packages/storage/src/__tests__/journal-store.test.ts @@ -126,4 +126,102 @@ describe("JsonlJournalStore", () => { const result = await emptyStore.latest(); expect(result).toBeNull(); }); + + // Torn-line tolerance: a crash / power loss / ENOSPC mid-append leaves a + // partial JSON line. One bad line must never make the whole journal + // unreadable — readers skip it and surface a count. + describe("torn/malformed lines", () => { + test("stream skips a torn trailing line and keeps prior entries", async () => { + const { appendFile } = await import("node:fs/promises"); + await store.append(makeEntry("2025-01-01")); + await store.append(makeEntry("2025-01-02")); + // Torn append: no newline, truncated mid-object + await appendFile(join(dir, "history.jsonl"), '{"date":"2025-01-0', "utf-8"); + + const results: HistoryEntry[] = []; + for await (const entry of store.stream()) { + results.push(entry); + } + + expect(results.map((e) => e.date)).toEqual(["2025-01-01", "2025-01-02"]); + expect(store.skippedLines).toBe(1); + }); + + test("stream skips a torn middle line and keeps reading past it", async () => { + const { writeFile } = await import("node:fs/promises"); + const good1 = JSON.stringify(makeEntry("2025-01-01")); + const good2 = JSON.stringify(makeEntry("2025-01-03")); + await writeFile( + join(dir, "history.jsonl"), + `${good1}\n{"date":"2025-01-02","cas\n${good2}\n`, + "utf-8", + ); + + const results: HistoryEntry[] = []; + for await (const entry of store.stream()) { + results.push(entry); + } + + expect(results.map((e) => e.date)).toEqual(["2025-01-01", "2025-01-03"]); + expect(store.skippedLines).toBe(1); + }); + + test("stream skips valid JSON that is not entry-shaped", async () => { + const { writeFile } = await import("node:fs/promises"); + const good = JSON.stringify(makeEntry("2025-01-01")); + // Scalars, arrays, and objects missing date/cast are all damage, not entries + await writeFile( + join(dir, "history.jsonl"), + `42\nnull\n[1,2]\n{"date":"2025-01-02"}\n${good}\n`, + "utf-8", + ); + + const results: HistoryEntry[] = []; + for await (const entry of store.stream()) { + results.push(entry); + } + + expect(results.map((e) => e.date)).toEqual(["2025-01-01"]); + expect(store.skippedLines).toBe(4); + }); + + test("latest falls back past a torn final line to the last good entry", async () => { + const { appendFile } = await import("node:fs/promises"); + await store.append(makeEntry("2025-01-01")); + await store.append(makeEntry("2025-01-02")); + await appendFile(join(dir, "history.jsonl"), '{"date":"2025-01-0', "utf-8"); + + const last = await store.latest(); + expect(last).not.toBeNull(); + expect(last!.date).toBe("2025-01-02"); + expect(store.skippedLines).toBe(1); + }); + + test("latest returns null when every line is torn", async () => { + const { writeFile } = await import("node:fs/promises"); + const path = join(dir, "history.jsonl"); + await writeFile(path, '{"date":"2025-\n{"broken\n', "utf-8"); + + const last = await store.latest(); + expect(last).toBeNull(); + expect(store.skippedLines).toBe(2); + }); + + test("skippedLines resets between reads", async () => { + const { appendFile } = await import("node:fs/promises"); + await store.append(makeEntry("2025-01-01")); + await appendFile(join(dir, "history.jsonl"), "garbage", "utf-8"); + + await store.latest(); + expect(store.skippedLines).toBe(1); + + // A clean read must not inherit the previous count + const clean = new JsonlJournalStore(join(dir, "clean.jsonl")); + await clean.append(makeEntry("2025-02-01")); + for await (const _ of clean.stream()) { + // drain + } + expect(clean.skippedLines).toBe(0); + }); + }); }); diff --git a/packages/storage/src/__tests__/schema-shape.test.ts b/packages/storage/src/__tests__/schema-shape.test.ts index 89dfd983..979d68ea 100644 --- a/packages/storage/src/__tests__/schema-shape.test.ts +++ b/packages/storage/src/__tests__/schema-shape.test.ts @@ -111,6 +111,7 @@ describe("schema shape — cache", () => { shown: true, structure: FIXTURE_STRUCTURE, intention: "ship it?", + method: "yarrow", }; await store.write(record); const onDisk = JSON.parse(await readFile(join(dir, "cache.json"), "utf-8")); @@ -144,6 +145,7 @@ describe("schema shape — history", () => { cast: FIXTURE_CAST, intention: "ship it?", timestamp: "2026-04-26T09:30:00.000Z", + method: "coin-manual", }; await store.append(entry); const text = await readFile(join(dir, "history.jsonl"), "utf-8"); diff --git a/packages/storage/src/journal-store.ts b/packages/storage/src/journal-store.ts index e5eb785e..a6456746 100644 --- a/packages/storage/src/journal-store.ts +++ b/packages/storage/src/journal-store.ts @@ -3,12 +3,18 @@ import type { HistoryQuery } from "./types.js"; /** Append-only journal of daily readings */ export interface JournalStore { + /** + * Malformed (torn) lines skipped by the most recent stream() or latest() + * read — surfaced so callers can note quietly that some lines were damaged. + */ + readonly skippedLines: number; + /** Append a single history entry as one JSONL line */ append(entry: HistoryEntry): Promise; - /** Stream entries, optionally filtered by query */ + /** Stream entries, optionally filtered by query (skipping torn lines) */ stream(query?: HistoryQuery): AsyncIterable; - /** Return the most recently appended entry, or null */ + /** Return the most recently appended readable entry, or null */ latest(): Promise; } diff --git a/packages/storage/src/json/json-daily-cache.ts b/packages/storage/src/json/json-daily-cache.ts index b6d9400a..fc2cd89a 100644 --- a/packages/storage/src/json/json-daily-cache.ts +++ b/packages/storage/src/json/json-daily-cache.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import type { DailyCacheRecord } from "../types.js"; import type { DailyCacheStore } from "../daily-cache-store.js"; import { atomicWriteJson } from "./atomic-write.js"; @@ -6,14 +6,46 @@ import { atomicWriteJson } from "./atomic-write.js"; export class JsonDailyCacheStore implements DailyCacheStore { constructor(private readonly path: string) {} + /** The corrupt warning fires once per store instance (cf. JsonConfigStore). */ + private warnedCorrupt = false; + async read(): Promise { + let raw: string; try { - const raw = await readFile(this.path, "utf-8"); - return JSON.parse(raw) as DailyCacheRecord; + raw = await readFile(this.path, "utf-8"); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw err; } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Corrupt cache (torn write / disk fault): treat as absent so startup + // proceeds to a fresh day instead of crashing until the user deletes + // the file by hand. Copy the bytes aside first — wx: never clobber the + // FIRST backup (cf. JsonConfigStore's corrupt handling) — so the next + // daily write can't silently destroy them. + let backupOk = false; + try { + await writeFile(`${this.path}.corrupt`, raw, { encoding: "utf-8", flag: "wx" }); + backupOk = true; + } catch (err: unknown) { + // EEXIST means a backup is already safe; anything else (read-only / + // full) is best-effort — a cache must never block startup. + backupOk = (err as NodeJS.ErrnoException).code === "EEXIST"; + } + if (!this.warnedCorrupt) { + this.warnedCorrupt = true; + const saved = backupOk ? ` The old bytes are saved at ${this.path}.corrupt.` : ""; + console.error(`iching: daily cache at ${this.path} is unreadable — starting fresh.${saved}`); + } + return null; + } + // Valid JSON but not a cache record (hand-edit damage): also treat as + // absent — callers expect an object with date/cast, never a scalar. + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as DailyCacheRecord; } async write(record: DailyCacheRecord): Promise { diff --git a/packages/storage/src/json/jsonl-journal.ts b/packages/storage/src/json/jsonl-journal.ts index df6d8392..84e736e8 100644 --- a/packages/storage/src/json/jsonl-journal.ts +++ b/packages/storage/src/json/jsonl-journal.ts @@ -6,7 +6,34 @@ import type { HistoryEntry } from "@iching/core"; import type { HistoryQuery } from "../types.js"; import type { JournalStore } from "../journal-store.js"; +/** + * Parse one JSONL line into a HistoryEntry, or null when the line is torn or + * malformed — invalid JSON (a partial append from a crash / power loss / + * ENOSPC) or valid JSON that isn't entry-shaped (hand-edit damage). A single + * bad line must never make the whole journal unreadable. + */ +function parseEntryLine(line: string): HistoryEntry | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const record = parsed as Record; + if (typeof record.date !== "string") return null; + if (typeof record.cast !== "object" || record.cast === null) return null; + return parsed as HistoryEntry; +} + export class JsonlJournalStore implements JournalStore { + /** + * Malformed lines skipped by the most recent stream() or latest() call. + * Reset at the start of each read; callers may surface it as a quiet note + * (the readings themselves remain intact on the surrounding lines). + */ + skippedLines = 0; + constructor(private readonly path: string) {} async append(entry: HistoryEntry): Promise { @@ -16,6 +43,7 @@ export class JsonlJournalStore implements JournalStore { } async *stream(query?: HistoryQuery): AsyncIterable { + this.skippedLines = 0; if (!(await this.exists())) return; const rl = createInterface({ @@ -28,7 +56,12 @@ export class JsonlJournalStore implements JournalStore { const trimmed = line.trim(); if (!trimmed) continue; - const entry: HistoryEntry = JSON.parse(trimmed); + // Torn/malformed line: skip it and keep reading — never throw forever. + const entry = parseEntryLine(trimmed); + if (entry === null) { + this.skippedLines++; + continue; + } // Apply since filter if (query?.since && entry.date < query.since) continue; @@ -45,15 +78,23 @@ export class JsonlJournalStore implements JournalStore { } async latest(): Promise { + this.skippedLines = 0; if (!(await this.exists())) return null; const content = await readFile(this.path, "utf-8"); const lines = content.trimEnd().split("\n"); - // Walk backwards to find last non-empty line + // Walk backwards to find the last non-empty line that parses — a torn + // final line (interrupted append) falls through to the previous entry. for (let i = lines.length - 1; i >= 0; i--) { const trimmed = lines[i].trim(); - if (trimmed) return JSON.parse(trimmed) as HistoryEntry; + if (!trimmed) continue; + const entry = parseEntryLine(trimmed); + if (entry === null) { + this.skippedLines++; + continue; + } + return entry; } return null; diff --git a/packages/storage/src/schema-keys.ts b/packages/storage/src/schema-keys.ts index 92a5df60..36771db9 100644 --- a/packages/storage/src/schema-keys.ts +++ b/packages/storage/src/schema-keys.ts @@ -29,10 +29,10 @@ export const SCHEMA_KEYS = { }, cache: { required: ["date", "cast", "shown", "structure"], - optional: ["intention"], + optional: ["intention", "method"], }, history: { required: ["date", "cast"], - optional: ["intention", "timestamp"], + optional: ["intention", "timestamp", "method"], }, } as const satisfies Record; From 68220d5c5624f44ddb344d5674bf027efb70dfda Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 17:14:55 -0700 Subject: [PATCH 002/334] =?UTF-8?q?fix(terminal):=20input=20&=20lifecycle?= =?UTF-8?q?=20correctness=20=E2=80=94=20full=20escape=20parsing,=20paste,?= =?UTF-8?q?=20persistent=20alt-screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workstream A1: input & terminal lifecycle correctness. - key-parser: consume CSI sequences to their final byte (0x40-0x7E) and SS3 fully; map ESC[3~ to a new 'delete' KeyEvent; swallow unknown CSI/SS3/F-key sequences without emitting a spurious escape (which cancelled scenes and discarded typed intentions); buffer incomplete UTF-8 multibyte tails across feed() calls so split CJK input decodes intact - bracketed paste: enable DEC 2004 on session enter / disable on exit; parse ESC[200~...ESC[201~ into a single {type:'paste', text} event with newlines normalized; intention scene inserts pastes with newlines/tabs folded to spaces so a multi-line paste no longer submits mid-paste - persistent alt-screen: runScene now only enters/exits the session it owns (TerminalSession.isActive + ownership flag); the interactive home loop enters once around the whole loop and exits once in finally, so scene transitions repaint in place instead of flashing the user's shell; each scene start clears the screen so stale rows never survive - resize: SIGWINCH invalidates the diff baseline and clears the screen so the next frame is a full repaint (no rewrap residue) - sync output: DiffRenderer wraps each presented frame in DEC 2026 guards - crash safety: .catch on the detail-scene history hydration promise; one-time uncaughtException/unhandledRejection handlers in interactive mode restore the terminal before reporting the error with stack - midnight rollover: localToday() computed per home-loop iteration and at persist time (ReadingFlowDeps.today is now () => string) - measure: emoji/pictograph wide ranges + VS16/ZWJ/combining-mark zero-width handling so the Node (npx) fallback measures like Bun - too-small terminal: below 40x12 the loop renders a calm centered notice (new i18n key notice.tooSmall) instead of silently clipped content New tests: key-parser sequences/paste/split-UTF-8, intention delete+paste, terminal-session lifecycle, loop ownership/resize/too-small, diff-render sync wrapper, wide-char measurement, corrupt-journal hydration. 653 tests pass. Co-Authored-By: Claude Fable 5 --- apps/cli/src/__tests__/reading-flow.test.ts | 2 +- .../cli/src/__tests__/scene-factories.test.ts | 28 +- apps/cli/src/app/reading-flow.ts | 13 +- apps/cli/src/app/scene-factories.ts | 10 +- apps/cli/src/main.ts | 280 ++++++++++-------- .../src/__tests__/diff-render-sync.test.ts | 40 +++ .../__tests__/intention-scene-input.test.ts | 68 +++++ .../__tests__/key-parser-sequences.test.ts | 203 +++++++++++++ .../src/__tests__/loop-lifecycle.test.ts | 192 ++++++++++++ .../src/__tests__/measure-wide.test.ts | 57 ++++ .../src/__tests__/terminal-session.test.ts | 99 +++++++ packages/terminal/src/ansi/codes.ts | 8 + packages/terminal/src/i18n/messages.ts | 4 + packages/terminal/src/input/key-parser.ts | 243 ++++++++++----- packages/terminal/src/layout/measure.ts | 65 +++- packages/terminal/src/render/diff-render.ts | 7 +- packages/terminal/src/scene/loop.ts | 73 ++++- .../src/scenes/intention/intention-scene.ts | 13 + .../terminal/src/session/terminal-session.ts | 28 +- 19 files changed, 1203 insertions(+), 230 deletions(-) create mode 100644 packages/terminal/src/__tests__/diff-render-sync.test.ts create mode 100644 packages/terminal/src/__tests__/intention-scene-input.test.ts create mode 100644 packages/terminal/src/__tests__/key-parser-sequences.test.ts create mode 100644 packages/terminal/src/__tests__/loop-lifecycle.test.ts create mode 100644 packages/terminal/src/__tests__/measure-wide.test.ts create mode 100644 packages/terminal/src/__tests__/terminal-session.test.ts diff --git a/apps/cli/src/__tests__/reading-flow.test.ts b/apps/cli/src/__tests__/reading-flow.test.ts index 577e2502..352bfab6 100644 --- a/apps/cli/src/__tests__/reading-flow.test.ts +++ b/apps/cli/src/__tests__/reading-flow.test.ts @@ -24,7 +24,7 @@ function makeDeps(dataDir: string, run: RunImpl): ReadingFlowDeps { runRouter: async () => ({ shouldExit: false }), paths, cacheStore: new JsonDailyCacheStore(paths.cache), - today: "2026-05-20", + today: () => "2026-05-20", session: { cols: 80, rows: 24 }, glyphConfig: { glyphAnim: "dots", glyphFont: "kaiti" }, language: "zh-Hant", diff --git a/apps/cli/src/__tests__/scene-factories.test.ts b/apps/cli/src/__tests__/scene-factories.test.ts index 9f778a17..3e9359c0 100644 --- a/apps/cli/src/__tests__/scene-factories.test.ts +++ b/apps/cli/src/__tests__/scene-factories.test.ts @@ -4,13 +4,14 @@ // SceneSignal objects instead of dotted strings. import { describe, test, expect, beforeEach } from "bun:test"; -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { JsonlJournalStore } from "@iching/storage"; import { BrowseScene, DetailScene, JournalScene } from "@iching/terminal"; import { makeBrowseFactory, + makeDetailScene, makeJournalFactory, } from "../app/scene-factories.ts"; @@ -44,6 +45,31 @@ describe("makeBrowseFactory", () => { }); }); +describe("makeDetailScene — history hydration crash safety", () => { + test("a corrupt journal line never escapes as an unhandled rejection", async () => { + const dir = await mkdtemp(join(tmpdir(), "detail-hydration-test-")); + const path = join(dir, "history.jsonl"); + await writeFile(path, "this is not json\n", "utf-8"); + const journal = new JsonlJournalStore(path); + + // Without the .catch on the hydration promise this rejection would kill + // the process outside runScene's restore path (terminal left raw). + let unhandled: unknown = null; + const onUnhandled = (err: unknown) => { + unhandled = err; + }; + process.on("unhandledRejection", onUnhandled); + try { + const scene = makeDetailScene(1, { journal }); + expect(scene).toBeInstanceOf(DetailScene); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(unhandled).toBeNull(); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +}); + describe("makeJournalFactory", () => { let dir: string; let journal: JsonlJournalStore; diff --git a/apps/cli/src/app/reading-flow.ts b/apps/cli/src/app/reading-flow.ts index 8293395b..7607807b 100644 --- a/apps/cli/src/app/reading-flow.ts +++ b/apps/cli/src/app/reading-flow.ts @@ -55,7 +55,12 @@ export interface ReadingFlowDeps { runRouter: (router: SceneRouter) => Promise<{ shouldExit: boolean }>; paths: ResolvedPaths; cacheStore: JsonDailyCacheStore; - today: string; + /** + * Returns today's local date (YYYY-MM-DD). Called at persist time — not at + * flow start — so a reading that crosses midnight is stamped with the day + * it actually completed. + */ + today: () => string; session: SessionDims; glyphConfig: CastGlyphInput; language: DisplayLanguage; @@ -123,12 +128,14 @@ export async function runReadingFlow( if (!isPlay && !isReplay) { const structure = buildStructure(cast); const timestamp = new Date().toISOString(); + // One call so journal and cache agree even at a midnight boundary. + const date = deps.today(); if (!usedSeed) { const journal = new JsonlJournalStore(deps.paths.state); - await journal.append({ date: deps.today, cast, intention, timestamp }); + await journal.append({ date, cast, intention, timestamp }); } await deps.cacheStore.write({ - date: deps.today, + date, cast, shown: true, structure, diff --git a/apps/cli/src/app/scene-factories.ts b/apps/cli/src/app/scene-factories.ts index b2f4f3ab..c1bc2055 100644 --- a/apps/cli/src/app/scene-factories.ts +++ b/apps/cli/src/app/scene-factories.ts @@ -35,9 +35,13 @@ 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, deps.language); - getHexagramHistory(deps.journal, kw).then((h) => - scene.setHistory(h.castCount, h.lastCastDate), - ); + getHexagramHistory(deps.journal, kw) + .then((h) => scene.setHistory(h.castCount, h.lastCastDate)) + .catch(() => { + // A corrupt journal must not surface as an unhandled rejection (which + // would kill the process outside runScene's restore path) — the detail + // scene simply renders without cast history. + }); return scene; } diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index bf46fbba..980a0c0b 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -38,7 +38,6 @@ async function main() { const devMode = !!opts.dev; const paths = resolvePaths(opts.dataDir ? { dataDir: opts.dataDir } : undefined); const cacheStore = new JsonDailyCacheStore(paths.cache); - const today = localToday(); // 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 @@ -65,145 +64,168 @@ async function main() { const runRouter = (router: InstanceType) => router.run(session, clock, colorSupport, devMode, language); + // Crash safety: if anything escapes the scene stack (floating promise + // rejections, programming errors), restore the terminal before dying so + // the user's shell isn't left in raw mode on the alt screen. + const onFatal = (err: unknown) => { + session.exit(); + console.error(err instanceof Error ? err.stack ?? err.message : String(err)); + process.exit(1); + }; + process.once("uncaughtException", onFatal); + process.once("unhandledRejection", onFatal); + + // Hold one alt-screen session across the whole home loop — scene + // transitions repaint in place instead of flashing the user's shell. + session.enter(); + // Home menu loop — keeps returning to home until exit let running = true; - while (running) { - const currentCache = await cacheStore.read(); - const homeScene = new HomeScene({ - todayCast: currentCache?.date === today ? currentCache : null, - taijituStyle, - devMode: !!opts.dev, - }); - - const signal = await run(homeScene); - - if (!signal || signal.type === "exit") { - running = false; - break; - } - - // Build per-iteration deps so any settings updates from prior iterations are picked up. - const flowDeps = { - run, runRouter, - paths, cacheStore, today, - session: { cols: session.cols, rows: session.rows }, - glyphConfig, - language, - motion: savedConfig.motion ?? "default", - }; - - // Derive the reading source from the (castMethod, castMode) pair. - // Cast and Play share this — Play is "sandbox-Cast" minus persistence. - const castSource = (): Parameters[1]["source"] => { - if (castMethod === "yarrow") { - return castMode === "manual" ? { type: "yarrow-manual" } : { type: "yarrow" }; - } - return castMode === "manual" - ? { type: "manual" } - : { type: "auto", seed: opts.seed ? Number(opts.seed) : undefined }; - }; - - switch (signal.type) { - case "startPlay": { - const result = await runReadingFlow(flowDeps, { - purpose: "play", - source: castSource(), - }); - if (result.shouldExit) running = false; + try { + while (running) { + // Computed per iteration (not once at startup) so a session left open + // past midnight rolls over to the new day's reading. + const today = localToday(); + const currentCache = await cacheStore.read(); + const homeScene = new HomeScene({ + todayCast: currentCache?.date === today ? currentCache : null, + taijituStyle, + devMode: !!opts.dev, + }); + + const signal = await run(homeScene); + + if (!signal || signal.type === "exit") { + running = false; break; } - case "startCast": { - const result = await runReadingFlow(flowDeps, { - purpose: "cast", - source: castSource(), - }); - if (result.shouldExit) running = false; - break; - } + // Build per-iteration deps so any settings updates from prior iterations are picked up. + const flowDeps = { + run, runRouter, + paths, cacheStore, today: localToday, + session: { cols: session.cols, rows: session.rows }, + glyphConfig, + language, + motion: savedConfig.motion ?? "default", + }; + + // Derive the reading source from the (castMethod, castMode) pair. + // Cast and Play share this — Play is "sandbox-Cast" minus persistence. + const castSource = (): Parameters[1]["source"] => { + if (castMethod === "yarrow") { + return castMode === "manual" ? { type: "yarrow-manual" } : { type: "yarrow" }; + } + return castMode === "manual" + ? { type: "manual" } + : { type: "auto", seed: opts.seed ? Number(opts.seed) : undefined }; + }; + + switch (signal.type) { + case "startPlay": { + const result = await runReadingFlow(flowDeps, { + purpose: "play", + source: castSource(), + }); + if (result.shouldExit) running = false; + break; + } - case "openDictionary": { - const journal = new JsonlJournalStore(paths.state); - const router = new SceneRouter( - new BrowseScene(), - makeBrowseFactory({ glyphConfig, language, journal }), - ); - const result = await runRouter(router); - if (result.shouldExit) running = false; - break; - } + case "startCast": { + const result = await runReadingFlow(flowDeps, { + purpose: "cast", + source: castSource(), + }); + if (result.shouldExit) running = false; + break; + } - case "openJournal": { - const journal = new JsonlJournalStore(paths.state); - const entries = await loadJournalEntries(journal); - const router = new SceneRouter( - new JournalScene(entries), - makeJournalFactory({ - glyphConfig, - language, - journal, - entries, - session: { cols: session.cols, rows: session.rows }, - }), - ); - const result = await runRouter(router); - if (result.shouldExit) running = false; - break; - } + case "openDictionary": { + const journal = new JsonlJournalStore(paths.state); + const router = new SceneRouter( + new BrowseScene(), + makeBrowseFactory({ glyphConfig, language, journal }), + ); + const result = await runRouter(router); + if (result.shouldExit) running = false; + break; + } - case "openSettings": { - const config = await configStore.load(); - const settingsScene = new SettingsScene({ - theme: config.theme, - language: config.language, - taijituStyle: config.taijituStyle, - glyphAnim: config.glyphAnim, - glyphFont: config.glyphFont, - castMethod: config.castMethod ?? "coin", - castMode: config.castMode ?? "auto", - }); - const settingsSignal = await run(settingsScene); - // Save on escape (signal "home"); revert on Ctrl+C ("exit") - if (settingsSignal?.type === "home") { - const updated = settingsScene.getValues(); - const newConfig = await configStore.load(); - newConfig.theme = updated.theme; - newConfig.language = updated.language; - newConfig.taijituStyle = updated.taijituStyle; - newConfig.glyphAnim = updated.glyphAnim; - newConfig.glyphFont = updated.glyphFont; - newConfig.castMethod = updated.castMethod; - newConfig.castMode = updated.castMode; - // Best-effort persist: a read-only / full data dir must not crash - // "save & back" — the deferred-seed session explicitly supports - // reopening Settings. Apply the changes live regardless. - try { - await configStore.save(newConfig); - } catch { - console.error( - "iching: couldn't save settings (read-only data dir?); changes apply for this session only.", - ); + case "openJournal": { + const journal = new JsonlJournalStore(paths.state); + const entries = await loadJournalEntries(journal); + const router = new SceneRouter( + new JournalScene(entries), + makeJournalFactory({ + glyphConfig, + language, + journal, + entries, + session: { cols: session.cols, rows: session.rows }, + }), + ); + const result = await runRouter(router); + if (result.shouldExit) running = false; + break; + } + + case "openSettings": { + const config = await configStore.load(); + const settingsScene = new SettingsScene({ + theme: config.theme, + language: config.language, + taijituStyle: config.taijituStyle, + glyphAnim: config.glyphAnim, + glyphFont: config.glyphFont, + castMethod: config.castMethod ?? "coin", + castMode: config.castMode ?? "auto", + }); + const settingsSignal = await run(settingsScene); + // Save on escape (signal "home"); revert on Ctrl+C ("exit") + if (settingsSignal?.type === "home") { + const updated = settingsScene.getValues(); + const newConfig = await configStore.load(); + newConfig.theme = updated.theme; + newConfig.language = updated.language; + newConfig.taijituStyle = updated.taijituStyle; + newConfig.glyphAnim = updated.glyphAnim; + newConfig.glyphFont = updated.glyphFont; + newConfig.castMethod = updated.castMethod; + newConfig.castMode = updated.castMode; + // Best-effort persist: a read-only / full data dir must not crash + // "save & back" — the deferred-seed session explicitly supports + // reopening Settings. Apply the changes live regardless. + try { + await configStore.save(newConfig); + } catch { + console.error( + "iching: couldn't save settings (read-only data dir?); changes apply for this session only.", + ); + } + setTheme(updated.theme); + language = updated.language; + taijituStyle = updated.taijituStyle; + castMethod = updated.castMethod; + castMode = updated.castMode; + glyphConfig = { + glyphAnim: updated.glyphAnim, + glyphFont: updated.glyphFont, + }; + } else if (settingsSignal?.type === "exit") { + // Ctrl+C: revert theme to saved state and exit + setTheme(config.theme); + running = false; } - setTheme(updated.theme); - language = updated.language; - taijituStyle = updated.taijituStyle; - castMethod = updated.castMethod; - castMode = updated.castMode; - glyphConfig = { - glyphAnim: updated.glyphAnim, - glyphFont: updated.glyphFont, - }; - } else if (settingsSignal?.type === "exit") { - // Ctrl+C: revert theme to saved state and exit - setTheme(config.theme); - running = false; + break; } - break; - } - default: - break; + default: + break; + } } + } finally { + // Leave the alt screen exactly once, after the last scene + session.exit(); } process.exit(0); @@ -214,6 +236,6 @@ async function main() { } main().catch((err) => { - console.error(err.message); + console.error(err instanceof Error ? err.stack ?? err.message : String(err)); process.exit(1); }); diff --git a/packages/terminal/src/__tests__/diff-render-sync.test.ts b/packages/terminal/src/__tests__/diff-render-sync.test.ts new file mode 100644 index 00000000..509e5542 --- /dev/null +++ b/packages/terminal/src/__tests__/diff-render-sync.test.ts @@ -0,0 +1,40 @@ +// DiffRenderer synchronized output (DEC 2026) — frames present atomically. + +import { describe, test, expect } from "bun:test"; +import { CellBuffer } from "../render/buffer.ts"; +import { DiffRenderer } from "../render/diff-render.ts"; + +function mockOutput() { + const writes: string[] = []; + return { + write(data: string) { + writes.push(data); + return true; + }, + writes, + }; +} + +describe("DiffRenderer — synchronized output", () => { + test("non-empty frames are wrapped in DEC 2026 begin/end guards", () => { + const out = mockOutput(); + const renderer = new DiffRenderer(out, "truecolor"); + const prev = CellBuffer.create(10, 3); + const next = CellBuffer.create(10, 3); + next.writeText(1, 0, "frame"); + renderer.present(prev, next); + expect(out.writes).toHaveLength(1); + const output = out.writes[0]; + expect(output.startsWith("\x1b[?2026h")).toBe(true); + expect(output.endsWith("\x1b[?2026l")).toBe(true); + }); + + test("identical buffers emit nothing — no empty sync wrappers", () => { + const out = mockOutput(); + const renderer = new DiffRenderer(out, "truecolor"); + const a = CellBuffer.create(10, 3); + const b = CellBuffer.create(10, 3); + renderer.present(a, b); + expect(out.writes).toHaveLength(0); + }); +}); diff --git a/packages/terminal/src/__tests__/intention-scene-input.test.ts b/packages/terminal/src/__tests__/intention-scene-input.test.ts new file mode 100644 index 00000000..f0a23ec9 --- /dev/null +++ b/packages/terminal/src/__tests__/intention-scene-input.test.ts @@ -0,0 +1,68 @@ +// IntentionScene input handling — delete key and bracketed paste. + +import { describe, test, expect } from "bun:test"; +import { IntentionScene } from "../scenes/intention/intention-scene.ts"; +import type { SceneContext } from "../scene/types.ts"; + +function ctx(): SceneContext { + return { cols: 80, rows: 24, colorSupport: "truecolor", done: false }; +} + +function type(scene: IntentionScene, text: string): void { + for (const char of text) { + scene.handleKey({ type: "char", char }, ctx()); + } +} + +describe("IntentionScene — delete key", () => { + test("delete removes the character at the cursor", () => { + const scene = new IntentionScene(); + type(scene, "ab"); + scene.handleKey({ type: "home" }, ctx()); + scene.handleKey({ type: "delete" }, ctx()); + scene.handleKey({ type: "enter" }, ctx()); + expect(scene.getIntention()).toBe("b"); + }); + + test("delete at the end of input is a no-op (no scene cancel)", () => { + const scene = new IntentionScene(); + type(scene, "abc"); + const signal = scene.handleKey({ type: "delete" }, ctx()); + expect(signal).toBeUndefined(); // crucially NOT { type: "home" } + scene.handleKey({ type: "enter" }, ctx()); + expect(scene.getIntention()).toBe("abc"); + }); +}); + +describe("IntentionScene — paste", () => { + test("pasted text is inserted as a block", () => { + const scene = new IntentionScene(); + scene.handleKey({ type: "paste", text: "what should I hold?" }, ctx()); + scene.handleKey({ type: "enter" }, ctx()); + expect(scene.getIntention()).toBe("what should I hold?"); + }); + + test("newlines in a paste fold to spaces instead of submitting", () => { + const scene = new IntentionScene(); + const signal = scene.handleKey({ type: "paste", text: "hi\nthere" }, ctx()); + expect(signal).toBeUndefined(); // paste must not confirm the intention + scene.handleKey({ type: "enter" }, ctx()); + expect(scene.getIntention()).toBe("hi there"); + }); + + test("tabs fold to spaces and control chars are dropped", () => { + const scene = new IntentionScene(); + scene.handleKey({ type: "paste", text: "a\tb\x07c" }, ctx()); + scene.handleKey({ type: "enter" }, ctx()); + expect(scene.getIntention()).toBe("a bc"); + }); + + test("paste lands at the cursor position", () => { + const scene = new IntentionScene(); + scene.handleKey({ type: "char", char: "x" }, ctx()); + scene.handleKey({ type: "home" }, ctx()); + scene.handleKey({ type: "paste", text: "問 " }, ctx()); + scene.handleKey({ type: "enter" }, ctx()); + expect(scene.getIntention()).toBe("問 x"); + }); +}); diff --git a/packages/terminal/src/__tests__/key-parser-sequences.test.ts b/packages/terminal/src/__tests__/key-parser-sequences.test.ts new file mode 100644 index 00000000..2bc0ccb9 --- /dev/null +++ b/packages/terminal/src/__tests__/key-parser-sequences.test.ts @@ -0,0 +1,203 @@ +// Regression tests for full CSI/SS3 sequence consumption, the delete key, +// bracketed paste, and split-UTF-8 buffering. The bug class these pin: +// unknown sequences used to leak a spurious escape (which cancels scenes) +// plus their remainder bytes as literal chars. + +import { describe, test, expect } from "bun:test"; +import { parseKey, KeyParser, type KeyEvent } from "../input/key-parser.ts"; + +function bytes(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +function collect(): { events: KeyEvent[]; parser: KeyParser } { + const events: KeyEvent[] = []; + const parser = new KeyParser((e) => events.push(e)); + return { events, parser }; +} + +describe("parseKey — full CSI consumption", () => { + test("Delete (ESC [ 3 ~) parses as delete, not escape + '~'", () => { + expect(parseKey(new Uint8Array([0x1b, 0x5b, 0x33, 0x7e]))).toEqual({ type: "delete" }); + }); + + test("Ctrl+Delete (ESC [ 3 ; 5 ~) parses as delete", () => { + expect(parseKey(bytes("\x1b[3;5~"))).toEqual({ type: "delete" }); + }); + + test("modified arrows map to plain arrows (Ctrl+Right ESC [ 1 ; 5 C)", () => { + expect(parseKey(bytes("\x1b[1;5C"))).toEqual({ type: "arrow", direction: "right" }); + expect(parseKey(bytes("\x1b[1;2A"))).toEqual({ type: "arrow", direction: "up" }); + }); + + test("modified Home/End (ESC [ 1 ; 5 H / F)", () => { + expect(parseKey(bytes("\x1b[1;5H"))).toEqual({ type: "home" }); + expect(parseKey(bytes("\x1b[1;5F"))).toEqual({ type: "end" }); + }); + + test("F5 (ESC [ 1 5 ~) is swallowed — no escape", () => { + expect(parseKey(bytes("\x1b[15~"))).toBeNull(); + }); + + test("Insert (ESC [ 2 ~) is swallowed", () => { + expect(parseKey(bytes("\x1b[2~"))).toBeNull(); + }); + + test("Shift+Tab (ESC [ Z) is swallowed", () => { + expect(parseKey(bytes("\x1b[Z"))).toBeNull(); + }); + + test("SS3 F1 (ESC O P) is swallowed", () => { + expect(parseKey(bytes("\x1bOP"))).toBeNull(); + }); + + test("SS3 application-mode arrows still navigate", () => { + expect(parseKey(bytes("\x1bOA"))).toEqual({ type: "arrow", direction: "up" }); + expect(parseKey(bytes("\x1bOD"))).toEqual({ type: "arrow", direction: "left" }); + expect(parseKey(bytes("\x1bOH"))).toEqual({ type: "home" }); + }); +}); + +describe("KeyParser — unknown sequences never eject the user", () => { + test("Delete key emits exactly one delete event", () => { + const { events, parser } = collect(); + parser.feed(new Uint8Array([0x1b, 0x5b, 0x33, 0x7e])); + expect(events).toEqual([{ type: "delete" }]); + parser.dispose(); + }); + + test("Ctrl+Right emits no escape and no leaked chars", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[1;5C")); + expect(events).toEqual([{ type: "arrow", direction: "right" }]); + parser.dispose(); + }); + + test("F1 (SS3) emits nothing", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1bOP")); + expect(events).toEqual([]); + parser.dispose(); + }); + + test("F12 (ESC [ 2 4 ~) followed by typed text only emits the text", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[24~hi")); + expect(events).toEqual([ + { type: "char", char: "h" }, + { type: "char", char: "i" }, + ]); + parser.dispose(); + }); + + test("CSI split across feeds still resolves to a single event", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[1;5")); + expect(events).toEqual([]); // incomplete — buffered + parser.feed(bytes("C")); + expect(events).toEqual([{ type: "arrow", direction: "right" }]); + parser.dispose(); + }); + + test("lone ESC still flushes as escape after the timeout", async () => { + const { events, parser } = collect(); + parser.feed(new Uint8Array([0x1b])); + expect(events).toEqual([]); + await new Promise((r) => setTimeout(r, 70)); + expect(events).toEqual([{ type: "escape" }]); + parser.dispose(); + }); +}); + +describe("KeyParser — bracketed paste", () => { + test("paste block becomes a single paste event", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[200~hello\x1b[201~")); + expect(events).toEqual([{ type: "paste", text: "hello" }]); + parser.dispose(); + }); + + test("newlines inside a paste do not become enter events", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[200~hi\rthere\x1b[201~")); + expect(events).toEqual([{ type: "paste", text: "hi\nthere" }]); + parser.dispose(); + }); + + test("CRLF is normalized to a single newline", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[200~a\r\nb\x1b[201~")); + expect(events).toEqual([{ type: "paste", text: "a\nb" }]); + parser.dispose(); + }); + + test("paste content split across feeds is accumulated", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[200~one ")); + parser.feed(bytes("two ")); + parser.feed(bytes("three\x1b[201~")); + expect(events).toEqual([{ type: "paste", text: "one two three" }]); + parser.dispose(); + }); + + test("paste markers split mid-sequence still resolve", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[20")); // half a start marker — buffered as incomplete CSI + parser.feed(bytes("0~cjk 世界\x1b[2")); + parser.feed(bytes("01~")); + expect(events).toEqual([{ type: "paste", text: "cjk 世界" }]); + parser.dispose(); + }); + + test("keys after the paste block parse normally", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[200~x\x1b[201~\rq")); + expect(events).toEqual([ + { type: "paste", text: "x" }, + { type: "enter" }, + { type: "char", char: "q" }, + ]); + parser.dispose(); + }); + + test("stray paste-end marker without a start is swallowed", () => { + const { events, parser } = collect(); + parser.feed(bytes("\x1b[201~a")); + expect(events).toEqual([{ type: "char", char: "a" }]); + parser.dispose(); + }); +}); + +describe("KeyParser — split UTF-8 buffering", () => { + test("3-byte CJK char split across two feeds decodes intact", () => { + const { events, parser } = collect(); + const cjk = bytes("世"); // e4 b8 96 + parser.feed(cjk.subarray(0, 1)); + expect(events).toEqual([]); + parser.feed(cjk.subarray(1)); + expect(events).toEqual([{ type: "char", char: "世" }]); + parser.dispose(); + }); + + test("4-byte char split 2+2 decodes intact", () => { + const { events, parser } = collect(); + const glyph = bytes("𠀀"); // U+20000, 4 bytes + parser.feed(glyph.subarray(0, 2)); + expect(events).toEqual([]); + parser.feed(glyph.subarray(2)); + expect(events).toEqual([{ type: "char", char: "𠀀" }]); + parser.dispose(); + }); + + test("split char followed by ascii in the same chunk", () => { + const { events, parser } = collect(); + const cjk = bytes("問"); + parser.feed(cjk.subarray(0, 2)); + parser.feed(new Uint8Array([...cjk.subarray(2), 0x61])); // rest + 'a' + expect(events).toEqual([ + { type: "char", char: "問" }, + { type: "char", char: "a" }, + ]); + parser.dispose(); + }); +}); diff --git a/packages/terminal/src/__tests__/loop-lifecycle.test.ts b/packages/terminal/src/__tests__/loop-lifecycle.test.ts new file mode 100644 index 00000000..64fc9407 --- /dev/null +++ b/packages/terminal/src/__tests__/loop-lifecycle.test.ts @@ -0,0 +1,192 @@ +// runScene lifecycle guards — persistent-session ownership, resize repaint, +// and the too-small-terminal placeholder. + +import { describe, test, expect } from "bun:test"; +import { ManualClock } from "../clock.ts"; +import { runScene, renderTooSmallNotice, MIN_COLS, MIN_ROWS } from "../scene/loop.ts"; +import type { Scene, SceneContext } from "../scene/types.ts"; +import { CellBuffer } from "../render/buffer.ts"; +import { TerminalSession } from "../session/terminal-session.ts"; + +function mockStdout(columns = 80, rows = 24) { + const writes: string[] = []; + return { + write(data: string) { + writes.push(data); + return true; + }, + columns, + rows, + writes, + }; +} + +function mockStdin() { + const handlers: Record = {}; + return { + isTTY: false, + resume() {}, + pause() {}, + setRawMode(_mode: boolean) {}, + on(event: string, handler: Function) { + (handlers[event] ??= []).push(handler); + }, + off(event: string, handler: Function) { + const list = handlers[event]; + if (list) { + const idx = list.indexOf(handler); + if (idx >= 0) list.splice(idx, 1); + } + }, + } as unknown as typeof process.stdin; +} + +/** Scene that exits after `frames` update calls. */ +function framesScene(frames: number, hooks?: Partial): Scene { + let count = 0; + return { + update(_elapsed, _dt, ctx) { + count++; + if (count >= frames) ctx.done = true; + }, + render() {}, + ...hooks, + }; +} + +function rowText(buf: CellBuffer, row: number): string { + let out = ""; + for (let col = 0; col < buf.width; col++) { + // Wide chars leave "" continuation cells — concatenating raw chars + // reconstructs the visible text without phantom gaps. + out += buf.getCell(row, col).char; + } + return out; +} + +describe("runScene — session ownership", () => { + test("standalone run enters and exits the session (one-shot commands)", async () => { + const stdout = mockStdout(); + const session = new TerminalSession(stdout, mockStdin()); + await runScene(framesScene(1), session, new ManualClock(), "truecolor"); + expect(session.isActive).toBe(false); + const out = stdout.writes.join(""); + expect(out).toContain("\x1b[?1049h"); + expect(out).toContain("\x1b[?1049l"); + }); + + test("an externally-held session stays active across scene runs", async () => { + const stdout = mockStdout(); + const session = new TerminalSession(stdout, mockStdin()); + session.enter(); + + await runScene(framesScene(1), session, new ManualClock(), "truecolor"); + await runScene(framesScene(1), session, new ManualClock(), "truecolor"); + + // Neither run left the alt screen — no shell flash between scenes + expect(session.isActive).toBe(true); + expect(stdout.writes.join("")).not.toContain("\x1b[?1049l"); + const altOns = stdout.writes.join("").split("\x1b[?1049h").length - 1; + expect(altOns).toBe(1); + + session.exit(); + expect(stdout.writes.join("")).toContain("\x1b[?1049l"); + }); + + test("each scene start clears the screen so stale rows never survive", async () => { + const stdout = mockStdout(); + const session = new TerminalSession(stdout, mockStdin()); + session.enter(); + const clearsAfterEnter = stdout.writes.join("").split("\x1b[2J").length - 1; + + await runScene(framesScene(1), session, new ManualClock(), "truecolor"); + + const clearsAfterScene = stdout.writes.join("").split("\x1b[2J").length - 1; + expect(clearsAfterScene).toBe(clearsAfterEnter + 1); + session.exit(); + }); +}); + +describe("runScene — resize repaint", () => { + test("SIGWINCH invalidates the diff baseline and clears the screen", async () => { + const stdout = mockStdout(); + const session = new TerminalSession(stdout, mockStdin()); + let frames = 0; + const dims: Array<{ cols: number; rows: number }> = []; + const scene: Scene = { + update(_elapsed, _dt, ctx) { + frames++; + if (frames === 2) { + stdout.columns = 100; + stdout.rows = 30; + process.emit("SIGWINCH"); + } + dims.push({ cols: ctx.cols, rows: ctx.rows }); + if (frames >= 3) ctx.done = true; + }, + render() {}, + }; + + await runScene(scene, session, new ManualClock(), "truecolor"); + + // ctx picked up the new dimensions + expect(dims[2]).toEqual({ cols: 100, rows: 30 }); + // enter() clears once, scene start clears once, resize clears once more + const clears = stdout.writes.join("").split("\x1b[2J").length - 1; + expect(clears).toBe(3); + }); +}); + +describe("runScene — too-small terminal", () => { + test("below the floor the scene render is replaced by the notice", async () => { + const stdout = mockStdout(30, 8); + const session = new TerminalSession(stdout, mockStdin()); + let rendered = false; + const scene = framesScene(1, { + render() { + rendered = true; + }, + }); + await runScene(scene, session, new ManualClock(), "truecolor"); + expect(rendered).toBe(false); + }); + + test("at or above the floor the scene renders normally", async () => { + const stdout = mockStdout(MIN_COLS, MIN_ROWS); + const session = new TerminalSession(stdout, mockStdin()); + let rendered = false; + const scene = framesScene(1, { + render() { + rendered = true; + }, + }); + await runScene(scene, session, new ManualClock(), "truecolor"); + expect(rendered).toBe(true); + }); +}); + +describe("renderTooSmallNotice", () => { + function ctx(cols: number, rows: number, language?: "en" | "zh-Hant" | "zh-Hans"): SceneContext { + return { cols, rows, colorSupport: "truecolor", language, done: false }; + } + + test("renders the centered notice and the size floor", () => { + const frame = CellBuffer.create(38, 10); + renderTooSmallNotice(frame, ctx(38, 10)); + const all = Array.from({ length: frame.height }, (_, r) => rowText(frame, r)).join("\n"); + expect(all).toContain("the window is too small"); + expect(all).toContain(`${MIN_COLS} × ${MIN_ROWS}`); + }); + + test("localizes the notice", () => { + const frame = CellBuffer.create(38, 10); + renderTooSmallNotice(frame, ctx(38, 10, "zh-Hant")); + const all = Array.from({ length: frame.height }, (_, r) => rowText(frame, r)).join("\n"); + expect(all).toContain("視窗過小"); + }); + + test("survives extremely small frames without throwing", () => { + const frame = CellBuffer.create(4, 2); + expect(() => renderTooSmallNotice(frame, ctx(4, 2))).not.toThrow(); + }); +}); diff --git a/packages/terminal/src/__tests__/measure-wide.test.ts b/packages/terminal/src/__tests__/measure-wide.test.ts new file mode 100644 index 00000000..a41798fd --- /dev/null +++ b/packages/terminal/src/__tests__/measure-wide.test.ts @@ -0,0 +1,57 @@ +// Width measurement for emoji / zero-width characters — these go through the +// hardcoded isWideChar/isZeroWidthChar tables, which is exactly the path the +// Node fallback (npx) takes, so Bun and Node measure identically. + +import { describe, test, expect } from "bun:test"; +import { stringWidth } from "../layout/measure.ts"; + +describe("stringWidth — emoji and symbols", () => { + test("common emoji measure 2 columns", () => { + expect(stringWidth("\u{1f600}")).toBe(2); // 😀 + expect(stringWidth("\u{1f327}")).toBe(2); // 🌧 + expect(stringWidth("\u{1f680}")).toBe(2); // 🚀 + expect(stringWidth("\u{1f9d8}")).toBe(2); // 🧘 + expect(stringWidth("\u{1fab7}")).toBe(2); // 🪷 lotus + }); + + test("wide BMP emoji measure 2 columns", () => { + expect(stringWidth("⌛")).toBe(2); // ⌛ hourglass + expect(stringWidth("⭐")).toBe(2); // ⭐ + expect(stringWidth("✨")).toBe(2); // ✨ + expect(stringWidth("☔")).toBe(2); // ☔ + }); + + test("mixed emoji + CJK + ascii", () => { + expect(stringWidth("hi \u{1f600} 世界")).toBe(2 + 1 + 2 + 1 + 4); + }); +}); + +describe("stringWidth — zero-width characters", () => { + test("VS16 and ZWJ measure 0 columns", () => { + expect(stringWidth("️")).toBe(0); // variation selector 16 + expect(stringWidth("‍")).toBe(0); // zero-width joiner + }); + + test("emoji + VS16 measures as the emoji alone", () => { + expect(stringWidth("⭐️")).toBe(2); // ⭐ + VS16 + }); + + test("ZWJ family sequence counts component glyphs only", () => { + // 👨‍👩‍👧 = 1F468 ZWJ 1F469 ZWJ 1F467 — three wide glyphs, joiners free + expect(stringWidth("\u{1f468}‍\u{1f469}‍\u{1f467}")).toBe(6); + }); + + test("combining marks measure 0 columns", () => { + expect(stringWidth("é")).toBe(1); // e + combining acute + }); +}); + +describe("stringWidth — CJK regression", () => { + test("hexagram symbols stay width 2", () => { + expect(stringWidth("䷀")).toBe(2); // ䷀ + }); + + test("CJK radicals supplement measures 2", () => { + expect(stringWidth("⺀")).toBe(2); // ⺀ + }); +}); diff --git a/packages/terminal/src/__tests__/terminal-session.test.ts b/packages/terminal/src/__tests__/terminal-session.test.ts new file mode 100644 index 00000000..82cb37d8 --- /dev/null +++ b/packages/terminal/src/__tests__/terminal-session.test.ts @@ -0,0 +1,99 @@ +// TerminalSession lifecycle — idempotent enter/exit, ownership-friendly +// isActive, mid-session clear, and bracketed paste mode toggling. + +import { describe, test, expect } from "bun:test"; +import { TerminalSession } from "../session/terminal-session.ts"; + +function mockStdout() { + const writes: string[] = []; + return { + write(data: string) { + writes.push(data); + return true; + }, + columns: 80, + rows: 24, + writes, + }; +} + +function mockStdin() { + const handlers: Record = {}; + return { + isTTY: false, + resume() {}, + pause() {}, + setRawMode(_mode: boolean) {}, + on(event: string, handler: Function) { + (handlers[event] ??= []).push(handler); + }, + off(event: string, handler: Function) { + const list = handlers[event]; + if (list) { + const idx = list.indexOf(handler); + if (idx >= 0) list.splice(idx, 1); + } + }, + } as unknown as typeof process.stdin; +} + +describe("TerminalSession", () => { + test("enter is idempotent — alt screen entered exactly once", () => { + const stdout = mockStdout(); + const session = new TerminalSession(stdout, mockStdin()); + session.enter(); + session.enter(); + const altOns = stdout.writes.join("").split("\x1b[?1049h").length - 1; + expect(altOns).toBe(1); + session.exit(); + }); + + test("isActive reflects the session state", () => { + const session = new TerminalSession(mockStdout(), mockStdin()); + expect(session.isActive).toBe(false); + session.enter(); + expect(session.isActive).toBe(true); + session.exit(); + expect(session.isActive).toBe(false); + }); + + test("enter enables bracketed paste; exit disables it before leaving the alt screen", () => { + const stdout = mockStdout(); + const session = new TerminalSession(stdout, mockStdin()); + session.enter(); + expect(stdout.writes.join("")).toContain("\x1b[?2004h"); + session.exit(); + const out = stdout.writes.join(""); + expect(out).toContain("\x1b[?2004l"); + // Paste mode is turned off before the alt screen is left + expect(out.indexOf("\x1b[?2004l")).toBeLessThan(out.indexOf("\x1b[?1049l")); + }); + + test("clear writes clear-screen + cursor-home while active", () => { + const stdout = mockStdout(); + const session = new TerminalSession(stdout, mockStdin()); + session.enter(); + const before = stdout.writes.length; + session.clear(); + expect(stdout.writes.length).toBe(before + 1); + expect(stdout.writes[before]).toContain("\x1b[2J"); + session.exit(); + }); + + test("clear is a no-op when the session is not active", () => { + const stdout = mockStdout(); + const session = new TerminalSession(stdout, mockStdin()); + session.clear(); + expect(stdout.writes).toHaveLength(0); + }); + + test("exit is idempotent — restore sequence written exactly once", () => { + const stdout = mockStdout(); + const session = new TerminalSession(stdout, mockStdin()); + session.enter(); + session.exit(); + session.exit(); + const altOffs = stdout.writes.join("").split("\x1b[?1049l").length - 1; + expect(altOffs).toBe(1); + }); +}); diff --git a/packages/terminal/src/ansi/codes.ts b/packages/terminal/src/ansi/codes.ts index 9c7a72d9..a513e420 100644 --- a/packages/terminal/src/ansi/codes.ts +++ b/packages/terminal/src/ansi/codes.ts @@ -26,3 +26,11 @@ export const clearToEndOfLine = `${CSI}0K`; // Alternate screen buffer export const altScreenOn = `${CSI}?1049h`; export const altScreenOff = `${CSI}?1049l`; + +// Bracketed paste mode (DEC 2004) — terminals without support ignore these +export const bracketedPasteOn = `${CSI}?2004h`; +export const bracketedPasteOff = `${CSI}?2004l`; + +// Synchronized output (DEC 2026) — atomic frame presentation, ignored when unsupported +export const syncOutputOn = `${CSI}?2026h`; +export const syncOutputOff = `${CSI}?2026l`; diff --git a/packages/terminal/src/i18n/messages.ts b/packages/terminal/src/i18n/messages.ts index 74d2aa9e..6fff7c67 100644 --- a/packages/terminal/src/i18n/messages.ts +++ b/packages/terminal/src/i18n/messages.ts @@ -57,6 +57,10 @@ export const MESSAGES = { "verb.cut": { en: "cut", zhHant: "分蓍", zhHans: "分蓍" }, "verb.cutAroundHere": { en: "cut around here", zhHant: "約此處分", zhHans: "约此处分" }, + // ── loop-level notices ── + // Shown (calm, centered) when the terminal is too small for honest layout. + "notice.tooSmall": { en: "the window is too small", zhHant: "視窗過小", zhHans: "窗口过小" }, + // ── counters / structure (ritual chrome) ── "chrome.line": { en: "line", zhHant: "爻", zhHans: "爻" }, "chrome.round": { en: "round", zhHant: "輪", zhHans: "轮" }, diff --git a/packages/terminal/src/input/key-parser.ts b/packages/terminal/src/input/key-parser.ts index 8396041d..f3d08858 100644 --- a/packages/terminal/src/input/key-parser.ts +++ b/packages/terminal/src/input/key-parser.ts @@ -11,13 +11,28 @@ export type KeyEvent = | { type: "home" } | { type: "end" } | { type: "tab" } - | { type: "backspace" }; + | { type: "backspace" } + | { type: "delete" } + | { type: "paste"; text: string }; export interface ParseResult { - event: KeyEvent; + /** + * The decoded event, or null when the sequence was consumed but + * intentionally swallowed (unknown CSI/SS3 sequences, F-keys). Swallowing + * matters: emitting a spurious escape for an unknown sequence would cancel + * the active scene. + */ + event: KeyEvent | null; consumed: number; } +// Bracketed paste markers: ESC [ 200 ~ ... ESC [ 201 ~ +const PASTE_START = new Uint8Array([0x1b, 0x5b, 0x32, 0x30, 0x30, 0x7e]); +const PASTE_END = new Uint8Array([0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e]); + +// How long to wait for the rest of an escape sequence before flushing. +const ESC_TIMEOUT_MS = 50; + /** * Determine the byte length of a single UTF-8 character from its leading byte. */ @@ -29,9 +44,59 @@ function utf8CharLen(leadByte: number): number { return 1; // invalid leading byte — consume 1 to avoid infinite loop } +/** + * Find the index of the CSI final byte (0x40–0x7E) for a buffer starting with + * ESC [. Parameter bytes (0x30–0x3F) and intermediate bytes (0x20–0x2F) are + * skipped. Returns -1 when the sequence is still incomplete (no final byte in + * the buffer yet). + */ +function csiFinalIndex(buf: Uint8Array): number { + let i = 2; + while (i < buf.length && buf[i] >= 0x20 && buf[i] <= 0x3f) i++; + return i < buf.length ? i : -1; +} + +/** Map a complete CSI sequence (final byte at `finalIdx`) to a ParseResult. */ +function parseCSI(buf: Uint8Array, finalIdx: number): ParseResult { + const final = buf[finalIdx]; + const consumed = finalIdx + 1; + + // Arrow keys — plain (ESC [ A) or modified (ESC [ 1 ; 5 C etc.) + if (final === 0x41) return { event: { type: "arrow", direction: "up" }, consumed }; + if (final === 0x42) return { event: { type: "arrow", direction: "down" }, consumed }; + if (final === 0x43) return { event: { type: "arrow", direction: "right" }, consumed }; + if (final === 0x44) return { event: { type: "arrow", direction: "left" }, consumed }; + + // Home (ESC [ H) / End (ESC [ F), with or without modifiers + if (final === 0x48) return { event: { type: "home" }, consumed }; + if (final === 0x46) return { event: { type: "end" }, consumed }; + + // VT-style sequences: ESC [ [;] ~ + if (final === 0x7e) { + let n = 0; + for (let i = 2; i < finalIdx && buf[i] >= 0x30 && buf[i] <= 0x39; i++) { + n = n * 10 + (buf[i] - 0x30); + } + if (n === 1 || n === 7) return { event: { type: "home" }, consumed }; + if (n === 3) return { event: { type: "delete" }, consumed }; + if (n === 4 || n === 8) return { event: { type: "end" }, consumed }; + if (n === 5) return { event: { type: "page", direction: "up" }, consumed }; + if (n === 6) return { event: { type: "page", direction: "down" }, consumed }; + // Insert (2), F-keys (11–24), paste markers (200/201) — swallow + return { event: null, consumed }; + } + + // Any other final byte (F-keys, mouse, device reports) — swallow silently + return { event: null, consumed }; +} + /** * Parse a single key event from the front of a byte buffer. * Returns the event and the number of bytes consumed, or null if unparseable. + * + * Truncated escape sequences (no final byte in the buffer) resolve to escape, + * consuming the whole tail — this is the timeout-flush behavior. Mid-stream, + * KeyParser.feed buffers incomplete sequences instead of calling this. */ export function parseKeyWithLength(buf: Uint8Array): ParseResult | null { if (buf.length === 0) return null; @@ -45,36 +110,32 @@ export function parseKeyWithLength(buf: Uint8Array): ParseResult | null { // CSI sequences: ESC [ ... if (buf[1] === 0x5b) { - if (buf.length < 3) { - // Incomplete CSI — treat as escape (2 bytes consumed) - return { event: { type: "escape" }, consumed: 2 }; + const finalIdx = csiFinalIndex(buf); + if (finalIdx === -1) { + // Truncated CSI — flush as escape, consuming everything + return { event: { type: "escape" }, consumed: buf.length }; } + return parseCSI(buf, finalIdx); + } - const third = buf[2]; - - // Arrow keys (3 bytes) - if (third === 0x41) return { event: { type: "arrow", direction: "up" }, consumed: 3 }; - if (third === 0x42) return { event: { type: "arrow", direction: "down" }, consumed: 3 }; - if (third === 0x43) return { event: { type: "arrow", direction: "right" }, consumed: 3 }; - if (third === 0x44) return { event: { type: "arrow", direction: "left" }, consumed: 3 }; - - // Home (ESC [ H) / End (ESC [ F) - if (third === 0x48) return { event: { type: "home" }, consumed: 3 }; - if (third === 0x46) return { event: { type: "end" }, consumed: 3 }; - - // Extended sequences: ESC [ ~ (4 bytes) - if (buf.length >= 4 && buf[3] === 0x7e) { - if (third === 0x35) return { event: { type: "page", direction: "up" }, consumed: 4 }; - if (third === 0x36) return { event: { type: "page", direction: "down" }, consumed: 4 }; - if (third === 0x31) return { event: { type: "home" }, consumed: 4 }; - if (third === 0x34) return { event: { type: "end" }, consumed: 4 }; + // SS3 sequences: ESC O (application cursor keys, F1–F4) + if (buf[1] === 0x4f) { + if (buf.length < 3) { + // Truncated SS3 — flush as escape + return { event: { type: "escape" }, consumed: buf.length }; } - - // Unrecognized CSI — consume ESC [ and the third byte - return { event: { type: "escape" }, consumed: 3 }; + const final = buf[2]; + if (final === 0x41) return { event: { type: "arrow", direction: "up" }, consumed: 3 }; + if (final === 0x42) return { event: { type: "arrow", direction: "down" }, consumed: 3 }; + if (final === 0x43) return { event: { type: "arrow", direction: "right" }, consumed: 3 }; + if (final === 0x44) return { event: { type: "arrow", direction: "left" }, consumed: 3 }; + if (final === 0x48) return { event: { type: "home" }, consumed: 3 }; + if (final === 0x46) return { event: { type: "end" }, consumed: 3 }; + // F1–F4 (P Q R S) and anything else — swallow + return { event: null, consumed: 3 }; } - // Two-byte ESC + something that isn't CSI + // Two-byte ESC + something that isn't CSI/SS3 (e.g. Alt+key) return { event: { type: "escape" }, consumed: 2 }; } @@ -110,14 +171,57 @@ export function parseKey(buf: Uint8Array): KeyEvent | null { return result ? result.event : null; } +/** True when the buffer starts with the full byte sequence `seq`. */ +function startsWithSeq(buf: Uint8Array, seq: Uint8Array): boolean { + if (buf.length < seq.length) return false; + for (let i = 0; i < seq.length; i++) { + if (buf[i] !== seq[i]) return false; + } + return true; +} + +/** Index of the first occurrence of `seq` in `buf`, or -1. */ +function indexOfSeq(buf: Uint8Array, seq: Uint8Array): number { + outer: for (let i = 0; i + seq.length <= buf.length; i++) { + for (let j = 0; j < seq.length; j++) { + if (buf[i + j] !== seq[j]) continue outer; + } + return i; + } + return -1; +} + +/** Concatenate two byte buffers into a fresh Uint8Array. */ +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const merged = new Uint8Array(a.length + b.length); + merged.set(a); + merged.set(b, a.length); + return merged; +} + +/** + * True when the buffer is an escape sequence whose final byte hasn't arrived + * yet — the parser should wait for more bytes rather than misread the prefix. + */ +function isIncompleteEscape(buf: Uint8Array): boolean { + if (buf[0] !== 0x1b) return false; + if (buf.length === 1) return true; // lone ESC — CSI/SS3 may follow + if (buf[1] === 0x5b) return csiFinalIndex(buf) === -1; + if (buf[1] === 0x4f) return buf.length < 3; + return false; +} + /** * KeyParser accumulates bytes and emits KeyEvents. - * Handles incomplete escape sequences by buffering. + * Handles incomplete escape sequences, bracketed paste blocks, and UTF-8 + * characters split across reads by buffering. */ export class KeyParser { private pending: Uint8Array | null = null; private timer: ReturnType | null = null; private callback: (event: KeyEvent) => void; + /** Non-null while inside an ESC[200~ ... ESC[201~ bracketed paste block. */ + private pasteData: Uint8Array | null = null; constructor(callback: (event: KeyEvent) => void) { this.callback = callback; @@ -132,77 +236,67 @@ export class KeyParser { let buf: Uint8Array; if (this.pending) { - // Concatenate pending + new data - const merged = new Uint8Array(this.pending.length + data.length); - merged.set(this.pending); - merged.set(data, this.pending.length); + buf = concatBytes(this.pending, data); this.pending = null; - buf = merged; } else { buf = data; } while (buf.length > 0) { - // Check for incomplete escape sequences at the tail — buffer them - if (buf[0] === 0x1b) { - // Lone ESC — wait for possible CSI follow-up - if (buf.length === 1) { - this.pending = buf; - this.timer = setTimeout(() => { - if (this.pending) { - this.pending = null; - this.callback({ type: "escape" }); - } - }, 50); + // Inside a bracketed paste — accumulate until the end marker arrives. + if (this.pasteData !== null) { + const merged = concatBytes(this.pasteData, buf); + const end = indexOfSeq(merged, PASTE_END); + if (end === -1) { + this.pasteData = merged; return; } + const raw = new TextDecoder().decode(merged.subarray(0, end)); + this.pasteData = null; + // Normalize line endings — terminals paste \r for newlines + const text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + this.callback({ type: "paste", text }); + buf = merged.subarray(end + PASTE_END.length); + continue; + } - // ESC [ without third byte — wait for it - if (buf.length === 2 && buf[1] === 0x5b) { - this.pending = buf; - this.timer = setTimeout(() => { - if (this.pending) { - const result = parseKeyWithLength(this.pending); - this.pending = null; - if (result) this.callback(result.event); - } - }, 50); - return; + if (buf[0] === 0x1b) { + // Bracketed paste start — switch to accumulation mode + if (startsWithSeq(buf, PASTE_START)) { + this.pasteData = new Uint8Array(0); + buf = buf.subarray(PASTE_START.length); + continue; } - // ESC [ without ~ — could be a 4-byte sequence - if ( - buf.length === 3 && - buf[1] === 0x5b && - buf[2] >= 0x31 && buf[2] <= 0x36 - ) { - // Check if parseKeyWithLength already handles it as a 3-byte sequence - const immediate = parseKeyWithLength(buf); - if (immediate && immediate.consumed === 3) { - this.callback(immediate.event); - buf = buf.subarray(3); - continue; - } - // Otherwise buffer for the 4th byte - this.pending = buf; + // Escape sequence still missing its final byte — buffer and wait. + // The timeout flushes a lone ESC (or truncated sequence) as escape. + if (isIncompleteEscape(buf)) { + this.pending = buf.slice(); this.timer = setTimeout(() => { if (this.pending) { - const result = parseKeyWithLength(this.pending); + const flush = this.pending; this.pending = null; - if (result) this.callback(result.event); + const result = parseKeyWithLength(flush); + if (result?.event) this.callback(result.event); } - }, 50); + }, ESC_TIMEOUT_MS); return; } } + // UTF-8 multibyte character split across reads — buffer the tail + if (buf[0] >= 0x80 && buf.length < utf8CharLen(buf[0])) { + this.pending = buf.slice(); + return; + } + const result = parseKeyWithLength(buf); if (!result) { // Unparseable byte — skip it to avoid infinite loop buf = buf.subarray(1); continue; } - this.callback(result.event); + if (result.event) this.callback(result.event); buf = buf.subarray(result.consumed); } } @@ -214,5 +308,6 @@ export class KeyParser { this.timer = null; } this.pending = null; + this.pasteData = null; } } diff --git a/packages/terminal/src/layout/measure.ts b/packages/terminal/src/layout/measure.ts index d7ba5e5d..683fa706 100644 --- a/packages/terminal/src/layout/measure.ts +++ b/packages/terminal/src/layout/measure.ts @@ -9,6 +9,9 @@ export function stringWidth(str: string): number { let width = 0; for (const ch of str) { const code = ch.codePointAt(0) ?? 0; + if (isZeroWidthChar(code)) { + continue; + } if (isWideChar(code)) { width += 2; } else if (typeof Bun !== "undefined" && typeof Bun.stringWidth === "function") { @@ -20,6 +23,22 @@ export function stringWidth(str: string): number { return width; } +/** + * Check if a code point occupies no columns: variation selectors (U+FE0F + * emoji presentation), zero-width joiner, and combining diacritics. Keeps + * the Node fallback aligned with how terminals actually advance the cursor. + */ +function isZeroWidthChar(code: number): boolean { + return ( + // Variation Selectors (incl. VS16 emoji presentation) + (code >= 0xfe00 && code <= 0xfe0f) || + // Zero-width space / non-joiner / joiner / marks + (code >= 0x200b && code <= 0x200d) || + // Combining Diacritical Marks + (code >= 0x0300 && code <= 0x036f) + ); +} + /** * Check if a Unicode code point is a "wide" character (CJK, etc.) */ @@ -43,7 +62,51 @@ function isWideChar(code: number): boolean { // Katakana / Hiragana (code >= 0x3000 && code <= 0x303f) || (code >= 0x3040 && code <= 0x309f) || - (code >= 0x30a0 && code <= 0x30ff) + (code >= 0x30a0 && code <= 0x30ff) || + // CJK Radicals Supplement / Kangxi Radicals + (code >= 0x2e80 && code <= 0x2fdf) || + // Emoji & pictograph blocks (East Asian Width = Wide). Hardcoded so the + // Node fallback (npx path) measures identically to Bun.stringWidth. + (code >= 0x1f300 && code <= 0x1f64f) || + (code >= 0x1f680 && code <= 0x1f6ff) || + (code >= 0x1f900 && code <= 0x1f9ff) || + (code >= 0x1fa70 && code <= 0x1faff) || + code === 0x1f004 || // mahjong tile red dragon + code === 0x1f0cf || // playing card black joker + // Wide emoji scattered through Misc Symbols / Dingbats (EAW=W list) + (code >= 0x231a && code <= 0x231b) || + (code >= 0x23e9 && code <= 0x23ec) || + code === 0x23f0 || + code === 0x23f3 || + (code >= 0x25fd && code <= 0x25fe) || + (code >= 0x2614 && code <= 0x2615) || + (code >= 0x2648 && code <= 0x2653) || + code === 0x267f || + code === 0x2693 || + code === 0x26a1 || + (code >= 0x26aa && code <= 0x26ab) || + (code >= 0x26bd && code <= 0x26be) || + (code >= 0x26c4 && code <= 0x26c5) || + code === 0x26ce || + code === 0x26d4 || + code === 0x26ea || + (code >= 0x26f2 && code <= 0x26f3) || + code === 0x26f5 || + code === 0x26fa || + code === 0x26fd || + code === 0x2705 || + (code >= 0x270a && code <= 0x270b) || + code === 0x2728 || + code === 0x274c || + code === 0x274e || + (code >= 0x2753 && code <= 0x2755) || + code === 0x2757 || + (code >= 0x2795 && code <= 0x2797) || + code === 0x27b0 || + code === 0x27bf || + (code >= 0x2b1b && code <= 0x2b1c) || + code === 0x2b50 || + code === 0x2b55 ); } diff --git a/packages/terminal/src/render/diff-render.ts b/packages/terminal/src/render/diff-render.ts index 71de830f..30b24706 100644 --- a/packages/terminal/src/render/diff-render.ts +++ b/packages/terminal/src/render/diff-render.ts @@ -2,7 +2,7 @@ import { CellBuffer } from "./buffer.ts"; import { type StyledCell, cellsEqual } from "./cell.ts"; -import { cursorTo, clearToEndOfLine } from "../ansi/codes.ts"; +import { cursorTo, clearToEndOfLine, syncOutputOn, syncOutputOff } from "../ansi/codes.ts"; import { fgColor, bgColor, boldStyle, dimStyle, resetStyle } from "../ansi/sgr.ts"; import { detectColorSupport, type ColorSupport } from "../color/detect.ts"; @@ -73,9 +73,10 @@ export class DiffRenderer { chunks.push(resetStyle()); } - // Single write for the entire frame + // Single write for the entire frame, wrapped in synchronized-output + // guards (DEC 2026) so busy frames present atomically without tearing. if (chunks.length > 0) { - this.output.write(chunks.join("")); + this.output.write(syncOutputOn + chunks.join("") + syncOutputOff); } } diff --git a/packages/terminal/src/scene/loop.ts b/packages/terminal/src/scene/loop.ts index af4a9bf4..56478a8f 100644 --- a/packages/terminal/src/scene/loop.ts +++ b/packages/terminal/src/scene/loop.ts @@ -9,19 +9,48 @@ import type { KeyEvent } from "../input/key-parser.ts"; import { KeyParser } from "../input/key-parser.ts"; import { CellBuffer } from "../render/buffer.ts"; import { DiffRenderer } from "../render/diff-render.ts"; +import { getTheme } from "../color/theme.ts"; +import { stringWidth } from "../layout/measure.ts"; +import { tr } from "../i18n/messages.ts"; const TARGET_FPS = 30; const FRAME_MS = 1000 / TARGET_FPS; const MAX_DT = 50; // cap dt to avoid large jumps after stalls +// Minimum terminal size for honest scene layout. Below this, scenes silently +// clip (CellBuffer no-ops out-of-bounds writes), so the loop renders a calm +// placeholder instead. +export const MIN_COLS = 40; +export const MIN_ROWS = 12; + +/** + * Render the centered too-small notice into a frame. Exported for tests. + */ +export function renderTooSmallNotice(frame: CellBuffer, ctx: SceneContext): void { + const t = getTheme(); + const lang = ctx.language ?? "en"; + const msg = tr(lang, "notice.tooSmall"); + const dims = `${MIN_COLS} × ${MIN_ROWS}`; + const msgRow = Math.max(0, Math.floor(frame.height / 2) - 1); + frame.writeText(msgRow, Math.max(0, Math.floor((frame.width - stringWidth(msg)) / 2)), msg, { + fg: t.secondary, + }); + frame.writeText(msgRow + 2, Math.max(0, Math.floor((frame.width - stringWidth(dims)) / 2)), dims, { + fg: t.tertiary, + dim: true, + }); +} + /** * Run a scene inside a terminal session. * - * 1. Enter alt screen, hide cursor, enable raw mode + * 1. Enter alt screen, hide cursor, enable raw mode — unless an outer owner + * (e.g. the interactive home loop) already holds the session, in which + * case the screen is cleared in place so transitions never flash the shell * 2. Call scene.enter() * 3. Loop at <=30 FPS with drift compensation * 4. Call scene.exit() - * 5. Restore terminal + * 5. Restore terminal (only when this call entered the session) */ export async function runScene( scene: Scene, @@ -31,8 +60,9 @@ export async function runScene( devMode = false, language: DisplayLanguage = "en", ): Promise { - // Enter alt screen, raw mode, hide cursor - session.enter(); + // Ownership: enter only if no outer holder is active; exit symmetrically. + const ownsSession = !session.isActive; + if (ownsSession) session.enter(); let exitSignal: SceneSignal | undefined; @@ -44,11 +74,14 @@ export async function runScene( done: false, }; - // Wire up resize (and track for cleanup) + // Wire up resize (and track for cleanup). The terminal rewraps alt-screen + // content on resize, so the next frame must be a full clear + repaint. + let needsRepaint = false; const onResize = (cols: number, rows: number) => { ctx.cols = cols; ctx.rows = rows; scene.resize?.(cols, rows); + needsRepaint = true; }; session.onResize(onResize); @@ -64,6 +97,10 @@ export async function runScene( // Create diff renderer const renderer = new DiffRenderer(undefined, colorSupport); + // Wipe any rows left by the previous scene — under a persistent session + // there is no enter()-time clear between scenes. + session.clear(); + await scene.enter?.(ctx); const start = clock.now(); @@ -91,14 +128,28 @@ export async function runScene( // Update scene.update(elapsed, dt, ctx); - // Render + // Render — below the size floor, show the calm placeholder instead of + // silently clipped scene content. const frame = CellBuffer.create(ctx.cols, ctx.rows); - scene.render(frame, ctx); - if (devMode) { - for (let r = 0; r < frame.height; r++) { - frame.writeText(r, 0, String(r).padStart(3), { fg: "#444444" }); + if (ctx.cols < MIN_COLS || ctx.rows < MIN_ROWS) { + renderTooSmallNotice(frame, ctx); + } else { + scene.render(frame, ctx); + if (devMode) { + for (let r = 0; r < frame.height; r++) { + frame.writeText(r, 0, String(r).padStart(3), { fg: "#444444" }); + } } } + + // After a resize, invalidate the diff baseline and clear the screen so + // this frame is a full repaint (stale rewrapped rows never survive). + if (needsRepaint) { + needsRepaint = false; + session.clear(); + prevBuffer = CellBuffer.create(ctx.cols, ctx.rows); + } + renderer.present(prevBuffer, frame); prevBuffer = frame; @@ -114,7 +165,7 @@ export async function runScene( process.stdin.off("data", onData); session.offResize(onResize); keyParser.dispose(); - session.exit(); + if (ownsSession) session.exit(); } return exitSignal; diff --git a/packages/terminal/src/scenes/intention/intention-scene.ts b/packages/terminal/src/scenes/intention/intention-scene.ts index 558d878f..46e5cc44 100644 --- a/packages/terminal/src/scenes/intention/intention-scene.ts +++ b/packages/terminal/src/scenes/intention/intention-scene.ts @@ -90,11 +90,24 @@ export class IntentionScene implements Scene { return; } + if (key.type === "delete") { + this.textInput.delete(); + return; + } + if (key.type === "char") { this.textInput.insert(key.char); return; } + if (key.type === "paste") { + // A pasted reflection arrives as one block: fold newlines/tabs to + // spaces (enter must not submit mid-paste) and drop control chars. + const text = key.text.replace(/[\n\t]+/g, " ").replace(/[\x00-\x1f\x7f]/g, ""); + if (text.length > 0) this.textInput.insert(text); + return; + } + if (key.type === "home") { this.textInput.moveToStart(); return; diff --git a/packages/terminal/src/session/terminal-session.ts b/packages/terminal/src/session/terminal-session.ts index 4d64edd7..cfcf524d 100644 --- a/packages/terminal/src/session/terminal-session.ts +++ b/packages/terminal/src/session/terminal-session.ts @@ -7,6 +7,8 @@ import { showCursor, clearScreen, cursorHome, + bracketedPasteOn, + bracketedPasteOff, } from "../ansi/codes.ts"; import { enableRawMode } from "../input/raw-input.ts"; @@ -39,13 +41,22 @@ export class TerminalSession { return this.stdout.rows; } - /** Enter alt screen, hide cursor, enable raw mode, register signal handlers */ + /** + * Whether the session currently holds the terminal (alt screen + raw mode). + * Callers use this for ownership: an outer holder (e.g. the interactive + * home loop) enters once, and inner scene runs leave the session alone. + */ + get isActive(): boolean { + return this.active; + } + + /** Enter alt screen, hide cursor, enable raw mode, register signal handlers. Idempotent. */ enter(): void { if (this.active) return; this.active = true; - // Enter alt screen and clear - this.stdout.write(altScreenOn + clearScreen + cursorHome + hideCursor); + // Enter alt screen, clear, and enable bracketed paste + this.stdout.write(altScreenOn + clearScreen + cursorHome + hideCursor + bracketedPasteOn); // Enable raw mode this.disableRaw = enableRawMode(this.stdin); @@ -78,7 +89,7 @@ export class TerminalSession { this.active = false; // Restore terminal - this.stdout.write(showCursor + altScreenOff); + this.stdout.write(bracketedPasteOff + showCursor + altScreenOff); // Disable raw mode if (this.disableRaw) { @@ -99,6 +110,15 @@ export class TerminalSession { this.signalHandlers.clear(); } + /** + * Clear the screen and home the cursor. Used between scenes while one + * persistent session stays active, and for full repaints after a resize. + */ + clear(): void { + if (!this.active) return; + this.stdout.write(clearScreen + cursorHome); + } + /** Register a resize callback */ onResize(cb: ResizeCallback): void { this.resizeCallbacks.push(cb); From 4685aa55bff175c4c47dd776a6aea3fa9d5eca61 Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 17:15:59 -0700 Subject: [PATCH 003/334] =?UTF-8?q?feat(terminal):=20visual=20&=20motion?= =?UTF-8?q?=20polish=20=E2=80=94=20shared=20lerp,=20preset-aware=20glyph?= =?UTF-8?q?=20reveal,=20themed=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - color/lerp.ts: shared lerpColor hex interpolation util; the four glyph animators (noise/dots/radial/sand) import it instead of carrying private duplicates; toss-scene uses it for the landing-flash fade - glyph reveal wait: per-style durations exported via GLYPH_ANIM_DURATION_MS; buildGlyphReveal holds for the chosen style's actual run time scaled by the new RitualTiming.glyphAnimScale, then breathes glyphBreathMs; reduced motion skips the animation and shows the settled glyph at once; animators accept a durationScale (time dilation) passed through createGlyphAnimator - 256-color quantization: rgbTo256 picks the true nearest of cube vs grayscale ramp (xterm levels 0/95/135/175/215/255) so dark theme tones stop rendering ~2x brighter on 256-color terminals; near-grays land on the ramp - theme token integrity: dictionary selected-row bg uses theme.dimmed, text-input block cursor falls back to theme primary/bg, noise halo lerps from theme.dimmed (were hardcoded #1A2030 / #C8A96B / #0D1117 / #141418) - taijitu: rendered in theme secondary by default (optional fg override param) instead of inheriting the terminal default text color - coin toss ground: dim tertiary surface line one row under the landing row, with a soft secondary touchdown flash that fades back over 0.35s 37 new tests (lerp-color, sgr quantization, glyph-anim durations, taijitu theming, toss ground/flash, preset fields, timeline glyph timing, text-input theme fallback). 632 pass, typecheck clean. Co-Authored-By: Claude Fable 5 --- .../terminal/src/__tests__/glyph-anim.test.ts | 73 ++++++++++++++ .../terminal/src/__tests__/lerp-color.test.ts | 34 +++++++ .../terminal/src/__tests__/presets.test.ts | 14 +++ packages/terminal/src/__tests__/sgr.test.ts | 59 ++++++++++++ .../src/__tests__/taijitu-render.test.ts | 38 ++++++++ .../terminal/src/__tests__/text-input.test.ts | 19 ++++ .../src/__tests__/timeline-builder.test.ts | 59 +++++++++++- .../terminal/src/__tests__/toss-scene.test.ts | 95 +++++++++++++++++++ packages/terminal/src/animation/presets.ts | 12 +++ packages/terminal/src/ansi/sgr.ts | 44 ++++++--- packages/terminal/src/color/lerp.ts | 18 ++++ packages/terminal/src/glyph-anim/dots.ts | 27 +++--- packages/terminal/src/glyph-anim/factory.ts | 34 +++++-- packages/terminal/src/glyph-anim/index.ts | 2 +- packages/terminal/src/glyph-anim/noise.ts | 25 ++--- packages/terminal/src/glyph-anim/radial.ts | 25 ++--- packages/terminal/src/glyph-anim/sand.ts | 29 +++--- .../src/scenes/cast/timeline-builder.ts | 32 +++++-- .../src/scenes/dict/browse-renderer.ts | 2 +- .../src/scenes/home/taijitu-render.ts | 7 +- .../terminal/src/scenes/toss/toss-scene.ts | 33 +++++++ packages/terminal/src/widgets/text-input.ts | 11 ++- 22 files changed, 591 insertions(+), 101 deletions(-) create mode 100644 packages/terminal/src/__tests__/glyph-anim.test.ts create mode 100644 packages/terminal/src/__tests__/lerp-color.test.ts create mode 100644 packages/terminal/src/__tests__/sgr.test.ts create mode 100644 packages/terminal/src/__tests__/taijitu-render.test.ts create mode 100644 packages/terminal/src/__tests__/toss-scene.test.ts create mode 100644 packages/terminal/src/color/lerp.ts diff --git a/packages/terminal/src/__tests__/glyph-anim.test.ts b/packages/terminal/src/__tests__/glyph-anim.test.ts new file mode 100644 index 00000000..afa9b99f --- /dev/null +++ b/packages/terminal/src/__tests__/glyph-anim.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect } from "bun:test"; +import type { GlyphEntry } from "@iching/core"; +import { CellBuffer } from "../render/buffer.ts"; +import { getTheme } from "../color/theme.ts"; +import type { GlyphAnimator } from "../glyph-anim/types.ts"; +import { createGlyphAnimator, GLYPH_ANIM_DURATION_MS } from "../glyph-anim/factory.ts"; +import { NoiseAnimator, NOISE_TOTAL_MS } from "../glyph-anim/noise.ts"; +import { DotsAnimator, DOTS_TOTAL_MS } from "../glyph-anim/dots.ts"; +import { RadialAnimator, RADIAL_TOTAL_MS } from "../glyph-anim/radial.ts"; +import { SandAnimator, SAND_TOTAL_MS } from "../glyph-anim/sand.ts"; + +const FULL = "⣿"; // braille all-dots + +function makeGlyph(): GlyphEntry { + return { + rows: [FULL.repeat(3), FULL.repeat(3)], + width: 3, + height: 2, + }; +} + +describe("glyph animator durations", () => { + test("duration map matches each animator's total run time", () => { + expect(GLYPH_ANIM_DURATION_MS.noise).toBe(NOISE_TOTAL_MS); + expect(GLYPH_ANIM_DURATION_MS.dots).toBe(DOTS_TOTAL_MS); + expect(GLYPH_ANIM_DURATION_MS.radial).toBe(RADIAL_TOTAL_MS); + expect(GLYPH_ANIM_DURATION_MS.sand).toBe(SAND_TOTAL_MS); + }); + + test("animators complete at their base duration with default scale", () => { + const cases: [GlyphAnimator, number][] = [ + [new NoiseAnimator(makeGlyph()), NOISE_TOTAL_MS], + [new DotsAnimator(makeGlyph()), DOTS_TOTAL_MS], + [new RadialAnimator(makeGlyph()), RADIAL_TOTAL_MS], + [new SandAnimator(makeGlyph()), SAND_TOTAL_MS], + ]; + for (const [anim, total] of cases) { + expect(anim.update(0)).toBe(false); + expect(anim.update(total - 1)).toBe(false); + expect(anim.update(total)).toBe(true); + } + }); + + test("durationScale below 1 completes the animation faster", () => { + const anim = new NoiseAnimator(makeGlyph(), 0.5); + anim.update(0); + expect(anim.update(NOISE_TOTAL_MS / 2 - 1)).toBe(false); + expect(anim.update(NOISE_TOTAL_MS / 2)).toBe(true); + }); + + test("durationScale above 1 stretches the animation", () => { + const anim = new RadialAnimator(makeGlyph(), 1.25); + anim.update(0); + expect(anim.update(RADIAL_TOTAL_MS)).toBe(false); + expect(anim.update(RADIAL_TOTAL_MS * 1.25)).toBe(true); + }); + + test("factory passes durationScale through to the animator", () => { + const anim = createGlyphAnimator("sand", makeGlyph(), 0.5); + anim.update(0); + expect(anim.update(SAND_TOTAL_MS * 0.5)).toBe(true); + }); + + test("scaled animator still settles to the real glyph in theme primary", () => { + const anim = new NoiseAnimator(makeGlyph(), 0.5); + anim.update(0); + anim.update(NOISE_TOTAL_MS); // well past the scaled total + const buf = new CellBuffer(10, 5); + anim.render(buf, 0, 0); + expect(buf.getCell(0, 0).char).toBe(FULL); + expect(buf.getCell(0, 0).fg).toBe(getTheme().primary); + }); +}); diff --git a/packages/terminal/src/__tests__/lerp-color.test.ts b/packages/terminal/src/__tests__/lerp-color.test.ts new file mode 100644 index 00000000..fbdf6557 --- /dev/null +++ b/packages/terminal/src/__tests__/lerp-color.test.ts @@ -0,0 +1,34 @@ +import { describe, test, expect } from "bun:test"; +import { lerpColor } from "../color/lerp.ts"; + +describe("lerpColor", () => { + test("t=0 returns the start color", () => { + expect(lerpColor("#102030", "#FFFFFF", 0)).toBe("#102030"); + }); + + test("t=1 returns the end color", () => { + expect(lerpColor("#102030", "#FFFFFF", 1)).toBe("#ffffff"); + }); + + test("midpoint mixes each channel independently", () => { + expect(lerpColor("#102030", "#304050", 0.5)).toBe("#203040"); + }); + + test("black to white midpoint is mid gray", () => { + expect(lerpColor("#000000", "#FFFFFF", 0.5)).toBe("#808080"); + }); + + test("t below 0 clamps to the start color", () => { + expect(lerpColor("#102030", "#FFFFFF", -2)).toBe("#102030"); + }); + + test("t above 1 clamps to the end color", () => { + expect(lerpColor("#102030", "#FFFFFF", 3)).toBe("#ffffff"); + }); + + test("output is always a 7-char hex color", () => { + for (const t of [0, 0.1, 0.33, 0.5, 0.77, 1]) { + expect(lerpColor("#0A0A0F", "#E0E0E0", t)).toMatch(/^#[0-9a-f]{6}$/); + } + }); +}); diff --git a/packages/terminal/src/__tests__/presets.test.ts b/packages/terminal/src/__tests__/presets.test.ts index e4401532..02081a60 100644 --- a/packages/terminal/src/__tests__/presets.test.ts +++ b/packages/terminal/src/__tests__/presets.test.ts @@ -69,6 +69,20 @@ describe("motion presets", () => { expect(r.finalGlowDownMs).toBe(0); }); + test("glyph reveal: reduced skips the animation, other presets scale it", () => { + expect(getPreset("default").glyphAnimScale).toBe(1); + expect(getPreset("brisk").glyphAnimScale).toBeLessThan(1); + expect(getPreset("brisk").glyphAnimScale).toBeGreaterThan(0); + expect(getPreset("deep").glyphAnimScale).toBeGreaterThan(1); + expect(getPreset("reduced").glyphAnimScale).toBe(0); + }); + + test("glyph breath is preserved across all presets (reduced keeps pauses)", () => { + for (const name of ["default", "brisk", "deep", "reduced"] as const) { + expect(getPreset(name).glyphBreathMs).toBeGreaterThan(0); + } + }); + test("getPreset returns a copy (not a reference)", () => { const a = getPreset("default"); const b = getPreset("default"); diff --git a/packages/terminal/src/__tests__/sgr.test.ts b/packages/terminal/src/__tests__/sgr.test.ts new file mode 100644 index 00000000..be4fc5c2 --- /dev/null +++ b/packages/terminal/src/__tests__/sgr.test.ts @@ -0,0 +1,59 @@ +import { describe, test, expect } from "bun:test"; +import { fgColor, bgColor } from "../ansi/sgr.ts"; + +/** Extract the palette index from a 256-color SGR sequence. */ +function fg256(hex: string): number { + const seq = fgColor(hex, "256"); + const m = seq.match(/^\x1b\[38;5;(\d+)m$/); + if (!m) throw new Error(`not a 256-color fg sequence: ${JSON.stringify(seq)}`); + return Number(m[1]); +} + +describe("rgbTo256 quantization (via fgColor/bgColor)", () => { + test("pure black maps to cube black (16)", () => { + expect(fg256("#000000")).toBe(16); + }); + + test("pure white maps to cube white (231)", () => { + expect(fg256("#FFFFFF")).toBe(231); + }); + + test("pure red maps to cube red (196)", () => { + expect(fg256("#FF0000")).toBe(196); + }); + + test("dark warm tone stays dark on the gray ramp (bone dimmed)", () => { + // #2C2418: old linear rounding sent channels 36-68 to cube level 95, + // rendering this ~2x brighter as olive (95,95,0). Nearest match is + // the dark gray-ramp entry 235 (38,38,38). + expect(fg256("#2C2418")).toBe(235); + }); + + test("mid-dark near-gray prefers the ramp over a saturated cube color", () => { + // #33312F averaged 49: cube candidates are 0 or 95 per channel — + // the gray ramp entry 236 (48,48,48) is far closer. + expect(fg256("#33312F")).toBe(236); + }); + + test("near-black theme background maps to the darkest ramp entry", () => { + // ink bg #0A0A0F is not exact gray; it should still land near black + expect(fg256("#0A0A0F")).toBe(232); + }); + + test("exact mid gray stays achromatic", () => { + // #606060 → cube gray (95,95,95) = index 59, which is nearest + expect(fg256("#606060")).toBe(59); + }); + + test("bgColor uses the same quantization", () => { + expect(bgColor("#2C2418", "256")).toBe("\x1b[48;5;235m"); + }); + + test("truecolor passes channels through unquantized", () => { + expect(fgColor("#2C2418", "truecolor")).toBe("\x1b[38;2;44;36;24m"); + }); + + test("none support emits nothing", () => { + expect(fgColor("#2C2418", "none")).toBe(""); + }); +}); diff --git a/packages/terminal/src/__tests__/taijitu-render.test.ts b/packages/terminal/src/__tests__/taijitu-render.test.ts new file mode 100644 index 00000000..002bf224 --- /dev/null +++ b/packages/terminal/src/__tests__/taijitu-render.test.ts @@ -0,0 +1,38 @@ +import { describe, test, expect } from "bun:test"; +import { renderTaijitu } from "../scenes/home/taijitu-render.ts"; +import { CellBuffer } from "../render/buffer.ts"; +import { getTheme } from "../color/theme.ts"; + +function countCells(buf: CellBuffer, predicate: (fg: string | undefined, char: string) => boolean): number { + let n = 0; + for (let r = 0; r < buf.height; r++) { + for (let c = 0; c < buf.width; c++) { + const cell = buf.getCell(r, c); + if (cell.char !== " " && predicate(cell.fg, cell.char)) n++; + } + } + return n; +} + +describe("renderTaijitu", () => { + test("cells use the theme secondary tone by default", () => { + const buf = new CellBuffer(40, 20); + renderTaijitu(buf, 20, 10, 8, 0, "dense"); + const themed = countCells(buf, (fg) => fg === getTheme().secondary); + const unthemed = countCells(buf, (fg) => fg !== getTheme().secondary); + expect(themed).toBeGreaterThan(0); + expect(unthemed).toBe(0); + }); + + test("explicit fg override is respected", () => { + const buf = new CellBuffer(40, 20); + renderTaijitu(buf, 20, 10, 8, 0, "dots", "#123456"); + expect(countCells(buf, (fg) => fg === "#123456")).toBeGreaterThan(0); + }); + + test("radius below 4 renders nothing", () => { + const buf = new CellBuffer(40, 20); + renderTaijitu(buf, 20, 10, 3, 0, "dense"); + expect(countCells(buf, () => true)).toBe(0); + }); +}); diff --git a/packages/terminal/src/__tests__/text-input.test.ts b/packages/terminal/src/__tests__/text-input.test.ts index dcfeff13..6fafee19 100644 --- a/packages/terminal/src/__tests__/text-input.test.ts +++ b/packages/terminal/src/__tests__/text-input.test.ts @@ -171,4 +171,23 @@ describe("TextInput", () => { // Cursor cell has inverted colors expect(cell2.bg).toBe("#FFFFFF"); }); + + test("cursor colors fall back to theme tokens when style omits fg/bg", () => { + const { getTheme } = require("../color/theme.ts"); + const t = getTheme(); + + const input = new TextInput(); + const buf = CellBuffer.create(10, 1); + input.render(buf, 0, 0, 5); + // Empty input: cursor block sits at col 0 + const cursor = buf.getCell(0, 0); + expect(cursor.bg).toBe(t.primary); + expect(cursor.fg).toBe(t.bg); + + const wrapped = CellBuffer.create(10, 2); + input.renderWrapped(wrapped, 0, 0, 5, 2); + const wCursor = wrapped.getCell(0, 0); + expect(wCursor.bg).toBe(t.primary); + expect(wCursor.fg).toBe(t.bg); + }); }); diff --git a/packages/terminal/src/__tests__/timeline-builder.test.ts b/packages/terminal/src/__tests__/timeline-builder.test.ts index cd94750e..87e6360a 100644 --- a/packages/terminal/src/__tests__/timeline-builder.test.ts +++ b/packages/terminal/src/__tests__/timeline-builder.test.ts @@ -1,8 +1,9 @@ import { describe, test, expect } from "bun:test"; -import { buildCastTimeline } from "../scenes/cast/timeline-builder.ts"; +import { buildCastTimeline, type CastGlyphConfig } from "../scenes/cast/timeline-builder.ts"; import { CastModel } from "../scenes/cast/model.ts"; import { getPreset } from "../animation/presets.ts"; import { stepDuration } from "../animation/timeline.ts"; +import { GLYPH_ANIM_DURATION_MS } from "../glyph-anim/factory.ts"; import type { Cast } from "@iching/core"; function makeCast(overrides?: Partial): Cast { @@ -211,3 +212,59 @@ describe("buildCastTimeline", () => { expect(duration).toBeGreaterThan(0); }); }); + +describe("glyph reveal timing", () => { + function makeGlyphConfig(anim: CastGlyphConfig["glyphAnim"]): CastGlyphConfig { + return { glyphAnim: anim, glyphFont: "kaiti", glyphSize: 32 }; + } + + test("reveal hold tracks the chosen style's duration", () => { + const cast = makeCast(); + const timing = getPreset("default"); + const sand = buildCastTimeline(cast, new CastModel(cast), timing, 80, makeGlyphConfig("sand")); + const radial = buildCastTimeline(cast, new CastModel(cast), timing, 80, makeGlyphConfig("radial")); + expect(stepDuration(sand) - stepDuration(radial)).toBe( + GLYPH_ANIM_DURATION_MS.sand - GLYPH_ANIM_DURATION_MS.radial, + ); + }); + + test("glyphAnimScale scales the reveal hold", () => { + const cast = makeCast(); + const base = getPreset("default"); + const halved = { ...base, glyphAnimScale: 0.5 }; + const full = buildCastTimeline(cast, new CastModel(cast), base, 80, makeGlyphConfig("noise")); + const half = buildCastTimeline(cast, new CastModel(cast), halved, 80, makeGlyphConfig("noise")); + expect(stepDuration(full) - stepDuration(half)).toBe( + Math.round(GLYPH_ANIM_DURATION_MS.noise * 0.5), + ); + }); + + test("reduced motion shows the settled glyph immediately, no animation", () => { + const cast = makeCast(); + const model = new CastModel(cast); + const timing = getPreset("reduced"); + const step = buildCastTimeline(cast, model, timing, 80, makeGlyphConfig("noise")); + + const { TimelineRunner } = require("../animation/runner.ts"); + const runner = new TimelineRunner(step); + runner.advance(runner.duration + 100, model); + + expect(model.primaryGlyphEntry).not.toBeNull(); + expect(model.glyphAnimator).toBeNull(); + expect(model.glyphAnimDone).toBe(true); + }); + + test("non-reduced presets create a real animator", () => { + const cast = makeCast(); + const model = new CastModel(cast); + const timing = getPreset("default"); + const step = buildCastTimeline(cast, model, timing, 80, makeGlyphConfig("noise")); + + const { TimelineRunner } = require("../animation/runner.ts"); + const runner = new TimelineRunner(step); + runner.advance(runner.duration + 100, model); + + expect(model.primaryGlyphEntry).not.toBeNull(); + expect(model.glyphAnimator).not.toBeNull(); + }); +}); diff --git a/packages/terminal/src/__tests__/toss-scene.test.ts b/packages/terminal/src/__tests__/toss-scene.test.ts new file mode 100644 index 00000000..d915ff94 --- /dev/null +++ b/packages/terminal/src/__tests__/toss-scene.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect } from "bun:test"; +import { TossScene } from "../scenes/toss/toss-scene.ts"; +import { anchorRow } from "../scenes/cast/hexagram-renderer.ts"; +import { CellBuffer } from "../render/buffer.ts"; +import { getTheme } from "../color/theme.ts"; +import type { SceneContext } from "../scene/types.ts"; + +function makeCtx(cols = 80, rows = 24): SceneContext { + return { cols, rows, done: false, colorSupport: "truecolor" }; +} + +/** Same landing-row derivation as TossScene.landRow(). */ +function groundRow(rows: number): number { + return Math.min(rows - 2, anchorRow(rows) + 12) + 1; +} + +describe("TossScene ground", () => { + test("renders a dim tertiary ground line under the landing row", () => { + const t = getTheme(); + const scene = new TossScene(); + const ctx = makeCtx(); + scene.enter(ctx); + + const frame = new CellBuffer(80, 24); + scene.render(frame, ctx); + + const row = groundRow(24); + const center = frame.getCell(row, 40); + expect(center.char).toBe("─"); + expect(center.fg).toBe(t.tertiary); + expect(center.dim).toBe(true); + }); + + test("ground line is a centered span, not full width", () => { + const scene = new TossScene(); + const ctx = makeCtx(); + scene.enter(ctx); + + const frame = new CellBuffer(80, 24); + scene.render(frame, ctx); + + const row = groundRow(24); + expect(frame.getCell(row, 2).char).toBe(" "); + expect(frame.getCell(row, 77).char).toBe(" "); + }); + + test("a coin touchdown briefly brightens the ground at the impact column", () => { + const t = getTheme(); + const scene = new TossScene(); + const ctx = makeCtx(); + scene.enter(ctx); + + // Launch the first coin, then step physics frame by frame. + scene.handleKey({ type: "char", char: " " }, ctx); + + const row = groundRow(24); + let flashed = false; + for (let ms = 16; ms <= 6000 && !flashed; ms += 16) { + scene.update(ms, 16, ctx); + const frame = new CellBuffer(80, 24); + scene.render(frame, ctx); + for (let c = 0; c < 80; c++) { + const cell = frame.getCell(row, c); + if (cell.char === "─" && cell.fg !== t.tertiary) { + flashed = true; + break; + } + } + } + expect(flashed).toBe(true); + }); + + test("the flash fades back to the base ground color", () => { + const t = getTheme(); + const scene = new TossScene(); + const ctx = makeCtx(); + scene.enter(ctx); + scene.handleKey({ type: "char", char: " " }, ctx); + + // Run well past any landing + flash window (flash is 0.35s). + for (let ms = 16; ms <= 8000; ms += 16) { + scene.update(ms, 16, ctx); + } + const frame = new CellBuffer(80, 24); + scene.render(frame, ctx); + + const row = groundRow(24); + for (let c = 0; c < 80; c++) { + const cell = frame.getCell(row, c); + if (cell.char === "─") { + expect(cell.fg).toBe(t.tertiary); + } + } + }); +}); diff --git a/packages/terminal/src/animation/presets.ts b/packages/terminal/src/animation/presets.ts index 9712be5c..8eddac95 100644 --- a/packages/terminal/src/animation/presets.ts +++ b/packages/terminal/src/animation/presets.ts @@ -19,6 +19,10 @@ export interface RitualTiming { compareRevealMs: number; splitSlideMs: number; restMs: number; + /** Multiplier on the glyph-reveal animation duration (0 = no animation, settled glyph at once). */ + glyphAnimScale: number; + /** Breath after the glyph settles, before the ritual continues. */ + glyphBreathMs: number; } export type MotionPreset = "default" | "brisk" | "deep" | "reduced"; @@ -42,6 +46,8 @@ const DEFAULT: RitualTiming = { compareRevealMs: 400, splitSlideMs: 400, restMs: 200, + glyphAnimScale: 1, + glyphBreathMs: 1200, }; const BRISK: RitualTiming = { @@ -63,6 +69,8 @@ const BRISK: RitualTiming = { compareRevealMs: 200, splitSlideMs: 250, restMs: 100, + glyphAnimScale: 0.6, + glyphBreathMs: 600, }; const DEEP: RitualTiming = { @@ -84,6 +92,8 @@ const DEEP: RitualTiming = { compareRevealMs: 600, splitSlideMs: 600, restMs: 350, + glyphAnimScale: 1.25, + glyphBreathMs: 1600, }; // Reduced motion: same ritual structure (pauses preserved), but no spin, @@ -107,6 +117,8 @@ const REDUCED: RitualTiming = { compareRevealMs: 0, splitSlideMs: 0, restMs: 200, + glyphAnimScale: 0, + glyphBreathMs: 1200, }; const PRESETS: Record = { diff --git a/packages/terminal/src/ansi/sgr.ts b/packages/terminal/src/ansi/sgr.ts index 2b91db3a..fdfd1e1c 100644 --- a/packages/terminal/src/ansi/sgr.ts +++ b/packages/terminal/src/ansi/sgr.ts @@ -14,19 +14,39 @@ function hexToRgb(hex: string): [number, number, number] { ]; } -// Convert RGB to nearest ANSI-256 color index -function rgbTo256(r: number, g: number, b: number): number { - // Check if it's a grayscale (r ≈ g ≈ b) - if (r === g && g === b) { - if (r < 8) return 16; - if (r > 248) return 231; - return Math.round((r - 8) / 247 * 24) + 232; +// xterm 6x6x6 cube channel levels (cube index 0-5 → channel value) +const CUBE_LEVELS = [0, 95, 135, 175, 215, 255]; + +// Index of the nearest cube level for one channel value +function nearestCubeIndex(v: number): number { + let best = 0; + for (let i = 1; i < CUBE_LEVELS.length; i++) { + if (Math.abs(CUBE_LEVELS[i] - v) < Math.abs(CUBE_LEVELS[best] - v)) best = i; } - // Map to 6x6x6 color cube (indices 16-231) - const ri = Math.round(r / 255 * 5); - const gi = Math.round(g / 255 * 5); - const bi = Math.round(b / 255 * 5); - return 16 + 36 * ri + 6 * gi + bi; + return best; +} + +// Convert RGB to nearest ANSI-256 color index. +// Compares the nearest cube color against the nearest grayscale-ramp entry +// and picks whichever is actually closer — dark tones stay dark (the cube +// jumps from 0 straight to 95 per channel) and near-grays land on the ramp. +function rgbTo256(r: number, g: number, b: number): number { + // Candidate from the 6x6x6 color cube (indices 16-231) + const ri = nearestCubeIndex(r); + const gi = nearestCubeIndex(g); + const bi = nearestCubeIndex(b); + const cubeDist = + (CUBE_LEVELS[ri] - r) ** 2 + (CUBE_LEVELS[gi] - g) ** 2 + (CUBE_LEVELS[bi] - b) ** 2; + + // Candidate from the grayscale ramp (indices 232-255: values 8, 18, … 238) + const grayIdx = Math.min(23, Math.max(0, Math.round(((r + g + b) / 3 - 8) / 10))); + const grayLevel = 8 + grayIdx * 10; + const grayDist = + (grayLevel - r) ** 2 + (grayLevel - g) ** 2 + (grayLevel - b) ** 2; + + return grayDist < cubeDist + ? 232 + grayIdx + : 16 + 36 * ri + 6 * gi + bi; } // Convert RGB to nearest ANSI-16 color index diff --git a/packages/terminal/src/color/lerp.ts b/packages/terminal/src/color/lerp.ts new file mode 100644 index 00000000..21b7b961 --- /dev/null +++ b/packages/terminal/src/color/lerp.ts @@ -0,0 +1,18 @@ +// lerpColor — linear interpolation between two "#RRGGBB" hex colors. +// +// Shared by the glyph animators and ritual renderers so eased tweens read as +// continuous fades instead of hard bucket switches. The output is a plain hex +// color: under truecolor it renders exactly; under 16/256 support it falls +// through the existing quantization in ansi/sgr.ts at write time. + +/** Interpolate between hex colors a and b. t is clamped to [0, 1]. */ +export function lerpColor(a: string, b: string, t: number): string { + const tt = Math.max(0, Math.min(1, t)); + const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); + const ar = parseInt(a.slice(1, 3), 16), ag = parseInt(a.slice(3, 5), 16), ab = parseInt(a.slice(5, 7), 16); + const br = parseInt(b.slice(1, 3), 16), bg = parseInt(b.slice(3, 5), 16), bb = parseInt(b.slice(5, 7), 16); + const r = clamp(ar + (br - ar) * tt); + const g = clamp(ag + (bg - ag) * tt); + const bv = clamp(ab + (bb - ab) * tt); + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bv.toString(16).padStart(2, "0")}`; +} diff --git a/packages/terminal/src/glyph-anim/dots.ts b/packages/terminal/src/glyph-anim/dots.ts index 9cf0d13d..c5ee783f 100644 --- a/packages/terminal/src/glyph-anim/dots.ts +++ b/packages/terminal/src/glyph-anim/dots.ts @@ -8,8 +8,10 @@ import type { GlyphEntry } from "@iching/core"; import type { CellBuffer } from "../render/buffer.ts"; import type { GlyphAnimator } from "./types.ts"; import { getTheme } from "../color/theme.ts"; +import { lerpColor } from "../color/lerp.ts"; -const TOTAL_MS = 3000; +/** Total run time (ms) at durationScale 1. */ +export const DOTS_TOTAL_MS = 3000; // Braille dot positions: each braille char is a 2x4 grid encoded in 8 bits. // Bit layout: dot1=0x01, dot2=0x02, dot3=0x04, dot4=0x08, @@ -48,16 +50,6 @@ function getDots(pattern: number): number[] { return dots; } -function lerpColor(a: string, b: string, t: number): string { - const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); - const ar = parseInt(a.slice(1, 3), 16), ag = parseInt(a.slice(3, 5), 16), ab = parseInt(a.slice(5, 7), 16); - const br = parseInt(b.slice(1, 3), 16), bg = parseInt(b.slice(3, 5), 16), bb = parseInt(b.slice(5, 7), 16); - const r = clamp(ar + (br - ar) * t); - const g = clamp(ag + (bg - ag) * t); - const bv = clamp(ab + (bb - ab) * t); - return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bv.toString(16).padStart(2, "0")}`; -} - interface CellMeta { isContent: boolean; realChar: string; @@ -72,12 +64,15 @@ interface CellMeta { export class DotsAnimator implements GlyphAnimator { private readonly glyph: GlyphEntry; + /** Motion-preset time dilation: <1 plays the same animation faster. */ + private readonly durationScale: number; private cells: CellMeta[][] = []; private startTime = -1; private localMs = 0; - constructor(glyph: GlyphEntry) { + constructor(glyph: GlyphEntry, durationScale: number = 1) { this.glyph = glyph; + this.durationScale = Math.max(0.05, durationScale); this.initCells(); } @@ -85,8 +80,8 @@ export class DotsAnimator implements GlyphAnimator { this.cells = []; const totalCells = this.glyph.height * this.glyph.width; // Stagger: each cell starts at a different time based on scan order - const staggerWindow = TOTAL_MS * 0.6; // first 60% is stagger window - const fillWindow = TOTAL_MS * 0.4; // each cell has 40% to fill its dots + const staggerWindow = DOTS_TOTAL_MS * 0.6; // first 60% is stagger window + const fillWindow = DOTS_TOTAL_MS * 0.4; // each cell has 40% to fill its dots let idx = 0; for (let r = 0; r < this.glyph.height; r++) { @@ -118,8 +113,8 @@ export class DotsAnimator implements GlyphAnimator { update(elapsed: number): boolean { if (this.startTime < 0) this.startTime = elapsed; - this.localMs = elapsed - this.startTime; - return this.localMs >= TOTAL_MS; + this.localMs = (elapsed - this.startTime) / this.durationScale; + return this.localMs >= DOTS_TOTAL_MS; } render(buf: CellBuffer, offsetR: number, offsetC: number): void { diff --git a/packages/terminal/src/glyph-anim/factory.ts b/packages/terminal/src/glyph-anim/factory.ts index bd62c1c3..2a3f307b 100644 --- a/packages/terminal/src/glyph-anim/factory.ts +++ b/packages/terminal/src/glyph-anim/factory.ts @@ -2,20 +2,36 @@ import type { GlyphEntry } from "@iching/core"; import type { GlyphAnimator, GlyphAnimStyle } from "./types.ts"; -import { NoiseAnimator } from "./noise.ts"; -import { DotsAnimator } from "./dots.ts"; -import { RadialAnimator } from "./radial.ts"; -import { SandAnimator } from "./sand.ts"; +import { NoiseAnimator, NOISE_TOTAL_MS } from "./noise.ts"; +import { DotsAnimator, DOTS_TOTAL_MS } from "./dots.ts"; +import { RadialAnimator, RADIAL_TOTAL_MS } from "./radial.ts"; +import { SandAnimator, SAND_TOTAL_MS } from "./sand.ts"; -/** Create a glyph animator for the given style and glyph data. */ +/** + * Base run time (ms) of each style at durationScale 1. Timeline builders use + * this to size the post-reveal hold so slow styles aren't truncated and fast + * styles don't sit in dead stillness. + */ +export const GLYPH_ANIM_DURATION_MS: Record = { + noise: NOISE_TOTAL_MS, + dots: DOTS_TOTAL_MS, + radial: RADIAL_TOTAL_MS, + sand: SAND_TOTAL_MS, +}; + +/** + * Create a glyph animator for the given style and glyph data. + * `durationScale` dilates time per the motion preset (<1 plays faster). + */ export function createGlyphAnimator( style: GlyphAnimStyle, glyph: GlyphEntry, + durationScale: number = 1, ): GlyphAnimator { switch (style) { - case "noise": return new NoiseAnimator(glyph); - case "dots": return new DotsAnimator(glyph); - case "radial": return new RadialAnimator(glyph); - case "sand": return new SandAnimator(glyph); + case "noise": return new NoiseAnimator(glyph, durationScale); + case "dots": return new DotsAnimator(glyph, durationScale); + case "radial": return new RadialAnimator(glyph, durationScale); + case "sand": return new SandAnimator(glyph, durationScale); } } diff --git a/packages/terminal/src/glyph-anim/index.ts b/packages/terminal/src/glyph-anim/index.ts index 25564687..9cb104d1 100644 --- a/packages/terminal/src/glyph-anim/index.ts +++ b/packages/terminal/src/glyph-anim/index.ts @@ -1,7 +1,7 @@ // Glyph reveal animations — reusable animators for braille glyph display export type { GlyphAnimator, GlyphAnimStyle } from "./types.ts"; -export { createGlyphAnimator } from "./factory.ts"; +export { createGlyphAnimator, GLYPH_ANIM_DURATION_MS } from "./factory.ts"; export { NoiseAnimator } from "./noise.ts"; export { DotsAnimator } from "./dots.ts"; export { RadialAnimator } from "./radial.ts"; diff --git a/packages/terminal/src/glyph-anim/noise.ts b/packages/terminal/src/glyph-anim/noise.ts index 2c668903..e115b806 100644 --- a/packages/terminal/src/glyph-anim/noise.ts +++ b/packages/terminal/src/glyph-anim/noise.ts @@ -8,8 +8,10 @@ import type { GlyphEntry } from "@iching/core"; import type { CellBuffer } from "../render/buffer.ts"; import type { GlyphAnimator } from "./types.ts"; import { getTheme } from "../color/theme.ts"; +import { lerpColor } from "../color/lerp.ts"; -const TOTAL_MS = 2800; +/** Total run time (ms) at durationScale 1. */ +export const NOISE_TOTAL_MS = 2800; const SETTLE_MIN = 800; const SETTLE_MAX = 2200; const EMPTY_CLEAR_MS = 400; @@ -37,16 +39,6 @@ function settleTime(r: number, c: number, rows: number, cols: number): number { return SETTLE_MIN + t * (SETTLE_MAX - SETTLE_MIN) + jitter; } -function lerpColor(a: string, b: string, t: number): string { - const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); - const ar = parseInt(a.slice(1, 3), 16), ag = parseInt(a.slice(3, 5), 16), ab = parseInt(a.slice(5, 7), 16); - const br = parseInt(b.slice(1, 3), 16), bg = parseInt(b.slice(3, 5), 16), bb = parseInt(b.slice(5, 7), 16); - const r = clamp(ar + (br - ar) * t); - const g = clamp(ag + (bg - ag) * t); - const bv = clamp(ab + (bb - ab) * t); - return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bv.toString(16).padStart(2, "0")}`; -} - interface CellMeta { settleAt: number; isContent: boolean; @@ -56,12 +48,15 @@ interface CellMeta { export class NoiseAnimator implements GlyphAnimator { private readonly glyph: GlyphEntry; + /** Motion-preset time dilation: <1 plays the same animation faster. */ + private readonly durationScale: number; private cells: CellMeta[][] = []; private startTime = -1; private localMs = 0; - constructor(glyph: GlyphEntry) { + constructor(glyph: GlyphEntry, durationScale: number = 1) { this.glyph = glyph; + this.durationScale = Math.max(0.05, durationScale); this.initCells(); } @@ -111,8 +106,8 @@ export class NoiseAnimator implements GlyphAnimator { update(elapsed: number): boolean { if (this.startTime < 0) this.startTime = elapsed; - this.localMs = elapsed - this.startTime; - return this.localMs >= TOTAL_MS; + this.localMs = (elapsed - this.startTime) / this.durationScale; + return this.localMs >= NOISE_TOTAL_MS; } render(buf: CellBuffer, offsetR: number, offsetC: number): void { @@ -142,7 +137,7 @@ export class NoiseAnimator implements GlyphAnimator { const progress = t / Math.max(meta.settleAt, 1); if (progress > 0.5) continue; ch = randomBraille(); - fg = lerpColor("#141418", th.tertiary, progress * 0.5); + fg = lerpColor(th.dimmed, th.tertiary, progress * 0.5); } else { const progress = t / meta.settleAt; ch = randomBraille(); diff --git a/packages/terminal/src/glyph-anim/radial.ts b/packages/terminal/src/glyph-anim/radial.ts index db73df0b..26dc83cf 100644 --- a/packages/terminal/src/glyph-anim/radial.ts +++ b/packages/terminal/src/glyph-anim/radial.ts @@ -7,35 +7,30 @@ import type { GlyphEntry } from "@iching/core"; import type { CellBuffer } from "../render/buffer.ts"; import type { GlyphAnimator } from "./types.ts"; import { getTheme } from "../color/theme.ts"; +import { lerpColor } from "../color/lerp.ts"; import { easeOut } from "../animation/easing.ts"; -const TOTAL_MS = 2400; +/** Total run time (ms) at durationScale 1. */ +export const RADIAL_TOTAL_MS = 2400; const EDGE_WIDTH = 2.5; // cells of gradient at the expanding edge function isEmpty(ch: string): boolean { return ch === "\u2800" || ch === " "; } -function lerpColor(a: string, b: string, t: number): string { - const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); - const ar = parseInt(a.slice(1, 3), 16), ag = parseInt(a.slice(3, 5), 16), ab = parseInt(a.slice(5, 7), 16); - const br = parseInt(b.slice(1, 3), 16), bg = parseInt(b.slice(3, 5), 16), bb = parseInt(b.slice(5, 7), 16); - const r = clamp(ar + (br - ar) * t); - const g = clamp(ag + (bg - ag) * t); - const bv = clamp(ab + (bb - ab) * t); - return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bv.toString(16).padStart(2, "0")}`; -} - export class RadialAnimator implements GlyphAnimator { private readonly glyph: GlyphEntry; + /** Motion-preset time dilation: <1 plays the same animation faster. */ + private readonly durationScale: number; private centerR: number; private centerC: number; private maxRadius: number; private startTime = -1; private localMs = 0; - constructor(glyph: GlyphEntry) { + constructor(glyph: GlyphEntry, durationScale: number = 1) { this.glyph = glyph; + this.durationScale = Math.max(0.05, durationScale); // Compute center of mass from non-empty cells let sumR = 0, sumC = 0, count = 0; @@ -63,13 +58,13 @@ export class RadialAnimator implements GlyphAnimator { update(elapsed: number): boolean { if (this.startTime < 0) this.startTime = elapsed; - this.localMs = elapsed - this.startTime; - return this.localMs >= TOTAL_MS; + this.localMs = (elapsed - this.startTime) / this.durationScale; + return this.localMs >= RADIAL_TOTAL_MS; } render(buf: CellBuffer, offsetR: number, offsetC: number): void { const t = getTheme(); - const progress = Math.min(1, this.localMs / TOTAL_MS); + const progress = Math.min(1, this.localMs / RADIAL_TOTAL_MS); const easedProgress = easeOut(progress); // Expand radius slightly past max so the edge gradient fully clears const currentRadius = easedProgress * (this.maxRadius + EDGE_WIDTH); diff --git a/packages/terminal/src/glyph-anim/sand.ts b/packages/terminal/src/glyph-anim/sand.ts index 84a2407e..bf1874fa 100644 --- a/packages/terminal/src/glyph-anim/sand.ts +++ b/packages/terminal/src/glyph-anim/sand.ts @@ -8,8 +8,10 @@ import type { GlyphEntry } from "@iching/core"; import type { CellBuffer } from "../render/buffer.ts"; import type { GlyphAnimator } from "./types.ts"; import { getTheme } from "../color/theme.ts"; +import { lerpColor } from "../color/lerp.ts"; -const TOTAL_MS = 3500; +/** Total run time (ms) at durationScale 1. */ +export const SAND_TOTAL_MS = 3500; // Braille block for random in-flight appearance const BRAILLE_BASE = 0x2800; @@ -31,16 +33,6 @@ function easeOutQuad(t: number): number { return 1 - (1 - t) * (1 - t); } -function lerpColor(a: string, b: string, t: number): string { - const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); - const ar = parseInt(a.slice(1, 3), 16), ag = parseInt(a.slice(3, 5), 16), ab = parseInt(a.slice(5, 7), 16); - const br = parseInt(b.slice(1, 3), 16), bg = parseInt(b.slice(3, 5), 16), bb = parseInt(b.slice(5, 7), 16); - const r = clamp(ar + (br - ar) * t); - const g = clamp(ag + (bg - ag) * t); - const bv = clamp(ab + (bb - ab) * t); - return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bv.toString(16).padStart(2, "0")}`; -} - interface Particle { targetR: number; targetC: number; @@ -57,20 +49,23 @@ interface Particle { export class SandAnimator implements GlyphAnimator { private readonly glyph: GlyphEntry; + /** Motion-preset time dilation: <1 plays the same animation faster. */ + private readonly durationScale: number; private particles: Particle[] = []; private startTime = -1; private localMs = 0; - constructor(glyph: GlyphEntry) { + constructor(glyph: GlyphEntry, durationScale: number = 1) { this.glyph = glyph; + this.durationScale = Math.max(0.05, durationScale); this.initParticles(); } private initParticles(): void { this.particles = []; - const staggerWindow = TOTAL_MS * 0.4; // first 40% is stagger - const fallBase = TOTAL_MS * 0.45; // base fall duration - const fallJitter = TOTAL_MS * 0.15; // jitter on fall duration + const staggerWindow = SAND_TOTAL_MS * 0.4; // first 40% is stagger + const fallBase = SAND_TOTAL_MS * 0.45; // base fall duration + const fallJitter = SAND_TOTAL_MS * 0.15; // jitter on fall duration // Collect content cells const contentCells: { r: number; c: number; ch: string }[] = []; @@ -107,8 +102,8 @@ export class SandAnimator implements GlyphAnimator { update(elapsed: number): boolean { if (this.startTime < 0) this.startTime = elapsed; - this.localMs = elapsed - this.startTime; - return this.localMs >= TOTAL_MS; + this.localMs = (elapsed - this.startTime) / this.durationScale; + return this.localMs >= SAND_TOTAL_MS; } render(buf: CellBuffer, offsetR: number, offsetC: number): void { diff --git a/packages/terminal/src/scenes/cast/timeline-builder.ts b/packages/terminal/src/scenes/cast/timeline-builder.ts index df2de85a..984b3857 100644 --- a/packages/terminal/src/scenes/cast/timeline-builder.ts +++ b/packages/terminal/src/scenes/cast/timeline-builder.ts @@ -9,7 +9,7 @@ import type { CastModel } from "./model.ts"; import { canSplit } from "./layout-calc.ts"; import type { GlyphAnimStyle } from "../../glyph-anim/types.ts"; import { composeGlyph } from "../../glyph-anim/compose.ts"; -import { createGlyphAnimator } from "../../glyph-anim/factory.ts"; +import { createGlyphAnimator, GLYPH_ANIM_DURATION_MS } from "../../glyph-anim/factory.ts"; export interface CastGlyphConfig { glyphAnim: GlyphAnimStyle; @@ -139,7 +139,7 @@ function buildRevealPhase( ...(glyphConfig ? [ wait(200), - ...buildGlyphReveal(GUA[cast.primary - 1].n, "primary", model, glyphConfig), + ...buildGlyphReveal(GUA[cast.primary - 1].n, "primary", model, glyphConfig, timing), ] : []), @@ -356,7 +356,7 @@ function buildWideBecoming( // Becoming glyph reveal ...(glyphConfig && cast.becoming !== null ? [ - ...buildGlyphReveal(GUA[cast.becoming! - 1].n, "becoming", model, glyphConfig), + ...buildGlyphReveal(GUA[cast.becoming! - 1].n, "becoming", model, glyphConfig, timing), ...buildEnterExploration(model), ] : cast.becoming !== null @@ -386,7 +386,7 @@ function buildNarrowBecoming( }, easeOut), ...(glyphConfig && cast.becoming !== null ? [ - ...buildGlyphReveal(GUA[cast.becoming! - 1].n, "becoming", model, glyphConfig), + ...buildGlyphReveal(GUA[cast.becoming! - 1].n, "becoming", model, glyphConfig, timing), ...buildEnterExploration(model), ] : cast.becoming !== null @@ -423,13 +423,21 @@ function buildMarkerPulse(cast: Cast, model: CastModel): Step[] { ]; } -/** Start a glyph reveal: compose + create animator. */ +/** + * Start a glyph reveal: compose + create animator, motion-preset aware. + * The hold matches the chosen style's actual run time (dilated by + * glyphAnimScale) so slow styles aren't truncated and fast styles don't + * sit in dead stillness. A scale of 0 (reduced motion) skips the animation + * entirely — the settled glyph appears at once, then only the breath plays. + */ function buildGlyphReveal( hexName: string, target: "primary" | "becoming", model: CastModel, glyphConfig: CastGlyphConfig, + timing: RitualTiming, ): Step[] { + const animMs = Math.round(GLYPH_ANIM_DURATION_MS[glyphConfig.glyphAnim] * timing.glyphAnimScale); return [ call(() => { // zh-Hans renders the Simplified glyph so it matches the Simplified text. @@ -442,12 +450,18 @@ function buildGlyphReveal( model.becomingGlyphEntry = glyph; model.focusedHex = "becoming"; } - model.glyphAnimator = createGlyphAnimator(glyphConfig.glyphAnim, glyph); - model.glyphAnimDone = false; + if (animMs > 0) { + model.glyphAnimator = createGlyphAnimator(glyphConfig.glyphAnim, glyph, timing.glyphAnimScale); + model.glyphAnimDone = false; + } else { + // Reduced motion: no animation — show the settled glyph immediately. + model.glyphAnimator = null; + model.glyphAnimDone = true; + } } }), - wait(4000), // covers all animation styles - wait(1200), // breath + ...(animMs > 0 ? [wait(animMs)] : []), + wait(timing.glyphBreathMs), // breath ]; } diff --git a/packages/terminal/src/scenes/dict/browse-renderer.ts b/packages/terminal/src/scenes/dict/browse-renderer.ts index c06df0d6..d5f34bec 100644 --- a/packages/terminal/src/scenes/dict/browse-renderer.ts +++ b/packages/terminal/src/scenes/dict/browse-renderer.ts @@ -125,7 +125,7 @@ function renderRow( : hex.ename; const fg = isSelected ? t.primary : t.secondary; - const bgStyle = isSelected ? { bg: "#1A2030" } : {}; + const bgStyle = isSelected ? { bg: t.dimmed } : {}; // Write background for selected row if (isSelected) { diff --git a/packages/terminal/src/scenes/home/taijitu-render.ts b/packages/terminal/src/scenes/home/taijitu-render.ts index 2b3455db..10fa15f2 100644 --- a/packages/terminal/src/scenes/home/taijitu-render.ts +++ b/packages/terminal/src/scenes/home/taijitu-render.ts @@ -1,8 +1,10 @@ // Taijitu — rotating yin-yang renderer using braille dot patterns. -// Monochrome: no fg/bg set, cells inherit the terminal's default text color. +// Drawn in the active theme's secondary tone by default so the centerpiece +// sits with the rest of the themed screen on any terminal profile. // Blank regions are achieved by simply not writing a cell. import type { CellBuffer } from "../../render/buffer.ts"; +import { getTheme } from "../../color/theme.ts"; export type TaijituStyle = "dots" | "dense"; @@ -35,6 +37,7 @@ export function renderTaijitu( radius: number, theta: number, style: TaijituStyle, + fg: string = getTheme().secondary, ): void { if (radius < 4) return; @@ -79,7 +82,7 @@ export function renderTaijitu( if (bits === 0) continue; - buf.setCell(row, col, { char: String.fromCharCode(0x2800 + bits) }); + buf.setCell(row, col, { char: String.fromCharCode(0x2800 + bits), fg }); } } } diff --git a/packages/terminal/src/scenes/toss/toss-scene.ts b/packages/terminal/src/scenes/toss/toss-scene.ts index abde15cb..5646d345 100644 --- a/packages/terminal/src/scenes/toss/toss-scene.ts +++ b/packages/terminal/src/scenes/toss/toss-scene.ts @@ -10,6 +10,7 @@ import { } from "@iching/core"; import type { Line, Cast } from "@iching/core"; import { getTheme } from "../../color/theme.ts"; +import { lerpColor } from "../../color/lerp.ts"; import { renderLine } from "../cast/line-renderer.ts"; import { anchorRow, LINE_ROW_OFFSETS } from "../cast/hexagram-renderer.ts"; import { @@ -28,6 +29,10 @@ import { const POST_LAND_DELAY = 0.55; const LINE_DRAW_DURATION = 0.3; +/** Half-width (cells) of the ground line the coins land on. */ +const GROUND_HALF_WIDTH = 13; +/** How long (seconds) a touchdown softly brightens the ground. */ +const IMPACT_FLASH_SEC = 0.35; type ScenePhase = "waiting" | "tossing" | "complete"; @@ -44,6 +49,8 @@ export class TossScene implements Scene { private coinsLaunched = 0; private pendingResults: [boolean, boolean, boolean] | null = null; private completedCast: Cast | null = null; + /** Recent coin touchdowns; each softly brightens the ground while young. */ + private impacts: { col: number; ageSec: number }[] = []; private get round() { return this.lines.length; } @@ -70,11 +77,20 @@ export class TossScene implements Scene { } } + // Age out landing flashes regardless of phase so none linger + for (const im of this.impacts) im.ageSec += dtSec; + this.impacts = this.impacts.filter((im) => im.ageSec < IMPACT_FLASH_SEC); + if (this.phase !== "tossing") return; let anyFlying = false; for (const coin of this.coins) { + const phaseBefore = coin.phase; stepCoin(coin, dt); + // Touchdown: airborne → bounce/spin transition marks the impact frame + if (phaseBefore !== coin.phase && (coin.phase === "bouncing" || coin.phase === "spinning")) { + this.impacts.push({ col: Math.round(coin.x), ageSec: 0 }); + } if (coin.phase !== "settled") anyFlying = true; } @@ -103,6 +119,23 @@ export class TossScene implements Scene { writeChromeFooter(frame, `[space] ${tr(lang, "verb.reveal")} · [esc] ${tr(lang, "verb.discard")}`); } + // Casting surface: a dim ground line under the coins so the bounce + // reads against something. Touchdowns brighten it briefly, then fade. + const groundRow = this.landRow() + 1; + if (groundRow >= 0 && groundRow < h) { + const cx = Math.floor(this.cols / 2); + const left = Math.max(0, cx - GROUND_HALF_WIDTH); + const right = Math.min(frame.width - 1, cx + GROUND_HALF_WIDTH); + for (let c = left; c <= right; c++) { + frame.writeText(groundRow, c, "─", { fg: t.tertiary, dim: true }); + } + for (const im of this.impacts) { + if (im.col < left || im.col > right) continue; + const fade = Math.min(1, im.ageSec / IMPACT_FLASH_SEC); + frame.writeText(groundRow, im.col, "─", { fg: lerpColor(t.secondary, t.tertiary, fade) }); + } + } + // Physics coins for (const coin of this.coins) { const row = Math.round(coin.y); diff --git a/packages/terminal/src/widgets/text-input.ts b/packages/terminal/src/widgets/text-input.ts index b0a700c8..bede1803 100644 --- a/packages/terminal/src/widgets/text-input.ts +++ b/packages/terminal/src/widgets/text-input.ts @@ -3,6 +3,7 @@ import type { CellBuffer } from "../render/buffer.ts"; import type { StyledCell } from "../render/cell.ts"; import { stringWidth } from "../layout/measure.ts"; +import { getTheme } from "../color/theme.ts"; export class TextInput { /** Internal storage as array of code points (handles surrogate pairs correctly) */ @@ -78,13 +79,15 @@ export class TextInput { width: number, style?: Partial, ): void { + const t = getTheme(); let c = 0; let charIdx = 0; const chars = this._chars; while (c < width) { const isCursor = charIdx === this.cursorPos; + // Block cursor: inverse video, falling back to theme tokens const cellStyle: Partial = isCursor - ? { ...style, bg: style?.fg ?? "#C8A96B", fg: style?.bg ?? "#0D1117" } + ? { ...style, bg: style?.fg ?? t.primary, fg: style?.bg ?? t.bg } : { ...style }; if (charIdx < chars.length) { @@ -115,10 +118,12 @@ export class TextInput { style?: Partial, ): number { const chars = this._chars; + const t = getTheme(); + // Block cursor: inverse video, falling back to theme tokens const cursorStyle: Partial = { ...style, - bg: style?.fg ?? "#C8A96B", - fg: style?.bg ?? "#0D1117", + bg: style?.fg ?? t.primary, + fg: style?.bg ?? t.bg, }; let r = 0; From 835abb2ada99967fae72d06384f397090b3711e4 Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 17:22:49 -0700 Subject: [PATCH 004/334] =?UTF-8?q?feat(reading):=20canonical=20oracle=20t?= =?UTF-8?q?exts=20=E2=80=94=20=E5=8D=A6=E8=BE=AD/=E5=B0=8F=E8=B1=A1?= =?UTF-8?q?=E5=82=B3=20data,=20cast=20reading=20panel,=20detail=20depth,?= =?UTF-8?q?=20CLI=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data: inject gc (卦辭), gcEn (Legge judgment), yaoXiao (小象傳 ×6) into all 64 hexagrams via scripts/inject-guaci.ts codemod (kept for provenance); add 用九/用六 extra texts to hexagrams 1-2; extend Hexagram type (+HexagramExtra); extend audited zh-Hans table with 20 vetted supplement chars + 繘 Ext-B retention; coverage + spot-check tests - core: readingFocus() — which text governs per the common classical rules (0→judgment, 1→line, 2-3→upper governs, 4-6→becoming, 6 on hex1/2→用九/用六) - cast scene: reading panel in exploration phase — changing lines' 爻辭 (bottom-first) or the 卦辭 when unchanging, plus a one-line dim reading-method hint; language-aware, fits 80x24, truncates with … - cast scene keys: fix dead [esc] (now → home, matching toss/yarrow); add reveal pace control mirroring yarrow (space pause/resume, s skip, f speed) with an honest dim pace footer during the reveal - detail view: 卦辭 renders as the first text section (en: Legge + classical anchor dim beneath; 彖傳 translation relabeled 'Tuan (Commentary on the Decision)'); each line's 小象 dim beneath its 爻辭 - cast context: openDetail signal carries changedPositions; DetailScene marks moving lines on the diagram (○/× gutter markers) and emphasizes their texts; dictionary browsing passes none - cli: castToJson/castToPlain include judgment + changing lines' texts (+用九/ 用六 when all six move); hexagramToJson gains ename/judgment/lineTexts/extra; plain hexagram shows the judgment first Co-Authored-By: Claude Fable 5 --- apps/cli/src/__tests__/cast.test.ts | 94 +++ .../cli/src/__tests__/hexagram-output.test.ts | 62 ++ .../cli/src/__tests__/scene-factories.test.ts | 23 + apps/cli/src/app/reading-flow.ts | 2 +- apps/cli/src/app/scene-factories.ts | 16 +- apps/cli/src/output/json.ts | 26 + apps/cli/src/output/plain.ts | 24 +- packages/core/src/__tests__/guaci.test.ts | 91 +++ .../core/src/__tests__/reading-focus.test.ts | 68 ++ packages/core/src/data/gua.ts | 650 ++++++++++++++++++ packages/core/src/format/reading.ts | 34 + packages/core/src/i18n/simplify.ts | 9 +- packages/core/src/index.ts | 4 +- packages/core/src/types.ts | 11 + .../terminal/src/__tests__/cast-scene.test.ts | 174 +++++ .../src/__tests__/detail-renderer.test.ts | 73 +- .../src/__tests__/reading-renderer.test.ts | 104 +++ packages/terminal/src/i18n/messages.ts | 14 + packages/terminal/src/scene/types.ts | 7 +- .../terminal/src/scenes/cast/cast-scene.ts | 81 ++- packages/terminal/src/scenes/cast/model.ts | 7 + .../src/scenes/cast/reading-renderer.ts | 136 ++++ .../src/scenes/cast/reveal-renderer.ts | 29 +- .../terminal/src/scenes/dict/detail-model.ts | 9 +- .../src/scenes/dict/detail-renderer.ts | 73 +- .../terminal/src/scenes/dict/detail-scene.ts | 3 +- scripts/inject-guaci.ts | 107 +++ 27 files changed, 1892 insertions(+), 39 deletions(-) create mode 100644 apps/cli/src/__tests__/hexagram-output.test.ts create mode 100644 packages/core/src/__tests__/guaci.test.ts create mode 100644 packages/core/src/__tests__/reading-focus.test.ts create mode 100644 packages/terminal/src/__tests__/reading-renderer.test.ts create mode 100644 packages/terminal/src/scenes/cast/reading-renderer.ts create mode 100644 scripts/inject-guaci.ts diff --git a/apps/cli/src/__tests__/cast.test.ts b/apps/cli/src/__tests__/cast.test.ts index 72adec66..5831e50a 100644 --- a/apps/cli/src/__tests__/cast.test.ts +++ b/apps/cli/src/__tests__/cast.test.ts @@ -112,3 +112,97 @@ describe("cast command", () => { expect(text).toContain("Commentary:"); }); }); + +describe("cast output oracle texts", () => { + /** Seed 7: hexagram 47 → 62, changing lines [2, 3, 5]. */ + function makeSeededCast() { + const cast = castHexagram(new SeededRandomSource(7)); + const primary = GUA[cast.primary - 1]; + const becoming = cast.becoming !== null ? GUA[cast.becoming - 1] : null; + return { cast, primary, becoming }; + } + + test("castToJson includes the judgment for primary and becoming", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson(cast, primary, becoming); + + const p = json.primary as Record; + expect(p.ename).toBe(primary.ename); + expect(p.judgment).toEqual({ gc: primary.gc, gcEn: primary.gcEn }); + const b = json.becoming as Record; + expect(b.judgment).toEqual({ gc: becoming!.gc, gcEn: becoming!.gcEn }); + }); + + test("castToJson includes the changing lines' texts with positions", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson(cast, primary, becoming); + + const changing = json.changingLines as Array>; + expect(changing).toHaveLength(cast.changingPositions.length); + for (let i = 0; i < changing.length; i++) { + const pos = cast.changingPositions[i]; + expect(changing[i]).toEqual({ + position: pos, + yao: primary.yao[pos - 1], + yaoEn: primary.yaoEn[pos - 1], + }); + } + }); + + test("castToJson extra is null unless all six lines move on hex 1/2", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson(cast, primary, becoming); + expect(json.extra).toBeNull(); + + // Synthesize the all-moving 乾 cast: 用九 governs + const allMoving = { + ...cast, + primary: 1, + becoming: 2, + changingPositions: [1, 2, 3, 4, 5, 6], + lines: Array.from({ length: 6 }, () => ({ + value: 9 as const, + isYang: true, + isChanging: true, + })), + }; + const yongJson = castToJson(allMoving, GUA[0], GUA[1]); + expect(yongJson.extra).toEqual({ + name: "用九", + text: "見群龍無首,吉。", + textEn: GUA[0].extra!.textEn, + }); + }); + + test("formatCastPlain lists judgment and changing-line texts", () => { + const { cast, primary } = makeSeededCast(); + const structure = buildStructure(cast); + const text = formatCastPlain(cast, primary, structure); + + expect(text).toContain(`Judgment (gc): ${primary.gc}`); + expect(text).toContain(`Judgment (gcEn): ${primary.gcEn}`); + expect(text).toContain("Changing lines:"); + for (const pos of cast.changingPositions) { + expect(text).toContain(` ${pos}: ${primary.yao[pos - 1]}`); + expect(text).toContain(` ${primary.yaoEn[pos - 1]}`); + } + }); + + test("formatCastPlain omits the changing-lines block when none move", () => { + // Force an unchanging cast by stripping the changing flags + const { cast, primary } = makeSeededCast(); + const unchanging = { + ...cast, + becoming: null, + changingPositions: [], + lines: cast.lines.map((l) => ({ + ...l, + isChanging: false, + value: (l.isYang ? 7 : 8) as 7 | 8, + })), + }; + const text = formatCastPlain(unchanging, primary, buildStructure(unchanging)); + expect(text).not.toContain("Changing lines:"); + expect(text).toContain("Judgment (gc):"); + }); +}); diff --git a/apps/cli/src/__tests__/hexagram-output.test.ts b/apps/cli/src/__tests__/hexagram-output.test.ts new file mode 100644 index 00000000..18085660 --- /dev/null +++ b/apps/cli/src/__tests__/hexagram-output.test.ts @@ -0,0 +1,62 @@ +// hexagram command output — oracle texts in JSON and plain formats + +import { describe, test, expect } from "bun:test"; +import { GUA } from "@iching/core"; +import { hexagramToJson } from "../output/json.js"; +import { formatHexagramPlain } from "../output/plain.js"; + +describe("hexagramToJson", () => { + test("includes ename, judgment, and all six line texts with 小象", () => { + const hex = GUA[0]; + const json = hexagramToJson(1, hex); + + expect(json.ename).toBe("The Creative"); + expect(json.judgment).toEqual({ gc: hex.gc, gcEn: hex.gcEn }); + + const lineTexts = json.lineTexts as Array>; + expect(lineTexts).toHaveLength(6); + expect(lineTexts[0]).toEqual({ + position: 1, + yao: hex.yao[0], + yaoEn: hex.yaoEn[0], + yaoXiao: hex.yaoXiao[0], + }); + expect(lineTexts[5].position).toBe(6); + }); + + test("extra is present for hexagrams 1-2 and null elsewhere", () => { + expect((hexagramToJson(1, GUA[0]).extra as Record).name).toBe("用九"); + expect((hexagramToJson(2, GUA[1]).extra as Record).name).toBe("用六"); + expect(hexagramToJson(3, GUA[2]).extra).toBeNull(); + }); + + test("existing commentary block is unchanged", () => { + const hex = GUA[20]; + const json = hexagramToJson(21, hex); + expect(json.commentary).toEqual({ + dx: hex.dx, + tu: hex.tu, + en: hex.en, + te: hex.te, + w: hex.w, + }); + }); +}); + +describe("formatHexagramPlain", () => { + test("all-styles output shows the judgment first", () => { + const hex = GUA[0]; + const text = formatHexagramPlain(1, hex); + expect(text).toContain(`Judgment (gc): ${hex.gc}`); + expect(text).toContain(`Judgment (gcEn): ${hex.gcEn}`); + // 卦辭 precedes the wing commentary + expect(text.indexOf("Judgment (gc):")).toBeLessThan(text.indexOf("大象 (dx):")); + }); + + test("single-style output stays style-only (no judgment block)", () => { + const hex = GUA[0]; + const text = formatHexagramPlain(1, hex, "dx"); + expect(text).toContain(hex.dx); + expect(text).not.toContain("Judgment (gc):"); + }); +}); diff --git a/apps/cli/src/__tests__/scene-factories.test.ts b/apps/cli/src/__tests__/scene-factories.test.ts index 9f778a17..3489d781 100644 --- a/apps/cli/src/__tests__/scene-factories.test.ts +++ b/apps/cli/src/__tests__/scene-factories.test.ts @@ -83,3 +83,26 @@ describe("makeJournalFactory", () => { expect(scene).toBeInstanceOf(JournalScene); }); }); + +describe("cast context (changedPositions) pass-through", () => { + let dir: string; + let journal: JsonlJournalStore; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "cast-context-factory-test-")); + journal = new JsonlJournalStore(join(dir, "history.jsonl")); + }); + + test("openDetail with changedPositions marks the DetailScene model", () => { + const factory = makeBrowseFactory({ journal }); + const scene = factory({ type: "openDetail", kw: 21, changedPositions: [1, 4] }); + expect(scene).toBeInstanceOf(DetailScene); + expect((scene as DetailScene).getModel().changedPositions).toEqual([1, 4]); + }); + + test("openDetail without changedPositions yields an unmarked model", () => { + const factory = makeBrowseFactory({ journal }); + const scene = factory({ type: "openDetail", kw: 21 }); + expect((scene as DetailScene).getModel().changedPositions).toEqual([]); + }); +}); diff --git a/apps/cli/src/app/reading-flow.ts b/apps/cli/src/app/reading-flow.ts index 8293395b..7e2359c5 100644 --- a/apps/cli/src/app/reading-flow.ts +++ b/apps/cli/src/app/reading-flow.ts @@ -203,7 +203,7 @@ async function runPostCastNavigation( journal, }; const startScene: Scene = signal.type === "openDetail" - ? makeDetailScene(signal.kw, factoryDeps) + ? makeDetailScene(signal.kw, factoryDeps, signal.changedPositions) : new BrowseScene(); const router = new SceneRouter(startScene, makeBrowseFactory(factoryDeps)); return await deps.runRouter(router); diff --git a/apps/cli/src/app/scene-factories.ts b/apps/cli/src/app/scene-factories.ts index b2f4f3ab..41971ac6 100644 --- a/apps/cli/src/app/scene-factories.ts +++ b/apps/cli/src/app/scene-factories.ts @@ -33,8 +33,12 @@ 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, deps.language); +export function makeDetailScene( + kw: number, + deps: DetailDeps, + changedPositions?: number[], +): DetailScene { + const scene = new DetailScene(kw, deps.glyphConfig, deps.language, changedPositions); getHexagramHistory(deps.journal, kw).then((h) => scene.setHistory(h.castCount, h.lastCastDate), ); @@ -44,7 +48,9 @@ export function makeDetailScene(kw: number, deps: DetailDeps): DetailScene { /** SceneRouter factory for the dictionary path: handles openDetail, falls back through. */ export function makeBrowseFactory(deps: DetailDeps): SceneFactory { return (signal): Scene | null => { - if (signal.type === "openDetail") return makeDetailScene(signal.kw, deps); + if (signal.type === "openDetail") { + return makeDetailScene(signal.kw, deps, signal.changedPositions); + } return null; }; } @@ -69,7 +75,9 @@ export function makeJournalFactory(deps: JournalDeps): SceneFactory { cs.skipToComplete(false); return cs; } - if (signal.type === "openDetail") return makeDetailScene(signal.kw, deps); + if (signal.type === "openDetail") { + return makeDetailScene(signal.kw, deps, signal.changedPositions); + } if (signal.type === "openDictionary") return new BrowseScene(); // `j` from a replayed CastScene inside the journal router → reset to the journal list. if (signal.type === "openJournal") return new JournalScene(deps.entries); diff --git a/apps/cli/src/output/json.ts b/apps/cli/src/output/json.ts index d12e9329..99687e50 100644 --- a/apps/cli/src/output/json.ts +++ b/apps/cli/src/output/json.ts @@ -13,13 +13,23 @@ export function castToJson( becoming: Hexagram | null, question?: string, ): Record { + // Changing lines carry their oracle texts — the texts a reading turns on. + // All six moving on hexagram 1/2 additionally reads 用九/用六 (extra). + const changingLines = cast.changingPositions.map((pos) => ({ + position: pos, + yao: primary.yao[pos - 1], + yaoEn: primary.yaoEn[pos - 1], + })); + return { question: question ?? null, primary: { number: cast.primary, name: primary.n, pinyin: primary.p, + ename: primary.ename, symbol: primary.u, + judgment: { gc: primary.gc, gcEn: primary.gcEn }, lines: cast.lines.map((l) => ({ value: l.value, yang: l.isYang, @@ -31,10 +41,17 @@ export function castToJson( number: cast.becoming, name: becoming.n, pinyin: becoming.p, + ename: becoming.ename, symbol: becoming.u, + judgment: { gc: becoming.gc, gcEn: becoming.gcEn }, } : null, changingPositions: cast.changingPositions, + changingLines, + extra: + cast.changingPositions.length === 6 && primary.extra + ? primary.extra + : null, derived: { nuclear: cast.nuclear, polarity: cast.polarity, @@ -60,8 +77,17 @@ export function hexagramToJson( number: kw, name: hex.n, pinyin: hex.p, + ename: hex.ename, symbol: hex.u, lines: hex.l, + judgment: { gc: hex.gc, gcEn: hex.gcEn }, + lineTexts: hex.yao.map((yao, i) => ({ + position: i + 1, + yao, + yaoEn: hex.yaoEn[i], + yaoXiao: hex.yaoXiao[i], + })), + extra: hex.extra ?? null, commentary: { dx: hex.dx, tu: hex.tu, diff --git a/apps/cli/src/output/plain.ts b/apps/cli/src/output/plain.ts index 4b27b01f..ab006613 100644 --- a/apps/cli/src/output/plain.ts +++ b/apps/cli/src/output/plain.ts @@ -59,6 +59,26 @@ export function formatCastPlain( lines.push(""); } + // Judgment (卦辭) — the hexagram's own text + lines.push(`Judgment (gc): ${primary.gc}`); + lines.push(`Judgment (gcEn): ${primary.gcEn}`); + lines.push(""); + + // Changing lines — the texts the reading turns on + if (cast.changingPositions.length > 0) { + lines.push("Changing lines:"); + for (const pos of cast.changingPositions) { + lines.push(` ${pos}: ${primary.yao[pos - 1]}`); + lines.push(` ${primary.yaoEn[pos - 1]}`); + } + // All six moving on hexagram 1/2 reads 用九/用六 + if (cast.changingPositions.length === 6 && primary.extra) { + lines.push(` ${primary.extra.name}: ${primary.extra.text}`); + lines.push(` ${primary.extra.textEn}`); + } + lines.push(""); + } + // Commentary lines.push("Commentary:"); lines.push(` 大象 (dx): ${primary.dx}`); @@ -95,7 +115,9 @@ export function formatHexagramPlain( // the trigram block above already covers it, so we skip the commentary. lines.push(hex[style]); } else if (!style) { - // Show all commentary styles + // Show the judgment (卦辭) first, then all commentary styles + lines.push(`Judgment (gc): ${hex.gc}`); + lines.push(`Judgment (gcEn): ${hex.gcEn}`); lines.push(`大象 (dx): ${hex.dx}`); lines.push(`彖傳 (tu): ${hex.tu}`); lines.push(`Image (en): ${hex.en}`); diff --git a/packages/core/src/__tests__/guaci.test.ts b/packages/core/src/__tests__/guaci.test.ts new file mode 100644 index 00000000..f86a92ad --- /dev/null +++ b/packages/core/src/__tests__/guaci.test.ts @@ -0,0 +1,91 @@ +// Coverage + spot checks for the canonical oracle texts injected by +// scripts/inject-guaci.ts: gc (卦辭), gcEn (Legge judgment), yaoXiao (小象傳), +// and the 用九/用六 extras on hexagrams 1-2. + +import { describe, test, expect } from "bun:test"; +import { GUA } from "../data/gua.js"; +import { toSimplified, SIMPLIFIED_MAP, SIMPLIFIED_EXCEPTIONS } from "../i18n/simplify.js"; + +describe("GUA judgment texts (卦辭)", () => { + test("all 64 hexagrams have non-empty gc and gcEn", () => { + for (let kw = 1; kw <= 64; kw++) { + const g = GUA[kw - 1]; + expect(g.gc.length).toBeGreaterThan(0); + expect(g.gcEn.length).toBeGreaterThan(0); + } + }); + + test("all 64 hexagrams have exactly 6 yaoXiao entries, all non-empty", () => { + for (let kw = 1; kw <= 64; kw++) { + const g = GUA[kw - 1]; + expect(g.yaoXiao).toHaveLength(6); + for (const x of g.yaoXiao) { + expect(x.length).toBeGreaterThan(0); + } + } + }); + + test("hexagram 1 (乾) exact strings", () => { + const g = GUA[0]; + expect(g.gc).toBe("元亨,利貞。"); + expect(g.gcEn).toBe( + "Khien (represents) what is great and originating, penetrating, advantageous, correct and firm.", + ); + expect(g.yaoXiao[0]).toBe("潛龍勿用,陽在下也。"); + expect(g.yaoXiao[5]).toBe("亢龍有悔,盈不可久也。"); + }); + + test("hexagram 2 (坤) exact strings", () => { + const g = GUA[1]; + expect(g.gc).toBe( + "元亨,利牝馬之貞。君子有攸往,先迷後得主,利西南得朋,東北喪朋。安貞,吉。", + ); + expect(g.yaoXiao[4]).toBe("黃裳元吉,文在中也。"); + }); + + test("hexagram 63 (既濟) exact gc", () => { + expect(GUA[62].gc).toBe("亨,小利貞,初吉終亂。"); + }); + + test("hexagram 64 (未濟) exact gc + gcEn prefix", () => { + expect(GUA[63].gc).toBe("亨,小狐汔濟,濡其尾,無攸利。"); + expect(GUA[63].gcEn.startsWith("Wei Zi intimates progress and success")).toBe(true); + }); + + test("用九/用六 extras exist exactly on hexagrams 1 and 2", () => { + expect(GUA[0].extra).toEqual({ + name: "用九", + text: "見群龍無首,吉。", + textEn: expect.stringContaining("the use of the number nine"), + }); + expect(GUA[1].extra).toEqual({ + name: "用六", + text: "利永貞。", + textEn: expect.stringContaining("the use of the number six"), + }); + expect(GUA.filter((g) => g.extra).length).toBe(2); + }); +}); + +describe("gc/yaoXiao zh-Hans conversion coverage", () => { + test("every Traditional char in the new texts is mapped, identity, or a listed exception", () => { + // The audited map covers the rendered corpus. New texts (gc/yaoXiao/extra) + // must not silently leak unconverted Traditional characters: any char with + // a distinct simplified form must be in SIMPLIFIED_MAP; deliberate + // retentions live in SIMPLIFIED_EXCEPTIONS. This test pins the supplement + // entries added for the 卦辭/小象傳 corpus. + expect(toSimplified("馴致其道")).toBe("驯致其道"); // hex 2 小象 + expect(toSimplified("以貴下賤")).toBe("以贵下贱"); // hex 3 小象 + expect(toSimplified("再三瀆,瀆則不告")).toBe("再三渎,渎则不告"); // hex 4 卦辭 + expect(toSimplified("其辯明也")).toBe("其辩明也"); // hex 6 小象 + expect(toSimplified("見群龍無首")).toBe("见群龙无首"); // 用九 + // Ext-B retention: 繘 stays Traditional (hex 48 卦辭) + expect(toSimplified("汔至亦未繘井")).toBe("汔至亦未繘井"); + expect(SIMPLIFIED_EXCEPTIONS).toContain("繘"); + }); + + test("乾 canonical exception is never converted in judgment texts", () => { + expect(toSimplified(GUA[0].yaoXiao[2])).toContain("乾乾"); + expect(SIMPLIFIED_MAP["乾"]).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/reading-focus.test.ts b/packages/core/src/__tests__/reading-focus.test.ts new file mode 100644 index 00000000..3b219f11 --- /dev/null +++ b/packages/core/src/__tests__/reading-focus.test.ts @@ -0,0 +1,68 @@ +// readingFocus — which canonical text governs a reading (common classical rules) + +import { describe, test, expect } from "bun:test"; +import { readingFocus } from "../format/reading.js"; + +describe("readingFocus", () => { + test("no moving lines → the judgment", () => { + expect(readingFocus({ primary: 21, changingPositions: [] })).toEqual({ + kind: "judgment", + }); + }); + + test("one moving line → that line", () => { + expect(readingFocus({ primary: 21, changingPositions: [4] })).toEqual({ + kind: "line", + position: 4, + }); + }); + + test("two moving lines → both noted, upper governs", () => { + expect(readingFocus({ primary: 21, changingPositions: [4, 1] })).toEqual({ + kind: "lines", + positions: [1, 4], + governing: 4, + }); + }); + + test("three moving lines → noted, upper governs", () => { + expect(readingFocus({ primary: 21, changingPositions: [2, 5, 3] })).toEqual({ + kind: "lines", + positions: [2, 3, 5], + governing: 5, + }); + }); + + test("four and five moving lines → the becoming", () => { + expect(readingFocus({ primary: 21, changingPositions: [1, 2, 3, 4] })).toEqual({ + kind: "becoming", + }); + expect(readingFocus({ primary: 21, changingPositions: [1, 2, 3, 4, 5] })).toEqual({ + kind: "becoming", + }); + }); + + test("all six on hexagram 1 → 用九", () => { + expect( + readingFocus({ primary: 1, changingPositions: [1, 2, 3, 4, 5, 6] }), + ).toEqual({ kind: "extra", name: "用九" }); + }); + + test("all six on hexagram 2 → 用六", () => { + expect( + readingFocus({ primary: 2, changingPositions: [1, 2, 3, 4, 5, 6] }), + ).toEqual({ kind: "extra", name: "用六" }); + }); + + test("all six on any other hexagram → the becoming", () => { + expect( + readingFocus({ primary: 63, changingPositions: [1, 2, 3, 4, 5, 6] }), + ).toEqual({ kind: "becoming" }); + }); + + test("does not mutate the input positions", () => { + const positions = [5, 2]; + readingFocus({ primary: 3, changingPositions: positions }); + expect(positions).toEqual([5, 2]); + }); +}); diff --git a/packages/core/src/data/gua.ts b/packages/core/src/data/gua.ts index a0d6037d..d773aa60 100644 --- a/packages/core/src/data/gua.ts +++ b/packages/core/src/data/gua.ts @@ -29,6 +29,21 @@ export const GUA: Hexagram[] = [ "Flying dragon in the heavens. It furthers one to see the great man.", "Arrogant dragon will have cause to repent.", ], + gc: "元亨,利貞。", + gcEn: "Khien (represents) what is great and originating, penetrating, advantageous, correct and firm.", + yaoXiao: [ + "潛龍勿用,陽在下也。", + "見龍在田,德施普也。", + "終日乾乾,反復道也。", + "或躍在淵,進無咎也。", + "飛龍在天,大人造也。", + "亢龍有悔,盈不可久也。", + ], + extra: { + name: "用九", + text: "見群龍無首,吉。", + textEn: "(The lines of this hexagram are all strong and undivided, as appears from) the use of the number nine. If the host of dragons (thus) appearing were to divest themselves of their heads, there would be good fortune.", + }, }, { u: "䷁", @@ -57,6 +72,21 @@ export const GUA: Hexagram[] = [ "A yellow lower garment brings supreme good fortune.", "Dragons fight in the meadow. Their blood is black and yellow.", ], + gc: "元亨,利牝馬之貞。君子有攸往,先迷後得主,利西南得朋,東北喪朋。安貞,吉。", + gcEn: "Khwan (represents) what is great and originating, penetrating, advantageous, correct and having the firmness of a mare. When the superior man (here intended) has to make any movement, if he take the initiative, he will go astray; if he follow, he will find his (proper) lord. The advantageousness will be seen in his getting friends in the south-west, and losing friends in the north-east. If he rest in correctness and firmness, there will be good fortune.", + yaoXiao: [ + "履霜堅冰,陰始凝也。馴致其道,至堅冰也。", + "六二之動,直以方也。不習無不利,地道光也。", + "含章可貞;以時發也。或從王事,知光大也。", + "括囊無咎,慎不害也。", + "黃裳元吉,文在中也。", + "戰龍於野,其道窮也。", + ], + extra: { + name: "用六", + text: "利永貞。", + textEn: "(The lines of this hexagram are all weak and divided, as appears from) the use of the number six. If those (who are thus represented) be perpetually correct and firm, advantage will arise.", + }, }, { u: "䷂", @@ -85,6 +115,16 @@ export const GUA: Hexagram[] = [ "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.", ], + gc: "元亨,利貞,勿用有攸往,利建侯。", + gcEn: "Kun (indicates that in the case which it presupposes) there will be great progress and success, and the advantage will come from being correct and firm. (But) any movement in advance should not be (lightly) undertaken. There will be advantage in appointing feudal princes.", + yaoXiao: [ + "雖磐桓,志行正也。以貴下賤,大得民也。", + "六二之難,乘剛也。十年乃字,反常也。", + "即鹿無虞,以縱禽也。君子舍之,往吝窮也。", + "求而往,明也。", + "屯其膏,施未光也。", + "泣血漣如,何可長也。", + ], }, { u: "䷃", @@ -113,6 +153,16 @@ export const GUA: Hexagram[] = [ "Childlike folly brings good fortune.", "In punishing folly, it does not further one to commit transgressions. It furthers one to prevent transgressions.", ], + gc: "亨。匪我求童蒙,童蒙求我。初筮告,再三瀆,瀆則不告。利貞。", + gcEn: "Mang (indicates that in the case which it presupposes) there will be progress and success. I do not (go and) seek the youthful and inexperienced, but he comes and seeks me. When he shows (the sincerity that marks) the first recourse to divination, I instruct him. If he apply a second and third time, that is troublesome; and I do not instruct the troublesome. There will be advantage in being firm and correct.", + yaoXiao: [ + "利用刑人,以正法也。", + "子克家,剛柔接也。", + "勿用取女,行不順也。", + "困蒙之吝,獨遠實也。", + "童蒙之吉,順以巽也。", + "利用禦寇,上下順也。", + ], }, { u: "䷄", @@ -141,6 +191,16 @@ export const GUA: Hexagram[] = [ "Waiting amid food and drink. Perseverance brings good fortune.", "One falls into the pit. Three uninvited guests arrive. Honor them, and in the end there will be good fortune.", ], + gc: "有孚,光亨,貞吉。利涉大川。", + gcEn: "Hsu intimates that, with the sincerity which is declared in it, there will be brilliant success. With firmness there will be good fortune; and it will be advantageous to cross the great stream.", + yaoXiao: [ + "需于郊,不犯難行也。利用恆,無咎;未失常也。", + "需于沙,衍在中也。雖小有言,以終吉也。", + "需于泥,災在外也。自我致寇,敬慎不敗也。", + "需于血,順以聽也。", + "酒食貞吉,以中正也。", + "不速之客來,敬之終吉。雖不當位,未大失也。", + ], }, { u: "䷅", @@ -169,6 +229,16 @@ export const GUA: Hexagram[] = [ "To contend before him brings supreme good fortune.", "Even if one is bestowed a leather belt of honor, by the end of a morning it will be stripped thrice.", ], + gc: "有孚,窒。惕中吉。終凶。利見大人,不利涉大川。", + gcEn: "Sung intimates how, though there is sincerity in one's contention, he will yet meet with opposition and obstruction; but if he cherish an apprehensive caution, there will be good fortune, while, if he must prosecute the contention to the (bitter) end, there will be evil. It will be advantageous to see the great man; it will not be advantageous to cross the great stream.", + yaoXiao: [ + "不永所事,訟不可長也。雖有小言,其辯明也。", + "不克訟,歸而逋也。自下訟上,患至掇也。", + "食舊德,從上吉也。", + "復即命,渝安貞;不失也。", + "訟元吉,以中正也。", + "以訟受服,亦不足敬也。", + ], }, { u: "䷆", @@ -197,6 +267,16 @@ export const GUA: Hexagram[] = [ "There is game in the field. It furthers one to catch it. No blame. Let the eldest lead the army. If the younger transports corpses, perseverance brings misfortune.", "The great prince issues commands, founds states, vests families with fiefs. Inferior people should not be employed.", ], + gc: "貞,丈人,吉無咎。", + gcEn: "Sze indicates how, in the case which it supposes, with firmness and correctness, and (a leader of) age and experience, there will be good fortune and no error.", + yaoXiao: [ + "師出以律,失律凶也。", + "在師中吉,承天寵也。王三錫命,懷萬邦也。", + "師或輿尸,大無功也。", + "左次無咎,未失常也。", + "長子帥師,以中行也。弟子輿尸,使不當也。", + "大君有命,以正功也。小人勿用,必亂邦也。", + ], }, { u: "䷇", @@ -225,6 +305,16 @@ export const GUA: Hexagram[] = [ "Manifestation of holding together. The king uses beaters on three sides only and forgoes the game that runs off in front. The citizens need no warning. Good fortune.", "Holding together without a head. Misfortune.", ], + gc: "吉。原筮元永貞,無咎。不寧方來,後夫凶。", + gcEn: "Pi indicates that (under the conditions which it supposes) there is good fortune. But let (the principal party intended in it) re-examine himself, (as if) by divination, whether his virtue be great, unintermitting, and firm. If it be so, there will be no error. Those who have not rest will then come to him; and with those who are (too) late in coming it will be ill.", + yaoXiao: [ + "比之初六,有他吉也。", + "比之自內,不自失也。", + "比之匪人,不亦傷乎!", + "外比於賢,以從上也。", + "顯比之吉,位正中也。舍逆取順,失前禽也。邑人不誡,上使中也。", + "比之無首,無所終也。", + ], }, { u: "䷈", @@ -253,6 +343,16 @@ export const GUA: Hexagram[] = [ "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 noble one persists, misfortune comes.", ], + gc: "亨。密雲不雨,自我西郊。", + gcEn: "Hsiao Khu indicates that (under its conditions) there will be progress and success. (We see) dense clouds, but no rain coming from our borders in the west.", + yaoXiao: [ + "復自道,其義吉也。", + "牽復在中,亦不自失也。", + "夫妻反目,不能正室也。", + "有孚惕出,上合志也。", + "有孚攣如,不獨富也。", + "既雨既處,德積載也。君子征凶,有所疑也。", + ], }, { u: "䷉", @@ -281,6 +381,16 @@ export const GUA: Hexagram[] = [ "Resolute conduct. Perseverance with awareness of danger.", "Look to your conduct and weigh the favorable signs. When everything is fulfilled, supreme good fortune comes.", ], + gc: "履虎尾,不咥人,亨。", + gcEn: "(Li suggests the idea of) one treading on the tail of a tiger, which does not bite him. There will be progress and success.", + yaoXiao: [ + "素履之往,獨行愿也。", + "幽人貞吉,中不自亂也。", + "眇能視;不足以有明也。跛能履;不足以與行也,咥人之凶;位不當也。武人為于大君;志剛也。", + "愬愬終吉,志行也。", + "夬履貞厲,位正當也。", + "元吉在上,大有慶也。", + ], }, { u: "䷊", @@ -309,6 +419,16 @@ export const GUA: Hexagram[] = [ "The sovereign gives his daughter in marriage. This brings blessing and supreme good fortune.", "The wall falls back into the moat. Use not the army. Make your commands known within your own town. Perseverance brings humiliation.", ], + gc: "小往大來,吉亨。", + gcEn: "In Thai (we see) the little gone and the great come. (It indicates that) there will be good fortune, with progress and success.", + yaoXiao: [ + "拔茅征吉,志在外也。", + "包荒,得尚于中行,以光大也。", + "無往不復,天地際也。", + "翩翩不富,皆失實也。不戒以孚,中心願也。", + "以祉元吉,中以行愿也。", + "城復于隍,其命亂也。", + ], }, { u: "䷋", @@ -337,6 +457,16 @@ export const GUA: Hexagram[] = [ "Standstill is giving way. Good fortune for the great man. 'What if it should fail? What if it should fail?' In this way he ties it to a cluster of mulberry shoots.", "The standstill comes to an end. First standstill, then good fortune.", ], + gc: "否之匪人,不利君子貞,大往小來。", + gcEn: "In Phi there is the want of good understanding between the (different classes of) men, and its indication is unfavourable to the firm and correct course of the superior man. We see in it the great gone and the little come.", + yaoXiao: [ + "拔茅貞吉,志在君也。", + "大人否亨,不亂群也。", + "包羞,位不當也。", + "有命無咎,志行也。", + "大人之吉,位正當也。", + "否終則傾,何可長也。", + ], }, { u: "䷌", @@ -365,6 +495,16 @@ export const GUA: Hexagram[] = [ "Men bound in fellowship first weep and lament, but afterward they laugh. After great struggles they succeed in meeting.", "Fellowship with men in the meadow. No remorse.", ], + gc: "同人于野,亨。利涉大川,利君子貞。", + gcEn: "Thung Zan (or 'Union of men') appears here (as we find it) in the (remote districts of the) country, indicating progress and success. It will be advantageous to cross the great stream. It will be advantageous to maintain the firm correctness of the superior man.", + yaoXiao: [ + "出門同人,又誰咎也。", + "同人于宗,吝道也。", + "伏戎于莽,敵剛也。三歲不興,安行也。", + "乘其墉,義弗克也,其吉,則困而反則也。", + "同人之先,以中直也。大師相遇,言相克也。", + "同人于郊,志未得也。", + ], }, { u: "䷍", @@ -393,6 +533,16 @@ export const GUA: Hexagram[] = [ "He whose truth is accessible yet dignified has good fortune.", "He is blessed by heaven. Good fortune. Nothing that does not further.", ], + gc: "元亨。", + gcEn: "Ta Yu indicates that, (under the circumstances which it implies), there will be great progress and success.", + yaoXiao: [ + "大有初九,無交害也。", + "大車以載,積中不敗也。", + "公用亨于天子,小人害也。", + "匪其彭,無咎;明辨晰也。", + "厥孚交如,信以發志也。威如之吉,易而無備也。", + "大有上吉,自天佑也。", + ], }, { u: "䷎", @@ -421,6 +571,16 @@ export const GUA: Hexagram[] = [ "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.", ], + gc: "亨,君子有終。", + gcEn: "Khien indicates progress and success. The superior man, (being humble as it implies), will have a (good) issue (to his undertakings).", + yaoXiao: [ + "謙謙君子,卑以自牧也。", + "鳴謙貞吉,中心得也。", + "勞謙君子,萬民服也。", + "無不利,撝謙;不違則也。", + "利用侵伐,征不服也。", + "鳴謙,志未得也。可用行師,征邑國也。", + ], }, { u: "䷏", @@ -449,6 +609,16 @@ export const GUA: Hexagram[] = [ "Persistently ill, yet does not die.", "Deluded enthusiasm. But if after completion one changes, there is no blame.", ], + gc: "利建侯,行師。", + gcEn: "Yu indicates that, (in the state which it implies), feudal princes may be set up, and the hosts put in motion, with advantage.", + yaoXiao: [ + "初六鳴豫,志窮凶也。", + "不終日,貞吉;以中正也。", + "盱豫有悔,位不當也。", + "由豫,大有得;志大行也。", + "六五貞疾,乘剛也。恆不死,中未亡也。", + "冥豫在上,何可長也。", + ], }, { u: "䷐", @@ -477,6 +647,16 @@ export const GUA: Hexagram[] = [ "Sincere in goodness — good fortune.", "He is bound and held fast, then released into following. The king makes offerings at the Western Mountain.", ], + gc: "元亨利貞,無咎。", + gcEn: "Sui indicates that (under its conditions) there will be great progress and success. But it will be advantageous to be firm and correct. There will (then) be no error.", + yaoXiao: [ + "官有渝,從正吉也。出門交有功,不失也。", + "係小子,弗兼與也。", + "系丈夫,志舍下也。", + "隨有獲,其義凶也。有孚在道,明功也。", + "孚于嘉,吉;位正中也。", + "拘系之,上窮也。", + ], }, { u: "䷑", @@ -505,6 +685,16 @@ export const GUA: Hexagram[] = [ "Setting right what the father has spoiled; one meets with praise.", "He does not serve kings and princes; he sets his goals higher.", ], + gc: "元亨,利涉大川。先甲三日,後甲三日。", + gcEn: "Ku indicates great progress and success (to him who deals properly with the condition represented by it). There will be advantage in (efforts like that of) crossing the great stream. (He should weigh well, however, the events of) three days before the turning point, and those (to be done) three days after it.", + yaoXiao: [ + "幹父之蠱,意承考也。", + "幹母之蠱,得中道也。", + "幹父之蠱,終無咎也。", + "裕父之蠱,往未得也。", + "干父之蠱;承以德也。", + "不事王侯,志可則也。", + ], }, { u: "䷒", @@ -533,6 +723,16 @@ export const GUA: Hexagram[] = [ "Wise approach, befitting a great ruler; good fortune.", "Approach with generosity of heart; good fortune and no blame.", ], + gc: "元,亨,利,貞。至于八月有凶。", + gcEn: "Lin (indicates that under the conditions supposed in it) there will be great progress and success, while it will be advantageous to be firmly correct. In the eighth month there will be evil.", + yaoXiao: [ + "咸臨貞吉,志行正也。", + "咸臨,吉無不利;未順命也。", + "甘臨,位不當也。既憂之,咎不長也。", + "至臨無咎,位當也。", + "大君之宜,行中之謂也。", + "敦臨之吉,志在內也。", + ], }, { u: "䷓", @@ -561,6 +761,16 @@ export const GUA: Hexagram[] = [ "Contemplation of my own life; the noble one is without blame.", "Contemplation of his life; the noble one is without blame.", ], + gc: "盥而不薦,有孚顒若。", + gcEn: "Kwan shows (how he whom it represents should be like) the worshipper who has washed his hands, but not (yet) presented his offerings;—with sincerity and an appearance of dignity (commanding reverent regard).", + yaoXiao: [ + "初六童觀,小人道也。", + "窺觀女貞,亦可醜也。", + "觀我生,進退;未失道也。", + "觀國之光,尚賓也。", + "觀我生,觀民也。", + "觀其生,志未平也。", + ], }, { u: "䷔", @@ -589,6 +799,16 @@ export const GUA: Hexagram[] = [ "Biting on dried meat and finding yellow gold. Perseverance in awareness of danger — no blame.", "His neck is fastened in a wooden cangue so that his ears disappear; misfortune.", ], + gc: "亨。利用獄。", + gcEn: "Shih Ho indicates successful progress (in the condition of things which it supposes). It will be advantageous to use legal constraints.", + yaoXiao: [ + "屨校滅趾,不行也。", + "噬膚滅鼻,乘剛也。", + "遇毒,位不當也。", + "利艱貞吉,未光也。", + "貞厲無咎,得當也。", + "何校滅耳,聰不明也。", + ], }, { u: "䷕", @@ -617,6 +837,16 @@ export const GUA: Hexagram[] = [ "Grace in hills and gardens. The roll of silk is meager and small; humiliation, but in the end good fortune.", "Simple grace without color — no blame.", ], + gc: "亨。小利有攸往。", + gcEn: "Pi indicates that there should be free course (in what it denotes). There will be little advantage (however) if it be allowed to advance (and take the lead).", + yaoXiao: [ + "舍車而徒,義弗乘也。", + "賁其須,與上興也。", + "永貞之吉,終莫之陵也。", + "六四,當位疑也。匪寇婚媾,終無尤也。", + "六五之吉,有喜也。", + "白賁無咎,上得志也。", + ], }, { u: "䷖", @@ -645,6 +875,16 @@ export const GUA: Hexagram[] = [ "A string of fish; favor comes through the court ladies. Everything acts to further.", "A large fruit uneaten. The noble one receives a carriage; the inferior person's hut is split apart.", ], + gc: "不利有攸往。", + gcEn: "Po indicates that (in the state which it symbolises) it will not be advantageous to make a movement in any direction whatever.", + yaoXiao: [ + "剝床以足,以滅下也。", + "剝床以辨,未有與也。", + "剝之無咎,失上下也。", + "剝床以膚,切近災也。", + "以宮人寵,終無尤也。", + "君子得輿,民所載也。小人剝廬,終不可用也。", + ], }, { u: "䷗", @@ -673,6 +913,16 @@ export const GUA: Hexagram[] = [ "Noble-hearted return — no remorse.", "Missing the return brings misfortune, calamity, and disaster. Using armies ends in great defeat, extending to the ruler. For ten years one cannot mount an expedition.", ], + gc: "亨。出入無疾,朋來無咎。反復其道,七日來復,利有攸往。", + gcEn: "Fu indicates that there will be free course and progress (in what it denotes). (The subject of it) finds no one to distress him in his exits and entrances; friends come to him, and no error is committed . He will return and repeat his (proper) course. In seven days comes his return. There will be advantage in whatever direction movement is made.", + yaoXiao: [ + "不遠之復,以修身也。", + "休復之吉,以下仁也。", + "頻復之厲,義無咎也。", + "中行獨復,以從道也。", + "敦復無悔,中以自考也。", + "迷復之凶,反君道也。", + ], }, { u: "䷘", @@ -701,6 +951,16 @@ export const GUA: Hexagram[] = [ "The illness of innocence: do not use medicine and there will be cause for joy.", "Innocent action still brings misadventure; nothing furthers.", ], + gc: "元亨,利貞。其匪正有眚,不利有攸往。", + gcEn: "Wu Wang indicates great progress and success, while there will be advantage in being firm and correct. If (its subject and his action) be not correct, he will fall into errors, and it will not be advantageous for him to move in any direction.", + yaoXiao: [ + "无妄之往,得志也。", + "不耕獲,未富也。", + "行人得牛,邑人災也。", + "可貞無咎,固有之也。", + "无妄之藥,不可試也。", + "无妄之行,窮之災也。", + ], }, { u: "䷙", @@ -729,6 +989,16 @@ export const GUA: Hexagram[] = [ "The tusk of a gelded boar — good fortune.", "One attains the way of heaven — success.", ], + gc: "利貞,不家食吉,利涉大川。", + gcEn: "Under the conditions of Ta Khu it will be advantageous to be firm and correct. (If its subject do not seek to) enjoy his revenues in his own family (without taking service at court), there will be good fortune. It will be advantageous for him to cross the great stream.", + yaoXiao: [ + "有厲利已,不犯災也。", + "輿說輹,中無尤也。", + "利有攸往,上合志也。", + "六四元吉,有喜也。", + "六五之吉,有慶也。", + "何天之衢,道大行也。", + ], }, { u: "䷚", @@ -757,6 +1027,16 @@ export const GUA: Hexagram[] = [ "Deviating from the path; to remain persevering brings good fortune, but one should not cross the great water.", "The source of nourishment; awareness of danger brings good fortune. It furthers to cross the great water.", ], + gc: "貞吉。觀頤,自求口實。", + gcEn: "I indicates that with firm correctness there will be good fortune (in what is denoted by it). We must look at what we are seeking to nourish, and by the exercise of our thoughts seek for the proper aliment.", + yaoXiao: [ + "觀我朵頤,亦不足貴也。", + "六二征凶,行失類也。", + "十年勿用,道大悖也。", + "顛頤之吉,上施光也。", + "居貞之吉,順以從上也。", + "由頤厲吉,大有慶也。", + ], }, { u: "䷛", @@ -785,6 +1065,16 @@ export const GUA: Hexagram[] = [ "A dry poplar puts forth flowers; an older woman takes a young husband. No blame, no praise.", "One must wade through the water until it flows over one's head; misfortune, but no blame.", ], + gc: "棟橈,利有攸往,亨。", + gcEn: "Ta Kwo suggests to us a beam that is weak. There will be advantage in moving (under its conditions) in any direction whatever; there will be success.", + yaoXiao: [ + "藉用白茅,柔在下也。", + "老夫女妻,過以相與也。", + "棟橈之凶,不可以有輔也。", + "棟隆之吉,不橈乎下也。", + "枯楊生華,何可久也。老婦士夫,亦可醜也。", + "過涉之凶,不可咎也。", + ], }, { u: "䷜", @@ -813,6 +1103,16 @@ export const GUA: Hexagram[] = [ "The abyss does not overflow; it only fills to the rim — no blame.", "Bound with cords and ropes, shut in among thorn-hedged prison walls. For three years one cannot find the way — misfortune.", ], + gc: "習坎,有孚,維心亨,行有尚。", + gcEn: "Khan, here repeated, shows the possession of sincerity, through which the mind is penetrating. Action (in accordance with this) will be of high value.", + yaoXiao: [ + "習坎入坎,失道凶也。", + "求小得,未出中也。", + "來之坎坎,終無功也。", + "樽酒簋貳,剛柔際也。", + "坎不盈,中未大也。", + "上六失道,凶三歲也。", + ], }, { u: "䷝", @@ -841,6 +1141,16 @@ export const GUA: Hexagram[] = [ "Tears flow in floods, sighing and lamenting — good fortune.", "The king uses him to march forth and punish. It is best to kill the leaders and capture the followers — no blame.", ], + gc: "利貞,亨。畜牝牛,吉。", + gcEn: "Li indicates that, (in regard to what it denotes), it will be advantageous to be firm and correct, and that thus there will be free course and success. Let (its subject) also nourish (a docility like that of) the cow, and there will be good fortune.", + yaoXiao: [ + "履錯之敬,以辟咎也。", + "黃離元吉,得中道也。", + "日昃之離,何可久也。", + "突如其來如,無所容也。", + "六五之吉,離王公也。", + "王用出征,以正邦也。", + ], }, { u: "䷞", @@ -869,6 +1179,16 @@ export const GUA: Hexagram[] = [ "Influence in the back of the neck — no remorse.", "Influence in the jaws, cheeks, and tongue.", ], + gc: "咸,亨,利貞,取女吉。", + gcEn: "Hsien indicates that, (on the fulfilment of the conditions implied in it), there will be free course and success. Its advantageousness will depend on the being firm and correct, (as) in marrying a young lady. There will be good fortune.", + yaoXiao: [ + "咸其拇,志在外也。", + "雖凶居吉,順不害也。", + "咸其股,亦不處也。志在隨人,所執下也。", + "貞吉悔亡,未感害也。憧憧往來,未光大也。", + "咸其脢,志末也。", + "咸其輔頰舌,滕口說也。", + ], }, { u: "䷟", @@ -897,6 +1217,16 @@ export const GUA: Hexagram[] = [ "Giving duration to one's character through perseverance: good fortune for a woman, misfortune for a man.", "Restlessness as an enduring condition brings misfortune.", ], + gc: "亨,無咎,利貞,利有攸往。", + gcEn: "Hang indicates successful progress and no error (in what it denotes). But the advantage will come from being firm and correct; and movement in any direction whatever will be advantageous.", + yaoXiao: [ + "浚恆之凶,始求深也。", + "九二悔亡,能久中也。", + "不恆其德,無所容也。", + "久非其位,安得禽也。", + "婦人貞吉,從一而終也。夫子制義,從婦凶也。", + "振恆在上,大無功也。", + ], }, { u: "䷠", @@ -925,6 +1255,16 @@ export const GUA: Hexagram[] = [ "Admirable retreat — perseverance brings good fortune.", "Retreat in abundance, with no direction unfavorable.", ], + gc: "亨,小利貞。", + gcEn: "Thun indicates successful progress (in its circumstances). To a small extent it will (still) be advantageous to be firm and correct.", + yaoXiao: [ + "遯尾之厲,不往何災也。", + "執用黃牛,固志也。", + "係遯之厲,有疾憊也。畜臣妾吉,不可大事也。", + "君子好遯,小人否也。", + "嘉遯貞吉,以正志也。", + "肥遯,無不利;無所疑也。", + ], }, { u: "䷡", @@ -953,6 +1293,16 @@ export const GUA: Hexagram[] = [ "Losing the ram with ease — no remorse.", "A ram butts a hedge and can neither retreat nor advance. Nothing is favorable; hardship brings good fortune.", ], + gc: "利貞。", + gcEn: "Ta Kwang indicates that (under the conditions which it symbolises) it will be advantageous to be firm and correct.", + yaoXiao: [ + "壯于趾,其孚窮也。", + "九二貞吉,以中也。", + "小人用壯,君子罔也。", + "藩決不羸,尚往也。", + "喪羊于易,位不當也。", + "不能退,不能遂,不祥也。艱則吉,咎不長也。", + ], }, { u: "䷢", @@ -981,6 +1331,16 @@ export const GUA: Hexagram[] = [ "Remorse vanishes. Do not worry about gain or loss; going forward is fortunate, nothing unfavorable.", "Advancing with horns — using force to discipline one's own domain is perilous but ends well. No blame, yet perseverance brings humiliation.", ], + gc: "康侯用錫馬蕃庶,晝日三接。", + gcEn: "In Zin we see a prince who secures the tranquillity (of the people) presented on that account with numerous horses (by the king), and three times in a day received at interviews.", + yaoXiao: [ + "晉如,摧如;獨行正也。裕無咎;未受命也。", + "受玆介福,以中正也。", + "眾允之,志上行也。", + "碩鼠貞厲,位不當也。", + "失得勿恤,往有慶也。", + "維用伐邑,道未光也。", + ], }, { u: "䷣", @@ -1009,6 +1369,16 @@ export const GUA: Hexagram[] = [ "The darkening of the light as with Prince Ji — perseverance is favorable.", "Not light but darkness. First ascending to heaven, then plunging into the earth.", ], + gc: "利艱貞。", + gcEn: "Ming I indicates that (in the circumstances which it denotes) it will be advantageous to realise the difficulty (of the position), and maintain firm correctness.", + yaoXiao: [ + "君子于行,義不食也。", + "六二之吉,順以則也。", + "南狩之志,乃大得也。", + "入于左腹,獲心意也。", + "箕子之貞,明不可息也。", + "初登于天,照四國也。後入于地,失則也。", + ], }, { u: "䷤", @@ -1037,6 +1407,16 @@ export const GUA: Hexagram[] = [ "The king approaches his household. Do not worry; good fortune.", "Sincerity and dignity bring good fortune in the end.", ], + gc: "利女貞。", + gcEn: "For (the realisation of what is taught in) Kia Zan, (or for the regulation of the family), what is most advantageous is that the wife be firm and correct.", + yaoXiao: [ + "閑有家,志未變也。", + "六二之吉,順以巽也。", + "家人嗃嗃,未失也;婦子嘻嘻,失家節也。", + "富家大吉,順在位也。", + "王假有家,交相愛也。", + "威如之吉,反身之謂也。", + ], }, { u: "䷥", @@ -1065,6 +1445,16 @@ export const GUA: Hexagram[] = [ "Remorse vanishes. The kinsman bites through the wrapping. Going forward, what blame could there be?", "Isolated in opposition, seeing a pig covered in mud, a cartload of ghosts. First drawing the bow, then lowering it — not enemies but marriage partners. Going forth and meeting rain brings good fortune.", ], + gc: "小事吉。", + gcEn: "Khwei indicates that, (notwithstanding the condition of things which it denotes), in small matters there will (still) be good success.", + yaoXiao: [ + "見惡人,以辟咎也。", + "遇主于巷,未失道也。", + "見輿曳,位不當也。無初有終,遇剛也。", + "交孚無咎,志行也。", + "厥宗噬膚,往有慶也。", + "遇雨之吉,群疑亡也。", + ], }, { u: "䷦", @@ -1093,6 +1483,16 @@ export const GUA: Hexagram[] = [ "In the midst of great obstruction, friends come.", "Going leads to obstruction; returning brings abundance and good fortune. It is favorable to see a great person.", ], + gc: "利西南,不利東北;利見大人,貞吉。", + gcEn: "In (the state indicated by) Kien advantage will be found in the south-west, and the contrary in the north-east. It will be advantageous (also) to meet with the great man. (In these circumstances), with firmness and correctness, there will be good fortune.", + yaoXiao: [ + "往蹇來譽,宜待也。", + "王臣蹇蹇,終無尤也。", + "往蹇來反,內喜之也。", + "往蹇來連,當位實也。", + "大蹇朋來,以中節也。", + "往蹇來碩,志在內也。利見大人,以從貴也。", + ], }, { u: "䷧", @@ -1121,6 +1521,16 @@ export const GUA: Hexagram[] = [ "The noble one achieves deliverance. Good fortune. He demonstrates sincerity to the petty.", "The prince shoots a hawk from atop a high wall and hits it. Nothing unfavorable.", ], + gc: "利西南,無所往,其來復吉。有攸往,夙吉。", + gcEn: "In (the state indicated by) Kieh advantage will be found in the south-west. If no (further) operations be called for, there will be good fortune in coming back (to the old conditions). If some operations be called for, there will be good fortune in the early conducting of them.", + yaoXiao: [ + "剛柔之際,義無咎也。", + "九二貞吉,得中道也。", + "負且乘,亦可醜也,自我致戎,又誰咎也。", + "解而拇,未當位也。", + "君子有解,小人退也。", + "公用射隼,以解悖也。", + ], }, { u: "䷨", @@ -1149,6 +1559,16 @@ export const GUA: Hexagram[] = [ "Someone enriches him with ten pairs of tortoise shells, and they cannot be refused. Supreme good fortune.", "Without decreasing, increasing others. No blame. Perseverance brings good fortune. It is favorable to undertake something. One gains servants but no longer has a separate household.", ], + gc: "有孚,元吉,無咎,可貞,利有攸往。曷之用,二簋可用享。", + gcEn: "In (what is denoted by) Sun, if there be sincerity (in him who employs it), there will be great good fortune:—freedom from error; firmness and correctness that can be maintained; and advantage in every movement that shall be made. In what shall this (sincerity in the exercise of Sun) be employed? (Even) in sacrifice two baskets of grain, (though there be nothing else), may be presented.", + yaoXiao: [ + "巳事遄往,尚合志也。", + "九二利貞,中以為志也。", + "一人行,三則疑也。", + "損其疾,亦可喜也。", + "六五元吉,自上佑也。", + "弗損益之,大得志也。", + ], }, { u: "䷩", @@ -1177,6 +1597,16 @@ export const GUA: Hexagram[] = [ "With sincerity and a benevolent heart, do not ask — supreme good fortune. Sincerity blesses one's own virtue.", "No one increases him. Someone strikes him. The heart is inconstant — misfortune.", ], + gc: "利有攸往,利涉大川。", + gcEn: "Yi indicates that (in the state which it denotes) there will be advantage in every movement which shall be undertaken, that it will be advantageous (even) to cross the great stream.", + yaoXiao: [ + "元吉無咎,下不厚事也。", + "或益之,自外來也。", + "益用凶事,固有之也。", + "告公從,以益志也。", + "有孚惠心,勿問之矣。惠我德,大得志也。", + "莫益之,偏辭也。或擊之,自外來也。", + ], }, { u: "䷪", @@ -1205,6 +1635,16 @@ export const GUA: Hexagram[] = [ "The weeds are resolutely uprooted. Walking the middle way — no blame.", "No cry. In the end, misfortune.", ], + gc: "揚于王庭,孚號,有厲,告自邑,不利即戎,利有攸往。", + gcEn: "Kwai requires (in him who would fulfil its meaning) the exhibition (of the culprit's guilt) in the royal court, and a sincere and earnest appeal (for sympathy and support), with a consciousness of the peril (involved in cutting off the criminal). He should (also) make announcement in his own city, and show that it will not be well to have recourse at once to arms. (In this way) there will be advantage in whatever he shall go forward to.", + yaoXiao: [ + "不勝而往,咎也。", + "莫夜有戎,得中道也。", + "君子夬夬,終無咎也。", + "其行次且,位不當也。聞言不信,聰不明也。", + "中行無咎,中未光也。", + "無號之凶,終不可長也。", + ], }, { u: "䷫", @@ -1233,6 +1673,16 @@ export const GUA: Hexagram[] = [ "A melon wrapped in willow leaves, containing hidden brilliance — it drops as if from heaven.", "Meeting at the horns — humiliation, but no blame.", ], + gc: "女壯,勿用取女。", + gcEn: "Kau shows a female who is bold and strong. It will not be good to marry (such) a female.", + yaoXiao: [ + "繫于金柅,柔道牽也。", + "包有魚,義不及賓也。", + "其行次且,行未牽也。", + "無魚之凶,遠民也。", + "九五含章,中正也。有隕自天,志不舍命也。", + "姤其角,上窮吝也。", + ], }, { u: "䷬", @@ -1261,6 +1711,16 @@ export const GUA: Hexagram[] = [ "Gathering in a position of authority — no blame. If some lack sincerity, supreme and lasting perseverance is needed, and remorse vanishes.", "Sighing, weeping, and lamenting — but no blame.", ], + gc: "亨。王假有廟,利見大人,亨,利貞。用大牲吉,利有攸往。", + gcEn: "In (the state denoted by) Zhui, the king will repair to his ancestral temple. It will be advantageous (also) to meet with the great man; and then there will be progress and success, though the advantage must come through firm correctness. The use of great victims will conduce to good fortune; and in whatever direction movement is made, it will be advantageous.", + yaoXiao: [ + "乃亂乃萃,其志亂也。", + "引吉無咎,中未變也。", + "往無咎,上巽也。", + "大吉無咎,位不當也。", + "萃有位,志未光也。", + "齎咨涕洟,未安上也。", + ], }, { u: "䷭", @@ -1289,6 +1749,16 @@ export const GUA: Hexagram[] = [ "Perseverance brings good fortune. Ascending step by step.", "Ascending in darkness — favorable only through ceaseless perseverance.", ], + gc: "元亨,用見大人,勿恤,南征吉。", + gcEn: "Shang indicates that (under its conditions) there will be great progress and success. Seeking by (the qualities implied in it) to meet with the great man, its subject need have no anxiety. Advance to the south will be fortunate.", + yaoXiao: [ + "允升大吉,上合志也。", + "九二之孚,有喜也。", + "升虛邑,無所疑也。", + "王用亨于岐山,順事也。", + "貞吉升階,大得志也。", + "冥升在上,消不富也。", + ], }, { u: "䷮", @@ -1317,6 +1787,16 @@ export const GUA: Hexagram[] = [ "Nose and feet cut off, oppressed by those in crimson sashes. Gradually joy comes. It is favorable to make offerings and sacrifices.", "Oppressed by creeping vines, tottering precariously. Saying 'movement brings regret' — yet having such regret, advancing brings good fortune.", ], + gc: "亨,貞,大人吉,無咎,有言不信。", + gcEn: "In (the condition denoted by) Khwan there may (yet be) progress and success. For the firm and correct, the (really) great man, there will be good fortune. He will fall into no error. If he make speeches, his words cannot be made good.", + yaoXiao: [ + "入于幽谷,幽不明也。", + "困于酒食,中有慶也。", + "據于蒺蔾,乘剛也。入于其宮,不見其妻,不祥也。", + "來徐徐,志在下也。雖不當位,有與也。", + "劓刖,志未得也。乃徐有說,以中直也。利用祭祀,受福也。", + "困于葛藟,未當也。動悔,有悔吉,行也。", + ], }, { u: "䷯", @@ -1345,6 +1825,16 @@ export const GUA: Hexagram[] = [ "The well is clear, its cool spring water fit to drink.", "The well is reached and should not be covered. With sincerity, supreme good fortune.", ], + gc: "改邑不改井,無喪無得,往來井井。汔至,亦未繘井,羸其瓶,凶。", + gcEn: "(Looking at) Zing, (we think of) how (the site of) a town may be changed, while (the fashion of) its wells undergoes no change. (The water of a well) never disappears and never receives (any great) increase, and those who come and those who go can draw and enjoy the benefit. If (the drawing) have nearly been accomplished, but, before the rope has quite reached the water, the bucket is broken, this is evil.", + yaoXiao: [ + "井泥不食,下也。舊井無禽,時舍也。", + "井谷射鮒,無與也。", + "井渫不食,行惻也。求王明,受福也。", + "井甃無咎,修井也。", + "寒泉之食,中正也。", + "元吉在上,大成也。", + ], }, { u: "䷰", @@ -1373,6 +1863,16 @@ export const GUA: Hexagram[] = [ "The great person transforms like a tiger — even before the oracle is consulted, trust is already present.", "The noble one transforms like a leopard; the lesser person merely changes their face. Advancing brings misfortune; remaining steadfast brings good fortune.", ], + gc: "巳日乃孚,元亨利貞,悔亡。", + gcEn: "(What takes place as indicated by) Ko is believed in only after it has been accomplished. There will be great progress and success. Advantage will come from being firm and correct. (In that case) occasion for repentance will disappear.", + yaoXiao: [ + "鞏用黃牛,不可以有為也。", + "巳日革之,行有嘉也。", + "革言三就,又何之矣。", + "改命之吉,信志也。", + "大人虎變,其文炳也。", + "君子豹變,其文蔚也。小人革面,順以從君也。", + ], }, { u: "䷱", @@ -1401,6 +1901,16 @@ export const GUA: Hexagram[] = [ "The cauldron has yellow ears and a golden carrying bar. Perseverance is favorable.", "The cauldron has a jade carrying bar. Great good fortune; nothing that is not favorable.", ], + gc: "元吉,亨。", + gcEn: "Ting gives the intimation of great progress and success.", + yaoXiao: [ + "鼎顛趾,未悖也。利出否,以從貴也。", + "鼎有實,慎所之也。我仇有疾,終無尤也。", + "鼎耳革,失其義也。", + "覆公餗,信如何也。", + "鼎黃耳,中以為實也。", + "玉鉉在上,剛柔節也。", + ], }, { u: "䷲", @@ -1429,6 +1939,16 @@ export const GUA: Hexagram[] = [ "Thunder comes and goes with peril, yet nothing of value is lost. There are tasks to attend to.", "Thunder brings trembling and fearful glances — advancing brings misfortune. When the shock does not strike oneself but one's neighbor, there is no blame. Marriage talk brings gossip.", ], + gc: "亨。震來虩虩,笑言啞啞。震驚百里,不喪匕鬯。", + gcEn: "Kan gives the intimation of ease and development. When (the time of) movement (which it indicates) comes, (the subject of the hexagram) will be found looking out with apprehension, and yet smiling and talking cheerfully. When the movement (like a crash of thunder) terrifies all within a hundred li, he will be (like the sincere worshipper) who is not (startled into) letting go his ladle and (cup of) sacrificial spirits.", + yaoXiao: [ + "震來虩虩,恐致福也。笑言啞啞,後有則也。", + "震來厲,乘剛也。", + "震蘇蘇,位不當也。", + "震遂泥,未光也。", + "震往來厲,危行也。其事在中,大無喪也。", + "震索索,中未得也。雖凶無咎,畏鄰戒也。", + ], }, { u: "䷳", @@ -1457,6 +1977,16 @@ export const GUA: Hexagram[] = [ "Keeping the jaws still; words come in proper order. Remorse vanishes.", "Noble stillness — good fortune.", ], + gc: "艮其背,不獲其身,行其庭,不見其人,無咎。", + gcEn: "When one's resting is like that of the back, and he loses all consciousness of self; when he walks in his courtyard, and does not see any (of the persons) in it,—there will be no error.", + yaoXiao: [ + "艮其趾,未失正也。", + "不拯其隨,未退聽也。", + "艮其限,危薰心也。", + "艮其身,止諸躬也。", + "艮其輔,以中正也。", + "敦艮之吉,以厚終也。", + ], }, { u: "䷴", @@ -1485,6 +2015,16 @@ export const GUA: Hexagram[] = [ "The wild goose gradually advances to the summit. The wife does not conceive for three years, but in the end nothing can prevent it — good fortune.", "The wild goose gradually advances to the highlands. Its feathers can be used for ceremonial display — good fortune.", ], + gc: "女歸吉,利貞。", + gcEn: "Kien suggests to us the marriage of a young lady, and the good fortune (attending it). There will be advantage in being firm and correct.", + yaoXiao: [ + "小子之厲,義無咎也。", + "飲食衎衎,吉,不素飽也。", + "夫征不復,離群醜也。婦孕不育,失其道也。利用御寇,順相保也。", + "或得其桷,順以巽也。", + "終莫之勝,吉;得所愿也。", + "其羽可用為儀,吉;不可亂也。", + ], }, { u: "䷵", @@ -1513,6 +2053,16 @@ export const GUA: Hexagram[] = [ "Emperor Yi gave his daughter in marriage; the princess's sleeves were not as fine as those of the junior wife. The moon is nearly full — good fortune.", "The woman holds a basket with nothing inside; the man slaughters a sheep but no blood flows. Nothing is favorable.", ], + gc: "征凶,無攸利。", + gcEn: "Kwei Mei indicates that (under the conditions which it denotes) action will be evil, and in no wise advantageous.", + yaoXiao: [ + "歸妹以娣,以恆也。跛能履吉,相承也。", + "利幽人之貞,未變常也。", + "歸妹以須,未當也。", + "愆期之志,有待而行也。", + "帝乙歸妹,不如其娣之袂良也。其位在中,以貴行也。", + "上六無實,承虛筐也。", + ], }, { u: "䷶", @@ -1541,6 +2091,16 @@ export const GUA: Hexagram[] = [ "Brilliance comes; there is blessing and fame — good fortune.", "One makes one's house so grand and screens the household in shadow. Peering through the gate, it is silent — no one is there. For three years no one is seen — misfortune.", ], + gc: "亨,王假之,勿憂,宜日中。", + gcEn: "Fang intimates progress and development. When a king has reached the point (which the name denotes) there is no occasion to be anxious (through fear of a change). Let him be as the sun at noon.", + yaoXiao: [ + "雖旬無咎,過旬災也。", + "有孚發若,信以發志也。", + "豐其沛,不可大事也。折其右肱,終不可用也。", + "豐其蔀,位不當也。日中見斗,幽不明也。遇其夷主,吉;行也。", + "六五之吉,有慶也。", + "豐其屋,天際翔也。闚其戶,闃其無人,自藏也。", + ], }, { u: "䷷", @@ -1569,6 +2129,16 @@ export const GUA: Hexagram[] = [ "Shooting a pheasant — one arrow is lost, but in the end it brings praise and a mandate.", "The bird's nest burns. The wanderer first laughs, then weeps and wails. Losing an ox at the border — misfortune.", ], + gc: "小亨,旅貞吉。", + gcEn: "Lu intimates that (in the condition which it denotes) there may be some little attainment and progress. If the stranger or traveller be firm and correct as he ought to be, there will be good fortune.", + yaoXiao: [ + "旅瑣瑣,志窮災也。", + "得童僕貞,終無尤也。", + "旅焚其次,亦以傷矣。以旅與下,其義喪也。", + "旅于處,未得位也。得其資斧,心未快也。", + "終以譽命,上逮也。", + "以旅在上,其義焚也。喪牛于易,終莫之聞也。", + ], }, { u: "䷸", @@ -1597,6 +2167,16 @@ export const GUA: Hexagram[] = [ "Perseverance brings good fortune; remorse vanishes. Nothing unfavorable. No beginning but a good end. Three days before the change and three days after — good fortune.", "Penetration beneath the bed — one loses one's provisions and axe. Perseverance brings misfortune.", ], + gc: "小亨,利攸往,利見大人。", + gcEn: "Sun intimates that (under the conditions which it denotes) there will be some little attainment and progress. There will be advantage in movement onward in whatever direction. It will be advantageous (also) to see the great man.", + yaoXiao: [ + "進退,志疑也。利武人之貞,志治也。", + "紛若之吉,得中也。", + "頻巽之吝,志窮也。", + "田獲三品,有功也。", + "九五之吉,位正中也。", + "巽在床下,上窮也。喪其資斧,正乎凶也。", + ], }, { u: "䷹", @@ -1625,6 +2205,16 @@ export const GUA: Hexagram[] = [ "Placing trust in what is destructive brings danger.", "Seductive joy — leading others into pleasure with no resolution.", ], + gc: "亨,利貞。", + gcEn: "Tui intimates that (under its conditions) there will be progress and attainment. (But) it will be advantageous to be firm and correct.", + yaoXiao: [ + "和兌之吉,行未疑也。", + "孚兌之吉,信志也。", + "來兌之凶,位不當也。", + "九四之喜,有慶也。", + "孚于剝,位正當也。", + "上六引兌,未光也。", + ], }, { u: "䷺", @@ -1653,6 +2243,16 @@ export const GUA: Hexagram[] = [ "A great proclamation pours forth like sweat, dissolving even the king's reserve. No blame.", "Dispersing blood and harm — departing and keeping far away. No blame.", ], + gc: "亨。王假有廟,利涉大川,利貞。", + gcEn: "Hwan intimates that (under its conditions) there will be progress and success. The king goes to his ancestral temple; and it will be advantageous to cross the great stream. It will be advantageous to be firm and correct.", + yaoXiao: [ + "初六之吉,順也。", + "渙奔其机,得愿也。", + "渙其躬,志在外也。", + "渙其群,元吉;光大也。", + "王居無咎,正位也。", + "渙其血,遠害也。", + ], }, { u: "䷻", @@ -1681,6 +2281,16 @@ export const GUA: Hexagram[] = [ "Sweet limitation brings good fortune. Going forward wins esteem.", "Bitter limitation — perseverance in this brings misfortune, but remorse vanishes in time.", ], + gc: "亨。苦節,不可貞。", + gcEn: "Kieh intimates that (under its conditions) there will be progress and attainment. (But) if the regulations (which it prescribes) be severe and difficult, they cannot be permanent.", + yaoXiao: [ + "不出戶庭,知通塞也。", + "不出門庭,失時極也。", + "不節之嗟,又誰咎也。", + "安節之亨,承上道也。", + "甘節之吉,居位中也。", + "苦節貞凶,其道窮也。", + ], }, { u: "䷼", @@ -1709,6 +2319,16 @@ export const GUA: Hexagram[] = [ "Possessing truth that binds together — no blame.", "A rooster's cry tries to reach heaven. Perseverance in this brings misfortune.", ], + gc: "豚魚吉,利涉大川,利貞。", + gcEn: "Kung Fu (moves even) pigs and fish, and leads to good fortune. There will be advantage in crossing the great stream. There will be advantage in being firm and correct.", + yaoXiao: [ + "初九虞吉,志未變也。", + "其子和之,中心願也。", + "可鼓或罷,位不當也。", + "馬匹亡,絕類上也。", + "有孚攣如,位正當也。", + "翰音登于天,何可長也。", + ], }, { u: "䷽", @@ -1737,6 +2357,16 @@ export const GUA: Hexagram[] = [ "Dense clouds but no rain from the western outskirts. The duke shoots and hits the quarry in its cave.", "Not meeting but passing beyond — the flying bird departs. Misfortune; this is called calamity and error.", ], + gc: "亨,利貞,可小事,不可大事。飛鳥遺之音,不宜上,宜下,大吉。", + gcEn: "Hsiao Kwo indicates that (in the circumstances which it implies) there will be progress and attainment. But it will be advantageous to be firm and correct. (What the name denotes) may be done in small affairs, but not in great affairs. (It is like) the notes that come down from a bird on the wing;—to descend is better than to ascend. There will (in this way) be great good fortune.", + yaoXiao: [ + "飛鳥以凶,不可如何也。", + "不及其君,臣不可過也。", + "從或戕之,凶如何也。", + "弗過遇之,位不當也。往厲必戒,終不可長也。", + "密雲不雨,已上也。", + "弗遇過之,已亢也。", + ], }, { u: "䷾", @@ -1765,6 +2395,16 @@ export const GUA: Hexagram[] = [ "The eastern neighbor slaughters an ox for sacrifice, but it does not match the simple spring offering of the western neighbor, who truly receives the blessing.", "Getting one's head wet — danger. Going too far past the crossing point invites peril.", ], + gc: "亨,小利貞,初吉終亂。", + gcEn: "Ki Zi intimates progress and success in small matters. There will be advantage in being firm and correct. There has been good fortune in the beginning; there may be disorder in the end.", + yaoXiao: [ + "曳其輪,義無咎也。", + "七日得,以中道也。", + "三年克之,憊也。", + "終日戒,有所疑也。", + "東鄰殺牛,不如西鄰之時也;實受其福,吉大來也。", + "濡其首厲,何可久也。", + ], }, { u: "䷿", @@ -1793,5 +2433,15 @@ export const GUA: Hexagram[] = [ "Perseverance brings good fortune; no remorse. The light of the noble one shines with sincerity — good fortune.", "Celebrating with wine in sincere confidence — no blame. But getting one's head wet means losing that trust through excess.", ], + gc: "亨,小狐汔濟,濡其尾,無攸利。", + gcEn: "Wei Zi intimates progress and success (in the circumstances which it implies). (We see) a young fox that has nearly crossed (the stream), when its tail gets immersed. There will be no advantage in any way.", + yaoXiao: [ + "濡其尾,亦不知極也。", + "九二貞吉,中以行正也。", + "未濟征凶,位不當也。", + "貞吉悔亡,志行也。", + "君子之光,其暉吉也。", + "飲酒濡首,亦不知節也。", + ], }, ]; diff --git a/packages/core/src/format/reading.ts b/packages/core/src/format/reading.ts index 446c52ce..9bdf153d 100644 --- a/packages/core/src/format/reading.ts +++ b/packages/core/src/format/reading.ts @@ -4,6 +4,40 @@ import { GUA } from "../data/gua.js"; import { QUOTE_STYLES } from "../data/trigrams.js"; import { formatTrigrams } from "../identify/structure.js"; +/** + * Which canonical text governs a reading, per the common (Zhu Xi) rules: + * 0 moving lines → the primary hexagram's 卦辭 is the reading + * 1 moving line → that line's 爻辭 speaks + * 2-3 moving → the noted lines' 爻辭, the uppermost governing + * 4-5 moving → the becoming hexagram's 卦辭 speaks + * 6 moving → 用九 (hex 1) / 用六 (hex 2); otherwise the becoming 卦辭 + */ +export type ReadingFocus = + | { kind: "judgment" } + | { kind: "line"; position: number } + | { kind: "lines"; positions: number[]; governing: number } + | { kind: "becoming" } + | { kind: "extra"; name: "用九" | "用六" }; + +/** Classify which text governs, from the cast's changing positions. */ +export function readingFocus( + cast: Pick, +): ReadingFocus { + const positions = [...cast.changingPositions].sort((a, b) => a - b); + const n = positions.length; + + if (n === 0) return { kind: "judgment" }; + if (n === 1) return { kind: "line", position: positions[0] }; + if (n <= 3) { + return { kind: "lines", positions, governing: positions[n - 1] }; + } + if (n === 6) { + if (cast.primary === 1) return { kind: "extra", name: "用九" }; + if (cast.primary === 2) return { kind: "extra", name: "用六" }; + } + return { kind: "becoming" }; +} + /** Unbiased random quote style for derived hexagrams (excludes "st"). */ export function getRandomQuoteStyle(source: RandomSource): QuoteStyle { let byte: number; diff --git a/packages/core/src/i18n/simplify.ts b/packages/core/src/i18n/simplify.ts index 18aaceae..eb8952b1 100644 --- a/packages/core/src/i18n/simplify.ts +++ b/packages/core/src/i18n/simplify.ts @@ -29,8 +29,10 @@ * - 乾: 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. + * - 繘 (hex48 卦辭 汔至亦未繘井): same Ext-B-tofu rationale — its only + * simplification is outside the BMP, so the Traditional form is kept. */ -export const SIMPLIFIED_EXCEPTIONS: readonly string[] = ["乾", "纆", "餗", "繻"]; +export const SIMPLIFIED_EXCEPTIONS: readonly string[] = ["乾", "纆", "餗", "繻", "繘"]; /** Audited Traditional -> Simplified character map (non-identity only). */ export const SIMPLIFIED_MAP: Readonly> = { @@ -79,6 +81,11 @@ export const SIMPLIFIED_MAP: Readonly> = { // 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. 陽: "阳", + // ── 卦辭/小象傳 corpus supplement (vetted; chars introduced by gc/yaoXiao/extra + // in data/gua.ts that the original 929-char extraction did not include) ── + 馴: "驯", 貴: "贵", 賤: "贱", 縱: "纵", 瀆: "渎", 辯: "辩", 傷: "伤", 際: "际", + 願: "愿", 誰: "谁", 備: "备", 聰: "聪", 試: "试", 憊: "惫", 晝: "昼", 玆: "兹", + 愛: "爱", 飽: "饱", 絕: "绝", 暉: "晖", }; /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 76039618..a1df349d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export type { Style, DisplayLanguage, Hexagram, + HexagramExtra, LineValue, Line, Cast, @@ -62,7 +63,8 @@ export { LARGE_GLYPHS, type GlyphFont, type GlyphSize, type GlyphEntry } from ". export { toSimplified, SIMPLIFIED_MAP, SIMPLIFIED_EXCEPTIONS } from "./i18n/simplify.js"; // Format -export { formatReading, getRandomQuoteStyle } from "./format/reading.js"; +export { formatReading, getRandomQuoteStyle, readingFocus } from "./format/reading.js"; +export type { ReadingFocus } from "./format/reading.js"; export { formatDerived } from "./format/derived.js"; // Search diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index eb3e589e..ab89b9ac 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -25,6 +25,17 @@ export interface Hexagram { w: string; // Inspired by Wilhelm — experimental, not direct quotes yao: string[]; // 爻辭 — 6 classical Chinese line texts (line 1 through 6) yaoEn: string[]; // 6 English line interpretations (line 1 through 6) + gc: string; // 卦辭 — canonical judgment (classical Chinese) + gcEn: string; // English judgment (Legge, public domain — glosses kept) + yaoXiao: string[]; // 小象傳 — 6 classical per-line commentaries (line 1 through 6) + extra?: HexagramExtra; // 用九/用六 — hexagrams 1 and 2 only +} + +/** 用九/用六 extra text (all-lines-moving reading for hexagrams 1 and 2) */ +export interface HexagramExtra { + name: string; // 用九 or 用六 + text: string; // classical Chinese + textEn: string; // Legge English } /** Line value from 3-coin toss: 6=old yin, 7=young yang, 8=young yin, 9=old yang */ diff --git a/packages/terminal/src/__tests__/cast-scene.test.ts b/packages/terminal/src/__tests__/cast-scene.test.ts index 9ddaebb4..79e993c9 100644 --- a/packages/terminal/src/__tests__/cast-scene.test.ts +++ b/packages/terminal/src/__tests__/cast-scene.test.ts @@ -242,3 +242,177 @@ describe("CastScene", () => { expect(hasContent).toBe(true); }); }); + +/** Collect the visible text of a rendered frame as one string per row. */ +function frameText(scene: CastScene, ctx: SceneContext): string[] { + const frame = CellBuffer.create(ctx.cols, ctx.rows); + scene.render(frame, ctx); + const rows: string[] = []; + for (let r = 0; r < frame.height; r++) { + let row = ""; + for (let c = 0; c < frame.width; c++) row += frame.getCell(r, c).char; + rows.push(row.trimEnd()); + } + return rows; +} + +describe("CastScene escape key", () => { + test("escape returns home during animation", () => { + const scene = new CastScene(makeCast(), "reduced"); + const result = scene.handleKey({ type: "escape" }, makeCtx()); + expect(result).toEqual({ type: "home" }); + }); + + test("escape returns home in exploration mode (footer advertises it)", () => { + const scene = new CastScene(makeChangingCast(), "reduced", 80); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(scene.getTimeline().duration + 100, 33, ctx); + expect(scene.getModel().explorationMode).toBe(true); + + const result = scene.handleKey({ type: "escape" }, ctx); + expect(result).toEqual({ type: "home" }); + }); +}); + +describe("CastScene pace control", () => { + test("space toggles pause during the reveal", () => { + const scene = new CastScene(makeCast(), "reduced"); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(100, 33, ctx); + + scene.handleKey({ type: "char", char: " " }, ctx); + expect(scene.getModel().paused).toBe(true); + + // While paused, the timeline does not advance + const before = scene.getModel().titleProgress; + scene.update(scene.getTimeline().duration + 5000, 33, ctx); + expect(scene.getModel().showPrompt).toBe(false); + expect(scene.getModel().titleProgress).toBe(before); + + scene.handleKey({ type: "char", char: " " }, ctx); + expect(scene.getModel().paused).toBe(false); + }); + + test("s skips to the fully revealed state", () => { + const scene = new CastScene(makeChangingCast(), "reduced", 80); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(100, 33, ctx); + + scene.handleKey({ type: "char", char: "s" }, ctx); + const model = scene.getModel(); + expect(model.showPrompt).toBe(true); + expect(model.explorationMode).toBe(true); + expect(model.layout).toBe("side-by-side"); + }); + + test("f cycles speed 1 → 2 → 4 → 1", () => { + const scene = new CastScene(makeCast(), "reduced"); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(100, 33, ctx); + + scene.handleKey({ type: "char", char: "f" }, ctx); + expect(scene.getModel().speed).toBe(2); + scene.handleKey({ type: "char", char: "f" }, ctx); + expect(scene.getModel().speed).toBe(4); + scene.handleKey({ type: "char", char: "f" }, ctx); + expect(scene.getModel().speed).toBe(1); + }); + + test("pace keys are inert once the prompt is shown", () => { + const scene = new CastScene(makeCast(), "reduced"); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(scene.getTimeline().duration + 100, 33, ctx); + expect(scene.getModel().showPrompt).toBe(true); + + scene.handleKey({ type: "char", char: " " }, ctx); + expect(scene.getModel().paused).toBe(false); + scene.handleKey({ type: "char", char: "f" }, ctx); + expect(scene.getModel().speed).toBe(1); + }); + + test("pace footer is shown during the reveal", () => { + const scene = new CastScene(makeCast(), "reduced"); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(100, 33, ctx); + + const rows = frameText(scene, ctx); + expect(rows[ctx.rows - 2]).toContain("[space] pause"); + expect(rows[ctx.rows - 2]).toContain("[esc] back"); + }); +}); + +describe("CastScene reading panel", () => { + test("changing lines' texts appear after the reveal", () => { + const scene = new CastScene(makeChangingCast(), "reduced", 80); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(scene.getTimeline().duration + 100, 33, ctx); + + const text = frameText(scene, ctx).join("\n"); + // Hexagram 21, changing lines 1 and 4 — the hint plus line 1's text + expect(text).toContain("two lines move — the upper governs"); + expect(text).toContain("1 · His feet are fastened"); + }); + + test("judgment shown when no lines move", () => { + const scene = new CastScene(makeCast(), "reduced"); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(scene.getTimeline().duration + 100, 33, ctx); + + const text = frameText(scene, ctx).join("\n"); + // Hexagram 63 既濟 — the judgment is the reading + expect(text).toContain("Judgment · "); + }); + + test("no reading panel before the reveal settles", () => { + const scene = new CastScene(makeChangingCast(), "reduced", 80); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(100, 33, ctx); + + const text = frameText(scene, ctx).join("\n"); + expect(text).not.toContain("two lines move"); + }); +}); + +describe("CastScene openDetail cast context", () => { + test("primary detail carries the changing positions", () => { + const scene = new CastScene(makeChangingCast(), "reduced", 80); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(scene.getTimeline().duration + 100, 33, ctx); + + const model = scene.getModel(); + model.focusedHex = "primary"; + const result = scene.handleKey({ type: "enter" }, ctx); + expect(result).toEqual({ type: "openDetail", kw: 21, changedPositions: [1, 4] }); + }); + + test("becoming detail opens without cast context", () => { + const scene = new CastScene(makeChangingCast(), "reduced", 80); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(scene.getTimeline().duration + 100, 33, ctx); + + scene.getModel().focusedHex = "becoming"; + const result = scene.handleKey({ type: "enter" }, ctx); + expect(result).toEqual({ type: "openDetail", kw: 42 }); + }); + + test("unchanging cast opens primary detail without context", () => { + const scene = new CastScene(makeCast(), "reduced"); + const ctx = makeCtx(); + scene.enter(ctx); + scene.update(scene.getTimeline().duration + 100, 33, ctx); + scene.handleKey({ type: "enter" }, ctx); // enter exploration + const result = scene.handleKey({ type: "enter" }, ctx); + expect(result).toEqual({ type: "openDetail", kw: 63 }); + }); +}); diff --git a/packages/terminal/src/__tests__/detail-renderer.test.ts b/packages/terminal/src/__tests__/detail-renderer.test.ts index fc87d010..85ff94c2 100644 --- a/packages/terminal/src/__tests__/detail-renderer.test.ts +++ b/packages/terminal/src/__tests__/detail-renderer.test.ts @@ -29,7 +29,7 @@ describe("DetailRenderer language policy", () => { expect(text).not.toContain("Line Texts"); }); - test("English mode hides Chinese commentary and line blocks", () => { + test("English mode hides Chinese wing-commentary blocks", () => { const text = textFor("en"); expect(text).toContain("The Creative"); expect(text).toContain("Image"); @@ -40,6 +40,75 @@ describe("DetailRenderer language policy", () => { expect(text).not.toContain("大象傳"); expect(text).not.toContain("彖傳"); expect(text).not.toContain("爻辭"); - expect(text).not.toContain("亢龍"); + // The translated wing texts stay English-only… + expect(text).not.toContain("天行健"); + expect(text).not.toContain("大哉乾元"); + // …but the canonical 卦辭 and 小象傳 are quoted dim as classical anchors + // (received text is never paraphrased — it is shown as itself). + expect(text).toContain("元亨,利貞。"); + expect(text).toContain("亢龍有悔,盈不可久也。"); + }); +}); + +describe("DetailRenderer oracle texts", () => { + test("the 卦辭 section comes before the wing commentary", () => { + const lines = buildContentLines(new DetailModel(1), 100, { language: "zh-Hant" }) + .map((line) => line.text); + const judgmentIdx = lines.indexOf("卦辭"); + const dxIdx = lines.indexOf("大象傳"); + expect(judgmentIdx).toBeGreaterThan(-1); + expect(dxIdx).toBeGreaterThan(judgmentIdx); + // The judgment text itself follows the label + expect(lines[judgmentIdx + 1]).toContain("元亨,利貞。"); + }); + + test("en mode shows Legge judgment with the classical text beneath", () => { + const lines = buildContentLines(new DetailModel(1), 100, { language: "en" }); + const idx = lines.findIndex((l) => l.text === "Judgment"); + expect(idx).toBeGreaterThan(-1); + expect(lines[idx + 1].text).toContain("Khien (represents)"); + expect(lines[idx + 2].text).toContain("元亨,利貞。"); + expect(lines[idx + 2].dim).toBe(true); + }); + + test("彖傳 translation is no longer mislabeled as the Judgment", () => { + const text = textFor("en"); + expect(text).toContain("Tuan (Commentary on the Decision)"); + }); + + test("each line's 小象 renders dim beneath its 爻辭", () => { + const lines = buildContentLines(new DetailModel(1), 100, { language: "zh-Hant" }); + const yaoIdx = lines.findIndex((l) => l.text.includes("初九:潛龍勿用。")); + expect(yaoIdx).toBeGreaterThan(-1); + expect(lines[yaoIdx + 1].text).toContain("潛龍勿用,陽在下也。"); + expect(lines[yaoIdx + 1].dim).toBe(true); + }); + + test("zh-Hans converts 卦辭 and 小象 via the audited table", () => { + const text = textFor("zh-Hans", 2); + expect(text).toContain("卦辞"); + expect(text).toContain("元亨,利牝马之贞"); // 馬→马, 貞→贞 + expect(text).toContain("驯致其道"); // 馴→驯 (supplement entry) + }); +}); + +describe("DetailRenderer cast context", () => { + test("changed positions mark diagram lines and emphasize their texts", () => { + const model = new DetailModel(1, [2, 5]); + const lines = buildContentLines(model, 100, { language: "en" }); + const text = lines.map((l) => l.text).join("\n"); + // Diagram gutter markers (hexagram 1: all yang → ○ marker) + expect(text).toContain("○"); + // Line headers carry the marker + expect(text).toContain("Line 2 ○"); + expect(text).toContain("Line 5 ○"); + expect(text).not.toContain("Line 3 ○"); + }); + + test("no markers when browsing without cast context", () => { + const lines = buildContentLines(new DetailModel(1), 100, { language: "en" }); + const text = lines.map((l) => l.text).join("\n"); + expect(text).not.toContain("○"); + expect(text).not.toContain("×"); }); }); diff --git a/packages/terminal/src/__tests__/reading-renderer.test.ts b/packages/terminal/src/__tests__/reading-renderer.test.ts new file mode 100644 index 00000000..82deafed --- /dev/null +++ b/packages/terminal/src/__tests__/reading-renderer.test.ts @@ -0,0 +1,104 @@ +// reading-renderer — the oracle texts shown in the cast exploration phase + +import { describe, test, expect } from "bun:test"; +import type { Cast } from "@iching/core"; +import { GUA } from "@iching/core"; +import { buildReadingLines, readingHint } from "../scenes/cast/reading-renderer.ts"; + +function makeCast(primary: number, changing: number[], becoming: number | null): Cast { + const gua = GUA[primary - 1]; + return { + lines: gua.l.map((v, i) => ({ + value: changing.includes(i + 1) ? (v === 1 ? 9 : 6) : v === 1 ? 7 : 8, + isYang: v === 1, + isChanging: changing.includes(i + 1), + })) as Cast["lines"], + primary, + becoming, + changingPositions: changing, + nuclear: 1, + polarity: 2, + mirror: 1, + diagonal: 2, + }; +} + +describe("readingHint", () => { + test("empty when no lines move", () => { + expect(readingHint(makeCast(21, [], null), "en")).toBe(""); + }); + + test("one moving line", () => { + expect(readingHint(makeCast(21, [4], 42), "en")).toBe("one line moves — it speaks"); + expect(readingHint(makeCast(21, [4], 42), "zh-Hant")).toBe("一爻動,以動爻為占"); + expect(readingHint(makeCast(21, [4], 42), "zh-Hans")).toBe("一爻动,以动爻为占"); + }); + + test("two moving lines — upper governs", () => { + expect(readingHint(makeCast(21, [1, 4], 42), "en")).toBe( + "two lines move — the upper governs", + ); + }); + + test("all six on hexagram 1 → 用九", () => { + expect(readingHint(makeCast(1, [1, 2, 3, 4, 5, 6], 2), "zh-Hant")).toBe( + "六爻皆動,以用九為占", + ); + }); + + test("all six on hexagram 2 → 用六", () => { + expect(readingHint(makeCast(2, [1, 2, 3, 4, 5, 6], 1), "en")).toBe( + "all six lines move — 用六 speaks", + ); + }); +}); + +describe("buildReadingLines", () => { + test("no moving lines → the judgment, labeled", () => { + const lines = buildReadingLines(makeCast(21, [], null), "zh-Hant", 60, 6); + expect(lines.length).toBeGreaterThan(0); + expect(lines[0].role).toBe("text"); + expect(lines[0].text).toContain("卦辭"); + expect(lines[0].text).toContain(GUA[20].gc); + }); + + test("zh-Hans judgment converts via toSimplified", () => { + const lines = buildReadingLines(makeCast(21, [], null), "zh-Hans", 60, 6); + expect(lines[0].text).toContain("卦辞"); + // 21 噬嗑 gc: 亨。利用獄。 → 狱 in Simplified + expect(lines[0].text).toContain("狱"); + }); + + test("changing lines render bottom-line-first with a hint", () => { + const lines = buildReadingLines(makeCast(21, [4, 1], 42), "zh-Hant", 70, 8); + expect(lines[0].role).toBe("hint"); + const texts = lines.filter((l) => l.role === "text").map((l) => l.text); + expect(texts[0]).toBe(GUA[20].yao[0]); // line 1 first (bottom) + expect(texts[1]).toBe(GUA[20].yao[3]); // line 4 second + }); + + test("en mode shows yaoEn prefixed with the line position", () => { + const lines = buildReadingLines(makeCast(21, [4], 42), "en", 200, 8); + const texts = lines.filter((l) => l.role === "text").map((l) => l.text); + expect(texts[0]).toBe(`4 · ${GUA[20].yaoEn[3]}`); + }); + + test("all six on hexagram 1 shows the 用九 text instead of six lines", () => { + const lines = buildReadingLines(makeCast(1, [1, 2, 3, 4, 5, 6], 2), "zh-Hant", 70, 8); + const texts = lines.filter((l) => l.role === "text").map((l) => l.text); + expect(texts).toHaveLength(1); + expect(texts[0]).toContain("用九"); + expect(texts[0]).toContain("見群龍無首"); + }); + + test("truncates to maxRows with a trailing … row", () => { + const lines = buildReadingLines(makeCast(21, [1, 4], 42), "en", 40, 3); + expect(lines).toHaveLength(3); + expect(lines[2]).toEqual({ text: "…", role: "more" }); + }); + + test("returns empty when there is no room", () => { + expect(buildReadingLines(makeCast(21, [1], 42), "en", 60, 0)).toEqual([]); + expect(buildReadingLines(makeCast(21, [1], 42), "en", 2, 4)).toEqual([]); + }); +}); diff --git a/packages/terminal/src/i18n/messages.ts b/packages/terminal/src/i18n/messages.ts index 74d2aa9e..51557f3f 100644 --- a/packages/terminal/src/i18n/messages.ts +++ b/packages/terminal/src/i18n/messages.ts @@ -63,6 +63,20 @@ export const MESSAGES = { // Connective joining the upper/lower trigram in the structure line ("X above Y"). "cast.trigramConnective": { en: "above", zhHant: "上", zhHans: "上" }, + // ── reading panel (exploration phase) ── + // Label prefixed to the 卦辭 when no lines move (the judgment IS the reading). + "cast.judgment": { en: "Judgment", zhHant: "卦辭", zhHans: "卦辞" }, + // One-line reading-method hints — the classical rule for which text governs, + // stated observationally (one dim line, never a lecture). + "cast.hint.one": { en: "one line moves — it speaks", zhHant: "一爻動,以動爻為占", zhHans: "一爻动,以动爻为占" }, + "cast.hint.two": { en: "two lines move — the upper governs", zhHant: "二爻動,以上爻為占", zhHans: "二爻动,以上爻为占" }, + "cast.hint.three": { en: "three lines move — the upper governs", zhHant: "三爻動,以上爻為占", zhHans: "三爻动,以上爻为占" }, + "cast.hint.four": { en: "four lines move — the becoming speaks", zhHant: "四爻動,以之卦為占", zhHans: "四爻动,以之卦为占" }, + "cast.hint.five": { en: "five lines move — the becoming speaks", zhHant: "五爻動,以之卦為占", zhHans: "五爻动,以之卦为占" }, + "cast.hint.all": { en: "all six lines move — the becoming speaks", zhHant: "六爻皆動,以之卦為占", zhHans: "六爻皆动,以之卦为占" }, + "cast.hint.allYong9": { en: "all six lines move — 用九 speaks", zhHant: "六爻皆動,以用九為占", zhHans: "六爻皆动,以用九为占" }, + "cast.hint.allYong6": { en: "all six lines move — 用六 speaks", zhHant: "六爻皆動,以用六為占", zhHans: "六爻皆动,以用六为占" }, + // ── dictionary chrome ── "dict.title": { en: "I Ching Dictionary", zhHant: "易經卦典", zhHans: "易经卦典" }, "dict.searchPrompt": { en: "Search: ", zhHant: "搜尋:", zhHans: "搜寻:" }, diff --git a/packages/terminal/src/scene/types.ts b/packages/terminal/src/scene/types.ts index 9451e38b..c87c69da 100644 --- a/packages/terminal/src/scene/types.ts +++ b/packages/terminal/src/scene/types.ts @@ -39,8 +39,11 @@ export type SceneSignal = | { type: "openDictionary" } // open the hexagram browser | { type: "openJournal" } // open the past-readings journal | { type: "openSettings" } // open the settings editor - // Cast / dictionary navigation - | { type: "openDetail"; kw: number } + // Cast / dictionary navigation. changedPositions carries cast context: + // when a detail view is opened from a cast with moving lines, those line + // positions (1-6, bottom-up) are marked and their texts emphasized. + // Dictionary browsing passes none. + | { type: "openDetail"; kw: number; changedPositions?: number[] } | { type: "openJournalReading"; key: string } // Inner-flow events | { type: "intentionConfirmed" } // intention input completed diff --git a/packages/terminal/src/scenes/cast/cast-scene.ts b/packages/terminal/src/scenes/cast/cast-scene.ts index 2526b6f7..dd2ef28c 100644 --- a/packages/terminal/src/scenes/cast/cast-scene.ts +++ b/packages/terminal/src/scenes/cast/cast-scene.ts @@ -11,6 +11,7 @@ import { CastModel } from "./model.ts"; import { renderCoins } from "./coin-renderer.ts"; import { renderHexagram, anchorRow, LINE_ROW_OFFSETS, COIN_ROW_OFFSET } from "./hexagram-renderer.ts"; import { renderTitle, renderBecomingTitle } from "./reveal-renderer.ts"; +import { renderReadingPanel } from "./reading-renderer.ts"; import { renderMorph } from "./morph-renderer.ts"; import { renderRightHexagram, renderRightMorph } from "./right-hex-renderer.ts"; import { buildCastTimeline, type CastGlyphConfig } from "./timeline-builder.ts"; @@ -26,12 +27,19 @@ import type { DisplayLanguage } from "@iching/core"; export type CastGlyphInput = Omit; +/** Reveal pace multipliers cycled by f — same ladder as the yarrow ritual. */ +const PACE_SPEEDS = [1, 2, 4]; + export class CastScene implements Scene { private model: CastModel; private timeline: TimelineRunner; private complete = false; private glyphConfig?: CastGlyphConfig; private termWidth: number; + // Scene-controlled clock — pace control (pause/speed) modulates how fast + // this advances relative to the loop's elapsed time. + private virtualElapsed = 0; + private lastElapsed = 0; constructor( cast: Cast, @@ -73,11 +81,18 @@ export class CastScene implements Scene { } update(elapsed: number, _dt: number, _ctx: SceneContext): void { + // Advance the virtual clock from the loop's elapsed time, honoring + // pause/speed. At speed 1 unpaused this tracks `elapsed` exactly. + const delta = Math.max(0, elapsed - this.lastElapsed); + this.lastElapsed = elapsed; + if (!this.model.paused) { + this.virtualElapsed += delta * this.model.speed; + } if (!this.complete) { - this.complete = this.timeline.advance(elapsed, this.model); + this.complete = this.timeline.advance(this.virtualElapsed, this.model); } if (this.model.glyphAnimator && !this.model.glyphAnimDone) { - const done = this.model.glyphAnimator.update(elapsed); + const done = this.model.glyphAnimator.update(this.virtualElapsed); if (done) { this.model.glyphAnimDone = true; } @@ -135,9 +150,12 @@ export class CastScene implements Scene { renderIntention(frame, model.intention); } - // Render prompt + // Reading panel + prompt once the reveal settles; pace hints before. if (model.showPrompt) { + renderReadingPanel(frame, model, lang); renderPrompt(frame, model, lang); + } else { + renderPaceFooter(frame, model, lang); } } @@ -147,6 +165,30 @@ export class CastScene implements Scene { return { type: "home" }; } + // Esc returns home from any phase — matching toss/yarrow semantics + // (the footer advertises it; it must work). + if (key.type === "escape") { + return { type: "home" }; + } + + // Reveal in progress — pace control (mirrors the yarrow ritual). + if (!this.model.showPrompt) { + if (key.type === "char" && key.char === " ") { + this.model.paused = !this.model.paused; + return; + } + if (key.type === "char" && key.char === "s") { + this.model.paused = false; + this.skipToComplete(); + return; + } + if (key.type === "char" && key.char === "f") { + const next = (PACE_SPEEDS.indexOf(this.model.speed) + 1) % PACE_SPEEDS.length; + this.model.speed = PACE_SPEEDS[next]; + return; + } + } + // Exploration mode: left/right arrows switch focus if (this.model.explorationMode && key.type === "arrow") { if (key.direction === "left" && this.model.focusedHex === "becoming") { @@ -159,13 +201,20 @@ export class CastScene implements Scene { } } - // Enter in exploration mode opens dictionary for focused hex + // Enter in exploration mode opens dictionary for focused hex. + // Primary detail carries the cast's changing positions so the detail + // view can mark the moving lines; the becoming's line texts are not + // read classically, so it opens without cast context. if (this.model.explorationMode && key.type === "enter") { - const kw = - this.model.focusedHex === "primary" - ? this.model.cast.primary - : this.model.cast.becoming; - if (kw) return { type: "openDetail", kw }; + const primaryFocused = this.model.focusedHex === "primary"; + const kw = primaryFocused + ? this.model.cast.primary + : this.model.cast.becoming; + if (kw) { + return primaryFocused && this.model.cast.changingPositions.length > 0 + ? { type: "openDetail", kw, changedPositions: [...this.model.cast.changingPositions] } + : { type: "openDetail", kw }; + } } // Only handle prompt keys after prompt is shown @@ -274,6 +323,20 @@ function renderPrompt(buf: CellBuffer, model: CastModel, language: DisplayLangua buf.writeText(row, col, text, { fg: t.tertiary }); } +/** Render the pace-control hints while the reveal is still unfolding. */ +function renderPaceFooter(buf: CellBuffer, model: CastModel, language: DisplayLanguage): void { + const t = getTheme(); + const speed = model.speed > 1 ? ` · ${model.speed}×` : ""; + const text = model.paused + ? `[space] ${tr(language, "verb.resume")} · [s] ${tr(language, "verb.skip")} · [esc] ${tr(language, "verb.back")}` + : `[space] ${tr(language, "verb.pause")} · [f] ${tr(language, "verb.speed")} · [s] ${tr(language, "verb.skip")} · [esc] ${tr(language, "verb.back")}${speed}`; + const row = buf.height - 2; + if (row < 0) return; + const w = stringWidth(text); + const col = Math.max(0, Math.floor((buf.width - w) / 2)); + buf.writeText(row, col, text, { fg: t.tertiary, dim: true }); +} + /** Render the user's intention at the top of the frame. */ function renderIntention(buf: CellBuffer, intention: string): void { const t = getTheme(); diff --git a/packages/terminal/src/scenes/cast/model.ts b/packages/terminal/src/scenes/cast/model.ts index ba3f9324..0b44ef14 100644 --- a/packages/terminal/src/scenes/cast/model.ts +++ b/packages/terminal/src/scenes/cast/model.ts @@ -48,6 +48,10 @@ export class CastModel { showPrompt: boolean; promptChoice: string | null; + // Pace control during the reveal (mirrors the yarrow ritual) + paused: boolean; + speed: number; + // Large glyph reveal glyphAnimator: GlyphAnimator | null; glyphAnimDone: boolean; @@ -99,6 +103,9 @@ export class CastModel { this.showPrompt = false; this.promptChoice = null; + this.paused = false; + this.speed = 1; + this.glyphAnimator = null; this.glyphAnimDone = false; this.primaryGlyphEntry = null; diff --git a/packages/terminal/src/scenes/cast/reading-renderer.ts b/packages/terminal/src/scenes/cast/reading-renderer.ts new file mode 100644 index 00000000..ccc720ac --- /dev/null +++ b/packages/terminal/src/scenes/cast/reading-renderer.ts @@ -0,0 +1,136 @@ +// reading-renderer.ts — the oracle texts of the reading, below the title block. +// +// Once the reveal settles, the texts a reading is classically made of appear: +// the changing lines' 爻辭 (bottom-line-first, as cast), or — when no lines +// move — the 卦辭, since the judgment IS the reading in that case. A single +// dim hint line states which text governs per the common classical rule. +// Quiet, observational, never interpretive. + +import { type Cast, type DisplayLanguage, GUA, readingFocus, toSimplified } from "@iching/core"; +import type { CellBuffer } from "../../render/buffer.ts"; +import type { CastModel } from "./model.ts"; +import { getTheme } from "../../color/theme.ts"; +import { stringWidth } from "../../layout/measure.ts"; +import { wordWrap } from "../dict/word-wrap.ts"; +import { titleLayout } from "./reveal-renderer.ts"; +import { tr, type MessageKey } from "../../i18n/messages.ts"; + +export interface ReadingLine { + text: string; + role: "hint" | "text" | "more"; +} + +const HINT_KEYS: Record = { + 1: "cast.hint.one", + 2: "cast.hint.two", + 3: "cast.hint.three", + 4: "cast.hint.four", + 5: "cast.hint.five", + 6: "cast.hint.all", +}; + +/** The one-line reading-method hint for a cast (empty when no lines move). */ +export function readingHint(cast: Cast, language: DisplayLanguage): string { + const focus = readingFocus(cast); + if (focus.kind === "judgment") return ""; + if (focus.kind === "extra") { + return tr(language, focus.name === "用九" ? "cast.hint.allYong9" : "cast.hint.allYong6"); + } + return tr(language, HINT_KEYS[cast.changingPositions.length]); +} + +/** + * Build the reading-panel lines, wrapped to `width` and truncated to + * `maxRows` (a dim "…" row stands in for what didn't fit — the detail + * view always holds the full texts). + */ +export function buildReadingLines( + cast: Cast, + language: DisplayLanguage, + width: number, + maxRows: number, +): ReadingLine[] { + if (maxRows < 1 || width < 4) return []; + + const gua = GUA[cast.primary - 1]; + const english = language === "en"; + const cn = (s: string): string => (language === "zh-Hans" ? toSimplified(s) : s); + const focus = readingFocus(cast); + + const lines: ReadingLine[] = []; + const hint = readingHint(cast, language); + if (hint) lines.push({ text: hint, role: "hint" }); + + const pushText = (text: string): void => { + for (const wl of wordWrap(text, width)) { + lines.push({ text: wl, role: "text" }); + } + }; + + if (focus.kind === "judgment") { + // No moving lines — the judgment is the reading. + const label = tr(language, "cast.judgment"); + pushText(english ? `${label} · ${gua.gcEn}` : `${label} · ${cn(gua.gc)}`); + } else if (focus.kind === "extra" && gua.extra) { + // All six lines move on hex 1/2 — the 用九/用六 text governs. + pushText( + english + ? `${gua.extra.name} · ${gua.extra.textEn}` + : `${cn(gua.extra.name)} · ${cn(gua.extra.text)}`, + ); + } else { + // The changing lines' 爻辭, bottom-line-first as cast. + const positions = [...cast.changingPositions].sort((a, b) => a - b); + for (const pos of positions) { + pushText( + english + ? `${pos} · ${gua.yaoEn[pos - 1]}` + : cn(gua.yao[pos - 1]), + ); + } + } + + if (lines.length > maxRows) { + return [...lines.slice(0, Math.max(0, maxRows - 1)), { text: "…", role: "more" }]; + } + return lines; +} + +/** + * Render the reading panel between the title block and the prompt bar. + * Skips itself entirely when the terminal leaves no room — the detail + * view remains the full reference. + */ +export function renderReadingPanel( + buf: CellBuffer, + model: CastModel, + language: DisplayLanguage, +): void { + const t = getTheme(); + const { baseRow, lines: titleLines } = titleLayout(buf, model, language); + const endRow = buf.height - 3; // one row above the prompt bar + + // Prefer a breathing row after the title; surrender it when the texts + // need the space. + const gapStart = baseRow + titleLines.length + 1; + const tightStart = baseRow + titleLines.length; + const tightBudget = endRow - tightStart + 1; + if (tightBudget < 1) return; + + const width = Math.max(4, buf.width - 8); + const panel = buildReadingLines(model.cast, language, width, tightBudget); + const startRow = panel.length <= endRow - gapStart + 1 ? gapStart : tightStart; + + for (let i = 0; i < panel.length; i++) { + const row = startRow + i; + if (row < 0 || row >= buf.height) break; + const line = panel[i]; + const w = stringWidth(line.text); + const col = Math.max(0, Math.floor((buf.width - w) / 2)); + if (line.role === "hint" || line.role === "more") { + buf.writeText(row, col, line.text, { fg: t.tertiary, dim: true }); + } else { + buf.writeText(row, col, line.text, { fg: t.secondary }); + } + } +} diff --git a/packages/terminal/src/scenes/cast/reveal-renderer.ts b/packages/terminal/src/scenes/cast/reveal-renderer.ts index a0b492a6..70c3d7e4 100644 --- a/packages/terminal/src/scenes/cast/reveal-renderer.ts +++ b/packages/terminal/src/scenes/cast/reveal-renderer.ts @@ -10,18 +10,14 @@ 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. - * Fade in based on titleProgress. + * Title-block layout: where the block starts and which text lines it shows. + * Shared by renderTitle and the reading panel (which starts below the block). */ -export function renderTitle( +export function titleLayout( buf: CellBuffer, model: CastModel, - xOffset: number = 0, language: DisplayLanguage = "en", -): void { - if (model.titleProgress <= 0) return; - - const t = getTheme(); +): { baseRow: number; lines: string[] } { const anchor = anchorRow(buf.height); // Use focused hexagram — focusedHex is authoritative regardless of explorationMode const focusedKw = model.focusedHex === "becoming" && model.cast.becoming @@ -69,6 +65,23 @@ export function renderTitle( lines = [line1, line2, structLine]; } } + return { baseRow, lines }; +} + +/** + * Render the title block: Chinese name, pinyin, English, trigram meta. + * Fade in based on titleProgress. + */ +export function renderTitle( + buf: CellBuffer, + model: CastModel, + xOffset: number = 0, + language: DisplayLanguage = "en", +): void { + if (model.titleProgress <= 0) return; + + const t = getTheme(); + const { baseRow, lines } = titleLayout(buf, model, language); const progress = model.titleProgress; for (let i = 0; i < lines.length; i++) { diff --git a/packages/terminal/src/scenes/dict/detail-model.ts b/packages/terminal/src/scenes/dict/detail-model.ts index f39cf381..fd0fcf01 100644 --- a/packages/terminal/src/scenes/dict/detail-model.ts +++ b/packages/terminal/src/scenes/dict/detail-model.ts @@ -19,6 +19,12 @@ export class DetailModel { readonly detail: HexagramDetail; readonly derivedLinks: DerivedLink[]; + /** + * Cast context — line positions (1-6, bottom-up) that were moving in the + * cast this detail was opened from. Empty when browsing the dictionary. + */ + readonly changedPositions: number[]; + /** Scroll offset for content */ scrollOffset: number; @@ -43,8 +49,9 @@ export class DetailModel { glyphEntry: GlyphEntry | null; glyphAnimDone: boolean; - constructor(kw: number) { + constructor(kw: number, changedPositions: number[] = []) { this.detail = buildHexagramDetail(kw); + this.changedPositions = changedPositions; this.scrollOffset = 0; this.focus = "content"; this.derivedCursor = 0; diff --git a/packages/terminal/src/scenes/dict/detail-renderer.ts b/packages/terminal/src/scenes/dict/detail-renderer.ts index fa096ddb..d3358198 100644 --- a/packages/terminal/src/scenes/dict/detail-renderer.ts +++ b/packages/terminal/src/scenes/dict/detail-renderer.ts @@ -101,10 +101,25 @@ export function buildContentLines( } lines.push({ text: "" }); - // Line diagram (top to bottom: line 6 down to line 1) + // Line diagram (top to bottom: line 6 down to line 1). Cast context marks + // the moving lines with the same gutter markers the cast ritual uses. for (let i = 5; i >= 0; i--) { const lineChar = gua.l[i] === 1 ? GLYPHS.yangFinal : GLYPHS.yinFinal; - lines.push({ text: centerPad(lineChar, textWidth), fg: t.primary }); + const changed = model.changedPositions.includes(i + 1); + let text = centerPad(lineChar, textWidth); + if (changed) { + const marker = gua.l[i] === 1 ? GLYPHS.changingMarkerYang : GLYPHS.changingMarkerYin; + // Marker 2 cols right of the line's end, like the cast hexagram gutter + const markerCol = + Math.floor(textWidth / 2) + Math.floor(stringWidth(lineChar) / 2) + 2; + if (markerCol < text.length) { + text = text.slice(0, markerCol) + marker + text.slice(markerCol + 1); + } + } + lines.push({ + text, + fg: changed ? (gua.l[i] === 1 ? t.accent : t.changingYin) : t.primary, + }); // Add gap between upper and lower trigrams (after line 4, before line 3) if (i === 3) { lines.push({ text: "" }); @@ -127,11 +142,29 @@ export function buildContentLines( lines.push({ text: "─".repeat(textWidth), fg: t.tertiary }); lines.push({ text: "" }); - // Commentary sections + // Commentary sections — the 卦辭 (the hexagram's own judgment) comes first; + // the Wings follow. In en mode the canonical classical text rides dim + // beneath the Legge translation; "Judgment" now labels the actual 卦辭, + // so the 彖傳 translation is labeled as the commentary it is. + lines.push({ + text: english ? "Judgment" : zh("卦辭", language), + fg: t.accent, + bold: true, + }); + if (english) { + pushWrapped(lines, gua.gcEn, textWidth, { fg: t.secondary }); + pushWrapped(lines, gua.gc, textWidth, { fg: t.tertiary, dim: true }); + } else { + pushWrapped(lines, zh(gua.gc, language), textWidth, { fg: t.secondary }); + } + lines.push({ text: "" }); + const sections: [string, string][] = english ? [ ["Image", gua.en], - ["Judgment", gua.te], + // 彖傳 EN name per docs/language-glossary.md (the old bare "Judgment" + // label now belongs to the 卦辭 section above). + ["Tuan (Commentary on the Decision)", gua.te], // "Wilhelm-inspired" (not bare "Wilhelm"): gua.w is interpretive advice // after Wilhelm, NOT a direct quotation (AC-010 attribution policy; C-005). ["Wilhelm-inspired", gua.w], @@ -147,7 +180,9 @@ export function buildContentLines( lines.push({ text: "" }); } - // Line interpretations (爻辭) + // Line interpretations (爻辭), each with its 小象傳 dim beneath — the Yi + // commenting on its own lines. Moving lines from the cast are marked and + // their texts emphasized. if (gua.yao && gua.yao.length === 6) { lines.push({ text: "─".repeat(textWidth), fg: t.tertiary }); lines.push({ text: "" }); @@ -156,13 +191,35 @@ export function buildContentLines( // Display top-to-bottom (line 6 down to line 1) to match visual diagram for (let i = 5; i >= 0; i--) { + const changed = model.changedPositions.includes(i + 1); + const marker = changed + ? ` ${gua.l[i] === 1 ? GLYPHS.changingMarkerYang : GLYPHS.changingMarkerYin}` + : ""; + const changedFg = gua.l[i] === 1 ? t.accent : t.changingYin; if (english) { - lines.push({ text: `Line ${i + 1}`, fg: t.secondary, bold: true }); + lines.push({ + text: `Line ${i + 1}${marker}`, + fg: changed ? changedFg : t.secondary, + bold: true, + }); if (gua.yaoEn?.[i]) { - pushWrapped(lines, gua.yaoEn[i], textWidth, { fg: t.tertiary }); + pushWrapped(lines, gua.yaoEn[i], textWidth, { + fg: changed ? t.secondary : t.tertiary, + bold: changed || undefined, + }); } } else { - pushWrapped(lines, zh(gua.yao[i], language), textWidth, { fg: t.secondary }); + // Marker prefixes the text (a trailing marker could wrap alone) + pushWrapped(lines, `${marker ? marker.trimStart() + " " : ""}${zh(gua.yao[i], language)}`, textWidth, { + fg: changed ? changedFg : t.secondary, + bold: changed || undefined, + }); + } + if (gua.yaoXiao?.[i]) { + pushWrapped(lines, zh(gua.yaoXiao[i], language), textWidth, { + fg: t.tertiary, + dim: true, + }); } lines.push({ text: "" }); } diff --git a/packages/terminal/src/scenes/dict/detail-scene.ts b/packages/terminal/src/scenes/dict/detail-scene.ts index aa82bf93..bcb281c0 100644 --- a/packages/terminal/src/scenes/dict/detail-scene.ts +++ b/packages/terminal/src/scenes/dict/detail-scene.ts @@ -28,8 +28,9 @@ export class DetailScene implements Scene { kw: number, glyphConfig?: DetailGlyphConfig, language: DisplayLanguage = "en", + changedPositions?: number[], ) { - this.model = new DetailModel(kw); + this.model = new DetailModel(kw, changedPositions); this.glyphConfig = glyphConfig; this.language = language; } diff --git a/scripts/inject-guaci.ts b/scripts/inject-guaci.ts new file mode 100644 index 00000000..636b5b29 --- /dev/null +++ b/scripts/inject-guaci.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env bun +/** + * One-off codemod: inject the canonical oracle texts into gua.ts. + * + * gc 卦辭 — canonical judgment (classical Chinese), from + * data-acquisition/guaci-xiaoxiang.json ("gc", keyed by hexagram 1-64) + * gcEn English judgment, from data-acquisition/legge-cleaned.json + * (hexagrams[].judgment — public-domain Legge, parenthetical glosses kept) + * yaoXiao 小象傳 — 6 classical per-line commentaries, from guaci-xiaoxiang.json + * extra 用九/用六 (hexagrams 1-2 only) — canonical text per zh.wikisource, + * English from Legge's 7th line entry in legge-cleaned.json + * + * The data-acquisition/ directory is gitignored (raw scrape artifacts live only + * in the main working copy); pass its path as argv[2] if running from elsewhere: + * + * bun scripts/inject-guaci.ts [path/to/data-acquisition] + * + * Idempotent: refuses to run if gua.ts already contains a `gc:` field. + * Kept in scripts/ for provenance of the 192+ mechanical insertions. + */ + +import { join } from "path"; + +interface GuaciEntry { + hexagram: number; + name: string; + gc: string; + yaoXiao: string[]; +} + +interface LeggeHexagram { + n: number; + chinese: string; + judgment: string; + lines: string[]; +} + +// 用九/用六 canonical texts (zh.wikisource 周易/乾, 周易/坤) — intentionally +// excluded from guaci-xiaoxiang.json to keep yaoXiao at 6 entries, so they +// are pinned here instead. +const EXTRA_TEXT: Record = { + 1: { name: "用九", text: "見群龍無首,吉。" }, + 2: { name: "用六", text: "利永貞。" }, +}; + +const dataDir = process.argv[2] ?? join(import.meta.dir, "..", "data-acquisition"); +const guaPath = join(import.meta.dir, "..", "packages", "core", "src", "data", "gua.ts"); + +const guaci = (await Bun.file(join(dataDir, "guaci-xiaoxiang.json")).json()) as { + entries: GuaciEntry[]; +}; +const legge = (await Bun.file(join(dataDir, "legge-cleaned.json")).json()) as { + hexagrams: LeggeHexagram[]; +}; + +if (guaci.entries.length !== 64) throw new Error(`guaci entries: ${guaci.entries.length} !== 64`); +if (legge.hexagrams.length !== 64) throw new Error(`legge hexagrams: ${legge.hexagrams.length} !== 64`); + +let src = await Bun.file(guaPath).text(); +if (/\n {4}gc: /.test(src)) { + throw new Error("gua.ts already contains gc fields — refusing to re-inject"); +} + +/** Render one hexagram's new fields, matching gua.ts indentation/style. */ +function renderFields(kw: number): string { + const g = guaci.entries.find((e) => e.hexagram === kw); + const l = legge.hexagrams.find((h) => h.n === kw); + if (!g || !l) throw new Error(`missing source data for hexagram ${kw}`); + if (g.yaoXiao.length !== 6) throw new Error(`hexagram ${kw}: yaoXiao length ${g.yaoXiao.length}`); + + const out: string[] = []; + out.push(` gc: ${JSON.stringify(g.gc)},`); + out.push(` gcEn: ${JSON.stringify(l.judgment)},`); + out.push(` yaoXiao: [`); + for (const x of g.yaoXiao) out.push(` ${JSON.stringify(x)},`); + out.push(` ],`); + + const extra = EXTRA_TEXT[kw]; + if (extra) { + // Legge renders 用九/用六 as a 7th line entry on hexagrams 1 and 2. + const textEn = l.lines[6]; + if (!textEn) throw new Error(`hexagram ${kw}: expected Legge 用九/用六 line`); + out.push(` extra: {`); + out.push(` name: ${JSON.stringify(extra.name)},`); + out.push(` text: ${JSON.stringify(extra.text)},`); + out.push(` textEn: ${JSON.stringify(textEn)},`); + out.push(` },`); + } + + return out.join("\n"); +} + +// Each hexagram entry ends with its yaoEn array followed by the object's +// closing brace. Inject the new fields between them, in King Wen order. +let kw = 0; +src = src.replace( + /(\n {4}yaoEn: \[[\s\S]*?\n {4}\],)(\n {2}\},)/g, + (_m, yaoEnBlock: string, close: string) => { + kw++; + return `${yaoEnBlock}\n${renderFields(kw)}${close}`; + }, +); + +if (kw !== 64) throw new Error(`injected ${kw} hexagrams, expected 64`); + +await Bun.write(guaPath, src); +console.log(`Injected gc/gcEn/yaoXiao into ${kw} hexagrams (+ extra on 1, 2) → ${guaPath}`); From 3ce41b4a2f0b90844127a4c6d071c76a28704b51 Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 17:33:56 -0700 Subject: [PATCH 005/334] =?UTF-8?q?feat(reading):=20=E5=BA=8F=E5=8D=A6/?= =?UTF-8?q?=E9=9B=9C=E5=8D=A6=20sequence=20section=20+=20language-gate=20r?= =?UTF-8?q?eopen=20for=20the=20new=20oracle=20texts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core: data/sequence.ts (generated by scripts/generate-sequence.ts) — per- hexagram 序卦傳 snippet (classical) and 雜卦傳 pair epigram (classical + pair-aligned Legge couplet); two legge-cleaned pair mistags corrected in the generator ([41]→39 蹇, [50,51]→[50,49] 革); Legge's 序卦 translation dropped as paragraph-segmented (not per-hexagram alignable) - detail view: quiet closing 序卦/雜卦 section, all dim; classical 序卦 in every mode, Legge couplet for 雜卦 in en - i18n: 8 more audited zh-Hans supplement chars (間輕飭飾盡爛誅稺); 著 kept identity (蒙雜而著, zhù sense) - language gate: TEXT_SURFACES.md inventory + policy-matrix rows for all new surfaces (core-gua-gc/gcEn/yaoXiao/extra, core-sequence-*, detail oracle labels, CLI plain labels); AR-001 guard amended — gc/gcEn/yaoXiao/extra are sanctioned WITH inventory rows, any other enrichment still reopens AC-001; 君子 harmonization check now excludes verbatim Legge quotation fields (gcEn/textEn) per attribution policy; glossary updated (用九/用六 carried as extra; 序卦/雜卦 now in data) Co-Authored-By: Claude Fable 5 --- docs/language-glossary.md | 8 +- packages/core/src/__tests__/guaci.test.ts | 30 ++ packages/core/src/data/sequence.ts | 334 ++++++++++++++++++ packages/core/src/i18n/simplify.ts | 5 + packages/core/src/index.ts | 1 + .../src/__tests__/detail-renderer.test.ts | 33 ++ .../src/scenes/dict/detail-renderer.ts | 32 +- scripts/generate-sequence.ts | 106 ++++++ scripts/verify-language-surfaces.ts | 38 +- tests/fixtures/language/TEXT_SURFACES.md | 207 ++++++++++- 10 files changed, 784 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/data/sequence.ts create mode 100644 scripts/generate-sequence.ts diff --git a/docs/language-glossary.md b/docs/language-glossary.md index 781493e8..ebe48553 100644 --- a/docs/language-glossary.md +++ b/docs/language-glossary.md @@ -80,8 +80,8 @@ Wings. `衍卦` ("Derived") is a product label, not a classical category. | 大象傳 | 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 (雜→杂) | +| 序卦 | Xugua (Sequence of the Hexagrams) | 序卦 | `data/sequence.ts` `xu` (reading-depth v1); classical shown in all modes | +| 雜卦 | Zagua (Miscellaneous Notes) | 杂卦 | `data/sequence.ts` `za`/`zaEn` (雜→杂); Legge couplets pair-aligned | ## Line designations (line-identity — preserve) @@ -90,7 +90,9 @@ EN: "nine/six at the beginning", "… in the second/third/fourth/fifth place", " 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). +texts carried OUTSIDE the 6-element `yao[]` as the optional `extra` field on hexagrams +1-2 (reading-depth v1; inventory row `core-gua-extra`). `yao[]` stays 6 entries — the +line-identity invariant is unchanged. ## Pinyin diff --git a/packages/core/src/__tests__/guaci.test.ts b/packages/core/src/__tests__/guaci.test.ts index f86a92ad..22a4b79b 100644 --- a/packages/core/src/__tests__/guaci.test.ts +++ b/packages/core/src/__tests__/guaci.test.ts @@ -89,3 +89,33 @@ describe("gc/yaoXiao zh-Hans conversion coverage", () => { expect(SIMPLIFIED_MAP["乾"]).toBeUndefined(); }); }); + +describe("SEQUENCE (序卦傳/雜卦傳)", () => { + test("all 64 entries have non-empty xu/za/zaEn", async () => { + const { SEQUENCE } = await import("../data/sequence.js"); + expect(SEQUENCE).toHaveLength(64); + for (const e of SEQUENCE) { + expect(e.xu.length).toBeGreaterThan(0); + expect(e.za.length).toBeGreaterThan(0); + expect(e.zaEn.length).toBeGreaterThan(0); + } + }); + + test("spot checks: 屯 sequence, 乾坤 epigram, Legge pair fixups", async () => { + const { SEQUENCE } = await import("../data/sequence.js"); + expect(SEQUENCE[2].xu).toBe("盈天地之間者唯萬物,故受之以《屯》。"); + expect(SEQUENCE[0].za).toBe("《乾》剛《坤》柔。"); + expect(SEQUENCE[1].za).toBe("《乾》剛《坤》柔。"); + // legge-cleaned mis-tagged these pairs; the generator corrects them + expect(SEQUENCE[38].zaEn).toContain("Kien their home"); // 39 蹇 + expect(SEQUENCE[48].zaEn).toContain("left by Ko"); // 49 革 + expect(SEQUENCE[63].xu).toContain("終焉"); // 未濟 closes the sequence + }); + + test("sequence texts convert cleanly to zh-Hans", async () => { + const { SEQUENCE } = await import("../data/sequence.js"); + expect(toSimplified(SEQUENCE[2].xu)).toBe("盈天地之间者唯万物,故受之以《屯》。"); + // 蒙雜而著 — 著 (zhù, manifest) deliberately stays 著 in Simplified + expect(toSimplified(SEQUENCE[3].za)).toBe("《蒙》杂而著。"); + }); +}); diff --git a/packages/core/src/data/sequence.ts b/packages/core/src/data/sequence.ts new file mode 100644 index 00000000..b53989b7 --- /dev/null +++ b/packages/core/src/data/sequence.ts @@ -0,0 +1,334 @@ +// 序卦傳/雜卦傳 per-hexagram snippets — generated by scripts/generate-sequence.ts +// from data-acquisition/xugua-zagua.json (classical, public domain) and +// legge-cleaned.json (Legge English, public domain). Do not edit by hand. + +/** Sequence (序卦傳) and paired epigram (雜卦傳) texts for one hexagram */ +export interface SequenceTexts { + xu: string; // 序卦傳 — why this hexagram follows the previous (classical only; Legge's xu is paragraph-segmented, not per-hexagram) + za: string; // 雜卦傳 — the terse paired epigram (shared by the 綜 pair) + zaEn: string; // Legge English couplet (pair-aligned) +} + +/** 64 entries, indexed by King Wen number - 1 */ +export const SEQUENCE: SequenceTexts[] = [ + { + xu: "有天地,然後萬物生焉。", + za: "《乾》剛《坤》柔。", + zaEn: "Strength in Khien, weakness in Khwan we find.", + }, + { + xu: "有天地,然後萬物生焉。", + za: "《乾》剛《坤》柔。", + zaEn: "Strength in Khien, weakness in Khwan we find.", + }, + { + xu: "盈天地之間者唯萬物,故受之以《屯》。", + za: "《屯》見而不失其居。", + zaEn: "Kun manifests itself, yet keeps its place", + }, + { + xu: "《屯》者,盈也。屯者,物之始生也。物生必蒙,故受之以《蒙》。", + za: "《蒙》雜而著。", + zaEn: "'Mid darkness still, to light Mang sets its face.", + }, + { + xu: "《蒙》者,蒙也,物之稺也。物稺不可不養也,故受之以《需》。", + za: "《需》,不進也。", + zaEn: "Hsu shows its subject making no advance:", + }, + { + xu: "《需》者,飲食之道也。飲食必有訟,故受之以《訟》。", + za: "《訟》,不親也。", + zaEn: "In Sung we seek in vain a friendly glance", + }, + { + xu: "訟必有眾起,故受之以《師》。", + za: "《比》樂《師》憂。", + zaEn: "Pi shows us joy, and Sze the anxious mind.", + }, + { + xu: "《師》者,眾也。眾必有所比,故受之以《比》。", + za: "《比》樂《師》憂。", + zaEn: "Pi shows us joy, and Sze the anxious mind.", + }, + { + xu: "《比》者,比也。比必有所畜,故受之以《小畜》。", + za: "《小畜》,寡也。", + zaEn: "Hsiao Khu with few 'gainst many foes contends.", + }, + { + xu: "物畜然後有禮,故受之以《履》。", + za: "《履》,不處也。", + zaEn: "Movement in Li, unresting, never ends.", + }, + { + xu: "履而泰然後安,故受之以《泰》。", + za: "《否》《泰》,反其類也。", + zaEn: "While Phi and Thai their different scopes prefer,", + }, + { + xu: "《泰》者,通也。物不可以終通,故受之以《否》。", + za: "《否》《泰》,反其類也。", + zaEn: "While Phi and Thai their different scopes prefer,", + }, + { + xu: "物不可以終否,故受之以《同人》。", + za: "《同人》,親也。", + zaEn: "Thung Zan reflects their warm affection's glow.", + }, + { + xu: "與人同者,物必歸焉,故受之以《大有》。", + za: "《大有》,眾也。", + zaEn: "Ta Yu adhering multitudes can show", + }, + { + xu: "有大者不可以盈,故受之以《謙》。", + za: "《謙》輕而《豫》怠也。", + zaEn: "Khien itself, Yu others doth despise.", + }, + { + xu: "有大而能謙必豫,故受之以《豫》。", + za: "《謙》輕而《豫》怠也。", + zaEn: "Khien itself, Yu others doth despise.", + }, + { + xu: "豫必有隨,故受之以《隨》。", + za: "《隨》,无故也。", + zaEn: "Sui quits the old; Ku makes a new decree.", + }, + { + xu: "以喜隨人者必有事,故受之以《蠱》。", + za: "《蠱》則飭也。", + zaEn: "Sui quits the old; Ku makes a new decree.", + }, + { + xu: "《蠱》者,事也。有事而後可大,故受之以《臨》。", + za: "《臨》《觀》之義,或與或求。", + zaEn: "Lin gives, Kwan seeks;—such are the several themes Their different figures were to teach designed.", + }, + { + xu: "《臨》者,大也。物大然後可觀,故受之以《觀》。", + za: "《臨》《觀》之義,或與或求。", + zaEn: "Lin gives, Kwan seeks;—such are the several themes Their different figures were to teach designed.", + }, + { + xu: "可觀而後有所合,故受之以《噬嗑》。", + za: "《噬嗑》,食也。", + zaEn: "Shih Ho takes eating for its theme; and Pi Takes what is plain, from ornament quite free.", + }, + { + xu: "嗑者,合也。物不可以苟合而已,故受之以《賁》。", + za: "《賁》,无色也。", + zaEn: "Shih Ho takes eating for its theme; and Pi Takes what is plain, from ornament quite free.", + }, + { + xu: "《賁》者,飾也。致飾然後亨則盡矣,故受之以《剝》。", + za: "《剝》,爛也。", + zaEn: "We see in Po its subject worn away", + }, + { + xu: "《剝》者,剝也。物不可以終盡剝,窮上反下,故受之以《復》。", + za: "《復》,反也。", + zaEn: "And Fu shows its recovering from decay.", + }, + { + xu: "復則不妄矣,故受之以《无妄》。", + za: "《无妄》,災也。", + zaEn: "Wu Wang sets forth how evil springs from crime.", + }, + { + xu: "有无妄,然後可畜,故受之以《大畜》。", + za: "《大畜》,時也。", + zaEn: "Ta Khu keeps still, and waits the proper time.", + }, + { + xu: "物畜然後可養,故受之以《頤》。", + za: "《頤》,養正也。", + zaEn: "Body and mind are nourished right in I", + }, + { + xu: "《頤》者,養也。不養則不可動,故受之以《大過》。", + za: "《大過》,顛也。", + zaEn: "And Ta Kwo's overthrown with sad mischance.", + }, + { + xu: "物不可以終過,故受之以《坎》。", + za: "《離》上而《坎》下也。", + zaEn: "Fire mounts in Li; water in Khan descends.", + }, + { + xu: "《坎》者,陷也。陷必有所麗,故受之以《離》。《離》者,麗也。", + za: "《離》上而《坎》下也。", + zaEn: "Fire mounts in Li; water in Khan descends.", + }, + { + xu: "有天地然後有萬物,有萬物然後有男女,有男女然後有夫婦,有夫婦然後有父子,有父子然後有君臣,有君臣然後有上下,有上下然後禮義有所錯。", + za: "《咸》,速也。", + zaEn: "Effect quick answering cause in Hsien appears", + }, + { + xu: "夫婦之道不可以不久也,故受之以《恆》。", + za: "《恆》,久也。", + zaEn: "While Hang denotes continuance for years.", + }, + { + xu: "《恆》者,久也。物不可以久居其所,故受之以《遯》。", + za: "《大壯》則止,《遯》則退也。", + zaEn: "Ta Kwang stops here as right; withdraws Thun there.", + }, + { + xu: "《遯》者,退也。物不可以終遯,故受之以《大壯》。", + za: "《大壯》則止,《遯》則退也。", + zaEn: "Ta Kwang stops here as right; withdraws Thun there.", + }, + { + xu: "物不可以終壯,故受之以《晉》。", + za: "《晉》,晝也。", + zaEn: "Above in Zin the sun shines clear and bright", + }, + { + xu: "《晉》者,進也。進必有所傷,故受之以《明夷》。", + za: "《明夷》,誅也。", + zaEn: "But in Ming I 'tis hidden from the sight.", + }, + { + xu: "夷者,傷也。傷於外者必反於家,故受之以《家人》。", + za: "《家人》,內也。", + zaEn: "Kia Zan all includes within its sphere.", + }, + { + xu: "家道窮必乖,故受之以《睽》。", + za: "《睽》,外也。", + zaEn: "Khwei looks on others as beyond its care", + }, + { + xu: "《睽》者,乖也。乖必有難,故受之以《蹇》。", + za: "《蹇》,難也。", + zaEn: "Hard toil and danger have in Kien their home.", + }, + { + xu: "《蹇》者,難也。物不可以終難,故受之以《解》。", + za: "《解》,緩也。", + zaEn: "Relief and ease with Kieh are sure to come", + }, + { + xu: "《解》者,緩也。緩必有所失,故受之以《損》。", + za: "《損》《益》,盛衰之始也。", + zaEn: "In Sun and Yi are seen How fulness and decay their course begin.", + }, + { + xu: "損而不已必益,故受之以《益》。", + za: "《損》《益》,盛衰之始也。", + zaEn: "In Sun and Yi are seen How fulness and decay their course begin.", + }, + { + xu: "益而不已必決,故受之以《夬》。", + za: "《夬》,決也,剛決柔也。", + zaEn: "The strong disperse the weak; Kwai teaches so. Prospers the good man's way; to grief all small men go.", + }, + { + xu: "《夬》者,決也。決必有遇,故受之以《姤》。", + za: "《姤》,遇也,柔遇剛也。", + zaEn: "Kau shows a meeting, where the many strong Are met by one that's weak, yet struggles long.", + }, + { + xu: "《姤》者,遇也。物相遇而後聚,故受之以《萃》。", + za: "《萃》聚而《升》不來也。", + zaEn: "Good men in Zhui collect; in Shang they rise:", + }, + { + xu: "《萃》者,聚也。聚而上者謂之升,故受之以《升》。", + za: "《萃》聚而《升》不來也。", + zaEn: "Good men in Zhui collect; in Shang they rise:", + }, + { + xu: "升而不已必困,故受之以《困》。", + za: "《井》通而《困》相遇也。", + zaEn: "Progress in Zing in Khwan encounters blight.", + }, + { + xu: "困乎上者必反下,故受之以《井》。", + za: "《井》通而《困》相遇也。", + zaEn: "Progress in Zing in Khwan encounters blight.", + }, + { + xu: "井道不可不革,故受之以《革》。", + za: "《革》,去故也。", + zaEn: "Ting takes what's new; the old is left by Ko.", + }, + { + xu: "革物者莫若鼎,故受之以《鼎》。", + za: "《鼎》,取新也。", + zaEn: "Ting takes what's new; the old is left by Ko.", + }, + { + xu: "主器者莫若長子,故受之以《震》。", + za: "《震》,起也。《艮》,止也。", + zaEn: "Kan starts; Kan stops.", + }, + { + xu: "《震》者,動也。物不可以終動,止之,故受之以《艮》。", + za: "《震》,起也。《艮》,止也。", + zaEn: "Kan starts; Kan stops.", + }, + { + xu: "《艮》者,止也。物不可以終止,故受之以《漸》。", + za: "《漸》,女歸待男行也。", + zaEn: "In Kien we see a bride who will delay To move until the bridegroom takes his way.", + }, + { + xu: "漸者,進也。進必有所歸,故受之以《歸妹》。", + za: "《歸妹》,女之終也。", + zaEn: "Kwei Mei reveals how ends the virgin life", + }, + { + xu: "得其所歸者必大,故受之以《豐》。", + za: "《豐》,多故也。", + zaEn: "Fang tells of trouble; Lu can boast few friends.", + }, + { + xu: "《豐》者,大也。窮大者必失其居,故受之以《旅》。", + za: "親寡《旅》也。", + zaEn: "Fang tells of trouble; Lu can boast few friends.", + }, + { + xu: "旅而无所容,故受之以《巽》。", + za: "《兌》見而《巽》伏也。", + zaEn: "Tui shows its scope, but Sun's we do not see.", + }, + { + xu: "《巽》者,入也。入而後說之,故受之以《兌》。", + za: "《兌》見而《巽》伏也。", + zaEn: "Tui shows its scope, but Sun's we do not see.", + }, + { + xu: "《兌》者,說也。說而後散之,故受之以《渙》。", + za: "《渙》,離也。", + zaEn: "Hwan scatters; but Zieh its code of rules uprears.", + }, + { + xu: "《渙》者,離也。物不可以終離,故受之以《節》。", + za: "《節》,止也。", + zaEn: "Hwan scatters; but Zieh its code of rules uprears.", + }, + { + xu: "節而信之,故受之以《中孚》。", + za: "《中孚》,信也。", + zaEn: "Sincere is Kung Fu; but exceeds, Hsiao Kwo.", + }, + { + xu: "有其信者必行之,故受之以《小過》。", + za: "《小過》,過也。", + zaEn: "Sincere is Kung Fu; but exceeds, Hsiao Kwo.", + }, + { + xu: "有過物者必濟,故受之以《既濟》。", + za: "《既濟》,定也。", + zaEn: "All things are well established in Ki Zi.", + }, + { + xu: "物不可窮也,故受之以《未濟》,終焉。", + za: "《未濟》,男之窮也。", + zaEn: "Wei Zi how fails the youth (to get a wife).", + }, +]; diff --git a/packages/core/src/i18n/simplify.ts b/packages/core/src/i18n/simplify.ts index eb8952b1..ec063d1b 100644 --- a/packages/core/src/i18n/simplify.ts +++ b/packages/core/src/i18n/simplify.ts @@ -86,6 +86,11 @@ export const SIMPLIFIED_MAP: Readonly> = { 馴: "驯", 貴: "贵", 賤: "贱", 縱: "纵", 瀆: "渎", 辯: "辩", 傷: "伤", 際: "际", 願: "愿", 誰: "谁", 備: "备", 聰: "聪", 試: "试", 憊: "惫", 晝: "昼", 玆: "兹", 愛: "爱", 飽: "饱", 絕: "绝", 暉: "晖", + // ── 序卦/雜卦 corpus supplement (vetted; chars introduced by data/sequence.ts). + // 著 (蒙雜而著, zhù "manifest") is deliberately IDENTITY — 著→着 applies only + // to the zhe/zhuó senses, and simplified editions keep 著 here. 稺 is the + // received variant of 稚; simplified 周易 editions print 稚. ── + 間: "间", 輕: "轻", 飭: "饬", 飾: "饰", 盡: "尽", 爛: "烂", 誅: "诛", 稺: "稚", }; /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a1df349d..e9b3aa84 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,6 +58,7 @@ export { DERIVED_LABELS_CN, } from "./data/trigrams.js"; export { LARGE_GLYPHS, type GlyphFont, type GlyphSize, type GlyphEntry } from "./data/large-glyphs.js"; +export { SEQUENCE, type SequenceTexts } from "./data/sequence.js"; // i18n — audited Traditional -> Simplified conversion export { toSimplified, SIMPLIFIED_MAP, SIMPLIFIED_EXCEPTIONS } from "./i18n/simplify.js"; diff --git a/packages/terminal/src/__tests__/detail-renderer.test.ts b/packages/terminal/src/__tests__/detail-renderer.test.ts index 85ff94c2..1c141160 100644 --- a/packages/terminal/src/__tests__/detail-renderer.test.ts +++ b/packages/terminal/src/__tests__/detail-renderer.test.ts @@ -92,6 +92,39 @@ describe("DetailRenderer oracle texts", () => { }); }); +describe("DetailRenderer sequence section (序卦/雜卦)", () => { + test("zh modes show the classical snippets dim at the end", () => { + const lines = buildContentLines(new DetailModel(3), 100, { language: "zh-Hant" }); + const texts = lines.map((l) => l.text); + const xuIdx = texts.indexOf("序卦"); + const zaIdx = texts.indexOf("雜卦"); + expect(xuIdx).toBeGreaterThan(-1); + expect(zaIdx).toBeGreaterThan(xuIdx); + expect(texts[xuIdx + 1]).toContain("盈天地之間者唯萬物"); + expect(lines[xuIdx + 1].dim).toBe(true); + }); + + test("en mode shows the classical 序卦 and the Legge 雜卦 couplet", () => { + const text = buildContentLines(new DetailModel(3), 100, { language: "en" }) + .map((l) => l.text) + .join("\n"); + expect(text).toContain("Xugua (Sequence of the Hexagrams)"); + expect(text).toContain("Zagua (Miscellaneous Notes)"); + // 序卦 stays classical in en mode (Legge's xu is paragraph-segmented, + // not per-hexagram); the 雜卦 couplet is pair-aligned Legge. + expect(text).toContain("盈天地之間者唯萬物"); + expect(text).toContain("Kun manifests itself, yet keeps its place"); + }); + + test("zh-Hans converts the sequence texts", () => { + const text = buildContentLines(new DetailModel(4), 100, { language: "zh-Hans" }) + .map((l) => l.text) + .join("\n"); + expect(text).toContain("杂卦"); + expect(text).toContain("《蒙》杂而著。"); + }); +}); + describe("DetailRenderer cast context", () => { test("changed positions mark diagram lines and emphasize their texts", () => { const model = new DetailModel(1, [2, 5]); diff --git a/packages/terminal/src/scenes/dict/detail-renderer.ts b/packages/terminal/src/scenes/dict/detail-renderer.ts index d3358198..84b1ffcb 100644 --- a/packages/terminal/src/scenes/dict/detail-renderer.ts +++ b/packages/terminal/src/scenes/dict/detail-renderer.ts @@ -4,7 +4,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 { toSimplified } from "@iching/core"; +import { SEQUENCE, toSimplified } from "@iching/core"; import { getTheme } from "../../color/theme.ts"; import { stringWidth, centerPad } from "../../layout/measure.ts"; import { wordWrap } from "./word-wrap.ts"; @@ -258,6 +258,36 @@ export function buildContentLines( lines.push({ text: "" }); + // Where this hexagram sits in the sequence (序卦) and its paired epigram + // (雜卦) — a quiet closing section, all dim. The 序卦 shows the classical + // snippet in every mode (Legge's translation is paragraph-segmented, not + // per-hexagram); the 雜卦 couplet has a pair-aligned Legge rendering. + const seq = SEQUENCE[model.detail.kw - 1]; + if (seq) { + lines.push({ text: "─".repeat(textWidth), fg: t.tertiary }); + lines.push({ text: "" }); + lines.push({ + text: english ? "Xugua (Sequence of the Hexagrams)" : zh("序卦", language), + fg: t.tertiary, + bold: true, + }); + pushWrapped(lines, zh(seq.xu, language), textWidth, { + fg: t.tertiary, + dim: true, + }); + lines.push({ text: "" }); + lines.push({ + text: english ? "Zagua (Miscellaneous Notes)" : zh("雜卦", language), + fg: t.tertiary, + bold: true, + }); + pushWrapped(lines, english ? seq.zaEn : zh(seq.za, language), textWidth, { + fg: t.tertiary, + dim: true, + }); + lines.push({ text: "" }); + } + // Separator before history lines.push({ text: "─".repeat(textWidth), fg: t.tertiary }); diff --git a/scripts/generate-sequence.ts b/scripts/generate-sequence.ts new file mode 100644 index 00000000..657d62a9 --- /dev/null +++ b/scripts/generate-sequence.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env bun +/** + * One-off generator: packages/core/src/data/sequence.ts — per-hexagram + * 序卦傳/雜卦傳 snippets. + * + * xu 序卦傳 — why this hexagram follows the previous one + * (classical, from data-acquisition/xugua-zagua.json xuGua). + * No English: legge-cleaned wings.xuGua is segmented by + * paragraph, not per hexagram (n=3..5 share one passage), so a + * per-hexagram English text would misattribute neighbors. + * za / zaEn 雜卦傳 — the terse paired epigram (classical zaGua entries are + * keyed by 綜 pair; each hexagram receives its pair's line; + * English couplets from legge-cleaned wings.zaGua, pair-aligned) + * + * The data-acquisition/ directory is gitignored; pass its path as argv[2] + * if running from elsewhere: + * + * bun scripts/generate-sequence.ts [path/to/data-acquisition] + * + * Kept in scripts/ for provenance of the generated module. + */ + +import { join } from "path"; + +interface WingEntry { + hexagram: number; + text: string; +} + +interface PairEntry { + pair: number[]; + text: string; +} + +const dataDir = process.argv[2] ?? join(import.meta.dir, "..", "data-acquisition"); +const outPath = join(import.meta.dir, "..", "packages", "core", "src", "data", "sequence.ts"); + +const wings = (await Bun.file(join(dataDir, "xugua-zagua.json")).json()) as { + xuGua: WingEntry[]; + zaGua: PairEntry[]; +}; +const legge = (await Bun.file(join(dataDir, "legge-cleaned.json")).json()) as { + wings: { xuGua: { n: number; text: string }[]; zaGua: PairEntry[] }; +}; + +/** + * legge-cleaned.json wings.zaGua carries two mis-tagged pairs (verified + * against the classical text): + * - "Hard toil and danger have in Kien their home." → Kien is 蹇 (39), + * not 41 (Sun, already covered by the [41,42] couplet); + * - "Ting takes what's new; the old is left by Ko." → Ko is 革 (49), + * not 51 (Kan, covered by its own couplet). 革去故也,鼎取新也. + */ +function fixLeggePair(e: PairEntry): number[] { + if (e.text.includes("in Kien their home")) return [39]; + if (e.text.includes("left by Ko")) return [50, 49]; + return e.pair ?? []; +} + +function zaFor( + n: number, + entries: PairEntry[], + label: string, + pairOf: (e: PairEntry) => number[] = (e) => e.pair ?? [], +): string { + const entry = entries.find((e) => pairOf(e).includes(n)); + if (!entry) throw new Error(`${label}: no 雜卦 entry covers hexagram ${n}`); + return entry.text; +} + +const rows: string[] = []; +for (let n = 1; n <= 64; n++) { + const xu = wings.xuGua.find((e) => e.hexagram === n); + if (!xu) throw new Error(`missing 序卦 for hexagram ${n}`); + const za = zaFor(n, wings.zaGua, "classical"); + const zaEn = zaFor(n, legge.wings.zaGua, "legge", fixLeggePair); + rows.push( + [ + " {", + ` xu: ${JSON.stringify(xu.text)},`, + ` za: ${JSON.stringify(za)},`, + ` zaEn: ${JSON.stringify(zaEn)},`, + " },", + ].join("\n"), + ); +} + +const out = `// 序卦傳/雜卦傳 per-hexagram snippets — generated by scripts/generate-sequence.ts +// from data-acquisition/xugua-zagua.json (classical, public domain) and +// legge-cleaned.json (Legge English, public domain). Do not edit by hand. + +/** Sequence (序卦傳) and paired epigram (雜卦傳) texts for one hexagram */ +export interface SequenceTexts { + xu: string; // 序卦傳 — why this hexagram follows the previous (classical only; Legge's xu is paragraph-segmented, not per-hexagram) + za: string; // 雜卦傳 — the terse paired epigram (shared by the 綜 pair) + zaEn: string; // Legge English couplet (pair-aligned) +} + +/** 64 entries, indexed by King Wen number - 1 */ +export const SEQUENCE: SequenceTexts[] = [ +${rows.join("\n")} +]; +`; + +await Bun.write(outPath, out); +console.log(`Wrote 64 sequence entries → ${outPath}`); diff --git a/scripts/verify-language-surfaces.ts b/scripts/verify-language-surfaces.ts index 34f7b5ad..793ed54a 100644 --- a/scripts/verify-language-surfaces.ts +++ b/scripts/verify-language-surfaces.ts @@ -82,6 +82,7 @@ const CORPUS_DATA_FILES: string[] = [ "packages/core/src/data/gua.ts", "packages/core/src/data/trigrams.ts", "packages/core/src/data/large-glyphs.ts", + "packages/core/src/data/sequence.ts", ]; const REQUIRED_FIELD_IDS: string[] = [ @@ -96,6 +97,13 @@ const REQUIRED_FIELD_IDS: string[] = [ "core-gua-w", "core-gua-yao", "core-gua-yaoEn", + "core-gua-gc", + "core-gua-gcEn", + "core-gua-yaoXiao", + "core-gua-extra", + "core-sequence-xu", + "core-sequence-za", + "core-sequence-zaEn", "core-trigram-name", "core-trigram-img", "core-trigram-sym", @@ -384,12 +392,26 @@ function runInventoryOnly(): void { fail(`sentinel not in inventory: ${JSON.stringify(str)} (source ${file})`); } - // 4. AR-001 guard: no enrichment fields on core Hexagram. + // 4. AR-001 guard (amended by reading-depth v1): the gc/gcEn/yaoXiao/extra + // enrichment fields are now SANCTIONED — AC-001 was reopened and each field + // carries an inventory field-class row (core-gua-gc et al.). The guard now + // verifies sanctioned fields stay inventoried, and any OTHER enrichment + // field still reopens AC-001. 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:"]) { + const sanctioned: Array<[string, string]> = [ + ["gc:", "core-gua-gc"], + ["gcEn:", "core-gua-gcEn"], + ["yaoXiao:", "core-gua-yaoXiao"], + ["extra?:", "core-gua-extra"], + ]; + for (const [field, rowId] of sanctioned) { + if (hexBlock.includes(field) && !invRaw.includes(rowId)) + fail(`enrichment field "${field}" on Hexagram lacks inventory row ${rowId} (AR-001)`); + } + for (const field of ["legge:"]) { if (hexBlock.includes(field)) fail(`enrichment field "${field}" on Hexagram — AC-001 must REOPEN (AR-001)`); } @@ -946,10 +968,16 @@ async function runCoreData(): Promise { 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 大人). + // 君子 harmonization (C-004): the interpretive English corpus must render 君子 + // consistently as "the noble one", never "superior man" ("great man" stays — + // that is 大人). gcEn/textEn are VERBATIM Legge quotations (public domain, + // attribution policy AC-010) and are excluded — quotations are not rewritten. const guaSrc = readMaybe("packages/core/src/data/gua.ts") ?? ""; - if (/superior man/.test(guaSrc)) + const guaInterpretive = guaSrc + .split("\n") + .filter((l) => !/^\s*(gcEn|textEn):/.test(l)) + .join("\n"); + if (/superior man/.test(guaInterpretive)) fail('君子 inconsistency: "superior man" still in corpus EN — harmonize to "the noble one" (C-004)'); } diff --git a/tests/fixtures/language/TEXT_SURFACES.md b/tests/fixtures/language/TEXT_SURFACES.md index b1429118..64b9713d 100644 --- a/tests/fixtures/language/TEXT_SURFACES.md +++ b/tests/fixtures/language/TEXT_SURFACES.md @@ -252,6 +252,72 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f 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-gc + file: packages/core/src/data/gua.ts + code_locator: "field gc ×64 (injected by scripts/inject-guaci.ts)" + current_text: '"元亨,利貞。" "元亨,利牝馬之貞。…" …' + surface_class: core-data-gua + render_context: "detail view 卦辭 section (all modes; dim classical anchor under Legge in en); cast reading panel when no lines move; cast/hexagram JSON + plain" + language_policy: canonical-anchor + source_layer: received-text + token_policy: preserve + json_policy: localized-display + script_exception_policy: "Traditional; zh-Hans via audited toSimplified (incl. 卦辭/小象傳 supplement rows); 乾 exception holds" + risk: high + agentify_required: yes + status: verified + verifier: "--inventory-only; packages/core/src/__tests__/guaci.test.ts" + notes: "卦辭 — the canonical judgment. AC-001 REOPENED for reading-depth v1 (AR-001 amended): enrichment sanctioned with these rows. Source: data-acquisition/guaci-xiaoxiang.json (ctext + wikisource cross-check)." + +- surface_id: core-gua-gcEn + file: packages/core/src/data/gua.ts + code_locator: "field gcEn ×64 (injected by scripts/inject-guaci.ts)" + current_text: '"Khien (represents) what is great and originating, penetrating, advantageous, correct and firm." …' + surface_class: core-data-gua + render_context: "detail view Judgment section (en mode); cast reading panel; cast/hexagram JSON + plain" + language_policy: canonical-anchor + source_layer: received-text + token_policy: preserve + json_policy: localized-display + risk: medium + agentify_required: no + status: verified + verifier: "--inventory-only; guaci.test.ts" + notes: "Legge's public-domain judgment translation, quoted VERBATIM (parenthetical glosses kept). NOT interpretive-english — C-004 君子 harmonization does not rewrite quotations ('superior man' stays as Legge wrote it; attribution over harmonization)." + +- surface_id: core-gua-yaoXiao + file: packages/core/src/data/gua.ts + code_locator: "field yaoXiao[6] ×64 = 384 strings (injected by scripts/inject-guaci.ts)" + current_text: '"潛龍勿用,陽在下也。" "亢龍有悔,盈不可久也。" …' + surface_class: core-data-gua + render_context: "detail view — dim beneath each 爻辭 (all modes); hexagram JSON lineTexts" + language_policy: canonical-anchor + source_layer: commentary-wing + token_policy: preserve + json_policy: localized-display + script_exception_policy: "Traditional; zh-Hans via audited toSimplified supplement (馴/貴/賤/瀆/辯/… rows); 繘 Ext-B retention" + risk: high + agentify_required: yes + status: verified + verifier: "--inventory-only; guaci.test.ts conversion coverage" + notes: "小象傳 per-line commentary. AC-001 reopen companion to core-gua-gc." + +- surface_id: core-gua-extra + file: packages/core/src/data/gua.ts + code_locator: "field extra (hexagrams 1-2 only): name/text/textEn" + current_text: '"用九" "見群龍無首,吉。" / "用六" "利永貞。" + Legge textEn' + surface_class: core-data-gua + render_context: "cast reading panel + JSON/plain when all six lines move on hex 1/2; hexagram JSON" + language_policy: canonical-anchor + source_layer: received-text + token_policy: preserve + json_policy: localized-display + risk: medium + agentify_required: no + status: verified + verifier: "--inventory-only; guaci.test.ts" + notes: "用九/用六 special statements — formerly the documented exclusion in the glossary; now carried OUTSIDE yao[6] as an optional field (yao stays 6 entries; line-identity invariant intact). textEn is verbatim Legge." + - surface_id: core-gua-doc-comment file: packages/core/src/data/gua.ts code_locator: "L3 /** 64 hexagrams with commentary in 5 styles */" @@ -364,6 +430,59 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f notes: "Not display text. 'st' has no backing GUA field (synthetic structure style)." ``` +## Group: core-data-sequence (`packages/core/src/data/sequence.ts`) + +```yaml +- surface_id: core-sequence-xu + file: packages/core/src/data/sequence.ts + code_locator: "SEQUENCE[].xu ×64 (generated by scripts/generate-sequence.ts)" + current_text: '"有天地,然後萬物生焉。" "盈天地之間者唯萬物,故受之以《屯》。" …' + surface_class: core-data-sequence + render_context: "detail view closing 序卦 section (classical in ALL modes — Legge's xu translation is paragraph-segmented, not per-hexagram, so en quotes the canonical text dim)" + language_policy: canonical-anchor + source_layer: commentary-wing + token_policy: preserve + json_policy: not-json + script_exception_policy: "Traditional; zh-Hans via audited toSimplified (序卦/雜卦 supplement rows: 間/輕/飭/飾/盡/爛/誅/稺; 著 deliberately identity in 蒙雜而著)" + risk: medium + agentify_required: no + status: verified + verifier: "--inventory-only; packages/core/src/__tests__/guaci.test.ts SEQUENCE block" + notes: "序卦傳 — where the hexagram sits in the sequence. Source: data-acquisition/xugua-zagua.json." + +- surface_id: core-sequence-za + file: packages/core/src/data/sequence.ts + code_locator: "SEQUENCE[].za ×64 (pair-shared lines)" + current_text: '"《乾》剛《坤》柔。" "《屯》見而不失其居。" …' + surface_class: core-data-sequence + render_context: "detail view closing 雜卦 section (zh modes)" + language_policy: canonical-anchor + source_layer: commentary-wing + token_policy: preserve + json_policy: not-json + risk: medium + agentify_required: no + status: verified + verifier: "--inventory-only; guaci.test.ts SEQUENCE block" + notes: "雜卦傳 epigram, keyed by 綜 pair; each hexagram receives its pair's line." + +- surface_id: core-sequence-zaEn + file: packages/core/src/data/sequence.ts + code_locator: "SEQUENCE[].zaEn ×64" + current_text: '"Strength in Khien, weakness in Khwan we find." …' + surface_class: core-data-sequence + render_context: "detail view closing 雜卦 section (en mode)" + language_policy: canonical-anchor + source_layer: commentary-wing + token_policy: preserve + json_policy: not-json + risk: low + agentify_required: no + status: verified + verifier: "--inventory-only; guaci.test.ts SEQUENCE block (incl. Legge pair-mistag fixups for 蹇/革)" + notes: "Legge's rhymed 雜卦 couplets, pair-aligned and quoted verbatim. Two legge-cleaned pair mistags ([41]→39 蹇, [50,51]→[50,49] 革) corrected in the generator." +``` + ## Group: runtime-symbols (large glyphs) (`packages/core/src/data/large-glyphs.ts`) ```yaml @@ -870,6 +989,22 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f 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-oracle-sections + file: packages/terminal/src/scenes/dict/detail-renderer.ts + code_locator: "buildContentLines — 卦辭/序卦/雜卦 section labels + en equivalents" + current_text: '"Judgment" / "卦辭" / "Tuan (Commentary on the Decision)" / "Xugua (Sequence of the Hexagrams)" / "序卦" / "Zagua (Miscellaneous Notes)" / "雜卦"' + surface_class: terminal-dict + render_context: "SYNTHETIC labels for the oracle-text sections added by reading-depth v1: 卦辭 first section, 序卦/雜卦 closing section; zh labels through zh()" + language_policy: canonical-anchor + source_layer: commentary-wing + token_policy: preserve + script_exception_policy: "zh-Hans via toSimplified (辭→辞, 雜→杂); EN names per docs/language-glossary.md Ten Wings table" + risk: medium + agentify_required: no + status: verified + verifier: "--inventory-only; packages/terminal/src/__tests__/detail-renderer.test.ts" + notes: "Resolves the field-divergence noted on term-dict-detail-sections-en: EN 'Judgment' now labels the actual 卦辭 (gcEn); the te section is relabeled 'Tuan (Commentary on the Decision)' per glossary." + - surface_id: term-dict-detail-footer file: packages/terminal/src/scenes/dict/detail-renderer.ts code_locator: "L392,393,396–398" @@ -1354,10 +1489,25 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f verifier: "--cli; --core-data (line-type/commentary labels)" notes: "HARDWIRED BILINGUAL: even with language=en, plain output prints Chinese name n, Chinese wing titles 大象/彖傳, and Chinese dx/tu values. Formatters take NO language param. Wilhelm label attribution. Untyped (x as any).ename escape hatch." +- surface_id: cli-plain-oracle-labels + file: apps/cli/src/output/plain.ts + code_locator: "formatCastPlain/formatHexagramPlain — judgment + changing-line blocks" + current_text: '"Judgment (gc): " / "Judgment (gcEn): " / "Changing lines:"' + surface_class: cli-commands + render_context: "cast/hexagram plain stdout — 卦辭 + Legge judgment lines, changing-line 爻辭 block (reading-depth v1)" + language_policy: translate + source_layer: product-ui + json_policy: not-json + risk: low + agentify_required: no + status: verified + verifier: "--inventory-only; apps/cli/src/__tests__/cast.test.ts + hexagram-output.test.ts" + notes: "Labels follow the existing parenthetical field-code convention (dx/tu/en/te/w); gc/gcEn values are canonical-anchor + verbatim Legge." + - surface_id: cli-json-output file: apps/cli/src/output/json.ts code_locator: "L5–81" - current_text: 'keys: question/primary/number/name/pinyin/symbol/lines/value/yang/changing/becoming/changingPositions/derived/nuclear/polarity/mirror/diagonal/commentary/dx/tu/en/te/w/key/value/path' + current_text: 'keys: question/primary/number/name/pinyin/ename/symbol/judgment/gc/gcEn/lines/value/yang/changing/becoming/changingPositions/changingLines/position/yao/yaoEn/extra/lineTexts/yaoXiao/derived/nuclear/polarity/mirror/diagonal/commentary/dx/tu/en/te/w/key/value/path' surface_class: json-api-output render_context: "all --json output (2-space pretty)" language_policy: developer-only @@ -1671,12 +1821,55 @@ Default language **en**; settings order **EN → 繁 → 简** (asserted by zh_hant_source: rendered as gua.yao in zh mode zh_hans_strategy: converted yao render_context: English Line Texts +- id: core-gua-gc + language_policy: canonical-anchor + en_source: quoted classical (dim, beneath gcEn) — received text shown as itself + zh_hant_source: gua.gc (existing Traditional data) + zh_hans_strategy: audited toSimplified (卦辭/小象傳 supplement rows) + render_context: 卦辭 section (detail first section; cast reading panel; CLI) +- id: core-gua-gcEn + language_policy: canonical-anchor + en_source: gua.gcEn — verbatim Legge judgment (public domain, quoted as-is) + zh_hant_source: not rendered in zh modes (gc carries the section) + zh_hans_strategy: not rendered in zh modes + render_context: Judgment section (en mode); cast reading panel; CLI +- id: core-gua-yaoXiao + language_policy: canonical-anchor + en_source: quoted classical (dim, beneath each yaoEn) — no translation exists in data + zh_hant_source: gua.yaoXiao (existing Traditional data) + zh_hans_strategy: audited toSimplified (supplement rows; 繘 Ext-B retention) + render_context: dim 小象 under each line text (detail view) +- id: core-gua-extra + language_policy: canonical-anchor + en_source: extra.textEn — verbatim Legge (public domain) + zh_hant_source: extra.text (用九/用六 canonical statements) + zh_hans_strategy: audited toSimplified + render_context: all-six-moving reading on hexagrams 1-2 (cast panel; CLI) - id: core-gua-doc-comment language_policy: developer-only en_source: n/a — source comment (not rendered) zh_hant_source: n/a — not rendered zh_hans_strategy: n/a — not rendered render_context: source comment +# ---- core data: sequence (序卦/雜卦) ---- +- id: core-sequence-xu + language_policy: canonical-anchor + en_source: quoted classical (dim) — Legge xu is paragraph-segmented, not per-hexagram + zh_hant_source: SEQUENCE[].xu (Traditional) + zh_hans_strategy: audited toSimplified (序卦/雜卦 supplement rows; 著 identity) + render_context: detail closing 序卦 section (all modes) +- id: core-sequence-za + language_policy: canonical-anchor + en_source: rendered as zaEn in en mode + zh_hant_source: SEQUENCE[].za (Traditional, pair-shared) + zh_hans_strategy: audited toSimplified + render_context: detail closing 雜卦 section (zh modes) +- id: core-sequence-zaEn + language_policy: canonical-anchor + en_source: SEQUENCE[].zaEn — verbatim Legge couplet (pair-aligned) + zh_hant_source: not rendered in zh modes (za carries the section) + zh_hans_strategy: not rendered in zh modes + render_context: detail closing 雜卦 section (en mode) # ---- core data: trigrams ---- - id: core-trigram-name language_policy: canonical-anchor @@ -1914,6 +2107,12 @@ Default language **en**; settings order **EN → 繁 → 简** (asserted by zh_hant_source: existing 大象傳/彖傳/爻辭/衍卦/鎖定對卦/上/下 zh_hans_strategy: zh() conversion (傳→传, 辭→辞, 鎖→锁, 對→对, 記→记) render_context: detail section labels (zh mode) +- id: term-dict-detail-oracle-sections + language_policy: canonical-anchor + en_source: glossary EN names (Judgment / Tuan (Commentary on the Decision) / Xugua / Zagua) + zh_hant_source: 卦辭/序卦/雜卦 (canonical wing names) + zh_hans_strategy: zh() conversion (辭→辞, 雜→杂) + render_context: oracle-text section labels added by reading-depth v1 - id: term-dict-detail-footer language_policy: translate en_source: hardcoded footer (currently always EN even in zh — bug) @@ -2095,6 +2294,12 @@ Default language **en**; settings order **EN → 繁 → 简** (asserted by zh_hant_source: catalog for labels; wing titles canonical zh_hans_strategy: convert render_context: cast/hexagram/journal plain output +- id: cli-plain-oracle-labels + language_policy: translate + en_source: hardcoded labels (Judgment (gc)/Judgment (gcEn)/Changing lines) + canonical data + zh_hant_source: catalog for labels; gc/yao values canonical + zh_hans_strategy: convert + render_context: cast/hexagram plain output judgment + changing-line blocks - id: cli-json-output language_policy: not-user-facing en_source: stable API keys (preserve) + all 5 commentary styles (locale-neutral) From 439c1f297988b31acc377a7d501db980c3850228 Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 17:42:40 -0700 Subject: [PATCH 006/334] fix(cast): honor motion-preset glyph scale on focus-change re-reveals The exploration-phase glyph re-reveal (flipping primary/becoming) always ran the animator at 1x; it now uses the preset's glyphAnimScale and goes static under reduced motion, matching the timeline builder. Co-Authored-By: Claude Fable 5 --- packages/terminal/src/scenes/cast/cast-scene.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/terminal/src/scenes/cast/cast-scene.ts b/packages/terminal/src/scenes/cast/cast-scene.ts index dd2ef28c..70063c86 100644 --- a/packages/terminal/src/scenes/cast/cast-scene.ts +++ b/packages/terminal/src/scenes/cast/cast-scene.ts @@ -40,6 +40,8 @@ export class CastScene implements Scene { // this advances relative to the loop's elapsed time. private virtualElapsed = 0; private lastElapsed = 0; + // Motion-preset time dilation for glyph reveals on focus changes (0 = static). + private glyphAnimScale = 1; constructor( cast: Cast, @@ -72,6 +74,7 @@ export class CastScene implements Scene { }; } const timing = getPreset(preset); + this.glyphAnimScale = timing.glyphAnimScale; const step = buildCastTimeline(cast, this.model, timing, termWidth, this.glyphConfig, opts); this.timeline = new TimelineRunner(step); } @@ -248,8 +251,18 @@ export class CastScene implements Scene { ? this.model.primaryGlyphEntry : this.model.becomingGlyphEntry; if (entry && this.glyphConfig) { - this.model.glyphAnimator = createGlyphAnimator(this.glyphConfig.glyphAnim, entry); - this.model.glyphAnimDone = false; + if (this.glyphAnimScale > 0) { + this.model.glyphAnimator = createGlyphAnimator( + this.glyphConfig.glyphAnim, + entry, + this.glyphAnimScale, + ); + this.model.glyphAnimDone = false; + } else { + // Reduced motion: no animation — show the settled glyph immediately. + this.model.glyphAnimator = null; + this.model.glyphAnimDone = true; + } } } From 43754a9d48af5b660665ead175324871ba1afd45 Mon Sep 17 00:00:00 2001 From: provi Date: Wed, 10 Jun 2026 20:42:54 -0700 Subject: [PATCH 007/334] feat(anchor): reopen today's reading from home + iching today command - Home gains a [t] menu item when today's cast exists; openToday replays the cached reading through the existing replay flow (static reveal, no re-persist). Escape on Home is now a no-op (q / Ctrl+C quit) so a habitual extra esc no longer kills the session. - New one-shot `iching today` command: prints the full reading from the daily cache (hexagrams, judgment, changing-line texts, intention, method); --json emits the structured payload for LLM/assistant use. No reading yet is a calm stdout invitation, exit 0. - Settings: q now saves & exits like escape. - README usage updated; language-surface inventory rows added. Co-Authored-By: Claude Fable 5 --- README.md | 6 +- apps/cli/src/__tests__/today-command.test.ts | 167 ++++++++++++++++++ apps/cli/src/commands/today.ts | 39 ++++ apps/cli/src/main.ts | 18 ++ apps/cli/src/output/json.ts | 35 +++- apps/cli/src/output/plain.ts | 25 ++- apps/cli/src/program.ts | 2 + .../terminal/src/__tests__/home-scene.test.ts | 88 +++++++++ .../src/__tests__/settings-quit-key.test.ts | 48 +++++ packages/terminal/src/i18n/messages.ts | 1 + packages/terminal/src/scene/types.ts | 1 + .../terminal/src/scenes/home/home-scene.ts | 9 +- .../src/scenes/settings/settings-scene.ts | 4 +- tests/fixtures/language/TEXT_SURFACES.md | 41 ++++- 14 files changed, 468 insertions(+), 16 deletions(-) create mode 100644 apps/cli/src/__tests__/today-command.test.ts create mode 100644 apps/cli/src/commands/today.ts create mode 100644 packages/terminal/src/__tests__/home-scene.test.ts create mode 100644 packages/terminal/src/__tests__/settings-quit-key.test.ts diff --git a/README.md b/README.md index c94cc0c8..49ccc02b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ iching # interactive TUI iching cast # one-shot cast (plain text) iching cast "should I ship?" # with a question iching cast --json # structured output +iching today # today's reading (cast in the TUI) +iching today --json # full reading payload for scripts/assistants iching journal list # recent readings iching journal show today # today's reading iching hexagram 1 # look up hexagram by number @@ -65,8 +67,8 @@ iching dict # browse all 64 in TUI iching config theme cinnabar # set theme ``` -Press `c` to cast, `j` for journal, `d` for dictionary, `s` for -settings, `q` to quit. +Press `c` to cast, `t` to return to today's reading, `j` for journal, +`d` for dictionary, `s` for settings, `q` to quit. ## Storage diff --git a/apps/cli/src/__tests__/today-command.test.ts b/apps/cli/src/__tests__/today-command.test.ts new file mode 100644 index 00000000..ce314b8c --- /dev/null +++ b/apps/cli/src/__tests__/today-command.test.ts @@ -0,0 +1,167 @@ +// Subprocess tests for `iching today` — the one-shot daily anchor. Reads the +// daily cache: prints the full reading when today's cast exists, and a calm +// invitation (exit 0 — a state, not an error) when it doesn't. `--json` is the +// LLM/scripting integration surface: castToJson payload + date/intention/method. + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { buildStructure } from "@iching/core"; +import type { Cast, DailyCache } from "@iching/core"; + +const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", ".."); +const MAIN_TS = resolve(REPO_ROOT, "apps/cli/src/main.ts"); + +/** + * The subprocess is pinned to TZ=UTC (see runCli) so the CLI's localToday() agrees + * with this UTC formula — bun test itself defaults to UTC, but the machine's + * shell TZ would otherwise leak into the spawned CLI and skew the date near + * midnight boundaries. + */ +function utcToday(): string { + return new Date().toISOString().slice(0, 10); +} + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +async function runCli(dataDir: string, args: string[]): Promise { + const proc = Bun.spawn( + ["bun", MAIN_TS, "--data-dir", dataDir, ...args], + { + cwd: REPO_ROOT, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1", TZ: "UTC" }, + }, + ); + proc.stdin.end(); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { exitCode, stdout, stderr }; +} + +/** KW3 屯 with line 1 moving → becoming KW8 比 (water over earth). */ +function makeCast(): Cast { + return { + lines: [ + { value: 9, isYang: true, isChanging: true }, + { value: 8, isYang: false, isChanging: false }, + { value: 8, isYang: false, isChanging: false }, + { value: 8, isYang: false, isChanging: false }, + { value: 7, isYang: true, isChanging: false }, + { value: 8, isYang: false, isChanging: false }, + ], + primary: 3, + becoming: 8, + changingPositions: [1], + nuclear: 23, + polarity: 50, + mirror: 4, + diagonal: 49, + }; +} + +function makeCache(date: string): DailyCache { + const cast = makeCast(); + return { + date, + cast, + shown: true, + structure: buildStructure(cast), + intention: "what needs patience?", + method: "yarrow", + }; +} + +async function seedCache(dataDir: string, cache: DailyCache): Promise { + await writeFile( + join(dataDir, "daily-cache.json"), + JSON.stringify(cache), + "utf-8", + ); +} + +describe("today command", () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), "iching-today-cmd-test-")); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + test("prints the full reading when today's cast exists", async () => { + await seedCache(dataDir, makeCache(utcToday())); + const { exitCode, stdout } = await runCli(dataDir, ["today"]); + expect(exitCode).toBe(0); + // Day context + expect(stdout).toContain(`Date: ${utcToday()}`); + expect(stdout).toContain("Intention: what needs patience?"); + expect(stdout).toContain("Method: yarrow stalks"); + // Primary + becoming + expect(stdout).toContain("屯"); + expect(stdout).toContain("Hexagram 3"); + expect(stdout).toContain("Becoming:"); + expect(stdout).toContain("比"); + // Judgment + the changing-line text the reading turns on + expect(stdout).toContain("Judgment (gc):"); + expect(stdout).toContain("Changing lines:"); + expect(stdout).toContain("磐桓"); // KW3 line 1 爻辭 + }, 20_000); + + test("--json emits the castToJson payload plus date/intention/method", async () => { + await seedCache(dataDir, makeCache(utcToday())); + const { exitCode, stdout } = await runCli(dataDir, ["--json", "today"]); + expect(exitCode).toBe(0); + const payload = JSON.parse(stdout); + expect(payload.date).toBe(utcToday()); + expect(payload.intention).toBe("what needs patience?"); + expect(payload.method).toBe("yarrow"); + expect(payload.primary.number).toBe(3); + expect(payload.primary.name).toBe("屯"); + expect(payload.primary.judgment.gc).toBeTruthy(); + expect(payload.becoming.number).toBe(8); + expect(payload.changingLines).toHaveLength(1); + expect(payload.changingLines[0].position).toBe(1); + expect(typeof payload.changingLines[0].yao).toBe("string"); + expect(typeof payload.changingLines[0].yaoEn).toBe("string"); + expect(payload.commentary.w).toBeTruthy(); + }, 20_000); + + test("no cache yet: calm invitation on stdout, exit 0", async () => { + const { exitCode, stdout, stderr } = await runCli(dataDir, ["today"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("no reading yet today — run `iching` to cast"); + expect(stderr).toBe(""); + }, 20_000); + + test("stale cache (yesterday) counts as no reading today", async () => { + await seedCache(dataDir, makeCache("2001-01-01")); + const { exitCode, stdout } = await runCli(dataDir, ["today"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("no reading yet today"); + expect(stdout).not.toContain("Hexagram 3"); + }, 20_000); + + test("--json with no cast: stable null payload, exit 0", async () => { + const { exitCode, stdout } = await runCli(dataDir, ["--json", "today"]); + expect(exitCode).toBe(0); + const payload = JSON.parse(stdout); + expect(payload.date).toBe(utcToday()); + expect(payload.primary).toBeNull(); + expect(payload.becoming).toBeNull(); + expect(payload.intention).toBeNull(); + expect(payload.method).toBeNull(); + }, 20_000); +}); diff --git a/apps/cli/src/commands/today.ts b/apps/cli/src/commands/today.ts new file mode 100644 index 00000000..d30f9cf0 --- /dev/null +++ b/apps/cli/src/commands/today.ts @@ -0,0 +1,39 @@ +import { Command } from "commander"; +import { resolvePaths, JsonDailyCacheStore } from "@iching/storage"; +import { formatTodayPlain } from "../output/plain.js"; +import { outputJson, todayToJson, noTodayToJson } from "../output/json.js"; +import { localToday } from "../util/today.js"; + +export function registerTodayCommand(program: Command): void { + program + .command("today") + .description("Show today's reading (cast in the TUI)") + .action(async () => { + const opts = program.opts(); + const paths = resolvePaths( + opts.dataDir ? { dataDir: opts.dataDir } : undefined, + ); + const store = new JsonDailyCacheStore(paths.cache); + + const today = localToday(); + const cache = await store.read(); + + // A stale cache (yesterday's reading) counts as "no reading yet today". + if (!cache || cache.date !== today) { + // A state, not an error: a calm invitation on stdout, exit 0. + // Scripts and shell greetings can run this without special-casing. + if (opts.json) { + outputJson(noTodayToJson(today)); + } else { + console.log("no reading yet today — run `iching` to cast"); + } + return; + } + + if (opts.json) { + outputJson(todayToJson(cache)); + } else { + console.log(formatTodayPlain(cache)); + } + }); +} diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 980a0c0b..af73456b 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -140,6 +140,24 @@ async function main() { break; } + case "openToday": { + // Reopen today's reading: replay the cached cast (static reveal, + // no persistence). HomeScene only emits this when a cast exists + // for today; re-check here so a stale signal can't replay yesterday. + if (currentCache?.date === today) { + const result = await runReadingFlow(flowDeps, { + purpose: "replay", + source: { + type: "existing", + cast: currentCache.cast, + intention: currentCache.intention, + }, + }); + if (result.shouldExit) running = false; + } + break; + } + case "openDictionary": { const journal = new JsonlJournalStore(paths.state); const router = new SceneRouter( diff --git a/apps/cli/src/output/json.ts b/apps/cli/src/output/json.ts index c7d86de8..954ee456 100644 --- a/apps/cli/src/output/json.ts +++ b/apps/cli/src/output/json.ts @@ -1,4 +1,4 @@ -import type { Cast, Hexagram, HistoryEntry, Style } from "@iching/core"; +import type { Cast, DailyCache, Hexagram, HistoryEntry, Style } from "@iching/core"; import { GUA } from "@iching/core"; import type { UserConfig } from "@iching/storage"; @@ -69,6 +69,39 @@ export function castToJson( }; } +/** + * Structure today's cached reading for JSON output (`iching today --json`): + * the full castToJson payload plus the day's context (date/intention/method). + * This is the one-call integration surface — an assistant can read the whole + * reading without touching the cache file or the data tables. + */ +export function todayToJson(cache: DailyCache): Record { + const cast = cache.cast; + const primary = GUA[cast.primary - 1]; + const becoming = cast.becoming !== null ? GUA[cast.becoming - 1] : null; + return { + date: cache.date, + intention: cache.intention ?? null, + method: cache.method ?? null, + ...castToJson(cast, primary, becoming, cache.intention), + }; +} + +/** + * JSON shape for `iching today --json` when no reading exists yet today. + * A state, not an error: stable keys, null payload, exit 0. + */ +export function noTodayToJson(date: string): Record { + return { + date, + intention: null, + method: null, + question: null, + primary: null, + becoming: null, + }; +} + /** Structure hexagram data for JSON output */ export function hexagramToJson( kw: number, diff --git a/apps/cli/src/output/plain.ts b/apps/cli/src/output/plain.ts index 426524c0..5af98983 100644 --- a/apps/cli/src/output/plain.ts +++ b/apps/cli/src/output/plain.ts @@ -1,4 +1,4 @@ -import type { Cast, CastMethod, Hexagram, Style, Structure } from "@iching/core"; +import type { Cast, CastMethod, DailyCache, Hexagram, Style, Structure } from "@iching/core"; import { GUA, STYLES, @@ -142,6 +142,29 @@ function methodLabel(method: CastMethod): string { } } +/** + * Format today's cached reading (`iching today`) as plain text — the full + * reading (judgment, changing-line texts, commentary via formatCastPlain) + * prefixed by the day's context: date, intention, method provenance. + */ +export function formatTodayPlain(cache: DailyCache): string { + const cast = cache.cast; + const primary = GUA[cast.primary - 1]; + const lines: string[] = []; + + lines.push(`Date: ${cache.date}`); + if (cache.intention) { + lines.push(`Intention: ${cache.intention}`); + } + if (cache.method) { + lines.push(`Method: ${methodLabel(cache.method)}`); + } + lines.push(""); + lines.push(formatCastPlain(cast, primary, cache.structure)); + + return lines.join("\n"); +} + /** Format journal entry list as plain text */ export function formatJournalListPlain( entries: HistoryEntry[], diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 133b6a3c..fc196fe8 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { registerCastCommand } from "./commands/cast.js"; +import { registerTodayCommand } from "./commands/today.js"; import { registerJournalCommand } from "./commands/journal.js"; import { registerHexagramCommand } from "./commands/hexagram.js"; import { registerConfigCommand } from "./commands/config.js"; @@ -18,6 +19,7 @@ export const program = new Command() .option("--dev", "enable dev mode (coin toss playground, etc.)"); registerCastCommand(program); +registerTodayCommand(program); registerJournalCommand(program); registerHexagramCommand(program); registerConfigCommand(program); diff --git a/packages/terminal/src/__tests__/home-scene.test.ts b/packages/terminal/src/__tests__/home-scene.test.ts new file mode 100644 index 00000000..6ea6af49 --- /dev/null +++ b/packages/terminal/src/__tests__/home-scene.test.ts @@ -0,0 +1,88 @@ +// HomeScene — [t] today menu item (reopen the daily reading) and quit/back +// consistency (escape is a no-op on Home; q and Ctrl+C remain the exits). + +import { describe, expect, test } from "bun:test"; +import { buildStructure, castHexagram, SeededRandomSource } from "@iching/core"; +import type { DailyCache } from "@iching/core"; +import { HomeScene } from "../scenes/home/home-scene.ts"; +import { CellBuffer } from "../render/buffer.ts"; +import type { SceneContext } from "../scene/types.ts"; + +function makeCtx(cols = 80, rows = 30): SceneContext { + return { cols, rows, done: false, colorSupport: "none" }; +} + +function makeTodayCast(): DailyCache { + const cast = castHexagram(new SeededRandomSource(7)); + return { + date: "2026-06-10", + cast, + shown: true, + structure: buildStructure(cast), + intention: "morning sit", + }; +} + +function makeScene(todayCast: DailyCache | null): HomeScene { + return new HomeScene({ todayCast, taijituStyle: "dots" }); +} + +function bufferText(buf: CellBuffer): string { + return Array.from({ length: buf.height }, (_, row) => + buf.getRow(row).map((cell) => cell.char).join(""), + ).join("\n"); +} + +describe("HomeScene today menu item", () => { + test("shows [t] Today between cast and dictionary when a cast exists", () => { + const scene = makeScene(makeTodayCast()); + const ctx = makeCtx(); + const buf = CellBuffer.create(ctx.cols, ctx.rows); + scene.render(buf, ctx); + const rows = bufferText(buf).split("\n"); + const castRow = rows.findIndex((l) => l.includes("[c]")); + const todayRow = rows.findIndex((l) => l.includes("[t]")); + const dictRow = rows.findIndex((l) => l.includes("[d]")); + expect(todayRow).toBeGreaterThan(castRow); + expect(todayRow).toBeLessThan(dictRow); + expect(rows[todayRow]).toContain("Today"); + }); + + test("hides [t] when there is no cast today (quiet empty line stays)", () => { + const scene = makeScene(null); + const ctx = makeCtx(); + const buf = CellBuffer.create(ctx.cols, ctx.rows); + scene.render(buf, ctx); + const text = bufferText(buf); + expect(text).not.toContain("[t]"); + expect(text).toContain("No cast today"); + }); + + test("t emits openToday when a cast exists", () => { + const scene = makeScene(makeTodayCast()); + const signal = scene.handleKey({ type: "char", char: "t" }, makeCtx()); + expect(signal).toEqual({ type: "openToday" }); + }); + + test("t is inert when there is no cast today", () => { + const scene = makeScene(null); + const signal = scene.handleKey({ type: "char", char: "t" }, makeCtx()); + expect(signal).toBeUndefined(); + }); +}); + +describe("HomeScene quit/back consistency", () => { + // Regression: escape used to hard-exit the app — one habitual extra esc + // after backing out of the journal terminated the session. + test("escape is a no-op on Home", () => { + const scene = makeScene(makeTodayCast()); + const signal = scene.handleKey({ type: "escape" }, makeCtx()); + expect(signal).toBeUndefined(); + }); + + test("q and Ctrl+C still exit", () => { + const scene = makeScene(null); + expect(scene.handleKey({ type: "char", char: "q" }, makeCtx())).toEqual({ type: "exit" }); + expect(scene.handleKey({ type: "ctrl", char: "c" }, makeCtx())).toEqual({ type: "exit" }); + }); +}); diff --git a/packages/terminal/src/__tests__/settings-quit-key.test.ts b/packages/terminal/src/__tests__/settings-quit-key.test.ts new file mode 100644 index 00000000..ab55faea --- /dev/null +++ b/packages/terminal/src/__tests__/settings-quit-key.test.ts @@ -0,0 +1,48 @@ +// SettingsScene quit/back consistency — q mirrors escape (save & back). +// Settings was the only scene where q did nothing; every other scene maps +// q to back/home, and Settings has no text input that needs the character. + +import { describe, expect, test } from "bun:test"; +import { SettingsScene } from "../scenes/settings/settings-scene.ts"; +import type { SceneContext } from "../scene/types.ts"; + +function makeCtx(cols = 80, rows = 24): SceneContext { + return { cols, rows, done: false, colorSupport: "none" }; +} + +function makeScene(): SettingsScene { + return new SettingsScene({ + theme: "bone", + language: "en", + taijituStyle: "dots", + glyphAnim: "dots", + glyphFont: "kaiti", + castMethod: "coin", + castMode: "auto", + }); +} + +describe("SettingsScene q key", () => { + test("q saves & backs exactly like escape", () => { + const scene = makeScene(); + const ctx = makeCtx(); + // Change a value first so "save" is observable through getValues(). + scene.handleKey({ type: "arrow", direction: "down" }, ctx); // focus Language + scene.handleKey({ type: "arrow", direction: "right" }, ctx); // en -> zh-Hant + const signal = scene.handleKey({ type: "char", char: "q" }, ctx); + expect(signal).toEqual({ type: "home" }); + expect(scene.getValues().language).toBe("zh-Hant"); + }); + + test("escape still saves & backs (unchanged)", () => { + const scene = makeScene(); + const signal = scene.handleKey({ type: "escape" }, makeCtx()); + expect(signal).toEqual({ type: "home" }); + }); + + test("Ctrl+C still exits (revert path)", () => { + const scene = makeScene(); + const signal = scene.handleKey({ type: "ctrl", char: "c" }, makeCtx()); + expect(signal).toEqual({ type: "exit" }); + }); +}); diff --git a/packages/terminal/src/i18n/messages.ts b/packages/terminal/src/i18n/messages.ts index 8dbf596f..b8826fa7 100644 --- a/packages/terminal/src/i18n/messages.ts +++ b/packages/terminal/src/i18n/messages.ts @@ -19,6 +19,7 @@ export const MESSAGES = { // ── home menu ── "menu.cast": { en: "Cast", zhHant: "起卦", zhHans: "起卦" }, "menu.play": { en: "Play", zhHant: "演練", zhHans: "演练" }, + "menu.today": { en: "Today", zhHant: "今日", zhHans: "今日" }, "menu.dictionary": { en: "Dictionary", zhHant: "卦典", zhHans: "卦典" }, "menu.journal": { en: "Journal", zhHant: "占記", zhHans: "占记" }, "menu.settings": { en: "Settings", zhHant: "設定", zhHans: "设定" }, diff --git a/packages/terminal/src/scene/types.ts b/packages/terminal/src/scene/types.ts index c87c69da..da796eab 100644 --- a/packages/terminal/src/scene/types.ts +++ b/packages/terminal/src/scene/types.ts @@ -36,6 +36,7 @@ export type SceneSignal = // Home-menu intents | { type: "startCast" } // begin a real cast (auto or manual depending on saved mode) | { type: "startPlay" } // begin the coin-toss sandbox (no persistence) + | { type: "openToday" } // reopen today's reading (replay from the daily cache) | { type: "openDictionary" } // open the hexagram browser | { type: "openJournal" } // open the past-readings journal | { type: "openSettings" } // open the settings editor diff --git a/packages/terminal/src/scenes/home/home-scene.ts b/packages/terminal/src/scenes/home/home-scene.ts index 043e8828..5693670c 100644 --- a/packages/terminal/src/scenes/home/home-scene.ts +++ b/packages/terminal/src/scenes/home/home-scene.ts @@ -57,10 +57,12 @@ export class HomeScene implements Scene { frame.writeText(row, titleCol, title, { fg: t.primary, bold: true }); row += 3; - // Menu items + // Menu items. "Today" appears only once a reading exists for the day — + // returning to sit with it is the most common daily action after the cast. 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 }] : []), + ...(this.state.todayCast ? [{ key: "t", msgKey: "menu.today" as MessageKey, fg: t.primary }] : []), { key: "d", msgKey: "menu.dictionary", fg: t.primary }, { key: "j", msgKey: "menu.journal", fg: t.secondary }, { key: "s", msgKey: "menu.settings", fg: t.secondary }, @@ -103,6 +105,7 @@ export class HomeScene implements Scene { switch (key.char) { case "c": return { type: "startCast" }; case "p": if (this.state.devMode) return { type: "startPlay" }; break; + case "t": if (this.state.todayCast) return { type: "openToday" }; break; case "d": return { type: "openDictionary" }; case "j": return { type: "openJournal" }; case "s": return { type: "openSettings" }; @@ -110,6 +113,8 @@ export class HomeScene implements Scene { } } if (key.type === "ctrl" && key.char === "c") return { type: "exit" }; - if (key.type === "escape") return { type: "exit" }; + // Escape is deliberately a no-op on Home: everywhere else it means "back", + // so a habitual extra esc after backing out of a scene must not kill the + // session. q and Ctrl+C remain the exits. } } diff --git a/packages/terminal/src/scenes/settings/settings-scene.ts b/packages/terminal/src/scenes/settings/settings-scene.ts index df658d0d..51213c94 100644 --- a/packages/terminal/src/scenes/settings/settings-scene.ts +++ b/packages/terminal/src/scenes/settings/settings-scene.ts @@ -407,7 +407,9 @@ export class SettingsScene implements Scene { } handleKey(key: KeyEvent, _ctx: SceneContext): SceneSignal | void { - if (key.type === "escape") { + // q mirrors escape (save & back) — every other scene maps q to back/home, + // and Settings has no text input that would need the literal character. + if (key.type === "escape" || (key.type === "char" && key.char === "q")) { this.values = this.getValues(); return { type: "home" }; } diff --git a/tests/fixtures/language/TEXT_SURFACES.md b/tests/fixtures/language/TEXT_SURFACES.md index 64b9713d..c965a8aa 100644 --- a/tests/fixtures/language/TEXT_SURFACES.md +++ b/tests/fixtures/language/TEXT_SURFACES.md @@ -678,7 +678,7 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f - 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"' + current_text: '"Cast" "Play"(dev) "Today"(cast-exists) "Dictionary" "Journal" "Settings" "Quit"' surface_class: terminal-home render_context: "home menu items '[k] Label'; key letters are accelerators" language_policy: translate @@ -688,7 +688,7 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f agentify_required: no status: open verifier: "--terminal" - notes: "English, hardcoded. No language branch. 'Play' is dev-mode-only." + notes: "Localized via catalog (menu.*). 'Play' is dev-mode-only; 'Today' (menu.today, [t] → replay) renders only when a cast exists for the day." - surface_id: term-home-status file: packages/terminal/src/scenes/home/home-scene.ts @@ -1288,11 +1288,12 @@ Field-class altitude. 64 entries × fields. Verifier uses field-class coverage f 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 + file: apps/cli/src/commands/{cast,config,dict,doctor,hexagram,journal,paths,today}.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