@wix/himalaya-cli 0.960.0 → 0.962.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/cli.mjs +1 -0
  2. package/package.json +2 -2
  3. package/templates/beauty-salon-owner/config.ts +1 -1
  4. package/templates/beauty-salon-owner/dev/index.ts +3075 -0
  5. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +466 -9
  6. package/templates/beauty-salon-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  7. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  8. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  9. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  11. package/templates/community-club-owner/config.ts +1 -1
  12. package/templates/community-club-owner/dev/index.ts +3075 -0
  13. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +466 -9
  14. package/templates/community-club-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  15. package/templates/community-club-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  16. package/templates/community-club-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  17. package/templates/community-club-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  18. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  19. package/templates/events-meetups-owner/config.ts +1 -1
  20. package/templates/events-meetups-owner/dev/index.ts +3075 -0
  21. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +466 -9
  22. package/templates/events-meetups-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  23. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  24. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  25. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  26. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  27. package/templates/gym-fitness-owner/config.ts +1 -1
  28. package/templates/gym-fitness-owner/dev/index.ts +3075 -0
  29. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +466 -9
  30. package/templates/gym-fitness-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  31. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  32. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  33. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  34. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  35. package/templates/nonprofit-cause-owner/config.ts +1 -1
  36. package/templates/nonprofit-cause-owner/dev/index.ts +3075 -0
  37. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +466 -9
  38. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  39. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  40. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  41. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  42. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  43. package/templates/owner-lite/config.ts +1 -1
  44. package/templates/owner-lite/dev/index.ts +3075 -0
  45. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +466 -9
  46. package/templates/owner-lite/tier5-src/_shared/aria-generic-widget.ts +251 -0
  47. package/templates/owner-lite/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  48. package/templates/owner-lite/tier5-src/_shared/aria-widget-model.ts +331 -0
  49. package/templates/owner-lite/tier5-src/_shared/aria-widget-view.ts +722 -0
  50. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  51. package/templates/podcast-creator-owner/config.ts +1 -1
  52. package/templates/podcast-creator-owner/dev/index.ts +3075 -0
  53. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +466 -9
  54. package/templates/podcast-creator-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  55. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  56. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  57. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  58. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  59. package/templates/portfolio-creative-owner/config.ts +1 -1
  60. package/templates/portfolio-creative-owner/dev/index.ts +3075 -0
  61. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +466 -9
  62. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  63. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  64. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  65. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  66. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  67. package/templates/restaurant-owner/config.ts +1 -1
  68. package/templates/restaurant-owner/dev/index.ts +3075 -0
  69. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +466 -9
  70. package/templates/restaurant-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  71. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  72. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  73. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  74. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  75. package/templates/retail-store-owner/config.ts +1 -1
  76. package/templates/retail-store-owner/dev/index.ts +3075 -0
  77. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +466 -9
  78. package/templates/retail-store-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  79. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  80. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  81. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  82. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  83. package/templates/yoga-studio-owner/config.ts +1 -1
  84. package/templates/yoga-studio-owner/dev/index.ts +3075 -0
  85. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +466 -9
  86. package/templates/yoga-studio-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  87. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  88. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  89. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  90. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
