@vincemakes/kiso-tui-cells 0.16.4 → 0.16.6

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.
@@ -273,7 +273,7 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
273
273
  // block, and none of them the composer's. A rule SEPARATES, a gutter
274
274
  // SCOPES — the args keep their gutter because they are a verbatim
275
275
  // block; everything that was drawing a boundary is one rule now.
276
- rows.push(`${p.dim}${"\u254c".repeat(Math.max(0, W))}${p.reset}`);
276
+ rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
277
277
  rows.push(` ${cutLine(panelRuleText(view), Math.max(1, W - 2))}`);
278
278
  rows.push(` ${cutLine(`${p.bold}${escapeTerminal(view.title)}${p.reset}`, Math.max(1, W - 2))}`);
279
279
  // TUI2-R1.5 ⑤ (VD-11): the divider is a LABEL, not a design note. "the
@@ -388,7 +388,7 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
388
388
  // else in the product, so a CAPPED panel emitted two elbow rows in a
389
389
  // row meaning entirely different things. The rule reads as an edge,
390
390
  // and the cut notice above it reads as a notice.
391
- rows.push(`${p.dim}${"\u254c".repeat(Math.max(0, W))}${p.reset}`);
391
+ rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
392
392
  return { rows, ...layout };
393
393
  }
394
394
  /**
@@ -493,7 +493,7 @@ export function panelAffordance(view, phase, cursor, safer) {
493
493
  export function pickBlockRows(view, state, W, maxRows) {
494
494
  const p = palette();
495
495
  const spec = view.pick;
496
- const rows = [`${p.dim}${"\u254c".repeat(Math.max(0, W))}${p.reset}`]; // R2: the same rule the composer and the other panels use
496
+ const rows = [`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`]; // R2: the same rule the composer and the other panels use
497
497
  const room = Math.max(1, W - 2);
498
498
  rows.push(` ${cutLine(`${p.bold}${escapeTerminal(spec.header.split(" \u2014 ")[0] ?? spec.header)}${p.reset}${p.dim}${escapeTerminal(spec.header.slice((spec.header.split(" \u2014 ")[0] ?? "").length))}${p.reset}`, room)}`);
499
499
  if (spec.options.length === 0) {
@@ -546,7 +546,7 @@ export function pickBlockRows(view, state, W, maxRows) {
546
546
  const tText = cutLine(`${typing ? p.bold : ""}${typing ? "\u2192" : " "} t ${p.reset}${p.dim}${escapeTerminal(spec.typeHint)}${p.reset}`, room);
547
547
  rows.push(typing ? selectionBar(tText, visibleWidth(tText), W) : ` ${tText}`);
548
548
  rows.push(` ${p.dim}${cutLine(pickAffordance(state), room)}${p.reset}`);
549
- rows.push(`${p.dim}${"\u254c".repeat(Math.max(0, W))}${p.reset}`);
549
+ rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
550
550
  return rows;
551
551
  }
552
552
  /** The digits are the keys, so the list the panel offers is bounded by
@@ -293,6 +293,18 @@ export declare function exploreRows(parts: readonly {
293
293
  * metadata terms survive (the W14 metadata rule — they give way LAST),
294
294
  * the words width-cut at the end with the honest "…" (never a silent
295
295
  * truncate — invariant ① holds on the ONE row by construction). */
