@colixsystems/widget-sdk 0.143.0 → 0.145.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
@@ -71,10 +71,28 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
71
71
 
72
72
  ## Status
73
73
 
74
- `v0.143.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
+ `v0.145.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**.
75
75
 
76
- ### What's new in 0.143.0 (contract 1.111.0)
76
+ ### What's new in 0.145.0 (contract 1.113.0)
77
77
 
78
+ **The app's bottom navigation bar gets its own active-tab look — `resolveFooterTokens` gains three (sc-7951).** `theme_config.footer` already carried an `activeStyle`, but no Studio control ever wrote it, so the SIDEBAR's active-item style silently decided the bottom bar too: an author who accented the rail accented the tab bar, with no way to separate them. The bar was also under-authorable — `"filled"` *bundled* the active tab's surface with its label, pinning the surface to `activeColor` and the label to a hard-coded white, so no author could paint a surface of their own.
79
+
80
+ - **`footer.activeStyle` is now the bar's**, reachable from the Design page's Bottom navigation bar panel. Unset it still falls back to `sidebar.activeStyle`, so every existing workspace renders exactly as before.
81
+ - **`footer.tabActiveBackgroundColor`** — the active tab's surface. New, and it falls back to **nothing**: a vertical rail item has no tab surface, so unlike the colours this inherits from no one.
82
+ - **`footer.tabLayout`** — `"inline"` (the row the bar has always drawn) or `"stacked"`, the glyph above its label. Also footer-only.
83
+
84
+ `resolveFooterTokens` now returns the active tab **already decided** — `activeSurface` (hex or `null`) and `activeTint` — so neither host re-derives the back-compat pairing. `"filled"` with nothing named is still the active colour behind a white label; **naming a surface unbundles the two**, and the label goes back to `activeColor` so the author owns both halves. `activeColor` is resolved in the helper now rather than by each caller, so it is a `string` rather than raw-or-null — `FooterTokens.activeColor` narrows accordingly. The inherited colours are also hex-guarded on the way **out**, not only in: the export already refused a malformed stored value while the Player handed it to a style object.
85
+
86
+ It also settles a divergence the bar carried: under `"accent"` the web Player drew a **left** border (the rail's rule on a horizontal tab) and the export drew no edge at all. Both now mark the edge **facing the page** — a bottom tab's top — reserved transparent at every tab's width so marking one cannot shift the row. A tab is also the same 6px-rounded chip on both hosts, where native used to be square.
87
+
88
+ ### What's new in 0.144.0 (contract 1.112.0)
89
+
90
+ **The global mobile quick bar is retired — `quickBarMaxItems` and `quickBarCap` leave the contract (sc-7753, REQ-NAV-STRUCTURE / REQ-NAV-LOCAL).** The quick bar was a second global menu bolted to the sidebar: its own per-page flag, its own five-item cap, its own strip on the app's bottom edge. Since sc-7695 a page can carry a bottom navigation bar of its own drawing a saved menu, which is the same strip with an author-chosen list behind it — so the global one was a duplicate shape.
91
+
92
+ - `CONTRACT.themeMenuTypes.sidebar` is now `{ name, summary, maxItems }`. `menuItemCap` is unchanged; `quickBarCap` is **removed** — a page's bottom bar draws the menu the author composed, so there is no global number to state.
93
+ - `resolveFooterTokens` stays exactly as it is. It was never the quick bar's alone: it is the BOTTOM EDGE's token set, and the page's own bottom bar reads it, so a workspace's bottom-edge colours survive the retirement untouched.
94
+ - The sc-7753 backfill turns each workspace's quick-bar pages into a saved menu and points the pages that drew the strip at it BEFORE this ships, so no destination is lost.
95
+ - Host-integration surface only; no author-facing hook, prop or manifest field changed. `CONTRACT.version` → `1.112.0`.
78
96
  **A widget can name the screen it is on — `useScreenTitle(title)` + the optional `ctx.screen` slice (sc-7955).** The app header now shows the PAGE title beside the back control on a sub-page, instead of repeating the app brand on every screen. The page's own name is the default, but a page is often about one record, and only the widget has it:
79
97
 
80
98
  ```js
@@ -115,8 +133,8 @@ if (triggeredBy && triggeredBy.type === "app_user") {
115
133
 
116
134
  **The app's global navigation is the sidebar alone — `top-bar` and `bottom-tabs` leave `CONTRACT.themeMenuTypes` (sc-7352, REQ-NAV-STRUCTURE).** A top bar is now a PAGE's local bar drawing a saved menu (REQ-NAV-LOCAL, sc-7103): the same tab row the `top-bar` shape used to draw, themed by the same `topBar` tokens (`resolveTopBarTokens` is unchanged), worn by one page under the sidebar. Two "top bars" competing for one word and one token set was the reason.
117
135
 
118
- - `normaliseNavigation` resolves a stored `top-bar` or `bottom-tabs` to `sidebar`, so a host never has to guard the value. `menuItemCap` / `quickBarCap` keep their signatures (`quickBarCap("sidebar")` is still 5).
119
- - The global menu never lived in the shape (it is the pages' `show_in_menu` flags), so a workspace that stored a retired shape already draws the sidebar with the same menu. The sc-7352 backfill deletes the stored key BEFORE the shapes disappear; a bottom-tabs app's phone strip becomes the sidebar's quick bar.
136
+ - `normaliseNavigation` resolves a stored `top-bar` or `bottom-tabs` to `sidebar`, so a host never has to guard the value. `menuItemCap` / `quickBarCap` keep their signatures (`quickBarCap("sidebar")` is still 5). *(`quickBarCap` was removed in 0.142.0.)*
137
+ - The global menu never lived in the shape (it is the pages' `show_in_menu` flags), so a workspace that stored a retired shape already draws the sidebar with the same menu. The sc-7352 backfill deletes the stored key BEFORE the shapes disappear; a bottom-tabs app's phone strip becomes the sidebar's quick bar. *(That strip was itself retired in 0.142.0.)*
120
138
  - Host-integration surface only; no author-facing hook, prop or manifest field changed. `CONTRACT.version` → `1.109.0`.
121
139
 
122
140
  ### What's new in 0.140.0 (contract 1.108.0)
package/dist/contract.cjs CHANGED
@@ -220,16 +220,13 @@ const THEME_WIDGET_STYLES = Object.freeze({
220
220
  // REQ-NAV-STRUCTURE (sc-7352): the app's GLOBAL navigation has exactly one
221
221
  // shape, the sidebar. A top bar is a PAGE's local bar drawing a saved menu
222
222
  // (REQ-NAV-LOCAL), themed by the `topBar` tokens, never an app-wide menu shape.
223
- // `maxItems` is the menu cap (none for a rail); `quickBarMaxItems` caps the
224
- // SECONDARY mobile quick bar, which may drop a page because the rail and the
225
- // drawer still list every one.
223
+ // `maxItems` is the menu cap (none for a rail).
226
224
  const THEME_MENU_TYPES = Object.freeze({
227
225
  sidebar: Object.freeze({
228
226
  name: "Sidebar",
229
227
  summary:
230
- "A persistent left rail on desktop; a hamburger drawer plus the optional bottom quick bar on mobile.",
228
+ "A persistent left rail on desktop; a hamburger drawer on mobile.",
231
229
  maxItems: null,
232
- quickBarMaxItems: 5,
233
230
  }),
234
231
  });
235
232
  const THEME_SPACING_SCALE = Object.freeze({
@@ -4036,7 +4033,28 @@ const CONTRACT = deepFreeze({
4036
4033
  // integration identity; this describes the caller, it does not grant
4037
4034
  // anything. The same commit adds `properties` to this list, which the
4038
4035
  // action-sdk copy had carried since 1.50.0 while this one had not.
4039
- version: "1.111.0",
4036
+ // 1.112.0: BREAKING on the pre-1.0 channel (sc-7753) — `themeMenuTypes`
4037
+ // loses `quickBarMaxItems` and the `quickBarCap` host export goes with
4038
+ // it. The app's global mobile quick bar is retired: it was a SELECTION
4039
+ // over the global menu, and the rail and drawer list every menu page
4040
+ // regardless, so nothing was reachable only through it. The bottom edge
4041
+ // belongs to a PAGE's own bottom navigation bar (`local_nav = BOTTOM_BAR`,
4042
+ // REQ-NAV-LOCAL), chosen per page. `resolveFooterTokens` is unchanged —
4043
+ // it is the bottom edge's token set and the surviving bar reads it.
4044
+ // 1.113.0: additive (sc-7951) — `resolveFooterTokens` gains the bottom
4045
+ // bar's OWN three. `activeStyle` was on the wire but had no Studio
4046
+ // control, so the SIDEBAR's decided the strip too; `tabActiveBackgroundColor`
4047
+ // (the active tab's surface) and `tabLayout` (`inline` | `stacked`) are new
4048
+ // and fall back to nothing, because a vertical rail item has neither. The
4049
+ // resolver now also returns the active tab already DECIDED — `activeSurface`
4050
+ // and `activeTint` — so neither host re-derives the back-compat pairing
4051
+ // ("filled" with nothing named is the active colour behind a white label);
4052
+ // naming a surface unbundles the two. `activeColor` is resolved here now
4053
+ // rather than by each caller, so it is a string rather than raw-or-null,
4054
+ // and the inherited colours are hex-guarded on the way out as well as in.
4055
+ // Every colour still falls back to the sidebar's: an untouched workspace is
4056
+ // unchanged.
4057
+ version: "1.113.0",
4040
4058
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4041
4059
  hooks: HOOKS,
4042
4060
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -220,16 +220,13 @@ const THEME_WIDGET_STYLES = Object.freeze({
220
220
  // REQ-NAV-STRUCTURE (sc-7352): the app's GLOBAL navigation has exactly one
221
221
  // shape, the sidebar. A top bar is a PAGE's local bar drawing a saved menu
222
222
  // (REQ-NAV-LOCAL), themed by the `topBar` tokens, never an app-wide menu shape.
223
- // `maxItems` is the menu cap (none for a rail); `quickBarMaxItems` caps the
224
- // SECONDARY mobile quick bar, which may drop a page because the rail and the
225
- // drawer still list every one.
223
+ // `maxItems` is the menu cap (none for a rail).
226
224
  const THEME_MENU_TYPES = Object.freeze({
227
225
  sidebar: Object.freeze({
228
226
  name: "Sidebar",
229
227
  summary:
230
- "A persistent left rail on desktop; a hamburger drawer plus the optional bottom quick bar on mobile.",
228
+ "A persistent left rail on desktop; a hamburger drawer on mobile.",
231
229
  maxItems: null,
232
- quickBarMaxItems: 5,
233
230
  }),
234
231
  });
235
232
  const THEME_SPACING_SCALE = Object.freeze({
@@ -4032,7 +4029,28 @@ const CONTRACT = deepFreeze({
4032
4029
  // integration identity; this describes the caller, it does not grant
4033
4030
  // anything. The same commit adds `properties` to this list, which the
4034
4031
  // action-sdk copy had carried since 1.50.0 while this one had not.
4035
- version: "1.111.0",
4032
+ // 1.112.0: BREAKING on the pre-1.0 channel (sc-7753) — `themeMenuTypes`
4033
+ // loses `quickBarMaxItems` and the `quickBarCap` host export goes with
4034
+ // it. The app's global mobile quick bar is retired: it was a SELECTION
4035
+ // over the global menu, and the rail and drawer list every menu page
4036
+ // regardless, so nothing was reachable only through it. The bottom edge
4037
+ // belongs to a PAGE's own bottom navigation bar (`local_nav = BOTTOM_BAR`,
4038
+ // REQ-NAV-LOCAL), chosen per page. `resolveFooterTokens` is unchanged —
4039
+ // it is the bottom edge's token set and the surviving bar reads it.
4040
+ // 1.113.0: additive (sc-7951) — `resolveFooterTokens` gains the bottom
4041
+ // bar's OWN three. `activeStyle` was on the wire but had no Studio
4042
+ // control, so the SIDEBAR's decided the strip too; `tabActiveBackgroundColor`
4043
+ // (the active tab's surface) and `tabLayout` (`inline` | `stacked`) are new
4044
+ // and fall back to nothing, because a vertical rail item has neither. The
4045
+ // resolver now also returns the active tab already DECIDED — `activeSurface`
4046
+ // and `activeTint` — so neither host re-derives the back-compat pairing
4047
+ // ("filled" with nothing named is the active colour behind a white label);
4048
+ // naming a surface unbundles the two. `activeColor` is resolved here now
4049
+ // rather than by each caller, so it is a string rather than raw-or-null,
4050
+ // and the inherited colours are hex-guarded on the way out as well as in.
4051
+ // Every colour still falls back to the sidebar's: an untouched workspace is
4052
+ // unchanged.
4053
+ version: "1.113.0",
4036
4054
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4037
4055
  hooks: HOOKS,
4038
4056
  primitives: PRIMITIVES,
package/dist/host.d.ts CHANGED
@@ -173,28 +173,35 @@ export function normaliseNavigation(navigation: unknown): ResolvedNavigation;
173
173
  */
174
174
  export function menuItemCap(menuType: string): number | null;
175
175
 
176
- /**
177
- * How many pages the SECONDARY mobile quick bar draws, or `null` where the shape
178
- * draws none (only `sidebar` has one). Unlike `menuItemCap` this cap MAY drop a
179
- * page: the rail and drawer still list every menu page.
180
- */
181
- export function quickBarCap(menuType: string): number | null;
182
-
183
- /** The footer strip's resolved tokens. A `null` colour means the host keeps its
184
- * own default; a `null` `borderColor` means no divider is drawn at all. */
176
+ /** The app's bottom edge — a page's own bottom navigation bar. A `null` colour
177
+ * means the host keeps its own default; a `null` `borderColor` means no
178
+ * divider is drawn at all. */
185
179
  export interface FooterTokens {
186
180
  backgroundColor: string | null;
187
181
  textColor: string | null;
188
- activeColor: string | null;
182
+ /** Never `null`: an unset value resolves to the brand primary here rather
183
+ * than in each host, which is what lets the two active-tab fields below be
184
+ * resolved here at all. */
185
+ activeColor: string;
189
186
  borderColor: string | null;
190
187
  borderWidth: number | null;
191
188
  activeStyle: "filled" | "accent";
189
+ /** Whether a tab's glyph sits beside its label or above it (sc-7951). */
190
+ tabLayout: "inline" | "stacked";
191
+ /** The active tab's surface, or `null` for none. Under "filled" with nothing
192
+ * named this is the active colour — what both hosts shipped. */
193
+ activeSurface: string | null;
194
+ /** The active tab's label and glyph. White only under an unnamed "filled"
195
+ * surface, which is the pairing both hosts hard-coded. */
196
+ activeTint: string;
192
197
  }
193
198
 
194
199
  /**
195
- * Resolves the footer strip's tokens from a whole `theme_config`. Every field
196
- * falls back to the sidebar's, so a workspace that never opens the Footer panel
197
- * renders exactly as it did before the block existed.
200
+ * Resolves the footer strip's tokens from a whole `theme_config`. Every colour
201
+ * falls back to the sidebar's, so a workspace that never opens the Bottom
202
+ * navigation bar panel renders exactly as it did before the block existed.
203
+ * `tabActiveBackgroundColor` and `tabLayout` are the strip's own and inherit
204
+ * from nothing — a vertical rail item has neither.
198
205
  */
199
206
  export function resolveFooterTokens(theme: unknown): FooterTokens;
200
207
 
package/dist/host.js CHANGED
@@ -55,18 +55,17 @@ export {
55
55
  // REQ-NAV-STRUCTURE: the navigation resolver both hosts draw their chrome from.
56
56
  // `normaliseNavigation` turns a stored `theme_config.navigation` block into the
57
57
  // resolved `{ menuType }` — always `sidebar` since sc-7352 retired the other
58
- // shapes, so a stored value never needs guarding; `menuItemCap` / `quickBarCap`
59
- // state the caps. One implementation, so the chrome cannot mean one thing in
60
- // the Player and another in the Expo export. `resolveFooterTokens` is the quick
61
- // bar's own token set, falling back to the sidebar's so an untouched workspace
62
- // keeps the appearance it has.
58
+ // shapes, so a stored value never needs guarding; `menuItemCap` states the cap.
59
+ // One implementation, so the chrome cannot mean one thing in the Player and
60
+ // another in the Expo export. `resolveFooterTokens` is the BOTTOM EDGE's token
61
+ // set — a page's own bottom navigation bar (REQ-NAV-LOCAL) — falling back to
62
+ // the sidebar's so an untouched workspace keeps the appearance it has.
63
63
  // REQ-THEME-14: `resolveSidebarTokens` and `resolveTopBarTokens` are the same
64
64
  // idea for the rail and the app bar, whose defaults used to live inline in each
65
65
  // host and had already drifted apart.
66
66
  export {
67
67
  normaliseNavigation,
68
68
  menuItemCap,
69
- quickBarCap,
70
69
  resolveFooterTokens,
71
70
  resolveSidebarTokens,
72
71
  resolveTopBarTokens,
@@ -60,7 +60,7 @@ function normaliseNavigation(navigation) {
60
60
 
61
61
  /**
62
62
  * How many MENU pages the chrome may draw at once, or `null` for no cap. The
63
- * sidebar has none. Contrast `quickBarCap` below.
63
+ * sidebar has none.
64
64
  *
65
65
  * @param {string} menuType — a resolved menu type.
66
66
  * @returns {number|null}
@@ -70,68 +70,75 @@ function menuItemCap(menuType) {
70
70
  return entry ? entry.maxItems : null;
71
71
  }
72
72
 
73
- /**
74
- * How many pages the SECONDARY mobile quick bar draws, or `null` where that
75
- * shape draws none. Only the `sidebar` shape has one.
76
- *
77
- * This cap MAY drop a page, and that is the difference from `menuItemCap`: the
78
- * rail and the drawer still list every menu page, so the bar is a shortcut
79
- * rather than the menu. It lived as a constant on one host and a literal on the
80
- * other before this, which is exactly how the two would have drifted.
81
- *
82
- * @param {string} menuType — a resolved menu type.
83
- * @returns {number|null}
84
- */
85
- function quickBarCap(menuType) {
86
- const entry = CONTRACT.themeMenuTypes[menuType];
87
- return entry ? entry.quickBarMaxItems : null;
88
- }
89
-
90
- // The strip's surface was hard-coded white on BOTH hosts and its items read the
91
- // sidebar's tokens. `footer` gives the quick bar tokens of its own.
73
+ // REQ-NAV-LOCAL: the app's BOTTOM EDGE — the tokens a page's own bottom
74
+ // navigation bar is drawn from. Every colour falls back to the sidebar's, so a
75
+ // workspace that never opens the panel keeps the appearance it has.
92
76
  //
93
- // Every colour falls back to the sidebar's, so a workspace that never opens the
94
- // panel keeps exactly the appearance it has today; only an explicit `footer`
95
- // value moves anything.
77
+ // sc-7753: the global quick bar these tokens were introduced for is retired;
78
+ // the bar that reads them now is the page's own (`local_nav = BOTTOM_BAR`).
96
79
  //
97
- // sc-7695: this block is the BOTTOM EDGE's, not the quick bar's alone. A page's
98
- // own bottom bar (REQ-NAV-LOCAL) is the same strip worn by one page and reads
99
- // exactly these values — the Design panel that edits them is named for that
100
- // bar, since the quick bar is on its way out (sc-7753) and the look outlives
101
- // it. No key is the bar's alone: two strips, one look.
80
+ // sc-7951: three of the keys ARE that bar's own — the active-item style it
81
+ // used to borrow from the rail for want of a control, the active tab's
82
+ // surface, and whether the glyph sits beside the label or above it.
102
83
  function resolveFooterTokens(theme) {
103
84
  const config = isPlainObject(theme) ? theme : {};
104
85
  const footer = isPlainObject(config.footer) ? config.footer : {};
105
86
  const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
106
- const pick = (key) => footer[key] || sidebar[key] || null;
87
+ // Hex-guarded on the way out, not merely on the way in: `theme_config` is an
88
+ // opaque blob an operator can hand-edit, and the EXPORT already refused a
89
+ // malformed colour here while the Player passed it to a style object — so the
90
+ // guard is what stops the two hosts painting a stored junk value differently.
91
+ const pick = (key) => hexOrNull(footer[key]) || hexOrNull(sidebar[key]) || null;
107
92
  const borderColor = pick("borderColor");
93
+ // sc-7951: the rail's style is the FALLBACK now, not the only source. Unset
94
+ // still follows the rail, which is what keeps every shipped app where it is.
108
95
  const activeStyle = footer.activeStyle || sidebar.activeStyle;
96
+ const accent = activeStyle === "accent";
97
+ const activeColor = hexOr(pick("activeColor"), brandPrimary(config));
98
+ // Footer-only: a rail item is a full-width row with no tab surface to paint,
99
+ // so this inherits from nothing and unset means "no surface of its own".
100
+ const namedSurface = hexOrNull(footer.tabActiveBackgroundColor);
109
101
  return {
110
102
  backgroundColor: pick("backgroundColor"),
111
103
  textColor: pick("textColor"),
112
- // Unlike the others this has no null state on either host: an unset active
113
- // colour resolves to the brand primary, which the caller supplies.
114
- activeColor: pick("activeColor"),
104
+ // Resolved here rather than by each caller, which is what lets the two
105
+ // active-tab fields below be resolved here too.
106
+ activeColor,
115
107
  // REQ-THEME-LOOK: the divider's COLOUR is its switch. Unset draws no line at
116
- // all — which the native quick bar did not honour before this, ruling a
117
- // hairline across a tinted strip that had asked for none.
108
+ // all — which the native strip did not honour before this, ruling a
109
+ // hairline across a tinted bar that had asked for none.
118
110
  borderColor,
119
111
  borderWidth: borderColor ? borderWidthOr(footer.borderWidth, sidebar.borderWidth) : null,
120
- activeStyle: activeStyle === "accent" ? "accent" : "filled",
112
+ activeStyle: accent ? "accent" : "filled",
113
+ // sc-7951: bottom tabs are the only strip tall enough to stack the two, so
114
+ // the choice is the footer's alone.
115
+ tabLayout: footer.tabLayout === "stacked" ? "stacked" : "inline",
116
+ // sc-7951: the active tab's two halves, resolved HERE so neither host
117
+ // re-derives the back-compat rule. "filled" with nothing named is what both
118
+ // hosts shipped — the active colour as the fill, the label forced white —
119
+ // and that pairing is the only reason a marked tab's label is not simply
120
+ // the active colour. Naming a surface unbundles them: the surface is the
121
+ // author's and the label goes back to the active colour.
122
+ activeSurface: namedSurface || (accent ? null : activeColor),
123
+ activeTint: accent || namedSurface ? activeColor : FILLED_TAB_LABEL,
121
124
  };
122
125
  }
123
126
 
124
127
  // REQ-THEME-14 — the rail's and the app bar's tokens, resolved once for both
125
128
  // hosts.
126
129
  //
127
- // These return FULLY resolved values where `resolveFooterTokens` above returns
128
- // raw-or-null, and the difference is deliberate. The footer's two hosts happen
129
- // to agree on their separately-written defaults; the rail's and the bar's did
130
- // not. An unset `sidebar.backgroundColor` painted #ffffff on web and the app
130
+ // These return FULLY resolved values where `resolveFooterTokens` above still
131
+ // returns raw-or-null for its SURFACE, LABEL and DIVIDER colours, and the
132
+ // difference is deliberate. The footer's two hosts happen to agree on their
133
+ // separately-written defaults for those three (`#ffffff` / `#475569` / no
134
+ // line); the rail's and the bar's did not. sc-7951 resolved the footer's active
135
+ // tab — `activeColor`, `activeSurface`, `activeTint` — because a rule that
136
+ // pairs a surface with a label cannot live in two hosts; the three above are
137
+ // what remains of the old shape. An unset `sidebar.backgroundColor` painted #ffffff on web and the app
131
138
  // background in the export, and an unset `topBar.textColor` painted the brand
132
139
  // primary on web and slate-700 in the export. A default that lives in the
133
- // resolver cannot drift; one that lives in each host already had. The footer
134
- // wants this same shape next — the same fix, not yet applied.
140
+ // resolver cannot drift; one that lives in each host already had. The footer's
141
+ // three remaining raw colours want this same shape next.
135
142
  //
136
143
  // Every default is what the WEB Player renders today, so the Player does not
137
144
  // move and the export converges onto it. That is the direction the footer was
@@ -149,6 +156,9 @@ const DEFAULT_TOP_BAR_TINT = "#334155";
149
156
  // The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
150
157
  // (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
151
158
  const DEFAULT_APP_SURFACE = "#f8fafc";
159
+ // The literal both hosts hard-coded behind an unnamed "filled" surface, before
160
+ // resolveFooterTokens decided the pairing once (sc-7951).
161
+ const FILLED_TAB_LABEL = "#ffffff";
152
162
 
153
163
  // A gradient shows only when BOTH stops are a flat hex — the rule both hosts'
154
164
  // page readers apply (`normaliseThemeGradient`, mirrored frontend/backend). A
@@ -362,4 +372,4 @@ function borderWidthOr(...values) {
362
372
  return 1;
363
373
  }
364
374
 
365
- module.exports = { normaliseNavigation, menuItemCap, quickBarCap, resolveFooterTokens, resolveSidebarTokens, resolveTopBarTokens };
375
+ module.exports = { normaliseNavigation, menuItemCap, resolveFooterTokens, resolveSidebarTokens, resolveTopBarTokens };
@@ -51,7 +51,7 @@ export function normaliseNavigation(navigation) {
51
51
 
52
52
  /**
53
53
  * How many MENU pages the chrome may draw at once, or `null` for no cap. The
54
- * sidebar has none. Contrast `quickBarCap` below.
54
+ * sidebar has none.
55
55
  *
56
56
  * @param {string} menuType — a resolved menu type.
57
57
  * @returns {number|null}
@@ -61,68 +61,75 @@ export function menuItemCap(menuType) {
61
61
  return entry ? entry.maxItems : null;
62
62
  }
63
63
 
64
- /**
65
- * How many pages the SECONDARY mobile quick bar draws, or `null` where that
66
- * shape draws none. Only the `sidebar` shape has one.
67
- *
68
- * This cap MAY drop a page, and that is the difference from `menuItemCap`: the
69
- * rail and the drawer still list every menu page, so the bar is a shortcut
70
- * rather than the menu. It lived as a constant on one host and a literal on the
71
- * other before this, which is exactly how the two would have drifted.
72
- *
73
- * @param {string} menuType — a resolved menu type.
74
- * @returns {number|null}
75
- */
76
- export function quickBarCap(menuType) {
77
- const entry = CONTRACT.themeMenuTypes[menuType];
78
- return entry ? entry.quickBarMaxItems : null;
79
- }
80
-
81
- // The strip's surface was hard-coded white on BOTH hosts and its items read the
82
- // sidebar's tokens. `footer` gives the quick bar tokens of its own.
64
+ // REQ-NAV-LOCAL: the app's BOTTOM EDGE — the tokens a page's own bottom
65
+ // navigation bar is drawn from. Every colour falls back to the sidebar's, so a
66
+ // workspace that never opens the panel keeps the appearance it has.
83
67
  //
84
- // Every colour falls back to the sidebar's, so a workspace that never opens the
85
- // panel keeps exactly the appearance it has today; only an explicit `footer`
86
- // value moves anything.
68
+ // sc-7753: the global quick bar these tokens were introduced for is retired;
69
+ // the bar that reads them now is the page's own (`local_nav = BOTTOM_BAR`).
87
70
  //
88
- // sc-7695: this block is the BOTTOM EDGE's, not the quick bar's alone. A page's
89
- // own bottom bar (REQ-NAV-LOCAL) is the same strip worn by one page and reads
90
- // exactly these values — the Design panel that edits them is named for that
91
- // bar, since the quick bar is on its way out (sc-7753) and the look outlives
92
- // it. No key is the bar's alone: two strips, one look.
71
+ // sc-7951: three of the keys ARE that bar's own — the active-item style it
72
+ // used to borrow from the rail for want of a control, the active tab's
73
+ // surface, and whether the glyph sits beside the label or above it.
93
74
  export function resolveFooterTokens(theme) {
94
75
  const config = isPlainObject(theme) ? theme : {};
95
76
  const footer = isPlainObject(config.footer) ? config.footer : {};
96
77
  const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
97
- const pick = (key) => footer[key] || sidebar[key] || null;
78
+ // Hex-guarded on the way out, not merely on the way in: `theme_config` is an
79
+ // opaque blob an operator can hand-edit, and the EXPORT already refused a
80
+ // malformed colour here while the Player passed it to a style object — so the
81
+ // guard is what stops the two hosts painting a stored junk value differently.
82
+ const pick = (key) => hexOrNull(footer[key]) || hexOrNull(sidebar[key]) || null;
98
83
  const borderColor = pick("borderColor");
84
+ // sc-7951: the rail's style is the FALLBACK now, not the only source. Unset
85
+ // still follows the rail, which is what keeps every shipped app where it is.
99
86
  const activeStyle = footer.activeStyle || sidebar.activeStyle;
87
+ const accent = activeStyle === "accent";
88
+ const activeColor = hexOr(pick("activeColor"), brandPrimary(config));
89
+ // Footer-only: a rail item is a full-width row with no tab surface to paint,
90
+ // so this inherits from nothing and unset means "no surface of its own".
91
+ const namedSurface = hexOrNull(footer.tabActiveBackgroundColor);
100
92
  return {
101
93
  backgroundColor: pick("backgroundColor"),
102
94
  textColor: pick("textColor"),
103
- // Unlike the others this has no null state on either host: an unset active
104
- // colour resolves to the brand primary, which the caller supplies.
105
- activeColor: pick("activeColor"),
95
+ // Resolved here rather than by each caller, which is what lets the two
96
+ // active-tab fields below be resolved here too.
97
+ activeColor,
106
98
  // REQ-THEME-LOOK: the divider's COLOUR is its switch. Unset draws no line at
107
- // all — which the native quick bar did not honour before this, ruling a
108
- // hairline across a tinted strip that had asked for none.
99
+ // all — which the native strip did not honour before this, ruling a
100
+ // hairline across a tinted bar that had asked for none.
109
101
  borderColor,
110
102
  borderWidth: borderColor ? borderWidthOr(footer.borderWidth, sidebar.borderWidth) : null,
111
- activeStyle: activeStyle === "accent" ? "accent" : "filled",
103
+ activeStyle: accent ? "accent" : "filled",
104
+ // sc-7951: bottom tabs are the only strip tall enough to stack the two, so
105
+ // the choice is the footer's alone.
106
+ tabLayout: footer.tabLayout === "stacked" ? "stacked" : "inline",
107
+ // sc-7951: the active tab's two halves, resolved HERE so neither host
108
+ // re-derives the back-compat rule. "filled" with nothing named is what both
109
+ // hosts shipped — the active colour as the fill, the label forced white —
110
+ // and that pairing is the only reason a marked tab's label is not simply
111
+ // the active colour. Naming a surface unbundles them: the surface is the
112
+ // author's and the label goes back to the active colour.
113
+ activeSurface: namedSurface || (accent ? null : activeColor),
114
+ activeTint: accent || namedSurface ? activeColor : FILLED_TAB_LABEL,
112
115
  };
113
116
  }
114
117
 
115
118
  // REQ-THEME-14 — the rail's and the app bar's tokens, resolved once for both
116
119
  // hosts.
117
120
  //
118
- // These return FULLY resolved values where `resolveFooterTokens` above returns
119
- // raw-or-null, and the difference is deliberate. The footer's two hosts happen
120
- // to agree on their separately-written defaults; the rail's and the bar's did
121
- // not. An unset `sidebar.backgroundColor` painted #ffffff on web and the app
121
+ // These return FULLY resolved values where `resolveFooterTokens` above still
122
+ // returns raw-or-null for its SURFACE, LABEL and DIVIDER colours, and the
123
+ // difference is deliberate. The footer's two hosts happen to agree on their
124
+ // separately-written defaults for those three (`#ffffff` / `#475569` / no
125
+ // line); the rail's and the bar's did not. sc-7951 resolved the footer's active
126
+ // tab — `activeColor`, `activeSurface`, `activeTint` — because a rule that
127
+ // pairs a surface with a label cannot live in two hosts; the three above are
128
+ // what remains of the old shape. An unset `sidebar.backgroundColor` painted #ffffff on web and the app
122
129
  // background in the export, and an unset `topBar.textColor` painted the brand
123
130
  // primary on web and slate-700 in the export. A default that lives in the
124
- // resolver cannot drift; one that lives in each host already had. The footer
125
- // wants this same shape next — the same fix, not yet applied.
131
+ // resolver cannot drift; one that lives in each host already had. The footer's
132
+ // three remaining raw colours want this same shape next.
126
133
  //
127
134
  // Every default is what the WEB Player renders today, so the Player does not
128
135
  // move and the export converges onto it. That is the direction the footer was
@@ -140,6 +147,9 @@ const DEFAULT_TOP_BAR_TINT = "#334155";
140
147
  // The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
141
148
  // (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
142
149
  const DEFAULT_APP_SURFACE = "#f8fafc";
150
+ // The literal both hosts hard-coded behind an unnamed "filled" surface, before
151
+ // resolveFooterTokens decided the pairing once (sc-7951).
152
+ const FILLED_TAB_LABEL = "#ffffff";
143
153
 
144
154
  // A gradient shows only when BOTH stops are a flat hex — the rule both hosts'
145
155
  // page readers apply (`normaliseThemeGradient`, mirrored frontend/backend). A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.143.0",
3
+ "version": "0.145.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",