@vincemakes/kiso-tui-cells 0.33.0 → 0.34.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.
Files changed (3) hide show
  1. package/dist/md.d.ts +3 -2
  2. package/dist/md.js +334 -52
  3. package/package.json +1 -1
package/dist/md.d.ts CHANGED
@@ -37,8 +37,9 @@
37
37
  * described twice.
38
38
  */
39
39
  /** The block kinds. `fence-open`/`fence-line` are separate kinds on
40
- * purpose: a fence's rows must be able to freeze ONE AT A TIME. */
41
- export type MdKind = "para" | "heading" | "list" | "table" | "quote" | "rule" | "fence-open" | "fence-line" | "fence-close";
40
+ * purpose: a fence's rows must be able to freeze ONE AT A TIME, and
41
+ * MD-1.4's `code-line` is the same shape for the same reason. */
42
+ export type MdKind = "para" | "heading" | "list" | "table" | "quote" | "rule" | "fence-open" | "fence-line" | "fence-close" | "code-line";
42
43
  /** One block: its SOURCE lines, never a rendered form. The render is a
43
44
  * pure function of (block, width), which is what makes the freeze
44
45
  * property a property of the scanner alone. */
package/dist/md.js CHANGED
@@ -52,6 +52,46 @@ const RULE = /^ {0,3}(?:-{3,}|\*{3,}|_{3,}) *$/;
52
52
  const QUOTE = /^ {0,3}> ?(.*)$/;
53
53
  const TABLE = /^ {0,3}\|/;
54
54
  const ITEM = /^( *)([-*+]|\d{1,9}[.)])[ \t]+(.*)$/;
55
+ /** MD-1.4 — a SETEXT underline. Only meaningful under an OPEN paragraph,
56
+ * which is the only place `#line` consults it. Before this, `=` was not a
57
+ * construct at all, so it leaked into the paragraph's own text, and a `-`
58
+ * underline classified as a RULE, so the heading's text became a
59
+ * paragraph with a full-width divider under it. Both outputs were
60
+ * CORRUPTED, not merely unstyled, and a guess printed into scrollback is
61
+ * indistinguishable from a fact. */
62
+ const SETEXT = /^ {0,3}(=+|-+) *$/;
63
+ /** MD-1.4 — an indented code block's line. Four columns, the markdown
64
+ * marker; tested AFTER `ITEM`, so a nested list written with four spaces
65
+ * stays a list. That is a deliberate deviation, and the reason is
66
+ * frequency: a four-space nested list is far more common in model prose
67
+ * than an indented code block that opens with a list marker. A TAB indent
68
+ * is not recognised here (it stays prose) — stated rather than implied.
69
+ *
70
+ * MD1-F4 — and the indent alone is NOT enough: a line that would classify
71
+ * here is PROSE when the most recent block is a `list`. For `1. ` the
72
+ * content indent is three columns, so a line indented four after a blank
73
+ * is the item's continuation paragraph and code inside that item would
74
+ * need seven. Models write that shape constantly — numbered steps, each
75
+ * with an explanatory paragraph under it — and rendering it verbatim
76
+ * destroyed the reflow and kept a four-space indent. `code-line` is
77
+ * therefore reached only after a paragraph, heading, rule, quote, table
78
+ * or fence, or at the start of a message.
79
+ *
80
+ * MD1-F4b — and the context OUTLIVES the paragraph it demoted. A demoted
81
+ * block is the item's continuation, so closing one leaves the context as
82
+ * `list` and the SECOND and third indented paragraphs under one item are
83
+ * prose as well. An earlier version of this rule consulted only the most
84
+ * recent block, and a numbered step with two paragraphs under it rendered
85
+ * the second one verbatim — the same defect one paragraph later. The
86
+ * context ends where it should: at the first unindented block, or at a
87
+ * heading, rule, quote, table or fence, each of which names its own kind.
88
+ *
89
+ * Two consequences, stated rather than discovered: an indented code block
90
+ * nested INSIDE a list item is not recognised (it renders as the item's
91
+ * paragraph), and blank lines inside an indented block collapse to ONE gap
92
+ * row, exactly as they do between paragraphs — two blank lines in a code
93
+ * block come back as one. */
94
+ const CODE = /^ {4,}/;
55
95
  /** The kind a line would START. null = blank (a block separator). */
