@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
package/README.md CHANGED
@@ -120,3 +120,121 @@ calls the module runtime. Availability controls UI placement. Existing authentic
120
120
  still control who may enter Dashboard or a Kee client.
121
121
 
122
122
  See [`examples/campaigns`](./examples/campaigns) for one complete in-memory example.
123
+
124
+ ## 6. Check that each app can draw your screen
125
+
126
+ An app parses a screen with the ui-sdk inside the sdk it was built with, frozen the day it shipped,
127
+ and it does not draw every node. Before core sends a screen, it reads the app from `X-Kee-Client`
128
+ and fits the screen to that app:
129
+
130
+ - each node the app cannot draw becomes a `text` or `markdown` line that says what the node said
131
+ (a pager, a select or an alert button stops being pressable); times in that line are written in
132
+ the screen's `timeZone` (the Company's when your screen names none, a calendar's own first),
133
+ and in UTC only when there is no zone;
134
+ - a value the app's parse refuses is folded to one it takes (an `info` or `accent` tone becomes
135
+ `default`; a date field's `min` and `max` are dropped), or the node becomes its text line;
136
+ - a control whose safety the app would lose (a form's `submit.confirm`, a `disabled` flag) becomes
137
+ "Open this in the web app to finish this step.", or is dropped from its list of controls, so
138
+ nothing acts without asking.
139
+
140
+ A table cell no released app draws yet (a `sparkline`, a `meter`, a `secondary` line under a text
141
+ or badge cell) reaches every app as a plain text cell: the sparkline's `label`, the meter's
142
+ `valueLabel` or percent, and no second line.
143
+
144
+ Every value the app refuses is fitted, however many there are: a 500-row table with an `info`
145
+ badge in each row reaches the app as a 500-row table with `default` badges. Only a screen nothing
146
+ can fit becomes one line.
147
+
148
+ | App | Parses with | Draws |
149
+ | ------------------- | ------------ | ---------------------------------------------------------------------------------------- |
150
+ | web (0.1.0, 0.2.0) | ui-sdk 1.4.0 | Every node, breadcrumbs, disabled buttons, row sections, every field kind, form confirms |
151
+ | mobile 0.2.0 | ui-sdk 1.1.0 | The 15 nodes of 1.1.0, and none of those features |
152
+ | mobile 0.2.3 and on | ui-sdk 1.4.0 | The 15 nodes of 1.1.0, breadcrumbs, disabled buttons, every field kind, form confirms |
153
+ | desktop | (floor) | Renders no module screens |
154
+ | no `X-Kee-Client` | this package | Keemakr's own web app: every node, as sent |
155
+
156
+ On dev and stage (never in production), a web build that installed the @dev sdk channel says
157
+ it parses this package (`web/0.2.0+abc1234.ui-current`) and gets every node as sent, so a new node
158
+ can be tried on the dev web app before a stable sdk carries it (`CHANNEL_BUILDS`,
159
+ `nodeSetForClaim`). Production web builds get the row above.
160
+
161
+ `CLIENT_RELEASES` holds the full table. Test against it with the testkit:
162
+
163
+ ```ts
164
+ import { assertDrawableOn, renderForClients } from '@keemakr/ui-sdk/testkit';
165
+
166
+ // Fails, naming each node and client, when a target client would get a fallback.
167
+ assertDrawableOn(await screen.load(ctx), ['web']);
168
+
169
+ // Accept the fallback, and assert on the tree that client gets.
170
+ const [mobile] = assertDrawableOn(tree, ['mobile'], { allowFallback: true });
171
+ ```
172
+
173
+ With no targets, the testkit checks every app that renders module screens (web and mobile). An app
174
+ named alone is checked at every release it has, so `['mobile']` covers mobile 0.2.0 as well as the
175
+ newest build.
176
+
177
+ ## 7. Return the new screen from an action
178
+
179
+ An action runs on your runtime, and by default the client then asks core to load the screen
180
+ again: two more round trips before the person sees the change. An action can return the screen
181
+ itself, as `load` would return it now, for the same screen and the same params the action ran
182
+ with:
183
+
184
+ ```ts
185
+ export default defineScreen({
186
+ load: async (ctx, params) => buildScreen(ctx, params),
187
+ actions: {
188
+ approve: async (ctx, input, params) => {
189
+ await approve(ctx, input);
190
+ return { toast: 'Approved', tree: await buildScreen(ctx, params) };
191
+ },
192
+ },
193
+ });
194
+ ```
195
+
196
+ - A client that knows `tree` draws it and does not call `load`. A client built before this field
197
+ ignores it and reloads, because `refresh` stays `true`. Send `tree` and leave `refresh` alone.
198
+ - `navigate` wins over `tree`. To show another page of the screen, return `navigate`, as before.
199
+ - Core sends the tree through the same checks as a loaded one: every node the client cannot draw
200
+ becomes a fallback (section 6), and the cards for `pending_request` slots come beside it in
201
+ `pendingRequests`. Test the tree with `assertDrawableOn(result.tree, ['web'])`.
202
+ - A tree that does not validate is dropped, and the client reloads: the action has already run, so core does not fail it for a tree that only saves a reload.
203
+ - When core answers a retried action from its log, it sends no tree, and the client reloads.
204
+
205
+ ## 8. Show a live count beside a menu entry
206
+
207
+ A surface can show a count beside its name in the side menu ("My Day 21", "Accounts 8 red"). Opt
208
+ in on the surface:
209
+
210
+ ```ts
211
+ export default defineSurface({ label: 'Accounts', badge: true, availableOn: ['client'] });
212
+ ```
213
+
214
+ Then answer `POST /screens/badges` in your screens channel, behind the same grant check as
215
+ `/screens/:screen/load`. Core sends the opted-in surfaces the person may open, and keeps only
216
+ those in your answer:
217
+
218
+ ```ts
219
+ import { answerBadges, defineBadges } from '@keemakr/ui-sdk';
220
+
221
+ const badges = defineBadges(async (ctx, surfaces) => ({
222
+ 'my-day': { count: await countMyDay(ctx) },
223
+ accounts: { count: 8, tone: 'destructive', label: '8 red' },
224
+ }));
225
+
226
+ POST('/screens/badges', async (req) => {
227
+ const auth = await authorize(req);
228
+ if (!auth.ok) return auth.res;
229
+ const { status, body } = await answerBadges(badges, auth.ctx, await bodyOf(req));
230
+ return json(status, body);
231
+ }),
232
+ ```
233
+
234
+ - `count` is a whole number from 0 to 999999. A menu draws more than 999 as "999+", and draws
235
+ nothing for 0 unless you give a `label`.
236
+ - `label` (up to 24 characters) is drawn in place of the count and read out after the name
237
+ ("Accounts, 8 red"). Put the meaning in it: `tone` only colours it.
238
+ - Core asks about once a minute per person, waits about two seconds, and draws no badge when you
239
+ are slow, fail, or have no such route (404). Answer from stored state.
240
+ - A person's own screen action makes their next menu read ask again sooner.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Menu badges: a live count beside a side-menu entry ("My Day 21", "Accounts 8 red").
3
+ *
4
+ * A surface opts in with `badge: true` in its surface.ts. While a person's menu is open, core asks
5
+ * the module once for all of its opted-in surfaces that person may open:
6
+ *
7
+ * POST {runtime}/screens/badges { surfaces: ['my-day', 'accounts'] }
8
+ * -> { badges: { 'my-day': { count: 21 }, accounts: { count: 8, tone: 'destructive', label: '8 red' } } }
9
+ *
10
+ * with the same grant a screen load carries (the person, their role, the install's scopes). Core
11
+ * waits about two seconds, keeps the answer for a minute per person, and draws no badge when the
12
+ * module is slow, fails or has no such route (404). One module's failure never touches another's
13
+ * badges or the menu itself.
14
+ *
15
+ * This file is the shared half: the module answers with `answerBadges`, core reads the answer with
16
+ * `parseScreenBadges`, and every menu writes and speaks a badge with `badgeText` and
17
+ * `badgeSpokenLabel`, so they all say the same.
18
+ */
19
+ import { type SurfaceBadge, type SurfaceBadgeInput } from './schema.js';
20
+ /** The most a menu draws as a bare count; above it the badge reads "999+". */
21
+ export declare const BADGE_DISPLAY_MAX = 999;
22
+ /**
23
+ * Answers the badges route. `surfaces` is what core asked for: the opted-in surfaces the person
24
+ * may open. Return a badge for each surface that has one; leave a surface out (or return null) for
25
+ * no badge. Answer from stored state, fast: core gives up after about two seconds.
26
+ */
27
+ export type BadgesHandler<Ctx> = (ctx: Ctx, surfaces: string[]) => Record<string, SurfaceBadgeInput | null | undefined> | Promise<Record<string, SurfaceBadgeInput | null | undefined>>;
28
+ /** Identity function, for inference: `export default defineBadges(async (ctx, surfaces) => …)`. */
29
+ export declare function defineBadges<Ctx = unknown>(handler: BadgesHandler<Ctx>): BadgesHandler<Ctx>;
30
+ /**
31
+ * Keep the badges core asked for that validate. Null when `payload` is not a badges answer at all.
32
+ * An entry that does not validate is dropped on its own; it never costs the other badges.
33
+ */
34
+ export declare function parseScreenBadges(payload: unknown, asked: readonly string[]): Record<string, SurfaceBadge> | null;
35
+ /**
36
+ * Run a badges handler for one request body, and return what to send back. Use it in the module's
37
+ * screens channel:
38
+ *
39
+ * POST('/screens/badges', async (req) => {
40
+ * const auth = await authorize(req); // the same grant check as /screens/:screen/load
41
+ * if (!auth.ok) return auth.res;
42
+ * const { status, body } = await answerBadges(badges, auth.ctx, await bodyOf(req));
43
+ * return json(status, body);
44
+ * }),
45
+ *
46
+ * A badge that does not validate is dropped and named in the log, so the author sees it here.
47
+ */
48
+ export declare function answerBadges<Ctx>(handler: BadgesHandler<Ctx>, ctx: Ctx, body: unknown): Promise<{
49
+ status: 200;
50
+ body: {
51
+ badges: Record<string, SurfaceBadge>;
52
+ };
53
+ } | {
54
+ status: 400 | 500;
55
+ body: {
56
+ error: string;
57
+ };
58
+ }>;
59
+ /**
60
+ * The text a menu draws: the module's label when it gave one, else the count ("999+" above 999).
61
+ * Null when there is nothing to draw: a count of 0 with no label.
62
+ */
63
+ export declare function badgeText(badge: Pick<SurfaceBadge, 'count' | 'label'>): string | null;
64
+ /** What a screen reader says for the menu entry: "Accounts, 8 red", "My Day, 21". */
65
+ export declare function badgeSpokenLabel(surfaceLabel: string, badge: Pick<SurfaceBadge, 'count' | 'label'> | null | undefined): string;
package/dist/badges.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Menu badges: a live count beside a side-menu entry ("My Day 21", "Accounts 8 red").
3
+ *
4
+ * A surface opts in with `badge: true` in its surface.ts. While a person's menu is open, core asks
5
+ * the module once for all of its opted-in surfaces that person may open:
6
+ *
7
+ * POST {runtime}/screens/badges { surfaces: ['my-day', 'accounts'] }
8
+ * -> { badges: { 'my-day': { count: 21 }, accounts: { count: 8, tone: 'destructive', label: '8 red' } } }
9
+ *
10
+ * with the same grant a screen load carries (the person, their role, the install's scopes). Core
11
+ * waits about two seconds, keeps the answer for a minute per person, and draws no badge when the
12
+ * module is slow, fails or has no such route (404). One module's failure never touches another's
13
+ * badges or the menu itself.
14
+ *
15
+ * This file is the shared half: the module answers with `answerBadges`, core reads the answer with
16
+ * `parseScreenBadges`, and every menu writes and speaks a badge with `badgeText` and
17
+ * `badgeSpokenLabel`, so they all say the same.
18
+ */
19
+ import { BADGE_COUNT_MAX, screenBadgesRequestSchema, surfaceBadgeSchema, } from './schema.js';
20
+ /** The most a menu draws as a bare count; above it the badge reads "999+". */
21
+ export const BADGE_DISPLAY_MAX = 999;
22
+ /** Identity function, for inference: `export default defineBadges(async (ctx, surfaces) => …)`. */
23
+ export function defineBadges(handler) {
24
+ return handler;
25
+ }
26
+ /**
27
+ * Keep the badges core asked for that validate. Null when `payload` is not a badges answer at all.
28
+ * An entry that does not validate is dropped on its own; it never costs the other badges.
29
+ */
30
+ export function parseScreenBadges(payload, asked) {
31
+ if (typeof payload !== 'object' || payload === null)
32
+ return null;
33
+ const badges = payload.badges;
34
+ if (typeof badges !== 'object' || badges === null || Array.isArray(badges))
35
+ return null;
36
+ const out = {};
37
+ for (const key of new Set(asked)) {
38
+ if (!Object.prototype.hasOwnProperty.call(badges, key))
39
+ continue;
40
+ const parsed = surfaceBadgeSchema.safeParse(badges[key]);
41
+ if (parsed.success)
42
+ out[key] = parsed.data;
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * Run a badges handler for one request body, and return what to send back. Use it in the module's
48
+ * screens channel:
49
+ *
50
+ * POST('/screens/badges', async (req) => {
51
+ * const auth = await authorize(req); // the same grant check as /screens/:screen/load
52
+ * if (!auth.ok) return auth.res;
53
+ * const { status, body } = await answerBadges(badges, auth.ctx, await bodyOf(req));
54
+ * return json(status, body);
55
+ * }),
56
+ *
57
+ * A badge that does not validate is dropped and named in the log, so the author sees it here.
58
+ */
59
+ export async function answerBadges(handler, ctx, body) {
60
+ const request = screenBadgesRequestSchema.safeParse(body ?? {});
61
+ if (!request.success)
62
+ return { status: 400, body: { error: 'invalid badges request' } };
63
+ const surfaces = request.data.surfaces;
64
+ let answered;
65
+ try {
66
+ answered = (await handler(ctx, surfaces)) ?? {};
67
+ }
68
+ catch (e) {
69
+ console.error('[screens] badges handler threw:', e);
70
+ return { status: 500, body: { error: 'badges failed' } };
71
+ }
72
+ const badges = parseScreenBadges({ badges: answered }, surfaces) ?? {};
73
+ for (const key of surfaces) {
74
+ if (answered[key] != null && !badges[key]) {
75
+ console.warn(`[screens] badges: the badge for "${key}" does not validate and was dropped ` +
76
+ `(count: a whole number from 0 to ${BADGE_COUNT_MAX}; label: 1 to 24 characters).`);
77
+ }
78
+ }
79
+ return { status: 200, body: { badges } };
80
+ }
81
+ /**
82
+ * The text a menu draws: the module's label when it gave one, else the count ("999+" above 999).
83
+ * Null when there is nothing to draw: a count of 0 with no label.
84
+ */
85
+ export function badgeText(badge) {
86
+ if (badge.label)
87
+ return badge.label;
88
+ if (badge.count <= 0)
89
+ return null;
90
+ return badge.count > BADGE_DISPLAY_MAX ? `${BADGE_DISPLAY_MAX}+` : String(badge.count);
91
+ }
92
+ /** What a screen reader says for the menu entry: "Accounts, 8 red", "My Day, 21". */
93
+ export function badgeSpokenLabel(surfaceLabel, badge) {
94
+ const text = badge ? badgeText(badge) : null;
95
+ return text ? `${surfaceLabel}, ${text}` : surfaceLabel;
96
+ }
package/dist/build.js CHANGED
@@ -27,7 +27,7 @@ function literalValue(expression) {
27
27
  return false;
28
28
  if (ts.isArrayLiteralExpression(value))
29
29
  return value.elements.map(literalValue);
30
- throw new Error('surface.ts values must be string or array literals');
30
+ throw new Error('surface.ts values must be string, boolean or array literals');
31
31
  }
32
32
  /** A dataset declaration also takes numbers and objects. Still literal only. */
33
33
  function declarationValue(expression) {
@@ -177,7 +177,7 @@ async function readSurfaceDefinition(path) {
177
177
  }
178
178
  raw[name.text] = literalValue(property.initializer);
179
179
  }
180
- const allowed = new Set(['label', 'icon', 'description', 'availableOn']);
180
+ const allowed = new Set(['label', 'icon', 'description', 'badge', 'availableOn']);
181
181
  for (const key of Object.keys(raw)) {
182
182
  if (!allowed.has(key))
183
183
  throw new Error(`${path}: unknown surface property "${key}"`);
@@ -0,0 +1,31 @@
1
+ type MeterCell = {
2
+ kind: 'meter';
3
+ value: number;
4
+ max: number;
5
+ valueLabel?: string;
6
+ };
7
+ /** How full a meter's bar is, 0 to 100. A value past `max` is a full bar. */
8
+ export declare function meterPercent(cell: Pick<MeterCell, 'value' | 'max'>): number;
9
+ /**
10
+ * The text drawn beside a meter's bar, always: its `valueLabel`, or the percent of `max`
11
+ * ("120%" when past it, so the text says what the full bar cannot).
12
+ */
13
+ export declare function meterLabel(cell: Pick<MeterCell, 'value' | 'max' | 'valueLabel'>): string;
14
+ type AnyCell = {
15
+ kind: string;
16
+ } & Record<string, unknown>;
17
+ /**
18
+ * A table cell as a client that draws no sparkline, meter or second line gets it (the
19
+ * `table.cells` feature in negotiate.ts):
20
+ * sparkline -> text: its `label`
21
+ * meter -> text: its `valueLabel`, or the percent of `max`
22
+ * text or badge -> the same cell, its `secondary` line dropped
23
+ * Any other cell is returned as is.
24
+ */
25
+ export declare function foldTableCell<C extends AnyCell>(cell: C): C | {
26
+ kind: 'text';
27
+ value: string;
28
+ };
29
+ /** True when a client without `table.cells` would get this cell changed. */
30
+ export declare function isNewerTableCell(cell: AnyCell): boolean;
31
+ export {};
package/dist/cells.js ADDED
@@ -0,0 +1,45 @@
1
+ // Table cells that draw more than a word: a sparkline, a meter, a second line (UI-09).
2
+ //
3
+ // Every renderer says the same words for them, so they live here: the meter's text beside its
4
+ // bar, how full the bar is, and the plain text cell a client that cannot draw them gets.
5
+ /** How full a meter's bar is, 0 to 100. A value past `max` is a full bar. */
6
+ export function meterPercent(cell) {
7
+ if (!(cell.max > 0))
8
+ return 0;
9
+ return Math.min(100, Math.max(0, (cell.value / cell.max) * 100));
10
+ }
11
+ /**
12
+ * The text drawn beside a meter's bar, always: its `valueLabel`, or the percent of `max`
13
+ * ("120%" when past it, so the text says what the full bar cannot).
14
+ */
15
+ export function meterLabel(cell) {
16
+ if (cell.valueLabel)
17
+ return cell.valueLabel;
18
+ if (!(cell.max > 0))
19
+ return `${cell.value}`;
20
+ return `${Math.round((cell.value / cell.max) * 100)}%`;
21
+ }
22
+ /**
23
+ * A table cell as a client that draws no sparkline, meter or second line gets it (the
24
+ * `table.cells` feature in negotiate.ts):
25
+ * sparkline -> text: its `label`
26
+ * meter -> text: its `valueLabel`, or the percent of `max`
27
+ * text or badge -> the same cell, its `secondary` line dropped
28
+ * Any other cell is returned as is.
29
+ */
30
+ export function foldTableCell(cell) {
31
+ if (cell.kind === 'sparkline')
32
+ return { kind: 'text', value: String(cell.label) };
33
+ if (cell.kind === 'meter') {
34
+ return { kind: 'text', value: meterLabel(cell) };
35
+ }
36
+ if ('secondary' in cell) {
37
+ const { secondary: _secondary, ...rest } = cell;
38
+ return rest;
39
+ }
40
+ return cell;
41
+ }
42
+ /** True when a client without `table.cells` would get this cell changed. */
43
+ export function isNewerTableCell(cell) {
44
+ return cell.kind === 'sparkline' || cell.kind === 'meter' || 'secondary' in cell;
45
+ }
@@ -0,0 +1,20 @@
1
+ /** The most categories a chart takes, and so the most values in each series. */
2
+ export declare const CHART_POINTS_MAX = 120;
3
+ /**
4
+ * `limit` evenly spaced indices out of `count`, first and last included, in order. All of them
5
+ * when `count` is not above `limit`.
6
+ */
7
+ export declare function thinnedIndices(count: number, limit: number): number[];
8
+ /** The chart fields that hold one entry per category. */
9
+ interface ChartPoints {
10
+ categories: string[];
11
+ series: Array<{
12
+ values: number[];
13
+ }>;
14
+ }
15
+ /**
16
+ * The chart with at most `limit` categories, each series keeping the value of every category it
17
+ * keeps. Everything else on the chart is kept as it is. The input is not touched.
18
+ */
19
+ export declare function thinChart<T extends ChartPoints>(chart: T, limit: number): T;
20
+ export {};
package/dist/chart.js ADDED
@@ -0,0 +1,38 @@
1
+ // How many points a chart series holds, and how a longer chart is cut down for an app that takes
2
+ // fewer (UI-05).
3
+ //
4
+ // A chart takes up to 120 points per series: 90 days plus margin, so an adoption screen can show
5
+ // DAU, WAU and MAU over the last 90 days. Released apps parse with ui-sdk 1.1.0 or 1.4.0, which
6
+ // refuse more than 50, and a refused chart would cost the app the whole chart. So core sends such
7
+ // an app the chart thinned to 50 evenly spaced points instead (conform.ts). The first and the last
8
+ // point are always kept: the start of the range and today.
9
+ /** The most categories a chart takes, and so the most values in each series. */
10
+ export const CHART_POINTS_MAX = 120;
11
+ /**
12
+ * `limit` evenly spaced indices out of `count`, first and last included, in order. All of them
13
+ * when `count` is not above `limit`.
14
+ */
15
+ export function thinnedIndices(count, limit) {
16
+ if (count <= limit)
17
+ return Array.from({ length: count }, (_, index) => index);
18
+ if (limit <= 1)
19
+ return [count - 1];
20
+ return Array.from({ length: limit }, (_, slot) => Math.round((slot * (count - 1)) / (limit - 1)));
21
+ }
22
+ /**
23
+ * The chart with at most `limit` categories, each series keeping the value of every category it
24
+ * keeps. Everything else on the chart is kept as it is. The input is not touched.
25
+ */
26
+ export function thinChart(chart, limit) {
27
+ if (chart.categories.length <= limit)
28
+ return chart;
29
+ const keep = thinnedIndices(chart.categories.length, limit);
30
+ return {
31
+ ...chart,
32
+ categories: keep.map((index) => chart.categories[index]),
33
+ series: chart.series.map((series) => ({
34
+ ...series,
35
+ values: keep.map((index) => series.values[index]),
36
+ })),
37
+ };
38
+ }
@@ -0,0 +1,21 @@
1
+ import type { z } from 'zod';
2
+ import type { Fallback } from './negotiate.js';
3
+ type Json = Record<string, unknown>;
4
+ export interface ConformOptions {
5
+ /** The node's own fallback (negotiate.ts `fallbackNode`). */
6
+ fallback: (node: unknown) => Json;
7
+ /** The line that replaces a control whose safety field this app would lose. */
8
+ safetyText: string;
9
+ /** The line that replaces a node whose own fallback this app still refuses. */
10
+ genericText: string;
11
+ }
12
+ export interface Conformed {
13
+ tree: Json;
14
+ fallbacks: Fallback[];
15
+ }
16
+ /**
17
+ * The tree as `parser` takes it, with every change listed. The input is not touched. Each change
18
+ * keeps the tree valid against this package's own schema.
19
+ */
20
+ export declare function conformTree(tree: unknown, parser: z.ZodType, options: ConformOptions): Conformed;
21
+ export {};