@colixsystems/widget-sdk 0.195.0 → 0.197.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
@@ -77,7 +77,24 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
77
77
 
78
78
  ## Status
79
79
 
80
- `v0.195.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**.
80
+ `v0.197.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**.
81
+
82
+ ### What's new in 0.197.0 (contract 1.157.0)
83
+
84
+ **Host navigation: a page's sidebar menu can be opened from any page.** `resolveSidebarUnfold({ entries, active, sections })` used to answer only the section being viewed, so a host could draw a section's menu only while the visitor was inside it. The result now also carries `trees`: one `{ under, items, standalone, inside }` for every sidebar entry that carries a local menu, in menu order. `inside` is true for the section being viewed, which a host draws open; the others it draws folded behind the entry's arrow. `under`, `items` and `standalone` still describe the viewed section, so existing callers keep working. Widgets are unaffected: this is the host surface (`@colixsystems/widget-sdk/host`), and the widget contract version does not change.
85
+
86
+ ### What's new in 0.196.0 (contract 1.157.0)
87
+
88
+ **A gradient can carry more than two colours.** A component gradient was exactly `{ from, to, angle }`. It now takes an optional `via` list of up to three middle colours, painted in order and evenly spaced between `from` and `to`:
89
+
90
+ ```js
91
+ { from: "#0f172a", via: ["#7c3aed", "#db2777"], to: "#f59e0b", angle: 135 }
92
+ ```
93
+
94
+ - **`normaliseComponentGradient`** returns `via` only when the value carries some, so a two-colour gradient normalises exactly as before. A malformed `via` (not a list, more than `CONTRACT.themeComponentGradient.maxViaStops` entries, or an entry that is not a hex colour) is dropped and the two ends still paint, so a stored value never loses its gradient over it. Pass `{ strict: true }` to get `null` for a malformed `via` instead, which is what a write boundary wants.
95
+ - **`componentGradientColors(gradient)`** gives a normalised gradient's colours in paint order, ready for `<Gradient colors={…}>`. **`componentGradientMidpoint(gradient)`** gives the colour halfway along it, the basis for choosing readable text.
96
+ - **`<Card>` and `<Button>` paint the middle colours** when their gradient carries them, and a layout container's wash and photo scrim do the same on the web Player and in the exported app. A middle colour of a container wash may bind to the theme (`"theme:colors.primary"`) like its two ends.
97
+ - **Fully additive.** Every stored two-colour gradient renders byte-identically.
81
98
 
82
99
  ### What's new in 0.195.0 (contract 1.156.0)
83
100
 
@@ -5,7 +5,13 @@
5
5
  // Every resolver emits ONLY the fields that are set, so a widget's own base
6
6
  // style keeps whatever the author left alone.
7
7
 
8
- import { CONTRACT, isHexColor, mixHex, normaliseComponentGradient } from "./contract.js";
8
+ import {
9
+ CONTRACT,
10
+ isHexColor,
11
+ normaliseComponentGradient,
12
+ componentGradientColors,
13
+ componentGradientMidpoint,
14
+ } from "./contract.js";
9
15
  import { normaliseCornerRadius, cornerRadiusStyle } from "./corner-radius.js";
10
16
  import { normaliseSpacing, spacingStyle, isZeroSpacing } from "./spacing.js";
11
17
  import { flattenStyle } from "./flatten-style.js";
