@colixsystems/widget-sdk 0.119.0 → 0.121.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
@@ -72,6 +72,33 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
72
72
 
73
73
  `v0.119.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**.
74
74
 
75
+ ### What's new in 0.121.0 (contract 1.93.0)
76
+
77
+ **An unset nav-chrome surface follows the PAGE, not a flat white — `resolveSidebarTokens` / `resolveTopBarTokens` (sc-6596).** 1.84.0 converged the two hosts' separately-written chrome defaults onto the web Player's `#ffffff`. Converging was right; the value was not. An app with a themed page and no explicit `sidebar`/`topBar` `backgroundColor` got bright white chrome beside the colour its author had picked — consistently on both hosts, and consistently wrong.
78
+
79
+ Both resolvers now resolve an unset `backgroundColor` to the colour the page actually shows: the app's `backgroundColor`, or a configured `backgroundGradient`'s start colour, falling back to the app default (`#f8fafc`) when the theme names neither. A gradient counts only when BOTH its stops are flat hex, which is what the hosts' own page readers require — a half-configured gradient paints nothing, so the chrome must not adopt its start colour. Per REQ-THEME-17 the page is read at FULL STRENGTH: a stored `#RRGGBBAA` reaches the chrome as its opaque base, because an alpha on the app's bottom layer composites against the host's canvas rather than anything the author chose.
80
+
81
+ This is deliberately NOT the rule an `attached` top-bar tab follows. A tab JOINED to the page refuses a translucent page rather than approximate it; chrome sitting BESIDE the page follows what the page renders. Both read one shared chain, which differs by exactly that rule.
82
+
83
+ An explicitly coloured rail or bar is unaffected. An app that themed nothing moves from `#ffffff` to the `#f8fafc` its page already was. The footer strip is untouched and still floors at white — it keeps `resolveFooterTokens`' raw-or-null shape and wants this same treatment next.
84
+
85
+ **Behaviour change, not additive:** no signature, field or export moved — only what a host renders for an unset chrome surface. Host-integration surface only; nothing a widget imports changed. `CONTRACT.version` → `1.93.0`.
86
+
87
+ ### What's new in 0.120.0 (contract 1.92.0)
88
+
89
+ **A `top-bar` app chooses which ROW its menu lives in, and how that row looks — `CONTRACT.themeTopBarMenuStyles` plus the `topBar` tab vocabulary (REQ-NAV-STRUCTURE).** The shape drew its menu as text links beside the brand, sharing the bar's one row wherever there was space. That reads as part of the header rather than as the app's global navigation. `topBarMenuStyle` now picks between `links` (that row, unchanged) and `tabs` — a dedicated tab row under the bar at every width, icon and label per page, scrolling sideways rather than dropping one. `normaliseNavigation` returns it beside `menuType`, so one resolver still answers both questions and the Player and the export cannot disagree about which row an app draws. Absent, unknown, or set on any other shape resolves to `links`.
90
+
91
+ `resolveTopBarTokens` gains the vocabulary that row is painted with:
92
+
93
+ - **`activeColor`** — the current page's mark: the active link's label, and an active tab's label and indicator. Its fallback chain is the FOOTER's rather than a new one: the bar's own value, else the **rail's**, else the brand. An app should not have to state the same navigation colour three times, and the rail is where an author already sets it. Unlike the footer's, this chain ends in the brand rather than in null — an active mark has no null state.
94
+ - **`tabStyle`** (`underline` | `attached`) and **`contentSurface`**. `attached` makes the current tab a folder tab joined to the page. The join is made by CONSTRUCTION rather than by matching: an opaque tab cannot track a page that is not flat, so the content area takes the same surface and becomes the panel the tab sits in. `contentSurface` is that shared value — always opaque, following a gradient to its start colour, and honouring an authored active surface so moving the tab moves the page with it.
95
+ - **`tabBackgroundColor`** / **`tabActiveBackgroundColor`** — a tab's own surface per state, authored or null. Null paints none and the bar shows through.
96
+ - **`tabIndicatorWidth`** (0-8, default 2) — the underline under the current tab. Zero draws none, so the WIDTH is its switch: the indicator shares `activeColor` with the label and has no null colour to switch on.
97
+ - **`tabCornerRadius`** (0-24) rounds the tab's TOP corners only — its feet stay square whatever the value, because a rounded foot notches the join — and **`tabPaddingX`** / **`tabPaddingY`** (0-32) replace the frozen 12/8, which remain the defaults.
98
+
99
+ Every measurement is CLAMPED rather than dropped: landing on the end of the range is what an author dragging a slider means. Host-integration surface only — no author-facing hook, prop, primitive, or manifest field changed.
100
+
101
+
75
102
  ### What's new in 0.119.0 (contract 1.91.0)
76
103
 
77
104
  **`useGeolocation()` can now track location while the app is BACKGROUNDED (sc-6450).** The hook only ever read a position while the app was in the foreground, so the whole class of field-work apps — delivery tracking, site visits, mileage and timesheet logging — could not be built. Its result gains four members; the existing foreground API is untouched:
@@ -242,7 +269,7 @@ return <View style={[styles.card, spacingStyle(padding, "padding")]} />;
242
269
 
243
270
  **`resolveSidebarTokens` and `resolveTopBarTokens` — the rail's and the app bar's tokens, resolved once for both hosts (sc-6289).** The footer strip got a shared resolver in 1.71.0; the two chrome parts beside it did not, so their defaults lived inline in the web Player and again in the compiler — and had drifted. An unset `sidebar.backgroundColor` painted the app background in the Expo export where the Player painted it white, so a dark app shipped a dark drawer beside a white rail; an unset `topBar.textColor` painted the app name slate in the export where the Player has always used the brand colour.
244
271
 
