@zenodinh/pi-render 0.1.8 → 0.1.9

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/package.json CHANGED
@@ -67,5 +67,5 @@
67
67
  "typescript": "^7.0.2",
68
68
  "vitest": "^5.0.3"
69
69
  },
70
- "version": "0.1.8"
70
+ "version": "0.1.9"
71
71
  }
package/src/core/paint.ts CHANGED
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Boundary: renderers call role methods and never emit an escape (AGENTS: every escape originates
5
5
  * here); both theme inputs are structural, so a plain-object fixture drives either factory with no
6
- * host import. The band's background ink is the one input that arrives late — through the theme
7
- * holder — so it is read per call and falls back to reverse video when nothing was fed.
6
+ * host import. Every content role is a live lookup over the markdown theme's own closures, so a theme
7
+ * switch repaints on the next call.
8
8
  *
9
9
  * shape: none at module level — a role is a token lookup with no discriminator. The two closure
10
- * factories and the band-style dispatch inside the second one declare their own shape.
10
+ * factories declare their own shape.
11
11
  *
12
12
  * ported from pi-pretty-tui/src/config.ts:71-100 — survives because: "read the theme token, degrade to
13
13
  * default when it is absent" is what resolveBaseBackground did; its dead links (`toolBg`,
@@ -71,33 +71,14 @@ export function createRowPaint(theme: HostTheme): RowPaint {
71
71
  };
72
72
  }
73
73
 
74
- /**
75
- * The header band: the header ink with its cell's foreground and background swapped — SGR reverse video.
76
- * Reversal is what makes the band safe on any theme: it preserves the theme's own ink-on-background
77
- * contrast ratio instead of pairing an ink with a fill that was picked for something else, and it keeps
78
- * the band theme-driven, because the header ink is the theme's own. No token, no construction-time
79
- * choice (owner ruling 2026-10-09; issue #52).
80
- */
81
- /**
82
- * SGR reverse video, on and off — the ONE escape pair that originates here rather than in a theme.
83
- * Reverse video needs no token, so it works on any theme and on a frame rendered before one arrives.
84
- */
85
- const REVERSE_ON = "\x1b[7m";
86
- const REVERSE_OFF = "\x1b[27m";
87
-
88
- /** The band: reverse video over the header ink — the cell's own ink and background swapped. */
89
- function reverseBand(text: string): string {
90
- return `${REVERSE_ON}${text}${REVERSE_OFF}`;
91
- }
92
-
93
74
  /**
94
75
  * Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
95
76
  *
96
- * The markdown theme's own closures supply the emphasis ink: `bold`/`italic` for inline spans, and its
97
- * `code` closure (the `mdCode` token, identical to `accent` in both stock themes) for the header ink.
77
+ * The markdown theme's own closures supply the ink: `code` (`mdCode`) under `bold` for the table header,
78
+ * `heading` (`mdHeading`, the accent purple) for the table body's cells, `bold`/`italic` for spans.
98
79
  */
99
80
  // shape: closure returning an object literal — trigger #4, eight stateless content roles over the
100
- // captured markdown theme, band style and theme holder.
81
+ // captured markdown theme.
101
82
  export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
102
83
  // The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
103
84
  // degrades on its own, so a theme missing one still stamps whatever the other gives.
@@ -113,6 +94,6 @@ export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
113
94
  strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
114
95
  em: (text) => tryStyle(mdTheme.italic, text) ?? text,
115
96
  header,
116
- headerBand: (text) => reverseBand(header(text)),
97
+ cell: (text) => tryStyle(mdTheme.heading, text) ?? text,
117
98
  };
118
99
  }
@@ -20,18 +20,6 @@ export interface HostTheme {
20
20
  bold(text: string): string;
21
21
  }
22
22
 
