@vincemakes/kiso-tui-cells 0.46.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * dependencies (the tui-cells package has none).
7
7
  */
8
8
  import { charWidth, displayWidth, visibleWidth, widthCut } from "./width.js";
9
+ import { bg, breathRamp, colourTier, fg, graphiteColours, mix } from "./graphite.js";
9
10
  const BASE = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", warn: "\x1b[33m", italic: "\x1b[3m", italicEnd: "\x1b[23m", underline: "\x1b[4m", underlineEnd: "\x1b[24m", rv: "\x1b[7m", rvEnd: "\x1b[27m", reset: "\x1b[0m" };
10
11
  /**
11
12
  * DC-9 (design §2.3) — the failure colour is theme-resolved.
@@ -17,11 +18,8 @@ const BASE = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32
17
18
  * cannot degrade to an attribute the way `dim` does — it needs a value
18
19
  * per ground, and the ground is what §3's ladder is for.
19
20
  *
20
- * 256-cube indices, never truecolor (§2). Measured against the grounds
21
- * §2 measures against — white, and #1E1E1E:
22
- *
23
- * light 124 `#af0000` 7.44:1
24
- * dark 173 `#d7875f` 5.97:1
21
+ * Its values are Graphite's `fail` (design.md §2), written in the
22
+ * resolved tier.
25
23
  *
26
24
  * With NO ground established the token stays ANSI 31 — the TERMINAL's
27
25
  * own red, which its theme picked for its own background. That is the last rung
@@ -31,8 +29,44 @@ const BASE = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32
31
29
  /** `washDimEnd` is DERIVED, never passed: a grey that cannot be closed
32
30
  * without taking the wash with it is not a usable token, and deriving
33
31
  * the close makes the pair impossible to mis-wire at a call site. */
32
+ /** The Graphite members, empty — the unknown ground's and colour-off's. */
33
+ const NO_GRAPHITE = {
34
+ ink2: "",
35
+ rail: "",
36
+ line: "",
37
+ gold: "",
38
+ goldMark: "",
39
+ blue: "",
40
+ ok: "",
41
+ fail: "",
42
+ humanInk: "",
43
+ track: "",
44
+ washRun: "",
45
+ washDone: "",
46
+ washFail: "",
47
+ washAsk: "",
48
+ human: "",
49
+ codeBg: "",
50
+ add: "",
51
+ del: "",
52
+ addWord: "",
53
+ delWord: "",
54
+ edge: "",
55
+ quoteBar: "",
56
+ noteBar: "",
57
+ warnBar: "",
58
+ cautionBar: "",
59
+ runEdge: "",
60
+ failEdge: "",
61
+ askEdge: "",
62
+ humanEdge: "",
63
+ fgEnd: "",
64
+ breath: [],
65
+ tier: null,
66
+ };
34
67
  const withWash = (wash, washEnd, red = BASE.red, dim = BASE.dim, washDim = "") => ({
35
68
  ...BASE,
69
+ ...NO_GRAPHITE,
36
70
  red,
37
71
  dim,
38
72
  wash,
@@ -41,12 +75,88 @@ const withWash = (wash, washEnd, red = BASE.red, dim = BASE.dim, washDim = "") =
41
75
  washDimEnd: washDim === "" ? "" : "\x1b[39m",
42
76
  code: wash,
43
77
  });
78
+ /**
79
+ * Graphite (design.md §2, §3.4) — the palette for a KNOWN ground.
80
+ *
81
+ * The colours come from `graphite.ts`: the table's values for the kind,
82
+ * with the surfaces derived from the reported ground when there is one.
83
+ * The members the pre-Graphite code reads keep their names and take the
84
+ * Graphite value of the same job: `dim` the dim token, `red` the failure
85
+ * colour, `green` the success colour, `warn` gold (the uncertain badge
86
+ * is a question for the person, which is gold's meaning), `wash` the
87
+ * settled card's ground, and `washDim` the dim token — which clears the
88
+ * floor on every card ground (§2.1), so the separate grey retires in
89
+ * value while the name stays for its call sites.
90
+ */
91
+ /** How far a card's edge cell is deepened from its ground toward the
92
+ * state's colour — quiet enough to read as the card's own edge, not a
93
+ * stripe (the owner's seam test, B6, 2026-09-29). */
94
+ const EDGE_DEPTH = 0.22;
95
+ /** The person's edge is gold, quieted toward the warm ground so a whole
96
+ * cell reads as an edge and not a stripe (the seam test's B4). */
97
+ const HUMAN_EDGE_DEPTH = 0.55;
98
+ /** How far a changed word's ground is deepened from its row's toward the
99
+ * row's colour: enough to find the word, not so much that ink stops
100
+ * reading on it. */
101
+ const WORD_DEPTH = 0.3;
102
+ /** How far a quote's or an alert's bar is deepened from the ground toward
103
+ * its colour: a bar, not a stripe (the person's edge's measure). */
104
+ const BAR_DEPTH = 0.5;
105
+ export function paletteFor(kind, ground, tier) {
106
+ const c = graphiteColours(kind, ground, tier);
107
+ const f = (x) => fg(x, tier);
108
+ const b = (x) => bg(x, tier);
109
+ return {
110
+ ...BASE,
111
+ dim: f(c.dim),
112
+ red: f(c.fail),
113
+ green: f(c.ok),
114
+ warn: f(c.gold),
115
+ wash: b(c.washDone),
116
+ washEnd: "\x1b[49m",
117
+ washDim: f(c.dim),
118
+ washDimEnd: "\x1b[39m",
119
+ code: b(c.washDone),
120
+ ink2: f(c.ink2),
121
+ rail: f(c.rail),
122
+ line: f(c.line),
123
+ gold: f(c.gold),
124
+ goldMark: f(c.goldMark),
125
+ blue: f(c.blue),
126
+ ok: f(c.ok),
127
+ fail: f(c.fail),
128
+ humanInk: f(c.humanInk),
129
+ track: f(c.track),
130
+ washRun: b(c.washRun),
131
+ washDone: b(c.washDone),
132
+ washFail: b(c.washFail),
133
+ washAsk: b(c.washAsk),
134
+ human: b(c.human),
135
+ codeBg: b(c.code),
136
+ add: b(c.add),
137
+ del: b(c.del),
138
+ addWord: b(mix(c.add, c.ok, WORD_DEPTH)),
139
+ delWord: b(mix(c.del, c.fail, WORD_DEPTH)),
140
+ edge: f(mix(c.line, c.rail, 0.5)),
141
+ quoteBar: b(mix(c.ground, c.rail, BAR_DEPTH)),
142
+ noteBar: b(mix(c.ground, c.blue, BAR_DEPTH)),
143
+ warnBar: b(mix(c.ground, c.goldMark, BAR_DEPTH)),
144
+ cautionBar: b(mix(c.ground, c.fail, BAR_DEPTH)),
145
+ runEdge: b(mix(c.washRun, c.blue, EDGE_DEPTH)),
146
+ failEdge: b(mix(c.washFail, c.fail, EDGE_DEPTH)),
147
+ askEdge: b(mix(c.washAsk, c.goldMark, EDGE_DEPTH)),
148
+ humanEdge: b(mix(c.human, c.goldMark, HUMAN_EDGE_DEPTH)),
149
+ fgEnd: "\x1b[39m",
150
+ breath: breathRamp(c).map(f),
151
+ tier,
152
+ };
153
+ }
44
154
  /**
45
155
  * DC-3 — one table per ground.
46
156
  *
47
- * R3 (owner, 2026-08-27) — `dim` is ABSOLUTE once the ground is known,
48
- * and design.md §2's table always said so: light `243` `#767676` at
49
- * 4.54:1, dark `246` `#949494` at 5.50:1 (both re-measured here).
157
+ * R3 (owner, 2026-08-27) — `dim` is ABSOLUTE once the ground is known:
158
+ * Graphite's `dim`, measured against the floor on every surface it can
159
+ * reach (design.md §2.1).
50
160
  *
51
161
  * DC-3 shipped SGR 2 instead, on the argument that an attribute adapts
52
162
  * to the ground while an absolute grey asserts one. That argument is
@@ -62,23 +172,39 @@ const withWash = (wash, washEnd, red = BASE.red, dim = BASE.dim, washDim = "") =
62
172
  * attribute is exactly the "correct on any ground" degradation there.
63
173
  */
64
174
  export const COLOR_NEUTRAL = withWash("\x1b[7m", "\x1b[27m");
65
- export const COLOR_LIGHT = withWash("\x1b[48;5;255m", "\x1b[49m", "\x1b[38;5;124m", "\x1b[38;5;243m", "\x1b[38;5;241m");
66
- export const COLOR_DARK = withWash("\x1b[48;5;236m", "\x1b[49m", "\x1b[38;5;173m", "\x1b[38;5;246m", "\x1b[38;5;247m");
175
+ /** The two reference palettes: Graphite on its reference grounds, in the
176
+ * 24-bit tier. `paletteFor` is the general form. */
177
+ export const COLOR_LIGHT = paletteFor("light", null, "24bit");
178
+ export const COLOR_DARK = paletteFor("dark", null, "24bit");
67
179
  /** The historical name — the palette for a colour TTY whose ground has
68
180
  * not been established. Unchanged in every byte except `code`, which
69
181
  * was the defect. */
70
182
  export const COLOR_ON = COLOR_NEUTRAL;
71
- export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", warn: "", code: "", italic: "", italicEnd: "", underline: "", underlineEnd: "", rv: "", rvEnd: "", wash: "", washEnd: "", washDim: "", washDimEnd: "", reset: "" };
183
+ export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", warn: "", code: "", italic: "", italicEnd: "", underline: "", underlineEnd: "", rv: "", rvEnd: "", wash: "", washEnd: "", washDim: "", washDimEnd: "", reset: "", ...NO_GRAPHITE };
72
184
  /** DC-3 — the resolved ground, set once at startup when the terminal
73
185
  * answers (see `ground.ts`). It starts UNKNOWN and may stay that way
74
186
  * forever; that is a supported state, not a failure. */
75
187
  let ground = "unknown";
76
- export function setGround(g) {
188
+ /** §3.4 — the colour the terminal reported, when it reported one; the
189
+ * surfaces are derived from it. Null when the ground was resolved
190
+ * without a colour. */
191
+ let groundRgb = null;
192
+ export function setGround(g, rgb = null) {
77
193
  ground = g;
194
+ groundRgb = g === "unknown" ? null : rgb;
78
195
  }
79
196
  export function currentGround() {
80
197
  return ground;
81
198
  }
199
+ export function currentGroundRgb() {
200
+ return groundRgb;
201
+ }
202
+ /** One palette per (ground, reported colour, tier): `palette()` runs on
203
+ * every render and the derivation is not free, so the last answer is
204
+ * kept and reused while none of its inputs changed. The inputs are
205
+ * compared field by field (the render-perf pass, 2026-10-07): a key
206
+ * string built on every call was the hottest line of a Graphite frame. */
207
+ let memo = null;
82
208
  export function palette() {
83
209
  // PH-1a (finding PH-F5): the no-color.org contract is "present AND
84
210
  // non-empty" — the old `=== undefined` check let an EMPTY `NO_COLOR=`
@@ -89,7 +215,15 @@ export function palette() {
89
215
  const noColor = process.env.NO_COLOR;
90
216
  if (!((noColor === undefined || noColor === "") && process.stdout.isTTY))
91
217
  return COLOR_OFF;
92
- return ground === "light" ? COLOR_LIGHT : ground === "dark" ? COLOR_DARK : COLOR_NEUTRAL;
218
+ if (ground === "unknown")
219
+ return COLOR_NEUTRAL;
220
+ const tier = colourTier(process.env.COLORTERM);
221
+ const r = groundRgb === null ? -1 : groundRgb.r;
222
+ const g = groundRgb === null ? -1 : groundRgb.g;
223
+ const b = groundRgb === null ? -1 : groundRgb.b;
224
+ if (memo === null || memo.ground !== ground || memo.tier !== tier || memo.r !== r || memo.g !== g || memo.b !== b)
225
+ memo = { ground, r, g, b, tier, p: paletteFor(ground, groundRgb, tier) };
226
+ return memo.p;
93
227
  }
94
228
  /**
95
229
  * E group/round 8: strip terminal-injection vectors from MODEL/TOOL text before it
@@ -362,23 +496,45 @@ export function renderTerminalGap(statusLine) {
362
496
  * the transition. Every glyph is in Menlo and absent from Apple Color
363
497
  * Emoji (§6.1's test, run). */
364
498
  export const TWINKLE = ["\u2727", "\u2726", "\u2736", "\u2738", "\u273a", "\u2738", "\u2726"];
365
- /** The COMMAND breath — brightness only, one glyph. The ramps bottom out
366
- * EXACTLY on the ground's dim token (§2.2: "the floor is a floor,
367
- * including mid-animation"): light ends at 243 (4.54:1 on white), dark
368
- * at 246 (5.50:1 on #1e1e1e). Measured, not assumed. */
369
- const BREATH_LIGHT = [232, 236, 240, 243, 240, 236, 232];
370
- const BREATH_DARK = [255, 251, 248, 246, 248, 251, 255];
371
- /** The breath's frame: `●` at the step's grey, for the CURRENT ground.
372
- * With no ground — or under NO_COLOR — it freezes to a static `●`,
373
- * because a brightness ramp needs a background to be a ramp against and
374
- * §3.1 forbids guessing one. The glyph never changes, so the freeze
375
- * degrades the motion and never the meaning. */
499
+ /**
500
+ * Graphite §7.8 — the composer's top rule: `gold-mark` for its first eighth,
501
+ * fading to `line` by a third of the width, `line` after. Drawn in a
502
+ * handful of runs, not a colour per cell, so the row costs a few escapes
503
+ * rather than one per column. Off a known ground it is the plain dim rule.
504
+ */
505
+ export function fadeRule(W) {
506
+ const p = palette();
507
+ const n = Math.max(0, W);
508
+ if (p.tier === null || ground === "unknown")
509
+ return `${p.dim}${"\u2500".repeat(n)}${p.reset}`;
510
+ const c = graphiteColours(ground, groundRgb, p.tier);
511
+ const solid = Math.max(1, Math.round(n / 8));
512
+ const end = Math.max(solid + 1, Math.round(n / 3));
513
+ const STEPS = 6;
514
+ let out = `${fg(c.goldMark, p.tier)}${"\u2500".repeat(Math.min(n, solid))}`;
515
+ let at = solid;
516
+ for (let k = 1; k <= STEPS && at < Math.min(n, end); k += 1) {
517
+ const to = k === STEPS ? Math.min(n, end) : Math.min(n, solid + Math.round(((end - solid) * k) / STEPS));
518
+ if (to > at)
519
+ out += `${fg(mix(c.goldMark, c.line, k / (STEPS + 1)), p.tier)}${"\u2500".repeat(to - at)}`;
520
+ at = Math.max(at, to);
521
+ }
522
+ if (at < n)
523
+ out += `${fg(c.line, p.tier)}${"\u2500".repeat(n - at)}`;
524
+ return `${out}${p.fgEnd}`;
525
+ }
526
+ /** The breath's frame: `●` at the step's brightness (§5.2 — seven steps
527
+ * of gold toward the running card's ground, never under the graphic
528
+ * floor, `graphite.ts` `breathRamp`). With no ground — or under
529
+ * NO_COLOR — it freezes to a static `●`, because a brightness ramp needs
530
+ * a background to be a ramp against and §3.1 forbids guessing one. The
531
+ * glyph never changes, so the freeze degrades the motion and never the
532
+ * meaning. */
376
533
  export function breathFrame(step) {
377
534
  const p = palette();
378
- const ramp = currentGround() === "light" ? BREATH_LIGHT : currentGround() === "dark" ? BREATH_DARK : null;
379
- if (ramp === null || p.bold === "")
535
+ if (p.breath.length === 0)
380
536
  return "\u25cf";
381
- return `\x1b[38;5;${ramp[step % ramp.length]}m\u25cf${p.reset}`;
537
+ return `${p.breath[step % p.breath.length]}\u25cf${p.reset}`;
382
538
  }
383
539
  /** The twinkle's frame — pure glyph, no palette involved. */
384
540
  export function twinkleFrame(step) {
@@ -389,33 +545,40 @@ export function twinkleFrame(step) {
389
545
  export const MOTION_FRAMES = 7;
390
546
  export const TAGLINE = "the coding agent that survives kill -9";
391
547
  /**
392
- * R2 — the wordmark is retired (2026-08-27, the nineteen-screen review).
548
+ * Graphite §7.10 — the opening: the wordmark, then what loaded.
393
549
  *
394
- * TT-1B had already cut the 36x6 pixel art down to two rows because a
395
- * tall banner's mid-scroll cut state renders as glyph garbage. The
396
- * remaining two rows go now for a different reason: they say the word
397
- * `kiso` in fifteen columns of block glyphs, and the word `kiso` says it
398
- * in four. A rendered clover mark was tried first, at 4x2, 10x5, 14x7
399
- * and 16x8, and rejected on measurement — below fourteen columns the
400
- * centre star closes and the mark reads as a domino, and at fourteen it
401
- * costs seven rows.
550
+ * DECLARED REVERSAL (Graphite, owner-ruled 2026-09-28) of R2's "no logo,
551
+ * the name is the mark". R2 retired the wordmark because it cost the rows
552
+ * a first screen needed for three questions — what model, where am I, what
553
+ * is loaded. The model and the folder moved to the status bar (§8.9), what
554
+ * is loaded moved beside the wordmark, and the wordmark came back: ten
555
+ * rows, once, at the top of a session — including in the 80×24 window a
556
+ * Mac opens by default (owner, 2026-09-29: a wordmark the default window
557
+ * never shows is not worth drawing). Under 20 rows, on a terminal too
558
+ * narrow for it, and on a resume (the history is above the opening there,
559
+ * and ten rows of wordmark would bury its tail) the opening is one line.
402
560
  *
403
- * What takes the room is not decoration. A first screen is asked three
404
- * questions — what model, where am I, what is loaded — and it now
405
- * answers them in one aligned column.
561
+ * The R2 keys row retires with it: `?` lists the keys (§8.5).
406
562
  */
407
- /** R2 — the keys a first screen teaches. One dim row, and deliberately
408
- * NOT derived from KEY_BINDINGS: the sheet is the complete list and
409
- * this is the opening's five, chosen rather than generated. */
410
- // R2: the keys row names bindings the product ACTUALLY has. The first
411
- // draft advertised `! bash` — there is no bang passthrough in kiso and
412
- // KEY_BINDINGS never had one, so the opening screen was teaching a key
413
- // that does nothing. A first screen that lies is worse than a short one.
414
- const BANNER_KEYS = "esc interrupt · ctrl+c exit · / commands · @ files · ? keys";
415
- /** R2 — the labels. Uppercase mono, dim, letter-spaced by the column
416
- * rather than by SGR: they mark sections and are never content. */
417
- const BANNER_LABELS = ["MODEL", "WORKSPACE", "EXTENSIONS"];
418
- const LABEL_STOP = Math.max(...BANNER_LABELS.map((l) => l.length)) + 2;
563
+ export const MOTTO = "intent \u2192 effect \u2192 durable fact";
564
+ const WORDMARK = [
565
+ "\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557",
566
+ "\u2588\u2588\u2551 \u2588\u2588\u2554\u255d\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557",
567
+ "\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551",
568
+ "\u2588\u2588\u2554\u2550\u2588\u2588\u2557 \u2588\u2588\u2551\u255a\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551",
569
+ "\u2588\u2588\u2551 \u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d",
570
+ "\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d",
571
+ ];
572
+ /** The wordmark's width in cells (its widest row). */
573
+ export const WORDMARK_W = Math.max(...WORDMARK.map((r) => displayWidth(r)));
574
+ /** The content edge (§1.8) the opening's rows start at. */
575
+ const OPENING_EDGE = 2;
576
+ /** §7.10: the facts sit beside the wordmark from this width, below it under. */
577
+ const FACTS_BESIDE_W = 96;
578
+ /** §7.10: under this height the opening is one line. */
579
+ const OPENING_TALL_H = 20;
580
+ /** The facts' label column: the longest label and two spaces. */
581
+ const FACT_LABEL_W = "EXTENSIONS".length + 2;
419
582
  /** W20 — the ONE-ROW cut with the honest mark, SGR-aware. A line that
420
583
  * fits (≤ W) passes through whole; an overflow cuts the content at
421
584
  * W−1 — the ellipsis's slot — and the ellipsis rides AFTER the reset
@@ -493,105 +656,186 @@ export function truncateRow(row, width) {
493
656
  marker = next;
494
657
  }
495
658
  }
496
- /** v3 §01 (V6-2) + W1: the banner lines for a width W and height H —
497
- * the tier table (extends the existing "under 40 columns, skip the
498
- * logo" rule with a HEIGHT input; VD-14 merged the two art tiers):
499
- * W ≥ 40 and H ≥ 14 → the 2-row wordmark, 2-column indent
500
- * anything smaller → text rows only
501
- * then the blank, then "vX — tagline" — the art IS the wordmark, so the
502
- * text row does not repeat the name — then extensions — then the W5
503
- * resume list (BIG only, W5). Every row truncates at the terminal width
504
- * with a " (+N)" marker. Pure. */
659
+ /**
660
+ * Graphite §7.10 — the opening's rows for a width W and height H. Pure;
661
+ * invariant ① holds at every width (each row is cut to W).
662
+ *
663
+ * - The tall form (H ≥ 30, W ≥ the wordmark at the content edge, not a
664
+ * resume): the wordmark, its rule, the tagline with the version, the
665
+ * motto. The facts sit beside it behind one hairline when W ≥ 96, and
666
+ * below it otherwise.
667
+ * - The one-line form otherwise: `✦ kiso <version> · <tagline>`, the
668
+ * facts below it.
669
+ *
670
+ * On a known ground the block cells take `mix(ink, dim, row / 4)` top to
671
+ * bottom, the box-drawing shadow `mix(rail, ground, 0.35)`, and the rule
672
+ * fades from `dim` to the ground; there is no gold (§1.2 — gold is the
673
+ * edge, and the opening has none). Off one the wordmark is the terminal's
674
+ * own foreground. `<version>` is the caller's, never a literal.
675
+ */
505
676
  export function bannerLines(W, H, version, extensionsText, resume = [], now = Date.now(), meta) {
506
677
  const p = palette();
507
- // R2: the banner styles itself per span. It used to be wrapped in one
508
- // blanket dim by its component, which made the answers as faint as the
509
- // labels asking the questions — the labels are the quiet half, the
510
- // values are what a human came to read.
511
- //
512
- // Every width decision below is taken on PLAIN text and the styling is
513
- // applied after, because truncateRow measures with displayWidth, which
514
- // counts SGR bytes as columns. Style then measure is a bug waiting.
515
- // DC-18: the name row is CUT like every other row here. It was the one
516
- // row in this function pushed unguarded, so at W ≤ 10 `kiso 0.16.4`
517
- // measured 11 cells and invariant ① threw AT STARTUP — the function
518
- // whose own comment preaches "invariant ① holds at every width".
519
- // The cut is taken on the plain text, per the note above.
520
- const namePlain = widthCut(`kiso ${version}`, Math.max(1, W));
521
- const nameCut = namePlain.slice(0, 4); // "kiso", or its surviving prefix
522
- const verCut = namePlain.slice(5); // the version, if the width left room for it
523
- const rows = [`${p.bold}${nameCut}${p.reset}${verCut === "" ? "" : `${p.dim} ${verCut}${p.reset}`}`];
524
- const facts = [];
525
- if (meta !== undefined) {
526
- facts.push([BANNER_LABELS[0], `${meta.model}${meta.mode === "" ? "" : ` · ${meta.mode}`}`], [BANNER_LABELS[1], meta.cwd]);
527
- // DC-49 — ONE row under the cwd, and only when the workspace is the
528
- // home directory. It STATES a fact and names the remedy; it does not
529
- // warn, because the configuration is ALLOWED (owner, 2026-09-06) and
530
- // a warning about an allowed thing teaches people to skip rows.
531
- //
532
- // An empty label puts it in the value column under `WORKSPACE`,
533
- // where it reads as a note on that fact rather than a fact of its
534
- // own. It must FIT at W=80 under the label indent: a cut row loses
535
- // the remedy, which is the only actionable half of the sentence.
536
- if (meta.homeWorkspace === true)
537
- facts.push(["", "home directory as workspace — cd into a project to narrow it"]);
678
+ const width = Math.max(1, W);
679
+ const cut = (row) => cutLine(row, width);
680
+ const facts = meta !== undefined ? meta.facts : extensionsText !== "" ? [{ label: "EXTENSIONS", value: extensionsText }] : [];
681
+ const tall = H >= OPENING_TALL_H && width >= OPENING_EDGE + WORDMARK_W && meta?.resumed !== true;
682
+ const pad = " ".repeat(OPENING_EDGE);
683
+ const rows = [];
684
+ if (!tall) {
685
+ rows.push(cut(`\u2726 ${p.bold}kiso${p.reset} ${p.dim}${version} \u00b7 ${TAGLINE}${p.reset}`));
686
+ if (facts.length > 0)
687
+ rows.push("", ...factRows(facts, width - OPENING_EDGE).map((r) => cut(`${pad}${r}`)));
538
688
  }
539
- if (extensionsText !== "")
540
- facts.push([BANNER_LABELS[2], extensionsText]);
541
- if (facts.length > 0) {
542
- rows.push("");
543
- // The value column HANGS rather than truncating. The label costs
544
- // columns the value used to have, and an extension list cut at the
545
- // width would hide which extensions loaded — on the one screen whose
546
- // job is to say what is loaded.
547
- const indent = 2 + LABEL_STOP;
548
- // a terminal too narrow to hold the label column at all: the room is
549
- // what is left, floored at one column, and the assembled row is
550
- // truncated as a unit so invariant ① holds at every width.
551
- const room = Math.max(1, W - indent);
552
- for (const [label, value] of facts) {
553
- const lead = ` ${p.dim}${label}${p.reset}${" ".repeat(LABEL_STOP - label.length)}`;
554
- const hang = " ".repeat(indent);
555
- const lines = [];
556
- let line = "";
557
- for (const word of value.split(" ")) {
558
- if (line === "")
559
- line = word;
560
- else if (displayWidth(`${line} ${word}`) <= room)
561
- line += ` ${word}`;
562
- else {
563
- lines.push(line);
564
- line = word;
689
+ else {
690
+ const head = [...wordmarkRows(), openingRule(WORDMARK_W), `${p.ink2}${TAGLINE}${p.fgEnd}${p.dim} \u00b7 ${version}${p.reset}`, `${p.dim}${MOTTO}${p.reset}`];
691
+ // two cells after the wordmark's widest row, the hairline, two more,
692
+ // then the facts
693
+ const factsCol = OPENING_EDGE + WORDMARK_W + 5;
694
+ // beside the wordmark from 96 columns, as long as the facts' rows fit
695
+ // in its six; below it otherwise
696
+ const beside = width >= FACTS_BESIDE_W ? factRows(facts, width - factsCol) : [];
697
+ if (beside.length > 0 && beside.length <= WORDMARK.length) {
698
+ const rule = p.line !== "" ? `${p.line}\u2502${p.fgEnd}` : `${p.dim}\u2502${p.reset}`;
699
+ for (const [i, h] of head.entries()) {
700
+ if (i >= WORDMARK.length) {
701
+ rows.push(cut(`${pad}${h}`));
702
+ continue;
565
703
  }
566
- }
567
- if (line !== "")
568
- lines.push(line);
569
- for (const [i, l] of lines.entries()) {
570
- const styled = `${i === 0 ? lead : hang}${truncateRow(l, room)}`;
571
- rows.push(displayWidth(styled) - (i === 0 ? p.dim.length + p.reset.length : 0) <= W ? styled : truncateRow(`${hang}${l}`, W));
704
+ const gap = " ".repeat(WORDMARK_W + 2 - visibleWidth(h));
705
+ rows.push(cut(`${pad}${h}${gap}${rule}${beside[i] === undefined ? "" : ` ${beside[i]}`}`));
572
706
  }
573
707
  }
574
- }
575
- if (meta !== undefined && W >= 40) {
576
- // R2/DC-2's device: the keys row is a list of independent clauses,
577
- // so a narrow terminal drops whole clauses from the end rather than
578
- // cutting one in half. `ctrl+o ex (+8)` teaches nothing.
579
- const clauses = BANNER_KEYS.split(" \u00b7 ");
580
- let keys = clauses[0];
581
- for (let n = clauses.length; n > 1; n -= 1) {
582
- const row = clauses.slice(0, n).join(" \u00b7 ");
583
- if (displayWidth(row) <= W - 2) {
584
- keys = row;
585
- break;
586
- }
708
+ else {
709
+ rows.push(...head.map((h) => cut(`${pad}${h}`)));
710
+ if (facts.length > 0)
711
+ rows.push("", ...factRows(facts, width - OPENING_EDGE).map((r) => cut(`${pad}${r}`)));
587
712
  }
588
- rows.push("", ` ${p.dim}${truncateRow(keys, W - 2)}${p.reset}`);
589
713
  }
590
714
  if (W >= 40 && H >= 20 && resume.length > 0) {
591
715
  rows.push("", ...renderResumeList(resume, W, now));
592
716
  }
593
717
  return rows;
594
718
  }
719
+ /**
720
+ * The wordmark's six rows, coloured per §7.10 on a known ground. The
721
+ * letters' cells are BACKGROUND, not `█` glyphs (§1.5): a glyph stops short
722
+ * of its cell in Apple Terminal and every row showed a white line through
723
+ * the letters (the owner's seam test, C1 against C2, 2026-09-29). The
724
+ * shadow stays in box-drawing glyphs, lighter than the letters. Off a known
725
+ * ground there is no background to paint, and the letters are `█`.
726
+ */
727
+ function wordmarkRows() {
728
+ const p = palette();
729
+ const tier = p.tier;
730
+ if (tier === null || ground === "unknown")
731
+ return [...WORDMARK];
732
+ const c = graphiteColours(ground, groundRgb, tier);
733
+ const shadow = fg(mix(c.rail, c.ground, 0.35), tier);
734
+ return WORDMARK.map((row, i) => {
735
+ const block = bg(mix(c.ink, c.dim, Math.min(1, i / 4)), tier);
736
+ // the shadow's colour holds for the whole row (a letter cell is a
737
+ // space, which has no foreground to show); the letters open and
738
+ // close their background in runs
739
+ let out = shadow;
740
+ let inBlock = false;
741
+ for (const ch of row) {
742
+ const isBlock = ch === "\u2588";
743
+ if (isBlock !== inBlock)
744
+ out += isBlock ? block : "\x1b[49m";
745
+ inBlock = isBlock;
746
+ out += isBlock ? " " : ch;
747
+ }
748
+ return `${out}${inBlock ? "\x1b[49m" : ""}${p.fgEnd}`;
749
+ });
750
+ }
751
+ /** The rule under the wordmark: `dim` fading to the ground, in a few runs.
752
+ * Off a known ground, the plain dim rule. */
753
+ /** Graphite §5 (R2b) — the rule under an answer's `#` heading: `gold-mark`
754
+ * fading to the ground over `n` cells (the prototype's forty). Off a known
755
+ * ground, the plain dim rule. */
756
+ export function headingRule(n) {
757
+ const p = palette();
758
+ if (p.tier === null || ground === "unknown")
759
+ return `${p.dim}${"\u2500".repeat(n)}${p.reset}`;
760
+ const c = graphiteColours(ground, groundRgb, p.tier);
761
+ const STEPS = 7;
762
+ let out = "";
763
+ let at = 0;
764
+ for (let k = 0; k < STEPS && at < n; k += 1) {
765
+ const to = k === STEPS - 1 ? n : Math.round((n * (k + 1)) / STEPS);
766
+ if (to > at)
767
+ out += `${fg(mix(c.goldMark, c.ground, k / STEPS), p.tier)}${"\u2500".repeat(to - at)}`;
768
+ at = Math.max(at, to);
769
+ }
770
+ return `${out}${p.fgEnd}`;
771
+ }
772
+ function openingRule(n) {
773
+ const p = palette();
774
+ if (p.tier === null || ground === "unknown")
775
+ return `${p.dim}${"\u2500".repeat(n)}${p.reset}`;
776
+ const c = graphiteColours(ground, groundRgb, p.tier);
777
+ const STEPS = 7;
778
+ let out = "";
779
+ let at = 0;
780
+ for (let k = 0; k < STEPS && at < n; k += 1) {
781
+ const to = k === STEPS - 1 ? n : Math.round((n * (k + 1)) / STEPS);
782
+ if (to > at)
783
+ out += `${fg(mix(c.dim, c.ground, k / STEPS), p.tier)}${"\u2500".repeat(to - at)}`;
784
+ at = Math.max(at, to);
785
+ }
786
+ return `${out}${p.fgEnd}`;
787
+ }
788
+ /** The facts as rows `room` cells wide: the label dim in its column, the
789
+ * fact in ink, the note dim after it. A row that does not fit loses its
790
+ * note first — from its end, one ` · ` part at a time (the last sweep,
791
+ * owner 2026-10-06: `<id> · new · resumable after kill -9` keeps `new`
792
+ * beside the wordmark when a 24-cell session id leaves no room for the
793
+ * rest); a fact still too long HANGS under itself, folded by word — an
794
+ * extensions list cut at the width would hide which extensions loaded, on
795
+ * the one screen whose job is to say so. */
796
+ function factRows(facts, room) {
797
+ const p = palette();
798
+ const rows = [];
799
+ for (const f of facts) {
800
+ const label = `${p.dim}${f.label}${p.reset}${" ".repeat(Math.max(1, FACT_LABEL_W - f.label.length))}`;
801
+ const value = escapeTerminal(f.value);
802
+ const parts = f.note === undefined || f.note === "" ? [] : escapeTerminal(f.note).split(" \u00b7 ");
803
+ let fitted = null;
804
+ for (let n = parts.length; n >= 0 && fitted === null; n -= 1) {
805
+ const kept = parts.slice(0, n).join(" \u00b7 ");
806
+ const row = `${label}${value}${kept === "" ? "" : `${p.dim} \u00b7 ${kept}${p.reset}`}`;
807
+ if (visibleWidth(row) <= room)
808
+ fitted = row;
809
+ }
810
+ if (fitted !== null) {
811
+ rows.push(fitted);
812
+ continue;
813
+ }
814
+ const valueRoom = room - FACT_LABEL_W;
815
+ if (valueRoom < 8) {
816
+ rows.push(cutLine(`${label}${value}`, Math.max(1, room)));
817
+ continue;
818
+ }
819
+ const lines = [];
820
+ let line = "";
821
+ for (const word of value.split(" ")) {
822
+ if (line === "")
823
+ line = word;
824
+ else if (displayWidth(`${line} ${word}`) <= valueRoom)
825
+ line += ` ${word}`;
826
+ else {
827
+ lines.push(line);
828
+ line = word;
829
+ }
830
+ }
831
+ if (line !== "")
832
+ lines.push(line);
833
+ const hang = " ".repeat(FACT_LABEL_W);
834
+ for (const [i, l] of lines.entries())
835
+ rows.push(cutLine(`${i === 0 ? label : hang}${l}`, Math.max(1, room)));
836
+ }
837
+ return rows;
838
+ }
595
839
  export function relativeTime(updatedAt, now) {
596
840
  const s = Math.max(0, now - updatedAt) / 1000;
597
841
  if (s < 60)