@colixsystems/widget-sdk 0.68.0 → 0.69.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
@@ -61,9 +61,10 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
61
61
 
62
62
  - **`useTheme()` gains a `components` slice.** It is HOST-OWNED plumbing, not an author API: by the time your component renders, the host has already folded the matching tokens into `props.style`. Do not read `theme.components` and do not re-apply it — you would double-apply the theme and defeat the author's own styling.
63
63
  - **New host-only exports on `@colixsystems/widget-sdk/host`:** `normaliseThemeComponents(raw)` and `applyThemeComponentStyle(manifestId, theme, props)`. These are the platform-host surface (the web Player / Studio canvas and the exported Expo app), never the author API — one implementation, so the two hosts cannot diverge.
64
- - **`CONTRACT.themeComponents` / `CONTRACT.themeComponentShadows`** publish the vocabulary: each scope's tokens, their value types and ranges, and the widget → style-field bindings. `themeTokens.components` defaults to `{}`.
64
+ - **`CONTRACT.themeComponents` / `CONTRACT.themeComponentShadows` / `CONTRACT.themeComponentGradient`** publish the vocabulary: each scope's tokens, their value types and ranges, and the widget → style-field bindings. `themeTokens.components` defaults to `{}`.
65
+ - **The `button` and `card` scopes carry a `gradient` token (sc-3727)** — `{ from: "#hex", to: "#hex", angle: 0-359 }`, painted through the `<Gradient>` primitive. It reaches a widget as an ordinary style field (`gradient`, `cardGradient`, `submitGradient`), so the author rule is unchanged: read `props.style`, and treat an explicit `null` as "this instance opted out of the app-wide gradient" rather than as unset.
65
66
 
66
- `CONTRACT.version` → `1.45.0`. Additive; no existing export changed signature, and an unthemed app renders identically.
67
+ `CONTRACT.version` → `1.46.0`. Additive; no existing export changed signature, and an unthemed app renders identically.
67
68
 
68
69
  ### What's new in 0.66.0
69
70
 
package/dist/contract.cjs CHANGED
@@ -107,6 +107,17 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
107
107
  // cannot drift between what Mason may emit and what a host actually applies.
108
108
  const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
109
109
 
110
+ // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
111
+ // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
112
+ // variant (a component fill projects through `<Gradient>`, which is linear-only
113
+ // on both hosts). Declared once so the coercion, the Studio control and the
114
+ // planner prompt agree on the bounds.
115
+ const THEME_COMPONENT_GRADIENT = Object.freeze({
116
+ angleMin: 0,
117
+ angleMax: 359,
118
+ defaultAngle: 180,
119
+ });
120
+
110
121
  // The card-surface field names shared by every widget that paints its own card
111
122
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
112
123
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -115,6 +126,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
115
126
  radius: "cardRadius",
116
127
  padding: "cardPadding",
117
128
  shadow: "shadow",
129
+ gradient: "cardGradient",
118
130
  });
119
131
 
120
132
  // A form widget's submit button — the `button` scope reaches it through the
@@ -122,6 +134,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
122
134
  const FORM_SUBMIT_FIELDS = Object.freeze({
123
135
  background: "submitBackground",
124
136
  textColor: "submitTextColor",
137
+ gradient: "submitGradient",
125
138
  });
126
139
 
