@colixsystems/widget-sdk 0.119.0 → 0.120.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,21 @@ 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.120.0 (contract 1.92.0)
76
+
77
+ **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`.
78
+
79
+ `resolveTopBarTokens` gains the vocabulary that row is painted with:
80
+
81
+ - **`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.
82
+ - **`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.
83
+ - **`tabBackgroundColor`** / **`tabActiveBackgroundColor`** — a tab's own surface per state, authored or null. Null paints none and the bar shows through.
84
+ - **`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.
85
+ - **`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.
86
+
87
+ 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.
88
+
89
+
75
90
  ### What's new in 0.119.0 (contract 1.91.0)
76
91
 
77
92
  **`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:
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,16 @@ 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
+ version: "1.92.0",
3572
3593
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3573
3594
  hooks: HOOKS,
3574
3595
  primitives: PRIMITIVES,
@@ -3585,6 +3606,7 @@ const CONTRACT = deepFreeze({
3585
3606
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
3586
3607
  themeSpacingScale: THEME_SPACING_SCALE,
3587
3608
  themeMenuTypes: THEME_MENU_TYPES,
3609
+ themeTopBarMenuStyles: THEME_TOP_BAR_MENU_STYLES,
3588
3610
  themeWidgetStyles: THEME_WIDGET_STYLES,
3589
3611
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
3590
3612
  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,16 @@ 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
+ version: "1.92.0",
3572
3593
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3573
3594
  hooks: HOOKS,
3574
3595
  primitives: PRIMITIVES,
@@ -3585,6 +3606,7 @@ const CONTRACT = deepFreeze({
3585
3606
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
3586
3607
  themeSpacingScale: THEME_SPACING_SCALE,
3587
3608
  themeMenuTypes: THEME_MENU_TYPES,
3609
+ themeTopBarMenuStyles: THEME_TOP_BAR_MENU_STYLES,
3588
3610
  themeWidgetStyles: THEME_WIDGET_STYLES,
3589
3611
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
3590
3612
  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
  /**
@@ -226,6 +232,37 @@ export interface TopBarTokens {
226
232
  titleColor: string;
227
233
  borderColor: string | null;
228
234
  borderWidth: number | null;
235
+ /** The current page's mark — the active link's label, and an active tab's
236
+ * label plus its indicator. Falls back to the RAIL's `activeColor` and then
237
+ * to the brand, so an app states its navigation colour once. */
238
+ activeColor: string;
239
+ /** How a `tabs` menu row marks its current page. `underline` keeps the tab in
240
+ * the bar's surface behind an indicator; `attached` makes it a folder tab
241
+ * that takes `contentSurface` and sits over the row's divider. Meaningless
242
+ * under the `links` style, which draws no tabs. */
243
+ tabStyle: "underline" | "attached";
244
+ /** The surface an `attached` tab AND its content panel share — always
245
+ * opaque, because a translucent tab would show the bar through the page it
246
+ * is part of. Honours `tabActiveBackgroundColor`, so moving the tab moves
247
+ * the page with it. */
248
+ contentSurface: string;
249
+ /** A tab's own surface, authored or null. Null paints none and the bar shows
250
+ * through. */
251
+ tabBackgroundColor: string | null;
252
+ /** The underline under the current tab in px, 0-8. Zero draws none and
253
+ * leaves the tab marked by its label colour alone, so the WIDTH is this
254
+ * one's switch — it shares `activeColor` with the label and so has no null
255
+ * colour to switch on. */
256
+ tabIndicatorWidth: number;
257
+ /** The tab's TOP corner rounding in px, 0-24. Its feet stay square whatever
258
+ * this says — a rounded foot notches the join with the content. */
259
+ tabCornerRadius: number;
260
+ /** The room inside a tab in px, 0-32, defaulting to the row's shipped 12/8. */
261
+ tabPaddingX: number;
262
+ tabPaddingY: number;
263
+ /** The current tab's own surface, authored or null. Under `attached` the
264
+ * resolved surface is `contentSurface` above, which already honours it. */
265
+ tabActiveBackgroundColor: string | null;
229
266
  }
230
267
 
231
268
  /**
@@ -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
  /**
@@ -137,6 +151,69 @@ const DEFAULT_CHROME_SURFACE = "#ffffff";
137
151
  const DEFAULT_CHROME_TEXT = "#475569";
138
152
  // The web mobile header's icon colour, `text-slate-700`.
139
153
  const DEFAULT_TOP_BAR_TINT = "#334155";
154
+ // The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
155
+ // (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
156
+ const DEFAULT_APP_SURFACE = "#f8fafc";
157
+
158
+ // An `attached` tab is joined to the page, so its surface must be OPAQUE: the
159
+ // 8-digit form a look uses to float a translucent rail would show the bar
160
+ // through the page the tab claims to be part of. Both hosts already drop alpha
161
+ // for the page itself (web `opaqueColor`, compiler `opaqueHex`); this is the
162
+ // same rule, in the one place the tab reads it from.
163
+ // The colour the PAGE actually shows where a tab meets it. A gradient owns the
164
+ // visible background wherever one is configured and the flat `backgroundColor`
165
+ // shows nowhere, so an attached tab matching the flat colour under a gradient is
166
+ // the mismatch this exists to avoid — it takes the gradient's START colour,
167
+ // which is what the page paints at the top edge the tab is joined to.
168
+ function pageSurface(config) {
169
+ const gradient = isPlainObject(config.backgroundGradient)
170
+ ? config.backgroundGradient
171
+ : null;
172
+ const from = gradient ? opaqueOr(gradient.from) : null;
173
+ return hexOr(from || opaqueOr(config.backgroundColor), DEFAULT_APP_SURFACE);
174
+ }
175
+
176
+ // The rail's raw block — the fallback every other chrome's active colour ends
177
+ // up at, so an app states its navigation colour once.
178
+ function sidebarBlock(config) {
179
+ return isPlainObject(config.sidebar) ? config.sidebar : {};
180
+ }
181
+
182
+ // REQ-NAV-STRUCTURE: the tab's TOP corners, in px. Zero is the default and the
183
+ // square tab the row shipped with; the feet stay square whatever this says,
184
+ // because a rounded foot notches the join. Clamped rather than dropped, so a
185
+ // value past the end lands on the end instead of silently reverting to square.
186
+ const TAB_RADIUS_MAX = 24;
187
+ function tabRadiusOr(value) {
188
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
189
+ return Math.min(Math.max(Math.round(value), 0), TAB_RADIUS_MAX);
190
+ }
191
+
192
+ // The room inside a tab, in px. The defaults are the row's shipped `px-3 py-2`.
193
+ const TAB_PADDING_MAX = 32;
194
+ const DEFAULT_TAB_PADDING_X = 12;
195
+ const DEFAULT_TAB_PADDING_Y = 8;
196
+ function tabSpaceOr(value, fallback) {
197
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
198
+ return Math.min(Math.max(Math.round(value), 0), TAB_PADDING_MAX);
199
+ }
200
+
201
+ // The current tab's underline, in px. Two is what the row shipped with; zero is
202
+ // off. Clamped rather than dropped, like the tab's other measurements.
203
+ const TAB_INDICATOR_MAX = 8;
204
+ const DEFAULT_TAB_INDICATOR = 2;
205
+ function tabIndicatorOr(value) {
206
+ if (typeof value !== "number" || !Number.isFinite(value)) {
207
+ return DEFAULT_TAB_INDICATOR;
208
+ }
209
+ return Math.min(Math.max(Math.round(value), 0), TAB_INDICATOR_MAX);
210
+ }
211
+
212
+ function opaqueOr(value) {
213
+ return isHexColor(value) && value.length !== 9 && value.length !== 5
214
+ ? value
215
+ : null;
216
+ }
140
217
 
141
218
  // The contract's own guard, which already admits the 8-digit form a look uses
142
219
  // to float a translucent rail (#RRGGBBAA).
@@ -201,8 +278,65 @@ function resolveTopBarTokens(theme) {
201
278
  backgroundColor: hexOr(topBar.backgroundColor, DEFAULT_CHROME_SURFACE),
202
279
  tintColor: authored || DEFAULT_TOP_BAR_TINT,
203
280
  titleColor: authored || brandPrimary(config),
281
+ // REQ-NAV-STRUCTURE: the CURRENT page's mark — the active link's label, and
282
+ // an active tab's label plus its indicator. It read the brand directly
283
+ // before, which is right as a default and wrong as a rule: an attached tab
284
+ // wears the page's surface, and the brand that reads well on the bar can
285
+ // fail on the page.
286
+ //
287
+ // The fallback chain is the FOOTER's, deliberately: the bar's own value,
288
+ // else the RAIL's, else the brand. One app should not have to state the
289
+ // same navigation colour three times, and the rail is where an author
290
+ // already sets it — so the bar follows the app's navigation by default and
291
+ // departs from it only when asked. Unlike the footer's `pick`, this one
292
+ // ends in the brand rather than in null: an active mark has no null state.
293
+ activeColor: hexOr(
294
+ topBar.activeColor,
295
+ hexOr(sidebarBlock(config).activeColor, brandPrimary(config)),
296
+ ),
204
297
  borderColor,
205
298
  borderWidth: borderColor ? borderWidthOr(topBar.borderWidth) : null,
299
+ // REQ-NAV-STRUCTURE: how the tab row marks its current page.
300
+ // `underline` keeps the tab in the bar's surface and marks it with an
301
+ // indicator. `attached` makes it a real folder tab: it takes the CONTENT's
302
+ // surface and sits over the row's divider, so the tab and the page beneath
303
+ // read as one plane. Meaningless under the `links` style, which draws no
304
+ // tabs — a host reads it only where it has tabs to draw.
305
+ tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
306
+ // The surface an `attached` tab AND its content panel share. One value for
307
+ // both, so the join cannot come apart: whatever the current tab is painted,
308
+ // the page beneath it is painted too. An authored active surface therefore
309
+ // moves both. Resolved HERE rather than by each host, because a tab that
310
+ // fails to match the page it is joined to reads as a mismatched box instead
311
+ // of a tab, and two hosts computing it separately is exactly how that
312
+ // drifts. Opaque by construction: a translucent tab would show the bar
313
+ // through the page it claims to be part of.
314
+ contentSurface: hexOr(
315
+ opaqueOr(topBar.tabActiveBackgroundColor),
316
+ pageSurface(config),
317
+ ),
318
+ // REQ-NAV-STRUCTURE: the underline that marks the current tab, in px. Zero
319
+ // turns it OFF and leaves the tab marked by its label colour alone, which
320
+ // is a real choice rather than an unstyled state — so unlike the chrome
321
+ // divider, whose COLOUR is its switch, this one's width is. It takes
322
+ // `activeColor` above; a mark and its label disagreeing about which colour
323
+ // means "you are here" would be two marks, not one.
324
+ tabIndicatorWidth: tabIndicatorOr(topBar.tabIndicatorWidth),
325
+ // The tab's TOP corners. Its feet stay square regardless — see tabRadiusOr.
326
+ tabCornerRadius: tabRadiusOr(topBar.tabCornerRadius),
327
+ // The room inside a tab. Defaulted to what the row shipped with (12/8), so
328
+ // an app that never touches them is unchanged, and clamped rather than
329
+ // dropped for the same reason the radius is.
330
+ tabPaddingX: tabSpaceOr(topBar.tabPaddingX, DEFAULT_TAB_PADDING_X),
331
+ tabPaddingY: tabSpaceOr(topBar.tabPaddingY, DEFAULT_TAB_PADDING_Y),
332
+ // A tab's own surface, authored or nothing. Null means the tab paints none
333
+ // and the bar shows through it, which is what both styles did before the
334
+ // keys existed — so an app that never set them is unchanged.
335
+ tabBackgroundColor: hexOrNull(topBar.tabBackgroundColor),
336
+ // Only meaningful where the style draws no panel: under `attached` the
337
+ // active surface IS `contentSurface` above. Null under `underline` leaves
338
+ // the current tab unpainted, marked by its indicator alone.
339
+ tabActiveBackgroundColor: hexOrNull(topBar.tabActiveBackgroundColor),
206
340
  };
207
341
  }
208
342
 
@@ -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
  /**
@@ -128,6 +142,69 @@ const DEFAULT_CHROME_SURFACE = "#ffffff";
128
142
  const DEFAULT_CHROME_TEXT = "#475569";
129
143
  // The web mobile header's icon colour, `text-slate-700`.
130
144
  const DEFAULT_TOP_BAR_TINT = "#334155";
145
+ // The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
146
+ // (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
147
+ const DEFAULT_APP_SURFACE = "#f8fafc";
148
+
149
+ // An `attached` tab is joined to the page, so its surface must be OPAQUE: the
150
+ // 8-digit form a look uses to float a translucent rail would show the bar
151
+ // through the page the tab claims to be part of. Both hosts already drop alpha
152
+ // for the page itself (web `opaqueColor`, compiler `opaqueHex`); this is the
153
+ // same rule, in the one place the tab reads it from.
154
+ // The colour the PAGE actually shows where a tab meets it. A gradient owns the
155
+ // visible background wherever one is configured and the flat `backgroundColor`
156
+ // shows nowhere, so an attached tab matching the flat colour under a gradient is
157
+ // the mismatch this exists to avoid — it takes the gradient's START colour,
158
+ // which is what the page paints at the top edge the tab is joined to.
159
+ function pageSurface(config) {
160
+ const gradient = isPlainObject(config.backgroundGradient)
161
+ ? config.backgroundGradient
162
+ : null;
163
+ const from = gradient ? opaqueOr(gradient.from) : null;
164
+ return hexOr(from || opaqueOr(config.backgroundColor), DEFAULT_APP_SURFACE);
165
+ }
166
+
167
+ // The rail's raw block — the fallback every other chrome's active colour ends
168
+ // up at, so an app states its navigation colour once.
169
+ function sidebarBlock(config) {
170
+ return isPlainObject(config.sidebar) ? config.sidebar : {};
171
+ }
172
+
173
+ // REQ-NAV-STRUCTURE: the tab's TOP corners, in px. Zero is the default and the
174
+ // square tab the row shipped with; the feet stay square whatever this says,
175
+ // because a rounded foot notches the join. Clamped rather than dropped, so a
176
+ // value past the end lands on the end instead of silently reverting to square.
177
+ const TAB_RADIUS_MAX = 24;
178
+ function tabRadiusOr(value) {
179
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
180
+ return Math.min(Math.max(Math.round(value), 0), TAB_RADIUS_MAX);
181
+ }
182
+
183
+ // The room inside a tab, in px. The defaults are the row's shipped `px-3 py-2`.
184
+ const TAB_PADDING_MAX = 32;
185
+ const DEFAULT_TAB_PADDING_X = 12;
186
+ const DEFAULT_TAB_PADDING_Y = 8;
187
+ function tabSpaceOr(value, fallback) {
188
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
189
+ return Math.min(Math.max(Math.round(value), 0), TAB_PADDING_MAX);
190
+ }
191
+
192
+ // The current tab's underline, in px. Two is what the row shipped with; zero is
193
+ // off. Clamped rather than dropped, like the tab's other measurements.
194
+ const TAB_INDICATOR_MAX = 8;
195
+ const DEFAULT_TAB_INDICATOR = 2;
196
+ function tabIndicatorOr(value) {
197
+ if (typeof value !== "number" || !Number.isFinite(value)) {
198
+ return DEFAULT_TAB_INDICATOR;
199
+ }
200
+ return Math.min(Math.max(Math.round(value), 0), TAB_INDICATOR_MAX);
201
+ }
202
+
203
+ function opaqueOr(value) {
204
+ return isHexColor(value) && value.length !== 9 && value.length !== 5
205
+ ? value
206
+ : null;
207
+ }
131
208
 
132
209
  // The contract's own guard, which already admits the 8-digit form a look uses
133
210
  // to float a translucent rail (#RRGGBBAA).
@@ -192,8 +269,65 @@ export function resolveTopBarTokens(theme) {
192
269
  backgroundColor: hexOr(topBar.backgroundColor, DEFAULT_CHROME_SURFACE),
193
270
  tintColor: authored || DEFAULT_TOP_BAR_TINT,
194
271
  titleColor: authored || brandPrimary(config),
272
+ // REQ-NAV-STRUCTURE: the CURRENT page's mark — the active link's label, and
273
+ // an active tab's label plus its indicator. It read the brand directly
274
+ // before, which is right as a default and wrong as a rule: an attached tab
275
+ // wears the page's surface, and the brand that reads well on the bar can
276
+ // fail on the page.
277
+ //
278
+ // The fallback chain is the FOOTER's, deliberately: the bar's own value,
279
+ // else the RAIL's, else the brand. One app should not have to state the
280
+ // same navigation colour three times, and the rail is where an author
281
+ // already sets it — so the bar follows the app's navigation by default and
282
+ // departs from it only when asked. Unlike the footer's `pick`, this one
283
+ // ends in the brand rather than in null: an active mark has no null state.
284
+ activeColor: hexOr(
285
+ topBar.activeColor,
286
+ hexOr(sidebarBlock(config).activeColor, brandPrimary(config)),
287
+ ),
195
288
  borderColor,
196
289
  borderWidth: borderColor ? borderWidthOr(topBar.borderWidth) : null,
290
+ // REQ-NAV-STRUCTURE: how the tab row marks its current page.
291
+ // `underline` keeps the tab in the bar's surface and marks it with an
292
+ // indicator. `attached` makes it a real folder tab: it takes the CONTENT's
293
+ // surface and sits over the row's divider, so the tab and the page beneath
294
+ // read as one plane. Meaningless under the `links` style, which draws no
295
+ // tabs — a host reads it only where it has tabs to draw.
296
+ tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
297
+ // The surface an `attached` tab AND its content panel share. One value for
298
+ // both, so the join cannot come apart: whatever the current tab is painted,
299
+ // the page beneath it is painted too. An authored active surface therefore
300
+ // moves both. Resolved HERE rather than by each host, because a tab that
301
+ // fails to match the page it is joined to reads as a mismatched box instead
302
+ // of a tab, and two hosts computing it separately is exactly how that
303
+ // drifts. Opaque by construction: a translucent tab would show the bar
304
+ // through the page it claims to be part of.
305
+ contentSurface: hexOr(
306
+ opaqueOr(topBar.tabActiveBackgroundColor),
307
+ pageSurface(config),
308
+ ),
309
+ // REQ-NAV-STRUCTURE: the underline that marks the current tab, in px. Zero
310
+ // turns it OFF and leaves the tab marked by its label colour alone, which
311
+ // is a real choice rather than an unstyled state — so unlike the chrome
312
+ // divider, whose COLOUR is its switch, this one's width is. It takes
313
+ // `activeColor` above; a mark and its label disagreeing about which colour
314
+ // means "you are here" would be two marks, not one.
315
+ tabIndicatorWidth: tabIndicatorOr(topBar.tabIndicatorWidth),
316
+ // The tab's TOP corners. Its feet stay square regardless — see tabRadiusOr.
317
+ tabCornerRadius: tabRadiusOr(topBar.tabCornerRadius),
318
+ // The room inside a tab. Defaulted to what the row shipped with (12/8), so
319
+ // an app that never touches them is unchanged, and clamped rather than
320
+ // dropped for the same reason the radius is.
321
+ tabPaddingX: tabSpaceOr(topBar.tabPaddingX, DEFAULT_TAB_PADDING_X),
322
+ tabPaddingY: tabSpaceOr(topBar.tabPaddingY, DEFAULT_TAB_PADDING_Y),
323
+ // A tab's own surface, authored or nothing. Null means the tab paints none
324
+ // and the bar shows through it, which is what both styles did before the
325
+ // keys existed — so an app that never set them is unchanged.
326
+ tabBackgroundColor: hexOrNull(topBar.tabBackgroundColor),
327
+ // Only meaningful where the style draws no panel: under `attached` the
328
+ // active surface IS `contentSurface` above. Null under `underline` leaves
329
+ // the current tab unpainted, marked by its indicator alone.
330
+ tabActiveBackgroundColor: hexOrNull(topBar.tabActiveBackgroundColor),
197
331
  };
198
332
  }
199
333
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.119.0",
3
+ "version": "0.120.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",