@colixsystems/widget-sdk 0.96.0 → 0.98.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.
package/README.md CHANGED
@@ -64,7 +64,42 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
64
64
 
65
65
  ## Status
66
66
 
67
- `v0.91.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
67
+ `v0.95.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
68
+
69
+ ### What's new in 0.95.0 (contract 1.68.0)
70
+
71
+ **One number for the quick bar's cap — `quickBarMaxItems` + `quickBarCap`.** The sidebar's secondary mobile quick bar caps at five, and that five lived in two places: a named constant in the web chrome and a bare literal in the compiler. Nothing stopped them drifting, and no Studio surface could state the number at all.
72
+
73
+ `CONTRACT.themeMenuTypes.*.quickBarMaxItems` now carries it (5 on `sidebar`, `null` on the two shapes that draw no quick bar), read through the new `quickBarCap(menuType)` host export.
74
+
75
+ The distinction from `menuItemCap` is the point and is worth keeping straight: **`quickBarCap` may drop a page** — the rail and the drawer still list every menu page, so the bar is a shortcut. **`menuItemCap` may not** — where the chrome IS the menu (`bottom-tabs`), the surplus moves behind a More sheet instead.
76
+
77
+ Host-integration surface only. `CONTRACT.version` → `1.68.0`.
78
+
79
+ ### What's new in 0.94.0 (contract 1.67.0)
80
+
81
+ **The footer strip is themeable — `resolveFooterTokens` (REQ-NAV-STRUCTURE).** The bottom strip's surface was hard-coded white on both hosts and its items read the SIDEBAR's tokens. That is fine while the strip is the sidebar's secondary quick bar, and untenable once it IS the menu: the `bottom-tabs` shape hides the sidebar panel, so those tokens have nowhere to be set.
82
+
83
+ A `theme_config.footer` block now carries `backgroundColor`, `textColor`, `activeColor` and the opt-in divider `borderColor` + `borderWidth`. **Every field falls back to the sidebar's**, so a workspace that never touches it renders exactly as before and only an explicit value moves anything.
84
+
85
+ `resolveFooterTokens(theme)` (from `@colixsystems/widget-sdk/host`) is the one resolver both hosts read it with. It also settles two divergences the strip carried: the native bar ruled a permanent `#e2e8f0` hairline the theme could not reach — REQ-THEME-LOOK's rule is that the divider's **colour** is its switch — and it tinted the active tab's *label* where the web painted a filled pill, so `activeStyle: "filled"` meant two different things per host.
86
+
87
+ Host-integration surface only: no author-facing hook, prop, primitive, or manifest field changed. `CONTRACT.version` → `1.67.0`.
88
+
89
+ ### What's new in 0.93.0 (contract 1.66.0)
90
+
91
+ **An app picks the SHAPE its navigation takes — `CONTRACT.themeMenuTypes` (REQ-NAV-STRUCTURE).** Until now the chrome was always a sidebar: a persistent left rail on desktop, a hamburger drawer plus an optional bottom quick bar on mobile. That is the right default for an admin tool and the wrong one for a phone-first app or a site, and there was no way to say so. `CONTRACT.themeMenuTypes` publishes the closed catalogue — `sidebar`, `top-bar`, `bottom-tabs` — each entry carrying `{ name, summary, maxItems }`.
92
+
93
+ `maxItems` caps how many menu pages the chrome draws at once, and its meaning differs per type deliberately: the sidebar's mobile quick bar is a **secondary** curated bar whose cap may drop a page (the rail still lists every one), while a `bottom-tabs` strip **is** the menu, so its cap must never drop one — the surplus moves behind a More sheet instead.
94
+
95
+ **New host exports (`@colixsystems/widget-sdk/host`)** — `normaliseNavigation(navigation)` resolves a stored `theme_config.navigation` block to the `{ menuType }` a host switches its chrome on, and `menuItemCap(menuType)` states that shape's cap. One implementation for both hosts, so a menu type cannot mean one thing in the web Player and another in the exported Expo app. An absent or unknown value resolves to `sidebar`, so every app authored before menu types existed renders and compiles byte-identically.
96
+
97
+ Host-integration surface only: no author-facing hook, prop, primitive, or manifest field changed. `CONTRACT.version` → `1.66.0`.
98
+
99
+ ### What's new in 0.97.0 (contract 1.69.0)
100
+
101
+ **`CONTRACT.themeComponents` gains five scopes: `accent`, `destructive`, `muted`, `popover`, `ring` (sc-5392).** The vocabulary shipped with exactly `button`/`card`/`text` (sc-1497), so a shadcn/Tailwind app import's `--accent`, `--destructive`, `--muted`, `--popover` and `--ring` custom properties had no `themeConfig` home and were reported "no theme home" on every import. Each new scope binds to real `styleSchema` fields on the built-ins that already had a matching surface — `accent` (a highlight/tag surface: `background`/`borderColor`/`radius`) to the Label widget's own fields, `destructive` (a themed danger/delete action: `background`/`textColor`/`borderColor`) to a new "Danger" Button variant, `muted` (a subtle/secondary surface: `background`/`textColor`/`borderColor`/`radius`) to Form Input's and Form Builder's pre-existing input fields, `popover` (a dropdown/menu surface: `background`/`textColor`/`borderColor`) to the same two widgets' choice-field option list, and `ring` (the app-wide focus-visible outline: `color`/`width`) to a new emphasis border on Button. **This is HOST-ONLY plumbing, exactly like the three scopes before it** — `useTheme()`'s documented `components` slice is unchanged, no widget-authoring hook or `propertySchema` type moved, and no scope declares `universalFields`, so a third-party or AI-generated widget's contract is unaffected; the Developer guide and `DEFAULT_SYSTEM_PROMPT` need no update because neither ever documented this internal vocabulary. Fully additive: a theme with no `components` key, or one using only `button`/`card`/`text`, resolves exactly as before.
102
+ - **`CONTRACT.version` → `1.69.0`** (additive: five new `themeComponents` scopes + their target-field bindings). No existing scope, token, or export changed shape.
68
103
 
