@keemakr/ui-sdk 1.5.0 → 1.6.0-dev-20261026112734

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 (73) hide show
  1. package/README.md +118 -0
  2. package/dist/badges.d.ts +65 -0
  3. package/dist/badges.js +96 -0
  4. package/dist/build.js +2 -2
  5. package/dist/cells.d.ts +31 -0
  6. package/dist/cells.js +45 -0
  7. package/dist/chart.d.ts +20 -0
  8. package/dist/chart.js +38 -0
  9. package/dist/conform.d.ts +21 -0
  10. package/dist/conform.js +310 -0
  11. package/dist/dataset.d.ts +6 -6
  12. package/dist/date-format.d.ts +64 -0
  13. package/dist/date-format.js +132 -0
  14. package/dist/drawer.d.ts +22 -0
  15. package/dist/drawer.js +21 -0
  16. package/dist/edit.d.ts +60 -0
  17. package/dist/edit.js +60 -0
  18. package/dist/filters.d.ts +54 -0
  19. package/dist/filters.js +81 -0
  20. package/dist/form.d.ts +45 -1
  21. package/dist/form.js +39 -0
  22. package/dist/heatmap.d.ts +36 -0
  23. package/dist/heatmap.js +56 -0
  24. package/dist/icon.d.ts +20 -0
  25. package/dist/icon.js +43 -0
  26. package/dist/index.d.ts +37 -4
  27. package/dist/index.js +21 -2
  28. package/dist/markdown-links.d.ts +29 -0
  29. package/dist/markdown-links.js +99 -0
  30. package/dist/media.js +7 -1
  31. package/dist/negotiate.d.ts +302 -0
  32. package/dist/negotiate.js +1092 -0
  33. package/dist/nodes.d.ts +1 -1
  34. package/dist/nodes.js +3 -0
  35. package/dist/pager.d.ts +2 -2
  36. package/dist/player.d.ts +46 -0
  37. package/dist/player.js +86 -0
  38. package/dist/published/index.d.ts +4 -0
  39. package/dist/published/index.js +18 -0
  40. package/dist/published/v1.1.0/schema.d.ts +439 -0
  41. package/dist/published/v1.1.0/schema.js +405 -0
  42. package/dist/published/v1.4.0/dataset-view.d.ts +160 -0
  43. package/dist/published/v1.4.0/dataset-view.js +702 -0
  44. package/dist/published/v1.4.0/dataset.d.ts +331 -0
  45. package/dist/published/v1.4.0/dataset.js +628 -0
  46. package/dist/published/v1.4.0/schema.d.ts +1132 -0
  47. package/dist/published/v1.4.0/schema.js +1410 -0
  48. package/dist/schema.d.ts +1638 -20
  49. package/dist/schema.js +1275 -72
  50. package/dist/section.d.ts +65 -0
  51. package/dist/section.js +81 -0
  52. package/dist/stacked-bar.d.ts +34 -0
  53. package/dist/stacked-bar.js +20 -0
  54. package/dist/stat.d.ts +24 -0
  55. package/dist/stat.js +27 -0
  56. package/dist/table-row.d.ts +24 -0
  57. package/dist/table-row.js +23 -0
  58. package/dist/tabs.d.ts +36 -0
  59. package/dist/tabs.js +83 -0
  60. package/dist/testkit.d.ts +38 -0
  61. package/dist/testkit.js +68 -0
  62. package/dist/timeline.d.ts +5 -0
  63. package/dist/timeline.js +35 -0
  64. package/examples/campaigns/README.md +22 -1
  65. package/examples/campaigns/approvals/index.ts +23 -0
  66. package/examples/campaigns/approvals/launch.ts +38 -0
  67. package/examples/campaigns/brief/index.ts +23 -0
  68. package/examples/campaigns/brief/ready.ts +43 -0
  69. package/examples/campaigns/demo-approvals.ts +35 -0
  70. package/examples/campaigns/demo-brief.ts +23 -0
  71. package/examples/campaigns/entry.json +71 -1
  72. package/examples/campaigns/tsconfig.json +8 -1
  73. package/package.json +6 -1
