@colixsystems/widget-sdk 0.150.1 → 0.152.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,7 +72,28 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
72
72
 
73
73
  ## Status
74
74
 
75
- `v0.150.1` — 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
+ `v0.152.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**.
76
+
77
+ ### What's new in 0.152.0 (contract 1.118.0 — unchanged)
78
+
79
+ **The app header's row is arrangeable, and `./host` publishes the resolver both hosts read (sc-8023).** The row runs across the top of the content outside the side panel, and is drawn three times — as the phone header where the panel is a drawer, as the desktop strip, and as the full-width bar when one is switched on. Each surface hard-coded its own copy of it, so an author could colour the row but never move anything in it.
80
+
81
+ - **`resolveTopStripeLayout(theme)`** reads `theme.sidebar.stripe` and returns the arrangement fully resolved: `slots` (already filtered and sequenced, so no host re-derives it), `order`, the box (`height`, `paddingX`, `contentOffsetY`), the resolved `backgroundColor`, and `menuSide` — which edge the drawer must arrive from, since the brand grows and a burger ordered after it sits at the trailing edge. The web Player picks the drawer's side with it; the export makes it `drawerPosition`.
82
+ - **`TOP_STRIPE_SLOTS`** is the vocabulary (`back`, `menu`, `brand`, `notifications`) and **`TOP_STRIPE_ALWAYS_SHOWN_SLOTS`** the slots that move but never hide. The burger is the only member: it is drawn only where the panel is a drawer, and there it is the sole route into the menu, so hiding it would strand the visitor. It keeps its place in `order` regardless.
83
+ - **`topStripeShowKey(slot)`** derives the `theme_config` key that shows or hides one slot, in one place, so a control, the write path and the resolver cannot spell it differently.
84
+ - **The bounds travel with the resolver** — `MIN`/`MAX_TOP_STRIPE_HEIGHT_PX`, `MIN`/`MAX_TOP_STRIPE_PADDING_X_PX`, `MAX_TOP_STRIPE_OFFSET_Y_PX` — so a control offers exactly the range the resolver clamps to. `DEFAULT_TOP_STRIPE_HEIGHT_PX` / `DEFAULT_TOP_STRIPE_PADDING_X_PX` are what the DESKTOP row measures with nothing stored, published so a control can show the number an author is departing from; the resolver still answers `null`, because each surface keeps its own box until one is named.
85
+
86
+ Additive — nothing in the existing `./host` surface changed, and the contract is untouched.
87
+
88
+ ### What's new in 0.151.0 (contract 1.118.0 — unchanged)
89
+
90
+ **`resolveSidebarUnfold` joins the host navigation surface: where a page's local menu unfolds inside the app's own sidebar (sc-7696, REQ-NAV-LOCAL).** A page's local navigation could only be a bar, above or below its content. `local_nav` now also takes `SIDEBAR`, which takes no strip of the page: the saved menu unfolds in the rail and the drawer, under the global menu entry it belongs to.
91
+
92
+ - `resolveSidebarUnfold({ entries, active, sections })` answers `{ under, items, standalone }` from host-neutral keys — page slugs on web, screen names in the export — so it is the ONE derivation both chromes read. Web calls it per render; the compiler asks it once per screen at build time, because the only runtime input is which screen is showing.
93
+ - A page belongs to a section in either of two ways, and both keep the tree open: it carries the model, or the section's menu lists it. Clicking a destination must not collapse the menu that led there, and an author should not have to repeat the setting on every sub-page.
94
+ - `standalone` names the case where the global menu carries no page of the section: the section's own page is the entry, for as long as the visitor is inside it, so the menu is never lost.
95
+ - Each returned item carries `current`, so neither host recomputes which destination is the visitor's.
96
+ - **`CONTRACT` is untouched** (`version` stays `1.118.0`): this is a host resolver, not a widget-facing surface, and no existing export changed. `resolveTopBarTokens`, `resolveFooterTokens` and `resolveSidebarTokens` are unchanged — an unfolded row is a sidebar row and wears the rail's own tokens.
76
97
 
77
98
  ### What's new in 0.150.1 (contract 1.118.0 — unchanged)
78
99
 
@@ -231,6 +252,18 @@ All three are `warning`, so `appstudio-widget lint` reports them and still exits
231
252
 
232
253
  `lintEditability(manifest, files)` is bundle-level like `lintStyleWiring`, and needs `--manifest` for the same reason. It counts rendered elements PER FILE, though: joining `widget.web.jsx` and `widget.native.jsx` would double every tag and call a single-`<Text>` split-impl widget multi-element.
233
254
 
255
+ ### What's new in 0.151.0 (contract 1.119.0)
256
+
257
+ **A container's gradient wash can bind its stops to the theme (sc-8326).** sc-8197 gave a layout container's flat fill a theme-following `surface` role, but a two-stop `backgroundGradient` had no role form — so a generated colour-band opener could only freeze two literals out of whichever palette was active when the app was built, and it stopped following the app's own look from then on.
258
+
259
+ Each stop of a `backgroundGradient` may now be a binding — `"theme:colors.primary"` or `"theme:colors.secondary"` — as well as a hex.
260
+
261
+ - **`isThemeColorTokenRef(value)` and `THEME_TOKEN_REF_PREFIX`** are exported from the contract: the one grammar predicate the Studio's writer, both hosts' resolvers and Mason's plan validator read, so what may be written and what will resolve cannot drift.
262
+ - **`resolveGradientTokenStops(gradient, theme)`** is the host-integration call behind it, exported from `@colixsystems/widget-sdk/host`. A layout container's style never reaches `resolveThemeTokenRefs` — containers are painted by the page renderer, not by WidgetHost — so this is its own boundary call. Widget authors never call it.
263
+ - **The hex grammar is still the injection guard.** Stops resolve *before* `normaliseComponentGradient`, so what reaches CSS and generated export source is unchanged, and an unresolvable binding fails that guard and drops the wash rather than painting blank.
264
+ - **Nothing changes for a widget author.** A `styleSchema` gradient field already resolved bindings through `resolveThemeTokenRefs`; this closes the container half.
265
+ - **Fully additive.** Every stored hex gradient is untouched and renders byte-identically.
266
+
234
267
  ### What's new in 0.139.0 (contract 1.108.0)
235
268
 
236
269
  **An author can BIND a colour field to the workspace brand instead of freezing a hex (sc-7800).** A `type: "color"` field tracked the theme only while it was *unset*, through its `themeDefault` placeholder. The moment the author picked a colour it became a literal that outranked the theme forever, so "make this our brand colour" was inexpressible — the author had to type today's hex, and it went stale silently at the next rebrand.
@@ -1687,8 +1720,8 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
1687
1720
  - **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
1688
1721
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
1689
1722
  - **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
1690
- - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget.
1691
- - **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
1723
+ - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead. **A metric / stat-tile row WRAPS** — it is a card grid with smaller cells (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }` plus `maxWidth: TILE_MIN * 1.6`), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
1724
+ - **Compose forms — pair fields into rows, don't stack one per row.** Cap the form column itself around 560–720px and centre it with the three declarations above, so it neither stretches across a full-width desktop section nor sits flush left with half the canvas empty beside it. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
1692
1725
  - **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
