@colixsystems/widget-sdk 0.97.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,37 @@ 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`.
68
98
 
69
99
  ### What's new in 0.97.0 (contract 1.69.0)
70
100
 
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,
@@ -3065,7 +3101,26 @@ const CONTRACT = deepFreeze({
3065
3101
  // shadcn/Tailwind import's matching custom properties have a themeConfig
3066
3102
  // home instead of being reported lost. Host-only plumbing: no scope
3067
3103
  // declares `universalFields`, so a third-party manifest is unaffected.
3068
- version: "1.69.0",
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",
3069
3124
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3070
3125
  hooks: HOOKS,
3071
3126
  primitives: PRIMITIVES,
@@ -3081,6 +3136,7 @@ const CONTRACT = deepFreeze({
3081
3136
  themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
3082
3137
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
3083
3138
  themeSpacingScale: THEME_SPACING_SCALE,
3139
+ themeMenuTypes: THEME_MENU_TYPES,
3084
3140
  themeWidgetStyles: THEME_WIDGET_STYLES,
3085
3141
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
3086
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,
@@ -3065,7 +3101,26 @@ const CONTRACT = deepFreeze({
3065
3101
  // shadcn/Tailwind import's matching custom properties have a themeConfig
3066
3102
  // home instead of being reported lost. Host-only plumbing: no scope
3067
3103
  // declares `universalFields`, so a third-party manifest is unaffected.
3068
- version: "1.69.0",
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",
3069
3124
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3070
3125
  hooks: HOOKS,
3071
3126
  primitives: PRIMITIVES,
@@ -3081,6 +3136,7 @@ const CONTRACT = deepFreeze({
3081
3136
  themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
3082
3137
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
3083
3138
  themeSpacingScale: THEME_SPACING_SCALE,
3139
+ themeMenuTypes: THEME_MENU_TYPES,
3084
3140
  themeWidgetStyles: THEME_WIDGET_STYLES,
3085
3141
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
3086
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.97.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"