245
- Both resolvers now own their defaults, which are what the web Player renders, so the export follows the appearance the author approved in the Studio rather than the other way round. Unlike `resolveFooterTokens`, the colours are never `null` — a default that lives in the resolver cannot drift, and one that lived in each host already had. `borderColor` stays nullable: the colour is the divider's switch.
272
+ Both resolvers now own their defaults, which at this version are what the web Player renders, so the export follows the appearance the author approved in the Studio rather than the other way round. (**Superseded in 1.93.0** for `backgroundColor` alone: the shared default became the page's own colour, moving both hosts rather than only the export.) Unlike `resolveFooterTokens`, the colours are never `null` — a default that lives in the resolver cannot drift, and one that lived in each host already had. `borderColor` stays nullable: the colour is the divider's switch.
246
273
 
247
274
  The top bar resolves **two** text colours. An unthemed bar paints its icons slate and its app name in the brand colour, and React Navigation's single `headerTintColor` cannot say both — so `tintColor` and `titleColor` are separate, and an authored `topBar.textColor` drives both. `show` is deliberately not among them: it depends on the menu type rather than the theme, and REQ-THEME-14 makes it a no-op on native.
248
275
 
package/dist/contract.cjs CHANGED
@@ -228,6 +228,18 @@ const THEME_MENU_TYPES = Object.freeze({
228
228
  quickBarMaxItems: null,
229
229
  }),
230
230
  });
