@namiml/cli 3.4.12-dev.202610051759 → 3.4.12-dev.202610060208

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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * A character grid to paint a page into.
3
+ *
4
+ * Ink has no z-order and an Ink `Box` carries exactly one background colour, so
5
+ * a paywall's background layer drew *above* the page, a gradient behind copy
6
+ * flattened to one colour, and an image could never sit under text. All three
7
+ * are the same limit. Painting into a grid where **every cell owns its own
8
+ * colours** removes it: the background is painted first, everything after lands
9
+ * on top, and each row of a box can take its own colour.
10
+ *
11
+ * This module is pure logic — no React, no Ink import. Ink stops being a layout
12
+ * engine and becomes a printer: `toRows()` hands it one row of like-styled spans
13
+ * per line.
14
+ *
15
+ * Two rules carry the whole thing:
16
+ *
17
+ * - `drawText` **keeps the background already in the cell** unless the style
18
+ * names one. That is precisely what puts text *over* a fill rather than
19
+ * punching a hole through it.
20
+ * - An image cell whose `fg` or `bg` is `""` means "leave what is underneath" —
21
+ * which is what `image.ts` emits for a pixel that is absent or fully
22
+ * transparent.
23
+ *
24
+ * Every write clips silently at every edge, including negative coordinates and
25
+ * rectangles that start off-grid: a painter that throws on an overhang would
26
+ * take the whole page render down with it.
27
+ */
28
+ import { parseColor, toHex } from "./style.js";
29
+ /**
30
+ * The marker for the right-hand half of a wide glyph.
31
+ *
32
+ * A wide glyph (an emoji, a CJK ideograph) takes **two** columns, so it occupies
33
+ * two cells: the lead cell carries the glyph and the next one is a continuation
34
+ * that `toRows()` prints nothing for. That keeps the printed width of a row
35
+ * exactly equal to `cols`, which is what stops one `👋` from misaligning every
36
+ * row after it. The alternative — refusing to place a wide glyph at all — would
37
+ * drop the real Penguin fixture's `👋 This is a list` on the floor.
38
+ */
39
+ const CONTINUATION = "";
40
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });
41
+ /**
42
+ * East Asian Wide and Fullwidth ranges. The emoji test below covers the
43
+ * pictographic blocks, so this table only has to carry the CJK side; it is the
44
+ * conventional `wcwidth` set, trimmed to the ranges a paywall could plausibly
45
+ * contain.
46
+ */
47
+ const WIDE_RANGES = [
48
+ [0x1100, 0x115f], // Hangul Jamo initial consonants
49
+ [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols and punctuation
50
+ [0x3041, 0x33ff], // Hiragana through CJK compatibility
51
+ [0x3400, 0x4dbf], // CJK unified ideographs extension A
52
+ [0x4e00, 0x9fff], // CJK unified ideographs
53
+ [0xa000, 0xa4cf], // Yi
54
+ [0xa960, 0xa97f], // Hangul Jamo extended-A
55
+ [0xac00, 0xd7a3], // Hangul syllables
56
+ [0xf900, 0xfaff], // CJK compatibility ideographs
57
+ [0xfe10, 0xfe19], // vertical forms
58
+ [0xfe30, 0xfe6f], // CJK compatibility forms, small form variants
59
+ [0xff00, 0xff60], // fullwidth forms
60
+ [0xffe0, 0xffe6], // fullwidth signs
61
+ [0x20000, 0x2fffd], // CJK extension B and beyond
62
+ [0x30000, 0x3fffd],
63
+ ];
64
+ /** Text split into grapheme clusters — the unit a terminal cell actually holds. */
65
+ export function graphemes(text) {
66
+ return Array.from(GRAPHEMES.segment(text), (part) => part.segment);
67
+ }
68
+ /**
69
+ * How many columns one grapheme occupies: 0 for a control character, 2 for an
70
+ * emoji with emoji presentation or an East Asian wide character, 1 otherwise.
71
+ *
72
+ * A control character is worth zero rather than one, so a stray `\n` in authored
73
+ * copy does not silently eat a cell.
74
+ */
75
+ export function cellWidth(grapheme) {
76
+ const first = grapheme.codePointAt(0);
77
+ if (first === undefined)
78
+ return 0;
79
+ if (first < 0x20 || (first >= 0x7f && first <= 0x9f))
80
+ return 0;
81
+ if (first === 0x200b)
82
+ return 0;
83
+ // U+FE0F asks for the emoji presentation of an otherwise text-default glyph.
84
+ if (grapheme.includes("️"))
85
+ return 2;
86
+ if (/\p{Emoji_Presentation}/u.test(grapheme))
87
+ return 2;
88
+ return WIDE_RANGES.some(([from, to]) => first >= from && first <= to) ? 2 : 1;
89
+ }
90
+ /**
91
+ * The columns a whole string occupies on screen.
92
+ *
93
+ * The one width in the renderer. It used to be three: `layout.ts` counted code
94
+ * points, `paint.ts` counted columns, and the two disagreed by a factor of two
95
+ * on CJK — `日本語` was measured as 3 and drawn as 6, so a Japanese paywall was
96
+ * laid out into half the room its text needed. Measurement and paint agreeing
97
+ * on where copy wraps is the rule the layout is built on; this is what makes
98
+ * them agree.
99
+ */
100
+ export function textColumns(value) {
101
+ return graphemes(value).reduce((total, glyph) => total + cellWidth(glyph), 0);
102
+ }
103
+ export class Surface {
104
+ cols;
105
+ rows;
106
+ cells;
107
+ constructor(cols, rows) {
108
+ this.cols = size(cols);
109
+ this.rows = size(rows);
110
+ this.cells = Array.from({ length: this.cols * this.rows }, () => ({ ch: " " }));
111
+ }
112
+ /** A copy of one cell, or `undefined` when the coordinates are off the grid. */
113
+ cellAt(x, y) {
114
+ const cell = this.at(x, y);
115
+ return cell ? { ...cell } : undefined;
116
+ }
117
+ /**
118
+ * Copy another surface onto this one at `(x, y)`, **skipping cells it never
119
+ * painted**.
120
+ *
121
+ * This is what lets a footer sit over the page the way an absolutely
122
+ * positioned one does on the web: where the footer has a fill it covers what
123
+ * is beneath, and where it has none the page shows through. Replacing whole
124
+ * rows instead would punch a hole in the page wherever the footer is
125
+ * transparent.
126
+ */
127
+ blit(other, x, y) {
128
+ for (let row = 0; row < other.rows; row += 1) {
129
+ for (let col = 0; col < other.cols; col += 1) {
130
+ const from = other.at(col, row);
131
+ if (!from || !painted(from))
132
+ continue;
133
+ const to = this.at(x + col, y + row);
134
+ if (!to)
135
+ continue;
136
+ Object.assign(to, from);
137
+ }
138
+ }
139
+ }
140
+ /**
141
+ * Empty a rectangle: no glyph, no colours, as if nothing had been painted.
142
+ *
143
+ * This exists for an image the terminal draws itself. Such an image is placed
144
+ * *below* cells that carry a background colour, so any fill left in its
145
+ * rectangle — the page's own, a section's — would hide it completely. Clearing
146
+ * is what lets the picture through, and it is why a button's fill painted
147
+ * afterwards still covers the picture, exactly as it should.
148
+ */
149
+ clearRect(x, y, w, h) {
150
+ for (let row = 0; row < h; row += 1) {
151
+ for (let col = 0; col < w; col += 1) {
152
+ const cell = this.at(x + col, y + row);
153
+ if (cell)
154
+ for (const key of Object.keys(cell))
155
+ delete cell[key];
156
+ if (cell)
157
+ cell.ch = " ";
158
+ }
159
+ }
160
+ }
161
+ /**
162
+ * Paint a rectangle's background. Cells keep whatever glyph and foreground are
163
+ * already on them, which is how a fill drawn after its content still sits
164
+ * behind it.
165
+ */
166
+ fillRect(x, y, w, h, bg) {
167
+ this.fillRectRows(x, y, w, h, () => bg);
168
+ }
169
+ /**
170
+ * Per-row colour, for a gradient: `colorAt(t)` decides each row's background
171
+ * with `t` running 0 at the top of the rect to 1 at the bottom. A rect one row
172
+ * tall is sampled at 0. Returning `null` leaves that row untouched, which is
173
+ * what an unresolvable fill does.
174
+ *
175
+ * `t` is measured against the **whole** rect, so a rect whose top is off-grid
176
+ * keeps its gradient undistorted.
177
+ */
178
+ fillRectRows(x, y, w, h, colorAt) {
179
+ const width = size(w);
180
+ const height = size(h);
181
+ if (width === 0 || height === 0)
182
+ return;
183
+ for (let row = 0; row < height; row += 1) {
184
+ const gridY = y + row;
185
+ if (gridY < 0 || gridY >= this.rows)
186
+ continue;
187
+ const bg = colorAt(height === 1 ? 0 : row / (height - 1));
188
+ if (!bg)
189
+ continue;
190
+ for (let col = 0; col < width; col += 1) {
191
+ const cell = this.at(x + col, gridY);
192
+ if (cell)
193
+ cell.bg = bg;
194
+ }
195
+ }
196
+ }
197
+ /**
198
+ * Write text over whatever is beneath it, keeping each cell's existing
199
+ * background unless `style.bg` names one. `maxWidth` caps the columns written
200
+ * from `x`; a wide glyph counts as two of them and is never split in half.
201
+ */
202
+ drawText(x, y, text, style = {}, maxWidth) {
203
+ if (y < 0 || y >= this.rows || this.cols === 0)
204
+ return;
205
+ const limit = typeof maxWidth === "number" && Number.isFinite(maxWidth) ? Math.max(0, Math.floor(maxWidth)) : null;
206
+ let used = 0;
207
+ let col = x;
208
+ for (const grapheme of graphemes(text)) {
209
+ const width = cellWidth(grapheme);
210
+ if (width === 0)
211
+ continue;
212
+ if (limit !== null && used + width > limit)
213
+ return;
214
+ if (col >= this.cols)
215
+ return;
216
+ // Never leave half a wide glyph against the right edge.
217
+ if (col + width > this.cols)
218
+ return;
219
+ if (col >= 0) {
220
+ this.breakWideAt(col, y);
221
+ if (width === 2)
222
+ this.breakWideAt(col + 1, y);
223
+ this.write(col, y, grapheme, style);
224
+ if (width === 2)
225
+ this.write(col + 1, y, CONTINUATION, style);
226
+ }
227
+ col += width;
228
+ used += width;
229
+ }
230
+ }
231
+ /**
232
+ * Half-block image cells, each carrying its own foreground and background. An
233
+ * empty `fg` or `bg` leaves what is already in the cell, and a cell that is a
234
+ * blank with neither is skipped entirely — that is `image.ts`'s way of saying
235
+ * the pixel was absent or fully transparent, so the page shows through.
236
+ */
237
+ drawCells(x, y, cells) {
238
+ cells.forEach((row, rowIndex) => {
239
+ const gridY = y + rowIndex;
240
+ if (gridY < 0 || gridY >= this.rows)
241
+ return;
242
+ row.forEach((image, colIndex) => {
243
+ const gridX = x + colIndex;
244
+ const cell = this.at(gridX, gridY);
245
+ if (!cell)
246
+ return;
247
+ if (image.ch === " " && image.fg === "" && image.bg === "")
248
+ return;
249
+ this.breakWideAt(gridX, gridY);
250
+ const fg = parseColor(image.fg);
251
+ const bg = parseColor(image.bg);
252
+ cell.ch = image.ch;
253
+ if (fg)
254
+ cell.fg = fg;
255
+ if (bg)
256
+ cell.bg = bg;
257
+ // An image has no text attributes; anything beneath it loses them.
258
+ delete cell.bold;
259
+ delete cell.italic;
260
+ delete cell.underline;
261
+ delete cell.dim;
262
+ });
263
+ });
264
+ }
265
+ /**
266
+ * One row per line, each a run of like-styled spans — what Ink prints. Adjacent
267
+ * cells with identical styling merge, so a 44-column row is a handful of spans
268
+ * rather than 44. A wide glyph's continuation cell contributes nothing, because
269
+ * the glyph itself already prints two columns wide.
270
+ */
271
+ toRows() {
272
+ const out = [];
273
+ for (let y = 0; y < this.rows; y += 1) {
274
+ const spans = [];
275
+ let current = null;
276
+ let currentKey = "";
277
+ for (let x = 0; x < this.cols; x += 1) {
278
+ const cell = this.cells[y * this.cols + x];
279
+ if (cell.ch === CONTINUATION)
280
+ continue;
281
+ const key = styleKey(cell);
282
+ if (current && key === currentKey) {
283
+ current.text += cell.ch;
284
+ continue;
285
+ }
286
+ current = { ...spanStyle(cell), text: cell.ch };
287
+ currentKey = key;
288
+ spans.push(current);
289
+ }
290
+ out.push(spans);
291
+ }
292
+ return out;
293
+ }
294
+ at(x, y) {
295
+ if (!Number.isInteger(x) || !Number.isInteger(y))
296
+ return undefined;
297
+ if (x < 0 || y < 0 || x >= this.cols || y >= this.rows)
298
+ return undefined;
299
+ return this.cells[y * this.cols + x];
300
+ }
301
+ write(x, y, ch, style) {
302
+ const cell = this.at(x, y);
303
+ if (!cell)
304
+ return;
305
+ const bg = style.bg ?? cell.bg;
306
+ const next = { ch };
307
+ if (style.fg)
308
+ next.fg = style.fg;
309
+ if (bg)
310
+ next.bg = bg;
311
+ if (style.bold)
312
+ next.bold = true;
313
+ if (style.italic)
314
+ next.italic = true;
315
+ if (style.underline)
316
+ next.underline = true;
317
+ if (style.dim)
318
+ next.dim = true;
319
+ this.cells[y * this.cols + x] = next;
320
+ }
321
+ /**
322
+ * Clear the other half of any wide glyph that covers this cell, so a write
323
+ * never leaves an orphaned lead or continuation behind — either of which would
324
+ * change the row's printed width.
325
+ */
326
+ breakWideAt(x, y) {
327
+ const cell = this.at(x, y);
328
+ if (!cell)
329
+ return;
330
+ if (cell.ch === CONTINUATION) {
331
+ const lead = this.at(x - 1, y);
332
+ if (lead)
333
+ lead.ch = " ";
334
+ return;
335
+ }
336
+ const next = this.at(x + 1, y);
337
+ if (next && next.ch === CONTINUATION)
338
+ next.ch = " ";
339
+ }
340
+ }
341
+ /** A key that changes whenever anything about a cell's styling changes. */
342
+ function styleKey(cell) {
343
+ return [
344
+ cell.fg ? toHex(cell.fg) : "",
345
+ cell.bg ? toHex(cell.bg) : "",
346
+ cell.bold ? "b" : "",
347
+ cell.italic ? "i" : "",
348
+ cell.underline ? "u" : "",
349
+ cell.dim ? "d" : "",
350
+ ].join("|");
351
+ }
352
+ /** A cell's styling as the span props Ink takes, with every absent key left off. */
353
+ function spanStyle(cell) {
354
+ const style = {};
355
+ if (cell.fg)
356
+ style.fg = toHex(cell.fg);
357
+ if (cell.bg)
358
+ style.bg = toHex(cell.bg);
359
+ if (cell.bold)
360
+ style.bold = true;
361
+ if (cell.italic)
362
+ style.italic = true;
363
+ if (cell.underline)
364
+ style.underline = true;
365
+ if (cell.dim)
366
+ style.dim = true;
367
+ return style;
368
+ }
369
+ function size(value) {
370
+ if (!Number.isFinite(value) || value <= 0)
371
+ return 0;
372
+ return Math.floor(value);
373
+ }
374
+ /** A cell nothing has drawn on: a bare space with no colours of its own. */
375
+ function painted(cell) {
376
+ return cell.ch !== " " || cell.bg !== undefined || cell.fg !== undefined;
377
+ }
@@ -0,0 +1,145 @@
1
+ import { SKU_TEXT_REGEX, SMART_TEXT_PATTERN, VAR_REGEX, getSkuSmartTextValue, productDetail, } from "@namiml/sdk-core";
2
+ const BOLD = "**";
3
+ const ITALIC = "*";
4
+ /**
5
+ * Core's regexes are module-level and global, and core calls `.exec()` on them,
6
+ * which leaves `lastIndex` behind. Clone before every use.
7
+ */
8
+ function clone(pattern) {
9
+ return new RegExp(pattern.source, pattern.flags);
10
+ }
11
+ /** Splits authored copy into styled segments. `**bold**`, `*italic*`, `[label](url)`. */
12
+ export function parseMarkdown(source) {
13
+ return merge(parseInline(source, {}));
14
+ }
15
+ /** Replaces the smart-text tokens a string carries, leaving any it cannot resolve in place. */
16
+ export function resolveSmartText(source, deps = {}) {
17
+ if (!source.includes(SMART_TEXT_PATTERN))
18
+ return source;
19
+ let values;
20
+ const skuValues = () => (values ??= buildSkuValues(deps));
21
+ return source.replace(clone(VAR_REGEX), (match, token) => {
22
+ const injected = asText(deps.resolve?.(token));
23
+ if (injected !== undefined)
24
+ return injected;
25
+ const name = skuTokenName(match);
26
+ if (name === undefined)
27
+ return match;
28
+ return asText(skuValues()[name]) ?? match;
29
+ });
30
+ }
31
+ /** Resolves the smart text, then parses the markdown around it. */
32
+ export function renderable(source, deps) {
33
+ return parseMarkdown(resolveSmartText(source, deps));
34
+ }
35
+ /**
36
+ * The `${sku.<name>}` variable name a match carries, or `undefined` when the
37
+ * token is not a sku token. A `${sku.<name>:<ref>}` token names another sku's
38
+ * value, which only core's per-sku resolution can answer, so it is reported as
39
+ * unresolvable and stays visible.
40
+ */
41
+ function skuTokenName(match) {
42
+ const parsed = clone(SKU_TEXT_REGEX).exec(match);
43
+ if (!parsed || parsed[2])
44
+ return undefined;
45
+ return parsed[1];
46
+ }
47
+ function buildSkuValues(deps) {
48
+ const sku = deps.sku;
49
+ if (!sku)
50
+ return {};
51
+ const catalogue = Array.isArray(deps.productDetails) ? deps.productDetails : [];
52
+ const detail = productDetail(catalogue, sku.sku_ref_id ?? "");
53
+ // `getSkuSmartTextValue` writes resolved values back into `sku.variables`, so
54
+ // it gets a copy — a caller's sku must read the same on the second call.
55
+ const copy = { ...sku, variables: sku.variables ? { ...sku.variables } : undefined };
56
+ const skus = Array.isArray(deps.skus) ? deps.skus : undefined;
57
+ return getSkuSmartTextValue(detail, copy, skus);
58
+ }
59
+ /** A token value as display text, or `undefined` when there is nothing to show. */
60
+ function asText(value) {
61
+ if (value === undefined || value === null)
62
+ return undefined;
63
+ const text = Array.isArray(value) ? value.join(", ") : String(value);
64
+ return text === "" ? undefined : text;
65
+ }
66
+ function parseInline(source, style) {
67
+ const out = [];
68
+ let literal = "";
69
+ let index = 0;
70
+ const flush = () => {
71
+ if (literal !== "") {
72
+ out.push(styled(literal, style));
73
+ literal = "";
74
+ }
75
+ };
76
+ while (index < source.length) {
77
+ const link = matchLink(source, index);
78
+ if (link) {
79
+ flush();
80
+ out.push(...parseInline(link.label, { ...style, link: link.url }));
81
+ index = link.end;
82
+ continue;
83
+ }
84
+ const emphasis = matchEmphasis(source, index);
85
+ if (emphasis) {
86
+ flush();
87
+ out.push(...parseInline(emphasis.inner, { ...style, ...emphasis.style }));
88
+ index = emphasis.end;
89
+ continue;
90
+ }
91
+ literal += source[index];
92
+ index += 1;
93
+ }
94
+ flush();
95
+ return out;
96
+ }
97
+ function matchLink(source, index) {
98
+ if (source[index] !== "[")
99
+ return null;
100
+ const close = source.indexOf("]", index + 1);
101
+ if (close < 0 || source[close + 1] !== "(")
102
+ return null;
103
+ const end = source.indexOf(")", close + 2);
104
+ if (end < 0)
105
+ return null;
106
+ return { label: source.slice(index + 1, close), url: source.slice(close + 2, end), end: end + 1 };
107
+ }
108
+ function matchEmphasis(source, index) {
109
+ const marker = source.startsWith(BOLD, index) ? BOLD : source.startsWith(ITALIC, index) ? ITALIC : null;
110
+ if (!marker)
111
+ return null;
112
+ const from = index + marker.length;
113
+ const close = source.indexOf(marker, from);
114
+ // An unmatched — or empty — marker run is authored text, not emphasis.
115
+ if (close <= from)
116
+ return null;
117
+ return {
118
+ inner: source.slice(from, close),
119
+ style: marker === BOLD ? { bold: true } : { italic: true },
120
+ end: close + marker.length,
121
+ };
122
+ }
123
+ function styled(text, style) {
124
+ const segment = { text };
125
+ if (style.bold)
126
+ segment.bold = true;
127
+ if (style.italic)
128
+ segment.italic = true;
129
+ if (style.link !== undefined)
130
+ segment.link = style.link;
131
+ return segment;
132
+ }
133
+ function sameStyle(a, b) {
134
+ return a.bold === b.bold && a.italic === b.italic && a.link === b.link;
135
+ }
136
+ function merge(segments) {
137
+ return segments.reduce((out, segment) => {
138
+ const previous = out[out.length - 1];
139
+ if (previous && sameStyle(previous, segment))
140
+ previous.text += segment.text;
141
+ else
142
+ out.push(segment);
143
+ return out;
144
+ }, []);
145
+ }
@@ -1,20 +1,38 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { palette } from "../theme.js";
4
4
  import { EventsPane } from "./EventsPane.js";
5
+ import { APPROXIMATION_NOTE, DEFAULT_VIEWPORT, NO_PRODUCTS_NOTE, PageRender, focusablePageNodes, frameCols, hasProductCards, } from "./PageRender.js";
5
6
  /**
6
- * A launched placement: the page outline on one side, what the SDK reported on
7
- * the other.
7
+ * A launched placement: the page on one side, what the SDK reported on the
8
+ * other.
9
+ *
10
+ * Two views of the same page share one pane and `t` swaps them. The **render**
11
+ * (NAM-4548) draws the page the way the paywall looks — authored colours,
12
+ * authored layout, real images — and is what the screen opens on. The
13
+ * **outline** (NAM-4542) is the developer's tree: every authored node in source
14
+ * order, scaffolding and hidden subtrees included, which is what you want when
15
+ * a paywall renders wrong. Neither replaces the other.
8
16
  *
9
17
  * Pure presentation plus input — it renders the props it is handed and turns
10
- * keys into callbacks. The subscriptions, the bus and the focus index live in
11
- * `src/placements/launch.ts` and `Shell`, so this file can be rendered from a
12
- * test with nothing configured.
18
+ * keys into callbacks. The subscriptions, the bus, the view, the focus index
19
+ * and the scroll offset live in `src/placements/launch.ts` and `Shell`, so this
20
+ * file can be rendered from a test with nothing configured.
13
21
  */
14
22
  /** At or above this many columns the two panes sit side by side; below it they stack. */
15
23
  export const SIDE_BY_SIDE_MIN_WIDTH = 100;
16
24
  /** Columns between the two panes when they sit side by side. */
17
25
  const GUTTER = 2;
26
+ /**
27
+ * Columns the Events pane keeps when the page asks for more.
28
+ *
29
+ * `EventRow` spends two on its marker and the rest on a timestamp, the event
30
+ * name and whatever of the payload fits, so below about this it stops being a
31
+ * readable log and becomes a column of ellipses.
32
+ */
33
+ const EVENTS_MIN_WIDTH = 44;
34
+ /** Rows one PgUp / PgDn moves the frame. */
35
+ const SCROLL_STEP = 10;
18
36
  /**
19
37
  * What the arrows move between: a node the paywall gave an action to, that is
20
38
  * actually on screen. The walker already drops `onTap` from a hidden subtree;
@@ -24,6 +42,20 @@ const GUTTER = 2;
24
42
  export function focusableNodes(composite) {
25
43
  return composite.sections.flatMap((section) => section.nodes).filter((node) => node.onTap && !node.hidden);
26
44
  }
45
+ /**
46
+ * The press the active view's focused node stands for.
47
+ *
48
+ * The render model and the outline are different node shapes, and `pressNode`
49
+ * speaks the outline's. A render node therefore presses as the `onTap` it
50
+ * carries — which is the only field `pressNode` reads — so both views report
51
+ * through exactly the same mapper.
52
+ */
53
+ export function pressableFor(node) {
54
+ if (!node)
55
+ return undefined;
56
+ const onTap = "onTap" in node ? node.onTap : undefined;
57
+ return { depth: 0, kind: node.kind, onTap };
58
+ }
27
59
  function clip(text, max) {
28
60
  if (max <= 1 || text.length <= max)
29
61
  return text;
@@ -41,23 +73,46 @@ function nodeLine(node) {
41
73
  parts.push("· hidden");
42
74
  return `${" ".repeat(node.depth)}${parts.join(" ")}`;
43
75
  }
76
+ /**
77
+ * Why the pane is empty, which both views need and neither may guess at: a flow
78
+ * has no one page, a paywall that resolved and served nothing is an authoring
79
+ * question, and a paywall that resolved at all is usually a device mismatch.
80
+ */
81
+ function PaneNotice({ composite, placement, deviceName }) {
82
+ if (composite.sections.length > 0)
83
+ return null;
84
+ if (placement.kind === "flow") {
85
+ return _jsx(Text, { dimColor: true, children: "A flow has no one page to outline \u2014 its screens and steps report as flow events in the pane." });
86
+ }
87
+ if (composite.unresolved) {
88
+ return (_jsxs(Text, { dimColor: true, children: ["No paywall for this placement on ", deviceName ? `a ${deviceName}` : "the configured device", " \u2014 it is probably authored for another form factor. Switch device (d on the home screen, or --device) and launch again."] }));
89
+ }
90
+ return _jsx(Text, { dimColor: true, children: "Nothing to outline on this page \u2014 the paywall resolved but served no components." });
91
+ }
44
92
  function CompositePane({ composite, placement, focused, width, deviceName }) {
45
- const empty = composite.sections.length === 0;
46
- return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Page" }), " ", _jsx(Text, { children: composite.pageName || "—" })] }), empty && placement.kind === "flow" && (_jsx(Text, { dimColor: true, children: "A flow has no one page to outline \u2014 its screens and steps report as flow events in the pane." })), empty && placement.kind !== "flow" && composite.unresolved && (_jsxs(Text, { dimColor: true, children: ["No paywall for this placement on ", deviceName ? `a ${deviceName}` : "the configured device", " \u2014 it is probably authored for another form factor. Switch device (d on the home screen, or --device) and launch again."] })), empty && placement.kind !== "flow" && !composite.unresolved && (_jsx(Text, { dimColor: true, children: "Nothing to outline on this page \u2014 the paywall resolved but served no components." })), composite.sections.map((section) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: section.name }), section.nodes.map((node, index) => {
93
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Page" }), " ", _jsx(Text, { children: composite.pageName || "—" })] }), _jsx(PaneNotice, { composite: composite, placement: placement, deviceName: deviceName }), composite.sections.map((section) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: section.name }), section.nodes.map((node, index) => {
47
94
  const isFocused = node === focused;
48
95
  return (_jsxs(Text, { dimColor: node.hidden, children: [_jsx(Text, { color: palette.aqua, children: isFocused ? "›" : " " }), " ", _jsx(Text, { bold: isFocused, children: clip(nodeLine(node), Math.max(width - 2, 1)) })] }, `${section.name}-${index}`));
49
96
  })] }, section.name))), composite.truncated > 0 && _jsxs(Text, { dimColor: true, children: ["+", composite.truncated, " more"] })] }));
