@colixsystems/widget-sdk 0.67.1 → 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
@@ -17,7 +17,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
17
17
 
18
18
  | Group | Hook (signature) | Returns | Reads / scope |
19
19
  | ----- | ---------------- | ------- | ------------- |
20
- | **CORE** | `useTheme()` | `{ colors, spacing, radii, typography, components }` | `ctx.workspace.theme` — no scope. `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
20
+ | **CORE** | `useTheme()` | `{ colors, elevation, spacing, radii, typography, components }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`). `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
21
21
  | **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
22
22
  | **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
23
23
  | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
@@ -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
 
@@ -514,17 +515,19 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
514
515
 
515
516
  **Decide before you build:** the widget's shape (card, list, form, control), its hierarchy (the one thing the eye lands on first), its single accent moment, and its empty/loading/error look — then compose. Specific decisions make a distinctive widget; leaving them to default makes a generic one.
516
517
 
517
- - **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners. Don't hardcode raw pixel values.
518
- - **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
518
+ - **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. Don't hardcode raw pixel values.
519
+ - **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
519
520
  - **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place) and size text with `theme.typography.sizes`.
520
- - **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.md` + a `colors.border` hairline or a subtle shadow. Use the status roles (`danger / success / warning / info`) for state.
521
+ - **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.lg` + `...theme.elevation.sm`. Give it the elevation **or** a `colors.border` hairline, not both — and prefer the elevation, because a hairline-only card reads as a wireframe. `theme.elevation` is a token table you spread into a style (`...theme.elevation.md`), covering `none / sm / md / lg / xl`; never hand-write `shadowOpacity` / `shadowRadius` / `boxShadow`. Use the status roles (`danger / success / warning / info`) for state.
522
+ - **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.
523
+ - **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.
521
524
  - **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 native-safe way to go multi-column (widgets have no breakpoint hook, so never hard-code fixed columns). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only).
522
525
  - **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
523
526
  - **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).
524
527
  - **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead.
525
528
  - **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
526
529
 
