pi-openui 0.2.0 → 0.3.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.
@@ -133,6 +133,11 @@ export interface BoxOpts {
133
133
 
134
134
  /** Wrap a block in a rounded box. `inner` is placed with padX columns of padding. Returns the boxed block. */
135
135
  export function boxed(inner: Block, opts: BoxOpts): Block {
136
+ // A title that does not fit its border is not cut: it moves inside as a wrapped heading, above the content.
137
+ if (opts.title && textWidth(opts.title) > Math.max(1, inner.w + 2 * (opts.padX ?? 1) - 4 - (opts.tag ? textWidth(opts.tag) + 2 : 0))) {
138
+ const head = wrapText(opts.title, Math.max(1, inner.w), opts.titleRole ?? opts.role).map((r) => padRow(r, inner.w));
139
+ return boxed({ w: inner.w, rows: [...head, ...inner.rows], targets: shiftTargets(inner.targets, 0, head.length) }, { ...opts, title: undefined });
140
+ }
136
141
  const padX = opts.padX ?? 1;
137
142
  const w = inner.w + 2 + 2 * padX;
138
143
  const role = opts.role;
@@ -129,7 +129,8 @@ function attempt(norm: Norm, dir: "LR" | "TB", cap: number): DiagramResult {
129
129
  return layeredDiagram("dag", { nodes, edges, dashReversed: true }, dir);
130
130
  }
131
131
 
132
- const CAPS = [28, 20, 14, 10, 8, 6];
132
+ /** How far labels shrink to fit the pane. Below 14 characters a label is no longer a name, so past this the graph scrolls with whole labels. */
133
+ const CAPS = [28, 20, 14];
133
134
 
134
135
  export function dag(nodesIn: unknown, edgesIn: unknown, opts: DagOptions = {}): DiagramResult {
135
136
  return budgeted(() => dagLayout(nodesIn, edgesIn, opts));
@@ -159,8 +160,8 @@ function dagLayout(nodesIn: unknown, edgesIn: unknown, opts: DagOptions): Diagra
159
160
  if (longest <= cap) break; // shrinking more changes nothing
160
161
  }
161
162
  if (!chosen) {
162
- // nothing fits even with short labels: the view will pan anyway, so keep the labels readable
163
- const cap = Math.min(maxLabel, 14);
163
+ // nothing fits even with short labels: the view will scroll, so scroll to whole labels instead of cutting them as well
164
+ const cap = maxLabel;
164
165
  const tries = dirs.map((d) => attempt(norm, d, cap));
165
166
  best = tries.reduce((a, b) => (b.width < a.width ? b : a));
166
167
  }
@@ -146,7 +146,8 @@ function tableSpec(e: NormErd["entities"][number], fieldCap: number, selfRef: bo
146
146
  };
147
147
  }
148
148
 
149
- const CAPS = [60, 40, 30, 22, 16];
149
+ /** How far an entity's lines shrink to fit the pane. Below 30 characters the types and flags are dropped, so past this the diagram scrolls. */
150
+ const CAPS = [60, 40, 30];
150
151
 
151
152
  export function erd(entitiesIn: unknown, relationsIn: unknown, opts: ErdOptions = {}): DiagramResult {
152
153
  return budgeted(() => erdLayout(entitiesIn, relationsIn, opts));
@@ -178,7 +179,8 @@ function erdLayout(entitiesIn: unknown, relationsIn: unknown, opts: ErdOptions):
178
179
  if (chosen) break;
179
180
  }
180
181
  if (!chosen) {
181
- const nodes = norm.entities.map((e, i) => tableSpec(e, 30, norm.selfRefs.has(i)));
182
+ // it will scroll whatever the fields: keep them whole (the widest cap) rather than cut them as well
183
+ const nodes = norm.entities.map((e, i) => tableSpec(e, CAPS[0], norm.selfRefs.has(i)));
182
184
  const tries = dirs.map((d) => layeredDiagram("erd", { nodes, edges, portSpacing: 4 }, d));
183
185
  best = tries.reduce((a, b) => (b.width < a.width ? b : a));
184
186
  }
@@ -402,11 +402,11 @@ function build(norm: NormInfra, dir: "LR" | "TB", caps: Caps): DiagramResult {
402
402
  };
403
403
  }
404
404
 
405
+ /** How far labels shrink to fit the pane. Past the last row the map scrolls with whole labels, not 8-character ones. */
405
406
  const CAPS: Caps[] = [
406
407
  { label: 22, detail: 26, link: 18 },
407
408
  { label: 16, detail: 18, link: 12 },
408
409
  { label: 12, detail: 12, link: 9 },
409
- { label: 8, detail: 8, link: 6 },
410
410
  ];
411
411
 
412
412
  export function infra(zonesIn: unknown, linksIn: unknown, opts: InfraOptions = {}): DiagramResult {
@@ -434,7 +434,8 @@ function infraLayout(zonesIn: unknown, linksIn: unknown, opts: InfraOptions): Di
434
434
  if (chosen) break;
435
435
  }