@@ -0,0 +1,251 @@
1
+ // A widget type that did not exist when this shipped, rendered anyway.
2
+ //
3
+ // The assistant service can ship a new widget kind any day. Left alone, an
4
+ // unrecognised kind degrades to plain prose — safe, but it throws away the
5
+ // options and buttons the payload carried. Eight of the fourteen known kinds
6
+ // share one skeleton (`text` + `options[]` + buttons), so a GENERIC renderer
7
+ // can present a future kind usefully without knowing what it is.
8
+ //
9
+ // This is the one place `WorkerSubtree` is the right tool. The rule elsewhere
10
+ // is that a variant you can enumerate belongs in a `FragmentSlot` case, and
11
+ // every known kind has one. A kind nobody could enumerate when the screen was
12
+ // authored is the exception: a `FragmentSlot` cannot hold a case that did not
13
+ // exist yet.
14
+ //
15
+ // FAIL-CLOSED IS THE WHOLE DESIGN. The host validates the entire tree before
16
+ // decoding it, and ANY violation — an unlisted type, a budget overrun, a bad
17
+ // id, an action outside the allowlist — rejects the whole value and renders
18
+ // the declared fallback. There is no partial render, so this file returns ""
19
+ // for "I could not compose something I am sure of" and lets the prose card
20
+ // take over. Returning a tree that is nearly right is the one outcome worse
21
+ // than returning none.
22
+ //
23
+ // Follows `himi-owner-lite-ask/blocks.ts`, which solved this shape first: the
24
+ // worker decides the layout, copy is sanitized, and nothing off the wire ever
25
+ // becomes STRUCTURE.
26
+
27
+ import type { NormalizedWidget, WidgetOption } from "./aria-widget-model.js";
28
+
29
+ /** Minimal structural type. A worker bundle cannot import the screen-authoring
30
+ * helpers — those run where screens are built, not in the worker — so it
31
+ * hand-rolls its nodes. */
32
+ interface Node {
33
+ id: string;
34
+ body: { package: "himalaya"; type: string; props: Record<string, unknown> };
35
+ }
36
+
37
+ /** The safe types this composer emits. A SUBSET of the platform's 19, and it
38
+ * must stay a subset of the descriptor's `allowedTypes` — the two are checked
39
+ * against each other in the tests, because a perfectly good tree is rejected
40
+ * wholesale when they drift. */
41
+ export const GENERIC_TYPES = ["Card", "VStack", "HStack", "Text", "Badge", "Image", "Spacer", "Button", "Divider"] as const;
42
+
43
+ /** What a future kind needing a chart or text entry gets instead.
44
+ *
45
+ * `Chart`, `DonutChart`, `TextField`, `Form`, `LazyList`, `ListItem`,
46
+ * `Timeline`, `Grid` and `SearchField` are ALL absent from the platform-safe
47
+ * list, so such a kind cannot be rendered generically at all. It falls back to
48
+ * prose until it is given a real case — and the copy says so rather than
49
+ * pretending the owner has seen everything. */
50
+ export const GENERIC_UNSUPPORTED_NOTE = "This needs the latest app version to show in full.";
51
+
52
+ /** The slot the Aria widget region mounts the generic card in.
53
+ *
54
+ * Exported because BOTH sides must agree: the descriptor names it as the
55
+ * `WorkerSubtree` node's id, and the worker prefixes every node id with
56
+ * `wsub.<slotId>.`. If the two ever disagree the entire tree is rejected with
57
+ * `id_violation` and every unknown widget silently becomes prose — which
58
+ * looks exactly like the feature working as designed. One constant, so they
59
+ * cannot drift. */
60
+ export const WIDGET_SUBTREE_SLOT = "ariaWidget";
61
+
62
+ /** Longest literal copy rendered from a payload. Same ceiling as `blocks.ts`. */
63
+ const MAX_COPY = 240;
64
+
65
+ /** Sanitize LITERAL copy off the wire.
66
+ *
67
+ * The host validates a worker-built tree's shape, not its prop VALUES, so a
68
+ * structurally perfect payload can still carry machine noise into the card.
69
+ * A model that degenerates mid-answer leaks its own reasoning as one long
70
+ * underscore-joined run, and two cheap signals catch that: absurd length, and
71
+ * long runs with no whitespace. Rejected copy returns "" so the caller DROPS
72
+ * that node rather than rendering it. */
73
+ export function cleanCopy(raw: unknown): string {
74
+ if (typeof raw !== "string") return "";
75
+ const text = raw.trim();
76
+ if (!text || text.length > MAX_COPY) return "";
77
+ let longest = 0;
78
+ for (const word of text.split(/\s+/)) longest = Math.max(longest, word.length);
79
+ if (longest > 40) return "";
80
+ return text;
81
+ }
82
+
83
+ export interface GenericCardOptions {
84
+ /** The `WorkerSubtree` slot. Every id must be prefixed `wsub.<slotId>.` or
85
+ * the whole tree is rejected with `id_violation` — that is the RFC's
86
+ * provenance mechanism, so a worker-authored action cannot be mistaken for
87
+ * an ordinary UI tap. */
88
+ slotId: string;
89
+ /** The worker service name, for `tier5.<service>.<action>` ids. Passed in
90
+ * rather than hardcoded, because more than one app mounts this chat and
91
+ * each names its service differently. */
92
+ service: string;
93
+ /** Whether an option's picture may be loaded. Supplied by the caller so the
94
+ * host check stays in one place — a decorative image cannot carry its own
95
+ * provenance, and a silently-loaded remote asset is a tracking pixel with
96
+ * extra steps. */
97
+ allowPicture?: (url: string) => boolean;
98
+ /** Node ceiling. Defaults below the platform's 200 so the composer refuses
99
+ * before the validator has to. */
100
+ maxNodes?: number;
101
+ /** Byte ceiling, likewise below the platform's 65 536. */
102
+ maxBytes?: number;
103
+ }
104
+
105
+ const DEFAULT_MAX_NODES = 180;
106
+ const DEFAULT_MAX_BYTES = 60_000;
107
+
108
+ /** Compose a generic card for a widget kind we do not recognise.
109
+ *
110
+ * Returns the tree as a JSON string, or "" when it cannot compose one it is
111
+ * sure of — no options AND no prompt, or a payload that would breach a
112
+ * budget. "" means "render the prose fallback", which is a good outcome, not
113
+ * an error.
114
+ *
115
+ * Budgets are checked against the COMPOSED tree rather than guessed from the
116
+ * option count, and breaching one returns "" rather than dropping rows. A
117
+ * truncated picker is indistinguishable from a complete one, so an owner
118
+ * choosing from it cannot tell that the option they wanted was removed. */
119
+ export function composeGenericCard(w: NormalizedWidget, opts: GenericCardOptions): string {
120
+ const { slotId, service } = opts;
121
+ const maxNodes = opts.maxNodes ?? DEFAULT_MAX_NODES;
122
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
123
+ const allowPicture = opts.allowPicture ?? (() => false);
124
+
125
+ const id = (local: string) => `wsub.${slotId}.${local}`;
126
+ const node = (local: string, type: string, props: Record<string, unknown>): Node =>
127
+ ({ id: id(local), body: { package: "himalaya", type, props } });
128
+ // Every action is the SAME worker action with different params. The id is
129
+ // what the policy allowlists, so keeping it to one keeps the allowlist to one
130
+ // entry — the smallest surface a future payload can reach.
131
+ const respond = (value: string) => ({ actionId: `tier5.${service}.widgetRespond`, params: { value } });
132
+
133
+ const prompt = cleanCopy(w.text);
134
+ const badge = cleanCopy(w.badge);
135
+ const bottom = cleanCopy(w.bottomText);
136
+
137
+ const rows: Node[] = [];
138
+ w.options.forEach((o: WidgetOption, i: number) => {
139
+ const title = cleanCopy(o.title) || cleanCopy(o.text);
140
+ // Same alternation the known kinds use: the service writes `subtitle` or
141
+ // `text`, never both, so the second line is whichever survived the title.
142
+ const sub = cleanCopy(o.title) ? (cleanCopy(o.subtitle) || cleanCopy(o.text)) : cleanCopy(o.subtitle);
143
+ if (!title && !sub) return; // an empty row is worse than a missing one
144
+ const value = typeof o.value === "string" ? o.value : "";
145
+ if (!value) return; // nothing to send back; the row would be inert
146
+
147
+ const line: Node[] = [];
148
+ const pic = typeof o.pictureUrl === "string" ? o.pictureUrl : "";
149
+ if (pic && allowPicture(pic)) {
150
+ line.push(node(`pic.${i}`, "Image", {
151
+ url: pic, width: 40, height: 40, shape: "rounded", contentMode: "fill",
152
+ contentDescription: title || sub,
153
+ }));
154
+ }
155
+ const stack: Node[] = [node(`t.${i}`, "Text", { text: title || sub, style: "body", maxLines: 2 })];
156
+ if (title && sub) stack.push(node(`s.${i}`, "Text", { text: sub, style: "footnote", color: "textSecondary", maxLines: 2 }));
157
+ line.push(node(`rt.${i}`, "VStack", { spacing: 2, children: stack }));
158
+ line.push(node(`sp.${i}`, "Spacer", {}));
159
+
160
+ const label = cleanCopy(o.buttonLabel);
161
+ // A row with no button of its own is still tappable through a button we
162
+ // mint: an option the owner cannot choose is decoration. `Chip` and
163
+ // `SelectionControl` are on the safe list too, but a button reads the same
164
+ // on every one of iOS, Android, macOS, Web and React Native, and this card
165
+ // exists precisely because nobody has designed for this kind yet.
166
+ line.push(node(`pick.${i}`, "Button", {
167
+ title: label || "Choose", style: "secondary", size: "sm", action: respond(value),
168
+ }));
169
+
170
+ rows.push(node(`row.${i}`, "HStack", { spacing: 10, children: line }));
171
+ });
172
+
173
+ // Nothing worth showing. The prose card already says what the reply said.
174
+ if (!prompt && rows.length === 0) return "";
175
+
176
+ const body: Node[] = [];
177
+ if (badge) body.push(node("badge", "Badge", { text: badge }));
178
+ if (prompt) body.push(node("prompt", "Text", { text: prompt, style: "body" }));
179
+ if (rows.length) {
180
+ body.push(node("rule", "Divider", {}));
181
+ body.push(...rows);
182
+ }
183
+ if (bottom) body.push(node("bottom", "Text", { text: bottom, style: "footnote", color: "textSecondary", maxLines: 3 }));
184
+
185
+ const footer: Node[] = [];
186
+ const primaryLabel = cleanCopy(w.primary?.label);
187
+ if (primaryLabel) {
188
+ footer.push(node("primary", "Button", { title: primaryLabel, style: "primary", action: respond("primary") }));
189
+ }
190
+ const secondaryLabel = cleanCopy(w.secondary?.label);
191
+ if (secondaryLabel) {
192
+ footer.push(node("secondary", "Button", { title: secondaryLabel, style: "secondary", action: respond("secondary") }));
193
+ }
194
+ if (footer.length) {
195
+ footer.push(node("fsp", "Spacer", {}));
196
+ body.push(node("footer", "HStack", { spacing: 8, children: footer }));
197
+ }
198
+
199
+ // Said out loud, because an owner on an older build should know the card is
200
+ // a stand-in rather than the whole thing.
201
+ body.push(node("note", "Text", { text: GENERIC_UNSUPPORTED_NOTE, style: "caption", color: "textSecondary", maxLines: 2 }));
202
+
203
+ const tree = node("card", "Card", {
204
+ children: [node("stack", "VStack", { spacing: 8, children: body })],
205
+ });
206
+
207
+ if (countNodes(tree) > maxNodes) return "";
208
+ const json = JSON.stringify(tree);
209
+ if (utf8Length(json) > maxBytes) return "";
210
+ return json;
211
+ }
212
+
213
+ /** UTF-8 byte length, counted by hand.
214
+ *
215
+ * NOT `new TextEncoder()`: it does not exist on JavaScriptCore, which is the
216
+ * engine this worker runs on for iOS and macOS, and the bundler does not
217
+ * polyfill it — so the reference throws before any work is done. The budget
218
+ * check would have taken the whole card down on the two platforms hardest to
219
+ * notice it on.
220
+ *
221
+ * The host measures the same string with a real encoder, so this has to agree
222
+ * with it exactly rather than approximate: 1 byte below U+0080, 2 below
223
+ * U+0800, 4 for a surrogate PAIR (one astral character, consuming both code
224
+ * units), 3 otherwise. */
225
+ export function utf8Length(s: string): number {
226
+ let bytes = 0;
227
+ for (let i = 0; i < s.length; i++) {
228
+ const code = s.charCodeAt(i);
229
+ if (code < 0x80) bytes += 1;
230
+ else if (code < 0x800) bytes += 2;
231
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
232
+ const next = s.charCodeAt(i + 1);
233
+ // A well-formed pair is one astral character: four bytes, two units.
234
+ if (next >= 0xdc00 && next <= 0xdfff) { bytes += 4; i++; }
235
+ // A LONE high surrogate is not a character. `JSON.stringify` escapes it
236
+ // to `\udXXX`, so it cannot reach here from this composer — but this
237
+ // function is exported, and answering 3 keeps it in step with what an
238
+ // encoder does with the replacement character it would emit.
239
+ else bytes += 3;
240
+ } else bytes += 3;
241
+ }
242
+ return bytes;
243
+ }
244
+
245
+ /** Every node in the tree, counted the way the validator counts them. */
246
+ function countNodes(n: Node): number {
247
+ let total = 1;
248
+ const children = (n.body.props as { children?: unknown }).children;
249
+ if (Array.isArray(children)) for (const c of children) total += countNodes(c as Node);
250
+ return total;
251
+ }
@@ -0,0 +1,332 @@
1
+ // Turning a scanned envelope into a `NormalizedWidget`.
2
+ //
3
+ // Eight of the fourteen kinds are the shared skeleton and need no code of their
4
+ // own; the rest each reshape one or two fields. The whole file is pure and
5
+ // total: `normalizeWidget` answers for ANY input, including a payload that is
6
+ // not JSON, a `data` that is an array, and a widget name nobody has shipped yet.
7
+
8
+ import type { WidgetPayload } from "./aria-widgets.js";
9
+ import type { NormalizedWidget, WidgetKind, WidgetOption } from "./aria-widget-model.js";
10
+ import {
11
+ badgeTextOf,
12
+ boolAt,
13
+ buttonAt,
14
+ emptyWidget,
15
+ objectAt,
16
+ objectsAt,
17
+ optionsOf,
18
+ stringAt,
19
+ textAt,
20
+ textOf,
21
+ } from "./aria-widget-model.js";
22
+
23
+ /** Wire name → kind. Thirteen of the fourteen carry a `_WIDGET` suffix and
24
+ * `OAUTH_REQUIRED` does not, so this is a table rather than a string trim — and
25
+ * a table is what we want anyway: an unrecognised name must fall to `UNKNOWN`,
26
+ * and trimming a suffix would silently invent a kind out of whatever the
27
+ * service sent next. */
28
+ //
29
+ // `Object.create(null)` rather than a plain literal: a plain object inherits
30
+ // from `Object.prototype`, so `KIND_BY_NAME["toString"]` is a FUNCTION and
31
+ // `?? "UNKNOWN"` never fires. A service that ships a widget called `toString`,
32
+ // `constructor` or `valueOf` — or an attacker who can influence the name — got
33
+ // back an inherited member as the "kind" instead of the forward-compatible
34
+ // fallback. A null-prototype table has nothing to inherit.
35
+ const KIND_BY_NAME: Record<string, WidgetKind> = Object.assign(Object.create(null) as Record<string, WidgetKind>, {
36
+ SINGLE_SELECT_WIDGET: "SINGLE_SELECT",
37
+ MULTI_SELECT_WIDGET: "MULTI_SELECT",
38
+ SINGLE_SELECT_BUTTON_WIDGET: "SINGLE_SELECT_BUTTON",
39
+ RADIO_BUTTON_WIDGET: "RADIO_BUTTON",
40
+ PLANS_WIDGET: "PLANS",
41
+ PREMIUM_UPSELL_WIDGET: "PREMIUM_UPSELL",
42
+ VISUAL_DISPLAY_WIDGET: "VISUAL_DISPLAY",
43
+ AUTOCOMPLETE_WIDGET: "AUTOCOMPLETE",
44
+ ITEMS_SELECTION_WIDGET: "ITEMS_SELECTION",
45
+ QUESTIONNAIRE_WIDGET: "QUESTIONNAIRE",
46
+ CHART_WIDGET: "CHART",
47
+ TODO_PLAN_WIDGET: "TODO_PLAN",
48
+ ASYNC_TOOL_WIDGET: "ASYNC_TOOL",
49
+ OAUTH_REQUIRED: "OAUTH_REQUIRED",
50
+ });
51
+
52
+ /** The kind for a wire name, or `"UNKNOWN"`. Exported for the descriptor work
53
+ * by the screen builder, which needs the same mapping without re-parsing a
54
+ * payload. */
55
+ export function kindOfName(name: string): WidgetKind {
56
+ return KIND_BY_NAME[name] ?? "UNKNOWN";
57
+ }
58
+
59
+ /** The eight-kind shared skeleton: text, bottom text, badge, options, buttons. */
60
+ function skeleton(kind: WidgetKind, d: Record<string, unknown> | null): NormalizedWidget {
61
+ return {
62
+ kind,
63
+ text: stringAt(d, "text"),
64
+ bottomText: stringAt(d, "bottomText"),
65
+ badge: badgeTextOf(d),
66
+ options: optionsOf(d),
67
+ primary: buttonAt(d, "primaryButton"),
68
+ secondary: buttonAt(d, "secondaryButton"),
69
+ extra: {},
70
+ };
71
+ }
72
+
73
+ /** One `sections[{title,subtitle}]` row of a VISUAL_DISPLAY.
74
+ *
75
+ * Sections are NOT options, and the distinction is load-bearing: the reference
76
+ * catalogue's visual-display entry carries both at once — three `sections`
77
+ * describing the product, and one `options` entry that is a selectable variant
78
+ * card with its own nested sections and button. An earlier draft of this file
79
+ * folded sections into `options` when `options` was empty, which read fine
80
+ * against a simpler fixture and silently DROPPED all three sections against
81
+ * this one. They are different axes, so they get different fields.
82
+ *
83
+ * `textButton` is read but only its label and respondText: the section button
84
+ * is the send-back loop's, and sections render as static rows. */
85
+ interface WidgetSection {
86
+ title: string;
87
+ subtitle: string;
88
+ buttonLabel: string;
89
+ respondText: string;
90
+ }
91
+
92
+ function sectionsOf(d: Record<string, unknown> | null): WidgetSection[] {
93
+ return objectsAt(d, "sections").map((s) => {
94
+ const b = objectAt(s.textButton);
95
+ return {
96
+ title: stringAt(s, "title"),
97
+ subtitle: stringAt(s, "subtitle"),
98
+ buttonLabel: stringAt(b, "label"),
99
+ respondText: stringAt(b, "respondText"),
100
+ };
101
+ });
102
+ }
103
+
104
+ /** One widget, normalized. Never throws.
105
+ *
106
+ * Three ways in, and all three end somewhere renderable:
107
+ *
108
+ * - a known kind with readable `data` → its normalizer;
109
+ * - an unknown NAME → `UNKNOWN` carrying whatever `text` the payload had, so
110
+ * the owner still reads the assistant's sentence;
111
+ * - `data` that is absent, not JSON, or not an object → `UNKNOWN` with empty
112
+ * text. There is nothing to show, and the region collapses rather than
113
+ * painting a blank card. */
114
+ export function normalizeWidget(p: WidgetPayload): NormalizedWidget {
115
+ const kind = kindOfName(p.name);
116
+
117
+ // Read `data` defensively. A payload whose JSON is corrupt is not an error
118
+ // path worth surfacing — the envelope already survived a scanner built to
119
+ // tolerate exactly this, and the screen's job is to keep rendering.
120
+ let d: Record<string, unknown> | null = null;
121
+ if (p.dataJson) {
122
+ try {
123
+ d = objectAt(JSON.parse(p.dataJson));
124
+ } catch {
125
+ d = null;
126
+ }
127
+ }
128
+
129
+ // An unreadable payload cannot even yield prose. Report UNKNOWN and let the
130
+ // region collapse — note this also catches a KNOWN name whose data is broken,
131
+ // which is deliberate: a SINGLE_SELECT with no options is not a widget, it is
132
+ // a card with nothing to tap.
133
+ if (!d) return emptyWidget("UNKNOWN");
134
+
135
+ const base = skeleton(kind, d);
136
+
137
+ switch (kind) {
138
+ // The eight that are the skeleton, unchanged.
139
+ case "SINGLE_SELECT":
140
+ case "SINGLE_SELECT_BUTTON":
141
+ case "RADIO_BUTTON":
142
+ return base;
143
+
144
+ case "PLANS":
145
+ // Each plan's benefit list, with its icons. `optionsOf` already joined
146
+ // the labels into `listText` for the templated row; this keeps the
147
+ // structured form for whoever renders the icons.
148
+ return {
149
+ ...base,
150
+ extra: {
151
+ lists: objectsAt(d, "options").map((opt) =>
152
+ objectsAt(opt, "list").map((item) => ({
153
+ text: textOf(item),
154
+ icon: stringAt(objectAt(item.icon), "name"),
155
+ iconColor: stringAt(objectAt(item.icon), "color"),
156
+ })),
157
+ ),
158
+ },
159
+ };
160
+
161
+ case "AUTOCOMPLETE":
162
+ // `single` vs `multi`. Without it the host cannot tell a one-choice
163
+ // autocomplete from a many-choice one, and picks the wrong submit
164
+ // behaviour for half of them.
165
+ return { ...base, extra: { selectionType: stringAt(d, "selectionType") } };
166
+
167
+ case "MULTI_SELECT":
168
+ // `optionLayout` decides thumbnail vs list rows; `footerButtonsAsColumn`
169
+ // stacks the footer. Both are layout hints the picker reads, so they ride in
170
+ // `extra` rather than widening the shared shape.
171
+ return {
172
+ ...base,
173
+ extra: {
174
+ optionLayout: stringAt(d, "optionLayout"),
175
+ footerButtonsAsColumn: boolAt(d, "footerButtonsAsColumn"),
176
+ // A selection CEILING. The reference catalogue only shows it on
177
+ // ITEMS_SELECTION, but it is a generic multi-select constraint and
178
+ // nothing in the wire format restricts it to one kind — reading it
179
+ // here costs a line and not reading it would silently ignore a limit
180
+ // the service had set. Enforced in the worker: no component carries
181
+ // a selection-count prop, so it cannot be declared.
182
+ maxSelected: numberAt(d, "maxSelected"),
183
+ },
184
+ };
185
+
186
+ case "PREMIUM_UPSELL":
187
+ // The only kind whose headline is `title` rather than `text`. Both are
188
+ // kept: `title` leads, `text` is the body, and flattening them would lose
189
+ // the upsell's own heading.
190
+ return { ...base, extra: { title: stringAt(d, "title") } };
191
+
192
+ case "VISUAL_DISPLAY":
193
+ // `sections` and `options` are independent and can both be present — see
194
+ // `sectionsOf`. Options stay options; sections ride in `extra`.
195
+ return {
196
+ ...base,
197
+ extra: {
198
+ // Two spellings, both live: the catalogue entry is named "…legacy
199
+ // image", which is the older bare-string `image` beside the newer
200
+ // `{url}` object. Trying the object first means a payload carrying
201
+ // both takes the structured one.
202
+ imageUrl: stringAt(objectAt(d.image), "url") || stringAt(d, "image"),
203
+ sectionsLayout: stringAt(d, "sectionsLayout"),
204
+ sections: sectionsOf(d),
205
+ },
206
+ };
207
+
208
+ case "ITEMS_SELECTION":
209
+ // Carries no options of its own — the rows are fetched later from
210
+ // `providerKey`. All this can do is record the handle.
211
+ return {
212
+ ...base,
213
+ extra: { providerKey: stringAt(d, "providerKey"), maxSelected: numberAt(d, "maxSelected") },
214
+ };
215
+
216
+ case "QUESTIONNAIRE":
217
+ // A questionnaire with no questions is in the catalogue on purpose. It
218
+ // normalizes to an empty list rather than UNKNOWN: the kind is known and
219
+ // its buttons may still be meaningful, and the picker decides what an empty
220
+ // one looks like.
221
+ return {
222
+ ...base,
223
+ extra: {
224
+ // Every label here is `{ text: … }` rather than a bare string — this
225
+ // kind is alone in that, and reading it with the scalar reader gave a
226
+ // questionnaire with the right number of questions and nothing
227
+ // written on any of them.
228
+ questions: objectsAt(d, "questions").map((q) => ({
229
+ type: stringAt(q, "type"),
230
+ required: boolAt(q, "required"),
231
+ title: textAt(q, "title"),
232
+ subtitle: textAt(q, "subtitle"),
233
+ options: optionsOf(q),
234
+ // The "or type your own" affordance, and it is PER QUESTION rather
235
+ // than per widget — only the multi-select one carries it in the
236
+ // catalogue. Its label is nested twice (`placeholder.text`).
237
+ // Dropping it removed the only answer some questions accept.
238
+ freeFormatType: stringAt(objectAt(q.freeFormatOption), "type"),
239
+ freeFormatPlaceholder: textAt(objectAt(q.freeFormatOption), "placeholder"),
240
+ })),
241
+ },
242
+ };
243
+
244
+ case "CHART":
245
+ return {
246
+ ...base,
247
+ extra: {
248
+ chartType: stringAt(d, "chartType"),
249
+ description: stringAt(d, "description"),
250
+ slices: objectsAt(d, "pieSlices").map((s) => ({
251
+ label: stringAt(s, "label"),
252
+ value: numberAt(s, "value"),
253
+ })),
254
+ },
255
+ };
256
+
257
+ case "TODO_PLAN":
258
+ return {
259
+ ...base,
260
+ extra: {
261
+ title: stringAt(d, "title"),
262
+ items: objectsAt(d, "items").map((i) => ({
263
+ label: stringAt(i, "label"),
264
+ status: stringAt(i, "status"),
265
+ })),
266
+ },
267
+ };
268
+
269
+ case "ASYNC_TOOL":
270
+ // The catalogue ships one WITHOUT a job id. It stays ASYNC_TOOL with an
271
+ // empty id rather than degrading to UNKNOWN: the status messages are the
272
+ // useful part, and they are shown while declining to poll for a job with
273
+ // cannot name.
274
+ return {
275
+ ...base,
276
+ extra: {
277
+ asyncJobId: stringAt(d, "asyncJobId"),
278
+ statusMessages: statusMessagesOf(objectAt(d.statusMessages)),
279
+ },
280
+ };
281
+
282
+ case "OAUTH_REQUIRED":
283
+ // Both are URLs, and both stay RAW here. They are disclosed through the
284
+ // existing helper; normalizing is not vetting.
285
+ return {
286
+ ...base,
287
+ extra: {
288
+ authorizationUrl: stringAt(d, "authorizationUrl"),
289
+ serverUrl: stringAt(d, "serverUrl"),
290
+ },
291
+ };
292
+
293
+ case "UNKNOWN":
294
+ default:
295
+ // A name we do not know, but a payload we could read.
296
+ //
297
+ // Read as the SHARED SKELETON rather than text alone. Eight of the
298
+ // fourteen known kinds are that skeleton, so it is the best available
299
+ // guess at what an unknown fifteenth carries — and it is only a guess,
300
+ // which is why the generic composer sanitizes every field and drops
301
+ // whatever it cannot use.
302
+ //
303
+ // Reading more is safe because every reader is TOTAL: a payload whose
304
+ // `options` is a string, a number or absent yields an empty array, not
305
+ // a throw. The cost of guessing wrong is a card with fewer rows; the
306
+ // cost of not guessing is the prose-only degradation this replaces.
307
+ return skeleton("UNKNOWN", d);
308
+ }
309
+ }
310
+
311
+ /** A finite number, or `0`. `NaN` and the infinities are excluded because they
312
+ * reach a chart axis and a selection limit, and both misbehave quietly there. */
313
+ function numberAt(o: Record<string, unknown> | null, key: string): number {
314
+ if (!o) return 0;
315
+ const v = o[key];
316
+ if (typeof v === "number" && Number.isFinite(v)) return v;
317
+ if (typeof v === "string") {
318
+ const n = Number(v);
319
+ if (Number.isFinite(n) && v.trim() !== "") return n;
320
+ }
321
+ return 0;
322
+ }
323
+
324
+ /** The four async-tool status lines, each defaulting to `""`. */
325
+ function statusMessagesOf(o: Record<string, unknown> | null): Record<string, string> {
326
+ return {
327
+ initialized: stringAt(o, "initialized"),
328
+ processing: stringAt(o, "processing"),
329
+ finished: stringAt(o, "finished"),
330
+ failed: stringAt(o, "failed"),
331
+ };
332
+ }