527
- **Honest ceilings:** the styling surface is React Native style objects, not full CSS. There are no per-widget gradients, no custom CSS keyframe animations or `transition` strings, and shadows are limited to the five elevation presets (`none / sm / md / lg / xl`). Aim for clean, confident, professional polish within those bounds.
530
+ **Honest ceilings:** the styling surface is React Native style objects, not full CSS. Gradients come from the `<Gradient>` primitive (not a CSS `linear-gradient` string), depth comes from `theme.elevation` (not arbitrary `box-shadow` stacks), and there are no custom CSS keyframe animations or `transition` strings, no `filter` / `backdrop-filter` / `clip-path` / `mask` / blend modes, and no opacity-faked tints (that's what `primarySoft` is for). Aim for clean, confident, professional polish within those bounds — lifted surfaces, generous corners, one accent moment.
528
531
 
529
532
  ## Managing app users from a widget
530
533
 
package/dist/contract.cjs CHANGED
@@ -8,10 +8,56 @@
8
8
  // Keep edits in lockstep between `contract.cjs` and `contract.js`. The
9
9
  // SDK build script copies both into `dist/`.
10
10
 
11
+ // REQ-AI-AGENT-DESIGN (sc-3696): the ONE cross-platform elevation vocabulary.
12
+ // `shadow*` applies on iOS + react-native-web, `elevation` drives Android.
13
+ // Consumed by `_shared/cardStyle.js` SHADOW_PRESETS (web + baked built-ins),
14
+ // the compiler's generated layout `elevationStyles`, and widgets reading
15
+ // `useTheme().elevation` — so a widget shadow and a container shadow match.
16
+ const ELEVATION = Object.freeze({
17
+ none: Object.freeze({}),
18
+ sm: Object.freeze({
19
+ shadowColor: "#000",
20
+ shadowOffset: Object.freeze({ width: 0, height: 1 }),
21
+ shadowOpacity: 0.08,
22
+ shadowRadius: 2,
23
+ elevation: 2,
24
+ }),
25
+ md: Object.freeze({
26
+ shadowColor: "#000",
27
+ shadowOffset: Object.freeze({ width: 0, height: 2 }),
28
+ shadowOpacity: 0.12,
29
+ shadowRadius: 4,
30
+ elevation: 4,
31
+ }),
32
+ lg: Object.freeze({
33
+ shadowColor: "#000",
34
+ shadowOffset: Object.freeze({ width: 0, height: 4 }),
35
+ shadowOpacity: 0.16,
36
+ shadowRadius: 8,
37
+ elevation: 8,
38
+ }),
39
+ xl: Object.freeze({
40
+ shadowColor: "#000",
41
+ shadowOffset: Object.freeze({ width: 0, height: 8 }),
42
+ shadowOpacity: 0.2,
43
+ shadowRadius: 12,
44
+ elevation: 12,
45
+ }),
46
+ });
47
+
11
48
  const DEFAULT_THEME_TOKENS = Object.freeze({
12
49
  colors: Object.freeze({
13
50
  primary: "#3b82f6",
14
51
  onPrimary: "#ffffff",
52
+ // REQ-AI-AGENT-DESIGN (sc-3696): the accent's quiet tiers. `primarySoft` is
53
+ // a tint of the accent over the surface (chips, secondary buttons, progress
54
+ // tracks); `onPrimarySoft` is contrast-guaranteed against it;
55
+ // `primaryStrong` is the accent's deeper end for a gradient's far stop.
56
+ // All three are re-derived per tenant by `deriveAccentTints` — these
57
+ // literals are the derivation applied to the default palette.
58
+ primarySoft: "#ebf3fe",
59
+ onPrimarySoft: "#3168c2",
60
+ primaryStrong: "#326bc8",
15
61
  secondary: "#10b981",
16
62
  onSecondary: "#ffffff",
17
63
  surface: "#ffffff",
@@ -24,6 +70,7 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
24
70
  warning: "#d97706",
25
71
  info: "#0284c7",
26
72
  }),
73
+ elevation: ELEVATION,
27
74
  spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
28
75
  radii: Object.freeze({ sm: 4, md: 8, lg: 16, pill: 9999 }),
29
76
  typography: Object.freeze({
@@ -60,6 +107,17 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
60
107
  // cannot drift between what Mason may emit and what a host actually applies.
61
108
  const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
62
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
+
63
121
  // The card-surface field names shared by every widget that paints its own card
64
122
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
65
123
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -68,6 +126,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
68
126
  radius: "cardRadius",
69
127
  padding: "cardPadding",
70
128
  shadow: "shadow",
129
+ gradient: "cardGradient",
71
130
  });
72
131
 
73
132
  // A form widget's submit button — the `button` scope reaches it through the
@@ -75,6 +134,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
75
134
  const FORM_SUBMIT_FIELDS = Object.freeze({
76
135
  background: "submitBackground",
77
136
  textColor: "submitTextColor",
137
+ gradient: "submitGradient",
78
138
  });
79
139
 
80
140
  const THEME_COMPONENTS = Object.freeze({
@@ -87,6 +147,7 @@ const THEME_COMPONENTS = Object.freeze({
87
147
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
88
148
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
89
149
  shadow: Object.freeze({ type: "shadow" }),
150
+ gradient: Object.freeze({ type: "gradient" }),
90
151
  }),
91
152
  targets: Object.freeze({
92
153
  "appstudio.button": Object.freeze({
@@ -96,6 +157,7 @@ const THEME_COMPONENTS = Object.freeze({
96
157
  radius: "radius",
97
158
  fontSize: "fontSize",
98
159
  shadow: "shadow",
160
+ gradient: "gradient",
99
161
  }),
100
162
  "appstudio.form-input": FORM_SUBMIT_FIELDS,
101
163
  "appstudio.form-builder": FORM_SUBMIT_FIELDS,
@@ -109,6 +171,7 @@ const THEME_COMPONENTS = Object.freeze({
109
171
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
110
172
  padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
111
173
  shadow: Object.freeze({ type: "shadow" }),
174
+ gradient: Object.freeze({ type: "gradient" }),
112
175
  }),
113
176
  targets: Object.freeze({
114
177
  "appstudio.user": CARD_SURFACE_FIELDS,
@@ -119,6 +182,7 @@ const THEME_COMPONENTS = Object.freeze({
119
182
  "appstudio.notifications": CARD_SURFACE_FIELDS,
120
183
  "appstudio.form-input": CARD_SURFACE_FIELDS,
121
184
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
185
+ "appstudio.user-management": CARD_SURFACE_FIELDS,
122
186
  }),
123
187
  }),
124
188
  text: Object.freeze({
@@ -376,7 +440,13 @@ const HOOKS = [
376
440
  "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
377
441
  "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
378
442
  "to the created file row or throws the wire error; a 404 means the " +
379
- "destination folder denied a write (REQ-FSH canWrite gate).",
443
+ "destination folder denied a write (REQ-FSH canWrite gate). " +
444
+ "ALWAYS pass spaceType explicitly — omitting it falls back to " +
445
+ "'project', which is unreadable by a logged-out visitor. Choose it by " +
446
+ "who must SEE the file: 'public' for content the app displays to " +
447
+ "anyone browsing (product, catalogue, gallery, cover images), " +
448
+ "'project' for content restricted to signed-in workspace users, " +
449
+ "'personal' for a file private to the uploading app user.",
380
450
  returnShape: {
381
451
  upload: "(file, { folderId? }) => Promise<FilestoreFile>",
382
452
  uploading: "boolean",
@@ -958,6 +1028,16 @@ const PRIMITIVES = [
958
1028
  rnComponent: null,
959
1029
  docsUrl: null,
960
1030
  },
1031
+ // REQ-AI-AGENT-DESIGN (sc-3696) — the gradient surface. Widgets previously had
1032
+ // no gradient at all, which is why generated widgets read flatter than the
1033
+ // competition; this is the ONE cross-platform way to paint one.
1034
+ {
1035
+ name: "Gradient",
1036
+ description:
1037
+ "Linear-gradient surface. `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={{ borderRadius: theme.radii.lg, padding: theme.spacing.md }}>…</Gradient>`. A View that paints a gradient behind its children — accepts every View style prop, so it replaces the `View` you would otherwise give a flat `backgroundColor`. `colors` takes two or more theme colour roles (never raw hex); `angle` is CSS degrees (0 = to top, 90 = to right, default 180 = to bottom). Web paints a CSS `linear-gradient`; native delegates to expo-linear-gradient with the same angle projection, so the two hosts match. Use it for at most ONE element per widget (the hero action or a stat header) — text on it uses `colors.onPrimary`.",
1038
+ rnComponent: "expo-linear-gradient",
1039
+ docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
1040
+ },
961
1041
  ];
962
1042
 
963
1043
  const CATEGORIES = [
@@ -1573,7 +1653,7 @@ const VETTED_IMPORTS = [
1573
1653
  platforms: ["web", "native"],
1574
1654
  category: "drawing",
1575
1655
  description:
1576
- "Linear-gradient fill (<LinearGradient colors={[...]} />). The cross-platform gradient works on both platforms (react-native-linear-gradient is native-only; prefer this).",
1656
+ "Linear-gradient fill (<LinearGradient colors={[...]} />). Prefer the SDK's <Gradient> primitive: it wraps this on native and CSS on web, so it resolves in an AI-agent widget (which is transpiled, not bundled) where a bare import of this package does not. Reach for the package directly only from a BUNDLED widget needing a gradient shape this primitive can't express.",
1577
1657
  },
1578
1658
  {
1579
1659
  specifier: "lottie-react-native",
@@ -2087,7 +2167,21 @@ const CONTRACT = deepFreeze({
2087
2167
  // (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
2088
2168
  // export changed signature and a theme with no `components` key resolves to
2089
2169
  // an empty override, rendering identically to before.
2090
- 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",
2091
2185
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2092
2186
  hooks: HOOKS,
2093
2187
  primitives: PRIMITIVES,
@@ -2100,6 +2194,7 @@ const CONTRACT = deepFreeze({
2100
2194
  themeTokens: DEFAULT_THEME_TOKENS,
2101
2195
  themeComponents: THEME_COMPONENTS,
2102
2196
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2197
+ themeComponentGradient: THEME_COMPONENT_GRADIENT,
2103
2198
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2104
2199
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
2105
2200
  bannedApis: BANNED_APIS,
@@ -2122,10 +2217,164 @@ function requiredContextKeys() {
2122
2217
  return [...keys];
2123
2218
  }
2124
2219
 
2220
+ // ---------------------------------------------------------------------------
2221
+ // Theme derivation (REQ-THEME-DARK + REQ-AI-AGENT-DESIGN)
2222
+ //
2223
+ // The colour maths every host applies on top of a tenant's picked palette.
2224
+ // It lives HERE, in the dual-format contract, because BOTH hosts need it and
2225
+ // both can reach it: the web Player through `frontend/src/services/widgetTheme.js`
2226
+ // and the exported Expo app through the compiler's generated widget-theme
2227
+ // module (which already imports `CONTRACT` from this package). Previously each
2228
+ // host carried its own hand-mirrored copy of `mixHex` / `readableTextColor` —
2229
+ // one source removes that drift (CLAUDE.md §3).
2230
+ // ---------------------------------------------------------------------------
2231
+
2232
+ const HEX_RE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
2233
+
2234
+ function isHexColor(value) {
2235
+ return typeof value === "string" && HEX_RE.test(value);
2236
+ }
2237
+
2238
+ function hexChannels(hex) {
2239
+ let h = hex.slice(1);
2240
+ if (h.length === 3) {
2241
+ h = h
2242
+ .split("")
2243
+ .map((c) => c + c)
2244
+ .join("");
2245
+ }
2246
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
2247
+ }
2248
+
2249
+ /** Relative luminance (WCAG 2.1) of a hex colour. */
2250
+ function relativeLuminance(hex) {
2251
+ const [r, g, b] = hexChannels(hex).map((v) => v / 255);
2252
+ const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
2253
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
2254
+ }
2255
+
2256
+ /** WCAG contrast ratio between two hex colours. Always >= 1. */
2257
+ function contrastRatio(a, b) {
2258
+ const la = relativeLuminance(a);
2259
+ const lb = relativeLuminance(b);
2260
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
2261
+ }
2262
+
2263
+ /** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
2264
+ function mixHex(a, b, t) {
2265
+ if (!isHexColor(a) || !isHexColor(b)) return a;
2266
+ const pa = hexChannels(a);
2267
+ const pb = hexChannels(b);
2268
+ const channel = (i) => Math.round(pa[i] + (pb[i] - pa[i]) * t);
2269
+ const hex = (n) =>
2270
+ Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0");
2271
+ return `#${hex(channel(0))}${hex(channel(1))}${hex(channel(2))}`;
2272
+ }
2273
+
2274
+ /**
2275
+ * Pick a readable text colour (`dark` or `light`) for a solid background.
2276
+ * A light background gets the dark option and vice versa, so `onPrimary` /
2277
+ * `onSecondary` follow whatever accent the tenant picked.
2278
+ */
2279
+ function readableTextColor(hex, dark, light) {
2280
+ if (!isHexColor(hex)) return light;
2281
+ return relativeLuminance(hex) > 0.55 ? dark : light;
2282
+ }
2283
+
2284
+ // A tint at 10% accent reads as "quietly accented" on both a white and a dark
2285
+ // surface without competing with the full-strength accent moment.
2286
+ const ACCENT_TINT_WEIGHT = 0.9;
2287
+ // How far `primaryStrong` travels toward the text colour. Deepens the accent on
2288
+ // a light theme and lightens it on a dark one, which is the right direction for
2289
+ // a gradient's far stop in both.
2290
+ const ACCENT_STRONG_WEIGHT = 0.22;
2291
+ const ACCENT_TEXT_MIN_CONTRAST = 4.5;
2292
+
2293
+ /**
2294
+ * REQ-AI-AGENT-DESIGN (sc-3696): derive the accent's quiet tiers from the
2295
+ * resolved palette. Returns `{ primarySoft, onPrimarySoft, primaryStrong }`.
2296
+ *
2297
+ * `onPrimarySoft` is not a fixed mix: the accent is stepped toward the surface's
2298
+ * text colour until it clears WCAG AA (4.5:1) against the tint, so a chip label
2299
+ * is legible for EVERY tenant accent rather than only for accents that happen
2300
+ * to be dark enough. Returns `null` when the inputs aren't usable hex, letting
2301
+ * the caller keep the default tokens.
2302
+ */
2303
+ function deriveAccentTints(primary, surface, onSurface) {
2304
+ if (!isHexColor(primary) || !isHexColor(surface) || !isHexColor(onSurface)) {
2305
+ return null;
2306
+ }
2307
+ const primarySoft = mixHex(primary, surface, ACCENT_TINT_WEIGHT);
2308
+ let onPrimarySoft = primary;
2309
+ for (let t = 0; t <= 0.9; t += 0.05) {
2310
+ onPrimarySoft = mixHex(primary, onSurface, t);
2311
+ if (contrastRatio(onPrimarySoft, primarySoft) >= ACCENT_TEXT_MIN_CONTRAST) {
2312
+ break;
2313
+ }
2314
+ }
2315
+ return {
2316
+ primarySoft,
2317
+ onPrimarySoft,
2318
+ primaryStrong: mixHex(primary, onSurface, ACCENT_STRONG_WEIGHT),
2319
+ };
2320
+ }
2321
+
2322
+ /**
2323
+ * Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
2324
+ * `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
2325
+ * top-left, y down). Single source for the SDK's `<Gradient>` primitive and
2326
+ * the compiler's app-background backdrop, so a widget gradient and the app
2327
+ * gradient read the same angle identically (CLAUDE.md §3).
2328
+ */
2329
+ function gradientAngleToVector(angle) {
2330
+ const deg = Number.isFinite(angle) ? angle : 180;
2331
+ const rad = ((deg - 90) * Math.PI) / 180;
2332
+ const dx = Math.cos(rad);
2333
+ const dy = Math.sin(rad);
2334
+ return {
2335
+ start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
2336
+ end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
2337
+ };
2338
+ }
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
+
2125
2367
  module.exports = {
2126
2368
  CONTRACT,
2127
2369
  isHookAllowed,
2128
2370
  requiredContextKeys,
2371
+ isHexColor,
2372
+ mixHex,
2373
+ contrastRatio,
2374
+ readableTextColor,
2375
+ deriveAccentTints,
2376
+ gradientAngleToVector,
2377
+ normaliseComponentGradient,
2129
2378
  widgetTranslationPrefix,
2130
2379
  widgetTranslationKey,
2131
2380
  sharedTranslationPrefix,
package/dist/contract.js CHANGED
@@ -8,10 +8,56 @@
8
8
  // Keep edits in lockstep between `contract.cjs` and `contract.js`. The
9
9
  // SDK build script copies both into `dist/`.
10
10
 
11
+ // REQ-AI-AGENT-DESIGN (sc-3696): the ONE cross-platform elevation vocabulary.
12
+ // `shadow*` applies on iOS + react-native-web, `elevation` drives Android.
13
+ // Consumed by `_shared/cardStyle.js` SHADOW_PRESETS (web + baked built-ins),
14
+ // the compiler's generated layout `elevationStyles`, and widgets reading
15
+ // `useTheme().elevation` — so a widget shadow and a container shadow match.
16
+ const ELEVATION = Object.freeze({
17
+ none: Object.freeze({}),
18
+ sm: Object.freeze({
19
+ shadowColor: "#000",
20
+ shadowOffset: Object.freeze({ width: 0, height: 1 }),
21
+ shadowOpacity: 0.08,
22
+ shadowRadius: 2,
23
+ elevation: 2,
24
+ }),
25
+ md: Object.freeze({
26
+ shadowColor: "#000",
27
+ shadowOffset: Object.freeze({ width: 0, height: 2 }),
28
+ shadowOpacity: 0.12,
29
+ shadowRadius: 4,
30
+ elevation: 4,
31
+ }),
32
+ lg: Object.freeze({
33
+ shadowColor: "#000",
34
+ shadowOffset: Object.freeze({ width: 0, height: 4 }),
35
+ shadowOpacity: 0.16,
36
+ shadowRadius: 8,
37
+ elevation: 8,
38
+ }),
39
+ xl: Object.freeze({
40
+ shadowColor: "#000",
41
+ shadowOffset: Object.freeze({ width: 0, height: 8 }),
42
+ shadowOpacity: 0.2,
43
+ shadowRadius: 12,
44
+ elevation: 12,
45
+ }),
46
+ });
47
+
11
48
  const DEFAULT_THEME_TOKENS = Object.freeze({
12
49
  colors: Object.freeze({
13
50
  primary: "#3b82f6",
14
51
  onPrimary: "#ffffff",
52
+ // REQ-AI-AGENT-DESIGN (sc-3696): the accent's quiet tiers. `primarySoft` is
53
+ // a tint of the accent over the surface (chips, secondary buttons, progress
54
+ // tracks); `onPrimarySoft` is contrast-guaranteed against it;
55
+ // `primaryStrong` is the accent's deeper end for a gradient's far stop.
56
+ // All three are re-derived per tenant by `deriveAccentTints` — these
57
+ // literals are the derivation applied to the default palette.
58
+ primarySoft: "#ebf3fe",
59
+ onPrimarySoft: "#3168c2",
60
+ primaryStrong: "#326bc8",
15
61
  secondary: "#10b981",
16
62
  onSecondary: "#ffffff",
17
63
  surface: "#ffffff",
@@ -24,6 +70,7 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
24
70
  warning: "#d97706",
25
71
  info: "#0284c7",
26
72
  }),
73
+ elevation: ELEVATION,
27
74
  spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
28
75
  radii: Object.freeze({ sm: 4, md: 8, lg: 16, pill: 9999 }),
29
76
  typography: Object.freeze({
@@ -60,6 +107,17 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
60
107
  // cannot drift between what Mason may emit and what a host actually applies.
61
108
  const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
62
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
+
63
121
  // The card-surface field names shared by every widget that paints its own card
64
122
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
65
123
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -68,6 +126,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
68
126
  radius: "cardRadius",
69
127
  padding: "cardPadding",
70
128
  shadow: "shadow",
129
+ gradient: "cardGradient",
71
130
  });
72
131
 
73
132
  // A form widget's submit button — the `button` scope reaches it through the
@@ -75,6 +134,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
75
134
  const FORM_SUBMIT_FIELDS = Object.freeze({
76
135
  background: "submitBackground",
77
136
  textColor: "submitTextColor",
137
+ gradient: "submitGradient",
78
138
  });
79
139
 
80
140
  const THEME_COMPONENTS = Object.freeze({
@@ -87,6 +147,7 @@ const THEME_COMPONENTS = Object.freeze({
87
147
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
88
148
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
89
149
  shadow: Object.freeze({ type: "shadow" }),
150
+ gradient: Object.freeze({ type: "gradient" }),
90
151
  }),
91
152
  targets: Object.freeze({
92
153
  "appstudio.button": Object.freeze({
@@ -96,6 +157,7 @@ const THEME_COMPONENTS = Object.freeze({
96
157
  radius: "radius",
97
158
  fontSize: "fontSize",
98
159
  shadow: "shadow",
160
+ gradient: "gradient",
99
161
  }),
100
162
  "appstudio.form-input": FORM_SUBMIT_FIELDS,
101
163
  "appstudio.form-builder": FORM_SUBMIT_FIELDS,
@@ -109,6 +171,7 @@ const THEME_COMPONENTS = Object.freeze({
109
171
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
110
172
  padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
111
173
  shadow: Object.freeze({ type: "shadow" }),
174
+ gradient: Object.freeze({ type: "gradient" }),
112
175
  }),
113
176
  targets: Object.freeze({
114
177
  "appstudio.user": CARD_SURFACE_FIELDS,
@@ -119,6 +182,7 @@ const THEME_COMPONENTS = Object.freeze({
119
182
  "appstudio.notifications": CARD_SURFACE_FIELDS,
120
183
  "appstudio.form-input": CARD_SURFACE_FIELDS,
121
184
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
185
+ "appstudio.user-management": CARD_SURFACE_FIELDS,
122
186
  }),
123
187
  }),
124
188
  text: Object.freeze({
@@ -376,7 +440,13 @@ const HOOKS = [
376
440
  "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
377
441
  "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
378
442
  "to the created file row or throws the wire error; a 404 means the " +
379
- "destination folder denied a write (REQ-FSH canWrite gate).",
443
+ "destination folder denied a write (REQ-FSH canWrite gate). " +
444
+ "ALWAYS pass spaceType explicitly — omitting it falls back to " +
445
+ "'project', which is unreadable by a logged-out visitor. Choose it by " +
446
+ "who must SEE the file: 'public' for content the app displays to " +
447
+ "anyone browsing (product, catalogue, gallery, cover images), " +
448
+ "'project' for content restricted to signed-in workspace users, " +
449
+ "'personal' for a file private to the uploading app user.",
380
450
  returnShape: {
381
451
  upload: "(file, { folderId? }) => Promise<FilestoreFile>",
382
452
  uploading: "boolean",
@@ -958,6 +1028,16 @@ const PRIMITIVES = [
958
1028
  rnComponent: null,
959
1029
  docsUrl: null,
960
1030
  },
1031
+ // REQ-AI-AGENT-DESIGN (sc-3696) — the gradient surface. Widgets previously had
1032
+ // no gradient at all, which is why generated widgets read flatter than the
1033
+ // competition; this is the ONE cross-platform way to paint one.
1034
+ {
1035
+ name: "Gradient",
1036
+ description:
1037
+ "Linear-gradient surface. `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={{ borderRadius: theme.radii.lg, padding: theme.spacing.md }}>…</Gradient>`. A View that paints a gradient behind its children — accepts every View style prop, so it replaces the `View` you would otherwise give a flat `backgroundColor`. `colors` takes two or more theme colour roles (never raw hex); `angle` is CSS degrees (0 = to top, 90 = to right, default 180 = to bottom). Web paints a CSS `linear-gradient`; native delegates to expo-linear-gradient with the same angle projection, so the two hosts match. Use it for at most ONE element per widget (the hero action or a stat header) — text on it uses `colors.onPrimary`.",
1038
+ rnComponent: "expo-linear-gradient",
1039
+ docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
1040
+ },
961
1041
  ];
962
1042
 
963
1043
  const CATEGORIES = [
@@ -1573,7 +1653,7 @@ const VETTED_IMPORTS = [
1573
1653
  platforms: ["web", "native"],
1574
1654
  category: "drawing",
1575
1655
  description:
1576
- "Linear-gradient fill (<LinearGradient colors={[...]} />). The cross-platform gradient works on both platforms (react-native-linear-gradient is native-only; prefer this).",
1656
+ "Linear-gradient fill (<LinearGradient colors={[...]} />). Prefer the SDK's <Gradient> primitive: it wraps this on native and CSS on web, so it resolves in an AI-agent widget (which is transpiled, not bundled) where a bare import of this package does not. Reach for the package directly only from a BUNDLED widget needing a gradient shape this primitive can't express.",
1577
1657
  },
1578
1658
  {
1579
1659
  specifier: "lottie-react-native",
@@ -2087,7 +2167,21 @@ const CONTRACT = deepFreeze({
2087
2167
  // (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
2088
2168
  // export changed signature and a theme with no `components` key resolves to
2089
2169
  // an empty override, rendering identically to before.
2090
- 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",
2091
2185
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2092
2186
  hooks: HOOKS,
2093
2187
  primitives: PRIMITIVES,
@@ -2100,6 +2194,7 @@ const CONTRACT = deepFreeze({
2100
2194
  themeTokens: DEFAULT_THEME_TOKENS,
2101
2195
  themeComponents: THEME_COMPONENTS,
2102
2196
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2197
+ themeComponentGradient: THEME_COMPONENT_GRADIENT,
2103
2198
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2104
2199
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
2105
2200
  bannedApis: BANNED_APIS,
@@ -2122,10 +2217,164 @@ function requiredContextKeys() {
2122
2217
  return [...keys];
2123
2218
  }
2124
2219
 
2220
+ // ---------------------------------------------------------------------------
2221
+ // Theme derivation (REQ-THEME-DARK + REQ-AI-AGENT-DESIGN)
2222
+ //
2223
+ // The colour maths every host applies on top of a tenant's picked palette.
2224
+ // It lives HERE, in the dual-format contract, because BOTH hosts need it and
2225
+ // both can reach it: the web Player through `frontend/src/services/widgetTheme.js`
2226
+ // and the exported Expo app through the compiler's generated widget-theme
2227
+ // module (which already imports `CONTRACT` from this package). Previously each
2228
+ // host carried its own hand-mirrored copy of `mixHex` / `readableTextColor` —
2229
+ // one source removes that drift (CLAUDE.md §3).
2230
+ // ---------------------------------------------------------------------------
2231
+
2232
+ const HEX_RE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
2233
+
2234
+ function isHexColor(value) {
2235
+ return typeof value === "string" && HEX_RE.test(value);
2236
+ }
2237
+
2238
+ function hexChannels(hex) {
2239
+ let h = hex.slice(1);
2240
+ if (h.length === 3) {
2241
+ h = h
2242
+ .split("")
2243
+ .map((c) => c + c)
2244
+ .join("");
2245
+ }
2246
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
2247
+ }
2248
+
2249
+ /** Relative luminance (WCAG 2.1) of a hex colour. */
2250
+ function relativeLuminance(hex) {
2251
+ const [r, g, b] = hexChannels(hex).map((v) => v / 255);
2252
+ const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
2253
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
2254
+ }
2255
+
2256
+ /** WCAG contrast ratio between two hex colours. Always >= 1. */
2257
+ function contrastRatio(a, b) {
2258
+ const la = relativeLuminance(a);
2259
+ const lb = relativeLuminance(b);
2260
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
2261
+ }
2262
+
2263
+ /** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
2264
+ function mixHex(a, b, t) {
2265
+ if (!isHexColor(a) || !isHexColor(b)) return a;
2266
+ const pa = hexChannels(a);
2267
+ const pb = hexChannels(b);
2268
+ const channel = (i) => Math.round(pa[i] + (pb[i] - pa[i]) * t);
2269
+ const hex = (n) =>
2270
+ Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0");
2271
+ return `#${hex(channel(0))}${hex(channel(1))}${hex(channel(2))}`;
2272
+ }
2273
+
2274
+ /**
2275
+ * Pick a readable text colour (`dark` or `light`) for a solid background.
2276
+ * A light background gets the dark option and vice versa, so `onPrimary` /
2277
+ * `onSecondary` follow whatever accent the tenant picked.
2278
+ */
2279
+ function readableTextColor(hex, dark, light) {
2280
+ if (!isHexColor(hex)) return light;
2281
+ return relativeLuminance(hex) > 0.55 ? dark : light;
2282
+ }
2283
+
2284
+ // A tint at 10% accent reads as "quietly accented" on both a white and a dark
2285
+ // surface without competing with the full-strength accent moment.
2286
+ const ACCENT_TINT_WEIGHT = 0.9;
2287
+ // How far `primaryStrong` travels toward the text colour. Deepens the accent on
2288
+ // a light theme and lightens it on a dark one, which is the right direction for
2289
+ // a gradient's far stop in both.
2290
+ const ACCENT_STRONG_WEIGHT = 0.22;
2291
+ const ACCENT_TEXT_MIN_CONTRAST = 4.5;
2292
+
2293
+ /**
2294
+ * REQ-AI-AGENT-DESIGN (sc-3696): derive the accent's quiet tiers from the
2295
+ * resolved palette. Returns `{ primarySoft, onPrimarySoft, primaryStrong }`.
2296
+ *
2297
+ * `onPrimarySoft` is not a fixed mix: the accent is stepped toward the surface's
2298
+ * text colour until it clears WCAG AA (4.5:1) against the tint, so a chip label
2299
+ * is legible for EVERY tenant accent rather than only for accents that happen
2300
+ * to be dark enough. Returns `null` when the inputs aren't usable hex, letting
2301
+ * the caller keep the default tokens.
2302
+ */
2303
+ function deriveAccentTints(primary, surface, onSurface) {
2304
+ if (!isHexColor(primary) || !isHexColor(surface) || !isHexColor(onSurface)) {
2305
+ return null;
2306
+ }
2307
+ const primarySoft = mixHex(primary, surface, ACCENT_TINT_WEIGHT);
2308
+ let onPrimarySoft = primary;
2309
+ for (let t = 0; t <= 0.9; t += 0.05) {
2310
+ onPrimarySoft = mixHex(primary, onSurface, t);
2311
+ if (contrastRatio(onPrimarySoft, primarySoft) >= ACCENT_TEXT_MIN_CONTRAST) {
2312
+ break;
2313
+ }
2314
+ }
2315
+ return {
2316
+ primarySoft,
2317
+ onPrimarySoft,
2318
+ primaryStrong: mixHex(primary, onSurface, ACCENT_STRONG_WEIGHT),
2319
+ };
2320
+ }
2321
+
2322
+ /**
2323
+ * Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
2324
+ * `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
2325
+ * top-left, y down). Single source for the SDK's `<Gradient>` primitive and
2326
+ * the compiler's app-background backdrop, so a widget gradient and the app
2327
+ * gradient read the same angle identically (CLAUDE.md §3).
2328
+ */
2329
+ function gradientAngleToVector(angle) {
2330
+ const deg = Number.isFinite(angle) ? angle : 180;
2331
+ const rad = ((deg - 90) * Math.PI) / 180;
2332
+ const dx = Math.cos(rad);
2333
+ const dy = Math.sin(rad);
2334
+ return {
2335
+ start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
2336
+ end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
2337
+ };
2338
+ }
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
+
2125
2367
  export {
2126
2368
  CONTRACT,
2127
2369
  isHookAllowed,
2128
2370
  requiredContextKeys,
2371
+ isHexColor,
2372
+ mixHex,
2373
+ contrastRatio,
2374
+ readableTextColor,
2375
+ deriveAccentTints,
2376
+ gradientAngleToVector,
2377
+ normaliseComponentGradient,
2129
2378
  widgetTranslationPrefix,
2130
2379
  widgetTranslationKey,
2131
2380
  sharedTranslationPrefix,
@@ -0,0 +1,21 @@
1
+ // REQ-AI-AGENT-DESIGN (sc-3696) — the `<Gradient>` colour-stop normaliser,
2
+ // shared by the web (./gradient.js) and native (./gradient.native.js) builds.
3
+ //
4
+ // Its own module because it must be platform-neutral: importing it from
5
+ // ./gradient.js would drag `react-native-web` into the native bundle.
6
+
7
+ /**
8
+ * Normalise the author's `colors` prop to at least two colour stops, or `null`
9
+ * when there is nothing paintable.
10
+ *
11
+ * A single stop is a legitimate authoring mistake (a "gradient" of one colour);
12
+ * doubling it renders a flat fill rather than throwing, which keeps a
13
+ * half-finished widget visible on the canvas instead of blanking it.
14
+ */
15
+ export function gradientStops(colors) {
16
+ const list = (Array.isArray(colors) ? colors : []).filter(
17
+ (c) => typeof c === "string" && c.trim(),
18
+ );
19
+ if (list.length === 0) return null;
20
+ return list.length === 1 ? [list[0], list[0]] : list;
21
+ }
@@ -0,0 +1,35 @@
1
+ // REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (web).
2
+ //
3
+ // Widgets had no gradient at all: `expo-linear-gradient` is a vetted import, but
4
+ // an AI-agent widget is only TRANSPILED (never bundled), so a bare
5
+ // `expo-linear-gradient` import has nothing to resolve against on the web
6
+ // Player. Exposing the gradient as an SDK primitive fixes that for every widget
7
+ // kind at once — the SDK is already host-shimmed, so authors import from the one
8
+ // entry point they always import from.
9
+ //
10
+ // Web paints a CSS `linear-gradient`. react-native-web (0.21) passes
11
+ // `backgroundImage` straight through to the DOM — it validates only against a
12
+ // shortform blocklist (`background`, `font`, …), and `backgroundImage` is not on
13
+ // it — so a plain `View` carries the gradient with no DOM escape hatch.
14
+ //
15
+ // The native counterpart (./gradient.native.js) delegates to
16
+ // expo-linear-gradient, projecting the SAME CSS angle through the SAME
17
+ // `gradientAngleToVector` from the contract. Keep the two in lockstep.
18
+
19
+ import React from "react";
20
+ import * as ReactNative from "react-native-web";
21
+ import { gradientStops } from "./gradient-stops.js";
22
+
23
+ const DEFAULT_ANGLE = 180;
24
+
25
+ export function Gradient({ colors, angle = DEFAULT_ANGLE, style, ...rest }) {
26
+ const stops = gradientStops(colors);
27
+ const deg = Number.isFinite(angle) ? angle : DEFAULT_ANGLE;
28
+ const gradientStyle = stops
29
+ ? { backgroundImage: `linear-gradient(${deg}deg, ${stops.join(", ")})` }
30
+ : null;
31
+ return React.createElement(ReactNative.View, {
32
+ ...rest,
33
+ style: [style, gradientStyle],
34
+ });
35
+ }
@@ -0,0 +1,32 @@
1
+ // REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (native).
2
+ //
3
+ // Delegates to expo-linear-gradient, which is pinned UNCONDITIONALLY in every
4
+ // export (BASE_DEPENDENCIES in backend/src/core/services/export-dependencies.ts,
5
+ // where the app-shell backdrop already uses it), so Metro always resolves it.
6
+ //
7
+ // The web counterpart (./gradient.js) paints a CSS `linear-gradient`. Both take
8
+ // the SAME CSS-degree `angle` and run it through the SAME
9
+ // `gradientAngleToVector` from the contract, so a widget authored once reads
10
+ // identically on both hosts (widget-parity skill).
11
+
12
+ import React from "react";
13
+ import { LinearGradient } from "expo-linear-gradient";
14
+ import { gradientAngleToVector } from "./contract.js";
15
+ import { gradientStops } from "./gradient-stops.js";
16
+
17
+ const DEFAULT_ANGLE = 180;
18
+
19
+ export function Gradient({ colors, angle = DEFAULT_ANGLE, style, ...rest }) {
20
+ const stops = gradientStops(colors);
21
+ if (!stops) return React.createElement(LinearGradient, { ...rest, style });
22
+ const { start, end } = gradientAngleToVector(
23
+ Number.isFinite(angle) ? angle : DEFAULT_ANGLE,
24
+ );
25
+ return React.createElement(LinearGradient, {
26
+ ...rest,
27
+ colors: stops,
28
+ start,
29
+ end,
30
+ style,
31
+ });
32
+ }
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
@@ -267,10 +267,26 @@ export interface WidgetManifest {
267
267
  translations?: Record<string, { en: string } & Record<string, string>>;
268
268
  }
269
269
 
270
+ /** A React Native shadow style object — one level of `ThemeTokens.elevation`. */
271
+ export interface ElevationLevel {
272
+ shadowColor?: string;
273
+ shadowOffset?: { width: number; height: number };
274
+ shadowOpacity?: number;
275
+ shadowRadius?: number;
276
+ /** Android elevation. */
277
+ elevation?: number;
278
+ }
279
+
270
280
  export interface ThemeTokens {
271
281
  colors: {
272
282
  primary: string;
273
283
  onPrimary: string;
284
+ /** sc-3696: tint of the accent over the surface — chips, quiet buttons. */
285
+ primarySoft: string;
286
+ /** sc-3696: contrast-guaranteed text/icon colour on `primarySoft`. */
287
+ onPrimarySoft: string;
288
+ /** sc-3696: the accent's deeper end — a gradient's far stop. */
289
+ primaryStrong: string;
274
290
  secondary: string;
275
291
  onSecondary: string;
276
292
  surface: string;
@@ -278,6 +294,14 @@ export interface ThemeTokens {
278
294
  danger: string;
279
295
  [k: string]: string;
280
296
  };
297
+ /** sc-3696: the shared elevation vocabulary. Spread a level into a style. */
298
+ elevation: {
299
+ none: ElevationLevel;
300
+ sm: ElevationLevel;
301
+ md: ElevationLevel;
302
+ lg: ElevationLevel;
303
+ xl: ElevationLevel;
304
+ };
281
305
  spacing: { xs: number; sm: number; md: number; lg: number; xl: number };
282
306
  radii: { sm: number; md: number; lg: number; pill: number };
283
307
  typography: {
@@ -1339,6 +1363,91 @@ export const Icon: (props: {
1339
1363
  color?: string;
1340
1364
  }) => any;
1341
1365
 
1366
+ /**
1367
+ * REQ-AI-AGENT-DESIGN (sc-3696) — linear-gradient surface. A `View` that paints
1368
+ * a gradient behind its children, so it replaces the `View` you would otherwise
1369
+ * give a flat `backgroundColor`. Web paints a CSS `linear-gradient`; native
1370
+ * delegates to expo-linear-gradient through the same angle projection.
1371
+ *
1372
+ * @example
1373
+ * <Gradient
1374
+ * colors={[theme.colors.primary, theme.colors.primaryStrong]}
1375
+ * angle={160}
1376
+ * style={{ borderRadius: theme.radii.lg, padding: theme.spacing.md }}
1377
+ * >
1378
+ * <Text style={{ color: theme.colors.onPrimary }}>Log a glass</Text>
1379
+ * </Gradient>
1380
+ */
1381
+ export const Gradient: (props: {
1382
+ /** Two or more colour stops. Use theme colour roles, never raw hex. */
1383
+ colors: string[];
1384
+ /** CSS degrees: 0 = to top, 90 = to right. Defaults to 180 (to bottom). */
1385
+ angle?: number;
1386
+ style?: any;
1387
+ children?: ReactNode;
1388
+ }) => any;
1389
+
1390
+ // ------------------------------------------------------- theme derivation
1391
+ // sc-3696: the colour maths both hosts resolve `useTheme()` with. Exported so
1392
+ // the Player (frontend/src/services/widgetTheme.js) and the exported app's
1393
+ // generated theme module share ONE implementation instead of mirrored copies.
1394
+
1395
+ /** True when `value` is a 3- or 6-digit hex colour string. */
1396
+ export function isHexColor(value: unknown): boolean;
1397
+
1398
+ /** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
1399
+ export function mixHex(a: string, b: string, t: number): string;
1400
+
1401
+ /** WCAG contrast ratio between two hex colours. Always >= 1. */
1402
+ export function contrastRatio(a: string, b: string): number;
1403
+
1404
+ /** Pick `dark` or `light` — whichever is readable on the given background. */
1405
+ export function readableTextColor(
1406
+ hex: string,
1407
+ dark: string,
1408
+ light: string,
1409
+ ): string;
1410
+
1411
+ /**
1412
+ * Derive the accent's quiet tiers from a resolved palette. `onPrimarySoft` is
1413
+ * stepped toward `onSurface` until it clears WCAG AA against the tint, so a chip
1414
+ * label is legible for every tenant accent. `null` when the inputs aren't hex.
1415
+ */
1416
+ export function deriveAccentTints(
1417
+ primary: string,
1418
+ surface: string,
1419
+ onSurface: string,
1420
+ ): {
1421
+ primarySoft: string;
1422
+ onPrimarySoft: string;
1423
+ primaryStrong: string;
1424
+ } | null;
1425
+
1426
+ /**
1427
+ * Map a CSS gradient angle (0 = to top, 90 = to right) to the `{ start, end }`
1428
+ * unit vectors expo-linear-gradient expects.
1429
+ */
1430
+ export function gradientAngleToVector(angle: number): {
1431
+ start: { x: number; y: number };
1432
+ end: { x: number; y: number };
1433
+ };
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
+
1342
1451
  // Linter
1343
1452
  export interface LintFinding {
1344
1453
  rule: string;
package/dist/index.js CHANGED
@@ -70,7 +70,24 @@ export {
70
70
  Icon,
71
71
  DateTimePicker,
72
72
  FilePicker,
73
+ Gradient,
73
74
  } from "./primitives.js";
74
75
  export { lintSource, bannedIdentifiers } from "./linter.js";
75
- export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
76
+ // sc-3696: the trailing six are the theme-derivation helpers both hosts resolve
77
+ // `useTheme()` with (the Player through widgetTheme.js, the export through its
78
+ // generated theme module), plus the gradient angle projection `<Gradient>`
79
+ // shares with the app-shell backdrop. Keep comments OUT of the export clause —
80
+ // the contract test's orphan-export scanner parses it with a regex.
81
+ export {
82
+ CONTRACT,
83
+ isHookAllowed,
84
+ requiredContextKeys,
85
+ isHexColor,
86
+ mixHex,
87
+ contrastRatio,
88
+ readableTextColor,
89
+ deriveAccentTints,
90
+ gradientAngleToVector,
91
+ normaliseComponentGradient,
92
+ } from "./contract.js";
76
93
  export { normalizeLucideIconName } from "./lucideIconName.js";
@@ -68,7 +68,24 @@ export {
68
68
  Icon,
69
69
  DateTimePicker,
70
70
  FilePicker,
71
+ Gradient,
71
72
  } from "./primitives.native.js";
72
73
  export { lintSource, bannedIdentifiers } from "./linter.js";
73
- export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
74
+ // sc-3696: the trailing six are the theme-derivation helpers both hosts resolve
75
+ // `useTheme()` with (the Player through widgetTheme.js, the export through its
76
+ // generated theme module), plus the gradient angle projection `<Gradient>`
77
+ // shares with the app-shell backdrop. Keep comments OUT of the export clause —
78
+ // the contract test's orphan-export scanner parses it with a regex.
79
+ export {
80
+ CONTRACT,
81
+ isHookAllowed,
82
+ requiredContextKeys,
83
+ isHexColor,
84
+ mixHex,
85
+ contrastRatio,
86
+ readableTextColor,
87
+ deriveAccentTints,
88
+ gradientAngleToVector,
89
+ normaliseComponentGradient,
90
+ } from "./contract.js";
74
91
  export { normalizeLucideIconName } from "./lucideIconName.js";
@@ -76,3 +76,7 @@ export { DateTimePicker } from "./datetimepicker.js";
76
76
  // a vetted expo-document-picker pin lands. `isSupported` is a static
77
77
  // boolean widgets branch on to hide the trigger on native.
78
78
  export { FilePicker } from "./filepicker.js";
79
+ // REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (web). Paints a CSS
80
+ // `linear-gradient` on a View; the native build (./gradient.native.js) delegates
81
+ // to expo-linear-gradient through the same angle projection.
82
+ export { Gradient } from "./gradient.js";
@@ -36,3 +36,7 @@ export { DateTimePicker } from "./datetimepicker.native.js";
36
36
  // equivalent yet, so this build renders a disabled trigger and exposes
37
37
  // `FilePicker.isSupported = false` for widgets to branch on.
38
38
  export { FilePicker } from "./filepicker.native.js";
39
+ // REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (native). Delegates to
40
+ // expo-linear-gradient (pinned unconditionally in every export); the web build
41
+ // (./gradient.js) paints the equivalent CSS `linear-gradient`.
42
+ export { Gradient } from "./gradient.native.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.67.1",
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",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"