436
436
  if (!chosen) {
437
- const tries = dirs.map((d) => build(norm, d, CAPS[1]));
437
+ // it will scroll whatever the labels: use the whole ones (the first, widest caps) rather than cut them as well
438
+ const tries = dirs.map((d) => build(norm, d, CAPS[0]));
438
439
  best = tries.reduce((a, b) => (b.width < a.width ? b : a));
439
440
  }
440
441
  const res = chosen ?? best!;
package/src/lang/docs.ts CHANGED
@@ -29,7 +29,10 @@ export const DOCS: Record<string, ComponentDoc> = {
29
29
  Stack: {
30
30
  group: "Layout and text",
31
31
  summary: "Lays its children out in a column (the default) or a row. Every widget starts with `root = Stack([...])`.",
32
- shots: [{ file: "stack", caption: "A row of tiles, then a row of buttons that wraps when it runs out of width" }],
32
+ shots: [
33
+ { file: "stack", caption: "A row of tiles, then a row of buttons that wraps when it runs out of width" },
34
+ { file: "tiles-wrap", caption: "On a phone-width pane (52 columns) the same idea: tiles and buttons wrap onto more rows, every label whole" },
35
+ ],
33
36
  use: ["The root of every widget.", "A row of metric tiles or buttons.", "Grouping a few components with a chosen gap."],
34
37
  avoid: "Do not put a Stack inside a Stack just to add space: use `gap`.",
35
38
  args: {
@@ -46,7 +49,7 @@ b = Metric("Middle", "2")
46
49
  c = Metric("Right", "3")`,
47
50
  tips: [
48
51
  "In a row, buttons, metric tiles and short text keep their natural width; every other child shares what is left equally.",
49
- "A row that is wider than the terminal shrinks its widest children first, never below 6 columns.",
52
+ "A row of buttons, tiles and short text that does not fit the terminal wraps onto more rows instead of squeezing them. A row with a child that shares the space (a Card, a Table) keeps its children side by side and shrinks the widest first, never below 6 columns.",
50
53
  "Inside Tabs, wrap a tab's content in one Stack: items placed directly in a tab touch each other.",
51
54
  ],
52
55
  related: ["Card", "Tabs", "Text"],
@@ -71,9 +74,12 @@ t3 = Text("Updated 2 minutes ago", "muted")`,
71
74
  Card: {
72
75
  group: "Layout and text",
73
76
  summary: "A bordered panel with an optional title in the top border.",
74
- shots: [{ file: "card", caption: "A card with a nested card" }],
77
+ shots: [
78
+ { file: "card", caption: "A card with a nested card" },
79
+ { file: "card-wide", caption: "A title too long for the border moves inside as a heading; a callout breaks a long word instead of cutting it" },
80
+ ],
75
81
  use: ["Giving a chart or table a heading and a frame.", "Separating sections inside one widget."],
76
- args: { "Card.children": "The components inside. They are laid out as a column with the default gap.", "Card.title": "Shown in the top border. Cut with an ellipsis if it does not fit." },
82
+ args: { "Card.children": "The components inside. They are laid out as a column with the default gap.", "Card.title": "Shown in the top border. If it does not fit there it moves inside the card as a wrapped heading." },
77
83
  example: `root = Stack([c])
78
84
  c = Card([chart], "Load by node")
79
85
  chart = BarChart(["a", "b", "c"], [3, 5, 2])`,
@@ -118,6 +124,9 @@ b = Metric("p95 latency", "180 ms", "+22 ms", "warn")`,
118
124
  { file: "table", caption: "Numbers right-aligned, long text truncated with an ellipsis" },
119
125
  { file: "table-tooltip", caption: "Hovering the row with a tooltip shows its hint in a bubble" },
120
126
  { file: "table-panel", caption: "Clicking it, or pressing Enter, opens the detail panel" },
127
+ { file: "table-cut", caption: "On a narrow pane a few columns fit by squeezing: the cells that were cut end in an ellipsis" },
128
+ { file: "table-read-full", caption: "Tapping a cut cell (or Tab to it and Enter) opens it whole under the table, as plain text" },
129
+ { file: "pan-table", caption: "Many columns do not fit: the table scrolls under a bar, and the columns the model pinned (Host and State here) stay in place" },
121
130
  ],
122
131
  use: ["Comparing a handful of items across several fields.", "A list of results with a status column."],
123
132
  avoid: "More than about 12 rows: summarise, or split across Tabs.",
@@ -125,14 +134,17 @@ b = Metric("p95 latency", "180 ms", "+22 ms", "warn")`,
125
134
  "Table.columns": "The header strings.",
126
135
  "Table.rows": "An array of rows. Each row has exactly one cell per column, a string or a number. A mismatch is a validation error that names the row.",
127
136
  "Table.tooltips": "Targets: `row:3` (1-based, not counting the header), `col:status`, or `cell:3.status`. Duplicate header names make `col:` and `cell:` ambiguous.",
137
+ "Table.pin": "The 4th argument: header names of the columns that stay in place while a table wider than the terminal scrolls sideways, such as `[\"Host\"]` (write `null` for tooltips you do not have: `Table(cols, rows, null, [\"Host\"])`). Left out, nothing is pinned and the whole table scrolls. Pin only the one or two narrow columns that identify each row: pinned columns take at most half the width, and one that would not fit is not pinned (the table then scrolls whole). A name that is not a column is a validation error that lists the columns. On a table that fits, nothing changes.",
128
138
  },
129
139
  example: `root = Stack([t])
130
140
  t = Table(["Node", "State", "GPU %"], [["spark-1", "serving", 91], ["spark-2", "draining", 84]], [tip])
131
141
  tip = Tooltip("row:2", "Rebooting for a driver update")`,
132
- interaction: ["A tooltip target shows a `◆` in the cell padding after its text.", "Hover, or Tab to the row, for the bubble; click or Enter for the panel."],
142
+ interaction: ["A tooltip target shows a `◆` in the cell padding after its text.", "Hover, or Tab to the row, for the bubble; click or Enter for the panel.", "A cell that was cut to fit ends in `…`. Click it, or Tab to it and press Enter, to open it whole under the table as plain text; click it again or press Esc to close it. A cut cell that also has a tooltip shows the whole text in the tooltip's panel."],
133
143
  tips: [
134
- "When the table is wider than the terminal the widest columns shrink first, to a minimum of 3 characters.",
135
- "A table that still does not fit becomes a scrollable window; moving focus to a target scrolls it into view.",
144
+ "When the table is wider than the terminal the widest columns shrink first, but never below the minimum column width (12 characters unless the user changes it: see Settings in the README).",
145
+ "A table that still does not fit becomes a window that scrolls sideways under a pan bar. Nothing is pinned unless you list it in `pin`: pin the one or two narrow columns that identify each row (a host, an id, a date) when a row is hard to read without them, and leave `pin` out otherwise. Pinned columns never take more than half the width: they are tried in the order you list them, and one that would not fit is skipped (never squeezed), so a long column is not pinned by accident and nothing in the table is ever out of reach.",
146
+ "While a wide table scrolls, its pinned columns are drawn first, in the order they have in the table, then the others. A table that fits is drawn in the order you wrote.",
147
+ "You cannot see the width of the user's terminal, so do not shorten text to make a table fit: a column is squeezed only to the user's minimum width (and a cell cut by that squeeze ends in an ellipsis; the reader can tap it to read it whole); a table that needs more than that scrolls instead, and then no cell is cut.",
136
148
  ],
137
149
  related: ["Markdown", "Metric", "Tooltip"],
138
150
  },
@@ -143,12 +155,13 @@ tip = Tooltip("row:2", "Rebooting for a driver update")`,
143
155
  shots: [
144
156
  { file: "tabs", caption: "The first tab is open when the widget is drawn" },
145
157
  { file: "tabs-nodes", caption: "After clicking Nodes" },
158
+ { file: "tabs-wrap", caption: "On a narrow pane (44 columns) a strip that does not fit wraps onto more rows; every label stays whole and tappable" },
146
159
  ],
147
160
  use: ["Alternate views of one subject: overview, nodes, notes.", "The same snippet in several languages."],
148
161
  avoid: "Steps that must be read in order, or content the reader must see at once: a tab that is not selected is hidden.",
149
162
  args: {
150
163
  "Tabs.items": "One TabItem per tab. The first is open at the start.",
151
- "TabItem.label": "The text in the strip. Cut to 22 characters, and shorter when the strip does not fit.",
164
+ "TabItem.label": "The text in the strip. When the labels do not fit the terminal the strip wraps onto more rows; a label wider than the whole terminal is the only one that is cut.",
152
165
  "TabItem.content": "The components in the tab. Wrap them in a single Stack: `[panel]`.",
153
166
  },
154
167
  example: `root = Stack([tabs])
@@ -255,6 +268,7 @@ b = FaqItem("How do I undo it?", "Run \`uncordon\`.")`,
255
268
  shots: [
256
269
  { file: "markdown", caption: "Headings, emphasis, lists, a quote, a table, a rule and a link" },
257
270
  { file: "markdown-image", caption: "A picture inside the text, written as a line of its own, with its caption underneath" },
271
+ { file: "markdown-wide", caption: "At 60 columns a code fence and a table that are wider than the pane scroll on their own, each under its own bar" },
258
272
  ],
259
273
  use: ["Release notes, explanations, anything with structure.", "A README-style view: text with pictures in it."],
260
274
  args: { "Markdown.text": "Markdown. Use `\\n` for newlines. Supported: `#`-`######` headings, **bold**, *italic*, `code`, ~~strike~~, `[text](https://example.com)`, lists (nested), `>` quotes, tables, fenced code, `---`, and a picture: a line that is only `![alt](path \"caption\")`." },
@@ -270,7 +284,10 @@ m = Markdown("# Notes\\n\\nThis adds **streaming** and \`--fast\` mode.\\n\\n- f
270
284
  CodeBlock: {
271
285
  group: "Rich content",
272
286
  summary: "Syntax-highlighted code in a frame, with a copy button.",
273
- shots: [{ file: "code", caption: "Highlighting for JavaScript, SQL, YAML, JSON, bash, Go and Rust" }],
287
+ shots: [
288
+ { file: "code", caption: "Highlighting for JavaScript, SQL, YAML, JSON, bash, Go and Rust" },
289
+ { file: "code-pan", caption: "A line wider than the pane is not cut: the code scrolls inside its box, under a bar, and [copy] still copies all of it" },
290
+ ],
274
291
  use: ["Commands to run, config to paste, a query."],
275
292
  args: {
276
293
  "CodeBlock.code": "The source. Use `\\n` for new lines. Tabs become two spaces; more than 200 lines are cut.",
@@ -280,7 +297,7 @@ m = Markdown("# Notes\\n\\nThis adds **streaming** and \`--fast\` mode.\\n\\n- f
280
297
  example: `root = Stack([c])
281
298
  c = CodeBlock("kubectl rollout restart deploy/api\\nkubectl rollout status deploy/api", "bash", "restart.sh")`,
282
299
  interaction: ["Click `[copy]` (or Tab to it and press Enter) to put the exact code on the clipboard with an OSC 52 escape. The label changes to `✓ copied` for two seconds."],
283
- tips: ["Long lines are cut with an ellipsis, not wrapped, so the code stays aligned. The copy still has the full line.", "The clipboard needs a terminal that accepts OSC 52 (most modern ones do)."],
300
+ tips: ["Long lines are not wrapped (the code stays aligned) and not cut: when a line is wider than the box the code scrolls sideways inside it, under a pan bar. Lines are cut at 240 columns. The copy has every line in full.", "The clipboard needs a terminal that accepts OSC 52 (most modern ones do)."],
284
301
  related: ["Markdown"],
285
302
  },
286
303
  Image: {
@@ -458,6 +475,7 @@ b = GeoPoint("Frankfurt", 50.11, 8.68, "warn")`,
458
475
  shots: [
459
476
  { file: "dag", caption: "A CI run with every status: done, failed, running, skipped and pending" },
460
477
  { file: "dag-tooltip", caption: "A focused node: bold accent border, hint bubble, and the tooltip panel below" },
478
+ { file: "dag-wide", caption: "Long step names on a 60-column pane: the labels shrink a little, then the graph scrolls with whole labels" },
461
479
  ],
462
480
  use: ["A pipeline where each step has a state.", "A fan-out and fan-in.", "Which job waits for which."],
463
481
  avoid: "Database tables (Erd) or a plain list of steps (Table).",
@@ -468,7 +486,7 @@ b = GeoPoint("Frankfurt", 50.11, 8.68, "warn")`,
468
486
  "Dag.title": "A bold line above the graph.",
469
487
  "Dag.tooltips": "Target: a node id.",
470
488
  "DagNode.id": "Unique in the graph. Edges refer to it.",
471
- "DagNode.label": "The text in the box. Shortened with an ellipsis when space is tight.",
489
+ "DagNode.label": "The text in the box. Shortened to 14 characters at the least to fit the terminal; if it still does not fit, the graph scrolls sideways with whole labels (28 characters at most).",
472
490
  "DagNode.status": "`done` ✓, `running` ◔, `failed` ✗, `pending` ○ or `skipped` ⊘ (also a dashed box). Omit it for a plain box. A legend names the statuses in use.",
473
491
  },
474
492
  example: `root = Stack([flow])
@@ -480,7 +498,7 @@ c = DagNode("deploy", "Deploy", "pending")`,
480
498
  tips: [
481
499
  "A cycle is allowed: the edge that closes it is drawn dashed, pointing back.",
482
500
  "Every edge has its own wire, wires cross only at right angles (`┼`), and the layout is checked by decoding the picture back into edges. An edge that truly cannot be routed is left out and listed under the graph as `a -> b (not drawn)`.",
483
- "Wider than the terminal: labels shrink first, then the direction switches, then the view scrolls sideways.",
501
+ "Wider than the terminal: labels shrink first (never below 14 characters), then the direction switches, then the view scrolls sideways with whole labels.",
484
502
  ],
485
503
  related: ["Erd", "InfraMap", "SequenceDiagram"],
486
504
  },
@@ -520,7 +538,10 @@ r = Relation("users", "projects", "1:N", "owns")`,
520
538
  group: "Diagrams and maps",
521
539
  summary: "Zones holding nodes (servers, databases, queues), with labelled arrows showing which talks to which.",
522
540
  helpers: ["InfraZone", "InfraNode", "InfraLink"],
523
- shots: [{ file: "infra", caption: "Three zones, with a degraded worker and a dashed link" }],
541
+ shots: [
542
+ { file: "infra", caption: "Three zones, with a degraded worker and a dashed link" },
543
+ { file: "infra-wide", caption: "A four-zone map on a 60-column pane: whole labels, the first zone in view, the rest a swipe or a tap on » away" },
544
+ ],
524
545
  use: ["How a system fits together.", "Where a fault is: nodes carry a status."],
525
546
  avoid: "More than about 4 zones and 12 nodes: split it in two.",
526
547
  args: {
@@ -556,7 +577,10 @@ l = InfraLink("lb", "api", "HTTP")`,
556
577
  group: "Diagrams and maps",
557
578
  summary: "Calls between participants: columns left to right, steps top to bottom, with notes and alt/loop frames.",
558
579
  helpers: ["SeqParticipant", "SeqMessage", "SeqNote", "SeqGroup"],
559
- shots: [{ file: "sequence", caption: "An alt/else frame, a note, a self-call and activation bars" }],
580
+ shots: [
581
+ { file: "sequence", caption: "An alt/else frame, a note, a self-call and activation bars" },
582
+ { file: "sequence-wide", caption: "Five participants and long labels on a 60-column pane: it scrolls, labels whole" },
583
+ ],
560
584
  use: ["The order of calls in a request.", "Who calls whom, and what comes back."],
561
585
  avoid: "More than about 5 participants or 20 steps: split it.",
562
586
  args: {
@@ -104,7 +104,7 @@ export const SPECS: CompSpec[] = [
104
104
  { name: "Text", params: [p("text", S, false, LIMITS.text), p("variant", e("title", "heading", "body", "muted", "code"), true)], desc: "A line or paragraph of text (default body)." },
105
105
  { name: "Card", params: [p("children", kids), p("title", S, true)], desc: "Bordered panel with an optional title." },
106
106
  { name: "Metric", params: [p("label", S), p("value", S), p("delta", S, true), p("tone", e("good", "warn", "bad", "neutral"), true), p("tooltips", tips, true)], desc: "KPI tile. value and delta are strings. tone colours the delta." },
107
- { name: "Table", params: [p("columns", { arr: S, max: LIMITS.tableCols }), p("rows", { arr: { arr: "cell", max: LIMITS.tableCols }, max: LIMITS.tableRows }), p("tooltips", tips, true)], desc: "Data table. rows is an array of rows, each the same length as columns (strings or numbers)." },
107
+ { name: "Table", params: [p("columns", { arr: S, max: LIMITS.tableCols }), p("rows", { arr: { arr: "cell", max: LIMITS.tableCols }, max: LIMITS.tableRows }), p("tooltips", tips, true), p("pin", { arr: S, max: LIMITS.tableCols }, true)], desc: "Data table. rows is an array of rows, each the same length as columns (strings or numbers). pin (4th argument, a list of header names) keeps those columns in place while a table wider than the reader's screen scrolls sideways. Nothing is pinned unless you list it: leave pin out when the rows are readable without a fixed column. Pin only the one or two narrow columns that identify each row (a name, an id). Pinned columns take at most half the width, and one that would not fit is not pinned: the table then just scrolls whole." },
108
108
  { name: "BarChart", params: [p("labels", { arr: S, max: LIMITS.bars }), p("values", { arr: "number", max: LIMITS.bars }), p("title", S, true), p("unit", S, true), p("tooltips", tips, true)], desc: "Bar chart of one series. labels and values are parallel arrays. unit is a suffix such as %." },
109
109
  { name: "Callout", params: [p("text", S), p("tone", e("info", "success", "warn", "error"), true)], desc: "Highlighted note." },
110
110
  { name: "Button", params: [p("label", S), p("action", "action", true)], desc: "Clickable button. Without an action it sends its label to the assistant." },
@@ -221,9 +221,71 @@ export interface Compiled {
221
221
  ok: boolean;
222
222
  errors: string[];
223
223
  warnings: string[];
224
+ /** what `restore` mode changed to make an older source draw (values cut to a limit, arguments ignored, ...). Always empty in strict mode. */
225
+ notes: string[];
224
226
  root?: El;
225
227
  }
226
228
 
229
+ /**
230
+ * The version of OpenUI Lang this code reads. A widget written by an earlier version of the language is version 1 or whatever was
231
+ * stored with it. Bump it when a change would stop an older source from compiling (a removed or renamed component or argument, a
232
+ * positional argument inserted, a required one added, a lower limit), and add an alias or a migration below. AGENTS.md has the rule.
233
+ */
234
+ export const LANG_VERSION = 1;
235
+
236
+ /** An old component name and what it is called now. Applied only when an old source is read again (`restore`), never to what the model writes. */
237
+ export const ALIASES: Record<string, string> = {};
238
+
239
+ /** Rewrites a source written under version `from` into one the next version reads. Run in order, only in `restore` mode. */
240
+ export const MIGRATIONS: Array<{ from: number; run(source: string): string }> = [];
241
+
242
+ export interface CompileOpts {
243
+ /** `strict` (default) is for what the model writes: every mistake is an error it can fix. `restore` is for a source read again from a session. */
244
+ mode?: "strict" | "restore";
245
+ /** the language version the source was written under (default 1) */
246
+ from?: number;
247
+ /** tests pass their own; the real tables above are the default */
248
+ migrations?: typeof MIGRATIONS;
249
+ aliases?: typeof ALIASES;
250
+ }
251
+
252
+ /** Rename `Old(` to `New(` outside string literals. */
253
+ function renameCalls(source: string, aliases: Record<string, string>): string {
254
+ const names = Object.keys(aliases);
255
+ if (!names.length) return source;
256
+ let out = "";
257
+ let i = 0;
258
+ while (i < source.length) {
259
+ const ch = source[i];
260
+ if (ch === '"') {
261
+ let j = i + 1;
262
+ while (j < source.length && source[j] !== '"') j += source[j] === "\\" ? 2 : 1;
263
+ out += source.slice(i, j + 1);
264
+ i = j + 1;
265
+ continue;
266
+ }
267
+ const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(i, i + 80));
268
+ if (m) {
269
+ const word = m[0];
270
+ const after = source.slice(i + word.length).match(/^\s*\(/);
271
+ out += after && Object.prototype.hasOwnProperty.call(aliases, word) ? aliases[word] : word;
272
+ i += word.length;
273
+ continue;
274
+ }
275
+ out += ch;
276
+ i++;
277
+ }
278
+ return out;
279
+ }
280
+
281
+ /** An older source brought up to date: migrations for the versions after the one it was written under, then renamed components. */
282
+ function upgrade(source: string, opts: CompileOpts): string {
283
+ const from = opts.from ?? 1;
284
+ let text = source;
285
+ for (const m of [...(opts.migrations ?? MIGRATIONS)].sort((a, b) => a.from - b.from)) if (m.from >= from) text = m.run(text);
286
+ return renameCalls(text, opts.aliases ?? ALIASES);
287
+ }
288
+
227
289
  export function showType(t: T): string {
228
290
  if (typeof t === "string") return t === "child" ? "component" : t === "cell" ? "string | number" : t;
229
291
  if ("enum" in t) return t.enum.map((x) => JSON.stringify(x)).join(" | ");
@@ -235,28 +297,32 @@ export function showType(t: T): string {
235
297
  }
236
298
 
237
299
  /** Reads and checks a program. Never throws: whatever goes wrong comes back as an error the model can read. */
238
- export function compile(source: string): Compiled {
300
+ export function compile(source: string, opts: CompileOpts = {}): Compiled {
239
301
  try {
240
- const text = String(source ?? "");
241
- if (text.length > LIMITS.source) return { ok: false, errors: [`the source is ${Math.round(text.length / 1000)} KB; the limit is ${LIMITS.source / 1000} KB. Show less data, or split it into several widgets`], warnings: [] };
242
- const c = compileProgram(text);
302
+ const restoring = opts.mode === "restore";
303
+ let text = String(source ?? "");
304
+ if (text.length > LIMITS.source) return { ok: false, errors: [`the source is ${Math.round(text.length / 1000)} KB; the limit is ${LIMITS.source / 1000} KB. Show less data, or split it into several widgets`], warnings: [], notes: [] };
305
+ if (restoring) text = upgrade(text, opts);
306
+ const c = compileProgram(text, restoring);
243
307
  if (c.errors.length > LIMITS.errors) {
244
308
  const more = c.errors.length - LIMITS.errors;
245
309
  return { ...c, errors: [...c.errors.slice(0, LIMITS.errors), `... and ${more} more error${more === 1 ? "" : "s"}; fix these first`], root: undefined, ok: false };
246
310
  }
247
311
  return c;
248
312
  } catch (e) {
249
- return { ok: false, errors: [`the widget could not be read: ${(e as Error).message}. Make it smaller and less nested`], warnings: [] };
313
+ return { ok: false, errors: [`the widget could not be read: ${(e as Error).message}. Make it smaller and less nested`], warnings: [], notes: [] };
250
314
  }
251
315
  }
252
316
 
253
- function compileProgram(source: string): Compiled {
317
+ function compileProgram(source: string, restore = false): Compiled {
254
318
  const prog = parseProgram(source);
255
319
  const errors = [...prog.errors];
256
320
  const warnings: string[] = [];
321
+ /** restore mode: what was changed to make an older source draw. In strict mode the same things are errors. */
322
+ const notes: string[] = [];
257
323
  if (!prog.statements.has("root")) {
258
324
  errors.push("define the entry point: root = Stack([...]) (put it on the first line)");
259
- return { ok: false, errors, warnings };
325
+ return { ok: false, errors, warnings, notes };
260
326
  }
261
327
  if (prog.order[0] !== "root") warnings.push("root is not the first statement; put it first so the shell appears immediately");
262
328
 
@@ -317,8 +383,15 @@ function compileProgram(source: string): Compiled {
317
383
  }
318
384
  };
319
385
 
320
- const tooLong = (v: string, max: number, ctx: string, line: number) => {
321
- if (v.length > max) errors.push(`${where(line)}${ctx} is ${v.length} characters; the limit is ${max}. Shorten it`);
386
+ /** A string within its limit. Over it: an error for the model, or (restoring an older widget) the string cut to the limit, with a note. */
387
+ const fitString = (v: string, max: number, ctx: string, line: number): string => {
388
+ if (v.length <= max) return v;
389
+ if (restore) {
390
+ notes.push(`${where(line)}${ctx} is ${v.length} characters; the limit is ${max}, so it was cut to ${max}`);
391
+ return v.slice(0, max);
392
+ }
393
+ errors.push(`${where(line)}${ctx} is ${v.length} characters; the limit is ${max}. Shorten it`);
394
+ return v;
322
395
  };
323
396
  const resolve = (x: Expr, t: T, ctx: string, maxChars: number = LIMITS.string): any => {
324
397
  const d0 = x;
@@ -333,16 +406,14 @@ function compileProgram(source: string): Compiled {
333
406
  switch (t) {
334
407
  case "string":
335
408
  if (d.k !== "str") return bad("a string");
336
- tooLong(d.v, maxChars, ctx, d.line);
337
- return d.v;
409
+ return fitString(d.v, maxChars, ctx, d.line);
338
410
  case "number":
339
411
  return d.k === "num" ? d.v : bad("a number");
340
412
  case "boolean":
341
413
  return d.k === "bool" ? d.v : bad("true or false");
342
414
  case "cell":
343
415
  if (d.k !== "str" && d.k !== "num") return bad("a string or a number");
344
- if (d.k === "str") tooLong(d.v, LIMITS.cell, ctx, d.line);
345
- return d.v;
416
+ return d.k === "str" ? fitString(d.v, LIMITS.cell, ctx, d.line) : d.v;
346
417
  case "any":
347
418
  return plain(d);
348
419
  case "action": {
@@ -395,7 +466,13 @@ function compileProgram(source: string): Compiled {
395
466
  if (d.k !== "arr") return bad("an array [...]");
396
467
  const out = d.items.map((it, i) => resolve(it, t.arr, `${ctx}[${i + 1}]`)).filter((v) => v !== undefined);
397
468
  if (t.min !== undefined && d.items.length < t.min) errors.push(`${where(d.line)}${ctx} needs at least ${t.min} item(s)`);
398
- if (t.max !== undefined && d.items.length > t.max) errors.push(`${where(d.line)}${ctx} allows at most ${t.max} item(s), got ${d.items.length}`);
469
+ if (t.max !== undefined && d.items.length > t.max) {
470
+ if (restore) {
471
+ notes.push(`${where(d.line)}${ctx} allows at most ${t.max} item(s), got ${d.items.length}; the extra ones were dropped`);
472
+ return out.slice(0, t.max);
473
+ }
474
+ errors.push(`${where(d.line)}${ctx} allows at most ${t.max} item(s), got ${d.items.length}`);
475
+ }
399
476
  return out;
400
477
  }
401
478
  // { comp: [...] }
@@ -437,7 +514,10 @@ function compileProgram(source: string): Compiled {
437
514
  }
438
515
  };
439
516
  const buildInner = (call: Extract<Expr, { k: "call" }>, spec: CompSpec): El | undefined => {
440
- if (call.args.length > spec.params.length) errors.push(`${where(call.line)}${spec.name} takes at most ${spec.params.length} argument(s), got ${call.args.length}`);
517
+ if (call.args.length > spec.params.length) {
518
+ if (restore) notes.push(`${where(call.line)}${spec.name} takes at most ${spec.params.length} argument(s), got ${call.args.length}; the extra ones were ignored`);
519
+ else errors.push(`${where(call.line)}${spec.name} takes at most ${spec.params.length} argument(s), got ${call.args.length}`);
520
+ }
441
521
  const props: Record<string, any> = {};
442
522
  spec.params.forEach((param, i) => {
443
523
  const arg = call.args[i];
@@ -503,6 +583,23 @@ function compileProgram(source: string): Compiled {
503
583
  pr.rows.forEach((r: any[], i: number) => {
504
584
  if (r.length !== pr.columns.length) err(`row ${i + 1} has ${r.length} cells but there are ${pr.columns.length} columns`);
505
585
  });
586
+ if (Array.isArray(pr.pin) && Array.isArray(pr.columns)) {
587
+ // headers are compared the way the table draws them: spacing collapsed
588
+ const header = (c: unknown) => String(c ?? "").replace(/\s+/g, " ").trim();
589
+ const heads = pr.columns.map(header);
590
+ const kept: string[] = [];
591
+ for (const name of pr.pin as string[]) {
592
+ const h = header(name);
593
+ if (!heads.includes(h)) {
594
+ if (restore) notes.push(`${where(el.line)}Table: pin "${h}" is not a column, so it was dropped`);
595
+ else err(`pin "${h}" is not a column. Columns: ${heads.join(", ")}`);
596
+ } else if (kept.includes(h)) {
597
+ if (restore) notes.push(`${where(el.line)}Table: pin lists "${h}" twice, so the second was dropped`);
598
+ else err(`pin lists "${h}" twice`);
599
+ } else kept.push(h);
600
+ }
601
+ if (restore) pr.pin = kept;
602
+ }
506
603
  break;
507
604
  case "BarChart":
508
605
  case "PieChart":
@@ -590,10 +687,15 @@ function compileProgram(source: string): Compiled {
590
687
  const rootEl = resolve(rootStmt.expr, "child", "root");
591
688
  visiting.pop();
592
689
  if (rootEl && rootEl.type !== "Stack") errors.push(`${where(rootStmt.line)}root must be a Stack(...), not ${rootEl.type}`);
593
- for (const name of prog.order) if (!used.has(name)) errors.push(`${where(prog.statements.get(name)!.line)}"${name}" is defined but not reachable from root, so it would not render. Add it to its parent's children or items array`);
690
+ for (const name of prog.order) {
691
+ if (used.has(name)) continue;
692
+ const line = where(prog.statements.get(name)!.line);
693
+ if (restore) notes.push(`${line}"${name}" is defined but not reachable from root, so it is not drawn`);
694
+ else errors.push(`${line}"${name}" is defined but not reachable from root, so it would not render. Add it to its parent's children or items array`);
695
+ }
594
696
  const seen = new Set<string>();
595
697
  const uniq = errors.filter((m) => (seen.has(m) ? false : (seen.add(m), true)));
596
- return { ok: uniq.length === 0 && !!rootEl, errors: uniq, warnings, root: uniq.length === 0 ? rootEl : undefined };
698
+ return { ok: uniq.length === 0 && !!rootEl, errors: uniq, warnings, notes: [...new Set(notes)], root: uniq.length === 0 ? rootEl : undefined };
597
699
  }
598
700
 
599
701
  /** Text for the model: syntax rules, signatures, actions, tooltips, examples. */
@@ -613,7 +715,7 @@ export function reference(): string {
613
715
  ${SPECS.map(sig).join("\n")}
614
716
 
615
717
  ## Tooltips
616
- \`tooltips: Tooltip[]\` is the LAST argument of Dag, Erd, InfraMap, SequenceDiagram, Table, Metric, BarChart, LineChart, AreaChart, PieChart, RadarChart, GeoMap and Image, so write null for each skipped optional argument before it, e.g. Dag(nodes, edges, null, null, [t1]).
718
+ \`tooltips: Tooltip[]\` is the LAST argument of Dag, Erd, InfraMap, SequenceDiagram, Metric, BarChart, LineChart, AreaChart, PieChart, RadarChart, GeoMap and Image, so write null for each skipped optional argument before it, e.g. Dag(nodes, edges, null, null, [t1]). A Table takes tooltips as its 3rd argument and \`pin\` as its 4th: Table(columns, rows, [t1], ["Host"]), or Table(columns, rows, null, ["Host"]) without tooltips.
617
719
  Targets: Dag node id; Erd entity name or entity.column; InfraMap node id, zone:<zone name> or <fromId>><toId>; SequenceDiagram participant id or SeqMessage id; Table row:3, col:status or cell:3.status; Metric its own label; BarChart a bar label; GeoMap a point label or A>B; Image the word image; LineChart and AreaChart an x label (that point) or series:<name>; PieChart a slice label; RadarChart an axis label or series:<name>.
618
720
  An element that has a tooltip shows a small ◆ on it. Hovering shows the hint; clicking (or Enter) opens the panel with detail, code and link. Use a tooltip for context the reader may want but should not have to read: why a point spiked, what a slice contains, what a node does.
619
721
  hint is the short text shown on hover (140 characters). detail (800), code (40 lines) and link (http/https) open in a panel when the element is clicked.
@@ -627,6 +729,7 @@ Choice(question, options, hint, multiple, other) asks the user. A click only sta
627
729
 
628
730
  ## Choosing components
629
731
  Metric for a number, Table for comparisons, BarChart for one series of categories, LineChart for trends with several series, AreaChart for stacked volume, PieChart for parts of a whole (8 slices at most), RadarChart for comparing entities across metrics, Dag for task graphs and pipelines, Erd for database schemas, InfraMap for how systems connect, SequenceDiagram for call flows, GeoMap for places, Tabs/Card to organise. Series values must have the same length as labels. All data is literal: no queries, no variables. Image shows a real picture in terminals that support it (Kitty, Ghostty, WezTerm, Warp, herdr): src is an absolute file path (png, jpg, gif, webp) or a data: URI.
732
+ The reader's terminal may be narrow (a phone is about 60 columns) and you cannot see its width. A Dag, Erd, InfraMap or SequenceDiagram wider than it, and a Table with many columns, scroll sideways under a bar and nothing in them is cut. A Table with only a few columns is squeezed to fit instead and a long cell ends in an ellipsis (the reader can tap it to read it whole), so still keep table cells short and put long text in Text or Markdown. A Table pins nothing unless you ask: when it has many columns and a row is hard to read without its first one, list the one or two narrow columns that identify each row (a name, an id) in \`pin\`, e.g. Table(columns, rows, null, ["Host"]). They stay in place while the rest scrolls. Do not pin a long column or one that is not needed to tell the rows apart: pinned columns take at most half the width, one that would not fit is not pinned, and the table scrolls whole without it.
630
733
 
631
734
  ## Example
632
735
  root = Stack([title, kpis, load, go])