69
104
  ### What's new in 0.96.0 (contract 1.68.0)
70
105
 
package/dist/contract.cjs CHANGED
@@ -159,6 +159,42 @@ const THEME_WIDGET_STYLES = Object.freeze({
159
159
  // Matches the styleSchema field cap the widget agent is held to.
160
160
  maxFieldsPerWidget: 12,
161
161
  });
162
+ // REQ-NAV-STRUCTURE: the SHAPE an app's navigation takes. One catalogue, four
163
+ // consumers -- the Studio's Navigation page, Mason's set_theme coercion, the web
164
+ // PlayerChrome and the compiler's navigator -- so a type cannot be offered to an
165
+ // author without every host actually drawing it.
166
+ //
167
+ // `maxItems` caps how many menu pages the chrome draws at once, and its meaning
168
+ // differs per type deliberately. The sidebar's mobile quick bar is a SECONDARY
169
+ // curated bar, so its own cap may drop a page -- the rail still lists every one.
170
+ // A bottom-tabs strip IS the menu, so its cap must never drop one: the surplus
171
+ // moves behind a "More" sheet instead.
172
+ const THEME_MENU_TYPES = Object.freeze({
173
+ sidebar: Object.freeze({
174
+ name: "Sidebar",
175
+ summary:
176
+ "A persistent left rail on desktop; a hamburger drawer plus the optional bottom quick bar on mobile.",
177
+ maxItems: null,
178
+ // The SECONDARY mobile quick bar's cap. It may drop a page past it, because
179
+ // the rail and the drawer still list every one -- the opposite of
180
+ // `bottom-tabs`' `maxItems`, where the strip IS the menu.
181
+ quickBarMaxItems: 5,
182
+ }),
183
+ "top-bar": Object.freeze({
184
+ name: "Top bar",
185
+ summary:
186
+ "A horizontal row of links in the app header, scrolling sideways when it runs out of room. No rail at any width.",
187
+ maxItems: null,
188
+ quickBarMaxItems: null,
189
+ }),
190
+ "bottom-tabs": Object.freeze({
191
+ name: "Bottom tabs",
192
+ summary:
193
+ "A sticky bottom strip at every width -- the phone-native shape. Pages past the cap move behind a More sheet.",
194
+ maxItems: 4,
195
+ quickBarMaxItems: null,
196
+ }),
197
+ });
162
198
  const THEME_SPACING_SCALE = Object.freeze({
163
199
  min: 0.5,
164
200
  max: 2,
@@ -211,6 +247,34 @@ const CARD_UNIVERSAL_FIELDS = Object.freeze({
211
247
  gradient: "cardGradient",
212
248
  });
213
249
 
250
+ // The Form widgets' text-entry surface, already read by both form widgets
251
+ // (REQ-THEME-13, predating this scope) -- a genuine subtle/secondary surface
252
+ // distinct from the card that hosts it.
253
+ const MUTED_INPUT_FIELDS = Object.freeze({
254
+ background: "inputBackground",
255
+ textColor: "inputTextColor",
256
+ borderColor: "inputBorderColor",
257
+ radius: "inputRadius",
258
+ });
259
+
260
+ // The Form widgets' choice-field option list -- the closest thing this
261
+ // catalog has to a menu/dropdown surface (singleChoice/multiChoice render an
262
+ // inline list of options here).
263
+ const POPOVER_FIELDS = Object.freeze({
264
+ background: "popoverBackground",
265
+ textColor: "popoverTextColor",
266
+ borderColor: "popoverBorderColor",
267
+ });
268
+
269
+ // The Button widget's danger-variant fields -- kept separate from its own
270
+ // `background`/`textColor`/`borderColor` (the `button` scope's fields above)
271
+ // so a themed destructive action never collides with the button's normal look.
272
+ const DANGER_BUTTON_FIELDS = Object.freeze({
273
+ background: "dangerBackground",
274
+ textColor: "dangerTextColor",
275
+ borderColor: "dangerBorderColor",
276
+ });
277
+
214
278
  const THEME_COMPONENTS = Object.freeze({
215
279
  button: Object.freeze({
216
280
  label: "Buttons",
@@ -277,6 +341,82 @@ const THEME_COMPONENTS = Object.freeze({
277
341
  "appstudio.data-value": TEXT_TYPOGRAPHY_FIELDS,
278
342
  }),
279
343
  }),
344
+ // sc-5392 -- a highlight/tag surface. Bound to Label's OWN background/
345
+ // borderColor/radius fields (REQ-THEME-13, predating this scope) rather
346
+ // than its `color` field, which the `text` scope already owns -- two
347
+ // scopes writing the same field on the same widget would race.
348
+ accent: Object.freeze({
349
+ label: "Accent",
350
+ tokens: Object.freeze({
351
+ background: Object.freeze({ type: "color", uiDefault: "colors.primarySoft" }),
352
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
353
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.pill" }),
354
+ }),
355
+ targets: Object.freeze({
356
+ "appstudio.label": Object.freeze({
357
+ background: "background",
358
+ borderColor: "borderColor",
359
+ radius: "radius",
360
+ }),
361
+ }),
362
+ }),
363
+ // sc-5392 -- the Button widget's danger/delete variant. Separate token set
364
+ // from `button` above so "make the buttons coral" never recolours Delete.
365
+ destructive: Object.freeze({
366
+ label: "Destructive",
367
+ tokens: Object.freeze({
368
+ background: Object.freeze({ type: "color", uiDefault: "colors.danger" }),
369
+ textColor: Object.freeze({ type: "color", uiDefault: "colors.onPrimary" }),
370
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.danger" }),
371
+ }),
372
+ targets: Object.freeze({
373
+ "appstudio.button": DANGER_BUTTON_FIELDS,
374
+ }),
375
+ }),
376
+ // sc-5392 -- a subtle/secondary surface, bound to the form widgets' own
377
+ // text-entry fields (already REQ-THEME-13 style fields, predating this
378
+ // scope) rather than to Text/Label/Data Value, whose `color` field the
379
+ // `text` scope already owns.
380
+ muted: Object.freeze({
381
+ label: "Muted",
382
+ tokens: Object.freeze({
383
+ background: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
384
+ textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
385
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
386
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
387
+ }),
388
+ targets: Object.freeze({
389
+ "appstudio.form-input": MUTED_INPUT_FIELDS,
390
+ "appstudio.form-builder": MUTED_INPUT_FIELDS,
391
+ }),
392
+ }),
393
+ // sc-5392 -- dropdown/menu surfaces. The form widgets' choice-field option
394
+ // list is the closest thing the built-in catalog has to a popover panel.
395
+ popover: Object.freeze({
396
+ label: "Popover",
397
+ tokens: Object.freeze({
398
+ background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
399
+ textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
400
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
401
+ }),
402
+ targets: Object.freeze({
403
+ "appstudio.form-input": POPOVER_FIELDS,
404
+ "appstudio.form-builder": POPOVER_FIELDS,
405
+ }),
406
+ }),
407
+ // sc-5392 -- the app-wide focus-visible outline. A themed emphasis border
408
+ // on the Button widget, applied after its variant colours and beaten by the
409
+ // widget's own per-instance `borderColor` (REQ-THEME-13 stays final).
410
+ ring: Object.freeze({
411
+ label: "Focus ring",
412
+ tokens: Object.freeze({
413
+ color: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
414
+ width: Object.freeze({ type: "size", min: 0, max: 8 }),
415
+ }),
416
+ targets: Object.freeze({
417
+ "appstudio.button": Object.freeze({ color: "ringColor", width: "ringWidth" }),
418
+ }),
419
+ }),
280
420
  });
281
421
 
282
422
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -2956,7 +3096,31 @@ const CONTRACT = deepFreeze({
2956
3096
  // action script now sees a `request` global — `{ body }` on a run fired
2957
3097
  // by the workspace-granted `http_post` webhook trigger, null otherwise.
2958
3098
  // `app` and `http_post` stay operator-granted and are NOT declarable.
2959
- version: "1.68.0",
3099
+ // 1.69.0: additive (sc-5392) — `themeComponents` gains five scopes:
3100
+ // `accent`, `destructive`, `muted`, `popover` and `ring`, so a
3101
+ // shadcn/Tailwind import's matching custom properties have a themeConfig
3102
+ // home instead of being reported lost. Host-only plumbing: no scope
3103
+ // declares `universalFields`, so a third-party manifest is unaffected.
3104
+ // 1.70.0: additive (REQ-NAV-STRUCTURE) -- `themeMenuTypes`, the closed
3105
+ // catalogue of navigation shapes an app can take (`sidebar`, `top-bar`,
3106
+ // `bottom-tabs`) with the per-type item cap. Read from `theme_config`
3107
+ // as `navigation.menuType`; absent or unknown resolves to `sidebar`, so
3108
+ // every app that exists today renders and compiles byte-identically.
3109
+ // Resolved through the new `normaliseNavigation` host export rather than
3110
+ // a literal per host.
3111
+ // 1.71.0: additive (REQ-NAV-STRUCTURE) -- `resolveFooterTokens` (host
3112
+ // export). The footer strip's surface was hard-coded white on BOTH hosts
3113
+ // and its items read the SIDEBAR's tokens, which stops being tenable the
3114
+ // moment the strip IS the menu (`bottom-tabs` hides the sidebar panel, so
3115
+ // those tokens have nowhere to be set). A `theme_config.footer` block now
3116
+ // carries its own, every field falling back to the sidebar's so an
3117
+ // untouched workspace is unchanged.
3118
+ // 1.72.0: additive (REQ-NAV-STRUCTURE) -- `themeMenuTypes.*.quickBarMaxItems`
3119
+ // and the `quickBarCap` host export. The sidebar's secondary quick bar
3120
+ // capped at five in TWO places -- a named constant on web and a bare `5`
3121
+ // literal in the compiler -- so the number could drift between the hosts
3122
+ // and no Studio surface could state it at all.
3123
+ version: "1.72.0",
2960
3124
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2961
3125
  hooks: HOOKS,
2962
3126
  primitives: PRIMITIVES,
@@ -2972,6 +3136,7 @@ const CONTRACT = deepFreeze({
2972
3136
  themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
2973
3137
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
2974
3138
  themeSpacingScale: THEME_SPACING_SCALE,
3139
+ themeMenuTypes: THEME_MENU_TYPES,
2975
3140
  themeWidgetStyles: THEME_WIDGET_STYLES,
2976
3141
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2977
3142
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
package/dist/contract.js CHANGED
@@ -159,6 +159,42 @@ const THEME_WIDGET_STYLES = Object.freeze({
159
159
  // Matches the styleSchema field cap the widget agent is held to.
160
160
  maxFieldsPerWidget: 12,
161
161
  });
162
+ // REQ-NAV-STRUCTURE: the SHAPE an app's navigation takes. One catalogue, four
163
+ // consumers -- the Studio's Navigation page, Mason's set_theme coercion, the web
164
+ // PlayerChrome and the compiler's navigator -- so a type cannot be offered to an
165
+ // author without every host actually drawing it.
166
+ //
167
+ // `maxItems` caps how many menu pages the chrome draws at once, and its meaning
168
+ // differs per type deliberately. The sidebar's mobile quick bar is a SECONDARY
169
+ // curated bar, so its own cap may drop a page -- the rail still lists every one.
170
+ // A bottom-tabs strip IS the menu, so its cap must never drop one: the surplus
171
+ // moves behind a "More" sheet instead.
172
+ const THEME_MENU_TYPES = Object.freeze({
173
+ sidebar: Object.freeze({
174
+ name: "Sidebar",
175
+ summary:
176
+ "A persistent left rail on desktop; a hamburger drawer plus the optional bottom quick bar on mobile.",
177
+ maxItems: null,
178
+ // The SECONDARY mobile quick bar's cap. It may drop a page past it, because
179
+ // the rail and the drawer still list every one -- the opposite of
180
+ // `bottom-tabs`' `maxItems`, where the strip IS the menu.
181
+ quickBarMaxItems: 5,
182
+ }),
183
+ "top-bar": Object.freeze({
184
+ name: "Top bar",
185
+ summary:
186
+ "A horizontal row of links in the app header, scrolling sideways when it runs out of room. No rail at any width.",
187
+ maxItems: null,
188
+ quickBarMaxItems: null,
189
+ }),
190
+ "bottom-tabs": Object.freeze({
191
+ name: "Bottom tabs",
192
+ summary:
193
+ "A sticky bottom strip at every width -- the phone-native shape. Pages past the cap move behind a More sheet.",
194
+ maxItems: 4,
195
+ quickBarMaxItems: null,
196
+ }),
197
+ });
162
198
  const THEME_SPACING_SCALE = Object.freeze({
163
199
  min: 0.5,
164
200
  max: 2,
@@ -211,6 +247,34 @@ const CARD_UNIVERSAL_FIELDS = Object.freeze({
211
247
  gradient: "cardGradient",
212
248
  });
213
249
 
250
+ // The Form widgets' text-entry surface, already read by both form widgets
251
+ // (REQ-THEME-13, predating this scope) -- a genuine subtle/secondary surface
252
+ // distinct from the card that hosts it.
253
+ const MUTED_INPUT_FIELDS = Object.freeze({
254
+ background: "inputBackground",
255
+ textColor: "inputTextColor",
256
+ borderColor: "inputBorderColor",
257
+ radius: "inputRadius",
258
+ });
259
+
260
+ // The Form widgets' choice-field option list -- the closest thing this
261
+ // catalog has to a menu/dropdown surface (singleChoice/multiChoice render an
262
+ // inline list of options here).
263
+ const POPOVER_FIELDS = Object.freeze({
264
+ background: "popoverBackground",
265
+ textColor: "popoverTextColor",
266
+ borderColor: "popoverBorderColor",
267
+ });
268
+
269
+ // The Button widget's danger-variant fields -- kept separate from its own
270
+ // `background`/`textColor`/`borderColor` (the `button` scope's fields above)
271
+ // so a themed destructive action never collides with the button's normal look.
272
+ const DANGER_BUTTON_FIELDS = Object.freeze({
273
+ background: "dangerBackground",
274
+ textColor: "dangerTextColor",
275
+ borderColor: "dangerBorderColor",
276
+ });
277
+
214
278
  const THEME_COMPONENTS = Object.freeze({
215
279
  button: Object.freeze({
216
280
  label: "Buttons",
@@ -277,6 +341,82 @@ const THEME_COMPONENTS = Object.freeze({
277
341
  "appstudio.data-value": TEXT_TYPOGRAPHY_FIELDS,
278
342
  }),
279
343
  }),
344
+ // sc-5392 -- a highlight/tag surface. Bound to Label's OWN background/
345
+ // borderColor/radius fields (REQ-THEME-13, predating this scope) rather
346
+ // than its `color` field, which the `text` scope already owns -- two
347
+ // scopes writing the same field on the same widget would race.
348
+ accent: Object.freeze({
349
+ label: "Accent",
350
+ tokens: Object.freeze({
351
+ background: Object.freeze({ type: "color", uiDefault: "colors.primarySoft" }),
352
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
353
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.pill" }),
354
+ }),
355
+ targets: Object.freeze({
356
+ "appstudio.label": Object.freeze({
357
+ background: "background",
358
+ borderColor: "borderColor",
359
+ radius: "radius",
360
+ }),
361
+ }),
362
+ }),
363
+ // sc-5392 -- the Button widget's danger/delete variant. Separate token set
364
+ // from `button` above so "make the buttons coral" never recolours Delete.
365
+ destructive: Object.freeze({
366
+ label: "Destructive",
367
+ tokens: Object.freeze({
368
+ background: Object.freeze({ type: "color", uiDefault: "colors.danger" }),
369
+ textColor: Object.freeze({ type: "color", uiDefault: "colors.onPrimary" }),
370
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.danger" }),
371
+ }),
372
+ targets: Object.freeze({
373
+ "appstudio.button": DANGER_BUTTON_FIELDS,
374
+ }),
375
+ }),
376
+ // sc-5392 -- a subtle/secondary surface, bound to the form widgets' own
377
+ // text-entry fields (already REQ-THEME-13 style fields, predating this
378
+ // scope) rather than to Text/Label/Data Value, whose `color` field the
379
+ // `text` scope already owns.
380
+ muted: Object.freeze({
381
+ label: "Muted",
382
+ tokens: Object.freeze({
383
+ background: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
384
+ textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
385
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
386
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
387
+ }),
388
+ targets: Object.freeze({
389
+ "appstudio.form-input": MUTED_INPUT_FIELDS,
390
+ "appstudio.form-builder": MUTED_INPUT_FIELDS,
391
+ }),
392
+ }),
393
+ // sc-5392 -- dropdown/menu surfaces. The form widgets' choice-field option
394
+ // list is the closest thing the built-in catalog has to a popover panel.
395
+ popover: Object.freeze({
396
+ label: "Popover",
397
+ tokens: Object.freeze({
398
+ background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
399
+ textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
400
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
401
+ }),
402
+ targets: Object.freeze({
403
+ "appstudio.form-input": POPOVER_FIELDS,
404
+ "appstudio.form-builder": POPOVER_FIELDS,
405
+ }),
406
+ }),
407
+ // sc-5392 -- the app-wide focus-visible outline. A themed emphasis border
408
+ // on the Button widget, applied after its variant colours and beaten by the
409
+ // widget's own per-instance `borderColor` (REQ-THEME-13 stays final).
410
+ ring: Object.freeze({
411
+ label: "Focus ring",
412
+ tokens: Object.freeze({
413
+ color: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
414
+ width: Object.freeze({ type: "size", min: 0, max: 8 }),
415
+ }),
416
+ targets: Object.freeze({
417
+ "appstudio.button": Object.freeze({ color: "ringColor", width: "ringWidth" }),
418
+ }),
419
+ }),
280
420
  });
281
421
 
282
422
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -2956,7 +3096,31 @@ const CONTRACT = deepFreeze({
2956
3096
  // action script now sees a `request` global — `{ body }` on a run fired
2957
3097
  // by the workspace-granted `http_post` webhook trigger, null otherwise.
2958
3098
  // `app` and `http_post` stay operator-granted and are NOT declarable.
2959
- version: "1.68.0",
3099
+ // 1.69.0: additive (sc-5392) — `themeComponents` gains five scopes:
3100
+ // `accent`, `destructive`, `muted`, `popover` and `ring`, so a
3101
+ // shadcn/Tailwind import's matching custom properties have a themeConfig
3102
+ // home instead of being reported lost. Host-only plumbing: no scope
3103
+ // declares `universalFields`, so a third-party manifest is unaffected.
3104
+ // 1.70.0: additive (REQ-NAV-STRUCTURE) -- `themeMenuTypes`, the closed
3105
+ // catalogue of navigation shapes an app can take (`sidebar`, `top-bar`,
3106
+ // `bottom-tabs`) with the per-type item cap. Read from `theme_config`
3107
+ // as `navigation.menuType`; absent or unknown resolves to `sidebar`, so
3108
+ // every app that exists today renders and compiles byte-identically.
3109
+ // Resolved through the new `normaliseNavigation` host export rather than
3110
+ // a literal per host.
3111
+ // 1.71.0: additive (REQ-NAV-STRUCTURE) -- `resolveFooterTokens` (host
3112
+ // export). The footer strip's surface was hard-coded white on BOTH hosts
3113
+ // and its items read the SIDEBAR's tokens, which stops being tenable the
3114
+ // moment the strip IS the menu (`bottom-tabs` hides the sidebar panel, so
3115
+ // those tokens have nowhere to be set). A `theme_config.footer` block now
3116
+ // carries its own, every field falling back to the sidebar's so an
3117
+ // untouched workspace is unchanged.
3118
+ // 1.72.0: additive (REQ-NAV-STRUCTURE) -- `themeMenuTypes.*.quickBarMaxItems`
3119
+ // and the `quickBarCap` host export. The sidebar's secondary quick bar
3120
+ // capped at five in TWO places -- a named constant on web and a bare `5`
3121
+ // literal in the compiler -- so the number could drift between the hosts
3122
+ // and no Studio surface could state it at all.
3123
+ version: "1.72.0",
2960
3124
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2961
3125
  hooks: HOOKS,
2962
3126
  primitives: PRIMITIVES,
@@ -2972,6 +3136,7 @@ const CONTRACT = deepFreeze({
2972
3136
  themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
2973
3137
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
2974
3138
  themeSpacingScale: THEME_SPACING_SCALE,
3139
+ themeMenuTypes: THEME_MENU_TYPES,
2975
3140
  themeWidgetStyles: THEME_WIDGET_STYLES,
2976
3141
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2977
3142
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
package/dist/host.d.ts CHANGED
@@ -133,3 +133,52 @@ export function resolveToastTokens(
133
133
  export function createToastController(
134
134
  opts?: ToastControllerOptions,
135
135
  ): ToastController;
136
+
137
+ // REQ-NAV-STRUCTURE — the navigation SHAPE a host draws its chrome in. The
138
+ // vocabulary is closed by `CONTRACT.themeMenuTypes`; these are the resolvers
139
+ // both hosts switch on.
140
+
141
+ export type ThemeMenuType = "sidebar" | "top-bar" | "bottom-tabs";
142
+
143
+ export interface ResolvedNavigation {
144
+ menuType: ThemeMenuType;
145
+ }
146
+
147
+ /**
148
+ * Resolves a stored `theme_config.navigation` block. Always returns a usable
149
+ * shape — a junk, partial, or absent input yields the default sidebar, so a
150
+ * host never has to guard the value it switches on.
151
+ */
152
+ export function normaliseNavigation(navigation: unknown): ResolvedNavigation;
153
+
154
+ /**
155
+ * How many menu pages this shape may draw at once, or `null` for no cap. The
156
+ * cap's meaning differs per type: the sidebar's quick bar may drop a page past
157
+ * it, a bottom-tabs strip must move its surplus behind a More sheet.
158
+ */
159
+ export function menuItemCap(menuType: string): number | null;
160
+
161
+ /**
162
+ * How many pages the SECONDARY mobile quick bar draws, or `null` where the shape
163
+ * draws none (only `sidebar` has one). Unlike `menuItemCap` this cap MAY drop a
164
+ * page: the rail and drawer still list every menu page.
165
+ */
166
+ export function quickBarCap(menuType: string): number | null;
167
+
168
+ /** The footer strip's resolved tokens. A `null` colour means the host keeps its
169
+ * own default; a `null` `borderColor` means no divider is drawn at all. */
170
+ export interface FooterTokens {
171
+ backgroundColor: string | null;
172
+ textColor: string | null;
173
+ activeColor: string | null;
174
+ borderColor: string | null;
175
+ borderWidth: number | null;
176
+ activeStyle: "filled" | "accent";
177
+ }
178
+
179
+ /**
180
+ * Resolves the footer strip's tokens from a whole `theme_config`. Every field
181
+ * falls back to the sidebar's, so a workspace that never opens the Footer panel
182
+ * renders exactly as it did before the block existed.
183
+ */
184
+ export function resolveFooterTokens(theme: unknown): FooterTokens;
package/dist/host.js CHANGED
@@ -39,3 +39,19 @@ export {
39
39
  normalizeToastKind,
40
40
  TOAST_DEFAULTS,
41
41
  } from "./toast-host.js";
42
+
43
+ // REQ-NAV-STRUCTURE: the navigation-shape resolver both hosts draw their chrome
44
+ // from. `normaliseNavigation` turns a stored `theme_config.navigation` block into
45
+ // the resolved `{ menuType }` the chrome switches on; `menuItemCap` states how
46
+ // many menu pages that shape may draw at once. One implementation, so a menu
47
+ // type cannot mean one thing in the Player and another in the Expo export.
48
+ // `resolveFooterTokens` is the footer strip's own token set, falling back to
49
+ // the sidebar's so an untouched workspace keeps the appearance it has. It
50
+ // exists because the strip IS the menu under `bottom-tabs`, where the sidebar
51
+ // panel is hidden and those tokens can no longer be set at all.
52
+ export {
53
+ normaliseNavigation,
54
+ menuItemCap,
55
+ quickBarCap,
56
+ resolveFooterTokens,
57
+ } from "./navigation.js";
@@ -0,0 +1,124 @@
1
+ // CommonJS mirror of navigation.js — the CJS compiler and the Mason build runner
2
+ // resolve a `theme_config.navigation` block with the SAME rules the web host
3
+ // applies, so an app's menu type cannot mean one thing on web and another in the
4
+ // exported Expo app.
5
+ //
6
+ // The BODY below is copied VERBATIM from navigation.js; only the module syntax
7
+ // differs. navigation-parity.test.js pins both facts — identical bodies AND
8
+ // identical behaviour over a shared case table — so drift fails CI.
9
+
10
+ // REQ-NAV-STRUCTURE — the host side of an app's navigation SHAPE and the
11
+ // tokens its footer strip is painted with.
12
+ //
13
+ // Host-integration surface, re-exported from `host.js`: consumed by the platform
14
+ // hosts that draw an app's chrome (the web PlayerChrome, the compiler that emits
15
+ // the exported Expo app's navigator) and by the Studio surface that authors it —
16
+ // never by a widget author.
17
+ //
18
+ // Living here — one implementation both hosts import — is what makes a menu type
19
+ // behave identically in the Player and the export (widget-parity skill). The
20
+ // alternative, a resolver copied per host, is the drift this file exists to
21
+ // prevent.
22
+ //
23
+ // `CONTRACT.themeMenuTypes` is the single source of the vocabulary: which shapes
24
+ // exist and each one's item cap. Mason's `set_theme` coercion validates against
25
+ // the SAME literal, so what a planner may persist and what a host draws cannot
26
+ // diverge.
27
+
28
+ const { CONTRACT } = require("./contract.cjs");
29
+
30
+ // Absent or unknown resolves here, so every app authored before menu types
31
+ // existed renders and compiles byte-identically.
32
+ const DEFAULT_MENU_TYPE = "sidebar";
33
+
34
+ function isPlainObject(value) {
35
+ return value !== null && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+
38
+ /**
39
+ * Resolve a `theme_config.navigation` block to the shape both hosts read.
40
+ *
41
+ * Always returns a fully resolved object — a junk, partial, or absent input
42
+ * yields the default sidebar rather than something a host has to guard against.
43
+ *
44
+ * @param {unknown} navigation — the raw `theme_config.navigation` value.
45
+ * @returns {{ menuType: string }} the resolved navigation structure.
46
+ */
47
+ function normaliseNavigation(navigation) {
48
+ const raw = isPlainObject(navigation) ? navigation.menuType : undefined;
49
+ const menuType =
50
+ typeof raw === "string" && Object.hasOwn(CONTRACT.themeMenuTypes, raw)
51
+ ? raw
52
+ : DEFAULT_MENU_TYPE;
53
+ return { menuType };
54
+ }
55
+
56
+ /**
57
+ * How many MENU pages the chrome may draw at once, or `null` for no cap.
58
+ *
59
+ * Where the chrome IS the menu (`bottom-tabs`) this cap must never drop a page:
60
+ * the surplus moves behind a More sheet. Contrast `quickBarCap` below.
61
+ *
62
+ * @param {string} menuType — a resolved menu type.
63
+ * @returns {number|null}
64
+ */
65
+ function menuItemCap(menuType) {
66
+ const entry = CONTRACT.themeMenuTypes[menuType];
67
+ return entry ? entry.maxItems : null;
68
+ }
69
+
70
+ /**
71
+ * How many pages the SECONDARY mobile quick bar draws, or `null` where that
72
+ * shape draws none. Only the `sidebar` shape has one.
73
+ *
74
+ * This cap MAY drop a page, and that is the difference from `menuItemCap`: the
75
+ * rail and the drawer still list every menu page, so the bar is a shortcut
76
+ * rather than the menu. It lived as a constant on one host and a literal on the
77
+ * other before this, which is exactly how the two would have drifted.
78
+ *
79
+ * @param {string} menuType — a resolved menu type.
80
+ * @returns {number|null}
81
+ */
82
+ function quickBarCap(menuType) {
83
+ const entry = CONTRACT.themeMenuTypes[menuType];
84
+ return entry ? entry.quickBarMaxItems : null;
85
+ }
86
+
87
+ // The strip's surface was hard-coded white on BOTH hosts and its items read the
88
+ // sidebar's tokens — which is fine while it is the sidebar's secondary quick bar
89
+ // and fatal once it IS the menu (`bottom-tabs` hides the sidebar panel, so those
90
+ // tokens have nowhere to be set). `footer` gives it tokens of its own.
91
+ //
92
+ // Every field falls back to the sidebar's, so a workspace that never opens the
93
+ // Footer panel keeps exactly the appearance it has today; only an explicit
94
+ // `footer` value moves anything.
95
+ function resolveFooterTokens(theme) {
96
+ const config = isPlainObject(theme) ? theme : {};
97
+ const footer = isPlainObject(config.footer) ? config.footer : {};
98
+ const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
99
+ const pick = (key) => footer[key] || sidebar[key] || null;
100
+ const borderColor = pick("borderColor");
101
+ const activeStyle = footer.activeStyle || sidebar.activeStyle;
102
+ return {
103
+ backgroundColor: pick("backgroundColor"),
104
+ textColor: pick("textColor"),
105
+ // Unlike the others this has no null state on either host: an unset active
106
+ // colour resolves to the brand primary, which the caller supplies.
107
+ activeColor: pick("activeColor"),
108
+ // REQ-THEME-LOOK: the divider's COLOUR is its switch. Unset draws no line at
109
+ // all — which the native quick bar did not honour before this, ruling a
110
+ // hairline across a tinted strip that had asked for none.
111
+ borderColor,
112
+ borderWidth: borderColor ? borderWidthOr(footer.borderWidth, sidebar.borderWidth) : null,
113
+ activeStyle: activeStyle === "accent" ? "accent" : "filled",
114
+ };
115
+ }
116
+
117
+ function borderWidthOr(...values) {
118
+ for (const value of values) {
119
+ if (typeof value === "number" && Number.isFinite(value)) return value;
120
+ }
121
+ return 1;
122
+ }
123
+
124
+ module.exports = { normaliseNavigation, menuItemCap, quickBarCap, resolveFooterTokens };
@@ -0,0 +1,113 @@
1
+ // REQ-NAV-STRUCTURE — the host side of an app's navigation SHAPE and the
2
+ // tokens its footer strip is painted with.
3
+ //
4
+ // Host-integration surface, re-exported from `host.js`: consumed by the platform
5
+ // hosts that draw an app's chrome (the web PlayerChrome, the compiler that emits
6
+ // the exported Expo app's navigator) and by the Studio surface that authors it —
7
+ // never by a widget author.
8
+ //
9
+ // Living here — one implementation both hosts import — is what makes a menu type
10
+ // behave identically in the Player and the export (widget-parity skill). The
11
+ // alternative, a resolver copied per host, is the drift this file exists to
12
+ // prevent.
13
+ //
14
+ // `CONTRACT.themeMenuTypes` is the single source of the vocabulary: which shapes
15
+ // exist and each one's item cap. Mason's `set_theme` coercion validates against
16
+ // the SAME literal, so what a planner may persist and what a host draws cannot
17
+ // diverge.
18
+
19
+ import { CONTRACT } from "./contract.js";
20
+
21
+ // Absent or unknown resolves here, so every app authored before menu types
22
+ // existed renders and compiles byte-identically.
23
+ const DEFAULT_MENU_TYPE = "sidebar";
24
+
25
+ function isPlainObject(value) {
26
+ return value !== null && typeof value === "object" && !Array.isArray(value);
27
+ }
28
+
29
+ /**
30
+ * Resolve a `theme_config.navigation` block to the shape both hosts read.
31
+ *
32
+ * Always returns a fully resolved object — a junk, partial, or absent input
33
+ * yields the default sidebar rather than something a host has to guard against.
34
+ *
35
+ * @param {unknown} navigation — the raw `theme_config.navigation` value.
36
+ * @returns {{ menuType: string }} the resolved navigation structure.
37
+ */
38
+ export function normaliseNavigation(navigation) {
39
+ const raw = isPlainObject(navigation) ? navigation.menuType : undefined;
40
+ const menuType =
41
+ typeof raw === "string" && Object.hasOwn(CONTRACT.themeMenuTypes, raw)
42
+ ? raw
43
+ : DEFAULT_MENU_TYPE;
44
+ return { menuType };
45
+ }
46
+
47
+ /**
48
+ * How many MENU pages the chrome may draw at once, or `null` for no cap.
49
+ *
50
+ * Where the chrome IS the menu (`bottom-tabs`) this cap must never drop a page:
51
+ * the surplus moves behind a More sheet. Contrast `quickBarCap` below.
52
+ *
53
+ * @param {string} menuType — a resolved menu type.
54
+ * @returns {number|null}
55
+ */
56
+ export function menuItemCap(menuType) {
57
+ const entry = CONTRACT.themeMenuTypes[menuType];
58
+ return entry ? entry.maxItems : null;
59
+ }
60
+
61
+ /**
62
+ * How many pages the SECONDARY mobile quick bar draws, or `null` where that
63
+ * shape draws none. Only the `sidebar` shape has one.
64
+ *
65
+ * This cap MAY drop a page, and that is the difference from `menuItemCap`: the
66
+ * rail and the drawer still list every menu page, so the bar is a shortcut
67
+ * rather than the menu. It lived as a constant on one host and a literal on the
68
+ * other before this, which is exactly how the two would have drifted.
69
+ *
70
+ * @param {string} menuType — a resolved menu type.
71
+ * @returns {number|null}
72
+ */
73
+ export function quickBarCap(menuType) {
74
+ const entry = CONTRACT.themeMenuTypes[menuType];
75
+ return entry ? entry.quickBarMaxItems : null;
76
+ }
77
+
78
+ // The strip's surface was hard-coded white on BOTH hosts and its items read the
79
+ // sidebar's tokens — which is fine while it is the sidebar's secondary quick bar
80
+ // and fatal once it IS the menu (`bottom-tabs` hides the sidebar panel, so those
81
+ // tokens have nowhere to be set). `footer` gives it tokens of its own.
82
+ //
83
+ // Every field falls back to the sidebar's, so a workspace that never opens the
84
+ // Footer panel keeps exactly the appearance it has today; only an explicit
85
+ // `footer` value moves anything.
86
+ export function resolveFooterTokens(theme) {
87
+ const config = isPlainObject(theme) ? theme : {};
88
+ const footer = isPlainObject(config.footer) ? config.footer : {};
89
+ const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
90
+ const pick = (key) => footer[key] || sidebar[key] || null;
91
+ const borderColor = pick("borderColor");
92
+ const activeStyle = footer.activeStyle || sidebar.activeStyle;
93
+ return {
94
+ backgroundColor: pick("backgroundColor"),
95
+ textColor: pick("textColor"),
96
+ // Unlike the others this has no null state on either host: an unset active
97
+ // colour resolves to the brand primary, which the caller supplies.
98
+ activeColor: pick("activeColor"),
99
+ // REQ-THEME-LOOK: the divider's COLOUR is its switch. Unset draws no line at
100
+ // all — which the native quick bar did not honour before this, ruling a
101
+ // hairline across a tinted strip that had asked for none.
102
+ borderColor,
103
+ borderWidth: borderColor ? borderWidthOr(footer.borderWidth, sidebar.borderWidth) : null,
104
+ activeStyle: activeStyle === "accent" ? "accent" : "filled",
105
+ };
106
+ }
107
+
108
+ function borderWidthOr(...values) {
109
+ for (const value of values) {
110
+ if (typeof value === "number" && Number.isFinite(value)) return value;
111
+ }
112
+ return 1;
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.96.0",
3
+ "version": "0.98.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"