56
96
  function classify(line) {
57
97
  if (line.trim() === "")
@@ -68,6 +108,8 @@ function classify(line) {
68
108
  return "table";
69
109
  if (ITEM.test(line))
70
110
  return "list";
111
+ if (CODE.test(line))
112
+ return "code-line";
71
113
  return "para";
72
114
  }
73
115
  /** Can `line` JOIN an open block of this kind? Headings, rules and the
@@ -78,13 +120,16 @@ function joins(kind, line) {
78
120
  return false; // a blank closes everything
79
121
  switch (kind) {
80
122
  case "para":
81
- return c === "para";
123
+ // MD-1.4: an indented line CONTINUES a paragraph rather than opening
124
+ // a code block — indented code cannot interrupt a paragraph, so a
125
+ // `code-line` only ever starts one after a blank.
126
+ return c === "para" || c === "code-line";
82
127
  case "list":
83
128
  // a list item's continuation must be INDENTED — an unindented
84
129
  // paragraph after a list starts a paragraph (the lazy-continuation
85
130
  // rule is a documented deviation: predictable beats compliant when
86
131
  // the output is committed).
87
- return c === "list" || (c === "para" && /^[ \t]/.test(line));
132
+ return c === "list" || ((c === "para" || c === "code-line") && /^[ \t]/.test(line));
88
133
  case "table":
89
134
  return c === "table";
90
135
  case "quote":
@@ -93,6 +138,16 @@ function joins(kind, line) {
93
138
  return false;
94
139
  }
95
140
  }
141
+ /** A heading's LEVEL and TEXT, ATX or setext. A setext block's lines are
142
+ * the paragraph's own with the underline last — the SOURCE, never a
143
+ * rewritten ATX form, so the block still says what the model sent. */
144
+ function headingShape(lines) {
145
+ const last = lines[lines.length - 1] ?? "";
146
+ if (lines.length > 1 && SETEXT.test(last))
147
+ return { level: last.trimStart().startsWith("=") ? 1 : 2, text: lines.slice(0, -1).join(" ") };
148
+ const m = HEADING.exec(lines[0] ?? "");
149
+ return { level: (m?.[1] ?? "#").length, text: m?.[2] ?? lines[0] ?? "" };
150
+ }
96
151
  /** The fence marker a line opens with (``` or ~~~, 3+). */
97
152
  function fenceMark(line) {
98
153
  return FENCE.exec(line)?.[1] ?? "```";
@@ -121,6 +176,18 @@ export class MdStream {
121
176
  /** blocks STARTED so far — the gap rule's only input (the first block
122
177
  * of a message opens tight; every later one carries its own blank). */
123
178
  #started = 0;
179
+ /** MD-1.4 / MD1-F4 — the kind of the most recent BLOCK, which is what
180
+ * decides whether an indented line is code or prose. Kept HERE rather
181
+ * than read back off `#closed`: the tail block is not in `#closed` at
182
+ * all, and `blocks()` hands out fresh objects. It is only ever read
183
+ * while `#open` is null, so "most recent" is never ambiguous. */
184
+ #lastKind = null;
185
+ /** MD-1.4 — was the last COMPLETE line an indented code line? Each such
186
+ * line is its own block (line-local, so a long indented block streams
187
+ * through the live region exactly as a fence body does), which means the
188
+ * gap rule needs to know that the block before it was the same block:
189
+ * the FIRST line of a block carries the blank, the rest do not. */
190
+ #code = false;
124
191
  /** Append streamed text. Only COMPLETE lines reach the state machine. */
125
192
  push(text) {
126
193
  // the renderer consumes already-scrubbed text and never re-introduces
@@ -157,16 +224,30 @@ export class MdStream {
157
224
  out.push(p === "" ? frozen(this.#open) : frozen(this.#open, p));
158
225
  return out;
159
226
  }
160
- const k = classify(p);
227
+ const { kind: k } = this.#kindOf(p);
161
228
  if (k !== null)
162
- out.push({ kind: k, lines: [p], gap: this.#started > 0, lang: k === "fence-open" ? fenceLang(p) : "" });
229
+ out.push({ kind: k, lines: [p], gap: this.#started > 0 && !(k === "code-line" && this.#code), lang: k === "fence-open" ? fenceLang(p) : "" });
163
230
  return out;
164
231
  }
232
+ /** MD1-F4 — the kind a line starts HERE: `classify` plus the one piece of
233
+ * context that decides code from prose, an indented line after a list
234
+ * being the list item's continuation paragraph. `demoted` says the
235
+ * answer CAME from that context, which is what the open block is marked
236
+ * with so the context can outlive it (MD1-F4b). */
237
+ #kindOf(line) {
238
+ const k = classify(line);
239
+ const demoted = k === "code-line" && this.#lastKind === "list";
240
+ return { kind: demoted ? "para" : k, demoted };
241
+ }
165
242
  /** How many leading blocks are CLOSED — the commit-eligible count. */
166
243
  closed() {
167
244
  return this.#closed.length;
168
245
  }
169
246
  #line(line) {
247
+ // MD-1.4: `#code` describes the PREVIOUS complete line, so it is read
248
+ // and cleared here and set again only on the code-line path.
249
+ const wasCode = this.#code;
250
+ this.#code = false;
170
251
  if (this.#fence !== null) {
171
252
  // E2: the closer emits its OWN block now. The rule it used to
172
253
  // obey — "a bottom border is drawn only by an actual close, and
@@ -183,7 +264,31 @@ export class MdStream {
183
264
  this.#push({ kind: "fence-line", lines: [line], gap: false, lang: "" });
184
265
  return;
185
266
  }
186
- const k = classify(line);
267
+ // MD-1.4 — a SETEXT underline closes the OPEN paragraph as a HEADING.
268
+ // The decision is taken on the COMPLETE underline line, which is the
269
+ // freeze rule itself: the paragraph has not closed yet when the
270
+ // underline arrives, so no committed row changes. This is also the
271
+ // whole of the `rule` interaction — a `-` underline outranks RULE
272
+ // exactly where a paragraph is open, which is the only condition under
273
+ // which it could be an underline at all. The block keeps its SOURCE
274
+ // lines, underline last; `headingShape` reads the level off it.
275
+ //
276
+ // One interaction worth naming rather than leaving to be discovered:
277
+ // the live cell holding an OPEN paragraph is eligible for the
278
+ // compositor's force-commit (`#capLive` excludes only a RUNNING tool
279
+ // card), so on a short terminal a long paragraph can reach scrollback
280
+ // before its underline arrives — and the promotion then applies to
281
+ // rows that are already committed and plain. Pre-existing class (the
282
+ // report's §4.9 escalated risk, FINDING TUI2-MD-1's neighbourhood);
283
+ // what is new here is that the flip it misses is a STYLE flip. Not
284
+ // reproduced in this round.
285
+ if (this.#open !== null && this.#open.kind === "para" && SETEXT.test(line)) {
286
+ this.#closed.push({ kind: "heading", lines: [...this.#open.lines, line], gap: this.#open.gap, lang: "" });
287
+ this.#open = null;
288
+ this.#lastKind = "heading";
289
+ return;
290
+ }
291
+ const { kind: k, demoted } = this.#kindOf(line);
187
292
  if (k === null) {
188
293
  this.#shut();
189
294
  return;
@@ -202,18 +307,32 @@ export class MdStream {
202
307
  this.#push({ kind: k, lines: [line], gap: this.#started > 0, lang: "" });
203
308
  return;
204
309
  }
205
- this.#open = { kind: k, lines: [line], gap: this.#started > 0, lang: "" };
310
+ if (k === "code-line") {
311
+ // line-local like a fence body: final the moment its newline lands.
312
+ // Only the FIRST line of the block carries the blank above it.
313
+ this.#push({ kind: k, lines: [line], gap: this.#started > 0 && !wasCode, lang: "" });
314
+ this.#code = true;
315
+ return;
316
+ }
317
+ this.#open = { kind: k, lines: [line], gap: this.#started > 0, lang: "", cont: demoted };
206
318
  this.#started += 1;
207
319
  }
208
320
  /** A block that is final the moment its line is. */
209
321
  #push(b) {
210
322
  this.#closed.push(b);
211
323
  this.#started += 1;
324
+ this.#lastKind = b.kind;
212
325
  }
213
326
  #shut() {
214
327
  if (this.#open === null)
215
328
  return;
216
329
  this.#closed.push(frozen(this.#open));
330
+ // MD1-F4b — a DEMOTED block is the list item's continuation, so the
331
+ // list context outlives it: the second and third indented paragraphs
332
+ // under one item are prose too. The context ends where it should, at
333
+ // the first unindented block or at a heading, rule, quote, table or
334
+ // fence, because those set `#lastKind` to their own kind.
335
+ this.#lastKind = this.#open.cont ? "list" : this.#open.kind;
217
336
  this.#open = null;
218
337
  }
219
338
  }
@@ -232,10 +351,22 @@ export function renderMarkdown(text, W) {
232
351
  /** One block's screen rows. Pure in (block, W) — this is the whole
233
352
  * freeze guarantee: same source, same width, same bytes, forever. */
234
353
  export function renderBlock(b, W) {
235
- const rows = blockBody(b, Math.max(1, W));
236
- return b.gap ? ["", ...rows] : rows;
354
+ return withGap(b, blockBody(b, Math.max(1, W), 0));
237
355
  }
238
- function blockBody(b, W) {
356
+ /** The markdown rhythm: a block that carries `gap` opens with a blank row.
357
+ * One helper because there are two callers — this module's entry point and
358
+ * the nested render inside a quote, which has to reproduce the rhythm of
359
+ * the blocks it contains. */
360
+ function withGap(b, rows) {
361
+ return b.gap ? ["", ...rows] : [...rows];
362
+ }
363
+ /** MD-1.5 — how deep a quote may nest before it stops being rendered as
364
+ * blocks. A `>>>>>` quote in a narrow terminal spends two columns per
365
+ * level, and past this it degrades to the flattened form rather than
366
+ * recursing on nothing. Cheap insurance: the wrapper already degrades an
367
+ * over-wide prefix instead of throwing. */
368
+ const QUOTE_DEPTH = 4;
369
+ function blockBody(b, W, depth) {
239
370
  const p = palette();
240
371
  switch (b.kind) {
241
372
  case "heading": {
@@ -247,18 +378,31 @@ function blockBody(b, W) {
247
378
  // and a marker is the only carrier that survives a pipe. A
248
379
  // `**bold**` inside a heading is still a no-op, which is the mono
249
380
  // discipline paying for itself.
250
- const m = HEADING.exec(b.lines[0] ?? "");
251
- const level = (m?.[1] ?? "#").length;
252
- const text = m?.[2] ?? b.lines[0] ?? "";
381
+ const { level, text } = headingShape(b.lines);
253
382
  const style = level === 1 ? `${p.bold}${p.underline}` : p.bold;
254
383
  const marker = level >= 3 ? `${"#".repeat(level)} ` : "";
255
384
  return wrap(`${style}${marker}${inlineSpans(text, style)}${p.reset}`, W, "", "");
256
385
  }
257
- case "rule":
258
- // R2: the dashed rule, at the block's own width. The 28 was a
259
- // guess that read as a short line rather than a divider, and ─
260
- // belonged to the box vocabulary this round is collapsing.
261
- return [`${p.dim}${"\u2500".repeat(Math.max(1, W))}${p.reset}`];
386
+ case "rule": {
387
+ // R2: the rule at the block's own width. The 28 was a guess that
388
+ // read as a short line rather than a divider.
389
+ //
390
+ // MD-1.6 — and it takes the BLOCK INSET, two columns, like a fence
391
+ // body. Without it this row and `boxTop` emitted identical bytes —
392
+ // same glyph, same dim, same full width — so in scrollback you could
393
+ // not tell "the model drew a divider" from "kiso closed a panel".
394
+ // The fix is the inset and NOT a new glyph: R3 (owner, 2026-08-27)
395
+ // ruled the rule is a solid hairline everywhere, "one line, one
396
+ // weight, no exceptions to remember", and a new glyph would reverse
397
+ // that. Content and chrome differ by their LEFT EDGE instead, which
398
+ // is how every other register is told apart under R13 E3.
399
+ //
400
+ // The inset is chrome this renderer generates, so at a width that
401
+ // cannot pay for it, it yields — the same rule `mdWrap` applies to
402
+ // an over-wide prefix.
403
+ const inset = W >= 4 ? " " : "";
404
+ return [`${inset}${p.dim}${"\u2500".repeat(Math.max(1, W - inset.length))}${p.reset}`];
405
+ }
262
406
  case "fence-open":
263
407
  // E2: the RAIL, not a gutter. A block drawn with ``` is still a
264
408
  // fenced block when a human selects it and pastes it somewhere
@@ -271,14 +415,20 @@ function blockBody(b, W) {
271
415
  // unterminated fence draws no bottom, which is the truth about
272
416
  // an unterminated fence.
273
417
  return [`${p.dim}${RAIL}${p.reset}`];
274
- case "fence-line": {
418
+ case "fence-line":
419
+ case "code-line": {
275
420
  // a fence body's INDENTATION is its content. The wrapper drops
276
421
  // leading spaces \u2014 right for prose, a lie for code \u2014 so the indent
277
422
  // rides as the row prefix instead, and a wrapped long line hangs
278
423
  // under it rather than returning to the gutter.
279
424
  const src = (b.lines[0] ?? "").replace(/\t/g, " ");
280
425
  const indent = /^ */.exec(src)[0];
281
- const gutter = " "; // E2: the rails bound the block; the body just insets
426
+ // MD-1.4: a fence body insets under its own ``` rails. An indented
427
+ // code block has no rails, so it takes no gutter either: its four
428
+ // spaces ARE the marker the model wrote, and a block that pastes
429
+ // back as indented code is the copy-fidelity goal. Saying "verbatim"
430
+ // twice would cost four columns and buy nothing.
431
+ const gutter = b.kind === "fence-line" ? " " : "";
282
432
  // DC-3: a fenced BODY carries no colour token. It used to take
283
433
  // `code` — 1.54:1 on a white terminal, applied to whole blocks,
284
434
  // which made the code the model just wrote the least readable
@@ -288,15 +438,8 @@ function blockBody(b, W) {
288
438
  // nothing.
289
439
  return foldLineWidth(src.slice(indent.length), W - visibleWidth(gutter), indent).map((r) => `${gutter}${r}`);
290
440
  }
291
- case "quote": {
292
- const text = b.lines.map((l) => QUOTE.exec(l)?.[1] ?? l).join(" ");
293
- // R2: one gutter glyph. A quote and a fenced block both say "this
294
- // text is not mine", and the screen was saying it two ways — ▏
295
- // here and │ for code. The fences took their own ``` rails, so │
296
- // is free and the quote takes it.
297
- const gutter = `${p.dim}\u2502${p.reset} `;
298
- return wrap(`${p.dim}${inlineSpans(text, p.dim)}${p.reset}`, W - visibleWidth(gutter), "", "").map((r) => `${gutter}${r}`);
299
- }
441
+ case "quote":
442
+ return quoteRows(b, W, depth);
300
443
  case "list":
301
444
  return listRows(b, W);
302
445
  case "table":
@@ -507,12 +650,53 @@ function listRows(b, W) {
507
650
  flush();
508
651
  return out.length > 0 ? out : [""];
509
652
  }
653
+ /**
654
+ * The quote. Its content is BLOCKS, and it is rendered as blocks: the `> `
655
+ * markers come off, the stripped text goes through a NESTED stream — a
656
+ * local, built per render, so `renderBlock` stays pure in (block, W) — and
657
+ * every row it produces takes the `│ ` gutter.
658
+ *
659
+ * R2: one gutter glyph. A quote and a fenced block both say "this text is
660
+ * not mine", and the screen was saying it two ways — ▏ here and │ for code.
661
+ * The fences took their own ``` rails, so │ is free and the quote takes it.
662
+ *
663
+ * MD-1.5 — joining the quote's lines with spaces made a quoted list and a
664
+ * quoted second paragraph into one reflowed line, which is the same class
665
+ * of defect as MD-1.4's: the structure the model sent was destroyed, not
666
+ * merely unstyled.
667
+ *
668
+ * MD-1.5's judgement call: the blanket `dim` over the whole quote is GONE
669
+ * and the gutter carries "not mine" alone. With inner blocks the blanket
670
+ * dim would put dim OVER bold in a quoted heading, which is a
671
+ * contradiction, and over a fence body's inset in a quoted fence. It is
672
+ * also MD-1.2's argument one item earlier: `dim` is a LABEL tier and a
673
+ * quote is body text. The gutter stays dim, because a gutter IS a label.
674
+ */
675
+ function quoteRows(b, W, depth) {
676
+ const p = palette();
677
+ const gutter = `${p.dim}\u2502${p.reset} `;
678
+ const bar = `${p.dim}\u2502${p.reset}`;
679
+ const text = b.lines.map((l) => QUOTE.exec(l)?.[1] ?? l).join("\n");
680
+ const room = Math.max(1, W - visibleWidth(gutter));
681
+ if (depth >= QUOTE_DEPTH)
682
+ return wrap(inlineSpans(text.split("\n").join(" "), ""), room, "", "").map((r) => `${gutter}${r}`);
683
+ const inner = new MdStream();
684
+ inner.push(text);
685
+ inner.end();
686
+ const rows = inner.blocks().flatMap((blk) => withGap(blk, blockBody(blk, room, depth + 1)));
687
+ // a blank row inside a quote takes the bar and no trailing space: the
688
+ // gutter says "still the quote", the space would be whitespace a human
689
+ // copies for nothing.
690
+ return rows.map((r) => (r === "" ? bar : `${gutter}${r}`));
691
+ }
510
692
  /**
511
693
  * The table. Columns are measured at their NATURAL widths, on the
512
694
  * inline-rendered text with the SGR stripped (a bold cell is four
513
- * columns, not twelve). If the whole table fits, it is drawn aligned
514
- * with the dim rails; if it does not, it does NOT shrink and it does
515
- * NOT cut — every row becomes a record, and every cell survives.
695
+ * columns, not twelve). If the natural widths fit, the table is drawn at
696
+ * them; if they do not, the columns SHRINK and the cells wrap inside
697
+ * them (MD-1.1); only when every column has reached its floor and the
698
+ * table still does not fit does every row become a record. It never
699
+ * cuts, at any width, in any form.
516
700
  *
517
701
  * A rejected shape (no delimiter row, or a body row wider than the
518
702
  * header) falls back to its own source lines, which are still valid
@@ -521,40 +705,126 @@ function listRows(b, W) {
521
705
  * fact.
522
706
  */
523
707
  function tableRows(b, W) {
524
- const p = palette();
525
708
  const t = tableShape(b.lines);
526
709
  if (t === null)
527
710
  return b.lines.flatMap((l) => wrap(l, W, "", ""));
528
- const cols = t.header.map((h, i) => Math.max(cellWidth(h), ...t.rows.map((r) => cellWidth(r[i] ?? ""))));
711
+ const natural = t.header.map((h, i) => Math.max(cellWidth(h), ...t.rows.map((r) => cellWidth(r[i] ?? ""))));
712
+ const cols = shrinkCols(natural, W);
713
+ if (cols === null)
714
+ return recordRows(t, W);
715
+ const p = palette();
529
716
  // R2: no rails. The drawn width is two columns of inset plus the
530
717
  // columns and their two-space gutters — a table is bounded by the
531
718
  // blank lines above and below it, exactly as every other block on the
532
719
  // screen is, and it was the last box left on a screen that has decided
533
720
  // not to have boxes. Alignment does the work the rails were doing, and
534
721
  // a copied table is closer to markdown without them.
535
- const total = cols.reduce((n, w) => n + w + 2, 2);
536
- if (total > W)
537
- return recordRows(t, W);
538
- const row = (cells, bold) => ` ${cells.map((c, i) => pad(c, cols[i], t.align[i], bold)).join(" ")}`.replace(/\s+$/, "");
539
- return [row(t.header, true), ...t.rows.map((r) => row(r, false))];
722
+ const row = (cells, bold) => {
723
+ const boxes = cells.map((c, i) => cellBox(c, cols[i], t.align[i], bold));
724
+ const rows = [];
725
+ for (let k = 0; k < Math.max(...boxes.map((x) => x.length)); k += 1) {
726
+ // a short box pays its blanks so the columns to its right do not
727
+ // move: a cell is a BOX, and the row is as tall as its tallest.
728
+ rows.push(` ${boxes.map((x, i) => x[k] ?? " ".repeat(cols[i])).join(" ")}`.replace(/\s+$/, ""));
729
+ }
730
+ return rows;
731
+ };
732
+ // MD-1.3 / R2 AMENDMENT 1 (owner ruling, 2026-09-11) — ONE rule under
733
+ // the header row, at the grid's own width. R2 removed the RAILS: the
734
+ // four-sided box that BOUNDS a table. This bounds nothing; it SEPARATES
735
+ // the header from the body, which is the one job the round's governing
736
+ // distinction gives a rule — a rule separates, a gutter scopes, a rail
737
+ // bounds. Rails stay out.
738
+ //
739
+ // What it buys is not decoration: without it a six-row table's header
740
+ // was carried by SGR bold ALONE, so in a pipe, under NO_COLOR, or on a
741
+ // terminal with weak bold, seven identical rows arrived with nothing
742
+ // saying which one names the columns.
743
+ const ruleW = cols.reduce((n, w) => n + w, 0) + Math.max(0, cols.length - 1) * 2;
744
+ return [...row(t.header, true), ` ${p.dim}${"\u2500".repeat(ruleW)}${p.reset}`, ...t.rows.flatMap((r) => row(r, false))];
540
745
  }
541
746
  /** A cell's column count: what a human sees, styling removed. */
542
747
  function cellWidth(cell) {
543
748
  return visibleWidth(inlineSpans(cell, ""));
544
749
  }
545
- /** One padded cell — the styling goes on AFTER the measure, so it can
546
- * never move a column. */
547
- function pad(cell, w, align, bold) {
750
+ /** MD-1.1 — the SHRINK FLOOR: eight columns, four CJK characters. A
751
+ * column whose natural width is already at or below it never shrinks at
752
+ * all. The 8 is a judgement and not a measurement — it is where the
753
+ * report's sample stopped reading as a table — and it is the one number
754
+ * that decides when the record form is still the better answer. */
755
+ const CELL_FLOOR = 8;
756
+ /** The drawn width of a grid with these columns, by the measure the R2
757
+ * table has always used: the two-column inset plus every column AND its
758
+ * two-space gutter. Conservative by one gutter (the last column has
759
+ * none), which is where the table's right margin comes from — kept as
760
+ * it was, because the record threshold has always been stated in it. */
761
+ function gridWidth(cols) {
762
+ return cols.reduce((n, w) => n + w + 2, 2);
763
+ }
764
+ /**
765
+ * MD-1.1 — take one column off the WIDEST column until the grid fits.
766
+ * Returns null when every column has reached its floor and it still does
767
+ * not, which is the one case the record form exists for.
768
+ *
769
+ * There was no shrink step at all before this: a table either fitted at
770
+ * its natural width or the whole block was abandoned. That made the
771
+ * degradation a cliff — the owner's 6-column sample missed the grid by 8
772
+ * columns at terminal 80 and collapsed into eleven rows of records, when
773
+ * the two columns carrying long CJK phrases would each have wrapped
774
+ * inside their cell for free.
775
+ *
776
+ * Greedy, and therefore PREDICTABLE rather than optimal: always the
777
+ * widest column, ties to the left. A column holding one long unbreakable
778
+ * token will spend its way to the floor and force its neighbours
779
+ * narrower — the cost of a rule a human can hold in their head.
780
+ */
781
+ function shrinkCols(natural, W) {
782
+ const floor = natural.map((w) => Math.min(w, CELL_FLOOR));
783
+ const cols = [...natural];
784
+ while (gridWidth(cols) > W) {
785
+ let at = -1;
786
+ for (let i = 0; i < cols.length; i += 1)
787
+ if (cols[i] > floor[i] && (at < 0 || cols[i] > cols[at]))
788
+ at = i;
789
+ if (at < 0)
790
+ return null;
791
+ cols[at] = cols[at] - 1;
792
+ }
793
+ return cols;
794
+ }
795
+ /** One cell as a BOX: its text wrapped inside the column, every row
796
+ * padded to the column's width by the column's alignment. The styling
797
+ * goes on AFTER the measure, so it can never move a column, and the
798
+ * wrapper is the same one every other block folds through — so a cell
799
+ * obeys the kinsoku set and the width authority like all other text. */
800
+ function cellBox(cell, w, align, bold) {
548
801
  const p = palette();
549
802
  const body = bold ? `${p.bold}${inlineSpans(cell, p.bold)}${p.reset}` : inlineSpans(cell, "");
550
- const slack = Math.max(0, w - cellWidth(cell));
551
- const left = align === "right" ? slack : align === "center" ? Math.floor(slack / 2) : 0;
552
- return `${" ".repeat(left)}${body}${" ".repeat(slack - left)}`;
553
- }
554
- /** The narrow degradation: one record per row. The first column names
555
- * the record (bold, with a dim colon); the rest is a dim `label:
556
- * value` run joined by `·`, wrapped rather than cut. A blank row
557
- * separates records — nothing is dropped at any width. */
803
+ return mdWrap(body, w, "", "").map((r) => {
804
+ const slack = Math.max(0, w - visibleWidth(r));
805
+ const left = align === "right" ? slack : align === "center" ? Math.floor(slack / 2) : 0;
806
+ return `${" ".repeat(left)}${r}${" ".repeat(slack - left)}`;
807
+ });
808
+ }
809
+ /**
810
+ * The narrow degradation: one record per row. The first column names the
811
+ * record (bold, with a dim colon); the rest is a `label: value` run
812
+ * joined by `·`, wrapped rather than cut. A blank row separates records —
813
+ * nothing is dropped at any width.
814
+ *
815
+ * MD-1.2 — the LABEL is dim and the VALUE is not. This used to wrap every
816
+ * label AND every value in ONE `p.dim` span, so a table's actual content
817
+ * arrived at the lowest contrast tier on the screen while the labels —
818
+ * scaffolding the reader already read in the header row — carried equal
819
+ * weight. The emphasis was exactly inverted.
820
+ *
821
+ * Per-token contrast was never the defect: `dim` measures 4.54:1 on a
822
+ * resolved light ground, which is legal for a LABEL. Setting a whole
823
+ * paragraph of body text in it is a different thing, and on a terminal
824
+ * that never answered OSC 11 it is worse — the palette keeps SGR 2 there
825
+ * rather than an absolute grey. `dim` is a label tier; this is the first
826
+ * place it was asked to be a body tier, and it is no longer asked.
827
+ */
558
828
  function recordRows(t, W) {
559
829
  const p = palette();
560
830
  const out = [];
@@ -562,9 +832,10 @@ function recordRows(t, W) {
562
832
  if (out.length > 0)
563
833
  out.push("");
564
834
  out.push(...wrap(`${p.bold}${inlineSpans(t.header[0] ?? "", p.bold)}${p.reset}${p.dim}:${p.reset} ${inlineSpans(r[0] ?? "", "")}`, W, "", ""));
565
- const rest = t.header.slice(1).map((h, i) => `${h}: ${r[i + 1] ?? ""}`);
835
+ const rest = t.header.slice(1).map((h, i) => `${p.dim}${inlineSpans(h, p.dim)}:${p.reset} ${inlineSpans(r[i + 1] ?? "", "")}`);
836
+ // the `·` stays dim: it is punctuation between pairs, not content.
566
837
  if (rest.length > 0)
567
- out.push(...wrap(`${p.dim}${inlineSpans(rest.join(" · "), p.dim)}${p.reset}`, W, "", ""));
838
+ out.push(...wrap(rest.join(`${p.dim} · ${p.reset}`), W, "", ""));
568
839
  }
569
840
  return out.length > 0 ? out : [row0(t)];
570
841
  }
@@ -624,7 +895,18 @@ function tokens(text) {
624
895
  prev = ch;
625
896
  continue;
626
897
  }
627
- if (cur !== "" && prev !== "" && breaks(prev, ch))
898
+ // MD1-F1: a `cur` holding nothing but SGR is ZERO-WIDTH and RIDES the
899
+ // token it precedes — this function's own contract, which the flush
900
+ // below used to break. Emitted as a token of its own it measures 0, so
901
+ // `w + pendW + 0 > room` can take a break AT it: the pending space is
902
+ // dropped, the style lands at the head of the next row, and the word it
903
+ // belonged to goes to the row after. Invisible while every style
904
+ // boundary sat at a space (every ASCII case), reachable the moment one
905
+ // sits before a CJK character, which MD-1.2's per-label dim made
906
+ // common in the record form. What it costs there is a trailing space
907
+ // and an empty style span; what it does NOT cause is FINDING MD1-F3
908
+ // below, which is older than this and survives the guard.
909
+ if (cur !== "" && w > 0 && prev !== "" && breaks(prev, ch))
628
910
  flush();
629
911
  cur += ch;
630
912
  w += charWidth(cp);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui-cells",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
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",