@@ -117,7 +123,7 @@ export function cardLayoutStyle(style) {
117
123
  export function gradientProps(value) {
118
124
  const gradient = normaliseComponentGradient(value);
119
125
  return gradient
120
- ? { colors: [gradient.from, gradient.to], angle: gradient.angle }
126
+ ? { colors: componentGradientColors(gradient), angle: gradient.angle }
121
127
  : null;
122
128
  }
123
129
 
@@ -131,8 +137,13 @@ export function gradientProps(value) {
131
137
  */
132
138
  export function cardBackdrop(styles, gradient) {
133
139
  if (gradient) {
134
- const [from, to] = gradient.colors;
135
- return isOpaqueHex(from) && isOpaqueHex(to) ? mixHex(from, to, 0.5) : null;
140
+ const { colors } = gradient;
141
+ if (!colors.every(isOpaqueHex)) return null;
142
+ return componentGradientMidpoint({
143
+ from: colors[0],
144
+ via: colors.slice(1, -1),
145
+ to: colors[colors.length - 1],
146
+ });
136
147
  }
137
148
  const fill = flattenStyle(styles).backgroundColor;
138
149
  return isOpaqueHex(fill) ? fill : null;
package/dist/contract.cjs CHANGED
@@ -332,6 +332,8 @@ const THEME_COMPONENT_GRADIENT = Object.freeze({
332
332
  angleMin: 0,
333
333
  angleMax: 359,
334
334
  defaultAngle: 180,
335
+ // sc-10685: middle colours allowed between the two ends (`via`).
336
+ maxViaStops: 3,
335
337
  });
336
338
 
337
339
  // REQ-THEME-LOOK: the app-wide SPACING MULTIPLIER. Layout spacing lives on the
@@ -5099,7 +5101,14 @@ const CONTRACT = deepFreeze({
5099
5101
  // `update(userId, { name?, email? })`, gated on `users.write:*`; the
5100
5102
  // linter requires that scope for a `.update(` call beside `useUsers(`.
5101
5103
  // `DirectoryError` gains `reason`, the host's specific refusal code.
5102
- version: "1.156.0",
5104
+ // 1.157.0: additive (sc-10685) — a component gradient takes an optional
5105
+ // `via` list of up to `themeComponentGradient.maxViaStops` middle colours
5106
+ // between `from` and `to`, evenly spaced. `normaliseComponentGradient`
5107
+ // returns it only when present, so a two-colour value normalises exactly
5108
+ // as before; a malformed `via` is dropped, or voids the gradient under the
5109
+ // new `{ strict: true }` option. New `componentGradientColors` / `componentGradientMidpoint`
5110
+ // read the paint list and the ink basis off a normalised gradient.
5111
+ version: "1.157.0",
5103
5112
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5104
5113
  hooks: HOOKS,
5105
5114
  primitives: PRIMITIVES,
@@ -5418,6 +5427,10 @@ const GRADIENT_HEX_RE = HEX_RE;
5418
5427
 
5419
5428
  /**
5420
5429
  * sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
5430
+ * sc-10685: plus `via`, the middle colours, only when the value carries some.
5431
+ * A malformed `via` is dropped and the two ends still paint, so a stored value
5432
+ * never loses its wash over it; `{ strict: true }` voids the gradient instead,
5433
+ * for the boundaries that must refuse the write.
5421
5434
  *
5422
5435
  * ONE validator for a value arriving by two routes — the theme token and the
5423
5436
  * author's never-coerced per-instance `props.style` — so `set_theme` and the
@@ -5426,7 +5439,7 @@ const GRADIENT_HEX_RE = HEX_RE;
5426
5439
  * hex grammar is also the injection guard — these stops are interpolated into
5427
5440
  * CSS and into generated export source.
5428
5441
  */
5429
- function normaliseComponentGradient(raw) {
5442
+ function normaliseComponentGradient(raw, options) {
5430
5443
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
5431
5444
  const from = typeof raw.from === "string" ? raw.from.trim() : "";
5432
5445
  const to = typeof raw.to === "string" ? raw.to.trim() : "";
@@ -5436,7 +5449,44 @@ function normaliseComponentGradient(raw) {
5436
5449
  // land NaN in a style.
5437
5450
  const angle = raw.angle;
5438
5451
  const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
5439
- return { from, to, angle: ((deg % 360) + 360) % 360 };
5452
+ const via = normaliseGradientVia(raw.via);
5453
+ if (!via && options && options.strict) return null;
5454
+ const gradient = { from, to, angle: ((deg % 360) + 360) % 360 };
5455
+ if (via && via.length > 0) gradient.via = via;
5456
+ return gradient;
5457
+ }
5458
+
5459
+ // All or nothing: one bad middle colour and none of them paint. `null` = malformed.
5460
+ function normaliseGradientVia(raw) {
5461
+ if (raw === undefined || raw === null) return [];
5462
+ if (!Array.isArray(raw) || raw.length > THEME_COMPONENT_GRADIENT.maxViaStops) {
5463
+ return null;
5464
+ }
5465
+ const via = [];
5466
+ for (const item of raw) {
5467
+ const stop = typeof item === "string" ? item.trim() : "";
5468
+ if (!GRADIENT_HEX_RE.test(stop)) return null;
5469
+ via.push(stop);
5470
+ }
5471
+ return via;
5472
+ }
5473
+
5474
+ /**
5475
+ * sc-10685 — a NORMALISED gradient's colours in paint order, evenly spaced:
5476
+ * `[from, ...via, to]`. The one place both hosts read the list from.
5477
+ */
5478
+ function componentGradientColors(gradient) {
5479
+ return [gradient.from, ...(gradient.via || []), gradient.to];
5480
+ }
5481
+
5482
+ /**
5483
+ * sc-10685 — the colour halfway along a NORMALISED gradient, which is what
5484
+ * text laid across it is contrasted against. Two colours: their mix, as before.
5485
+ */
5486
+ function componentGradientMidpoint(gradient) {
5487
+ const colors = componentGradientColors(gradient);
5488
+ const middle = (colors.length - 1) / 2;
5489
+ return mixHex(colors[Math.floor(middle)], colors[Math.ceil(middle)], 0.5);
5440
5490
  }
5441
5491
 
5442
5492
  // REQ-THEME-LOOK: coerce a theme's spacing scale into the usable band. Shared
@@ -5479,6 +5529,8 @@ module.exports = {
5479
5529
  INK_CONTRAST,
5480
5530
  gradientAngleToVector,
5481
5531
  normaliseComponentGradient,
5532
+ componentGradientColors,
5533
+ componentGradientMidpoint,
5482
5534
  isThemeColorTokenRef,
5483
5535
  parseThemeColorTokenRef,
5484
5536
  formatThemeColorTokenRef,
package/dist/contract.js CHANGED
@@ -332,6 +332,8 @@ const THEME_COMPONENT_GRADIENT = Object.freeze({
332
332
  angleMin: 0,
333
333
  angleMax: 359,
334
334
  defaultAngle: 180,
335
+ // sc-10685: middle colours allowed between the two ends (`via`).
336
+ maxViaStops: 3,
335
337
  });
336
338
 
337
339
  // REQ-THEME-LOOK: the app-wide SPACING MULTIPLIER. Layout spacing lives on the
@@ -5095,7 +5097,14 @@ const CONTRACT = deepFreeze({
5095
5097
  // `update(userId, { name?, email? })`, gated on `users.write:*`; the
5096
5098
  // linter requires that scope for a `.update(` call beside `useUsers(`.
5097
5099
  // `DirectoryError` gains `reason`, the host's specific refusal code.
5098
- version: "1.156.0",
5100
+ // 1.157.0: additive (sc-10685) — a component gradient takes an optional
5101
+ // `via` list of up to `themeComponentGradient.maxViaStops` middle colours
5102
+ // between `from` and `to`, evenly spaced. `normaliseComponentGradient`
5103
+ // returns it only when present, so a two-colour value normalises exactly
5104
+ // as before; a malformed `via` is dropped, or voids the gradient under the
5105
+ // new `{ strict: true }` option. New `componentGradientColors` / `componentGradientMidpoint`
5106
+ // read the paint list and the ink basis off a normalised gradient.
5107
+ version: "1.157.0",
5099
5108
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5100
5109
  hooks: HOOKS,
5101
5110
  primitives: PRIMITIVES,
@@ -5414,6 +5423,10 @@ const GRADIENT_HEX_RE = HEX_RE;
5414
5423
 
5415
5424
  /**
5416
5425
  * sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
5426
+ * sc-10685: plus `via`, the middle colours, only when the value carries some.
5427
+ * A malformed `via` is dropped and the two ends still paint, so a stored value
5428
+ * never loses its wash over it; `{ strict: true }` voids the gradient instead,
5429
+ * for the boundaries that must refuse the write.
5417
5430
  *
5418
5431
  * ONE validator for a value arriving by two routes — the theme token and the
5419
5432
  * author's never-coerced per-instance `props.style` — so `set_theme` and the
@@ -5422,7 +5435,7 @@ const GRADIENT_HEX_RE = HEX_RE;
5422
5435
  * hex grammar is also the injection guard — these stops are interpolated into
5423
5436
  * CSS and into generated export source.
5424
5437
  */
5425
- function normaliseComponentGradient(raw) {
5438
+ function normaliseComponentGradient(raw, options) {
5426
5439
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
5427
5440
  const from = typeof raw.from === "string" ? raw.from.trim() : "";
5428
5441
  const to = typeof raw.to === "string" ? raw.to.trim() : "";
@@ -5432,7 +5445,44 @@ function normaliseComponentGradient(raw) {
5432
5445
  // land NaN in a style.
5433
5446
  const angle = raw.angle;
5434
5447
  const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
5435
- return { from, to, angle: ((deg % 360) + 360) % 360 };
5448
+ const via = normaliseGradientVia(raw.via);
5449
+ if (!via && options && options.strict) return null;
5450
+ const gradient = { from, to, angle: ((deg % 360) + 360) % 360 };
5451
+ if (via && via.length > 0) gradient.via = via;
5452
+ return gradient;
5453
+ }
5454
+
5455
+ // All or nothing: one bad middle colour and none of them paint. `null` = malformed.
5456
+ function normaliseGradientVia(raw) {
5457
+ if (raw === undefined || raw === null) return [];
5458
+ if (!Array.isArray(raw) || raw.length > THEME_COMPONENT_GRADIENT.maxViaStops) {
5459
+ return null;
5460
+ }
5461
+ const via = [];
5462
+ for (const item of raw) {
5463
+ const stop = typeof item === "string" ? item.trim() : "";
5464
+ if (!GRADIENT_HEX_RE.test(stop)) return null;
5465
+ via.push(stop);
5466
+ }
5467
+ return via;
5468
+ }
5469
+
5470
+ /**
5471
+ * sc-10685 — a NORMALISED gradient's colours in paint order, evenly spaced:
5472
+ * `[from, ...via, to]`. The one place both hosts read the list from.
5473
+ */
5474
+ function componentGradientColors(gradient) {
5475
+ return [gradient.from, ...(gradient.via || []), gradient.to];
5476
+ }
5477
+
5478
+ /**
5479
+ * sc-10685 — the colour halfway along a NORMALISED gradient, which is what
5480
+ * text laid across it is contrasted against. Two colours: their mix, as before.
5481
+ */
5482
+ function componentGradientMidpoint(gradient) {
5483
+ const colors = componentGradientColors(gradient);
5484
+ const middle = (colors.length - 1) / 2;
5485
+ return mixHex(colors[Math.floor(middle)], colors[Math.ceil(middle)], 0.5);
5436
5486
  }
5437
5487
 
5438
5488
  // REQ-THEME-LOOK: coerce a theme's spacing scale into the usable band. Shared
@@ -5475,6 +5525,8 @@ export {
5475
5525
  INK_CONTRAST,
5476
5526
  gradientAngleToVector,
5477
5527
  normaliseComponentGradient,
5528
+ componentGradientColors,
5529
+ componentGradientMidpoint,
5478
5530
  isThemeColorTokenRef,
5479
5531
  parseThemeColorTokenRef,
5480
5532
  formatThemeColorTokenRef,
package/dist/host.d.ts CHANGED
@@ -254,14 +254,26 @@ export interface SidebarUnfoldItem {
254
254
  [field: string]: unknown;
255
255
  }
256
256
 
257
+ /** One section's local menu and the sidebar entry it hangs beneath. `inside`
258
+ * marks the section the visitor is in — the one that starts unfolded. */
259
+ export interface SidebarUnfoldTree {
260
+ under: string;
261
+ items: Array<SidebarUnfoldItem & { current: boolean }>;
262
+ standalone: boolean;
263
+ inside: boolean;
264
+ }
265
+
257
266
  /** Where a page's `SIDEBAR` local menu unfolds (REQ-NAV-LOCAL, sc-7696).
258
- * `under` is the global menu entry the items hang beneath, or null when
259
- * nothing unfolds. `standalone` marks the case where the global menu does not
260
- * list the page, so the page itself is the entry. */
267
+ * `under` is the global menu entry the visitor's own section hangs beneath,
268
+ * or null when they are in none. `standalone` marks the case where the global
269
+ * menu does not list the page, so the page itself is the entry. `trees` is
270
+ * every section an entry carries, in menu order, so an entry can offer its
271
+ * menu from any page; a `standalone` tree comes last. */
261
272
  export interface SidebarUnfold {
262
273
  under: string | null;
263
274
  items: Array<SidebarUnfoldItem & { current: boolean }>;
264
275
  standalone: boolean;
276
+ trees: SidebarUnfoldTree[];
265
277
  }
266
278
 
267
279
  /** Resolve where the viewed page's local menu unfolds in the sidebar and the
package/dist/index.d.ts CHANGED
@@ -3449,18 +3449,30 @@ export interface ComponentGradient {
3449
3449
  from: string;
3450
3450
  to: string;
3451
3451
  angle: number;
3452
+ /** Middle colours between `from` and `to`. Present only when there are some. */
3453
+ via?: string[];
3452
3454
  }
3453
3455
 
3454
3456
  /**
3455
3457
  * Normalise a component `gradient` style value to `{ from, to, angle }`, or null
3456
3458
  * when it is unusable. Both stops are required; the angle wraps into 0-359.
3459
+ * An optional `via` list (at most `CONTRACT.themeComponentGradient.maxViaStops`
3460
+ * hex colours) is kept as given. A malformed one is dropped and the two ends
3461
+ * still paint; pass `{ strict: true }` to get null for it instead.
3457
3462
  * Shared by the theme-token coercion and the widget render path, so the same
3458
3463
  * value cannot resolve two ways.
3459
3464
  */
3460
3465
  export function normaliseComponentGradient(
3461
3466
  raw: unknown,
3467
+ options?: { strict?: boolean },
3462
3468
  ): ComponentGradient | null;
3463
3469
 
3470
+ /** A normalised gradient's colours in paint order: `[from, ...via, to]`. */
3471
+ export function componentGradientColors(gradient: ComponentGradient): string[];
3472
+
3473
+ /** The colour halfway along a normalised gradient: the basis for text ink. */
3474
+ export function componentGradientMidpoint(gradient: ComponentGradient): string;
3475
+
3464
3476
  /** sc-8326 — the `theme:<path>` binding prefix, shared with the hosts' resolver. */
3465
3477
  export const THEME_TOKEN_REF_PREFIX: string;
3466
3478
 
package/dist/index.js CHANGED
@@ -178,6 +178,8 @@ export {
178
178
  deriveAccentTints,
179
179
  gradientAngleToVector,
180
180
  normaliseComponentGradient,
181
+ componentGradientColors,
182
+ componentGradientMidpoint,
181
183
  isThemeColorTokenRef,
182
184
  parseThemeColorTokenRef,
183
185
  formatThemeColorTokenRef,
@@ -171,6 +171,8 @@ export {
171
171
  deriveAccentTints,
172
172
  gradientAngleToVector,
173
173
  normaliseComponentGradient,
174
+ componentGradientColors,
175
+ componentGradientMidpoint,
174
176
  isThemeColorTokenRef,
175
177
  parseThemeColorTokenRef,
176
178
  formatThemeColorTokenRef,
@@ -636,21 +636,25 @@ function resolveTopBarTokens(theme) {
636
636
  * inside it, so the menu is never lost — a section off the menu is exactly
637
637
  * the case that would otherwise render blank.
638
638
  *
639
+ * sc-10693: `trees` answers EVERY section, not only the visitor's own, so a
640
+ * section's entry can offer its menu from any page. `under` / `items` /
641
+ * `standalone` still name the one the visitor is inside.
642
+ *
639
643
  * @param {{ entries?: unknown, active?: unknown, sections?: unknown }} input
640
644
  * `entries` — the global menu's keys, in menu order.
641
645
  * `active` — the key of the page being viewed.
642
646
  * `sections` — `{ [key]: { menu, items } }` for pages whose local nav is
643
647
  * `SIDEBAR`; `menu` identifies the saved menu, `items` carry their own key.
644
- * @returns {{ under: string|null, items: Array<object>, standalone: boolean }}
645
- * `under` is null when nothing unfolds; when `standalone` it names the
646
- * section's own page, which the host draws as the entry. Each item is
647
- * returned with a `current` flag rather than leaving each host to
648
- * recompute it.
648
+ * @returns {{ under: string|null, items: Array<object>, standalone: boolean, trees: Array<object> }}
649
+ * `under` is null when the visitor is in no section; when `standalone` it
650
+ * names the section's own page, which the host draws as the entry. Each
651
+ * item is returned with a `current` flag rather than leaving each host to
652
+ * recompute it. `trees` lists `{ under, items, standalone, inside }` in menu
653
+ * order, a `standalone` one last; `inside` marks the visitor's own.
649
654
  */
650
655
  function resolveSidebarUnfold(input) {
651
656
  const { entries, active, sections } = isPlainObject(input) ? input : {};
652
- const nothing = { under: null, items: [], standalone: false };
653
- if (typeof active !== "string" || !active) return nothing;
657
+ const viewed = typeof active === "string" && active ? active : null;
654
658
  const bank = isPlainObject(sections) ? sections : {};
655
659
  const order = Array.isArray(entries) ? entries : [];
656
660
  const sectionAt = (key) => {
@@ -663,31 +667,60 @@ function resolveSidebarUnfold(input) {
663
667
  return (
664
668
  Boolean(candidate) &&
665
669
  candidate.items.some(
666
- (item) => isPlainObject(item) && item.key === active,
670
+ (item) => isPlainObject(item) && item.key === viewed,
667
671
  )
668
672
  );
669
673
  };
670
674
  // The page's own section wins; otherwise the section whose menu lists it,
671
675
  // preferring one a global entry carries so the tree hangs where it always
672
676
  // does. Both are the same section from the visitor's side.
673
- const section =
674
- sectionAt(active) ||
675
- sectionAt(order.find(lists)) ||
676
- sectionAt(Object.keys(bank).find(lists));
677
- if (!section) return nothing;
678
- const marked = section.items.map((item) => ({
679
- ...(isPlainObject(item) ? item : {}),
680
- current:
681
- isPlainObject(item) && typeof item.key === "string" && item.key === active,
682
- }));
683
- const owner = order.find((key) => {
684
- const candidate = isPlainObject(bank[key]) ? bank[key] : null;
685
- return Boolean(candidate) && candidate.menu === section.menu;
686
- });
687
- if (typeof owner === "string" && owner) {
688
- return { under: owner, items: marked, standalone: false };
677
+ const section = viewed
678
+ ? sectionAt(viewed) ||
679
+ sectionAt(order.find(lists)) ||
680
+ sectionAt(Object.keys(bank).find(lists))
681
+ : null;
682
+ const mark = (items) =>
683
+ items.map((item) => ({
684
+ ...(isPlainObject(item) ? item : {}),
685
+ current:
686
+ viewed !== null &&
687
+ isPlainObject(item) &&
688
+ typeof item.key === "string" &&
689
+ item.key === viewed,
690
+ }));
691
+ // A saved menu hangs under the FIRST global entry carrying it, so two
692
+ // entries sharing one menu never draw it twice.
693
+ const claimed = [];
694
+ const trees = [];
695
+ for (const key of order) {
696
+ if (!isPlainObject(bank[key])) continue;
697
+ const { menu } = bank[key];
698
+ if (claimed.includes(menu)) continue;
699
+ claimed.push(menu);
700
+ // An unusable key still owns its menu: nothing may hang it elsewhere.
701
+ if (typeof key !== "string" || !key) continue;
702
+ const inside = Boolean(section) && menu === section.menu;
703
+ const own = inside ? section : sectionAt(key);
704
+ if (!own) continue;
705
+ trees.push({ under: key, items: mark(own.items), standalone: false, inside });
706
+ }
707
+ if (!section) return { under: null, items: [], standalone: false, trees };
708
+ const owned = trees.find((tree) => tree.inside);
709
+ if (owned) {
710
+ return { under: owned.under, items: owned.items, standalone: false, trees };
689
711
  }
690
- return { under: section.key, items: marked, standalone: true };
712
+ const alone = {
713
+ under: section.key,
714
+ items: mark(section.items),
715
+ standalone: true,
716
+ inside: true,
717
+ };
718
+ return {
719
+ under: alone.under,
720
+ items: alone.items,
721
+ standalone: true,
722
+ trees: [...trees, alone],
723
+ };
691
724
  }
692
725
 
693
726
  /**
@@ -627,21 +627,25 @@ export function resolveTopBarTokens(theme) {
627
627
  * inside it, so the menu is never lost — a section off the menu is exactly
628
628
  * the case that would otherwise render blank.
629
629
  *
630
+ * sc-10693: `trees` answers EVERY section, not only the visitor's own, so a
631
+ * section's entry can offer its menu from any page. `under` / `items` /
632
+ * `standalone` still name the one the visitor is inside.
633
+ *
630
634
  * @param {{ entries?: unknown, active?: unknown, sections?: unknown }} input
631
635
  * `entries` — the global menu's keys, in menu order.
632
636
  * `active` — the key of the page being viewed.
633
637
  * `sections` — `{ [key]: { menu, items } }` for pages whose local nav is
634
638
  * `SIDEBAR`; `menu` identifies the saved menu, `items` carry their own key.
635
- * @returns {{ under: string|null, items: Array<object>, standalone: boolean }}
636
- * `under` is null when nothing unfolds; when `standalone` it names the
637
- * section's own page, which the host draws as the entry. Each item is
638
- * returned with a `current` flag rather than leaving each host to
639
- * recompute it.
639
+ * @returns {{ under: string|null, items: Array<object>, standalone: boolean, trees: Array<object> }}
640
+ * `under` is null when the visitor is in no section; when `standalone` it
641
+ * names the section's own page, which the host draws as the entry. Each
642
+ * item is returned with a `current` flag rather than leaving each host to
643
+ * recompute it. `trees` lists `{ under, items, standalone, inside }` in menu
644
+ * order, a `standalone` one last; `inside` marks the visitor's own.
640
645
  */
641
646
  export function resolveSidebarUnfold(input) {
642
647
  const { entries, active, sections } = isPlainObject(input) ? input : {};
643
- const nothing = { under: null, items: [], standalone: false };
644
- if (typeof active !== "string" || !active) return nothing;
648
+ const viewed = typeof active === "string" && active ? active : null;
645
649
  const bank = isPlainObject(sections) ? sections : {};
646
650
  const order = Array.isArray(entries) ? entries : [];
647
651
  const sectionAt = (key) => {
@@ -654,31 +658,60 @@ export function resolveSidebarUnfold(input) {
654
658
  return (
655
659
  Boolean(candidate) &&
656
660
  candidate.items.some(
657
- (item) => isPlainObject(item) && item.key === active,
661
+ (item) => isPlainObject(item) && item.key === viewed,
658
662
  )
659
663
  );
660
664
  };
661
665
  // The page's own section wins; otherwise the section whose menu lists it,
662
666
  // preferring one a global entry carries so the tree hangs where it always
663
667
  // does. Both are the same section from the visitor's side.
664
- const section =
665
- sectionAt(active) ||
666
- sectionAt(order.find(lists)) ||
667
- sectionAt(Object.keys(bank).find(lists));
668
- if (!section) return nothing;
669
- const marked = section.items.map((item) => ({
670
- ...(isPlainObject(item) ? item : {}),
671
- current:
672
- isPlainObject(item) && typeof item.key === "string" && item.key === active,
673
- }));
674
- const owner = order.find((key) => {
675
- const candidate = isPlainObject(bank[key]) ? bank[key] : null;
676
- return Boolean(candidate) && candidate.menu === section.menu;
677
- });
678
- if (typeof owner === "string" && owner) {
679
- return { under: owner, items: marked, standalone: false };
668
+ const section = viewed
669
+ ? sectionAt(viewed) ||
670
+ sectionAt(order.find(lists)) ||
671
+ sectionAt(Object.keys(bank).find(lists))
672
+ : null;
673
+ const mark = (items) =>
674
+ items.map((item) => ({
675
+ ...(isPlainObject(item) ? item : {}),
676
+ current:
677
+ viewed !== null &&
678
+ isPlainObject(item) &&
679
+ typeof item.key === "string" &&
680
+ item.key === viewed,
681
+ }));
682
+ // A saved menu hangs under the FIRST global entry carrying it, so two
683
+ // entries sharing one menu never draw it twice.
684
+ const claimed = [];
685
+ const trees = [];
686
+ for (const key of order) {
687
+ if (!isPlainObject(bank[key])) continue;
688
+ const { menu } = bank[key];
689
+ if (claimed.includes(menu)) continue;
690
+ claimed.push(menu);
691
+ // An unusable key still owns its menu: nothing may hang it elsewhere.
692
+ if (typeof key !== "string" || !key) continue;
693
+ const inside = Boolean(section) && menu === section.menu;
694
+ const own = inside ? section : sectionAt(key);
695
+ if (!own) continue;
696
+ trees.push({ under: key, items: mark(own.items), standalone: false, inside });
697
+ }
698
+ if (!section) return { under: null, items: [], standalone: false, trees };
699
+ const owned = trees.find((tree) => tree.inside);
700
+ if (owned) {
701
+ return { under: owned.under, items: owned.items, standalone: false, trees };
680
702
  }
681
- return { under: section.key, items: marked, standalone: true };
703
+ const alone = {
704
+ under: section.key,
705
+ items: mark(section.items),
706
+ standalone: true,
707
+ inside: true,
708
+ };
709
+ return {
710
+ under: alone.under,
711
+ items: alone.items,
712
+ standalone: true,
713
+ trees: [...trees, alone],
714
+ };
682
715
  }
683
716
 
684
717
  /**
@@ -148,7 +148,9 @@ function resolveAgainstSchema(schema, value, theme) {
148
148
  // VALUES — colours, sizes, enums — so an allowlisted `theme:` string in it can
149
149
  // only ever have been a binding. The depth cap keeps the walk bounded for the
150
150
  // structured style values (a gradient's stops, per-corner radii).
151
- const MAX_STYLE_DEPTH = 3;
151
+ // sc-10685: 4, so a widget gradient's `via` list (style > gradient > via >
152
+ // item) resolves like its two ends.
153
+ const MAX_STYLE_DEPTH = 4;
152
154
 
153
155
  function resolveStyleBag(value, theme, depth) {
154
156
  if (typeof value === "string") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.195.0",
3
+ "version": "0.197.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",
package/src/contract.js CHANGED
@@ -332,6 +332,8 @@ const THEME_COMPONENT_GRADIENT = Object.freeze({
332
332
  angleMin: 0,
333
333
  angleMax: 359,
334
334
  defaultAngle: 180,
335
+ // sc-10685: middle colours allowed between the two ends (`via`).
336
+ maxViaStops: 3,
335
337
  });
336
338
 
337
339
  // REQ-THEME-LOOK: the app-wide SPACING MULTIPLIER. Layout spacing lives on the
@@ -5095,7 +5097,14 @@ const CONTRACT = deepFreeze({
5095
5097
  // `update(userId, { name?, email? })`, gated on `users.write:*`; the
5096
5098
  // linter requires that scope for a `.update(` call beside `useUsers(`.
5097
5099
  // `DirectoryError` gains `reason`, the host's specific refusal code.
5098
- version: "1.156.0",
5100
+ // 1.157.0: additive (sc-10685) — a component gradient takes an optional
5101
+ // `via` list of up to `themeComponentGradient.maxViaStops` middle colours
5102
+ // between `from` and `to`, evenly spaced. `normaliseComponentGradient`
5103
+ // returns it only when present, so a two-colour value normalises exactly
5104
+ // as before; a malformed `via` is dropped, or voids the gradient under the
5105
+ // new `{ strict: true }` option. New `componentGradientColors` / `componentGradientMidpoint`
5106
+ // read the paint list and the ink basis off a normalised gradient.
5107
+ version: "1.157.0",
5099
5108
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5100
5109
  hooks: HOOKS,
5101
5110
  primitives: PRIMITIVES,
@@ -5414,6 +5423,10 @@ const GRADIENT_HEX_RE = HEX_RE;
5414
5423
 
5415
5424
  /**
5416
5425
  * sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
5426
+ * sc-10685: plus `via`, the middle colours, only when the value carries some.
5427
+ * A malformed `via` is dropped and the two ends still paint, so a stored value
5428
+ * never loses its wash over it; `{ strict: true }` voids the gradient instead,
5429
+ * for the boundaries that must refuse the write.
5417
5430
  *
5418
5431
  * ONE validator for a value arriving by two routes — the theme token and the
5419
5432
  * author's never-coerced per-instance `props.style` — so `set_theme` and the
@@ -5422,7 +5435,7 @@ const GRADIENT_HEX_RE = HEX_RE;
5422
5435
  * hex grammar is also the injection guard — these stops are interpolated into
5423
5436
  * CSS and into generated export source.
5424
5437
  */
5425
- function normaliseComponentGradient(raw) {
5438
+ function normaliseComponentGradient(raw, options) {
5426
5439
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
5427
5440
  const from = typeof raw.from === "string" ? raw.from.trim() : "";
5428
5441
  const to = typeof raw.to === "string" ? raw.to.trim() : "";
@@ -5432,7 +5445,44 @@ function normaliseComponentGradient(raw) {
5432
5445
  // land NaN in a style.
5433
5446
  const angle = raw.angle;
5434
5447
  const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
5435
- return { from, to, angle: ((deg % 360) + 360) % 360 };
5448
+ const via = normaliseGradientVia(raw.via);
5449
+ if (!via && options && options.strict) return null;
5450
+ const gradient = { from, to, angle: ((deg % 360) + 360) % 360 };
5451
+ if (via && via.length > 0) gradient.via = via;
5452
+ return gradient;
5453
+ }
5454
+
5455
+ // All or nothing: one bad middle colour and none of them paint. `null` = malformed.
5456
+ function normaliseGradientVia(raw) {
5457
+ if (raw === undefined || raw === null) return [];
5458
+ if (!Array.isArray(raw) || raw.length > THEME_COMPONENT_GRADIENT.maxViaStops) {
5459
+ return null;
5460
+ }
5461
+ const via = [];
5462
+ for (const item of raw) {
5463
+ const stop = typeof item === "string" ? item.trim() : "";
5464
+ if (!GRADIENT_HEX_RE.test(stop)) return null;
5465
+ via.push(stop);
5466
+ }
5467
+ return via;
5468
+ }
5469
+
5470
+ /**
5471
+ * sc-10685 — a NORMALISED gradient's colours in paint order, evenly spaced:
5472
+ * `[from, ...via, to]`. The one place both hosts read the list from.
5473
+ */
5474
+ function componentGradientColors(gradient) {
5475
+ return [gradient.from, ...(gradient.via || []), gradient.to];
5476
+ }
5477
+
5478
+ /**
5479
+ * sc-10685 — the colour halfway along a NORMALISED gradient, which is what
5480
+ * text laid across it is contrasted against. Two colours: their mix, as before.
5481
+ */
5482
+ function componentGradientMidpoint(gradient) {
5483
+ const colors = componentGradientColors(gradient);
5484
+ const middle = (colors.length - 1) / 2;
5485
+ return mixHex(colors[Math.floor(middle)], colors[Math.ceil(middle)], 0.5);
5436
5486
  }
5437
5487
 
5438
5488
  // REQ-THEME-LOOK: coerce a theme's spacing scale into the usable band. Shared
@@ -5475,6 +5525,8 @@ export {
5475
5525
  INK_CONTRAST,
5476
5526
  gradientAngleToVector,
5477
5527
  normaliseComponentGradient,
5528
+ componentGradientColors,
5529
+ componentGradientMidpoint,
5478
5530
  isThemeColorTokenRef,
5479
5531
  parseThemeColorTokenRef,
5480
5532
  formatThemeColorTokenRef,