296
+ /**
297
+ * R3b — what a run of work DID, in words. One definition, because two
298
+ * surfaces say it: the fold line (`turnFold`, above) and the expand
299
+ * header the compositor writes when that fold is opened. A second copy
300
+ * would be a second answer to the same question, and the first thing to
301
+ * drift would be the plurals — `search_text` is "matches", not
302
+ * "searchs", and only the ROLLUP_NOUN table knows that.
303
+ *
304
+ * Zero terms are dropped (owner ruling, R3b): a term earns its place by
305
+ * having a count.
306
+ */
307
+ export declare function foldTerms(reads: number, edits: number, others: readonly [string, number][]): string[];
296
308
  export declare function turnFold(t: {
297
309
  words: string;
298
310
  thoughtSeconds: number;
@@ -378,14 +390,19 @@ export declare function selectionBar(styled: string, visible: number, W: number)
378
390
  /**
379
391
  * R2 — the composer's rails, and the ONE edge vocabulary.
380
392
  *
393
+ * R3 (owner, 2026-08-27): the rule is a SOLID hairline (`\u2500`), not
394
+ * the dashed `\u254c` R2 shipped, and it is solid EVERYWHERE — the
395
+ * composer, every panel's open and close, the band headers and the
396
+ * markdown rule. One line, one weight, no exceptions to remember.
397
+ *
381
398
  * W6 turned two \u254c dotted rows into a rounded box, reasoning that
382
399
  * "the box already says input lives here". That is reversed here, and
383
400
  * the reason is not taste: a rule is a DELIMITER and a box is a
384
401
  * CONTAINER, and the screen was carrying six edge vocabularies at once
385
402
  * (this box, the panel's \u2502 gutter and \u2514\u2500\u2500 tail, the
386
403
  * diff gutter, the quote's \u258f, the table's rails, the markdown
387
- * rule). One dashed rule replaces the ones that SEPARATE; the \u2502
388
- * gutter survives where it SCOPES.
404
+ * rule). ONE rule replaces the ones that SEPARATE; the \u2502 gutter
405
+ * survives where it SCOPES.
389
406
  *
390
407
  * Row-neutral by construction: CHROME_ROWS is still 4, so every gate
391
408
  * keyed on H \u2212 4 is untouched, and the input row gains the two
@@ -22,7 +22,7 @@ import { displayWidth, visibleWidth } from "./width.js";
22
22
  // KEY_BINDINGS). strings.js imports only render/width here, so this edge
23
23
  // adds no cycle.
24
24
  import { displayVerb } from "./strings.js";
25
- import { bannerLines, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
25
+ import { bannerLines, breathFrame, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
26
26
  // TUI2-MD: the markdown renderer's surface reaches the tui through this
27
27
  // module (the tui's components shim re-exports it) — one import edge,
28
28
  // and it points one way: md.ts measures with the width authority, never
@@ -657,7 +657,14 @@ class ToolExecution {
657
657
  // leaves; the duration then rides the row, always legible.
658
658
  const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
659
659
  const dur = ` · ${elapsed}s`;
660
- const out = gutterCut(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
660
+ // R3 (design §5.2): a running command BREATHES one glyph, seven
661
+ // greys, bottoming out on the ground's dim token (§2.2 applies
662
+ // mid-animation, not just at rest). The quadrant spinner it
663
+ // replaces ROTATED, which §5.3 forbids for a call whose duration
664
+ // cannot be predicted: a turning mark implies progress the
665
+ // product does not have. With no ground the breath freezes to a
666
+ // static `●` and says the same thing more quietly.
667
+ const out = gutterCut(`${breathFrame(ctx.spinnerI)} `, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
661
668
  out[0] = `${out[0]}${p.dim}${dur}${p.reset}`;
662
669
  out.push(...toolBlockBody(c, W));
663
670
  return out;
@@ -953,23 +960,56 @@ function countTerm(n, singular, plural) {
953
960
  * metadata terms survive (the W14 metadata rule — they give way LAST),
954
961
  * the words width-cut at the end with the honest "…" (never a silent
955
962
  * truncate — invariant ① holds on the ONE row by construction). */
956
- export function turnFold(t, W) {
957
- const p = palette();
958
- const parts = [`thought ${t.thoughtSeconds}s`, countTerm(t.reads, "read", "reads"), countTerm(t.edits, "edit", "edits")];
959
- for (const [name, n] of t.others) {
963
+ /**
964
+ * R3b — what a run of work DID, in words. One definition, because two
965
+ * surfaces say it: the fold line (`turnFold`, above) and the expand
966
+ * header the compositor writes when that fold is opened. A second copy
967
+ * would be a second answer to the same question, and the first thing to
968
+ * drift would be the plurals — `search_text` is "matches", not
969
+ * "searchs", and only the ROLLUP_NOUN table knows that.
970
+ *
971
+ * Zero terms are dropped (owner ruling, R3b): a term earns its place by
972
+ * having a count.
973
+ */
974
+ export function foldTerms(reads, edits, others) {
975
+ const parts = [];
976
+ if (reads > 0)
977
+ parts.push(countTerm(reads, "read", "reads"));
978
+ if (edits > 0)
979
+ parts.push(countTerm(edits, "edit", "edits"));
980
+ for (const [name, n] of others) {
981
+ if (n === 0)
982
+ continue;
960
983
  const noun = ROLLUP_NOUN[name];
961
- if (noun !== undefined) {
984
+ if (noun !== undefined)
962
985
  parts.push(countTerm(n, noun.endsWith("es") ? noun.slice(0, -2) : noun.slice(0, -1), noun));
963
- }
964
986
  else {
965
987
  const verb = displayVerb(name);
966
988
  parts.push(countTerm(n, verb, `${verb}s`));
967
989
  }
968
990
  }
969
- const meta = parts.join(" · ");
991
+ return parts;
992
+ }
993
+ export function turnFold(t, W) {
994
+ const p = palette();
995
+ // R3b (owner, 2026-08-27): ZERO TERMS ARE DROPPED. W14 always wrote
996
+ // `no reads · no edits`, which is a sentence about things that did not
997
+ // happen — on a segment fold, where a run is usually all reads or all
998
+ // edits, half the row was the half that said nothing. A term earns its
999
+ // place by having a count.
1000
+ const meta = [`thought ${t.thoughtSeconds}s`, ...foldTerms(t.reads, t.edits, t.others)].join(" · ");
970
1001
  const words = escapeTerminal(t.words);
971
1002
  if (words === "") {
972
1003
  const row = `${p.bold}✦${p.reset} ${meta}`;
1004
+ // R3b: the fold NAMES ITS OWN KEY. Without it the segment's work is
1005
+ // unreachable — no rollup row, no expand, nothing — and hiding a
1006
+ // tool call behind a line with no way back is the one thing this
1007
+ // product must not do. The suffix gives way first at a narrow
1008
+ // width, on the same principle the settled card's does: an
1009
+ // affordance that does not fit is dropped, never half-drawn.
1010
+ const keyed = `${row}${p.dim} · ctrl+r${p.reset}`;
1011
+ if (visibleWidth(keyed) <= W)
1012
+ return [keyed];
973
1013
  return visibleWidth(row) <= W ? [row] : [`${p.bold}✦${p.reset} ${widthCut(meta, Math.max(1, W - 3))}…`]; // a wordless turn folds to the W14 shape
974
1014
  }
975
1015
  // A9 (ruling R2, mock A): the user chip rides the fold — the human's
@@ -1547,26 +1587,36 @@ export function selectionBar(styled, visible, W) {
1547
1587
  /**
1548
1588
  * R2 — the composer's rails, and the ONE edge vocabulary.
1549
1589
  *
1590
+ * R3 (owner, 2026-08-27): the rule is a SOLID hairline (`\u2500`), not
1591
+ * the dashed `\u254c` R2 shipped, and it is solid EVERYWHERE — the
1592
+ * composer, every panel's open and close, the band headers and the
1593
+ * markdown rule. One line, one weight, no exceptions to remember.
1594
+ *
1550
1595
  * W6 turned two \u254c dotted rows into a rounded box, reasoning that
1551
1596
  * "the box already says input lives here". That is reversed here, and
1552
1597
  * the reason is not taste: a rule is a DELIMITER and a box is a
1553
1598
  * CONTAINER, and the screen was carrying six edge vocabularies at once
1554
1599
  * (this box, the panel's \u2502 gutter and \u2514\u2500\u2500 tail, the
1555
1600
  * diff gutter, the quote's \u258f, the table's rails, the markdown
1556
- * rule). One dashed rule replaces the ones that SEPARATE; the \u2502
1557
- * gutter survives where it SCOPES.
1601
+ * rule). ONE rule replaces the ones that SEPARATE; the \u2502 gutter
1602
+ * survives where it SCOPES.
1558
1603
  *
1559
1604
  * Row-neutral by construction: CHROME_ROWS is still 4, so every gate
1560
1605
  * keyed on H \u2212 4 is untouched, and the input row gains the two
1561
1606
  * columns the walls were taking.
1562
1607
  */
1563
1608
  export function boxTop(W) {
1564
- return `\x1b[2m${"\u254c".repeat(Math.max(0, W))}\x1b[0m`;
1609
+ // R3: the palette's dim, not a hardcoded SGR 2 — `dim` is an absolute
1610
+ // grey once the ground is known, and a rail that hardcodes the
1611
+ // attribute would be the one chrome row not obeying the table.
1612
+ const p = palette();
1613
+ return `${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`;
1565
1614
  }
1566
1615
  /** R2 — the same rule below. Named for its POSITION, not its shape, so
1567
1616
  * the compositor's two call sites did not have to move. */
1568
1617
  export function boxBottom(W) {
1569
- return `\x1b[2m${"\u254c".repeat(Math.max(0, W))}\x1b[0m`;
1618
+ const p = palette();
1619
+ return `${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`;
1570
1620
  }
1571
1621
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
1572
1622
  * exact render the passthrough needs). */
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * package); the cli never imports it directly. Experimental — no
7
7
  * API-stability promise yet.
8
8
  */
9
- export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, focusToken, ROLLUP_NOUN, turnFold, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, type FrameCtx, type RenderLine, type Component, type BodyCell, } from "./components.js";
9
+ export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, focusToken, ROLLUP_NOUN, turnFold, foldTerms, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, type FrameCtx, type RenderLine, type Component, type BodyCell, } from "./components.js";
10
10
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
11
11
  export { pendingQueueRows } from "./components.js";
12
12
  export { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
@@ -14,7 +14,7 @@ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWi
14
14
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact, } from "./strings.js";
15
15
  export { extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
16
16
  export { displayVerb } from "./strings.js";
17
- export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type ResumeMeta, } from "./render.js";
17
+ export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type ResumeMeta, } from "./render.js";
18
18
  /** DC-3 — the ground: is the terminal light or dark. Pure; see the
19
19
  * module comment for why `unknown` is a result and not a failure. */
20
20
  export { groundFrom, parseOscColor, relativeLuminance, resolveGround, type Ground, type GroundInputs, type Rgb } from "./ground.js";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * package); the cli never imports it directly. Experimental — no