1693
1726
  - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` below `isNarrowWidth`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
1694
1727
  - **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
package/dist/contract.cjs CHANGED
@@ -192,6 +192,20 @@ const THEME_COLOR_TOKENS = Object.freeze([
192
192
  Object.freeze({ path: "colors.secondary", label: "Secondary" }),
193
193
  ]);
194
194
 
195
+ // sc-8326 — the `theme:<path>` value grammar, as a PREDICATE over a STORED
196
+ // value and declared beside the allowlist it reads. Three readers have to agree
197
+ // on what a binding IS: the Studio's colour picker writes one, both hosts
198
+ // resolve one, and Mason's plan validator must accept one as a gradient stop.
199
+ // One definition, so they cannot drift (CLAUDE.md §3).
200
+ const THEME_TOKEN_REF_PREFIX = "theme:";
201
+
202
+ function isThemeColorTokenRef(value) {
203
+ if (typeof value !== "string") return false;
204
+ if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return false;
205
+ const path = value.slice(THEME_TOKEN_REF_PREFIX.length);
206
+ return THEME_COLOR_TOKENS.some((token) => token.path === path);
207
+ }
208
+
195
209
 
196
210
  // REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
197
211
  // single lever: remapping `primaryColor` to recolour "the buttons" recolours
@@ -2961,7 +2975,7 @@ const VETTED_IMPORTS = [
2961
2975
  platforms: ["native"],
2962
2976
  category: "sensors",
2963
2977
  description:
2964
- "Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx and pair it with a widget.web.jsx reading window.DeviceMotionEvent (accelerationIncludingGravity / rotationRate), the browser API the same hardware exposes. Both hosts need a user gesture before readings start, and iOS Safari additionally needs an explicit DeviceMotionEvent.requestPermission() grant — so gate the reading behind a Pressable, never start it on mount.",
2978
+ "Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx. There is NO web path through this package OR through the browser motion API: the browser exposes it as window.DeviceMotionEvent, and `window` is a BANNED widget identifier (see BANNED_APIS), so a widget.web.jsx reading it is rejected by the linter. Give the web half a non-sensor way in instead — manual entry, or a control that does not need motion. On native, readings still need a user gesture, so gate them behind a Pressable and never start them on mount.",
2965
2979
  },
2966
2980
  {
2967
2981
  specifier: "react-native-nfc-manager",
@@ -4264,7 +4278,17 @@ const CONTRACT = deepFreeze({
4264
4278
  // because Newsfeed and Chat both render per-emoji pills over a Reactions
4265
4279
  // table, and the legacy slug mapping (`like` -> emoji) must not be
4266
4280
  // re-derived per widget.
4267
- version: "1.118.0",
4281
+ // 1.119.0: additive (sc-8326) — a component gradient's `from` / `to` stops
4282
+ // accept a `theme:<path>` binding as well as a hex, with
4283
+ // `isThemeColorTokenRef` as the shared grammar predicate. `surface` gave a
4284
+ // container's FLAT fill a theme-following role in sc-8197, but a two-stop
4285
+ // wash had no role form, so Mason's colour-band opener could only freeze
4286
+ // two literals out of the creation-time palette and the band stopped
4287
+ // following the app's own theme. Additive: a stored hex gradient is
4288
+ // untouched and renders byte-identically, and an unresolvable binding
4289
+ // fails the existing hex guard, dropping the wash rather than painting it
4290
+ // blank.
4291
+ version: "1.119.0",
4268
4292
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4269
4293
  hooks: HOOKS,
4270
4294
  primitives: PRIMITIVES,
@@ -4567,6 +4591,8 @@ module.exports = {
4567
4591
  deriveSurfaceTokens,
4568
4592
  gradientAngleToVector,
4569
4593
  normaliseComponentGradient,
4594
+ isThemeColorTokenRef,
4595
+ THEME_TOKEN_REF_PREFIX,
4570
4596
  formatMoneyIn,
4571
4597
  normaliseCurrencyCode,
4572
4598
  sameWidgetRouteValue,
package/dist/contract.js CHANGED
@@ -192,6 +192,20 @@ const THEME_COLOR_TOKENS = Object.freeze([
192
192
  Object.freeze({ path: "colors.secondary", label: "Secondary" }),
193
193
  ]);
194
194
 
195
+ // sc-8326 — the `theme:<path>` value grammar, as a PREDICATE over a STORED
196
+ // value and declared beside the allowlist it reads. Three readers have to agree
197
+ // on what a binding IS: the Studio's colour picker writes one, both hosts
198
+ // resolve one, and Mason's plan validator must accept one as a gradient stop.
199
+ // One definition, so they cannot drift (CLAUDE.md §3).
200
+ const THEME_TOKEN_REF_PREFIX = "theme:";
201
+
202
+ function isThemeColorTokenRef(value) {
203
+ if (typeof value !== "string") return false;
204
+ if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return false;
205
+ const path = value.slice(THEME_TOKEN_REF_PREFIX.length);
206
+ return THEME_COLOR_TOKENS.some((token) => token.path === path);
207
+ }
208
+
195
209
 
196
210
  // REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
197
211
  // single lever: remapping `primaryColor` to recolour "the buttons" recolours
@@ -2957,7 +2971,7 @@ const VETTED_IMPORTS = [
2957
2971
  platforms: ["native"],
2958
2972
  category: "sensors",
2959
2973
  description:
2960
- "Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx and pair it with a widget.web.jsx reading window.DeviceMotionEvent (accelerationIncludingGravity / rotationRate), the browser API the same hardware exposes. Both hosts need a user gesture before readings start, and iOS Safari additionally needs an explicit DeviceMotionEvent.requestPermission() grant — so gate the reading behind a Pressable, never start it on mount.",
2974
+ "Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx. There is NO web path through this package OR through the browser motion API: the browser exposes it as window.DeviceMotionEvent, and `window` is a BANNED widget identifier (see BANNED_APIS), so a widget.web.jsx reading it is rejected by the linter. Give the web half a non-sensor way in instead — manual entry, or a control that does not need motion. On native, readings still need a user gesture, so gate them behind a Pressable and never start them on mount.",
2961
2975
  },
2962
2976
  {
2963
2977
  specifier: "react-native-nfc-manager",
@@ -4260,7 +4274,17 @@ const CONTRACT = deepFreeze({
4260
4274
  // because Newsfeed and Chat both render per-emoji pills over a Reactions
4261
4275
  // table, and the legacy slug mapping (`like` -> emoji) must not be
4262
4276
  // re-derived per widget.
4263
- version: "1.118.0",
4277
+ // 1.119.0: additive (sc-8326) — a component gradient's `from` / `to` stops
4278
+ // accept a `theme:<path>` binding as well as a hex, with
4279
+ // `isThemeColorTokenRef` as the shared grammar predicate. `surface` gave a
4280
+ // container's FLAT fill a theme-following role in sc-8197, but a two-stop
4281
+ // wash had no role form, so Mason's colour-band opener could only freeze
4282
+ // two literals out of the creation-time palette and the band stopped
4283
+ // following the app's own theme. Additive: a stored hex gradient is
4284
+ // untouched and renders byte-identically, and an unresolvable binding
4285
+ // fails the existing hex guard, dropping the wash rather than painting it
4286
+ // blank.
4287
+ version: "1.119.0",
4264
4288
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4265
4289
  hooks: HOOKS,
4266
4290
  primitives: PRIMITIVES,
@@ -4563,6 +4587,8 @@ export {
4563
4587
  deriveSurfaceTokens,
4564
4588
  gradientAngleToVector,
4565
4589
  normaliseComponentGradient,
4590
+ isThemeColorTokenRef,
4591
+ THEME_TOKEN_REF_PREFIX,
4566
4592
  formatMoneyIn,
4567
4593
  normaliseCurrencyCode,
4568
4594
  sameWidgetRouteValue,
package/dist/host.d.ts CHANGED
@@ -224,6 +224,35 @@ export interface SidebarTokens {
224
224
  */
225
225
  export function resolveSidebarTokens(theme: unknown): SidebarTokens;
226
226
 
227
+ /** One destination of a page's local menu, as a host hands it over. `key`
228
+ * identifies the target in the caller's own vocabulary (a page slug on web, a
229
+ * screen name in the export); a URL item carries none. */
230
+ export interface SidebarUnfoldItem {
231
+ key?: string | null;
232
+ [field: string]: unknown;
233
+ }
234
+
235
+ /** Where a page's `SIDEBAR` local menu unfolds (REQ-NAV-LOCAL, sc-7696).
236
+ * `under` is the global menu entry the items hang beneath, or null when
237
+ * nothing unfolds. `standalone` marks the case where the global menu does not
238
+ * list the page, so the page itself is the entry. */
239
+ export interface SidebarUnfold {
240
+ under: string | null;
241
+ items: Array<SidebarUnfoldItem & { current: boolean }>;
242
+ standalone: boolean;
243
+ }
244
+
245
+ /** Resolve where the viewed page's local menu unfolds in the sidebar and the
246
+ * drawer. The one derivation both hosts read: the Player calls it per render,
247
+ * the compiler once per screen, so the rail and the exported drawer cannot
248
+ * disagree. */
249
+ export function resolveSidebarUnfold(input: {
250
+ entries?: readonly string[];
251
+ active?: string | null;
252
+ sections?: Record<string, { menu?: unknown; items?: readonly unknown[] }>;
253
+ }): SidebarUnfold;
254
+
255
+
227
256
  /** The top app bar's resolved tokens. `tintColor` and `titleColor` are separate
228
257
  * because an unthemed bar paints its icons slate and its app name in the brand
229
258
  * colour; an authored `topBar.textColor` drives both. The bar carries NO
@@ -271,6 +300,57 @@ export interface TopBarTokens {
271
300
  */
272
301
  export function resolveTopBarTokens(theme: unknown): TopBarTokens;
273
302
 
303
+ /** sc-8023 — the arrangeable contents of the sidebar layout's TOP STRIPE: the
304
+ * row across the top of the content, outside the panel and above it. */
305
+ export type TopStripeSlot = "back" | "menu" | "brand" | "notifications";
306
+
307
+ export const TOP_STRIPE_SLOTS: TopStripeSlot[];
308
+
309
+ /** The slots that move but never hide. The burger is drawn only where the panel
310
+ * is a drawer, and there it is the only route into the menu. */
311
+ export const TOP_STRIPE_ALWAYS_SHOWN_SLOTS: TopStripeSlot[];
312
+
313
+ export const MIN_TOP_STRIPE_HEIGHT_PX: number;
314
+ export const MAX_TOP_STRIPE_HEIGHT_PX: number;
315
+ export const MIN_TOP_STRIPE_PADDING_X_PX: number;
316
+ export const MAX_TOP_STRIPE_PADDING_X_PX: number;
317
+ export const MAX_TOP_STRIPE_OFFSET_Y_PX: number;
318
+
319
+ /** What the DESKTOP stripe measures with nothing stored, so a control can
320
+ * show the number an author is departing from rather than an empty box. */
321
+ export const DEFAULT_TOP_STRIPE_HEIGHT_PX: number;
322
+ export const DEFAULT_TOP_STRIPE_PADDING_X_PX: number;
323
+
324
+ /** The `theme_config` key that shows or hides one slot (`back` → `showBack`). */
325
+ export function topStripeShowKey(slot: TopStripeSlot): string;
326
+
327
+ export interface TopStripeLayout {
328
+ /** The stripe's own surface, falling back to the app-header colour so an
329
+ * untouched app is unmoved and a page's top navigation bar cannot repaint it. */
330
+ backgroundColor: string;
331
+ /** The row's box in px, or null to keep each surface's own — the desktop
332
+ * bar's 64px, the phone header's content-sized row. */
333
+ height: number | null;
334
+ paddingX: number | null;
335
+ /** How far the row is pushed down, 0-60px. */
336
+ contentOffsetY: number;
337
+ /** Every slot in the author's sequence, hidden ones included. */
338
+ order: TopStripeSlot[];
339
+ /** What the bar actually SHOWS, already filtered and sequenced. A surface
340
+ * still drops what it cannot draw, and a bar left empty is not drawn. */
341
+ slots: TopStripeSlot[];
342
+ /** Which edge the drawer arrives from: the brand grows, so a burger ordered
343
+ * after it sits at the trailing edge and must open a panel from there.
344
+ * Web picks the drawer's side with it, native its `drawerPosition`. */
345
+ menuSide: "start" | "end";
346
+ }
347
+
348
+ /**
349
+ * Resolves the top stripe's arrangement from a whole `theme_config`, reading
350
+ * `sidebar.stripe`. Both hosts draw the row from this one answer (§8).
351
+ */
352
+ export function resolveTopStripeLayout(theme: unknown): TopStripeLayout;
353
+
274
354
  /** A value a widget may persist: a scalar, or a flat array of them. */
275
355
  export type WidgetRouteScalar = string | number | boolean;
276
356
  export type WidgetRouteValue = WidgetRouteScalar | WidgetRouteScalar[];
@@ -361,3 +441,14 @@ export function resolveThemeTokenRefs<T>(
361
441
  theme: unknown,
362
442
  schemas?: { propertySchema?: unknown; styleSchema?: unknown },
363
443
  ): T;
444
+
445
+ /**
446
+ * sc-8326 — resolve the `theme:` stops of a layout container's gradient wash.
447
+ * A container's style never reaches `resolveThemeTokenRefs`, so this is its own
448
+ * boundary call; the result still goes through `normaliseComponentGradient`,
449
+ * which stays the hex injection guard.
450
+ */
451
+ export function resolveGradientTokenStops(
452
+ gradient: unknown,
453
+ theme: unknown,
454
+ ): unknown;
package/dist/host.js CHANGED
@@ -28,10 +28,13 @@ export {
28
28
  // at the SAME render boundary on both hosts — so a rebrand repaints the Player,
29
29
  // the Canvas and the exported app alike, and the widget still receives a plain
30
30
  // colour string.
31
+ // sc-8326 extends the same grammar to a layout container's gradient stops, the
32
+ // one themed value that never passes through WidgetHost.
31
33
  export {
32
34
  isThemeTokenRef,
33
35
  resolveThemeTokenRef,
34
36
  resolveThemeTokenRefs,
37
+ resolveGradientTokenStops,
35
38
  } from "./theme-token-refs.js";
36
39
 
37
40
  // REQ-THEME-SURFACE: the surface-token derivation both hosts apply per painted
@@ -63,12 +66,34 @@ export {
63
66
  // REQ-THEME-14: `resolveSidebarTokens` and `resolveTopBarTokens` are the same
64
67
  // idea for the rail and the app bar, whose defaults used to live inline in each
65
68
  // host and had already drifted apart.
69
+ // sc-8023: `resolveTopStripeLayout` is the app header bar's ARRANGEMENT — what the
70
+ // row above the side panel holds, in what order, and the box it sits in. The
71
+ // same bar is drawn by the desktop top bar, the phone header and the exported
72
+ // app's screen header, and each hard-coded its own row before this.
73
+ // REQ-NAV-LOCAL (sc-7696): `resolveSidebarUnfold` answers where a page's
74
+ // SIDEBAR local menu hangs in the rail and the drawer. The compiler asks it
75
+ // once per screen at build time and the Player once per render, so the two
76
+ // chromes derive the same tree from the same code.
66
77
  export {
67
78
  normaliseNavigation,
68
79
  menuItemCap,
69
80
  resolveFooterTokens,
70
81
  resolveSidebarTokens,
82
+ resolveTopStripeLayout,
71
83
  resolveTopBarTokens,
84
+ topStripeShowKey,
85
+ TOP_STRIPE_ALWAYS_SHOWN_SLOTS,
86
+ TOP_STRIPE_SLOTS,
87
+ // The bar's BOUNDS travel with it: the Studio's controls offer exactly the
88
+ // range the resolver clamps to, rather than restating the numbers.
89
+ MIN_TOP_STRIPE_HEIGHT_PX,
90
+ MAX_TOP_STRIPE_HEIGHT_PX,
91
+ MIN_TOP_STRIPE_PADDING_X_PX,
92
+ MAX_TOP_STRIPE_PADDING_X_PX,
93
+ MAX_TOP_STRIPE_OFFSET_Y_PX,
94
+ DEFAULT_TOP_STRIPE_HEIGHT_PX,
95
+ DEFAULT_TOP_STRIPE_PADDING_X_PX,
96
+ resolveSidebarUnfold,
72
97
  } from "./navigation.js";
73
98
 
74
99
  // sc-5717: the codec behind `useWidgetRoute()` — how a widget's own internal
package/dist/index.d.ts CHANGED
@@ -2674,6 +2674,16 @@ export function normaliseComponentGradient(
2674
2674
  raw: unknown,
2675
2675
  ): ComponentGradient | null;
2676
2676
 
2677
+ /** sc-8326 — the `theme:<path>` binding prefix, shared with the hosts' resolver. */
2678
+ export const THEME_TOKEN_REF_PREFIX: string;
2679
+
2680
+ /**
2681
+ * sc-8326 — true when a stored value is a `theme:<path>` binding naming a token
2682
+ * in `CONTRACT.themeColorTokens`. The one grammar predicate the Studio's writer,
2683
+ * the hosts' resolver and Mason's plan validator all read.
2684
+ */
2685
+ export function isThemeColorTokenRef(value: unknown): boolean;
2686
+
2677
2687
  // Linter
2678
2688
  export interface LintFinding {
2679
2689
  rule: string;
package/dist/index.js CHANGED
@@ -158,6 +158,8 @@ export {
158
158
  deriveAccentTints,
159
159
  gradientAngleToVector,
160
160
  normaliseComponentGradient,
161
+ isThemeColorTokenRef,
162
+ THEME_TOKEN_REF_PREFIX,
161
163
  clampSpacingScale,
162
164
  scaleSpacing,
163
165
  } from "./contract.js";
@@ -151,6 +151,8 @@ export {
151
151
  deriveAccentTints,
152
152
  gradientAngleToVector,
153
153
  normaliseComponentGradient,
154
+ isThemeColorTokenRef,
155
+ THEME_TOKEN_REF_PREFIX,
154
156
  clampSpacingScale,
155
157
  scaleSpacing,
156
158
  } from "./contract.js";
package/dist/linter.cjs CHANGED
@@ -1498,11 +1498,16 @@ const _GENERIC_STYLE_GROUPS = new Set([
1498
1498
  "general",
1499
1499
  ]);
1500
1500
 
1501
- // The three the basic-style gate mandates; they restyle the widget as a whole.
1501
+ // The five the basic-style gate mandates; they restyle the widget as a whole.
1502
+ // Lockstep with BASIC_STYLE_FIELDS (backend/src/core/services/widget-basic-style.ts)
1503
+ // — a mandated field missing here reads as per-element coverage the widget does
1504
+ // not have, which silently retires this rule for EVERY generated widget.
1502
1505
  const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
1503
1506
  "background",
1504
1507
  "textColor",
1505
1508
  "align",
1509
+ "padding",
1510
+ "margin",
1506
1511
  ]);
1507
1512
 
1508
1513
  // A second element an author would style apart from the text. Pressable and
@@ -1697,7 +1702,7 @@ function _styleCoverageFindings(manifest, codes) {
1697
1702
  severity: "warning",
1698
1703
  label:
1699
1704
  `The widget renders more than one visual element but its styleSchema ` +
1700
- `stops at background/textColor/align. Add 2-4 grouped per-element ` +
1705
+ `stops at the whole-widget basics. Add 2-4 grouped per-element ` +
1701
1706
  `fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