231
+ const THEME_TOP_BAR_MENU_STYLES = Object.freeze({
232
+ links: Object.freeze({
233
+ name: "Links",
234
+ summary:
235
+ "Text links beside the brand, dropping to their own row only when a phone leaves them no space.",
236
+ }),
237
+ tabs: Object.freeze({
238
+ name: "Tabs",
239
+ summary:
240
+ "A dedicated tab row under the bar at every width -- icon and label per page, marked by the brand, scrolling sideways when it runs out of room.",
241
+ }),
242
+ });
231
243
  const THEME_SPACING_SCALE = Object.freeze({
232
244
  min: 0.5,
233
245
  max: 2,
@@ -3568,7 +3580,30 @@ const CONTRACT = deepFreeze({
3568
3580
  // asynchronously after an OS relaunch, the OS can end it, and a sibling
3569
3581
  // widget can start or stop it), so subscribeBackgroundWatchState is how every
3570
3582
  // mounted widget stays truthful.
3571
- version: "1.91.0",
3583
+ // 1.92.0: additive (REQ-NAV-STRUCTURE) -- `themeTopBarMenuStyles`, the closed
3584
+ // catalogue of how a `top-bar` app draws its menu: `links` (today's row of
3585
+ // text links beside the brand) or `tabs` (a dedicated tab row under the
3586
+ // bar at every width, so the menu reads as global navigation rather than
3587
+ // as part of the header). Resolved with the shape by `normaliseNavigation`,
3588
+ // which now returns `topBarMenuStyle` beside `menuType` -- one resolver, so
3589
+ // the Player and the export cannot disagree about which row an app draws.
3590
+ // Absent, unknown, or set on any other shape resolves to `links`, so every
3591
+ // app authored before the choice existed renders and compiles identically.
3592
+ // 1.93.0: BEHAVIOUR (sc-6596) -- `resolveSidebarTokens` and
3593
+ // `resolveTopBarTokens` resolve an UNSET `backgroundColor` to the page's
3594
+ // own surface instead of a flat `#ffffff`. No signature or field changed;
3595
+ // what a host RENDERS for an unset chrome surface did. 1.84.0 converged
3596
+ // the two hosts' separately-written defaults onto the Player's white,
3597
+ // which fixed the drift and kept the wrong value: a themed app got a dark
3598
+ // page and bright white chrome on both hosts. The surface now comes from
3599
+ // `chromeSurface`, which shares its chain with the `attached` tab's
3600
+ // `pageSurface` and differs in exactly one rule: a gradient contributes
3601
+ // its start colour to both, but chrome reads a translucent app background
3602
+ // at FULL STRENGTH (trimmed to its opaque base, REQ-THEME-17) where a tab
3603
+ // JOINED to the page refuses it. An app that colours its rail or bar
3604
+ // explicitly is untouched; an app that themed NOTHING moves from
3605
+ // `#ffffff` to the `#f8fafc` its page already was.
3606
+ version: "1.93.0",
3572
3607
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3573
3608
  hooks: HOOKS,
3574
3609
  primitives: PRIMITIVES,
@@ -3585,6 +3620,7 @@ const CONTRACT = deepFreeze({
3585
3620
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
3586
3621
  themeSpacingScale: THEME_SPACING_SCALE,
3587
3622
  themeMenuTypes: THEME_MENU_TYPES,
3623
+ themeTopBarMenuStyles: THEME_TOP_BAR_MENU_STYLES,
3588
3624
  themeWidgetStyles: THEME_WIDGET_STYLES,
3589
3625
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
3590
3626
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
package/dist/contract.js CHANGED
@@ -228,6 +228,18 @@ const THEME_MENU_TYPES = Object.freeze({
228
228
  quickBarMaxItems: null,
229
229
  }),
230
230
  });
231
+ const THEME_TOP_BAR_MENU_STYLES = Object.freeze({
232
+ links: Object.freeze({
233
+ name: "Links",
234
+ summary:
235
+ "Text links beside the brand, dropping to their own row only when a phone leaves them no space.",
236
+ }),
237
+ tabs: Object.freeze({
238
+ name: "Tabs",
239
+ summary:
240
+ "A dedicated tab row under the bar at every width -- icon and label per page, marked by the brand, scrolling sideways when it runs out of room.",
241
+ }),
242
+ });
231
243
  const THEME_SPACING_SCALE = Object.freeze({
232
244
  min: 0.5,
233
245
  max: 2,
@@ -3568,7 +3580,30 @@ const CONTRACT = deepFreeze({
3568
3580
  // asynchronously after an OS relaunch, the OS can end it, and a sibling
3569
3581
  // widget can start or stop it), so subscribeBackgroundWatchState is how every
3570
3582
  // mounted widget stays truthful.
3571
- version: "1.91.0",
3583
+ // 1.92.0: additive (REQ-NAV-STRUCTURE) -- `themeTopBarMenuStyles`, the closed
3584
+ // catalogue of how a `top-bar` app draws its menu: `links` (today's row of
3585
+ // text links beside the brand) or `tabs` (a dedicated tab row under the
3586
+ // bar at every width, so the menu reads as global navigation rather than
3587
+ // as part of the header). Resolved with the shape by `normaliseNavigation`,
3588
+ // which now returns `topBarMenuStyle` beside `menuType` -- one resolver, so
3589
+ // the Player and the export cannot disagree about which row an app draws.
3590
+ // Absent, unknown, or set on any other shape resolves to `links`, so every
3591
+ // app authored before the choice existed renders and compiles identically.
3592
+ // 1.93.0: BEHAVIOUR (sc-6596) -- `resolveSidebarTokens` and
3593
+ // `resolveTopBarTokens` resolve an UNSET `backgroundColor` to the page's
3594
+ // own surface instead of a flat `#ffffff`. No signature or field changed;
3595
+ // what a host RENDERS for an unset chrome surface did. 1.84.0 converged
3596
+ // the two hosts' separately-written defaults onto the Player's white,
3597
+ // which fixed the drift and kept the wrong value: a themed app got a dark
3598
+ // page and bright white chrome on both hosts. The surface now comes from
3599
+ // `chromeSurface`, which shares its chain with the `attached` tab's
3600
+ // `pageSurface` and differs in exactly one rule: a gradient contributes
3601
+ // its start colour to both, but chrome reads a translucent app background
3602
+ // at FULL STRENGTH (trimmed to its opaque base, REQ-THEME-17) where a tab
3603
+ // JOINED to the page refuses it. An app that colours its rail or bar
3604
+ // explicitly is untouched; an app that themed NOTHING moves from
3605
+ // `#ffffff` to the `#f8fafc` its page already was.
3606
+ version: "1.93.0",
3572
3607
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3573
3608
  hooks: HOOKS,
3574
3609
  primitives: PRIMITIVES,
@@ -3585,6 +3620,7 @@ const CONTRACT = deepFreeze({
3585
3620
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
3586
3621
  themeSpacingScale: THEME_SPACING_SCALE,
3587
3622
  themeMenuTypes: THEME_MENU_TYPES,
3623
+ themeTopBarMenuStyles: THEME_TOP_BAR_MENU_STYLES,
3588
3624
  themeWidgetStyles: THEME_WIDGET_STYLES,
3589
3625
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
3590
3626
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
package/dist/host.d.ts CHANGED
@@ -155,8 +155,14 @@ export function createToastController(
155
155
 
156
156
  export type ThemeMenuType = "sidebar" | "top-bar" | "bottom-tabs";
157
157
 
158
+ // How a `top-bar` app draws its menu — closed by
159
+ // `CONTRACT.themeTopBarMenuStyles`. Meaningless on the other two shapes, where
160
+ // the resolver always reports "links".
161
+ export type ThemeTopBarMenuStyle = "links" | "tabs";
162
+
158
163
  export interface ResolvedNavigation {
159
164
  menuType: ThemeMenuType;
165
+ topBarMenuStyle: ThemeTopBarMenuStyle;
160
166
  }
161
167
 
162
168
  /**
@@ -213,7 +219,10 @@ export interface SidebarTokens {
213
219
  /**
214
220
  * Resolves the sidebar rail's tokens from a whole `theme_config`. Every default
215
221
  * is what the web Player renders, so the export follows the Player rather than
216
- * the other way round.
222
+ * the other way round — except `backgroundColor`, which sc-6596 moved on BOTH
223
+ * hosts: an unset surface takes the colour the PAGE shows (the app's
224
+ * `backgroundColor`, or a fully configured `backgroundGradient`'s start colour,
225
+ * read at full strength) rather than a flat white.
217
226
  */
218
227
  export function resolveSidebarTokens(theme: unknown): SidebarTokens;
219
228
 
@@ -226,12 +235,44 @@ export interface TopBarTokens {
226
235
  titleColor: string;
227
236
  borderColor: string | null;
228
237
  borderWidth: number | null;
238
+ /** The current page's mark — the active link's label, and an active tab's
239
+ * label plus its indicator. Falls back to the RAIL's `activeColor` and then
240
+ * to the brand, so an app states its navigation colour once. */
241
+ activeColor: string;
242
+ /** How a `tabs` menu row marks its current page. `underline` keeps the tab in
243
+ * the bar's surface behind an indicator; `attached` makes it a folder tab
244
+ * that takes `contentSurface` and sits over the row's divider. Meaningless
245
+ * under the `links` style, which draws no tabs. */
246
+ tabStyle: "underline" | "attached";
247
+ /** The surface an `attached` tab AND its content panel share — always
248
+ * opaque, because a translucent tab would show the bar through the page it
249
+ * is part of. Honours `tabActiveBackgroundColor`, so moving the tab moves
250
+ * the page with it. */
251
+ contentSurface: string;
252
+ /** A tab's own surface, authored or null. Null paints none and the bar shows
253
+ * through. */
254
+ tabBackgroundColor: string | null;
255
+ /** The underline under the current tab in px, 0-8. Zero draws none and
256
+ * leaves the tab marked by its label colour alone, so the WIDTH is this
257
+ * one's switch — it shares `activeColor` with the label and so has no null
258
+ * colour to switch on. */
259
+ tabIndicatorWidth: number;
260
+ /** The tab's TOP corner rounding in px, 0-24. Its feet stay square whatever
261
+ * this says — a rounded foot notches the join with the content. */
262
+ tabCornerRadius: number;
263
+ /** The room inside a tab in px, 0-32, defaulting to the row's shipped 12/8. */
264
+ tabPaddingX: number;
265
+ tabPaddingY: number;
266
+ /** The current tab's own surface, authored or null. Under `attached` the
267
+ * resolved surface is `contentSurface` above, which already honours it. */
268
+ tabActiveBackgroundColor: string | null;
229
269
  }
230
270
 
231
271
  /**
232
272
  * Resolves the top app bar's tokens from a whole `theme_config`. `show` is not
233
273
  * among them: it depends on the menu type, not the theme, and is a no-op on
234
- * native.
274
+ * native. An unset `backgroundColor` follows the PAGE's colour, as the rail's
275
+ * does (sc-6596).
235
276
  */
236
277
  export function resolveTopBarTokens(theme: unknown): TopBarTokens;
237
278
 
@@ -30,6 +30,9 @@ const { CONTRACT, isHexColor } = require("./contract.cjs");
30
30
  // Absent or unknown resolves here, so every app authored before menu types
31
31
  // existed renders and compiles byte-identically.
32
32
  const DEFAULT_MENU_TYPE = "sidebar";
33
+ // Today's row of text links, so an app that never made the choice draws exactly
34
+ // what it drew before it existed.
35
+ const DEFAULT_TOP_BAR_MENU_STYLE = "links";
33
36
 
34
37
  function isPlainObject(value) {
35
38
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -42,15 +45,26 @@ function isPlainObject(value) {
42
45
  * yields the default sidebar rather than something a host has to guard against.
43
46
  *
44
47
  * @param {unknown} navigation — the raw `theme_config.navigation` value.
45
- * @returns {{ menuType: string }} the resolved navigation structure.
48
+ * @returns {{ menuType: string, topBarMenuStyle: string }} the resolved
49
+ * navigation structure.
46
50
  */
47
51
  function normaliseNavigation(navigation) {
48
- const raw = isPlainObject(navigation) ? navigation.menuType : undefined;
52
+ const block = isPlainObject(navigation) ? navigation : {};
53
+ const raw = block.menuType;
49
54
  const menuType =
50
55
  typeof raw === "string" && Object.hasOwn(CONTRACT.themeMenuTypes, raw)
51
56
  ? raw
52
57
  : DEFAULT_MENU_TYPE;
53
- return { menuType };
58
+ const rawStyle = block.topBarMenuStyle;
59
+ // Resolved to `links` for every shape but `top-bar`, so a host reading it can
60
+ // never act on a value the app's own chrome has no row to draw.
61
+ const topBarMenuStyle =
62
+ menuType === "top-bar" &&
63
+ typeof rawStyle === "string" &&
64
+ Object.hasOwn(CONTRACT.themeTopBarMenuStyles, rawStyle)
65
+ ? rawStyle
66
+ : DEFAULT_TOP_BAR_MENU_STYLE;
67
+ return { menuType, topBarMenuStyle };
54
68
  }
55
69
 
56
70
  /**
@@ -129,14 +143,112 @@ function resolveFooterTokens(theme) {
129
143
  // Every default is what the WEB Player renders today, so the Player does not
130
144
  // move and the export converges onto it. That is the direction the footer was
131
145
  // converged in too (compiler.service's footerBg defaults to the web's #ffffff).
146
+ //
147
+ // sc-6596 — the ONE exception, and it moves BOTH hosts: an unset rail or bar
148
+ // takes the page's own colour. Converging on the Player's white left a themed
149
+ // app's chrome contradicting its own theme.
132
150
 
133
151
  const DEFAULT_PRIMARY_COLOR = "#3b82f6";
134
- // The web rail's and app bar's `bg-white`.
135
- const DEFAULT_CHROME_SURFACE = "#ffffff";
136
152
  // The web nav item's `text-slate-600`.
137
153
  const DEFAULT_CHROME_TEXT = "#475569";
138
154
  // The web mobile header's icon colour, `text-slate-700`.
139
155
  const DEFAULT_TOP_BAR_TINT = "#334155";
156
+ // The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
157
+ // (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
158
+ const DEFAULT_APP_SURFACE = "#f8fafc";
159
+
160
+ // A gradient shows only when BOTH stops are a flat hex — the rule both hosts'
161
+ // page readers apply (`normaliseThemeGradient`, mirrored frontend/backend). A
162
+ // half-configured or alpha-carrying gradient paints NOTHING, so reading its
163
+ // `from` would hand the chrome a colour the page never shows.
164
+ function gradientStart(config) {
165
+ const gradient = isPlainObject(config.backgroundGradient)
166
+ ? config.backgroundGradient
167
+ : null;
168
+ if (!gradient) return null;
169
+ const from = flatHexOrNull(gradient.from);
170
+ return from && flatHexOrNull(gradient.to) ? from : null;
171
+ }
172
+
173
+ // The colour the PAGE actually shows. A gradient owns the visible background,
174
+ // so its START colour wins — the flat colour shows nowhere under one.
175
+ //
176
+ // `alphaRule` is the only thing the two callers differ by, and they differ for a
177
+ // reason: a tab JOINED to the page refuses a translucent page rather than
178
+ // approximate it, while chrome BESIDE the page follows what the page renders —
179
+ // the alpha-trimmed value both hosts paint (REQ-THEME-17). Sharing the chain is
180
+ // what keeps them from drifting on everything else.
181
+ function pageColor(config, alphaRule) {
182
+ return hexOr(
183
+ gradientStart(config) || alphaRule(config.backgroundColor),
184
+ DEFAULT_APP_SURFACE,
185
+ );
186
+ }
187
+
188
+ // An `attached` tab: opaque, or the bar shows through the page it joins.
189
+ function pageSurface(config) {
190
+ return pageColor(config, opaqueOr);
191
+ }
192
+
193
+ // sc-6596: what an UNSET rail or bar is painted. Read at full strength — an
194
+ // alpha on the bottom layer would mean nothing to chrome above it.
195
+ function chromeSurface(config) {
196
+ return pageColor(config, flattenedHex);
197
+ }
198
+
199
+ // The rail's raw block — the fallback every other chrome's active colour ends
200
+ // up at, so an app states its navigation colour once.
201
+ function sidebarBlock(config) {
202
+ return isPlainObject(config.sidebar) ? config.sidebar : {};
203
+ }
204
+
205
+ // REQ-NAV-STRUCTURE: the tab's TOP corners, in px. Zero is the default and the
206
+ // square tab the row shipped with; the feet stay square whatever this says,
207
+ // because a rounded foot notches the join. Clamped rather than dropped, so a
208
+ // value past the end lands on the end instead of silently reverting to square.
209
+ const TAB_RADIUS_MAX = 24;
210
+ function tabRadiusOr(value) {
211
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
212
+ return Math.min(Math.max(Math.round(value), 0), TAB_RADIUS_MAX);
213
+ }
214
+
215
+ // The room inside a tab, in px. The defaults are the row's shipped `px-3 py-2`.
216
+ const TAB_PADDING_MAX = 32;
217
+ const DEFAULT_TAB_PADDING_X = 12;
218
+ const DEFAULT_TAB_PADDING_Y = 8;
219
+ function tabSpaceOr(value, fallback) {
220
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
221
+ return Math.min(Math.max(Math.round(value), 0), TAB_PADDING_MAX);
222
+ }
223
+
224
+ // The current tab's underline, in px. Two is what the row shipped with; zero is
225
+ // off. Clamped rather than dropped, like the tab's other measurements.
226
+ const TAB_INDICATOR_MAX = 8;
227
+ const DEFAULT_TAB_INDICATOR = 2;
228
+ function tabIndicatorOr(value) {
229
+ if (typeof value !== "number" || !Number.isFinite(value)) {
230
+ return DEFAULT_TAB_INDICATOR;
231
+ }
232
+ return Math.min(Math.max(Math.round(value), 0), TAB_INDICATOR_MAX);
233
+ }
234
+
235
+ function opaqueOr(value) {
236
+ return isHexColor(value) && value.length !== 9 && value.length !== 5
237
+ ? value
238
+ : null;
239
+ }
240
+
241
+ // A gradient stop as the page readers admit one: flat hex, never alpha.
242
+ function flatHexOrNull(value) {
243
+ return isHexColor(value) && value.length !== 9 ? value : null;
244
+ }
245
+
246
+ // `#RRGGBBAA` trimmed to its opaque base rather than refused — what both hosts'
247
+ // page readers paint (web `opaqueColor`, compiler `opaqueHex`).
248
+ function flattenedHex(value) {
249
+ if (!isHexColor(value)) return null;
250
+ return value.length === 9 ? value.slice(0, 7) : value;
251
+ }
140
252
 
141
253
  // The contract's own guard, which already admits the 8-digit form a look uses
142
254
  // to float a translucent rail (#RRGGBBAA).
@@ -156,7 +268,9 @@ function brandPrimary(config) {
156
268
  * The sidebar rail's surface, labels, active item and opt-in divider.
157
269
  *
158
270
  * @param {unknown} theme — the whole `theme_config`; `primaryColor` is read
159
- * because an unset active colour resolves to the brand.
271
+ * because an unset active colour resolves to the brand, and the app's
272
+ * `backgroundColor`/`backgroundGradient` because an unset surface resolves to
273
+ * the page's (sc-6596).
160
274
  * @returns {{ backgroundColor: string, textColor: string, activeColor: string,
161
275
  * activeStyle: string, borderColor: string|null, borderWidth: number|null }}
162
276
  */
@@ -165,7 +279,7 @@ function resolveSidebarTokens(theme) {
165
279
  const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
166
280
  const borderColor = hexOrNull(sidebar.borderColor);
167
281
  return {
168
- backgroundColor: hexOr(sidebar.backgroundColor, DEFAULT_CHROME_SURFACE),
282
+ backgroundColor: hexOr(sidebar.backgroundColor, chromeSurface(config)),
169
283
  textColor: hexOr(sidebar.textColor, DEFAULT_CHROME_TEXT),
170
284
  activeColor: hexOr(sidebar.activeColor, brandPrimary(config)),
171
285
  activeStyle: sidebar.activeStyle === "accent" ? "accent" : "filled",
@@ -187,7 +301,9 @@ function resolveSidebarTokens(theme) {
187
301
  * `show` is deliberately absent: it depends on the menu TYPE, not the theme,
188
302
  * and REQ-THEME-14 makes it a no-op on native — so it is not a shared token.
189
303
  *
190
- * @param {unknown} theme — the whole `theme_config`.
304
+ * @param {unknown} theme — the whole `theme_config`; the app's
305
+ * `backgroundColor`/`backgroundGradient` are read because an unset surface
306
+ * resolves to the page's, as an `attached` tab's already did (sc-6596).
191
307
  * @returns {{ backgroundColor: string, tintColor: string, titleColor: string,
192
308
  * borderColor: string|null, borderWidth: number|null }}
193
309
  */
@@ -198,11 +314,68 @@ function resolveTopBarTokens(theme) {
198
314
  // An explicit colour drives BOTH slots; only the unset case splits.
199
315
  const authored = hexOrNull(topBar.textColor);
200
316
  return {
201
- backgroundColor: hexOr(topBar.backgroundColor, DEFAULT_CHROME_SURFACE),
317
+ backgroundColor: hexOr(topBar.backgroundColor, chromeSurface(config)),
202
318
  tintColor: authored || DEFAULT_TOP_BAR_TINT,
203
319
  titleColor: authored || brandPrimary(config),
320
+ // REQ-NAV-STRUCTURE: the CURRENT page's mark — the active link's label, and
321
+ // an active tab's label plus its indicator. It read the brand directly
322
+ // before, which is right as a default and wrong as a rule: an attached tab
323
+ // wears the page's surface, and the brand that reads well on the bar can
324
+ // fail on the page.
325
+ //
326
+ // The fallback chain is the FOOTER's, deliberately: the bar's own value,
327
+ // else the RAIL's, else the brand. One app should not have to state the
328
+ // same navigation colour three times, and the rail is where an author
329
+ // already sets it — so the bar follows the app's navigation by default and
330
+ // departs from it only when asked. Unlike the footer's `pick`, this one
331
+ // ends in the brand rather than in null: an active mark has no null state.
332
+ activeColor: hexOr(
333
+ topBar.activeColor,
334
+ hexOr(sidebarBlock(config).activeColor, brandPrimary(config)),
335
+ ),
204
336
  borderColor,
205
337
  borderWidth: borderColor ? borderWidthOr(topBar.borderWidth) : null,
338
+ // REQ-NAV-STRUCTURE: how the tab row marks its current page.
339
+ // `underline` keeps the tab in the bar's surface and marks it with an
340
+ // indicator. `attached` makes it a real folder tab: it takes the CONTENT's
341
+ // surface and sits over the row's divider, so the tab and the page beneath
342
+ // read as one plane. Meaningless under the `links` style, which draws no
343
+ // tabs — a host reads it only where it has tabs to draw.
344
+ tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
345
+ // The surface an `attached` tab AND its content panel share. One value for
346
+ // both, so the join cannot come apart: whatever the current tab is painted,
347
+ // the page beneath it is painted too. An authored active surface therefore
348
+ // moves both. Resolved HERE rather than by each host, because a tab that
349
+ // fails to match the page it is joined to reads as a mismatched box instead
350
+ // of a tab, and two hosts computing it separately is exactly how that
351
+ // drifts. Opaque by construction: a translucent tab would show the bar
352
+ // through the page it claims to be part of.
353
+ contentSurface: hexOr(
354
+ opaqueOr(topBar.tabActiveBackgroundColor),
355
+ pageSurface(config),
356
+ ),
357
+ // REQ-NAV-STRUCTURE: the underline that marks the current tab, in px. Zero
358
+ // turns it OFF and leaves the tab marked by its label colour alone, which
359
+ // is a real choice rather than an unstyled state — so unlike the chrome
360
+ // divider, whose COLOUR is its switch, this one's width is. It takes
361
+ // `activeColor` above; a mark and its label disagreeing about which colour
362
+ // means "you are here" would be two marks, not one.
363
+ tabIndicatorWidth: tabIndicatorOr(topBar.tabIndicatorWidth),
364
+ // The tab's TOP corners. Its feet stay square regardless — see tabRadiusOr.
365
+ tabCornerRadius: tabRadiusOr(topBar.tabCornerRadius),
366
+ // The room inside a tab. Defaulted to what the row shipped with (12/8), so
367
+ // an app that never touches them is unchanged, and clamped rather than
368
+ // dropped for the same reason the radius is.
369
+ tabPaddingX: tabSpaceOr(topBar.tabPaddingX, DEFAULT_TAB_PADDING_X),
370
+ tabPaddingY: tabSpaceOr(topBar.tabPaddingY, DEFAULT_TAB_PADDING_Y),
371
+ // A tab's own surface, authored or nothing. Null means the tab paints none
372
+ // and the bar shows through it, which is what both styles did before the
373
+ // keys existed — so an app that never set them is unchanged.
374
+ tabBackgroundColor: hexOrNull(topBar.tabBackgroundColor),
375
+ // Only meaningful where the style draws no panel: under `attached` the
376
+ // active surface IS `contentSurface` above. Null under `underline` leaves
377
+ // the current tab unpainted, marked by its indicator alone.
378
+ tabActiveBackgroundColor: hexOrNull(topBar.tabActiveBackgroundColor),
206
379
  };
207
380
  }
208
381
 
@@ -21,6 +21,9 @@ import { CONTRACT, isHexColor } from "./contract.js";
21
21
  // Absent or unknown resolves here, so every app authored before menu types
22
22
  // existed renders and compiles byte-identically.
23
23
  const DEFAULT_MENU_TYPE = "sidebar";
24
+ // Today's row of text links, so an app that never made the choice draws exactly
25
+ // what it drew before it existed.
26
+ const DEFAULT_TOP_BAR_MENU_STYLE = "links";
24
27
 
25
28
  function isPlainObject(value) {
26
29
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -33,15 +36,26 @@ function isPlainObject(value) {
33
36
  * yields the default sidebar rather than something a host has to guard against.
34
37
  *
35
38
  * @param {unknown} navigation — the raw `theme_config.navigation` value.
36
- * @returns {{ menuType: string }} the resolved navigation structure.
39
+ * @returns {{ menuType: string, topBarMenuStyle: string }} the resolved
40
+ * navigation structure.
37
41
  */
38
42
  export function normaliseNavigation(navigation) {
39
- const raw = isPlainObject(navigation) ? navigation.menuType : undefined;
43
+ const block = isPlainObject(navigation) ? navigation : {};
44
+ const raw = block.menuType;
40
45
  const menuType =
41
46
  typeof raw === "string" && Object.hasOwn(CONTRACT.themeMenuTypes, raw)
42
47
  ? raw
43
48
  : DEFAULT_MENU_TYPE;
44
- return { menuType };
49
+ const rawStyle = block.topBarMenuStyle;
50
+ // Resolved to `links` for every shape but `top-bar`, so a host reading it can
51
+ // never act on a value the app's own chrome has no row to draw.
52
+ const topBarMenuStyle =
53
+ menuType === "top-bar" &&
54
+ typeof rawStyle === "string" &&
55
+ Object.hasOwn(CONTRACT.themeTopBarMenuStyles, rawStyle)
56
+ ? rawStyle
57
+ : DEFAULT_TOP_BAR_MENU_STYLE;
58
+ return { menuType, topBarMenuStyle };
45
59
  }
46
60
 
47
61
  /**
@@ -120,14 +134,112 @@ export function resolveFooterTokens(theme) {
120
134
  // Every default is what the WEB Player renders today, so the Player does not
121
135
  // move and the export converges onto it. That is the direction the footer was
122
136
  // converged in too (compiler.service's footerBg defaults to the web's #ffffff).
137
+ //
138
+ // sc-6596 — the ONE exception, and it moves BOTH hosts: an unset rail or bar
139
+ // takes the page's own colour. Converging on the Player's white left a themed
140
+ // app's chrome contradicting its own theme.
123
141
 
124
142
  const DEFAULT_PRIMARY_COLOR = "#3b82f6";
125
- // The web rail's and app bar's `bg-white`.
126
- const DEFAULT_CHROME_SURFACE = "#ffffff";
127
143
  // The web nav item's `text-slate-600`.
128
144
  const DEFAULT_CHROME_TEXT = "#475569";
129
145
  // The web mobile header's icon colour, `text-slate-700`.
130
146
  const DEFAULT_TOP_BAR_TINT = "#334155";
147
+ // The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
148
+ // (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
149
+ const DEFAULT_APP_SURFACE = "#f8fafc";
150
+
151
+ // A gradient shows only when BOTH stops are a flat hex — the rule both hosts'
152
+ // page readers apply (`normaliseThemeGradient`, mirrored frontend/backend). A
153
+ // half-configured or alpha-carrying gradient paints NOTHING, so reading its
154
+ // `from` would hand the chrome a colour the page never shows.
155
+ function gradientStart(config) {
156
+ const gradient = isPlainObject(config.backgroundGradient)
157
+ ? config.backgroundGradient
158
+ : null;
159
+ if (!gradient) return null;
160
+ const from = flatHexOrNull(gradient.from);
161
+ return from && flatHexOrNull(gradient.to) ? from : null;
162
+ }
163
+
164
+ // The colour the PAGE actually shows. A gradient owns the visible background,
165
+ // so its START colour wins — the flat colour shows nowhere under one.
166
+ //
167
+ // `alphaRule` is the only thing the two callers differ by, and they differ for a
168
+ // reason: a tab JOINED to the page refuses a translucent page rather than
169
+ // approximate it, while chrome BESIDE the page follows what the page renders —
170
+ // the alpha-trimmed value both hosts paint (REQ-THEME-17). Sharing the chain is
171
+ // what keeps them from drifting on everything else.
172
+ function pageColor(config, alphaRule) {
173
+ return hexOr(
174
+ gradientStart(config) || alphaRule(config.backgroundColor),
175
+ DEFAULT_APP_SURFACE,
176
+ );
177
+ }
178
+
179
+ // An `attached` tab: opaque, or the bar shows through the page it joins.
180
+ function pageSurface(config) {
181
+ return pageColor(config, opaqueOr);
182
+ }
183
+
184
+ // sc-6596: what an UNSET rail or bar is painted. Read at full strength — an
185
+ // alpha on the bottom layer would mean nothing to chrome above it.
186
+ function chromeSurface(config) {
187
+ return pageColor(config, flattenedHex);
188
+ }
189
+
190
+ // The rail's raw block — the fallback every other chrome's active colour ends
191
+ // up at, so an app states its navigation colour once.
192
+ function sidebarBlock(config) {
193
+ return isPlainObject(config.sidebar) ? config.sidebar : {};
194
+ }
195
+
196
+ // REQ-NAV-STRUCTURE: the tab's TOP corners, in px. Zero is the default and the
197
+ // square tab the row shipped with; the feet stay square whatever this says,
198
+ // because a rounded foot notches the join. Clamped rather than dropped, so a
199
+ // value past the end lands on the end instead of silently reverting to square.
200
+ const TAB_RADIUS_MAX = 24;
201
+ function tabRadiusOr(value) {
202
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
203
+ return Math.min(Math.max(Math.round(value), 0), TAB_RADIUS_MAX);
204
+ }
205
+
206
+ // The room inside a tab, in px. The defaults are the row's shipped `px-3 py-2`.
207
+ const TAB_PADDING_MAX = 32;
208
+ const DEFAULT_TAB_PADDING_X = 12;
209
+ const DEFAULT_TAB_PADDING_Y = 8;
210
+ function tabSpaceOr(value, fallback) {
211
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
212
+ return Math.min(Math.max(Math.round(value), 0), TAB_PADDING_MAX);
213
+ }
214
+
215
+ // The current tab's underline, in px. Two is what the row shipped with; zero is
216
+ // off. Clamped rather than dropped, like the tab's other measurements.
217
+ const TAB_INDICATOR_MAX = 8;
218
+ const DEFAULT_TAB_INDICATOR = 2;
219
+ function tabIndicatorOr(value) {
220
+ if (typeof value !== "number" || !Number.isFinite(value)) {
221
+ return DEFAULT_TAB_INDICATOR;
222
+ }
223
+ return Math.min(Math.max(Math.round(value), 0), TAB_INDICATOR_MAX);
224
+ }
225
+
226
+ function opaqueOr(value) {
227
+ return isHexColor(value) && value.length !== 9 && value.length !== 5
228
+ ? value
229
+ : null;
230
+ }
231
+
232
+ // A gradient stop as the page readers admit one: flat hex, never alpha.
233
+ function flatHexOrNull(value) {
234
+ return isHexColor(value) && value.length !== 9 ? value : null;
235
+ }
236
+
237
+ // `#RRGGBBAA` trimmed to its opaque base rather than refused — what both hosts'
238
+ // page readers paint (web `opaqueColor`, compiler `opaqueHex`).
239
+ function flattenedHex(value) {
240
+ if (!isHexColor(value)) return null;
241
+ return value.length === 9 ? value.slice(0, 7) : value;
242
+ }
131
243
 
132
244
  // The contract's own guard, which already admits the 8-digit form a look uses
133
245
  // to float a translucent rail (#RRGGBBAA).
@@ -147,7 +259,9 @@ function brandPrimary(config) {
147
259
  * The sidebar rail's surface, labels, active item and opt-in divider.
148
260
  *
149
261
  * @param {unknown} theme — the whole `theme_config`; `primaryColor` is read
150
- * because an unset active colour resolves to the brand.
262
+ * because an unset active colour resolves to the brand, and the app's
263
+ * `backgroundColor`/`backgroundGradient` because an unset surface resolves to
264
+ * the page's (sc-6596).
151
265
  * @returns {{ backgroundColor: string, textColor: string, activeColor: string,
152
266
  * activeStyle: string, borderColor: string|null, borderWidth: number|null }}
153
267
  */
@@ -156,7 +270,7 @@ export function resolveSidebarTokens(theme) {
156
270
  const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
157
271
  const borderColor = hexOrNull(sidebar.borderColor);
158
272
  return {
159
- backgroundColor: hexOr(sidebar.backgroundColor, DEFAULT_CHROME_SURFACE),
273
+ backgroundColor: hexOr(sidebar.backgroundColor, chromeSurface(config)),
160
274
  textColor: hexOr(sidebar.textColor, DEFAULT_CHROME_TEXT),
161
275
  activeColor: hexOr(sidebar.activeColor, brandPrimary(config)),
162
276
  activeStyle: sidebar.activeStyle === "accent" ? "accent" : "filled",
@@ -178,7 +292,9 @@ export function resolveSidebarTokens(theme) {
178
292
  * `show` is deliberately absent: it depends on the menu TYPE, not the theme,
179
293
  * and REQ-THEME-14 makes it a no-op on native — so it is not a shared token.
180
294
  *
181
- * @param {unknown} theme — the whole `theme_config`.
295
+ * @param {unknown} theme — the whole `theme_config`; the app's
296
+ * `backgroundColor`/`backgroundGradient` are read because an unset surface
297
+ * resolves to the page's, as an `attached` tab's already did (sc-6596).
182
298
  * @returns {{ backgroundColor: string, tintColor: string, titleColor: string,
183
299
  * borderColor: string|null, borderWidth: number|null }}
184
300
  */
@@ -189,11 +305,68 @@ export function resolveTopBarTokens(theme) {
189
305
  // An explicit colour drives BOTH slots; only the unset case splits.
190
306
  const authored = hexOrNull(topBar.textColor);
191
307
  return {
192
- backgroundColor: hexOr(topBar.backgroundColor, DEFAULT_CHROME_SURFACE),
308
+ backgroundColor: hexOr(topBar.backgroundColor, chromeSurface(config)),
193
309
  tintColor: authored || DEFAULT_TOP_BAR_TINT,
194
310
  titleColor: authored || brandPrimary(config),
311
+ // REQ-NAV-STRUCTURE: the CURRENT page's mark — the active link's label, and
312
+ // an active tab's label plus its indicator. It read the brand directly
313
+ // before, which is right as a default and wrong as a rule: an attached tab
314
+ // wears the page's surface, and the brand that reads well on the bar can
315
+ // fail on the page.
316
+ //
317
+ // The fallback chain is the FOOTER's, deliberately: the bar's own value,
318
+ // else the RAIL's, else the brand. One app should not have to state the
319
+ // same navigation colour three times, and the rail is where an author
320
+ // already sets it — so the bar follows the app's navigation by default and
321
+ // departs from it only when asked. Unlike the footer's `pick`, this one
322
+ // ends in the brand rather than in null: an active mark has no null state.
323
+ activeColor: hexOr(
324
+ topBar.activeColor,
325
+ hexOr(sidebarBlock(config).activeColor, brandPrimary(config)),
326
+ ),
195
327
  borderColor,
196
328
  borderWidth: borderColor ? borderWidthOr(topBar.borderWidth) : null,
329
+ // REQ-NAV-STRUCTURE: how the tab row marks its current page.
330
+ // `underline` keeps the tab in the bar's surface and marks it with an
331
+ // indicator. `attached` makes it a real folder tab: it takes the CONTENT's
332
+ // surface and sits over the row's divider, so the tab and the page beneath
333
+ // read as one plane. Meaningless under the `links` style, which draws no
334
+ // tabs — a host reads it only where it has tabs to draw.
335
+ tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
336
+ // The surface an `attached` tab AND its content panel share. One value for
337
+ // both, so the join cannot come apart: whatever the current tab is painted,
338
+ // the page beneath it is painted too. An authored active surface therefore
339
+ // moves both. Resolved HERE rather than by each host, because a tab that
340
+ // fails to match the page it is joined to reads as a mismatched box instead
341
+ // of a tab, and two hosts computing it separately is exactly how that
342
+ // drifts. Opaque by construction: a translucent tab would show the bar
343
+ // through the page it claims to be part of.
344
+ contentSurface: hexOr(
345
+ opaqueOr(topBar.tabActiveBackgroundColor),
346
+ pageSurface(config),
347
+ ),
348
+ // REQ-NAV-STRUCTURE: the underline that marks the current tab, in px. Zero
349
+ // turns it OFF and leaves the tab marked by its label colour alone, which
350
+ // is a real choice rather than an unstyled state — so unlike the chrome
351
+ // divider, whose COLOUR is its switch, this one's width is. It takes
352
+ // `activeColor` above; a mark and its label disagreeing about which colour
353
+ // means "you are here" would be two marks, not one.
354
+ tabIndicatorWidth: tabIndicatorOr(topBar.tabIndicatorWidth),
355
+ // The tab's TOP corners. Its feet stay square regardless — see tabRadiusOr.
356
+ tabCornerRadius: tabRadiusOr(topBar.tabCornerRadius),
357
+ // The room inside a tab. Defaulted to what the row shipped with (12/8), so
358
+ // an app that never touches them is unchanged, and clamped rather than
359
+ // dropped for the same reason the radius is.
360
+ tabPaddingX: tabSpaceOr(topBar.tabPaddingX, DEFAULT_TAB_PADDING_X),
361
+ tabPaddingY: tabSpaceOr(topBar.tabPaddingY, DEFAULT_TAB_PADDING_Y),
362
+ // A tab's own surface, authored or nothing. Null means the tab paints none
363
+ // and the bar shows through it, which is what both styles did before the
364
+ // keys existed — so an app that never set them is unchanged.
365
+ tabBackgroundColor: hexOrNull(topBar.tabBackgroundColor),
366
+ // Only meaningful where the style draws no panel: under `attached` the
367
+ // active surface IS `contentSurface` above. Null under `underline` leaves
368
+ // the current tab unpainted, marked by its indicator alone.
369
+ tabActiveBackgroundColor: hexOrNull(topBar.tabActiveBackgroundColor),
197
370
  };
198
371
  }
199
372
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.119.0",
3
+ "version": "0.121.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",