127
140
  const THEME_COMPONENTS = Object.freeze({
@@ -134,6 +147,7 @@ const THEME_COMPONENTS = Object.freeze({
134
147
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
135
148
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
136
149
  shadow: Object.freeze({ type: "shadow" }),
150
+ gradient: Object.freeze({ type: "gradient" }),
137
151
  }),
138
152
  targets: Object.freeze({
139
153
  "appstudio.button": Object.freeze({
@@ -143,6 +157,7 @@ const THEME_COMPONENTS = Object.freeze({
143
157
  radius: "radius",
144
158
  fontSize: "fontSize",
145
159
  shadow: "shadow",
160
+ gradient: "gradient",
146
161
  }),
147
162
  "appstudio.form-input": FORM_SUBMIT_FIELDS,
148
163
  "appstudio.form-builder": FORM_SUBMIT_FIELDS,
@@ -156,6 +171,7 @@ const THEME_COMPONENTS = Object.freeze({
156
171
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
157
172
  padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
158
173
  shadow: Object.freeze({ type: "shadow" }),
174
+ gradient: Object.freeze({ type: "gradient" }),
159
175
  }),
160
176
  targets: Object.freeze({
161
177
  "appstudio.user": CARD_SURFACE_FIELDS,
@@ -166,6 +182,7 @@ const THEME_COMPONENTS = Object.freeze({
166
182
  "appstudio.notifications": CARD_SURFACE_FIELDS,
167
183
  "appstudio.form-input": CARD_SURFACE_FIELDS,
168
184
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
185
+ "appstudio.user-management": CARD_SURFACE_FIELDS,
169
186
  }),
170
187
  }),
171
188
  text: Object.freeze({
@@ -2150,7 +2167,21 @@ const CONTRACT = deepFreeze({
2150
2167
  // (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
2151
2168
  // export changed signature and a theme with no `components` key resolves to
2152
2169
  // an empty override, rendering identically to before.
2153
- version: "1.45.0",
2170
+ // 1.46.0: additive (sc-3727) — a `gradient` token type on the per-component
2171
+ // vocabulary. The `button` and `card` scopes each gain a `gradient` token
2172
+ // whose value is `{ from: "#hex", to: "#hex", angle: 0-359 }` — the
2173
+ // `backgroundGradient` grammar minus the radial variant, because a component
2174
+ // fill paints through the `<Gradient>` primitive, which is linear on both
2175
+ // hosts. `themeComponentGradient` publishes the angle bounds + default so the
2176
+ // coercion, the Studio control and the planner prompt cannot disagree. The
2177
+ // token binds to per-instance style fields the target widgets read
2178
+ // (`gradient` on Button, `cardGradient` on every card surface,
2179
+ // `submitGradient` on a form's submit button), so the theme value is a
2180
+ // DEFAULT an author overrides per instance — or suppresses with an explicit
2181
+ // null, which is how one button stays flat while the rest are gradiented.
2182
+ // Additive: no export changed signature and a theme with no gradient token
2183
+ // renders exactly as before.
2184
+ version: "1.46.0",
2154
2185
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2155
2186
  hooks: HOOKS,
2156
2187
  primitives: PRIMITIVES,
@@ -2163,6 +2194,7 @@ const CONTRACT = deepFreeze({
2163
2194
  themeTokens: DEFAULT_THEME_TOKENS,
2164
2195
  themeComponents: THEME_COMPONENTS,
2165
2196
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2197
+ themeComponentGradient: THEME_COMPONENT_GRADIENT,
2166
2198
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2167
2199
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
2168
2200
  bannedApis: BANNED_APIS,
@@ -2305,6 +2337,33 @@ function gradientAngleToVector(angle) {
2305
2337
  };
2306
2338
  }
2307
2339
 
2340
+ // Alpha is allowed here (unlike HEX_RE) because the Mason build runner already
2341
+ // persists 8-digit component colours; the host must not drop what it accepted.
2342
+ const GRADIENT_HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
2343
+
2344
+ /**
2345
+ * sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
2346
+ *
2347
+ * ONE validator for a value arriving by two routes — the theme token and the
2348
+ * author's never-coerced per-instance `props.style` — so `set_theme` and the
2349
+ * renderer cannot disagree about the same value (CLAUDE.md §3). Both stops are
2350
+ * required: a one-stop gradient would paint a fill the author never chose. The
2351
+ * hex grammar is also the injection guard — these stops are interpolated into
2352
+ * CSS and into generated export source.
2353
+ */
2354
+ function normaliseComponentGradient(raw) {
2355
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
2356
+ const from = typeof raw.from === "string" ? raw.from.trim() : "";
2357
+ const to = typeof raw.to === "string" ? raw.to.trim() : "";
2358
+ if (!GRADIENT_HEX_RE.test(from) || !GRADIENT_HEX_RE.test(to)) return null;
2359
+ const { defaultAngle } = THEME_COMPONENT_GRADIENT;
2360
+ // Read once — a second read of an accessor could return a different value and
2361
+ // land NaN in a style.
2362
+ const angle = raw.angle;
2363
+ const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
2364
+ return { from, to, angle: ((deg % 360) + 360) % 360 };
2365
+ }
2366
+
2308
2367
  module.exports = {
2309
2368
  CONTRACT,
2310
2369
  isHookAllowed,
@@ -2315,6 +2374,7 @@ module.exports = {
2315
2374
  readableTextColor,
2316
2375
  deriveAccentTints,
2317
2376
  gradientAngleToVector,
2377
+ normaliseComponentGradient,
2318
2378
  widgetTranslationPrefix,
2319
2379
  widgetTranslationKey,
2320
2380
  sharedTranslationPrefix,
package/dist/contract.js CHANGED
@@ -107,6 +107,17 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
107
107
  // cannot drift between what Mason may emit and what a host actually applies.
108
108
  const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
109
109
 
110
+ // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
111
+ // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
112
+ // variant (a component fill projects through `<Gradient>`, which is linear-only
113
+ // on both hosts). Declared once so the coercion, the Studio control and the
114
+ // planner prompt agree on the bounds.
115
+ const THEME_COMPONENT_GRADIENT = Object.freeze({
116
+ angleMin: 0,
117
+ angleMax: 359,
118
+ defaultAngle: 180,
119
+ });
120
+
110
121
  // The card-surface field names shared by every widget that paints its own card
111
122
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
112
123
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -115,6 +126,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
115
126
  radius: "cardRadius",
116
127
  padding: "cardPadding",
117
128
  shadow: "shadow",
129
+ gradient: "cardGradient",
118
130
  });
119
131
 
120
132
  // A form widget's submit button — the `button` scope reaches it through the
@@ -122,6 +134,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
122
134
  const FORM_SUBMIT_FIELDS = Object.freeze({
123
135
  background: "submitBackground",
124
136
  textColor: "submitTextColor",
137
+ gradient: "submitGradient",
125
138
  });
126
139
 
127
140
  const THEME_COMPONENTS = Object.freeze({
@@ -134,6 +147,7 @@ const THEME_COMPONENTS = Object.freeze({
134
147
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
135
148
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
136
149
  shadow: Object.freeze({ type: "shadow" }),
150
+ gradient: Object.freeze({ type: "gradient" }),
137
151
  }),
138
152
  targets: Object.freeze({
139
153
  "appstudio.button": Object.freeze({
@@ -143,6 +157,7 @@ const THEME_COMPONENTS = Object.freeze({
143
157
  radius: "radius",
144
158
  fontSize: "fontSize",
145
159
  shadow: "shadow",
160
+ gradient: "gradient",
146
161
  }),
147
162
  "appstudio.form-input": FORM_SUBMIT_FIELDS,
148
163
  "appstudio.form-builder": FORM_SUBMIT_FIELDS,
@@ -156,6 +171,7 @@ const THEME_COMPONENTS = Object.freeze({
156
171
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
157
172
  padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
158
173
  shadow: Object.freeze({ type: "shadow" }),
174
+ gradient: Object.freeze({ type: "gradient" }),
159
175
  }),
160
176
  targets: Object.freeze({
161
177
  "appstudio.user": CARD_SURFACE_FIELDS,
@@ -166,6 +182,7 @@ const THEME_COMPONENTS = Object.freeze({
166
182
  "appstudio.notifications": CARD_SURFACE_FIELDS,
167
183
  "appstudio.form-input": CARD_SURFACE_FIELDS,
168
184
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
185
+ "appstudio.user-management": CARD_SURFACE_FIELDS,
169
186
  }),
170
187
  }),
171
188
  text: Object.freeze({
@@ -2150,7 +2167,21 @@ const CONTRACT = deepFreeze({
2150
2167
  // (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
2151
2168
  // export changed signature and a theme with no `components` key resolves to
2152
2169
  // an empty override, rendering identically to before.
2153
- version: "1.45.0",
2170
+ // 1.46.0: additive (sc-3727) — a `gradient` token type on the per-component
2171
+ // vocabulary. The `button` and `card` scopes each gain a `gradient` token
2172
+ // whose value is `{ from: "#hex", to: "#hex", angle: 0-359 }` — the
2173
+ // `backgroundGradient` grammar minus the radial variant, because a component
2174
+ // fill paints through the `<Gradient>` primitive, which is linear on both
2175
+ // hosts. `themeComponentGradient` publishes the angle bounds + default so the
2176
+ // coercion, the Studio control and the planner prompt cannot disagree. The
2177
+ // token binds to per-instance style fields the target widgets read
2178
+ // (`gradient` on Button, `cardGradient` on every card surface,
2179
+ // `submitGradient` on a form's submit button), so the theme value is a
2180
+ // DEFAULT an author overrides per instance — or suppresses with an explicit
2181
+ // null, which is how one button stays flat while the rest are gradiented.
2182
+ // Additive: no export changed signature and a theme with no gradient token
2183
+ // renders exactly as before.
2184
+ version: "1.46.0",
2154
2185
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2155
2186
  hooks: HOOKS,
2156
2187
  primitives: PRIMITIVES,
@@ -2163,6 +2194,7 @@ const CONTRACT = deepFreeze({
2163
2194
  themeTokens: DEFAULT_THEME_TOKENS,
2164
2195
  themeComponents: THEME_COMPONENTS,
2165
2196
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2197
+ themeComponentGradient: THEME_COMPONENT_GRADIENT,
2166
2198
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2167
2199
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
2168
2200
  bannedApis: BANNED_APIS,
@@ -2305,6 +2337,33 @@ function gradientAngleToVector(angle) {
2305
2337
  };
2306
2338
  }
2307
2339
 
2340
+ // Alpha is allowed here (unlike HEX_RE) because the Mason build runner already
2341
+ // persists 8-digit component colours; the host must not drop what it accepted.
2342
+ const GRADIENT_HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
2343
+
2344
+ /**
2345
+ * sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
2346
+ *
2347
+ * ONE validator for a value arriving by two routes — the theme token and the
2348
+ * author's never-coerced per-instance `props.style` — so `set_theme` and the
2349
+ * renderer cannot disagree about the same value (CLAUDE.md §3). Both stops are
2350
+ * required: a one-stop gradient would paint a fill the author never chose. The
2351
+ * hex grammar is also the injection guard — these stops are interpolated into
2352
+ * CSS and into generated export source.
2353
+ */
2354
+ function normaliseComponentGradient(raw) {
2355
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
2356
+ const from = typeof raw.from === "string" ? raw.from.trim() : "";
2357
+ const to = typeof raw.to === "string" ? raw.to.trim() : "";
2358
+ if (!GRADIENT_HEX_RE.test(from) || !GRADIENT_HEX_RE.test(to)) return null;
2359
+ const { defaultAngle } = THEME_COMPONENT_GRADIENT;
2360
+ // Read once — a second read of an accessor could return a different value and
2361
+ // land NaN in a style.
2362
+ const angle = raw.angle;
2363
+ const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
2364
+ return { from, to, angle: ((deg % 360) + 360) % 360 };
2365
+ }
2366
+
2308
2367
  export {
2309
2368
  CONTRACT,
2310
2369
  isHookAllowed,
@@ -2315,6 +2374,7 @@ export {
2315
2374
  readableTextColor,
2316
2375
  deriveAccentTints,
2317
2376
  gradientAngleToVector,
2377
+ normaliseComponentGradient,
2318
2378
  widgetTranslationPrefix,
2319
2379
  widgetTranslationKey,
2320
2380
  sharedTranslationPrefix,
package/dist/host.d.ts CHANGED
@@ -12,7 +12,17 @@ export function resolveProps<T = Record<string, unknown>>(
12
12
  props: unknown,
13
13
  ): T;
14
14
 
15
- export type ThemeComponentStyle = Record<string, string | number>;
15
+ // sc-3727: a `gradient` token's value, the one non-scalar token type.
16
+ export interface ThemeComponentGradient {
17
+ from: string;
18
+ to: string;
19
+ angle: number;
20
+ }
21
+
22
+ export type ThemeComponentStyle = Record<
23
+ string,
24
+ string | number | ThemeComponentGradient
25
+ >;
16
26
  export type ThemeComponents = Record<string, ThemeComponentStyle>;
17
27
 
18
28
  /**
package/dist/index.d.ts CHANGED
@@ -1432,6 +1432,22 @@ export function gradientAngleToVector(angle: number): {
1432
1432
  end: { x: number; y: number };
1433
1433
  };
1434
1434
 
1435
+ export interface ComponentGradient {
1436
+ from: string;
1437
+ to: string;
1438
+ angle: number;
1439
+ }
1440
+
1441
+ /**
1442
+ * Normalise a component `gradient` style value to `{ from, to, angle }`, or null
1443
+ * when it is unusable. Both stops are required; the angle wraps into 0-359.
1444
+ * Shared by the theme-token coercion and the widget render path, so the same
1445
+ * value cannot resolve two ways.
1446
+ */
1447
+ export function normaliseComponentGradient(
1448
+ raw: unknown,
1449
+ ): ComponentGradient | null;
1450
+
1435
1451
  // Linter
1436
1452
  export interface LintFinding {
1437
1453
  rule: string;
package/dist/index.js CHANGED
@@ -88,5 +88,6 @@ export {
88
88
  readableTextColor,
89
89
  deriveAccentTints,
90
90
  gradientAngleToVector,
91
+ normaliseComponentGradient,
91
92
  } from "./contract.js";
92
93
  export { normalizeLucideIconName } from "./lucideIconName.js";
@@ -86,5 +86,6 @@ export {
86
86
  readableTextColor,
87
87
  deriveAccentTints,
88
88
  gradientAngleToVector,
89
+ normaliseComponentGradient,
89
90
  } from "./contract.js";
90
91
  export { normalizeLucideIconName } from "./lucideIconName.js";
@@ -23,7 +23,7 @@
23
23
  // widget reads. The Mason build runner validates `set_theme` against the SAME
24
24
  // literal, so what a planner may emit and what a host applies cannot diverge.
25
25
 
26
- const { CONTRACT } = require("./contract.cjs");
26
+ const { CONTRACT, normaliseComponentGradient } = require("./contract.cjs");
27
27
 
28
28
  // 3-, 6- and 8-digit hex, matching what the build runner persists — the host
29
29
  // must never drop a colour the runner already accepted.
@@ -47,6 +47,11 @@ function coerceToken(def, value) {
47
47
  if (def.type === "shadow") {
48
48
  return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
49
49
  }
50
+ // sc-3727: the gradient value has its own normaliser on the contract, shared
51
+ // with the widget render path so both routes agree (CLAUDE.md §3).
52
+ if (def.type === "gradient") {
53
+ return normaliseComponentGradient(value) || undefined;
54
+ }
50
55
  return undefined;
51
56
  }
52
57
 
@@ -15,7 +15,7 @@
15
15
  // widget reads. The Mason build runner validates `set_theme` against the SAME
16
16
  // literal, so what a planner may emit and what a host applies cannot diverge.
17
17
 
18
- import { CONTRACT } from "./contract.js";
18
+ import { CONTRACT, normaliseComponentGradient } from "./contract.js";
19
19
 
20
20
  // 3-, 6- and 8-digit hex, matching what the build runner persists — the host
21
21
  // must never drop a colour the runner already accepted.
@@ -39,6 +39,11 @@ function coerceToken(def, value) {
39
39
  if (def.type === "shadow") {
40
40
  return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
41
41
  }
42
+ // sc-3727: the gradient value has its own normaliser on the contract, shared
43
+ // with the widget render path so both routes agree (CLAUDE.md §3).
44
+ if (def.type === "gradient") {
45
+ return normaliseComponentGradient(value) || undefined;
46
+ }
42
47
  return undefined;
43
48
  }
44
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",