50
97
  }
51
- export function LaunchScreen({ placement, composite, events, width, paused, expanded, selectedSeq, focusIndex, onPress, onMove, onTogglePause, onClear, onToggleExpand, onBack, notice, deviceName }) {
52
- const focusable = focusableNodes(composite);
53
- const focused = focusable[focusIndex];
98
+ export function LaunchScreen({ placement, composite, page, caps, events, width, height, viewport, view, paused, expanded, selectedSeq, focusIndex, scrollTop, onPress, onMove, onScroll, onToggleView, onTogglePause, onClear, onToggleExpand, onBack, notice, deviceName }) {
99
+ const rendering = view === "render";
100
+ const outlineFocus = focusableNodes(composite)[focusIndex];
101
+ const renderFocus = focusablePageNodes(page)[focusIndex];
102
+ const pressable = rendering ? pressableFor(renderFocus) : outlineFocus;
54
103
  useInput((input, key) => {
104
+ if (key.pageUp)
105
+ return onScroll(-SCROLL_STEP);
106
+ if (key.pageDown)
107
+ return onScroll(SCROLL_STEP);
55
108
  if (key.upArrow || key.leftArrow)
56
109
  return onMove(-1);
57
110
  if (key.downArrow || key.rightArrow)
58
111
  return onMove(1);
59
112
  if (key.return)
60
- return void (focused && onPress(focused));
113
+ return void (pressable && onPress(pressable));
114
+ if (input === "t")
115
+ return onToggleView();
61
116
  if (input === "p")
62
117
  return onTogglePause();
63
118
  if (input === "c")
@@ -68,7 +123,23 @@ export function LaunchScreen({ placement, composite, events, width, paused, expa
68
123
  return onBack();
69
124
  });
70
125
  const sideBySide = width >= SIDE_BY_SIDE_MIN_WIDTH;
71
- const paneWidth = sideBySide ? Math.floor(width / 2) - 1 : width;
126
+ const empty = composite.sections.length === 0;
127
+ // Only a drawn device frame gets to size the pane. The outline and the
128
+ // empty-state notice are text: they want the columns, and a phone frame's
129
+ // width would crop their sentences for no reason.
130
+ const showingFrame = rendering && !empty;
131
+ // For a frame, ask for exactly what it can use and no more — an even split
132
+ // starves it on a 120-column terminal and wastes columns on a 200-column one.
133
+ // The frame is usually limited by its rows rather than its columns, so the
134
+ // same height budget goes in here, plus two for its own border. The Events
135
+ // pane keeps its floor either way.
136
+ const pageRoom = Math.max(1, width - GUTTER - EVENTS_MIN_WIDTH);
137
+ const evenSplit = Math.floor(width / 2) - 1;
138
+ const paneWidth = !sideBySide
139
+ ? width
140
+ : showingFrame
141
+ ? Math.min(frameCols(viewport ?? DEFAULT_VIEWPORT, pageRoom - 2, Math.max(height - 4, 1)) + 2, pageRoom)
142
+ : evenSplit;
72
143
  const eventsWidth = sideBySide ? width - paneWidth - GUTTER : width;
73
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: placement.label }), " ", _jsxs(Text, { color: palette.secondaryBlue, children: ["[", placement.kind, "]"] }), placement.campaignName && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", placement.campaignName] })] }), _jsxs(Box, { flexDirection: sideBySide ? "row" : "column", marginTop: 1, children: [_jsx(CompositePane, { composite: composite, placement: placement, focused: focused, width: paneWidth, deviceName: deviceName }), _jsx(Box, { flexDirection: "column", marginLeft: sideBySide ? GUTTER : 0, marginTop: sideBySide ? 0 : 1, children: _jsx(EventsPane, { events: events, selectedSeq: selectedSeq ?? null, paused: paused, expanded: expanded, width: eventsWidth }) })] }), notice && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.coral, children: notice }) })), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 enter press \u00B7 p pause \u00B7 c clear \u00B7 x expand \u00B7 q back" })] }));
144
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: placement.label }), " ", _jsxs(Text, { color: palette.secondaryBlue, children: ["[", placement.kind, "]"] }), placement.campaignName && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", placement.campaignName] })] }), _jsxs(Box, { flexDirection: sideBySide ? "row" : "column", marginTop: 1, children: [rendering ? (_jsx(Box, { flexDirection: "column", width: paneWidth, children: empty ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Page" }), " ", _jsx(Text, { children: page.pageName || "—" })] }), _jsx(PaneNotice, { composite: composite, placement: placement, deviceName: deviceName })] })) : (_jsx(PageRender, { page: page, caps: caps, width: paneWidth, height: height, viewport: viewport, focused: renderFocus, scrollTop: scrollTop })) })) : (_jsx(CompositePane, { composite: composite, placement: placement, focused: outlineFocus, width: paneWidth, deviceName: deviceName })), _jsx(Box, { flexDirection: "column", marginLeft: sideBySide ? GUTTER : 0, marginTop: sideBySide ? 0 : 1, children: _jsx(EventsPane, { events: events, selectedSeq: selectedSeq ?? null, paused: paused, expanded: expanded, width: eventsWidth }) })] }), notice && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.coral, children: notice }) })), rendering && !empty && _jsx(Text, { dimColor: true, children: APPROXIMATION_NOTE }), rendering && !empty && page && hasProductCards(page.root) && _jsx(Text, { dimColor: true, children: NO_PRODUCTS_NOTE }), _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 move \u00B7 enter press \u00B7 t ", rendering ? "outline" : "render", rendering ? " · PgUp/PgDn scroll" : "", " \u00B7 p pause \u00B7 c clear \u00B7 x expand \u00B7 q back"] })] }));
74
145
  }