23
- /**
24
- * The background-capable theme slice the header band reads. Structural host shape: the host's live
25
- * theme is a proxy over the active theme, so any object answering `bg` qualifies.
26
- *
27
- * Declared apart from {@link HostTheme} on purpose — that type is what every row renderer and every
28
- * golden fixture constructs, so a background member there would break call sites that draw no band.
29
- */
30
- export interface HostBackgroundTheme {
31
- /** Wraps text in a background token's escape, e.g. bg("selectedBg", "x"). Required. */
32
- bg(key: string, text: string): string;
33
- }
34
-
35
23
  /** Live markdown-theme closures the content painter wraps. Structural host shape — no host import. */
36
24
  export interface MarkdownTheme {
37
25
  /** Horizontal-rule escape wrapper. Optional: a host without this token degrades to plain text. */
@@ -44,6 +32,8 @@ export interface MarkdownTheme {
44
32
  code?(text: string): string;
45
33
  /** Fenced-code-block border wrapper. Optional. */
46
34
  codeBlockBorder?(text: string): string;
35
+ /** Heading wrapper (the theme's `mdHeading` ink); the table body's cell ink reads it. Optional. */
36
+ heading?(text: string): string;
47
37
  /** Bold wrapper for `**strong**` spans — the host markdown theme's own `bold` closure. Optional. */
48
38
  bold?(text: string): string;
49
39
  /** Italic wrapper for `*em*` spans — the host markdown theme's own `italic` closure. Optional. */
@@ -175,10 +165,5 @@ export interface EventContext {
175
165
  ui?: {
176
166
  /** Collapses (false) / expands (true) every tool row. The collapse-first default rides this. Optional. */
177
167
  setToolsExpanded?(expanded: boolean): void;
178
- /**
179
- * The live theme handle — a getter over the host's theme proxy, so one read sees every later
180
- * switch. Optional: it is the band ink's only reach, and the extension API carries no theme.
181
- */
182
- theme?: HostBackgroundTheme;
183
168
  };
184
169
  }
@@ -1,6 +1,5 @@
1
1
  /**
2
- * paint.ts — the painting contract: the two role sets every renderer draws through, plus the theme
3
- * reach the header band reads.
2
+ * paint.ts — the painting contract: the two role sets every renderer draws through.
4
3
  *
5
4
  * Boundary: region 1 is the tool row, region 2 is markdown content. Renderers call role methods
6
5
  * and never emit an escape sequence themselves (AGENTS: escapes originate in core/paint.ts).
@@ -8,7 +7,7 @@
8
7
  * shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
9
8
  */
10
9
 
11
- import type { HostBackgroundTheme, HostTheme } from "./host.ts";
10
+ import type { HostTheme } from "./host.ts";
12
11
 
13
12
  /** Region 1 — the tool-row roles. Text in, theme-derived escape-wrapped text out. */
14
13
  export interface RowPaint {
@@ -49,19 +48,6 @@ export type RowPaintFactory = (
49
48
  theme: HostTheme,
50
49
  ) => RowPaint;
51
50
 
52
- /**
53
- * The band's theme reach: one long-lived handle, fed from the per-event UI context and read per frame.
54
- *
55
- * The HANDLE is what travels, never a resolved colour: the host's theme is a proxy over the active
56
- * theme, so a cached escape would freeze the band on the theme selected at load.
57
- */
58
- export interface ThemeHolder {
59
- /** Stores a live theme handle; a value that cannot paint a background is stored as no theme. */
60
- set(theme: unknown): void;
61
- /** The handle for this frame, or undefined when none was fed — the band's fallback state. */
62
- get(): HostBackgroundTheme | undefined;
63
- }
64
-
65
51
  /** Region 2 — the markdown-content roles. */
66
52
  export interface ContentPaint {
67
53
  /** Horizontal rule; falls back hr → quoteBorder → plain rather than throw. Required. */
@@ -77,14 +63,14 @@ export interface ContentPaint {
77
63
  /** An `*em*` inline span, drawn in the theme's italic. Required. */
78
64
  em(text: string): string;
79
65
  /**
80
- * One stamped header cell: the accent ink under bold. The ink is the markdown theme's `code` closure
81
- * (`mdCode`, identical to `accent` in both stock themes) and the weight its `bold` closure. Required.
66
+ * One stamped header cell: the accent ink under bold — the table header's only ink. The ink is the
67
+ * markdown theme's `code` closure (`mdCode`, identical to `accent` in both stock themes) and the
68
+ * weight its `bold` closure. No background: the owner removed the band on review (#62). Required.
82
69
  */
83
70
  header(text: string): string;
84
71
  /**
85
- * One header-band cell: {@link header} carrying the band's own background ink, read from the ACTIVE
86
- * theme through the holder and therefore refreshed per render. Reverse video is the fallback for a
87
- * frame with no theme reachable. Required.
72
+ * One table body cell: the theme's `heading` closure (`mdHeading`) wrapped around the whole cell, so
73
+ * a code span inside adds no ink of its own. Degrades to plain text without the closure. Required.
88
74
  */
89
- headerBand(text: string): string;
75
+ cell(text: string): string;
90
76
  }
@@ -1,18 +1,18 @@
1
- // ported from pi-pretty-tui/src/features/canvas/table.ts — survives because: the banded layout,
2
- // the column-width solver and the cell wrapping are the reading experience this requirement keeps.
3
- // Only the escape source changed: frame and bars go through paint.rule, inline code through
4
- // paint.code, and the predecessor's own bold/italic literals are dropped because no escape may
5
- // originate outside core/paint.ts.
1
+ // ported from pi-pretty-tui/src/features/canvas/table.ts — survives because: the boxed layout, the
2
+ // column-width solver and the cell wrapping are the reading experience this requirement keeps.
3
+ // Only the escape source changed: frame and bars go through paint.rule, the header through
4
+ // paint.header, body cells through paint.cell; the predecessor's own bold/italic literals are dropped
5
+ // because no escape may originate outside core/paint.ts.
6
6
 
7
7
  /**
8
- * table.ts — the table surface: a markdown table becomes a banded box.
8
+ * table.ts — the table surface: a markdown table becomes a boxed box with inked text.
9
9
  *
10
- * The header row is stamped through the header-band role — bold over the accent ink inside a
11
- * full-bleed band spanning each cell's whole interior plus its trailing separator, so the header
12
- * reads as one bar (FP-11); where the band abuts the outer frame, the frame column joins it too
13
- * (#57), so the field reaches the border. When the header row is empty (`| | |`), that stamp moves
14
- * onto the first column instead of spending a blank band. `**strong**` and `*em*` cells route
15
- * through their own roles.
10
+ * The header row's cells take the header ink — bold over `mdCode`; the body cells take the cell ink,
11
+ * the theme's `mdHeading` purple. Each cell's whole text carries that one ink, so a code span inside a
12
+ * cell adds no green or gray of its own. Nothing paints a background: the owner removed the full-bleed
13
+ * band on a live look-check (2026-10-09, #62). When the header row is empty (`| | |`), that row and its
14
+ * separator are dropped and the first column's labels take the header ink instead. `**strong**` and
15
+ * `*em*` cells route through their own roles.
16
16
  *
17
17
  * Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back
18
18
  * out, so every escape in the output originates in the injected ContentPaint (T-08) — the #22 fix.
@@ -86,11 +86,16 @@ function inlineText(token: InlineToken, paint: ContentPaint): string {
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * The cell's text with its inline spans composed. A code span adds no ink: its wrapper is the
91
+ * identity, so the cell's own role wraps the whole composed text (owner ruling, #62).
92
+ */
89
93
  function cellText(cell: CellLike | undefined, paint: ContentPaint): string {
90
94
  const tokens = cell?.tokens;
95
+ const uninked: ContentPaint = { ...paint, code: (text) => text };
91
96
  const text =
92
97
  Array.isArray(tokens) && tokens.length > 0
93
- ? tokens.map((token) => inlineText(token, paint)).join("")
98
+ ? tokens.map((token) => inlineText(token, uninked)).join("")
94
99
  : (cell?.text ?? "");
95
100
  // Soft wraps inside a cell are one logical line before the width solve.
96
101
  return text.replace(/\s+/g, " ").trim();
@@ -143,7 +148,7 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
143
148
  }
144
149
 
145
150
  // An all-empty header (`| | |`) means the table carries its labels in the first column: the blank
146
- // header row and its separator are dropped, and the band moves onto that first column instead.
151
+ // header row and its separator are dropped, and those labels take the header ink instead.
147
152
  const keyColumn = header.every((cell) => cell === "");
148
153
 
149
154
  // A light box around and between everything: the frame is what keeps a wrapped cell visually
@@ -153,26 +158,15 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
153
158
  const bar = paint.rule("│");
154
159
  const physical: string[] = [];
155
160
 
156
- /** One logical row, wrapped into physical lines; `banded` picks the cells drawn through headerBand. */
157
- const renderRow = (cells: string[], banded: (index: number) => boolean): void => {
161
+ /** One logical row, wrapped into physical lines; `ink` picks the role each cell's text is drawn in. */
162
+ const renderRow = (cells: string[], ink: (text: string, index: number) => string): void => {
158
163
  const wrapped = cells.map((cell, index) => wrapTextWithAnsi(cell, widths[index] ?? MIN_COLUMN));
159
164
  const height = Math.max(1, ...wrapped.map((lines) => lines.length));
160
165
  for (let line = 0; line < height; line++) {
161
- const parts = wrapped.map((lines, index) => {
162
- const padded = padCell(lines[line] ?? "", widths[index] ?? MIN_COLUMN);
163
- // Full-bleed unit (FP-11): the cell's whole interior — both padding columns — plus its
164
- // trailing separator (the bar keeps its rule ink inside the band), so banded cells form one
165
- // unbroken bar; the last cell has no trailing separator, so its band ends at its own padding.
166
- const unit = index < cells.length - 1 ? ` ${padded} ${bar}` : ` ${padded} `;
167
- return banded(index) ? paint.headerBand(unit) : unit;
168
- });
169
- // #57 (owner pick): the band reaches the outer frame where it abuts it — a banded first cell
170
- // takes the left frame column into the band, a banded last cell the right one. The frame glyph
171
- // joins the band's own style (dark-on-green), not the rule ink, so no unbanded sliver remains
172
- // between the field and the border.
173
- const left = banded(0) ? paint.headerBand("│") : bar;
174
- const right = banded(cells.length - 1) ? paint.headerBand("│") : bar;
175
- physical.push(`${left}${parts.join("")}${right}`);
166
+ const parts = wrapped.map(
167
+ (lines, index) => ` ${padCell(ink(lines[line] ?? "", index), widths[index] ?? MIN_COLUMN)} `,
168
+ );
169
+ physical.push(`${bar}${parts.join(bar)}${bar}`);
176
170
  }
177
171
  };
178
172
 
@@ -180,14 +174,14 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
180
174
  if (keyColumn) {
181
175
  rows.forEach((row, index) => {
182
176
  if (index > 0) physical.push(ruleLine("├", "┼", "┤"));
183
- renderRow(row, (index) => index === 0);
177
+ renderRow(row, (text, column) => (column === 0 ? paint.header(text) : paint.cell(text)));
184
178
  });
185
179
  } else {
186
- renderRow(header, () => true);
180
+ renderRow(header, (text) => paint.header(text));
187
181
  physical.push(ruleLine("├", "┼", "┤"));
188
182
  rows.forEach((row, index) => {
189
183
  if (index > 0) physical.push(ruleLine("├", "┼", "┤"));
190
- renderRow(row, () => false);
184
+ renderRow(row, (text) => paint.cell(text));
191
185
  });
192
186
  }
193
187
  physical.push(ruleLine("└", "┴", "┘"));