@colixsystems/widget-sdk 0.67.0 → 0.68.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) |
@@ -514,17 +514,19 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
514
514
 
515
515
  **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
516
 
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.
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 — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. 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 full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
519
519
  - **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.
520
+ - **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.
521
+ - **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.
522
+ - **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
523
  - **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
524
  - **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
523
525
  - **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
526
  - **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
527
  - **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
528
 
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.
529
+ **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
530
 
529
531
  ## Managing app users from a widget
530
532
 
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({
@@ -376,7 +423,13 @@ const HOOKS = [
376
423
  "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
377
424
  "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
378
425
  "to the created file row or throws the wire error; a 404 means the " +
379
- "destination folder denied a write (REQ-FSH canWrite gate).",
426
+ "destination folder denied a write (REQ-FSH canWrite gate). " +
427
+ "ALWAYS pass spaceType explicitly — omitting it falls back to " +
428
+ "'project', which is unreadable by a logged-out visitor. Choose it by " +
429
+ "who must SEE the file: 'public' for content the app displays to " +
430
+ "anyone browsing (product, catalogue, gallery, cover images), " +
431
+ "'project' for content restricted to signed-in workspace users, " +
432
+ "'personal' for a file private to the uploading app user.",
380
433
  returnShape: {
381
434
  upload: "(file, { folderId? }) => Promise<FilestoreFile>",
382
435
  uploading: "boolean",
@@ -958,6 +1011,16 @@ const PRIMITIVES = [
958
1011
  rnComponent: null,
959
1012
  docsUrl: null,
960
1013
  },
1014
+ // REQ-AI-AGENT-DESIGN (sc-3696) — the gradient surface. Widgets previously had
1015
+ // no gradient at all, which is why generated widgets read flatter than the
1016
+ // competition; this is the ONE cross-platform way to paint one.
1017
+ {
1018
+ name: "Gradient",
1019
+ description:
1020
+ "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`.",
1021
+ rnComponent: "expo-linear-gradient",
1022
+ docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
1023
+ },
961
1024
  ];
962
1025
 
963
1026
  const CATEGORIES = [
@@ -1573,7 +1636,7 @@ const VETTED_IMPORTS = [
1573
1636
  platforms: ["web", "native"],
1574
1637
  category: "drawing",
1575
1638
  description:
1576
- "Linear-gradient fill (<LinearGradient colors={[...]} />). The cross-platform gradient works on both platforms (react-native-linear-gradient is native-only; prefer this).",
1639
+ "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
1640
  },
1578
1641
  {
1579
1642
  specifier: "lottie-react-native",
@@ -2122,10 +2185,136 @@ function requiredContextKeys() {
2122
2185
  return [...keys];
2123
2186
  }
2124
2187
 
2188
+ // ---------------------------------------------------------------------------
2189
+ // Theme derivation (REQ-THEME-DARK + REQ-AI-AGENT-DESIGN)
2190
+ //
2191
+ // The colour maths every host applies on top of a tenant's picked palette.
2192
+ // It lives HERE, in the dual-format contract, because BOTH hosts need it and
2193
+ // both can reach it: the web Player through `frontend/src/services/widgetTheme.js`
2194
+ // and the exported Expo app through the compiler's generated widget-theme
2195
+ // module (which already imports `CONTRACT` from this package). Previously each
2196
+ // host carried its own hand-mirrored copy of `mixHex` / `readableTextColor` —
2197
+ // one source removes that drift (CLAUDE.md §3).
2198
+ // ---------------------------------------------------------------------------
2199
+
2200
+ const HEX_RE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
2201
+
2202
+ function isHexColor(value) {
2203
+ return typeof value === "string" && HEX_RE.test(value);
2204
+ }
2205
+
2206
+ function hexChannels(hex) {
2207
+ let h = hex.slice(1);
2208
+ if (h.length === 3) {
2209
+ h = h
2210
+ .split("")
2211
+ .map((c) => c + c)
2212
+ .join("");
2213
+ }
2214
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
2215
+ }
2216
+
2217
+ /** Relative luminance (WCAG 2.1) of a hex colour. */
2218
+ function relativeLuminance(hex) {
2219
+ const [r, g, b] = hexChannels(hex).map((v) => v / 255);
2220
+ const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
2221
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
2222
+ }
2223
+
2224
+ /** WCAG contrast ratio between two hex colours. Always >= 1. */
2225
+ function contrastRatio(a, b) {
2226
+ const la = relativeLuminance(a);
2227
+ const lb = relativeLuminance(b);
2228
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
2229
+ }
2230
+
2231
+ /** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
2232
+ function mixHex(a, b, t) {
2233
+ if (!isHexColor(a) || !isHexColor(b)) return a;
2234
+ const pa = hexChannels(a);
2235
+ const pb = hexChannels(b);
2236
+ const channel = (i) => Math.round(pa[i] + (pb[i] - pa[i]) * t);
2237
+ const hex = (n) =>
2238
+ Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0");
2239
+ return `#${hex(channel(0))}${hex(channel(1))}${hex(channel(2))}`;
2240
+ }
2241
+
2242
+ /**
2243
+ * Pick a readable text colour (`dark` or `light`) for a solid background.
2244
+ * A light background gets the dark option and vice versa, so `onPrimary` /
2245
+ * `onSecondary` follow whatever accent the tenant picked.
2246
+ */
2247
+ function readableTextColor(hex, dark, light) {
2248
+ if (!isHexColor(hex)) return light;
2249
+ return relativeLuminance(hex) > 0.55 ? dark : light;
2250
+ }
2251
+
2252
+ // A tint at 10% accent reads as "quietly accented" on both a white and a dark
2253
+ // surface without competing with the full-strength accent moment.
2254
+ const ACCENT_TINT_WEIGHT = 0.9;
2255
+ // How far `primaryStrong` travels toward the text colour. Deepens the accent on
2256
+ // a light theme and lightens it on a dark one, which is the right direction for
2257
+ // a gradient's far stop in both.
2258
+ const ACCENT_STRONG_WEIGHT = 0.22;
2259
+ const ACCENT_TEXT_MIN_CONTRAST = 4.5;
2260
+
2261
+ /**
2262
+ * REQ-AI-AGENT-DESIGN (sc-3696): derive the accent's quiet tiers from the
2263
+ * resolved palette. Returns `{ primarySoft, onPrimarySoft, primaryStrong }`.
2264
+ *
2265
+ * `onPrimarySoft` is not a fixed mix: the accent is stepped toward the surface's
2266
+ * text colour until it clears WCAG AA (4.5:1) against the tint, so a chip label
2267
+ * is legible for EVERY tenant accent rather than only for accents that happen
2268
+ * to be dark enough. Returns `null` when the inputs aren't usable hex, letting
2269
+ * the caller keep the default tokens.
2270
+ */
2271
+ function deriveAccentTints(primary, surface, onSurface) {
2272
+ if (!isHexColor(primary) || !isHexColor(surface) || !isHexColor(onSurface)) {
2273
+ return null;
2274
+ }
2275
+ const primarySoft = mixHex(primary, surface, ACCENT_TINT_WEIGHT);
2276
+ let onPrimarySoft = primary;
2277
+ for (let t = 0; t <= 0.9; t += 0.05) {
2278
+ onPrimarySoft = mixHex(primary, onSurface, t);
2279
+ if (contrastRatio(onPrimarySoft, primarySoft) >= ACCENT_TEXT_MIN_CONTRAST) {
2280
+ break;
2281
+ }
2282
+ }
2283
+ return {
2284
+ primarySoft,
2285
+ onPrimarySoft,
2286
+ primaryStrong: mixHex(primary, onSurface, ACCENT_STRONG_WEIGHT),
2287
+ };
2288
+ }
2289
+
2290
+ /**
2291
+ * Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
2292
+ * `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
2293
+ * top-left, y down). Single source for the SDK's `<Gradient>` primitive and
2294
+ * the compiler's app-background backdrop, so a widget gradient and the app
2295
+ * gradient read the same angle identically (CLAUDE.md §3).
2296
+ */
2297
+ function gradientAngleToVector(angle) {
2298
+ const deg = Number.isFinite(angle) ? angle : 180;
2299
+ const rad = ((deg - 90) * Math.PI) / 180;
2300
+ const dx = Math.cos(rad);
2301
+ const dy = Math.sin(rad);
2302
+ return {
2303
+ start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
2304
+ end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
2305
+ };
2306
+ }
2307
+
2125
2308
  module.exports = {
2126
2309
  CONTRACT,
2127
2310
  isHookAllowed,
2128
2311
  requiredContextKeys,
2312
+ isHexColor,
2313
+ mixHex,
2314
+ contrastRatio,
2315
+ readableTextColor,
2316
+ deriveAccentTints,
2317
+ gradientAngleToVector,
2129
2318
  widgetTranslationPrefix,
2130
2319
  widgetTranslationKey,
2131
2320
  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({
@@ -376,7 +423,13 @@ const HOOKS = [
376
423
  "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
377
424
  "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
378
425
  "to the created file row or throws the wire error; a 404 means the " +
379
- "destination folder denied a write (REQ-FSH canWrite gate).",
426
+ "destination folder denied a write (REQ-FSH canWrite gate). " +
427
+ "ALWAYS pass spaceType explicitly — omitting it falls back to " +
428
+ "'project', which is unreadable by a logged-out visitor. Choose it by " +
429
+ "who must SEE the file: 'public' for content the app displays to " +
430
+ "anyone browsing (product, catalogue, gallery, cover images), " +
431
+ "'project' for content restricted to signed-in workspace users, " +
432
+ "'personal' for a file private to the uploading app user.",
380
433
  returnShape: {
381
434
  upload: "(file, { folderId? }) => Promise<FilestoreFile>",
382
435
  uploading: "boolean",
@@ -958,6 +1011,16 @@ const PRIMITIVES = [
958
1011
  rnComponent: null,
959
1012
  docsUrl: null,
960
1013
  },
1014
+ // REQ-AI-AGENT-DESIGN (sc-3696) — the gradient surface. Widgets previously had
1015
+ // no gradient at all, which is why generated widgets read flatter than the
1016
+ // competition; this is the ONE cross-platform way to paint one.
1017
+ {
1018
+ name: "Gradient",
1019
+ description:
1020
+ "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`.",
1021
+ rnComponent: "expo-linear-gradient",
1022
+ docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
1023
+ },
961
1024
  ];
962
1025
 
963
1026
  const CATEGORIES = [
@@ -1573,7 +1636,7 @@ const VETTED_IMPORTS = [
1573
1636
  platforms: ["web", "native"],
1574
1637
  category: "drawing",
1575
1638
  description:
1576
- "Linear-gradient fill (<LinearGradient colors={[...]} />). The cross-platform gradient works on both platforms (react-native-linear-gradient is native-only; prefer this).",
1639
+ "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
1640
  },
1578
1641
  {
1579
1642
  specifier: "lottie-react-native",
@@ -2122,10 +2185,136 @@ function requiredContextKeys() {
2122
2185
  return [...keys];
2123
2186
  }
2124
2187
 
2188
+ // ---------------------------------------------------------------------------
2189
+ // Theme derivation (REQ-THEME-DARK + REQ-AI-AGENT-DESIGN)
2190
+ //
2191
+ // The colour maths every host applies on top of a tenant's picked palette.
2192
+ // It lives HERE, in the dual-format contract, because BOTH hosts need it and
2193
+ // both can reach it: the web Player through `frontend/src/services/widgetTheme.js`
2194
+ // and the exported Expo app through the compiler's generated widget-theme
2195
+ // module (which already imports `CONTRACT` from this package). Previously each
2196
+ // host carried its own hand-mirrored copy of `mixHex` / `readableTextColor` —
2197
+ // one source removes that drift (CLAUDE.md §3).
2198
+ // ---------------------------------------------------------------------------
2199
+
2200
+ const HEX_RE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
2201
+
2202
+ function isHexColor(value) {
2203
+ return typeof value === "string" && HEX_RE.test(value);
2204
+ }
2205
+
2206
+ function hexChannels(hex) {
2207
+ let h = hex.slice(1);
2208
+ if (h.length === 3) {
2209
+ h = h
2210
+ .split("")
2211
+ .map((c) => c + c)
2212
+ .join("");
2213
+ }
2214
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
2215
+ }
2216
+
2217
+ /** Relative luminance (WCAG 2.1) of a hex colour. */
2218
+ function relativeLuminance(hex) {
2219
+ const [r, g, b] = hexChannels(hex).map((v) => v / 255);
2220
+ const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
2221
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
2222
+ }
2223
+
2224
+ /** WCAG contrast ratio between two hex colours. Always >= 1. */
2225
+ function contrastRatio(a, b) {
2226
+ const la = relativeLuminance(a);
2227
+ const lb = relativeLuminance(b);
2228
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
2229
+ }
2230
+
2231
+ /** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
2232
+ function mixHex(a, b, t) {
2233
+ if (!isHexColor(a) || !isHexColor(b)) return a;
2234
+ const pa = hexChannels(a);
2235
+ const pb = hexChannels(b);
2236
+ const channel = (i) => Math.round(pa[i] + (pb[i] - pa[i]) * t);
2237
+ const hex = (n) =>
2238
+ Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0");
2239
+ return `#${hex(channel(0))}${hex(channel(1))}${hex(channel(2))}`;
2240
+ }
2241
+
2242
+ /**
2243
+ * Pick a readable text colour (`dark` or `light`) for a solid background.
2244
+ * A light background gets the dark option and vice versa, so `onPrimary` /
2245
+ * `onSecondary` follow whatever accent the tenant picked.
2246
+ */
2247
+ function readableTextColor(hex, dark, light) {
2248
+ if (!isHexColor(hex)) return light;
2249
+ return relativeLuminance(hex) > 0.55 ? dark : light;
2250
+ }
2251
+
2252
+ // A tint at 10% accent reads as "quietly accented" on both a white and a dark
2253
+ // surface without competing with the full-strength accent moment.
2254
+ const ACCENT_TINT_WEIGHT = 0.9;
2255
+ // How far `primaryStrong` travels toward the text colour. Deepens the accent on
2256
+ // a light theme and lightens it on a dark one, which is the right direction for
2257
+ // a gradient's far stop in both.
2258
+ const ACCENT_STRONG_WEIGHT = 0.22;
2259
+ const ACCENT_TEXT_MIN_CONTRAST = 4.5;
2260
+
2261
+ /**
2262
+ * REQ-AI-AGENT-DESIGN (sc-3696): derive the accent's quiet tiers from the
2263
+ * resolved palette. Returns `{ primarySoft, onPrimarySoft, primaryStrong }`.
2264
+ *
2265
+ * `onPrimarySoft` is not a fixed mix: the accent is stepped toward the surface's
2266
+ * text colour until it clears WCAG AA (4.5:1) against the tint, so a chip label
2267
+ * is legible for EVERY tenant accent rather than only for accents that happen
2268
+ * to be dark enough. Returns `null` when the inputs aren't usable hex, letting
2269
+ * the caller keep the default tokens.
2270
+ */
2271
+ function deriveAccentTints(primary, surface, onSurface) {
2272
+ if (!isHexColor(primary) || !isHexColor(surface) || !isHexColor(onSurface)) {
2273
+ return null;
2274
+ }
2275
+ const primarySoft = mixHex(primary, surface, ACCENT_TINT_WEIGHT);
2276
+ let onPrimarySoft = primary;
2277
+ for (let t = 0; t <= 0.9; t += 0.05) {
2278
+ onPrimarySoft = mixHex(primary, onSurface, t);
2279
+ if (contrastRatio(onPrimarySoft, primarySoft) >= ACCENT_TEXT_MIN_CONTRAST) {
2280
+ break;
2281
+ }
2282
+ }
2283
+ return {
2284
+ primarySoft,
2285
+ onPrimarySoft,
2286
+ primaryStrong: mixHex(primary, onSurface, ACCENT_STRONG_WEIGHT),
2287
+ };
2288
+ }
2289
+
2290
+ /**
2291
+ * Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
2292
+ * `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
2293
+ * top-left, y down). Single source for the SDK's `<Gradient>` primitive and
2294
+ * the compiler's app-background backdrop, so a widget gradient and the app
2295
+ * gradient read the same angle identically (CLAUDE.md §3).
2296
+ */
2297
+ function gradientAngleToVector(angle) {
2298
+ const deg = Number.isFinite(angle) ? angle : 180;
2299
+ const rad = ((deg - 90) * Math.PI) / 180;
2300
+ const dx = Math.cos(rad);
2301
+ const dy = Math.sin(rad);
2302
+ return {
2303
+ start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
2304
+ end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
2305
+ };
2306
+ }
2307
+
2125
2308
  export {
2126
2309
  CONTRACT,
2127
2310
  isHookAllowed,
2128
2311
  requiredContextKeys,
2312
+ isHexColor,
2313
+ mixHex,
2314
+ contrastRatio,
2315
+ readableTextColor,
2316
+ deriveAccentTints,
2317
+ gradientAngleToVector,
2129
2318
  widgetTranslationPrefix,
2130
2319
  widgetTranslationKey,
2131
2320
  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/hooks.js CHANGED
@@ -593,6 +593,12 @@ function toDatastoreError(err) {
593
593
  });
594
594
  }
595
595
 
596
+ // sc-1579 — consecutive renders with a different serialized query before
597
+ // `useDatastoreQuery` calls the loop out. High enough that no real interaction
598
+ // reaches it (a settled fetch resets the count), low enough to fire instantly
599
+ // on a genuine loop.
600
+ const VOLATILE_QUERY_RENDER_LIMIT = 10;
601
+
596
602
  /**
597
603
  * Stateful datastore query hook. Returns { data, loading, error, refetch }.
598
604
  *
@@ -682,6 +688,35 @@ export function useDatastoreQuery(table, query) {
682
688
  return null;
683
689
  }
684
690
  })();
691
+
692
+ // sc-1579 — a query argument built from a volatile value (`Date.now()`,
693
+ // `new Date()`, `Math.random()`) serialises differently every render, so the
694
+ // effect below re-fetches forever and the widget never settles. A genuine
695
+ // edit (a typed filter) settles within one render because the re-render the
696
+ // fetch triggers reuses the same argument; a volatile one never does. That
697
+ // gap is the signal — no timers, no heuristics. Warned once per instance,
698
+ // and in production too: the Studio canvas and the widget agent's render
699
+ // smoke check both run production builds, which is where this must surface.
700
+ const volatileKeyRef = useRef({ key: queryKey, changes: 0, warned: false });
701
+ const keyWatch = volatileKeyRef.current;
702
+ if (keyWatch.key === queryKey) {
703
+ keyWatch.changes = 0;
704
+ } else {
705
+ keyWatch.key = queryKey;
706
+ keyWatch.changes += 1;
707
+ if (keyWatch.changes >= VOLATILE_QUERY_RENDER_LIMIT && !keyWatch.warned) {
708
+ keyWatch.warned = true;
709
+ // eslint-disable-next-line no-console
710
+ console.warn(
711
+ `useDatastoreQuery(${JSON.stringify(table)}): the query argument changed on ` +
712
+ `${keyWatch.changes} consecutive renders, so this widget is re-fetching in a ` +
713
+ "loop. Something in the query is regenerated every render — usually a date " +
714
+ "or random value. Compute it once outside render (or with useMemo) and pass " +
715
+ "the stable value in.",
716
+ );
717
+ }
718
+ }
719
+
685
720
  useEffect(() => {
686
721
  doFetch();
687
722
  // eslint-disable-next-line react-hooks/exhaustive-deps
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,75 @@ 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
+
1342
1435
  // Linter
1343
1436
  export interface LintFinding {
1344
1437
  rule: string;
package/dist/index.js CHANGED
@@ -70,7 +70,23 @@ 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
+ } from "./contract.js";
76
92
  export { normalizeLucideIconName } from "./lucideIconName.js";
@@ -68,7 +68,23 @@ 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
+ } from "./contract.js";
74
90
  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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.67.0",
3
+ "version": "0.68.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__/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"