@@ -0,0 +1,1092 @@
1
+ // ui-sdk negotiation (C1.6): what each client can draw, and the tree core sends it.
2
+ //
3
+ // A client parses the whole screen tree with the ui-sdk bundled in the @fsztpartners/sdk it was
4
+ // built with, and throws on any node or value that parse does not know, so one newer node fails
5
+ // the WHOLE screen. A client that parses a node may still have no renderer arm for it, so the node
6
+ // vanishes without a sign. And a field its parse does not know is stripped without a sign, which
7
+ // for a `confirm` or a `disabled` flag means a control that acts when it should ask or refuse.
8
+ // Core is the only party that knows both the tree and the client (`X-Kee-Client`), so core
9
+ // rewrites the tree before it answers:
10
+ // 1. each node the client cannot draw becomes a `text` or `markdown` node every client draws;
11
+ // 2. each feature it cannot draw is folded away or made safe (below);
12
+ // 3. the result is checked against the schema that client REALLY parses with, a frozen copy of
13
+ // the published release (published/), and fitted to it (conform.ts).
14
+ //
15
+ // The node sets live here, not in core, so the testkit (`@keemakr/ui-sdk/testkit`)
16
+ // checks a module's screens against exactly what core sends.
17
+ //
18
+ // Adding a node or a field kind: add it to the schema; the `current` snapshot follows it. Add it to
19
+ // a client row only when that client's release draws it AND parses it. The lockstep test in core
20
+ // (tests/sdk/ui-negotiation.test.ts) fails until the current snapshot matches KNOWN_NODE_TYPES,
21
+ // until every node type has a fallback, and until every sample tree, negotiated for every row,
22
+ // parses with that row's published schema with no safety field lost.
23
+ import { foldTableCell, isNewerTableCell } from './cells.js';
24
+ import { conformTree } from './conform.js';
25
+ import { withoutScreenLinks } from './markdown-links.js';
26
+ import { pageRangeLabel } from './pager.js';
27
+ import { formatPlayhead, isSeekPress } from './player.js';
28
+ import { stackedBarParts } from './stacked-bar.js';
29
+ import { PUBLISHED_PARSERS } from './published/index.js';
30
+ import { foldTabs, TABS_MAX_1_4_0 } from './tabs.js';
31
+ /** The features ui-sdk 1.4.0 draws: every one but those added after it. */
32
+ const FEATURES_1_4_0 = [
33
+ 'breadcrumbs',
34
+ 'button.disabled',
35
+ 'section.row',
36
+ 'form.field_kinds',
37
+ 'form.confirm',
38
+ ];
39
+ /** Every feature: what this package parses and core's own renderer draws. */
40
+ const ALL_FEATURES = [
41
+ ...FEATURES_1_4_0,
42
+ 'press.chat',
43
+ 'form.radio',
44
+ 'table.cells',
45
+ 'header.meta',
46
+ 'tabs.many',
47
+ 'choice.action',
48
+ 'markdown.links',
49
+ 'form.disabled',
50
+ 'press.seek',
51
+ 'edit.inline',
52
+ ];
53
+ /** The nodes of ui-sdk 1.1.0 and 1.2.0 (one schema). */
54
+ const NODES_1_1_0 = [
55
+ 'heading',
56
+ 'text',
57
+ 'markdown',
58
+ 'stat',
59
+ 'button',
60
+ 'form',
61
+ 'table',
62
+ 'timeline',
63
+ 'pending_request',
64
+ 'pending_requests',
65
+ 'choice_cards',
66
+ 'progress',
67
+ 'chart',
68
+ 'section',
69
+ 'tabs',
70
+ ];
71
+ /** The nodes of the published ui-sdk 1.4.0: 1.1.0's, 1.3.0's five, and the twelve KEE-1009 blocks. */
72
+ const NODES_1_4_0 = [
73
+ ...NODES_1_1_0,
74
+ 'pagination',
75
+ 'select',
76
+ 'alert',
77
+ 'details',
78
+ 'timestamp',
79
+ 'divider',
80
+ 'board',
81
+ 'link',
82
+ 'tags',
83
+ 'toggle',
84
+ 'segmented',
85
+ 'progress_bar',
86
+ 'conversation',
87
+ 'composer',
88
+ 'calendar',
89
+ 'image',
90
+ 'video',
91
+ ];
92
+ /**
93
+ * The nodes of this package: every published 1.4.0 node, and the ones added since. A node added
94
+ * here reaches a client only once a row below says that client draws it; until then it gets its
95
+ * fallback (`fallbackNode`).
96
+ */
97
+ const NODES_CURRENT = [...NODES_1_4_0, 'stacked_bar', 'heatmap', 'audio'];
98
+ /**
99
+ * Nodes a module may send that core draws itself before any client sees the tree: a `dataset`
100
+ * node becomes plain blocks from the module's rows (dataset-view.ts). No client parses them, so
101
+ * no snapshot lists them; one that reached negotiation undrawn gets its own fallback.
102
+ */
103
+ export const CORE_DRAWN_NODE_TYPES = ['dataset'];
104
+ /**
105
+ * What each schema a client can parse with admits. `1.1.0` and `1.4.0` are published releases
106
+ * (frozen copies in published/); `current` is this package, newer than any released app.
107
+ */
108
+ export const UI_SDK_SNAPSHOTS = {
109
+ '1.1.0': { name: 'ui-sdk 1.1.0', nodes: NODES_1_1_0, features: [] },
110
+ '1.4.0': { name: 'ui-sdk 1.4.0', nodes: NODES_1_4_0, features: FEATURES_1_4_0 },
111
+ current: { name: 'ui-sdk (this package)', nodes: NODES_CURRENT, features: ALL_FEATURES },
112
+ };
113
+ /** The snapshot this package is. Core's own renderer draws all of it. */
114
+ export const CURRENT_UI_SDK_SNAPSHOT = 'current';
115
+ /**
116
+ * Every client release core negotiates for, oldest first per client. A client
117
+ * version below every row of its client gets the floor (ui-sdk 1.1.0).
118
+ *
119
+ * Checked 2026-10-20 against kee-clients' release tags and their lockfiles (which sdk, and so which
120
+ * ui-sdk, each build parses with) and their renderers.
121
+ */
122
+ export const CLIENT_RELEASES = [
123
+ {
124
+ client: 'web',
125
+ since: '0.0.0',
126
+ parses: '1.4.0',
127
+ draws: { nodes: NODES_1_4_0, features: FEATURES_1_4_0 },
128
+ note: 'Web parses with @fsztpartners/sdk 3.5.0, which bundles ui-sdk 1.4.0, and draws every 1.4.0 ' +
129
+ 'node (NewerBlocks.tsx, listed in its negotiated-nodes.ts), breadcrumbs, button.disabled, ' +
130
+ 'row sections, the typed form fields and form confirms. Every web build before 0.2.0 ' +
131
+ 'reports 0.1.0 (its package version, with the build after a "+"), so this row covers them ' +
132
+ 'all: a tab still open on an older build keeps getting a screen it can parse.',
133
+ },
134
+ {
135
+ client: 'web',
136
+ since: '0.2.0',
137
+ parses: '1.4.0',
138
+ draws: { nodes: NODES_1_4_0, features: FEATURES_1_4_0 },
139
+ note: 'Web 0.2.0 (kee-clients #901) still pins @fsztpartners/sdk ^3.5.0, and its committed lockfile ' +
140
+ 'installs 3.5.0, which bundles ui-sdk 1.4.0. Only a build that installs the @dev channel ' +
141
+ '(SDK_CHANNEL=dev) parses the current schema; a production build installs the lockfile. The ' +
142
+ 'newest stable sdk (3.6.0, ui-sdk 1.5.0) still refuses string date bounds. So this row says ' +
143
+ 'what every 0.2.0 build can parse: 1.4.0, with the folds (info and accent tones to the ' +
144
+ 'nearest tone, string date bounds dropped; a screen time zone is kept on the wire, and a ' +
145
+ 'build that parses 1.4.0 ignores it). Point a web row at `current` only once a stable sdk ' +
146
+ "carrying the current schema is in web's committed lockfile, and freeze that schema first " +
147
+ '(tests/sdk/ui-published-parsers.test.ts). kee-clients checks a frozen-lockfile build ' +
148
+ 'against this row in CI. A dev-channel build says it parses the current schema instead, and ' +
149
+ 'a non-production core sends it that (CHANNEL_BUILDS below); this row is unchanged by it.',
150
+ },
151
+ {
152
+ client: 'mobile',
153
+ since: '0.0.0',
154
+ parses: '1.1.0',
155
+ draws: { nodes: NODES_1_1_0, features: [] },
156
+ note: 'Mobile 0.2.0 (the staging build) parses with sdk 2.5.0, which bundles ui-sdk 1.1.0, and ' +
157
+ 'draws its 15 nodes. Its parse drops a form submit confirm, and its renderer posts a form ' +
158
+ 'without asking, so a form with a confirm reaches it as one calm line.',
159
+ },
160
+ {
161
+ client: 'mobile',
162
+ since: '0.2.3',
163
+ parses: '1.4.0',
164
+ draws: {
165
+ nodes: NODES_1_1_0,
166
+ features: ['breadcrumbs', 'button.disabled', 'form.field_kinds', 'form.confirm'],
167
+ },
168
+ note: 'Mobile 0.2.3 to 0.2.5 parse with sdk 3.5.0 (ui-sdk 1.4.0). The renderer draws the 15 nodes ' +
169
+ 'of 1.1.0, breadcrumbs, disabled buttons (body, header and row actions), every form field ' +
170
+ 'kind and the form confirms. It has no arm for the 1.4.0 blocks, so they get fallbacks. A ' +
171
+ "'row' section is drawn as a stack, so it is folded to one.",
172
+ },
173
+ {
174
+ client: 'desktop',
175
+ since: '0.0.0',
176
+ parses: '1.1.0',
177
+ draws: { nodes: NODES_1_1_0, features: [] },
178
+ note: 'Desktop renders no module screens (no desktop release through 0.3.15 calls the screen routes).',
179
+ },
180
+ {
181
+ client: 'extension',
182
+ since: '0.0.0',
183
+ parses: '1.1.0',
184
+ draws: { nodes: NODES_1_1_0, features: [] },
185
+ note: 'The extension renders no module screens; it gets the floor.',
186
+ },
187
+ {
188
+ client: 'cli',
189
+ since: '0.0.0',
190
+ parses: '1.1.0',
191
+ draws: { nodes: NODES_1_1_0, features: [] },
192
+ note: 'The CLI renders no module screens; it gets the floor.',
193
+ },
194
+ ];
195
+ /** The floor: what every client that sends `X-Kee-Client` can take. */
196
+ export const FLOOR_NODE_SET = {
197
+ ...UI_SDK_SNAPSHOTS['1.1.0'],
198
+ name: 'ui-sdk 1.1.0',
199
+ parses: '1.1.0',
200
+ };
201
+ /** The rows of one client, oldest first. */
202
+ export function releasesOf(client, releases = CLIENT_RELEASES) {
203
+ return releases
204
+ .filter((row) => row.client === client)
205
+ .sort((a, b) => compareVersions(a.since, b.since));
206
+ }
207
+ /**
208
+ * What a client draws, from its `X-Kee-Client` platform and version: the newest
209
+ * row of that client whose `since` it has reached. An unknown platform, or a
210
+ * version below every row, gets the floor. A version core cannot read counts as
211
+ * 0.0.0: a client core cannot identify is treated as the oldest, never the newest.
212
+ */
213
+ export function nodeSetForClient(platform, version, releases = CLIENT_RELEASES) {
214
+ const rows = releasesOf(platform, releases);
215
+ if (rows.length === 0)
216
+ return { ...FLOOR_NODE_SET, name: `${platform || 'unknown'} (floor)` };
217
+ // An unreadable version compares as 0.0.0: the oldest, never the newest.
218
+ const row = rows.filter((r) => compareVersions(r.since, version) <= 0).at(-1);
219
+ if (!row)
220
+ return { ...FLOOR_NODE_SET, name: `${platform} ${version} (floor)` };
221
+ return { name: `${platform} ${version || '?'}`, ...row.draws, parses: row.parses };
222
+ }
223
+ /** The build metadata identifier a build that parses the current schema adds to its version. */
224
+ export const CURRENT_SCHEMA_CLAIM = 'ui-current';
225
+ /**
226
+ * The schema claim in an `X-Kee-Client` value, or null. Read from the build metadata (after the
227
+ * `+`) only, so a claim never changes the version core compares, and a header without one, or
228
+ * with anything else, claims nothing.
229
+ */
230
+ export function uiSchemaClaimOf(header) {
231
+ if (!header)
232
+ return null;
233
+ const version = header.slice(header.indexOf('/') + 1);
234
+ const plus = version.indexOf('+');
235
+ if (plus < 0)
236
+ return null;
237
+ const build = version
238
+ .slice(plus + 1)
239
+ .trim()
240
+ .split('.');
241
+ return build.includes(CURRENT_SCHEMA_CLAIM) ? 'current' : null;
242
+ }
243
+ /**
244
+ * The client builds core may send the current schema, when they claim it and core honours claims
245
+ * (core's own deployment decides that: src/lib/modules/ui-negotiation.ts). A client with no row
246
+ * here gets its release row whatever it claims.
247
+ */
248
+ export const CHANNEL_BUILDS = [
249
+ {
250
+ client: 'web',
251
+ parses: 'current',
252
+ draws: { nodes: NODES_CURRENT, features: ALL_FEATURES },
253
+ note: 'A web build that installed the @dev channel (SDK_CHANNEL=dev, the dev deploy) parses with ' +
254
+ 'the newest @dev sdk, which bundles this schema, and says so (`+<commit>.ui-current`). It ' +
255
+ 'gets the screen as written, so new nodes and features can be tried on the dev web app ' +
256
+ 'before a stable sdk carries them. A build from the committed lockfile never says it and ' +
257
+ 'keeps its release row. kee-clients lists what its dev build draws (negotiated-nodes.ts).',
258
+ },
259
+ ];
260
+ /**
261
+ * What a client draws, given the schema it claims. A `current` claim from a client with a channel
262
+ * build row gets that row; anything else gets `nodeSetForClient`, exactly as with no claim. A
263
+ * forged claim gets the forger a screen its own parser may refuse, nothing else: the tree is the
264
+ * same data for the same session, only less folded.
265
+ */
266
+ export function nodeSetForClaim(platform, version, claim, builds = CHANNEL_BUILDS) {
267
+ const build = claim === 'current' ? builds.find((row) => row.client === platform) : undefined;
268
+ if (!build)
269
+ return nodeSetForClient(platform, version);
270
+ return {
271
+ name: `${platform} ${version || '?'} (${CURRENT_SCHEMA_CLAIM})`,
272
+ ...build.draws,
273
+ parses: build.parses,
274
+ };
275
+ }
276
+ /** The newest release row of a client: what a module targets when it names only the client. */
277
+ export function latestRelease(client) {
278
+ const row = releasesOf(client).at(-1);
279
+ if (!row)
280
+ throw new Error(`no client release for "${client}"`);
281
+ return row;
282
+ }
283
+ const TEXT_MAX = 5000;
284
+ const MARKDOWN_MAX = 20000;
285
+ const CHILDREN_MAX = 100;
286
+ /** The fallback for a node type that has no specific one: a sign that something is there. */
287
+ export const GENERIC_FALLBACK_TEXT = 'Open this in the web app to see this part of the screen.';
288
+ /**
289
+ * What stands in for a control that would act without its safety on this client: a form that
290
+ * would post without its confirm, a button that would press although it is disabled.
291
+ */
292
+ export const SAFETY_FALLBACK_TEXT = 'Open this in the web app to finish this step.';
293
+ /**
294
+ * The tree a client with `set` receives: every node it cannot draw replaced with
295
+ * `text` or `markdown`, every feature it cannot draw folded away. The result
296
+ * still validates against the ui-sdk schema. A tree that needs nothing is
297
+ * returned as the same object.
298
+ */
299
+ export function negotiateTree(tree, set) {
300
+ const nodes = new Set(set.nodes);
301
+ const features = new Set(set.features);
302
+ const fallbacks = [];
303
+ // The zone the screen's times are drawn in (core fills in the Company's when the module named
304
+ // none). A stand-in line writes its times in it, read before a fit may take the field away.
305
+ const zone = tree.timeZone;
306
+ const visit = (original, path) => {
307
+ const loose = original;
308
+ if (!nodes.has(loose.type)) {
309
+ const replacement = fallbackNode(original, zone);
310
+ fallbacks.push({ path, type: loose.type, reason: 'node', as: replacement.type });
311
+ return replacement;
312
+ }
313
+ // A client that cannot open its chat, or move a recording, gets no control that offers to.
314
+ let node = features.has('press.chat')
315
+ ? original
316
+ : withoutPresses(original, path, fallbacks, isChatPress, 'press.chat');
317
+ if (node.type !== 'text' && !features.has('press.seek')) {
318
+ node = withoutSeeks(node, path, fallbacks);
319
+ }
320
+ if (node !== original && node.type === 'text')
321
+ return node;
322
+ // A client that draws no edit control gets the block read-only.
323
+ if (!features.has('edit.inline'))
324
+ node = withoutInlineEdit(node, path, fallbacks);
325
+ switch (node.type) {
326
+ case 'button':
327
+ if (node.disabled && !features.has('button.disabled')) {
328
+ fallbacks.push({ path, type: 'button', reason: 'button.disabled', as: 'text' });
329
+ return { type: 'text', value: clip(node.label, TEXT_MAX), muted: true };
330
+ }
331
+ return node;
332
+ case 'section': {
333
+ const children = visitAll(node.children, `${path}.children`);
334
+ let next = children === node.children ? node : { ...node, children };
335
+ if (node.layout === 'row' && !features.has('section.row')) {
336
+ fallbacks.push({ path, type: 'section', reason: 'section.row', as: 'folded' });
337
+ next = { ...next, layout: 'stack' };
338
+ }
339
+ return next;
340
+ }
341
+ case 'form': {
342
+ // A form whose send asks first must not reach a client that would post without asking.
343
+ const asks = !!node.submit.confirm || (node.secondary ?? []).some((send) => !!send.confirm);
344
+ if (asks && !features.has('form.confirm')) {
345
+ fallbacks.push({ path, type: 'form', reason: 'form.confirm', as: 'text' });
346
+ return { type: 'text', value: SAFETY_FALLBACK_TEXT, muted: true };
347
+ }
348
+ const form = features.has('form.disabled')
349
+ ? node
350
+ : foldDisabledSends(node, path, fallbacks);
351
+ if (form.type === 'text')
352
+ return form;
353
+ const kinds = features.has('form.field_kinds');
354
+ const radio = features.has('form.radio');
355
+ if (kinds && radio)
356
+ return form;
357
+ let changed = false;
358
+ const fields = form.fields.map((field, index) => {
359
+ let next = field;
360
+ const fold = (reason) => {
361
+ changed = true;
362
+ fallbacks.push({
363
+ path: `${path}.fields[${index}]`,
364
+ type: 'form',
365
+ reason,
366
+ as: 'folded',
367
+ });
368
+ };
369
+ if (!radio && (next.kind === 'radio' || hasOptionExtras(next))) {
370
+ next = foldChoiceField(next);
371
+ fold('form.radio');
372
+ }
373
+ if (!kinds && !OLDER_FIELD_KINDS.has(next.kind)) {
374
+ next = foldFormField(next);
375
+ fold('form.field_kinds');
376
+ }
377
+ return next;
378
+ });
379
+ return changed ? { ...form, fields } : form;
380
+ }
381
+ case 'choice_cards':
382
+ return features.has('choice.action') ? node : foldChoiceActions(node, path, fallbacks);
383
+ case 'markdown': {
384
+ if (features.has('markdown.links'))
385
+ return node;
386
+ const value = withoutScreenLinks(node.value);
387
+ if (value === node.value && node.links === undefined)
388
+ return node;
389
+ fallbacks.push({ path, type: 'markdown', reason: 'markdown.links', as: 'folded' });
390
+ return { type: 'markdown', value };
391
+ }
392
+ case 'tabs': {
393
+ let changed = false;
394
+ const tabs = node.tabs.map((tab, index) => {
395
+ const children = visitAll(tab.children, `${path}.tabs[${index}].children`);
396
+ if (children === tab.children)
397
+ return tab;
398
+ changed = true;
399
+ return { ...tab, children };
400
+ });
401
+ const visited = changed ? { ...node, tabs } : node;
402
+ if (visited.tabs.length > TABS_MAX_1_4_0 && !features.has('tabs.many')) {
403
+ fallbacks.push({ path, type: 'tabs', reason: 'tabs.many', as: 'folded' });
404
+ return foldTabs(visited);
405
+ }
406
+ return visited;
407
+ }
408
+ case 'table': {
409
+ const foldCells = !features.has('table.cells');
410
+ let changed = false;
411
+ const rows = node.rows.map((row, index) => {
412
+ let next = row;
413
+ if (foldCells) {
414
+ let cellsChanged = false;
415
+ const cells = Object.fromEntries(Object.entries(row.cells).map(([key, cell]) => {
416
+ if (!isNewerTableCell(cell))
417
+ return [key, cell];
418
+ cellsChanged = true;
419
+ fallbacks.push({
420
+ path: `${path}.rows[${index}].cells.${key}`,
421
+ type: 'table',
422
+ reason: 'table.cells',
423
+ as: 'folded',
424
+ });
425
+ return [key, foldTableCell(cell)];
426
+ }));
427
+ if (cellsChanged)
428
+ next = { ...next, cells };
429
+ }
430
+ if (row.expand) {
431
+ const children = visitAll(row.expand.children, `${path}.rows[${index}].expand.children`);
432
+ if (children !== row.expand.children) {
433
+ next = { ...next, expand: { children: children } };
434
+ }
435
+ }
436
+ if (next !== row)
437
+ changed = true;
438
+ return next;
439
+ });
440
+ return changed ? { ...node, rows } : node;
441
+ }
442
+ default:
443
+ return node;
444
+ }
445
+ };
446
+ // Returns the same array when nothing in it changed, so an untouched tree is
447
+ // returned as the same object and costs no copy.
448
+ const visitAll = (list, path) => {
449
+ let changed = false;
450
+ const out = list.map((child, index) => {
451
+ const next = visit(child, `${path}[${index}]`);
452
+ if (next !== child)
453
+ changed = true;
454
+ return next;
455
+ });
456
+ return changed ? out : list;
457
+ };
458
+ let next = tree;
459
+ const children = visitAll(tree.children, 'children');
460
+ if (children !== tree.children)
461
+ next = { ...next, children };
462
+ if (tree.actions &&
463
+ (!features.has('button.disabled') || !features.has('press.chat') || !features.has('press.seek'))) {
464
+ // The header holds buttons only, so one the client cannot draw safely is dropped.
465
+ const kept = tree.actions.filter((action, index) => {
466
+ const reason = action.disabled && !features.has('button.disabled')
467
+ ? 'button.disabled'
468
+ : isChatPress(action.onPress) && !features.has('press.chat')
469
+ ? 'press.chat'
470
+ : isSeekPress(action.onPress) && !features.has('press.seek')
471
+ ? 'press.seek'
472
+ : null;
473
+ if (!reason)
474
+ return true;
475
+ fallbacks.push({ path: `actions[${index}]`, type: 'button', reason, as: 'dropped' });
476
+ return false;
477
+ });
478
+ if (kept.length !== tree.actions.length) {
479
+ next = { ...next, actions: kept };
480
+ if (kept.length === 0)
481
+ delete next.actions;
482
+ }
483
+ }
484
+ // Before the breadcrumbs fold, so a trail line (which says where the screen sits) lands above it.
485
+ if ((tree.badges || tree.meta) && !features.has('header.meta')) {
486
+ const line = headerFactsLine(tree, zone);
487
+ const kept = next.children.length < CHILDREN_MAX;
488
+ next = { ...next };
489
+ for (const field of ['badges', 'meta']) {
490
+ if (!tree[field])
491
+ continue;
492
+ delete next[field];
493
+ fallbacks.push({
494
+ path: field,
495
+ type: 'header',
496
+ reason: 'header.meta',
497
+ as: kept ? 'text' : 'dropped',
498
+ });
499
+ }
500
+ if (kept) {
501
+ next = { ...next, children: [{ type: 'text', value: line, muted: true }, ...next.children] };
502
+ }
503
+ }
504
+ if (tree.breadcrumbs && !features.has('breadcrumbs')) {
505
+ next = { ...next };
506
+ delete next.breadcrumbs;
507
+ // The crumbs say where the screen sits; keep that as one muted line above
508
+ // the content, when the tree has room for one more node.
509
+ const trail = tree.breadcrumbs.map((crumb) => crumb.label).join(' / ');
510
+ if (next.children.length < CHILDREN_MAX) {
511
+ next = {
512
+ ...next,
513
+ children: [{ type: 'text', value: clip(trail, TEXT_MAX), muted: true }, ...next.children],
514
+ };
515
+ fallbacks.push({
516
+ path: 'breadcrumbs',
517
+ type: 'breadcrumbs',
518
+ reason: 'breadcrumbs',
519
+ as: 'text',
520
+ });
521
+ }
522
+ else {
523
+ fallbacks.push({
524
+ path: 'breadcrumbs',
525
+ type: 'breadcrumbs',
526
+ reason: 'breadcrumbs',
527
+ as: 'dropped',
528
+ });
529
+ }
530
+ }
531
+ const negotiated = fallbacks.length > 0 ? next : tree;
532
+ if (!set.parses || set.parses === 'current')
533
+ return { tree: negotiated, fallbacks };
534
+ // Fit what is left to the schema this client really parses with (conform.ts).
535
+ const conformed = conformTree(negotiated, PUBLISHED_PARSERS[set.parses], {
536
+ fallback: (node) => fallbackNode(node, zone),
537
+ safetyText: SAFETY_FALLBACK_TEXT,
538
+ genericText: GENERIC_FALLBACK_TEXT,
539
+ });
540
+ if (conformed.fallbacks.length === 0)
541
+ return { tree: negotiated, fallbacks };
542
+ return {
543
+ tree: conformed.tree,
544
+ fallbacks: [...fallbacks, ...conformed.fallbacks],
545
+ };
546
+ }
547
+ /** True for a press that opens the chat with a prompt typed in. */
548
+ export function isChatPress(press) {
549
+ return press?.kind === 'chat';
550
+ }
551
+ /**
552
+ * A form for a client that draws no disabled send (`form.disabled`): a disabled `secondary` is
553
+ * dropped, and a form whose `submit` is disabled becomes one calm line. Returns the same object
554
+ * when no send is disabled.
555
+ */
556
+ export function foldDisabledSends(node, path, fallbacks) {
557
+ if (node.submit.disabled) {
558
+ fallbacks.push({ path, type: 'form', reason: 'form.disabled', as: 'text' });
559
+ return { type: 'text', value: SAFETY_FALLBACK_TEXT, muted: true };
560
+ }
561
+ if (!node.secondary?.some((send) => send.disabled))
562
+ return node;
563
+ const secondary = node.secondary.filter((send, index) => {
564
+ if (!send.disabled)
565
+ return true;
566
+ fallbacks.push({
567
+ path: `${path}.secondary[${index}]`,
568
+ type: 'form',
569
+ reason: 'form.disabled',
570
+ as: 'dropped',
571
+ });
572
+ return false;
573
+ });
574
+ return secondary.length > 0 ? { ...node, secondary } : omit(node, 'secondary');
575
+ }
576
+ /**
577
+ * Choice cards for a client that draws no card button (`choice.action`). A card with its own
578
+ * `onPress` keeps it and loses the button; a card with only the button gets the button's press as
579
+ * its own, so it can still be acted on (the button's label is not drawn). Returns the same object
580
+ * when no card has a button.
581
+ */
582
+ export function foldChoiceActions(node, path, fallbacks) {
583
+ if (!node.choices.some((choice) => choice.action))
584
+ return node;
585
+ const choices = node.choices.map((choice, index) => {
586
+ if (!choice.action)
587
+ return choice;
588
+ const { action, ...rest } = choice;
589
+ const at = `${path}.choices[${index}]`;
590
+ if (rest.onPress) {
591
+ fallbacks.push({
592
+ path: `${at}.action`,
593
+ type: 'choice_cards',
594
+ reason: 'choice.action',
595
+ as: 'dropped',
596
+ });
597
+ return rest;
598
+ }
599
+ fallbacks.push({ path: at, type: 'choice_cards', reason: 'choice.action', as: 'folded' });
600
+ return { ...rest, onPress: action.onPress };
601
+ });
602
+ return { ...node, choices };
603
+ }
604
+ /**
605
+ * The node as a client that draws no edit control should get it (`edit.inline`): the same block,
606
+ * read-only. Drops `edit` from a `text`, `markdown` or `details` block and its items, and from each
607
+ * `table` column. Only the node's own fields; a row's expanded children are visited on their own.
608
+ * Returns the same object when there is nothing to take away.
609
+ */
610
+ export function withoutInlineEdit(node, path, fallbacks) {
611
+ const fold = () => fallbacks.push({ path, type: node.type, reason: 'edit.inline', as: 'folded' });
612
+ switch (node.type) {
613
+ case 'text':
614
+ case 'markdown': {
615
+ if (!node.edit)
616
+ return node;
617
+ fold();
618
+ return omit(node, 'edit');
619
+ }
620
+ case 'details': {
621
+ if (!node.edit && !node.items.some((item) => item.edit))
622
+ return node;
623
+ fold();
624
+ const { edit: _edit, ...rest } = node;
625
+ void _edit;
626
+ return {
627
+ ...rest,
628
+ items: node.items.map((item) => (item.edit ? omit(item, 'edit') : item)),
629
+ };
630
+ }
631
+ case 'table': {
632
+ if (!node.columns.some((column) => column.edit))
633
+ return node;
634
+ fold();
635
+ return {
636
+ ...node,
637
+ columns: node.columns.map((column) => (column.edit ? omit(column, 'edit') : column)),
638
+ };
639
+ }
640
+ default:
641
+ return node;
642
+ }
643
+ }
644
+ /**
645
+ * The node as a client that cannot run one kind of press should get it: no control left on it that
646
+ * offers to (see `press.chat` and `press.seek` above). `matches` picks the presses to take away and
647
+ * `reason` is the feature the client lacks. Only the node's own presses; its child nodes are
648
+ * visited on their own. Returns the same object when there is nothing to take away.
649
+ */
650
+ function withoutPresses(node, path, fallbacks, matches, reason) {
651
+ const dropped = (at, type, as = 'dropped') => fallbacks.push({ path: at, type, reason, as });
652
+ // A list of buttons without the ones the client cannot run, or the same list when none is.
653
+ const keepButtons = (list, at) => {
654
+ if (!list?.some((button) => matches(button.onPress)))
655
+ return list;
656
+ return list.filter((button, index) => {
657
+ if (!matches(button.onPress))
658
+ return true;
659
+ dropped(`${at}[${index}]`, 'button');
660
+ return false;
661
+ });
662
+ };
663
+ switch (node.type) {
664
+ case 'button':
665
+ if (!matches(node.onPress))
666
+ return node;
667
+ dropped(path, 'button', 'text');
668
+ return { type: 'text', value: clip(node.label, TEXT_MAX), muted: true };
669
+ case 'alert':
670
+ if (!node.action || !matches(node.action.onPress))
671
+ return node;
672
+ dropped(`${path}.action`, 'button');
673
+ return omit(node, 'action');
674
+ case 'image':
675
+ if (!matches(node.onPress))
676
+ return node;
677
+ dropped(`${path}.onPress`, 'image');
678
+ return omit(node, 'onPress');
679
+ case 'choice_cards': {
680
+ const chat = (choice) => matches(choice.onPress) || matches(choice.action?.onPress);
681
+ if (!node.choices.some(chat))
682
+ return node;
683
+ const choices = [];
684
+ node.choices.forEach((choice, index) => {
685
+ const at = `${path}.choices[${index}]`;
686
+ if (!chat(choice)) {
687
+ choices.push(choice);
688
+ return;
689
+ }
690
+ // Keep whichever of the card's press and its button the client can run.
691
+ let next = choice;
692
+ if (matches(next.onPress))
693
+ next = omit(next, 'onPress');
694
+ if (matches(next.action?.onPress))
695
+ next = omit(next, 'action');
696
+ if (!next.onPress && !next.action) {
697
+ dropped(at, 'choice_cards');
698
+ return;
699
+ }
700
+ dropped(next.onPress ? `${at}.action` : `${at}.onPress`, 'choice_cards');
701
+ choices.push(next);
702
+ });
703
+ if (choices.length > 0)
704
+ return { ...node, choices };
705
+ dropped(path, 'choice_cards', 'text');
706
+ const titles = node.choices.map((choice) => choice.title).join(', ');
707
+ return { type: 'text', value: clip(titles, TEXT_MAX), muted: true };
708
+ }
709
+ case 'board': {
710
+ let changed = false;
711
+ const columns = node.columns.map((column, c) => {
712
+ if (!column.cards.some((card) => matches(card.onPress)))
713
+ return column;
714
+ changed = true;
715
+ const cards = column.cards.map((card, i) => {
716
+ if (!matches(card.onPress))
717
+ return card;
718
+ dropped(`${path}.columns[${c}].cards[${i}].onPress`, 'board');
719
+ return omit(card, 'onPress');
720
+ });
721
+ return { ...column, cards };
722
+ });
723
+ return changed ? { ...node, columns } : node;
724
+ }
725
+ case 'calendar': {
726
+ let changed = false;
727
+ const events = node.events.map((event, i) => {
728
+ const at = `${path}.events[${i}]`;
729
+ const actions = keepButtons(event.actions, `${at}.actions`);
730
+ const press = matches(event.onPress);
731
+ if (actions === event.actions && !press)
732
+ return event;
733
+ changed = true;
734
+ if (press)
735
+ dropped(`${at}.onPress`, 'calendar');
736
+ return withPresses(event, actions, press);
737
+ });
738
+ return changed ? { ...node, events } : node;
739
+ }
740
+ case 'table': {
741
+ let changed = false;
742
+ const rows = node.rows.map((row, i) => {
743
+ const at = `${path}.rows[${i}]`;
744
+ const actions = keepButtons(row.actions, `${at}.actions`);
745
+ const press = matches(row.onPress);
746
+ if (actions === row.actions && !press)
747
+ return row;
748
+ changed = true;
749
+ if (press)
750
+ dropped(`${at}.onPress`, 'table');
751
+ return withPresses(row, actions, press);
752
+ });
753
+ return changed ? { ...node, rows } : node;
754
+ }
755
+ default:
756
+ return node;
757
+ }
758
+ }
759
+ /**
760
+ * The node as a client that cannot move a recording should get it (`press.seek` above): no seek
761
+ * press, and each moment's or transcript turn's time written in front of its words. Returns the
762
+ * same object when there is nothing to change.
763
+ */
764
+ function withoutSeeks(node, path, fallbacks) {
765
+ const next = withoutPresses(node, path, fallbacks, isSeekPress, 'press.seek');
766
+ const folded = (at, type) => fallbacks.push({ path: at, type, reason: 'press.seek', as: 'folded' });
767
+ const timed = (seconds, words, max) => clip(words ? `${formatPlayhead(seconds)} · ${words}` : formatPlayhead(seconds), max);
768
+ if (next.type === 'timeline') {
769
+ if (!next.items.some((item) => item.seek))
770
+ return next;
771
+ const items = next.items.map((item, i) => {
772
+ if (!item.seek)
773
+ return item;
774
+ folded(`${path}.items[${i}]`, 'timeline');
775
+ const { seek, ...rest } = item;
776
+ return { ...rest, label: timed(seek.at, item.label, 200) };
777
+ });
778
+ return { ...next, items };
779
+ }
780
+ if (next.type === 'conversation') {
781
+ if (!next.messages.some((message) => message.seek))
782
+ return next;
783
+ const messages = next.messages.map((message, i) => {
784
+ if (!message.seek)
785
+ return message;
786
+ folded(`${path}.messages[${i}]`, 'conversation');
787
+ const { seek, ...rest } = message;
788
+ return { ...rest, text: timed(seek.at, message.text, 5000) };
789
+ });
790
+ return { ...next, messages };
791
+ }
792
+ return next;
793
+ }
794
+ /** A copy of `value` without `key`. */
795
+ function omit(value, key) {
796
+ const copy = { ...value };
797
+ delete copy[key];
798
+ return copy;
799
+ }
800
+ /** A row or entry with its kept buttons (none: no `actions`) and without its press when `drop`. */
801
+ function withPresses(item, actions, drop) {
802
+ const next = { ...item, actions };
803
+ if (!actions || actions.length === 0)
804
+ delete next.actions;
805
+ if (drop)
806
+ delete next.onPress;
807
+ return next;
808
+ }
809
+ /** The form field kinds every client's parse knows (ui-sdk 1.1.0 onwards). */
810
+ const OLDER_FIELD_KINDS = new Set(['text', 'textarea', 'select']);
811
+ /** True when any option of the field carries a badge or a description. */
812
+ function hasOptionExtras(field) {
813
+ return (field.options ?? []).some((o) => o.badge !== undefined || o.description !== undefined);
814
+ }
815
+ /**
816
+ * A radio, or a multiselect with option extras, for a client that draws neither (`form.radio`).
817
+ * A radio becomes a `select` with the same options and starting value, so it posts the same
818
+ * string. Each badge moves into its option's label, so "Recommended" still reaches the reader;
819
+ * each description is dropped, as a select has no line for it.
820
+ */
821
+ export function foldChoiceField(field) {
822
+ const options = field.options?.map(({ badge, description: _description, ...option }) => badge ? { ...option, label: `${option.label} (${badge})` } : option);
823
+ return {
824
+ ...field,
825
+ ...(field.kind === 'radio' ? { kind: 'select' } : {}),
826
+ ...(options ? { options } : {}),
827
+ };
828
+ }
829
+ /**
830
+ * A form field of a kind the client's parse does not know, as the nearest kind it does. It keeps
831
+ * the label, the name and the starting value; what it posts becomes a string (the older kinds post
832
+ * strings), so a module that types its input must accept that from an older client.
833
+ * number, date, datetime -> text (the value is already a string)
834
+ * combobox, radio -> select (the same options; typed text is not offered)
835
+ * checkbox -> select with "Yes" and "No"
836
+ * multiselect -> select, starting at the first chosen value
837
+ * list -> textarea, one item per line
838
+ */
839
+ export function foldFormField(field) {
840
+ const { values, checked, min: _min, max: _max, step: _step, ...rest } = field;
841
+ switch (field.kind) {
842
+ case 'number':
843
+ case 'date':
844
+ case 'datetime':
845
+ return { ...rest, kind: 'text' };
846
+ case 'combobox':
847
+ return { ...rest, kind: 'select' };
848
+ case 'radio':
849
+ return foldChoiceField({ ...rest, kind: 'radio' });
850
+ case 'checkbox':
851
+ return {
852
+ ...rest,
853
+ kind: 'select',
854
+ value: checked ? 'true' : 'false',
855
+ options: [
856
+ { value: 'true', label: 'Yes' },
857
+ { value: 'false', label: 'No' },
858
+ ],
859
+ };
860
+ case 'multiselect':
861
+ return {
862
+ ...rest,
863
+ kind: 'select',
864
+ ...(values?.[0] !== undefined ? { value: values[0] } : {}),
865
+ };
866
+ case 'list':
867
+ return {
868
+ ...rest,
869
+ kind: 'textarea',
870
+ ...(values && values.length > 0 ? { value: clip(values.join('\n'), 20000) } : {}),
871
+ };
872
+ default:
873
+ return field;
874
+ }
875
+ }
876
+ /**
877
+ * What a node becomes on a client that cannot draw it. It keeps what the node
878
+ * SAYS; it cannot keep what the node DOES, so a pager, a select or an alert's
879
+ * button is not pressable in the fallback. Times are written in `timeZone` (the
880
+ * screen's; a calendar's own wins), or in UTC when there is none.
881
+ */
882
+ export function fallbackNode(node, timeZone) {
883
+ const n = node;
884
+ switch (n.type) {
885
+ case 'pagination':
886
+ return text(`Page ${n.page} of ${n.pageCount} · ${pageRangeLabel(n.page, n.pageSize, n.total)}`, true);
887
+ case 'select': {
888
+ if (n.multiple) {
889
+ const on = n.options.filter((option) => n.values?.includes(option.value));
890
+ return text(`${n.label}: ${on.length ? on.map((o) => o.label).join(', ') : 'any'}`, true);
891
+ }
892
+ const chosen = n.options.find((option) => option.value === n.value);
893
+ return text(`${n.label}: ${chosen ? chosen.label : 'not set'}`, true);
894
+ }
895
+ case 'alert': {
896
+ const tone = TONE_WORD[n.tone] ?? 'Note';
897
+ const head = n.title ? `**${tone}:** **${escapeMarkdown(n.title)}**` : `**${tone}:**`;
898
+ return markdown(`${head}\n\n${escapeMarkdown(n.text)}`);
899
+ }
900
+ case 'details': {
901
+ const lines = n.items.map((item) => `- **${escapeMarkdown(item.label)}:** ${escapeMarkdown(cellText(item.value, timeZone))}`);
902
+ const title = n.title ? `**${escapeMarkdown(n.title)}**\n\n` : '';
903
+ return markdown(`${title}${lines.join('\n')}`);
904
+ }
905
+ case 'timestamp': {
906
+ const when = formatMoment(n.value, n.style === 'date' ? 'date' : 'datetime', timeZone);
907
+ return text(n.label ? `${n.label} ${when}` : when, false);
908
+ }
909
+ case 'button':
910
+ return text(n.label, true);
911
+ case 'divider':
912
+ return markdown('---');
913
+ case 'link': {
914
+ const target = n.href ?? (typeof n.copy === 'string' ? n.copy : '');
915
+ return text(target ? `${n.label}: ${target}` : n.label, false);
916
+ }
917
+ case 'tags': {
918
+ const items = n.items.map((item) => item.label).join(', ');
919
+ return text(n.label ? `${n.label}: ${items}` : items, false);
920
+ }
921
+ case 'toggle':
922
+ return text(`${n.label}: ${n.value ? 'on' : 'off'}`, true);
923
+ case 'segmented': {
924
+ const chosen = n.options.find((option) => option.value === n.value);
925
+ return text(`${n.label}: ${chosen ? chosen.label : 'not set'}`, true);
926
+ }
927
+ case 'progress_bar':
928
+ return text(`${n.label}: ${n.valueLabel ?? `${n.value} of ${n.max}`}`, false);
929
+ case 'heatmap':
930
+ return heatmapFallback(n);
931
+ case 'stacked_bar':
932
+ return text(stackedBarParts(n).summary, false);
933
+ case 'board': {
934
+ const columns = n.columns.map((column) => {
935
+ const cards = column.cards.map((card) => `- ${escapeMarkdown(card.title)}`);
936
+ return `**${escapeMarkdown(column.label)}** (${column.cards.length})${cards.length ? `\n\n${cards.join('\n')}` : ''}`;
937
+ });
938
+ return markdown(columns.join('\n\n'));
939
+ }
940
+ case 'conversation': {
941
+ if (n.messages.length === 0)
942
+ return text(n.empty, true);
943
+ const lines = n.messages.map((message) => {
944
+ const who = message.author ?? (message.side === 'us' ? 'Us' : 'Them');
945
+ const when = message.at ? ` (${formatMoment(message.at, 'datetime', timeZone)})` : '';
946
+ return `**${escapeMarkdown(who)}**${when}: ${escapeMarkdown(message.text)}`;
947
+ });
948
+ return markdown(lines.join('\n\n'));
949
+ }
950
+ case 'composer':
951
+ return text(`${n.label}: open this in the web app to write here.`, true);
952
+ case 'calendar': {
953
+ if (n.events.length === 0)
954
+ return text(n.empty, true);
955
+ const zone = n.timeZone ?? timeZone;
956
+ const lines = n.events.map((event) => `- ${escapeMarkdown(event.title)}: ${formatMoment(event.start, 'datetime', zone)} to ${formatMoment(event.end, 'datetime', zone)}`);
957
+ return markdown(lines.join('\n'));
958
+ }
959
+ case 'image':
960
+ return text(n.alt ?? n.caption ?? 'An image', true);
961
+ case 'video':
962
+ return text(`Video: ${n.title}`, true);
963
+ case 'audio':
964
+ return text(`Audio: ${n.title}${n.duration ? `, ${formatPlayhead(n.duration)}` : ''}`, true);
965
+ case 'dataset':
966
+ return text('This list could not be shown here.', true);
967
+ default:
968
+ return text(GENERIC_FALLBACK_TEXT, true);
969
+ }
970
+ }
971
+ /** Columns a table holds in every published schema. */
972
+ const TABLE_COLUMNS_MAX = 12;
973
+ /**
974
+ * A heatmap without its shading: the same numbers as a plain table every client draws, the row
975
+ * labels in the first column. A cell with no data reads "-". A grid too wide for a table (more
976
+ * than 11 columns) becomes one markdown line per row.
977
+ */
978
+ function heatmapFallback(n) {
979
+ const write = (value) => value === null ? '-' : clip(`${value}${n.valueSuffix ?? ''}`, 500);
980
+ if (n.columns.length < TABLE_COLUMNS_MAX) {
981
+ return {
982
+ type: 'table',
983
+ columns: [
984
+ { key: 'row', label: clip(n.title ?? n.rowHeader ?? '', 80) },
985
+ ...n.columns.map((label, i) => ({ key: `c${i}`, label: clip(label, 80) })),
986
+ ],
987
+ rows: n.rows.map((row, r) => ({
988
+ key: `r${r}`,
989
+ cells: Object.fromEntries([
990
+ ['row', { kind: 'text', value: clip(row.label, 500) }],
991
+ ...row.values.map((value, i) => [
992
+ `c${i}`,
993
+ { kind: 'text', value: write(value) },
994
+ ]),
995
+ ]),
996
+ })),
997
+ empty: 'No data yet.',
998
+ };
999
+ }
1000
+ const lines = n.rows.map((row) => {
1001
+ const cells = row.values.map((value, i) => `${escapeMarkdown(n.columns[i] ?? '')} ${escapeMarkdown(write(value))}`);
1002
+ return `- **${escapeMarkdown(row.label)}:** ${cells.join(', ')}`;
1003
+ });
1004
+ const title = n.title ? `**${escapeMarkdown(n.title)}**\n\n` : '';
1005
+ return markdown(`${title}${lines.join('\n')}`);
1006
+ }
1007
+ const TONE_WORD = {
1008
+ info: 'Note',
1009
+ success: 'Success',
1010
+ warning: 'Warning',
1011
+ destructive: 'Error',
1012
+ };
1013
+ function cellText(cell, timeZone) {
1014
+ return cell.kind === 'date' ? formatMoment(cell.value, 'datetime', timeZone) : cell.value;
1015
+ }
1016
+ /**
1017
+ * The tree's `badges` and `meta` as one line, for a client that draws neither: the badge labels,
1018
+ * then each fact as "label: value", joined with " · ". Dates are written in the screen's zone.
1019
+ */
1020
+ function headerFactsLine(tree, timeZone) {
1021
+ const parts = [
1022
+ ...(tree.badges ?? []).map((badge) => badge.label),
1023
+ ...(tree.meta ?? []).map((item) => `${item.label}: ${cellText(item.value, timeZone)}`),
1024
+ ];
1025
+ return clip(parts.join(' · '), TEXT_MAX);
1026
+ }
1027
+ /** A calendar day with no time ("2026-10-09"): the same day in every zone. */
1028
+ const DAY_ONLY = /^\d{4}-\d{2}-\d{2}$/;
1029
+ /**
1030
+ * ISO 8601 → "2026-10-09 16:03 CEST" in the screen's zone, the way a person there reads it (or the
1031
+ * date alone). Without a zone, or with one this runtime cannot format in: "2026-10-09 14:03 UTC".
1032
+ */
1033
+ function formatMoment(iso, style, timeZone) {
1034
+ if (style === 'date' && DAY_ONLY.test(iso))
1035
+ return iso;
1036
+ const date = new Date(iso);
1037
+ if (Number.isNaN(date.getTime()))
1038
+ return iso;
1039
+ if (timeZone) {
1040
+ try {
1041
+ const parts = new Intl.DateTimeFormat('en-GB', {
1042
+ timeZone,
1043
+ year: 'numeric',
1044
+ month: '2-digit',
1045
+ day: '2-digit',
1046
+ hour: '2-digit',
1047
+ minute: '2-digit',
1048
+ hourCycle: 'h23',
1049
+ timeZoneName: 'short',
1050
+ }).formatToParts(date);
1051
+ const part = (type) => parts.find((p) => p.type === type)?.value ?? '';
1052
+ const day = `${part('year')}-${part('month')}-${part('day')}`;
1053
+ return style === 'date'
1054
+ ? day
1055
+ : `${day} ${part('hour')}:${part('minute')} ${part('timeZoneName')}`;
1056
+ }
1057
+ catch {
1058
+ // A zone this runtime does not know: UTC, said as such, below.
1059
+ }
1060
+ }
1061
+ const day = date.toISOString().slice(0, 10);
1062
+ return style === 'date' ? day : `${day} ${date.toISOString().slice(11, 16)} UTC`;
1063
+ }
1064
+ function text(value, muted) {
1065
+ return { type: 'text', value: clip(value, TEXT_MAX), muted };
1066
+ }
1067
+ function markdown(value) {
1068
+ return { type: 'markdown', value: clip(value, MARKDOWN_MAX) };
1069
+ }
1070
+ function clip(value, max) {
1071
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
1072
+ }
1073
+ /** Module text is plain text: escape it so it reads the same inside a markdown fallback. */
1074
+ export function escapeMarkdown(value) {
1075
+ return value.replace(/[\\`*_{}[\]()#+\-.!|<>~]/g, (ch) => `\\${ch}`);
1076
+ }
1077
+ /** x.y.z with an optional leading `v`; build and pre-release suffixes are ignored. */
1078
+ function parseVersion(version) {
1079
+ const m = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version.trim());
1080
+ if (!m)
1081
+ return null;
1082
+ return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
1083
+ }
1084
+ function compareVersions(a, b) {
1085
+ const pa = parseVersion(a) ?? [0, 0, 0];
1086
+ const pb = parseVersion(b) ?? [0, 0, 0];
1087
+ for (let i = 0; i < 3; i += 1) {
1088
+ if (pa[i] !== pb[i])
1089
+ return pa[i] < pb[i] ? -1 : 1;
1090
+ }
1091
+ return 0;
1092
+ }