1702
1707
  `each applied ONLY when set.`,
1703
1708
  line: 0,
package/dist/linter.js CHANGED
@@ -1569,11 +1569,16 @@ const _GENERIC_STYLE_GROUPS = new Set([
1569
1569
  "general",
1570
1570
  ]);
1571
1571
 
1572
- // The three the basic-style gate mandates; they restyle the widget as a whole.
1572
+ // The five the basic-style gate mandates; they restyle the widget as a whole.
1573
+ // Lockstep with BASIC_STYLE_FIELDS (backend/src/core/services/widget-basic-style.ts)
1574
+ // — a mandated field missing here reads as per-element coverage the widget does
1575
+ // not have, which silently retires this rule for EVERY generated widget.
1573
1576
  const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
1574
1577
  "background",
1575
1578
  "textColor",
1576
1579
  "align",
1580
+ "padding",
1581
+ "margin",
1577
1582
  ]);
1578
1583
 
1579
1584
  // A second element an author would style apart from the text. Pressable and
@@ -1768,7 +1773,7 @@ function _styleCoverageFindings(manifest, codes) {
1768
1773
  severity: "warning",
1769
1774
  label:
1770
1775
  `The widget renders more than one visual element but its styleSchema ` +
1771
- `stops at background/textColor/align. Add 2-4 grouped per-element ` +
1776
+ `stops at the whole-widget basics. Add 2-4 grouped per-element ` +
1772
1777
  `fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
1773
1778
  `each applied ONLY when set.`,
1774
1779
  line: 0,
@@ -301,6 +301,175 @@ function resolveSidebarTokens(theme) {
301
301
  * resolves to the page's, as an `attached` tab's already did (sc-6596).
302
302
  * @returns {{ backgroundColor: string, tintColor: string, titleColor: string }}
303
303
  */
304
+ // sc-8023 — the TOP STRIPE's arrangement: the row that runs across the top of
305
+ // the content in the sidebar layout, outside the panel and above it.
306
+ //
307
+ // It is NOT the optional full-width top bar, which keeps its own fixed row.
308
+ // The stripe is what an app with a side panel actually shows, and it is drawn
309
+ // twice — across the content on desktop, and as the phone header where the panel
310
+ // is a drawer — each hard-coding its own arrangement, so the author could colour
311
+ // the row but never move anything in it.
312
+ //
313
+ // It lives on the SIDEBAR block because it belongs to that layout: `topBar` is
314
+ // the other bar. Its colours still come from the app-header tokens
315
+ // (`resolveTopBarTokens`), which is what paints all of this chrome today.
316
+ // The identity is deliberately NOT here. On a phone the panel's own foot owns
317
+ // the account and sign-out (sc-5907 — a 120px name pill left the brand nothing
318
+ // to render in), and on desktop they sit in the content strip the rail does not
319
+ // reach. Offering them as arrangeable slots meant two checkboxes that could
320
+ // never move anything, which is worse than not offering them.
321
+ const TOP_STRIPE_SLOTS = ["back", "menu", "brand", "notifications"];
322
+
323
+ // Everything the bar draws today, so an app that never opens the panel is
324
+ // unmoved. Unlike the slots a surface adds, none of these is new — they are the
325
+ // controls already in the row, now nameable.
326
+ const TOP_STRIPE_SLOT_SHOWN_BY_DEFAULT = {
327
+ back: true,
328
+ menu: true,
329
+ brand: true,
330
+ notifications: true,
331
+ };
332
+
333
+ // The burger MOVES but never hides. It is drawn only where the panel is a
334
+ // drawer — a phone — and there it is the sole way in: the REQ-NAV-14 floating
335
+ // button belongs to site mode, so an app page that dropped it would strand the
336
+ // visitor with no route to the menu. Its position still matters (it decides
337
+ // which edge the drawer arrives from), so it stays in `order` and simply
338
+ // offers no visibility of its own, the way the identity offers no slot.
339
+ const TOP_STRIPE_ALWAYS_SHOWN_SLOTS = ["menu"];
340
+
341
+ // The bar's box. Neither measurement has a shared default: the desktop bar is a
342
+ // fixed `h-16` with `px-4`, the phone header is content-sized with `px-2`.
343
+ // `null` keeps each of those, so nothing moves until an author names one — and
344
+ // then both surfaces take the same, which is the convergence (§3).
345
+ const MIN_TOP_STRIPE_HEIGHT_PX = 40;
346
+ const MAX_TOP_STRIPE_HEIGHT_PX = 200;
347
+ const MIN_TOP_STRIPE_PADDING_X_PX = 0;
348
+ const MAX_TOP_STRIPE_PADDING_X_PX = 64;
349
+ const MAX_TOP_STRIPE_OFFSET_Y_PX = 60;
350
+
351
+ // What the DESKTOP stripe measures when nothing is stored — the row's shipped
352
+ // `h-16` and `px-4`. Published so a control can show the author the number
353
+ // they are departing from instead of an empty box; the resolver still answers
354
+ // `null`, because the phone header's box is its own and neither surface moves
355
+ // until an author names a value.
356
+ const DEFAULT_TOP_STRIPE_HEIGHT_PX = 64;
357
+ const DEFAULT_TOP_STRIPE_PADDING_X_PX = 16;
358
+
359
+ /**
360
+ * The `theme_config` key that shows or hides one slot. Derived in ONE place so
361
+ * the Studio's control, the write-path coercion and the resolver cannot each
362
+ * spell it slightly differently.
363
+ *
364
+ * @param {string} slot — a member of `TOP_STRIPE_SLOTS`.
365
+ * @returns {string}
366
+ */
367
+ function topStripeShowKey(slot) {
368
+ return `show${slot.charAt(0).toUpperCase()}${slot.slice(1)}`;
369
+ }
370
+
371
+ // Clamped rather than dropped: a value past the end expresses a real intent and
372
+ // lands on the end. A non-number is a different case and takes the default.
373
+ function topStripeClampedOr(value, min, max, fallback) {
374
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
375
+ return Math.min(Math.max(Math.round(value), min), max);
376
+ }
377
+
378
+ // An `order` is at most a permutation; anything longer is not an ordering, and
379
+ // `theme_config` is stored close to verbatim, so it is refused rather than
380
+ // walked. Both hosts resolve on every render.
381
+ const MAX_TOP_STRIPE_ORDER_ENTRIES = TOP_STRIPE_SLOTS.length * 2;
382
+
383
+ function topStripeOrder(value) {
384
+ if (!Array.isArray(value) || value.length > MAX_TOP_STRIPE_ORDER_ENTRIES) {
385
+ return TOP_STRIPE_SLOTS.slice();
386
+ }
387
+ const named = [];
388
+ for (const slot of value) {
389
+ if (TOP_STRIPE_SLOTS.includes(slot) && !named.includes(slot)) named.push(slot);
390
+ }
391
+ // An authored order names what it cares about; everything it leaves out keeps
392
+ // its default place. Dropping the unnamed slots would make "reorder two" and
393
+ // "hide the rest" the same gesture, and one of those is an accident.
394
+ return named.concat(TOP_STRIPE_SLOTS.filter((s) => !named.includes(s)));
395
+ }
396
+
397
+ /**
398
+ * The app header bar's arrangement, resolved for every host.
399
+ *
400
+ * `slots` is the ordered list of what the bar SHOWS — already filtered and
401
+ * sequenced, so no surface re-derives it. A surface still drops what it cannot
402
+ * draw (a desktop bar has no burger; a root page has no back control), and a
403
+ * bar left holding nothing is not drawn at all.
404
+ *
405
+ * @param {unknown} theme — the whole `theme_config`.
406
+ * @returns {{ backgroundColor: string, height: number|null,
407
+ * paddingX: number|null, contentOffsetY: number, order: string[],
408
+ * slots: string[], menuSide: "start"|"end" }}
409
+ */
410
+ function resolveTopStripeLayout(theme) {
411
+ const config = isPlainObject(theme) ? theme : {};
412
+ const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
413
+ const stripe = isPlainObject(sidebar.stripe) ? sidebar.stripe : {};
414
+ const order = topStripeOrder(stripe.order);
415
+ const shown = (slot) => {
416
+ if (TOP_STRIPE_ALWAYS_SHOWN_SLOTS.includes(slot)) return true;
417
+ const value = stripe[topStripeShowKey(slot)];
418
+ return typeof value === "boolean"
419
+ ? value
420
+ : TOP_STRIPE_SLOT_SHOWN_BY_DEFAULT[slot];
421
+ };
422
+ const slots = order.filter(shown);
423
+ return {
424
+ // There is no alignment to resolve: the brand slot GROWS, so the row has no
425
+ // free space for `justify-content` to distribute and every value looked
426
+ // identical. The row spreads because the brand takes the slack.
427
+ // Unset defers to each surface's own box — the desktop bar's fixed 64px,
428
+ // the phone header's content-sized row.
429
+ height: topStripeClampedOr(
430
+ stripe.height,
431
+ MIN_TOP_STRIPE_HEIGHT_PX,
432
+ MAX_TOP_STRIPE_HEIGHT_PX,
433
+ null,
434
+ ),
435
+ paddingX: topStripeClampedOr(
436
+ stripe.paddingX,
437
+ MIN_TOP_STRIPE_PADDING_X_PX,
438
+ MAX_TOP_STRIPE_PADDING_X_PX,
439
+ null,
440
+ ),
441
+ contentOffsetY: topStripeClampedOr(
442
+ stripe.contentOffsetY,
443
+ 0,
444
+ MAX_TOP_STRIPE_OFFSET_Y_PX,
445
+ 0,
446
+ ),
447
+ // sc-8023: the stripe's OWN surface, inheriting the app-header colour when
448
+ // unset — so an untouched app is unmoved, and an author who dresses a
449
+ // page's top navigation bar no longer repaints the stripe above the panel by
450
+ // accident. Fully resolved here, not raw-or-null, for the reason sc-8033
451
+ // resolved the footer's: a default that lives in each host drifts.
452
+ backgroundColor: hexOr(
453
+ stripe.backgroundColor,
454
+ resolveTopBarTokens(theme).backgroundColor,
455
+ ),
456
+ order,
457
+ slots,
458
+ // sc-8023: which edge the panel the burger opens should come in from. The
459
+ // brand GROWS, so anything ordered after it is pushed to the trailing edge
460
+ // — a burger over there opening a panel from the opposite side is a panel
461
+ // that flies past the button that summoned it. With no brand drawn nothing
462
+ // grows and the whole row packs to the start, so the burger is leading
463
+ // whatever its index. Both hosts read this: web picks the drawer's side,
464
+ // native its `drawerPosition`.
465
+ menuSide:
466
+ slots.includes("brand") &&
467
+ slots.indexOf("menu") > slots.indexOf("brand")
468
+ ? "end"
469
+ : "start",
470
+ };
471
+ }
472
+
304
473
  function resolveTopBarTokens(theme) {
305
474
  const config = isPlainObject(theme) ? theme : {};
306
475
  const topBar = isPlainObject(config.topBar) ? config.topBar : {};
@@ -366,6 +535,81 @@ function resolveTopBarTokens(theme) {
366
535
  };
367
536
  }
368
537
 
538
+ /**
539
+ * REQ-NAV-LOCAL (sc-7696) — where a page's `SIDEBAR` local menu unfolds.
540
+ *
541
+ * The one derivation both hosts read, so the web rail, the web drawer and the
542
+ * exported app's drawer cannot disagree about what hangs under what. Keys are
543
+ * the caller's own (the web Player passes page slugs, the compiler passes
544
+ * screen names); this only compares them.
545
+ *
546
+ * A page belongs to a section in either of two ways, and both keep the tree
547
+ * open: it CARRIES the model itself, or the section's menu LISTS it. The
548
+ * second is what makes the model usable — clicking a destination would
549
+ * otherwise collapse the menu that led there unless the author repeated the
550
+ * setting on every sub-page.
551
+ *
552
+ * The unfold point is the first GLOBAL menu entry carrying that section's
553
+ * saved menu, so the tree hangs in the same place from every page of the
554
+ * section. When the global menu lists no such page, the section's own page
555
+ * becomes a top-level entry (`standalone`) for as long as the visitor is
556
+ * inside it, so the menu is never lost — a section off the menu is exactly
557
+ * the case that would otherwise render blank.
558
+ *
559
+ * @param {{ entries?: unknown, active?: unknown, sections?: unknown }} input
560
+ * `entries` — the global menu's keys, in menu order.
561
+ * `active` — the key of the page being viewed.
562
+ * `sections` — `{ [key]: { menu, items } }` for pages whose local nav is
563
+ * `SIDEBAR`; `menu` identifies the saved menu, `items` carry their own key.
564
+ * @returns {{ under: string|null, items: Array<object>, standalone: boolean }}
565
+ * `under` is null when nothing unfolds; when `standalone` it names the
566
+ * section's own page, which the host draws as the entry. Each item is
567
+ * returned with a `current` flag rather than leaving each host to
568
+ * recompute it.
569
+ */
570
+ function resolveSidebarUnfold(input) {
571
+ const { entries, active, sections } = isPlainObject(input) ? input : {};
572
+ const nothing = { under: null, items: [], standalone: false };
573
+ if (typeof active !== "string" || !active) return nothing;
574
+ const bank = isPlainObject(sections) ? sections : {};
575
+ const order = Array.isArray(entries) ? entries : [];
576
+ const sectionAt = (key) => {
577
+ const candidate = isPlainObject(bank[key]) ? bank[key] : null;
578
+ const items = candidate && Array.isArray(candidate.items) ? candidate.items : [];
579
+ return items.length ? { key, menu: candidate.menu, items } : null;
580
+ };
581
+ const lists = (key) => {
582
+ const candidate = sectionAt(key);
583
+ return (
584
+ Boolean(candidate) &&
585
+ candidate.items.some(
586
+ (item) => isPlainObject(item) && item.key === active,
587
+ )
588
+ );
589
+ };
590
+ // The page's own section wins; otherwise the section whose menu lists it,
591
+ // preferring one a global entry carries so the tree hangs where it always
592
+ // does. Both are the same section from the visitor's side.
593
+ const section =
594
+ sectionAt(active) ||
595
+ sectionAt(order.find(lists)) ||
596
+ sectionAt(Object.keys(bank).find(lists));
597
+ if (!section) return nothing;
598
+ const marked = section.items.map((item) => ({
599
+ ...(isPlainObject(item) ? item : {}),
600
+ current:
601
+ isPlainObject(item) && typeof item.key === "string" && item.key === active,
602
+ }));
603
+ const owner = order.find((key) => {
604
+ const candidate = isPlainObject(bank[key]) ? bank[key] : null;
605
+ return Boolean(candidate) && candidate.menu === section.menu;
606
+ });
607
+ if (typeof owner === "string" && owner) {
608
+ return { under: owner, items: marked, standalone: false };
609
+ }
610
+ return { under: section.key, items: marked, standalone: true };
611
+ }
612
+
369
613
  // REQ-THEME-LOOK: the divider's width in px, bounded HERE so both hosts inherit
370
614
  // one range. The export clamped it on its way into the StyleSheet
371
615
  // (`coerceBorderWidth(...) ?? 1`) where the Player passed any finite number
@@ -402,4 +646,4 @@ function borderWidthOr(...values) {
402
646
  return BORDER_WIDTH_MIN;
403
647
  }
404
648
 
405
- module.exports = { normaliseNavigation, menuItemCap, resolveFooterTokens, resolveSidebarTokens, resolveTopBarTokens };
649
+ module.exports = { normaliseNavigation, menuItemCap, resolveFooterTokens, resolveSidebarTokens, resolveTopStripeLayout, resolveTopBarTokens, topStripeShowKey, TOP_STRIPE_ALWAYS_SHOWN_SLOTS, TOP_STRIPE_SLOTS, MIN_TOP_STRIPE_HEIGHT_PX, MAX_TOP_STRIPE_HEIGHT_PX, MIN_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_OFFSET_Y_PX, DEFAULT_TOP_STRIPE_HEIGHT_PX, DEFAULT_TOP_STRIPE_PADDING_X_PX, resolveSidebarUnfold };
@@ -292,6 +292,175 @@ export function resolveSidebarTokens(theme) {
292
292
  * resolves to the page's, as an `attached` tab's already did (sc-6596).
293
293
  * @returns {{ backgroundColor: string, tintColor: string, titleColor: string }}
294
294
  */
295
+ // sc-8023 — the TOP STRIPE's arrangement: the row that runs across the top of
296
+ // the content in the sidebar layout, outside the panel and above it.
297
+ //
298
+ // It is NOT the optional full-width top bar, which keeps its own fixed row.
299
+ // The stripe is what an app with a side panel actually shows, and it is drawn
300
+ // twice — across the content on desktop, and as the phone header where the panel
301
+ // is a drawer — each hard-coding its own arrangement, so the author could colour
302
+ // the row but never move anything in it.
303
+ //
304
+ // It lives on the SIDEBAR block because it belongs to that layout: `topBar` is
305
+ // the other bar. Its colours still come from the app-header tokens
306
+ // (`resolveTopBarTokens`), which is what paints all of this chrome today.
307
+ // The identity is deliberately NOT here. On a phone the panel's own foot owns
308
+ // the account and sign-out (sc-5907 — a 120px name pill left the brand nothing
309
+ // to render in), and on desktop they sit in the content strip the rail does not
310
+ // reach. Offering them as arrangeable slots meant two checkboxes that could
311
+ // never move anything, which is worse than not offering them.
312
+ export const TOP_STRIPE_SLOTS = ["back", "menu", "brand", "notifications"];
313
+
314
+ // Everything the bar draws today, so an app that never opens the panel is
315
+ // unmoved. Unlike the slots a surface adds, none of these is new — they are the
316
+ // controls already in the row, now nameable.
317
+ const TOP_STRIPE_SLOT_SHOWN_BY_DEFAULT = {
318
+ back: true,
319
+ menu: true,
320
+ brand: true,
321
+ notifications: true,
322
+ };
323
+
324
+ // The burger MOVES but never hides. It is drawn only where the panel is a
325
+ // drawer — a phone — and there it is the sole way in: the REQ-NAV-14 floating
326
+ // button belongs to site mode, so an app page that dropped it would strand the
327
+ // visitor with no route to the menu. Its position still matters (it decides
328
+ // which edge the drawer arrives from), so it stays in `order` and simply
329
+ // offers no visibility of its own, the way the identity offers no slot.
330
+ export const TOP_STRIPE_ALWAYS_SHOWN_SLOTS = ["menu"];
331
+
332
+ // The bar's box. Neither measurement has a shared default: the desktop bar is a
333
+ // fixed `h-16` with `px-4`, the phone header is content-sized with `px-2`.
334
+ // `null` keeps each of those, so nothing moves until an author names one — and
335
+ // then both surfaces take the same, which is the convergence (§3).
336
+ export const MIN_TOP_STRIPE_HEIGHT_PX = 40;
337
+ export const MAX_TOP_STRIPE_HEIGHT_PX = 200;
338
+ export const MIN_TOP_STRIPE_PADDING_X_PX = 0;
339
+ export const MAX_TOP_STRIPE_PADDING_X_PX = 64;
340
+ export const MAX_TOP_STRIPE_OFFSET_Y_PX = 60;
341
+
342
+ // What the DESKTOP stripe measures when nothing is stored — the row's shipped
343
+ // `h-16` and `px-4`. Published so a control can show the author the number
344
+ // they are departing from instead of an empty box; the resolver still answers
345
+ // `null`, because the phone header's box is its own and neither surface moves
346
+ // until an author names a value.
347
+ export const DEFAULT_TOP_STRIPE_HEIGHT_PX = 64;
348
+ export const DEFAULT_TOP_STRIPE_PADDING_X_PX = 16;
349
+
350
+ /**
351
+ * The `theme_config` key that shows or hides one slot. Derived in ONE place so
352
+ * the Studio's control, the write-path coercion and the resolver cannot each
353
+ * spell it slightly differently.
354
+ *
355
+ * @param {string} slot — a member of `TOP_STRIPE_SLOTS`.
356
+ * @returns {string}
357
+ */
358
+ export function topStripeShowKey(slot) {
359
+ return `show${slot.charAt(0).toUpperCase()}${slot.slice(1)}`;
360
+ }
361
+
362
+ // Clamped rather than dropped: a value past the end expresses a real intent and
363
+ // lands on the end. A non-number is a different case and takes the default.
364
+ function topStripeClampedOr(value, min, max, fallback) {
365
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
366
+ return Math.min(Math.max(Math.round(value), min), max);
367
+ }
368
+
369
+ // An `order` is at most a permutation; anything longer is not an ordering, and
370
+ // `theme_config` is stored close to verbatim, so it is refused rather than
371
+ // walked. Both hosts resolve on every render.
372
+ const MAX_TOP_STRIPE_ORDER_ENTRIES = TOP_STRIPE_SLOTS.length * 2;
373
+
374
+ function topStripeOrder(value) {
375
+ if (!Array.isArray(value) || value.length > MAX_TOP_STRIPE_ORDER_ENTRIES) {
376
+ return TOP_STRIPE_SLOTS.slice();
377
+ }
378
+ const named = [];
379
+ for (const slot of value) {
380
+ if (TOP_STRIPE_SLOTS.includes(slot) && !named.includes(slot)) named.push(slot);
381
+ }
382
+ // An authored order names what it cares about; everything it leaves out keeps
383
+ // its default place. Dropping the unnamed slots would make "reorder two" and
384
+ // "hide the rest" the same gesture, and one of those is an accident.
385
+ return named.concat(TOP_STRIPE_SLOTS.filter((s) => !named.includes(s)));
386
+ }
387
+
388
+ /**
389
+ * The app header bar's arrangement, resolved for every host.
390
+ *
391
+ * `slots` is the ordered list of what the bar SHOWS — already filtered and
392
+ * sequenced, so no surface re-derives it. A surface still drops what it cannot
393
+ * draw (a desktop bar has no burger; a root page has no back control), and a
394
+ * bar left holding nothing is not drawn at all.
395
+ *
396
+ * @param {unknown} theme — the whole `theme_config`.
397
+ * @returns {{ backgroundColor: string, height: number|null,
398
+ * paddingX: number|null, contentOffsetY: number, order: string[],
399
+ * slots: string[], menuSide: "start"|"end" }}
400
+ */
401
+ export function resolveTopStripeLayout(theme) {
402
+ const config = isPlainObject(theme) ? theme : {};
403
+ const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
404
+ const stripe = isPlainObject(sidebar.stripe) ? sidebar.stripe : {};
405
+ const order = topStripeOrder(stripe.order);
406
+ const shown = (slot) => {
407
+ if (TOP_STRIPE_ALWAYS_SHOWN_SLOTS.includes(slot)) return true;
408
+ const value = stripe[topStripeShowKey(slot)];
409
+ return typeof value === "boolean"
410
+ ? value
411
+ : TOP_STRIPE_SLOT_SHOWN_BY_DEFAULT[slot];
412
+ };
413
+ const slots = order.filter(shown);
414
+ return {
415
+ // There is no alignment to resolve: the brand slot GROWS, so the row has no
416
+ // free space for `justify-content` to distribute and every value looked
417
+ // identical. The row spreads because the brand takes the slack.
418
+ // Unset defers to each surface's own box — the desktop bar's fixed 64px,
419
+ // the phone header's content-sized row.
420
+ height: topStripeClampedOr(
421
+ stripe.height,
422
+ MIN_TOP_STRIPE_HEIGHT_PX,
423
+ MAX_TOP_STRIPE_HEIGHT_PX,
424
+ null,
425
+ ),
426
+ paddingX: topStripeClampedOr(
427
+ stripe.paddingX,
428
+ MIN_TOP_STRIPE_PADDING_X_PX,
429
+ MAX_TOP_STRIPE_PADDING_X_PX,
430
+ null,
431
+ ),
432
+ contentOffsetY: topStripeClampedOr(
433
+ stripe.contentOffsetY,
434
+ 0,
435
+ MAX_TOP_STRIPE_OFFSET_Y_PX,
436
+ 0,
437
+ ),
438
+ // sc-8023: the stripe's OWN surface, inheriting the app-header colour when
439
+ // unset — so an untouched app is unmoved, and an author who dresses a
440
+ // page's top navigation bar no longer repaints the stripe above the panel by
441
+ // accident. Fully resolved here, not raw-or-null, for the reason sc-8033
442
+ // resolved the footer's: a default that lives in each host drifts.
443
+ backgroundColor: hexOr(
444
+ stripe.backgroundColor,
445
+ resolveTopBarTokens(theme).backgroundColor,
446
+ ),
447
+ order,
448
+ slots,
449
+ // sc-8023: which edge the panel the burger opens should come in from. The
450
+ // brand GROWS, so anything ordered after it is pushed to the trailing edge
451
+ // — a burger over there opening a panel from the opposite side is a panel
452
+ // that flies past the button that summoned it. With no brand drawn nothing
453
+ // grows and the whole row packs to the start, so the burger is leading
454
+ // whatever its index. Both hosts read this: web picks the drawer's side,
455
+ // native its `drawerPosition`.
456
+ menuSide:
457
+ slots.includes("brand") &&
458
+ slots.indexOf("menu") > slots.indexOf("brand")
459
+ ? "end"
460
+ : "start",
461
+ };
462
+ }
463
+
295
464
  export function resolveTopBarTokens(theme) {
296
465
  const config = isPlainObject(theme) ? theme : {};
297
466
  const topBar = isPlainObject(config.topBar) ? config.topBar : {};
@@ -357,6 +526,81 @@ export function resolveTopBarTokens(theme) {
357
526
  };
358
527
  }
359
528
 
529
+ /**
530
+ * REQ-NAV-LOCAL (sc-7696) — where a page's `SIDEBAR` local menu unfolds.
531
+ *
532
+ * The one derivation both hosts read, so the web rail, the web drawer and the
533
+ * exported app's drawer cannot disagree about what hangs under what. Keys are
534
+ * the caller's own (the web Player passes page slugs, the compiler passes
535
+ * screen names); this only compares them.
536
+ *
537
+ * A page belongs to a section in either of two ways, and both keep the tree
538
+ * open: it CARRIES the model itself, or the section's menu LISTS it. The
539
+ * second is what makes the model usable — clicking a destination would
540
+ * otherwise collapse the menu that led there unless the author repeated the
541
+ * setting on every sub-page.
542
+ *
543
+ * The unfold point is the first GLOBAL menu entry carrying that section's
544
+ * saved menu, so the tree hangs in the same place from every page of the
545
+ * section. When the global menu lists no such page, the section's own page
546
+ * becomes a top-level entry (`standalone`) for as long as the visitor is
547
+ * inside it, so the menu is never lost — a section off the menu is exactly
548
+ * the case that would otherwise render blank.
549
+ *
550
+ * @param {{ entries?: unknown, active?: unknown, sections?: unknown }} input
551
+ * `entries` — the global menu's keys, in menu order.
552
+ * `active` — the key of the page being viewed.
553
+ * `sections` — `{ [key]: { menu, items } }` for pages whose local nav is
554
+ * `SIDEBAR`; `menu` identifies the saved menu, `items` carry their own key.
555
+ * @returns {{ under: string|null, items: Array<object>, standalone: boolean }}
556
+ * `under` is null when nothing unfolds; when `standalone` it names the
557
+ * section's own page, which the host draws as the entry. Each item is
558
+ * returned with a `current` flag rather than leaving each host to
559
+ * recompute it.
560
+ */
561
+ export function resolveSidebarUnfold(input) {
562
+ const { entries, active, sections } = isPlainObject(input) ? input : {};
563
+ const nothing = { under: null, items: [], standalone: false };
564
+ if (typeof active !== "string" || !active) return nothing;
565
+ const bank = isPlainObject(sections) ? sections : {};
566
+ const order = Array.isArray(entries) ? entries : [];
567
+ const sectionAt = (key) => {
568
+ const candidate = isPlainObject(bank[key]) ? bank[key] : null;
569
+ const items = candidate && Array.isArray(candidate.items) ? candidate.items : [];
570
+ return items.length ? { key, menu: candidate.menu, items } : null;
571
+ };
572
+ const lists = (key) => {
573
+ const candidate = sectionAt(key);
574
+ return (
575
+ Boolean(candidate) &&
576
+ candidate.items.some(
577
+ (item) => isPlainObject(item) && item.key === active,
578
+ )
579
+ );
580
+ };
581
+ // The page's own section wins; otherwise the section whose menu lists it,
582
+ // preferring one a global entry carries so the tree hangs where it always
583
+ // does. Both are the same section from the visitor's side.
584
+ const section =
585
+ sectionAt(active) ||
586
+ sectionAt(order.find(lists)) ||
587
+ sectionAt(Object.keys(bank).find(lists));
588
+ if (!section) return nothing;
589
+ const marked = section.items.map((item) => ({
590
+ ...(isPlainObject(item) ? item : {}),
591
+ current:
592
+ isPlainObject(item) && typeof item.key === "string" && item.key === active,
593
+ }));
594
+ const owner = order.find((key) => {
595
+ const candidate = isPlainObject(bank[key]) ? bank[key] : null;
596
+ return Boolean(candidate) && candidate.menu === section.menu;
597
+ });
598
+ if (typeof owner === "string" && owner) {
599
+ return { under: owner, items: marked, standalone: false };
600
+ }
601
+ return { under: section.key, items: marked, standalone: true };
602
+ }
603
+
360
604
  // REQ-THEME-LOOK: the divider's width in px, bounded HERE so both hosts inherit
361
605
  // one range. The export clamped it on its way into the StyleSheet
362
606
  // (`coerceBorderWidth(...) ?? 1`) where the Player passed any finite number
@@ -18,11 +18,13 @@
18
18
  // type today's hex, which went stale silently. A bound field stores the TOKEN,
19
19
  // so the intent survives the rebrand.
20
20
 
21
- import { CONTRACT } from "./contract.js";
21
+ import { isThemeColorTokenRef, THEME_TOKEN_REF_PREFIX } from "./contract.js";
22
22
 
23
23
  // A value is a binding, not a colour, when it carries this prefix. No CSS
24
24
  // colour can begin with it, so a stored literal can never be misread as one.
25
- const PREFIX = "theme:";
25
+ // Sourced from the contract (sc-8326): Mason's plan validator reads the same
26
+ // grammar from there, and two spellings of it would drift.
27
+ const PREFIX = THEME_TOKEN_REF_PREFIX;
26
28
 
27
29
  /** True when `value` is a theme-token binding rather than a literal colour. */
28
30
  export function isThemeTokenRef(value) {
@@ -38,13 +40,11 @@ export function isThemeTokenRef(value) {
38
40
  * un-bound field does. A malformed binding must never paint blank.
39
41
  */
40
42
  export function resolveThemeTokenRef(value, theme) {
41
- if (!isThemeTokenRef(value)) return undefined;
42
- const path = value.slice(PREFIX.length);
43
43
  // The allowlist is the contract's, so what the Studio may WRITE and what a
44
- // host will RESOLVE cannot drift apart.
45
- if (!CONTRACT.themeColorTokens.some((token) => token.path === path)) {
46
- return undefined;
47
- }
44
+ // host will RESOLVE cannot drift apart. sc-8326 moved the test itself there
45
+ // too, since Mason's plan validator has to ask the same question.
46
+ if (!isThemeColorTokenRef(value)) return undefined;
47
+ const path = value.slice(PREFIX.length);
48
48
  const resolved = path
49
49
  .split(".")
50
50
  .reduce((o, k) => (o == null ? undefined : o[k]), theme);
@@ -196,3 +196,27 @@ export function resolveThemeTokenRefs(props, theme, schemas) {
196
196
  }
197
197
  return out;
198
198
  }
199
+
200
+ /**
201
+ * sc-8326 — resolve the `theme:` stops of a LAYOUT CONTAINER's gradient wash.
202
+ *
203
+ * A container's style never reaches `resolveThemeTokenRefs`: containers are
204
+ * painted by the page renderer, not by WidgetHost, which is why sc-7800 left
205
+ * them unbindable. Their gradient is the one place that needs a binding —
206
+ * sc-8197 gave the flat fill a theme-following `surface` role, but a two-stop
207
+ * wash has no role form, so Mason's colour-band opener could only freeze two
208
+ * literals out of the palette that happened to be active at build time.
209
+ *
210
+ * Resolution happens HERE, BEFORE `normaliseComponentGradient`: that hex
211
+ * grammar stays the injection guard for stops reaching CSS and generated export
212
+ * source, and an unresolvable binding fails it like any other non-hex value,
213
+ * dropping the wash instead of painting it blank.
214
+ *
215
+ * @param {unknown} gradient — a raw `style.backgroundGradient`.
216
+ * @param {object} theme — the resolved widget theme.
217
+ * @returns {unknown} the same value with every bound stop resolved.
218
+ */
219
+ export function resolveGradientTokenStops(gradient, theme) {
220
+ if (!isPlainObject(gradient)) return gradient;
221
+ return resolveStyleBag(gradient, theme, 1);
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.150.1",
3
+ "version": "0.152.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",