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

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,310 @@
1
+ // Fit a negotiated tree to the schema an app really parses with (published/index.ts).
2
+ //
3
+ // Negotiation (negotiate.ts) swaps the node TYPES an app cannot draw. Two kinds of drift are left,
4
+ // and this file closes both against the app's own published schema:
5
+ //
6
+ // 1. A value or shape the app's parse REFUSES fails the whole screen. Known ones are folded to the
7
+ // nearest value the parse takes; anything else replaces the smallest node that holds it:
8
+ // - a chart's `chartType: 'area'` (after 1.4.0) -> 'line' (a chart's `stacked` and
9
+ // `legend` are stripped by such a parse, so its bars are drawn side by side)
10
+ // - `tone: 'info' | 'accent'` (badge cells, tags, board columns, calendar entries, progress
11
+ // bars; after 1.4.0) -> 'default', or the first tone that parse lists
12
+ // - a string `min`/`max` on a date or datetime field (after 1.4.0) -> dropped
13
+ // - an image's or video's portrait `aspect: '3:4' | '9:16'` (after 1.4.0)
14
+ // -> 'auto', so the picture keeps its own shape
15
+ // - a chart with more points than the parse takes (120 after 1.4.0, 50 before)
16
+ // -> thinned to that many evenly spaced points,
17
+ // first and last kept (chart.ts)
18
+ // - anything else the parse refuses (a longer label, a larger list, a value no fold knows)
19
+ // -> the node's own text fallback
20
+ // 2. A SAFETY field the app's parse STRIPS while it keeps the control (a `confirm`, or
21
+ // `disabled: true`) would leave a control that acts without asking, or acts when it must not.
22
+ // Found by comparing the tree with what the parse returns, so every such field is caught, not
23
+ // only the ones listed here. Known today: a form's `submit.confirm` (ui-sdk 1.1.0 drops it).
24
+ // A control in a list of controls (header `actions`, row `actions`, bulk `actions`, form
25
+ // `secondary`, `headerControls`) is dropped; any other node becomes one calm line.
26
+ //
27
+ // Pure, and bounded by the screen's size: each parse lists every issue at once and they are all
28
+ // repaired before the next parse, so a 500-row table with a refused tone in every row costs one
29
+ // repair pass, not 500 parses. Every repair makes the tree strictly simpler (a value folded to one
30
+ // the parse lists, a key or a control removed, a node replaced by its fallback and that by one
31
+ // line), so the number of repairs is bounded by the number of objects in the tree; the passes are
32
+ // capped at that bound, and a pass that can repair nothing ends the loop. Only then does the
33
+ // screen become one line.
34
+ import { thinChart } from './chart.js';
35
+ /** Repairs one object of a tree can take at most: a tone, a min, a max, a drop, two replacements. */
36
+ const REPAIRS_PER_OBJECT = 6;
37
+ /** Lists of controls a client can lose one of without losing the node that holds them. */
38
+ const CONTROL_LISTS = new Set(['actions', 'secondary', 'headerControls']);
39
+ /** Keys of a tree's root that are not optional: the tree cannot do without them. */
40
+ const ROOT_REQUIRED = new Set(['version', 'title', 'children']);
41
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
42
+ const pathText = (path) => path
43
+ .map((part, index) => typeof part === 'number' ? `[${part}]` : index === 0 ? part : `.${part}`)
44
+ .join('');
45
+ function at(root, path) {
46
+ let here = root;
47
+ for (const part of path) {
48
+ if (here === null || typeof here !== 'object')
49
+ return undefined;
50
+ here = here[part];
51
+ }
52
+ return here;
53
+ }
54
+ /** The deepest `children[i]` on the path: the node that holds whatever the path points at. */
55
+ function nodeAt(path) {
56
+ for (let i = path.length - 1; i >= 1; i--) {
57
+ if (path[i - 1] === 'children' && typeof path[i] === 'number')
58
+ return path.slice(0, i + 1);
59
+ }
60
+ return null;
61
+ }
62
+ /** The deepest element of a list of controls on the path, if it is nearer than any node. */
63
+ function controlAt(path) {
64
+ const node = nodeAt(path);
65
+ for (let i = path.length - 1; i >= 1; i--) {
66
+ if (node && i < node.length)
67
+ break;
68
+ const list = path[i - 1];
69
+ if (typeof list === 'string' && CONTROL_LISTS.has(list) && typeof path[i] === 'number') {
70
+ return path.slice(0, i + 1);
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ /** Remove one control from its list; an emptied list goes, and so does a bulk bar left empty. */
76
+ function dropControl(tree, path) {
77
+ const listPath = path.slice(0, -1);
78
+ const list = at(tree, listPath);
79
+ list.splice(path[path.length - 1], 1);
80
+ if (list.length > 0)
81
+ return;
82
+ const holderPath = listPath.slice(0, -1);
83
+ const holder = at(tree, holderPath);
84
+ delete holder[listPath[listPath.length - 1]];
85
+ if (holderPath[holderPath.length - 1] === 'bulk') {
86
+ const table = at(tree, holderPath.slice(0, -1));
87
+ delete table.bulk;
88
+ }
89
+ }
90
+ function setAt(tree, path, value) {
91
+ const holder = at(tree, path.slice(0, -1));
92
+ holder[path[path.length - 1]] = value;
93
+ }
94
+ /** Every object whose `confirm` or `disabled: true` the parse dropped while keeping it. */
95
+ function strippedSafety(sent, kept, path = [], out = []) {
96
+ if (Array.isArray(sent)) {
97
+ if (!Array.isArray(kept))
98
+ return out;
99
+ for (let i = 0; i < sent.length; i++)
100
+ strippedSafety(sent[i], kept[i], [...path, i], out);
101
+ return out;
102
+ }
103
+ if (!isObject(sent) || !isObject(kept))
104
+ return out;
105
+ if ((typeof sent.confirm === 'string' && kept.confirm !== sent.confirm) ||
106
+ (sent.disabled === true && kept.disabled !== true)) {
107
+ out.push(path);
108
+ return out;
109
+ }
110
+ for (const [key, value] of Object.entries(sent)) {
111
+ strippedSafety(value, kept[key], [...path, key], out);
112
+ }
113
+ return out;
114
+ }
115
+ /** How many objects and arrays the tree holds: what bounds the repairs. */
116
+ function sizeOf(value) {
117
+ let count = 0;
118
+ const stack = [value];
119
+ while (stack.length > 0) {
120
+ const here = stack.pop();
121
+ if (here === null || typeof here !== 'object')
122
+ continue;
123
+ count += 1;
124
+ for (const child of Array.isArray(here) ? here : Object.values(here)) {
125
+ if (child !== null && typeof child === 'object')
126
+ stack.push(child);
127
+ }
128
+ }
129
+ return count;
130
+ }
131
+ /**
132
+ * Later paths first: a higher index before a lower one in the same list (removing one control
133
+ * does not move the ones still to come), and a longer path before its own prefix.
134
+ */
135
+ function laterFirst(a, b) {
136
+ const n = Math.min(a.length, b.length);
137
+ for (let i = 0; i < n; i++) {
138
+ const x = a[i];
139
+ const y = b[i];
140
+ if (x === y)
141
+ continue;
142
+ if (typeof x === 'number' && typeof y === 'number')
143
+ return y - x;
144
+ return String(y) < String(x) ? -1 : 1;
145
+ }
146
+ return b.length - a.length;
147
+ }
148
+ const pathKey = (path) => path.join('\u0000');
149
+ /**
150
+ * The tree as `parser` takes it, with every change listed. The input is not touched. Each change
151
+ * keeps the tree valid against this package's own schema.
152
+ */
153
+ export function conformTree(tree, parser, options) {
154
+ const work = structuredClone(tree);
155
+ const fallbacks = [];
156
+ // What one pass removed or replaced: a later issue inside it has nothing left to repair.
157
+ // Kept as path keys, so checking an issue costs its depth, not the number of repairs so far.
158
+ let gone = new Set();
159
+ const markGone = (path) => {
160
+ gone.add(pathKey(path));
161
+ };
162
+ const isGone = (path) => {
163
+ for (let i = 1; i <= path.length; i++)
164
+ if (gone.has(pathKey(path.slice(0, i))))
165
+ return true;
166
+ return false;
167
+ };
168
+ const replaceNode = (path, reason, line) => {
169
+ const node = at(work, path);
170
+ const type = typeof node?.type === 'string' ? node.type : 'unknown';
171
+ let next = line ? { type: 'text', value: line, muted: true } : options.fallback(node);
172
+ // A node that is already its own fallback (a text this parse still refuses) says less instead.
173
+ if (JSON.stringify(next) === JSON.stringify(node)) {
174
+ next = { type: 'text', value: options.genericText, muted: true };
175
+ // Already the last line it can say: nothing simpler is left, so this is no repair.
176
+ if (JSON.stringify(next) === JSON.stringify(node))
177
+ return false;
178
+ }
179
+ setAt(work, path, next);
180
+ markGone(path);
181
+ fallbacks.push({ path: pathText(path), type, reason, as: next.type });
182
+ return true;
183
+ };
184
+ const drop = (control, reason) => {
185
+ dropControl(work, control);
186
+ markGone(control);
187
+ fallbacks.push({ path: pathText(control), type: 'control', reason, as: 'dropped' });
188
+ return true;
189
+ };
190
+ const repair = (issue) => {
191
+ const path = issue.path;
192
+ if (isGone(path))
193
+ return false;
194
+ const key = path[path.length - 1];
195
+ const holder = at(work, path.slice(0, -1));
196
+ if (key === 'tone' && issue.code === 'invalid_value' && isObject(holder)) {
197
+ const values = issue.values ?? [];
198
+ const folded = values.includes('default') ? 'default' : values[0];
199
+ if (holder.tone === folded)
200
+ return false;
201
+ holder.tone = folded;
202
+ fallbacks.push({ path: pathText(path), type: 'tone', reason: 'parse', as: 'folded' });
203
+ return true;
204
+ }
205
+ if (key === 'chartType' &&
206
+ issue.code === 'invalid_value' &&
207
+ isObject(holder) &&
208
+ holder.type === 'chart' &&
209
+ holder.chartType === 'area' &&
210
+ (issue.values ?? []).includes('line')) {
211
+ // An area is a line with the room under it filled: the line keeps the trend.
212
+ holder.chartType = 'line';
213
+ fallbacks.push({ path: pathText(path), type: 'chartType', reason: 'parse', as: 'folded' });
214
+ return true;
215
+ }
216
+ if (key === 'aspect' &&
217
+ issue.code === 'invalid_value' &&
218
+ isObject(holder) &&
219
+ (holder.type === 'image' || holder.type === 'video') &&
220
+ holder.aspect !== 'auto' &&
221
+ (issue.values ?? []).includes('auto')) {
222
+ // A portrait photograph cropped to a landscape box loses what it shows; `auto` draws it at
223
+ // its own shape, which for a portrait picture is the shape the module asked for.
224
+ holder.aspect = 'auto';
225
+ fallbacks.push({ path: pathText(path), type: 'aspect', reason: 'parse', as: 'folded' });
226
+ return true;
227
+ }
228
+ if ((key === 'min' || key === 'max') && isObject(holder) && key in holder) {
229
+ delete holder[key];
230
+ fallbacks.push({ path: pathText(path), type: String(key), reason: 'parse', as: 'folded' });
231
+ return true;
232
+ }
233
+ if ((key === 'categories' || key === 'values') && issue.code === 'too_big') {
234
+ const chartPath = key === 'categories' ? path.slice(0, -1) : path.slice(0, -3);
235
+ const chart = at(work, chartPath);
236
+ const limit = Number(issue.maximum);
237
+ if ((key === 'categories' || path[path.length - 3] === 'series') &&
238
+ isObject(chart) &&
239
+ chart.type === 'chart' &&
240
+ Array.isArray(chart.categories) &&
241
+ Array.isArray(chart.series) &&
242
+ Number.isInteger(limit) &&
243
+ limit >= 1) {
244
+ // Every too-big list of one chart is one thinning: a later issue of it finds it done.
245
+ if (chart.categories.length <= limit)
246
+ return false;
247
+ setAt(work, chartPath, thinChart(chart, limit));
248
+ fallbacks.push({ path: pathText(chartPath), type: 'chart', reason: 'parse', as: 'folded' });
249
+ return true;
250
+ }
251
+ }
252
+ const control = controlAt(path);
253
+ if (control)
254
+ return isGone(control) ? false : drop(control, 'parse');
255
+ const node = nodeAt(path);
256
+ if (node)
257
+ return isGone(node) ? false : replaceNode(node, 'parse');
258
+ const root = path[0];
259
+ if (typeof root === 'string' && !ROOT_REQUIRED.has(root) && root in work) {
260
+ delete work[root];
261
+ markGone([root]);
262
+ fallbacks.push({ path: root, type: root, reason: 'parse', as: 'dropped' });
263
+ return true;
264
+ }
265
+ return false;
266
+ };
267
+ const guard = (unsafe) => {
268
+ if (isGone(unsafe))
269
+ return false;
270
+ const control = controlAt(unsafe);
271
+ if (control)
272
+ return isGone(control) ? false : drop(control, 'safety');
273
+ const node = nodeAt(unsafe);
274
+ if (!node || isGone(node))
275
+ return false;
276
+ return replaceNode(node, 'safety', options.safetyText);
277
+ };
278
+ const maxPasses = REPAIRS_PER_OBJECT * sizeOf(work) + 2;
279
+ for (let pass = 0; pass < maxPasses; pass++) {
280
+ gone = new Set();
281
+ const parsed = parser.safeParse(work);
282
+ // Every issue of this parse, later paths first, each repaired once.
283
+ const found = parsed.success
284
+ ? strippedSafety(work, parsed.data)
285
+ : parsed.error.issues.map((issue) => issue.path);
286
+ if (found.length === 0)
287
+ return { tree: work, fallbacks };
288
+ const order = found
289
+ .map((path, index) => ({ path, index }))
290
+ .sort((a, b) => laterFirst(a.path, b.path));
291
+ let repaired = false;
292
+ for (const { path, index } of order) {
293
+ const did = parsed.success ? guard(path) : repair(parsed.error.issues[index]);
294
+ repaired = did || repaired;
295
+ }
296
+ if (!repaired)
297
+ break;
298
+ }
299
+ // Nothing above made the tree acceptable: send the screen as one line rather than a broken one.
300
+ const title = typeof work.title === 'string' ? work.title : '';
301
+ fallbacks.push({ path: 'children', type: 'screen', reason: 'parse', as: 'text' });
302
+ return {
303
+ tree: {
304
+ version: 1,
305
+ title,
306
+ children: [{ type: 'text', value: options.genericText, muted: true }],
307
+ },
308
+ fallbacks,
309
+ };
310
+ }
package/dist/dataset.d.ts CHANGED
@@ -62,9 +62,9 @@ export declare const datasetFieldSchema: z.ZodObject<{
62
62
  type: z.ZodEnum<{
63
63
  number: "number";
64
64
  boolean: "boolean";
65
- text: "text";
66
- datetime: "datetime";
67
65
  date: "date";
66
+ datetime: "datetime";
67
+ text: "text";
68
68
  enum: "enum";
69
69
  url: "url";
70
70
  tags: "tags";
@@ -102,9 +102,9 @@ export declare const datasetDeclarationSchema: z.ZodObject<{
102
102
  type: z.ZodEnum<{
103
103
  number: "number";
104
104
  boolean: "boolean";
105
- text: "text";
106
- datetime: "datetime";
107
105
  date: "date";
106
+ datetime: "datetime";
107
+ text: "text";
108
108
  enum: "enum";
109
109
  url: "url";
110
110
  tags: "tags";
@@ -159,9 +159,9 @@ export declare const datasetManifestSchema: z.ZodArray<z.ZodObject<{
159
159
  type: z.ZodEnum<{
160
160
  number: "number";
161
161
  boolean: "boolean";
162
- text: "text";
163
- datetime: "datetime";
164
162
  date: "date";
163
+ datetime: "datetime";
164
+ text: "text";
165
165
  enum: "enum";
166
166
  url: "url";
167
167
  tags: "tags";
@@ -0,0 +1,64 @@
1
+ /** How a screen writes dates and times. Every field is optional. */
2
+ export interface DateFormat {
3
+ /** Lead with the short weekday: "Thu 15 Oct". Default false. */
4
+ weekday?: boolean;
5
+ /**
6
+ * When to write the year. `always` (the default) writes it, `never` leaves it
7
+ * out, and `auto` leaves it out for a date in the current year (in the
8
+ * screen's time zone) and writes it otherwise.
9
+ */
10
+ year?: 'auto' | 'always' | 'never';
11
+ /**
12
+ * true: a 12-hour clock with a lowercase "am"/"pm" ("8:47 pm", the gap a
13
+ * no-break space). false: a 24-hour clock ("20:47"). Unset: the locale's
14
+ * own clock.
15
+ */
16
+ hour12?: boolean;
17
+ /**
18
+ * A BCP 47 locale the words follow, "en-GB", in place of the viewer's own.
19
+ * The order of day and month, and the commas, come from it.
20
+ */
21
+ locale?: string;
22
+ }
23
+ /** What a renderer draws: a date, a date and a time, or only the clock. */
24
+ export type MomentStyle = 'date' | 'datetime' | 'time';
25
+ /**
26
+ * The format one value is drawn with: the screen's, with the fields the node
27
+ * sets in its own `dateFormat` laid over it. undefined when neither sets one,
28
+ * which means "draw as before".
29
+ */
30
+ export declare function mergeDateFormat(screen: DateFormat | undefined, own: DateFormat | undefined): DateFormat | undefined;
31
+ /** True for a BCP 47 locale this runtime can format in: "en-GB", "fr-CA". */
32
+ export declare function isDateLocale(value: string): boolean;
33
+ /**
34
+ * The Intl options for a moment. Without a format these are the renderers'
35
+ * defaults (medium date, short time); with one, the parts it asks for.
36
+ */
37
+ export declare function dateTimeFormatOptions(iso: string, style: MomentStyle, opts?: {
38
+ format?: DateFormat;
39
+ timeZone?: string;
40
+ now?: number;
41
+ }): Intl.DateTimeFormatOptions;
42
+ /**
43
+ * A moment as words, in `timeZone` (an IANA name; undefined is the runtime's
44
+ * own zone). `locale` is the viewer's (undefined: the runtime's), and a
45
+ * format's own `locale` wins over it. With `hour12: true` the day period is
46
+ * written lowercase without dots, "8:47 pm", whatever the locale writes, after
47
+ * a no-break space (U+00A0) so the time never wraps apart from it.
48
+ *
49
+ * Never throws for a locale the runtime cannot use: it falls back to the
50
+ * viewer's.
51
+ *
52
+ * ```ts
53
+ * formatMoment('2026-10-16T00:47:00Z', 'datetime', {
54
+ * format: { weekday: true, year: 'auto', hour12: true, locale: 'en-GB' },
55
+ * timeZone: 'America/Toronto',
56
+ * }); // "Thu 15 Oct, 8:47 pm" during 2026
57
+ * ```
58
+ */
59
+ export declare function formatMoment(iso: string, style: MomentStyle, opts?: {
60
+ format?: DateFormat;
61
+ timeZone?: string;
62
+ locale?: string;
63
+ now?: number;
64
+ }): string;
@@ -0,0 +1,132 @@
1
+ // Renderer-side helper for how a screen writes a moment: "Thu 15 Oct, 8:47 pm"
2
+ // instead of the default "15 Oct 2026, 20:47". It lives in the SDK, not in a
3
+ // renderer, because core and every client draw dates: the same tree must read
4
+ // the same on web and mobile.
5
+ //
6
+ // A screen opts in with `tree.dateFormat`; a `timestamp` block or a `date` cell
7
+ // may override single fields with its own `dateFormat`. Without either, a
8
+ // renderer keeps its default (Intl's medium date and short time), so screens
9
+ // that never set it draw exactly as before.
10
+ const FIELDS = ['weekday', 'year', 'hour12', 'locale'];
11
+ /**
12
+ * The format one value is drawn with: the screen's, with the fields the node
13
+ * sets in its own `dateFormat` laid over it. undefined when neither sets one,
14
+ * which means "draw as before".
15
+ */
16
+ export function mergeDateFormat(screen, own) {
17
+ if (!screen && !own)
18
+ return undefined;
19
+ const out = {};
20
+ for (const source of [screen, own]) {
21
+ if (!source)
22
+ continue;
23
+ for (const key of FIELDS) {
24
+ if (source[key] !== undefined)
25
+ Object.assign(out, { [key]: source[key] });
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+ /** True for a BCP 47 locale this runtime can format in: "en-GB", "fr-CA". */
31
+ export function isDateLocale(value) {
32
+ try {
33
+ return Intl.DateTimeFormat.supportedLocalesOf([value]).length > 0;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /** The year a moment falls in, in a zone (undefined: the runtime's own). */
40
+ function yearIn(ms, timeZone) {
41
+ return new Intl.DateTimeFormat('en-US', { year: 'numeric', timeZone }).format(new Date(ms));
42
+ }
43
+ /**
44
+ * The Intl options for a moment. Without a format these are the renderers'
45
+ * defaults (medium date, short time); with one, the parts it asks for.
46
+ */
47
+ export function dateTimeFormatOptions(iso, style, opts = {}) {
48
+ const { format, timeZone } = opts;
49
+ if (!format) {
50
+ if (style === 'date')
51
+ return { dateStyle: 'medium', timeZone };
52
+ if (style === 'time')
53
+ return { timeStyle: 'short', timeZone };
54
+ return { dateStyle: 'medium', timeStyle: 'short', timeZone };
55
+ }
56
+ const options = { timeZone };
57
+ if (style !== 'time') {
58
+ if (format.weekday)
59
+ options.weekday = 'short';
60
+ options.day = 'numeric';
61
+ options.month = 'short';
62
+ const year = format.year ?? 'always';
63
+ const showYear = year === 'always' ||
64
+ (year === 'auto' &&
65
+ yearIn(Date.parse(iso), timeZone) !== yearIn(opts.now ?? Date.now(), timeZone));
66
+ if (showYear)
67
+ options.year = 'numeric';
68
+ }
69
+ if (style !== 'date') {
70
+ options.hour = 'numeric';
71
+ options.minute = '2-digit';
72
+ if (format.hour12 === true)
73
+ options.hourCycle = 'h12';
74
+ if (format.hour12 === false) {
75
+ // A 24-hour clock pads the hour: "08:05", "00:05".
76
+ options.hour = '2-digit';
77
+ options.hourCycle = 'h23';
78
+ }
79
+ }
80
+ return options;
81
+ }
82
+ /** "p.m.", "PM", "p. m." → "pm". Only an a/p + m day period is rewritten. */
83
+ function lowerDayPeriod(value) {
84
+ const match = /^([ap])\.?\s?m\.?$/i.exec(value.trim());
85
+ return match ? `${match[1].toLowerCase()}m` : value;
86
+ }
87
+ /**
88
+ * A moment as words, in `timeZone` (an IANA name; undefined is the runtime's
89
+ * own zone). `locale` is the viewer's (undefined: the runtime's), and a
90
+ * format's own `locale` wins over it. With `hour12: true` the day period is
91
+ * written lowercase without dots, "8:47 pm", whatever the locale writes, after
92
+ * a no-break space (U+00A0) so the time never wraps apart from it.
93
+ *
94
+ * Never throws for a locale the runtime cannot use: it falls back to the
95
+ * viewer's.
96
+ *
97
+ * ```ts
98
+ * formatMoment('2026-10-16T00:47:00Z', 'datetime', {
99
+ * format: { weekday: true, year: 'auto', hour12: true, locale: 'en-GB' },
100
+ * timeZone: 'America/Toronto',
101
+ * }); // "Thu 15 Oct, 8:47 pm" during 2026
102
+ * ```
103
+ */
104
+ export function formatMoment(iso, style, opts = {}) {
105
+ const options = dateTimeFormatOptions(iso, style, opts);
106
+ const date = new Date(iso);
107
+ const wanted = opts.format?.locale ?? opts.locale;
108
+ let formatter;
109
+ try {
110
+ formatter = new Intl.DateTimeFormat(wanted, options);
111
+ }
112
+ catch {
113
+ formatter = new Intl.DateTimeFormat(opts.locale, options);
114
+ }
115
+ if (opts.format?.hour12 !== true || style === 'date')
116
+ return formatter.format(date);
117
+ const parts = formatter.formatToParts(date);
118
+ return parts
119
+ .map((part, i) => {
120
+ if (part.type === 'dayPeriod')
121
+ return lowerDayPeriod(part.value);
122
+ // The gap before "pm" is a no-break space on every engine (ICU writes a
123
+ // narrow one, older engines a plain one), so "8:47 pm" never wraps apart.
124
+ if (part.type === 'literal' &&
125
+ parts[i + 1]?.type === 'dayPeriod' &&
126
+ /^\s+$/u.test(part.value)) {
127
+ return '\u00a0';
128
+ }
129
+ return part.value;
130
+ })
131
+ .join('');
132
+ }
@@ -0,0 +1,22 @@
1
+ import type { NavigatePress } from './schema.js';
2
+ /** Where a screen is shown: a page of its own, or a drawer over another screen. */
3
+ export type ScreenPlace = 'page' | 'drawer';
4
+ /** True for a navigate press that opens its screen in a drawer. */
5
+ export declare function opensInDrawer(press: {
6
+ kind: string;
7
+ open?: unknown;
8
+ } | undefined): press is NavigatePress & {
9
+ open: 'drawer';
10
+ };
11
+ /**
12
+ * Where a navigate lands, from the screen it came from (`from`):
13
+ * - a press with `open: 'drawer'` opens a drawer, or shows its screen in the drawer it was
14
+ * pressed in (a drawer never opens another drawer);
15
+ * - a press without it opens a page, and from a drawer that leaves the drawer;
16
+ * - an action result's `navigate` (`via: 'result'`) stays where the action ran: a drawer's
17
+ * action moves the drawer on ("Save and next"), a page's moves the page.
18
+ */
19
+ export declare function screenPlace(navigate: {
20
+ screen: string;
21
+ open?: unknown;
22
+ }, via: 'press' | 'result', from: ScreenPlace): ScreenPlace;
package/dist/drawer.js ADDED
@@ -0,0 +1,21 @@
1
+ // Where a module screen opens (UI-14): as a page, or in a drawer over the screen the reader was
2
+ // on. Core and every client host screens, so the rule is written once, here, and the same tree
3
+ // opens the same way on web, on the phone and in core's own pages. The contract is on the
4
+ // navigate press in schema.ts (`open`).
5
+ /** True for a navigate press that opens its screen in a drawer. */
6
+ export function opensInDrawer(press) {
7
+ return press?.kind === 'navigate' && press.open === 'drawer';
8
+ }
9
+ /**
10
+ * Where a navigate lands, from the screen it came from (`from`):
11
+ * - a press with `open: 'drawer'` opens a drawer, or shows its screen in the drawer it was
12
+ * pressed in (a drawer never opens another drawer);
13
+ * - a press without it opens a page, and from a drawer that leaves the drawer;
14
+ * - an action result's `navigate` (`via: 'result'`) stays where the action ran: a drawer's
15
+ * action moves the drawer on ("Save and next"), a page's moves the page.
16
+ */
17
+ export function screenPlace(navigate, via, from) {
18
+ if (via === 'result')
19
+ return from;
20
+ return navigate.open === 'drawer' ? 'drawer' : 'page';
21
+ }
package/dist/edit.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ /** The `edit` fields these helpers read. Loose, so a client whose installed SDK predates them can
2
+ * pass its own node. */
3
+ export interface InlineEditLike {
4
+ action: string;
5
+ input?: Record<string, unknown>;
6
+ label?: string;
7
+ }
8
+ export interface InlineEditOption {
9
+ value: string;
10
+ label: string;
11
+ }
12
+ /** What an action press built by `inlineEditPress` looks like. */
13
+ export interface InlineEditPress {
14
+ kind: 'action';
15
+ action: string;
16
+ input: Record<string, unknown>;
17
+ }
18
+ /** The edit control's accessible name and tooltip when the module gave no `label`. */
19
+ export declare const INLINE_EDIT_DEFAULT_LABEL = "Edit";
20
+ /** The edit control's accessible name and tooltip: the module's `label`, or "Edit". */
21
+ export declare function inlineEditLabel(edit: Pick<InlineEditLike, 'label'>): string;
22
+ /**
23
+ * The ONE action press Save posts: the edit's `action`, its `input`, and each field's key set to
24
+ * its new value. A value wins over `input` on the same key, because it is what the reader just
25
+ * typed.
26
+ */
27
+ export declare function inlineEditPress(edit: InlineEditLike, values: Readonly<Record<string, string>>): InlineEditPress;
28
+ /**
29
+ * Save for one table cell: `row` is the row's key, and the column's key holds the new value.
30
+ */
31
+ export declare function tableCellEditPress(edit: InlineEditLike, column: string, rowKey: string, value: string): InlineEditPress;
32
+ /** The cell fields these helpers read. */
33
+ export interface EditableCellLike {
34
+ kind: string;
35
+ value?: unknown;
36
+ }
37
+ /** True for a cell an editable column lets the reader change: a `text` or `badge` cell. */
38
+ export declare function cellEditable(cell: EditableCellLike | undefined): boolean;
39
+ /**
40
+ * What a field starts with. An explicit `start` (a details item's `edit.value`) wins. With
41
+ * options, the option whose value or label is the shown text (a badge shows the label), or the
42
+ * first option. Without, the shown text.
43
+ */
44
+ export declare function inlineEditStart(shown: string, options?: readonly InlineEditOption[], start?: string): string;
45
+ /** The details item fields these helpers read. */
46
+ export interface EditableDetailsItemLike {
47
+ value: EditableCellLike;
48
+ edit?: {
49
+ field: string;
50
+ options?: readonly InlineEditOption[];
51
+ value?: string;
52
+ };
53
+ }
54
+ /**
55
+ * What each field of an editable `details` block starts with, by field key. Items without `edit`
56
+ * (and date items, which cannot be edited) are left out.
57
+ */
58
+ export declare function detailsEditStart(items: readonly EditableDetailsItemLike[]): Record<string, string>;
59
+ /** True when any field differs from what it started with: an edit that holds the screen still. */
60
+ export declare function inlineEditDirty(start: Readonly<Record<string, string>>, draft: Readonly<Record<string, string>>): boolean;