7
7
  * API-stability promise yet.
8
8
  */
9
- export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, focusToken, ROLLUP_NOUN, turnFold, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, } from "./components.js";
9
+ export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, focusToken, ROLLUP_NOUN, turnFold, foldTerms, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, } from "./components.js";
10
10
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
11
11
  // W22 (the v8 input round): the pending-queue chips — the SAME
12
12
  // UserMessage chip with the □ gutter, pre-rendered above the input
@@ -28,7 +28,7 @@ export { extensionsBannerText, helpRows, unansweredAskView } from "./strings.js"
28
28
  // TUI2-R2pre ④: the ONE display-verb table — the screen names the act,
29
29
  // the tool table names the call.
30
30
  export { displayVerb } from "./strings.js";
31
- export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
31
+ export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
32
32
  /** DC-3 — the ground: is the terminal light or dark. Pure; see the
33
33
  * module comment for why `unknown` is a result and not a failure. */
34
34
  export { groundFrom, parseOscColor, relativeLuminance, resolveGround } from "./ground.js";
package/dist/md.js CHANGED
@@ -258,7 +258,7 @@ function blockBody(b, W) {
258
258
  // R2: the dashed rule, at the block's own width. The 28 was a
259
259
  // guess that read as a short line rather than a divider, and ─
260
260
  // belonged to the box vocabulary this round is collapsing.
261
- return [`${p.dim}${"\u254c".repeat(Math.max(1, W))}${p.reset}`];
261
+ return [`${p.dim}${"\u2500".repeat(Math.max(1, W))}${p.reset}`];
262
262
  case "fence-open":
263
263
  // E2: the RAIL, not a gutter. A block drawn with ``` is still a
264
264
  // fenced block when a human selects it and pastes it somewhere
package/dist/render.d.ts CHANGED
@@ -82,11 +82,22 @@ export interface Palette {
82
82
  /**
83
83
  * DC-3 — one table per ground.
84
84
  *
85
- * `dim` is the same in all three ON tables and that is the point: SGR 2
86
- * is an ATTRIBUTE, it dims whatever the terminal's own foreground is, so
87
- * it adapts to the ground instead of asserting one. Only the background
88
- * genuinely needs to know, which is why `wash` is the only member that
89
- * varies.
85
+ * R3 (owner, 2026-08-27) — `dim` is ABSOLUTE once the ground is known,
86
+ * and design.md §2's table always said so: light `243` `#767676` at
87
+ * 4.54:1, dark `246` `#949494` at 5.50:1 (both re-measured here).
88
+ *
89
+ * DC-3 shipped SGR 2 instead, on the argument that an attribute adapts
90
+ * to the ground while an absolute grey asserts one. That argument is
91
+ * right about what SGR 2 IS and wrong about what it MEASURES: a
92
+ * terminal renders it as a fraction of its own foreground, and on Apple
93
+ * Terminal's light profile that lands well under the 4.5:1 floor — the
94
+ * labels, the keys row and the status row were all reported unreadable
95
+ * in real use. An attribute that adapts to an unknown ratio is not a
96
+ * contrast guarantee; the table's measured value is.
97
+ *
98
+ * The UNKNOWN ground keeps SGR 2, because §3.1 forbids an absolute
99
+ * foreground in a palette that has not established a background — the
100
+ * attribute is exactly the "correct on any ground" degradation there.
90
101
  */
91
102
  export declare const COLOR_NEUTRAL: Palette;
92
103
  export declare const COLOR_LIGHT: Palette;
@@ -141,6 +152,42 @@ export declare function kUnit(value: number | null): string;
141
152
  * this verbatim; the render tests pin the sequence.
142
153
  */
143
154
  export declare function renderTerminalGap(statusLine: string | null): string;
155
+ /**
156
+ * v3 §01 (V6-2) — the banner, block-split. The logo is THREE BRICK rows
157
+ * (the logo.svg pixel form — K I S O), then a BLANK, then the info rows:
158
+ * "kiso vX — tagline" + extensions. The tagline rides the version line
159
+ * (the old logo MIDDLE row was the tagline — a text row masquerading as
160
+ * the logo's centre). Every row truncates at the terminal width with a
161
+ * " (+N)" marker (N = the hidden display width); a window narrower than
162
+ * 40 columns skips the logo + the blank entirely — only the info rows.
163
+ * Pure.
164
+ */
165
+ /**
166
+ * design.md §5.2 — THE TWO CYCLES, built. Seven frames each, walked at
167
+ * the existing 200ms spinner cadence, so a waiting screen's byte volume
168
+ * and frame rate are exactly what they were.
169
+ *
170
+ * §5.3 is why neither rotates: "a breath says alive; a turn says
171
+ * counting". A call whose duration cannot be predicted must not wear a
172
+ * mark that implies progress it does not have.
173
+ */
174
+ /** The THINKING twinkle — glyphs only, no colour at all, so it survives
175
+ * NO_COLOR and any ground intact. §4.1: it settles onto `✦`, which is
176
+ * the same mark the collapsed segment keeps, so nothing new appears at
177
+ * the transition. Every glyph is in Menlo and absent from Apple Color
178
+ * Emoji (§6.1's test, run). */
179
+ export declare const TWINKLE: readonly ["✧", "✦", "✶", "✸", "✺", "✸", "✦"];
180
+ /** The breath's frame: `●` at the step's grey, for the CURRENT ground.
181
+ * With no ground — or under NO_COLOR — it freezes to a static `●`,
182
+ * because a brightness ramp needs a background to be a ramp against and
183
+ * §3.1 forbids guessing one. The glyph never changes, so the freeze
184
+ * degrades the motion and never the meaning. */
185
+ export declare function breathFrame(step: number): string;
186
+ /** The twinkle's frame — pure glyph, no palette involved. */
187
+ export declare function twinkleFrame(step: number): string;
188
+ /** Both cycles are seven frames, so ONE counter walks them and the two
189
+ * marks stay in step on a screen showing both. */
190
+ export declare const MOTION_FRAMES = 7;
144
191
  export declare const TAGLINE = "the coding agent that survives kill -9";
145
192
  /** R2 — what the opening knows about the session. Optional because the
146
193
  * off-TTY caller prints a banner before a model is bound. */
package/dist/render.js CHANGED
@@ -28,19 +28,30 @@ const BASE = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32
28
28
  * 4's principle exactly: when the ground is unknown, use the thing that
29
29
  * is correct on any ground rather than guessing one.
30
30
  */
31
- const withWash = (wash, washEnd, red = BASE.red) => ({ ...BASE, red, wash, washEnd, code: wash });
31
+ const withWash = (wash, washEnd, red = BASE.red, dim = BASE.dim) => ({ ...BASE, red, dim, wash, washEnd, code: wash });
32
32
  /**
33
33
  * DC-3 — one table per ground.
34
34
  *
35
- * `dim` is the same in all three ON tables and that is the point: SGR 2
36
- * is an ATTRIBUTE, it dims whatever the terminal's own foreground is, so
37
- * it adapts to the ground instead of asserting one. Only the background
38
- * genuinely needs to know, which is why `wash` is the only member that
39
- * varies.
35
+ * R3 (owner, 2026-08-27) — `dim` is ABSOLUTE once the ground is known,
36
+ * and design.md §2's table always said so: light `243` `#767676` at
37
+ * 4.54:1, dark `246` `#949494` at 5.50:1 (both re-measured here).
38
+ *
39
+ * DC-3 shipped SGR 2 instead, on the argument that an attribute adapts
40
+ * to the ground while an absolute grey asserts one. That argument is
41
+ * right about what SGR 2 IS and wrong about what it MEASURES: a
42
+ * terminal renders it as a fraction of its own foreground, and on Apple
43
+ * Terminal's light profile that lands well under the 4.5:1 floor — the
44
+ * labels, the keys row and the status row were all reported unreadable
45
+ * in real use. An attribute that adapts to an unknown ratio is not a
46
+ * contrast guarantee; the table's measured value is.
47
+ *
48
+ * The UNKNOWN ground keeps SGR 2, because §3.1 forbids an absolute
49
+ * foreground in a palette that has not established a background — the
50
+ * attribute is exactly the "correct on any ground" degradation there.
40
51
  */
41
52
  export const COLOR_NEUTRAL = withWash("\x1b[7m", "\x1b[27m");
42
- export const COLOR_LIGHT = withWash("\x1b[48;5;255m", "\x1b[49m", "\x1b[38;5;124m");
43
- export const COLOR_DARK = withWash("\x1b[48;5;236m", "\x1b[49m", "\x1b[38;5;173m");
53
+ export const COLOR_LIGHT = withWash("\x1b[48;5;255m", "\x1b[49m", "\x1b[38;5;124m", "\x1b[38;5;243m");
54
+ export const COLOR_DARK = withWash("\x1b[48;5;236m", "\x1b[49m", "\x1b[38;5;173m", "\x1b[38;5;246m");
44
55
  /** The historical name — the palette for a colour TTY whose ground has
45
56
  * not been established. Unchanged in every byte except `code`, which
46
57
  * was the defect. */
@@ -211,6 +222,46 @@ export function renderTerminalGap(statusLine) {
211
222
  * 40 columns skips the logo + the blank entirely — only the info rows.
212
223
  * Pure.
213
224
  */
225
+ /**
226
+ * design.md §5.2 — THE TWO CYCLES, built. Seven frames each, walked at
227
+ * the existing 200ms spinner cadence, so a waiting screen's byte volume
228
+ * and frame rate are exactly what they were.
229
+ *
230
+ * §5.3 is why neither rotates: "a breath says alive; a turn says
231
+ * counting". A call whose duration cannot be predicted must not wear a
232
+ * mark that implies progress it does not have.
233
+ */
234
+ /** The THINKING twinkle — glyphs only, no colour at all, so it survives
235
+ * NO_COLOR and any ground intact. §4.1: it settles onto `✦`, which is
236
+ * the same mark the collapsed segment keeps, so nothing new appears at
237
+ * the transition. Every glyph is in Menlo and absent from Apple Color
238
+ * Emoji (§6.1's test, run). */
239
+ export const TWINKLE = ["\u2727", "\u2726", "\u2736", "\u2738", "\u273a", "\u2738", "\u2726"];
240
+ /** The COMMAND breath — brightness only, one glyph. The ramps bottom out
241
+ * EXACTLY on the ground's dim token (§2.2: "the floor is a floor,
242
+ * including mid-animation"): light ends at 243 (4.54:1 on white), dark
243
+ * at 246 (5.50:1 on #1e1e1e). Measured, not assumed. */
244
+ const BREATH_LIGHT = [232, 236, 240, 243, 240, 236, 232];
245
+ const BREATH_DARK = [255, 251, 248, 246, 248, 251, 255];
246
+ /** The breath's frame: `●` at the step's grey, for the CURRENT ground.
247
+ * With no ground — or under NO_COLOR — it freezes to a static `●`,
248
+ * because a brightness ramp needs a background to be a ramp against and
249
+ * §3.1 forbids guessing one. The glyph never changes, so the freeze
250
+ * degrades the motion and never the meaning. */
251
+ export function breathFrame(step) {
252
+ const p = palette();
253
+ const ramp = currentGround() === "light" ? BREATH_LIGHT : currentGround() === "dark" ? BREATH_DARK : null;
254
+ if (ramp === null || p.bold === "")
255
+ return "\u25cf";
256
+ return `\x1b[38;5;${ramp[step % ramp.length]}m\u25cf${p.reset}`;
257
+ }
258
+ /** The twinkle's frame — pure glyph, no palette involved. */
259
+ export function twinkleFrame(step) {
260
+ return TWINKLE[step % TWINKLE.length];
261
+ }
262
+ /** Both cycles are seven frames, so ONE counter walks them and the two
263
+ * marks stay in step on a screen showing both. */
264
+ export const MOTION_FRAMES = 7;
214
265
  /** DC-18: the display-width prefix of PLAIN text. `widthCut` lives in
215
266
  * components.ts, which imports this module — the dependency runs one
216
267
  * way, so the four lines live here rather than inverting it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui-cells",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
4
4
  "description": "kiso tui-cells — the components cell renderer (components, diff, width, the render slice). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",