@colixsystems/widget-sdk 0.68.0 → 0.70.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
@@ -30,6 +30,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
30
30
  | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
31
31
  | **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). |
32
32
  | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
33
+ | **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
33
34
  | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
34
35
  | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
35
36
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
@@ -53,7 +54,39 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
53
54
 
54
55
  ## Status
55
56
 
56
- `v0.67.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
57
+ `v0.70.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
58
+
59
+ ### What's new in 0.70.0
60
+
61
+ **Translate content the user typed — `useTranslate()` (sc-3783).** The workspace dictionary only covers strings *you* authored; content living in the app's data — a record's description, a file name, a REST payload — has no translation key because nobody assigned it one, so an app user who picked English still read it in whatever language it was entered. `useTranslate()` closes that gap:
62
+
63
+ ```jsx
64
+ import { useState, useEffect } from "react";
65
+ import { Text, useTranslate } from "@colixsystems/widget-sdk";
66
+
67
+ const { translate, available, language } = useTranslate();
68
+ const [shown, setShown] = useState(rows.map((r) => r.notes));
69
+ useEffect(() => {
70
+ if (!available) return;
71
+ let live = true;
72
+ // ONE request for the whole batch. On failure keep the original text.
73
+ translate(rows.map((r) => r.notes))
74
+ .then((out) => { if (live) setShown(out); })
75
+ .catch(() => {});
76
+ return () => { live = false; };
77
+ // `language` is in the deps on purpose: the app user can switch language
78
+ // at any time, and this must re-translate when they do.
79
+ }, [rows, available, translate, language]);
80
+ ```
81
+
82
+ - **The target language is the app user's selected language** by default — that is the point of the hook, so a widget never has to know how the language was chosen. Pass `{ target }` only when the widget's purpose is translating into a language the user picks, and `{ source }` when you know the content's language (otherwise the provider auto-detects).
83
+ - **A string in, a string out; an array in, an array out** — positionally aligned, and an array is ONE request. Calling it per row is the mistake to avoid. Limits per call: 50 segments, 5 000 characters each, 20 000 total.
84
+ - **Repeat text is free.** Three caches sit behind it: the hook memoizes per session, the API keeps a short-lived in-process cache, and the platform keeps a durable per-workspace cache keyed by the content itself. Text already in the target language and blank text never reach the network at all.
85
+ - **It costs a metered budget, so use it deliberately.** Each workspace has a monthly translated-character cap (only cache misses count). Exhausting it rejects with `TranslateError` code `TRANSLATION_QUOTA_EXCEEDED`, which — unlike a rate limit — will not clear until the next period; cached translations keep working. `TRANSLATE_NOT_CONFIGURED` means the platform has no provider at all.
86
+ - **Never block a render on it.** Show the original text and swap in the translation when it resolves; always `catch` and fall back. `available` is `false` on a host that brokers no translation client (the Studio canvas preview), where `translate` rejects `UNSUPPORTED` instead of throwing at render — so hide any translate affordance when it is false.
87
+ - **Identical on both hosts.** The web Player and the exported Expo app inject the same new `@colixsystems/translation-client` into `ctx.i18n.translate`, so the hook behaves the same in the browser and on a device.
88
+
89
+ `CONTRACT.version` → `1.47.0`. Additive: one new hook + its error class, and one optional `ctx.i18n.translate` slice field. No existing export changed signature.
57
90
 
58
91
  ### What's new in 0.67.0
59
92
 
@@ -61,9 +94,10 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
61
94
 
62
95
  - **`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
96
  - **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 `{}`.
97
+ - **`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 `{}`.
98
+ - **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
99
 
66
- `CONTRACT.version` → `1.45.0`. Additive; no existing export changed signature, and an unthemed app renders identically.
100
+ `CONTRACT.version` → `1.46.0`. Additive; no existing export changed signature, and an unthemed app renders identically.
67
101
 
68
102
  ### What's new in 0.66.0
69
103
 
@@ -522,6 +556,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
522
556
  - **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.
523
557
  - **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).
524
558
  - **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
559
+ - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`.
525
560
  - **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).
526
561
  - **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.
527
562
  - **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.
package/dist/contract.cjs CHANGED
@@ -107,6 +107,17 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
107
107
  // cannot drift between what Mason may emit and what a host actually applies.
108
108
  const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
109
109
 
110
+ // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
111
+ // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
112
+ // variant (a component fill projects through `<Gradient>`, which is linear-only
113
+ // on both hosts). Declared once so the coercion, the Studio control and the
114
+ // planner prompt agree on the bounds.
115
+ const THEME_COMPONENT_GRADIENT = Object.freeze({
116
+ angleMin: 0,
117
+ angleMax: 359,
118
+ defaultAngle: 180,
119
+ });
120
+
110
121
  // The card-surface field names shared by every widget that paints its own card
111
122
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
112
123
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -115,6 +126,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
115
126
  radius: "cardRadius",
116
127
  padding: "cardPadding",
117
128
  shadow: "shadow",
129
+ gradient: "cardGradient",
118
130
  });
119
131
 
120
132
  // A form widget's submit button — the `button` scope reaches it through the
@@ -122,6 +134,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
122
134
  const FORM_SUBMIT_FIELDS = Object.freeze({
123
135
  background: "submitBackground",
124
136
  textColor: "submitTextColor",
137
+ gradient: "submitGradient",
125
138
  });
126
139
 
127
140
  const THEME_COMPONENTS = Object.freeze({
@@ -134,6 +147,7 @@ const THEME_COMPONENTS = Object.freeze({
134
147
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
135
148
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
136
149
  shadow: Object.freeze({ type: "shadow" }),
150
+ gradient: Object.freeze({ type: "gradient" }),
137
151
  }),
138
152
  targets: Object.freeze({
139
153
  "appstudio.button": Object.freeze({
@@ -143,6 +157,7 @@ const THEME_COMPONENTS = Object.freeze({
143
157
  radius: "radius",
144
158
  fontSize: "fontSize",
145
159
  shadow: "shadow",
160
+ gradient: "gradient",
146
161
  }),
147
162
  "appstudio.form-input": FORM_SUBMIT_FIELDS,
148
163
  "appstudio.form-builder": FORM_SUBMIT_FIELDS,
@@ -156,6 +171,7 @@ const THEME_COMPONENTS = Object.freeze({
156
171
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
157
172
  padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
158
173
  shadow: Object.freeze({ type: "shadow" }),
174
+ gradient: Object.freeze({ type: "gradient" }),
159
175
  }),
160
176
  targets: Object.freeze({
161
177
  "appstudio.user": CARD_SURFACE_FIELDS,
@@ -166,6 +182,7 @@ const THEME_COMPONENTS = Object.freeze({
166
182
  "appstudio.notifications": CARD_SURFACE_FIELDS,
167
183
  "appstudio.form-input": CARD_SURFACE_FIELDS,
168
184
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
185
+ "appstudio.user-management": CARD_SURFACE_FIELDS,
169
186
  }),
170
187
  }),
171
188
  text: Object.freeze({
@@ -230,6 +247,31 @@ const HOOKS = [
230
247
  requiredContextSlice: ["i18n.t", "i18n.locale"],
231
248
  scopes: null,
232
249
  },
250
+ {
251
+ name: "useTranslate",
252
+ signature: "useTranslate()",
253
+ description:
254
+ "sc-3783 — translate USER-GENERATED content (a record's text, a file name, an API payload) into the app user's selected language. " +
255
+ "NOT for the app's own copy: author-written strings belong in the workspace dictionary and are resolved for free by useI18n().t(key); " +
256
+ "reach for translate() only when there is no key because there is no author. Returns { translate, translating, error, language, available }. " +
257
+ "translate(input, options?) takes a string (resolves to a string) or an array of strings (resolves to an array, positionally aligned) and " +
258
+ "batches an array into ONE request. options.target defaults to the app user's language; options.source is optional (the provider auto-detects). " +
259
+ "Text already in the target language, blank text, and text already translated this session cost nothing and never reach the network. " +
260
+ "Rejects with a TranslateError whose .code is one of UNSUPPORTED | TRANSLATE_NOT_CONFIGURED | TRANSLATION_QUOTA_EXCEEDED | RATE_LIMITED | " +
261
+ "PAYLOAD_TOO_LARGE | VALIDATION | AUTH_REQUIRED | INTERNAL. Limits per call: 50 segments, 5 000 chars each, 20 000 chars total. " +
262
+ "`available` is false on a host that brokers no translation client; translate() then rejects UNSUPPORTED instead of throwing at render. " +
263
+ "Identical on web (Player) and the Expo export — both hosts inject the same @colixsystems/translation-client.",
264
+ returnShape: {
265
+ translate:
266
+ "(input: string | string[], options?: { target?: string, source?: string }) => Promise<string | string[]> // rejects with TranslateError",
267
+ translating: "boolean",
268
+ error: "TranslateError | null",
269
+ language: "string // the app user's selected language (the default target)",
270
+ available: "boolean",
271
+ },
272
+ requiredContextSlice: ["i18n.locale"],
273
+ scopes: null,
274
+ },
233
275
  {
234
276
  name: "useUser",
235
277
  signature: "useUser()",
@@ -1326,9 +1368,18 @@ const WIDGET_CONTEXT_SHAPE = {
1326
1368
  // ctx.datastore.records(table).permissions(record). See the `directory`
1327
1369
  // and `datastore` slices above.
1328
1370
  i18n: {
1329
- description: "{ t(key, fallback?), locale }.",
1371
+ // sc-3783 `translate` is the injected @colixsystems/translation-client
1372
+ // instance backing useTranslate(). OPTIONAL on the slice: a host that
1373
+ // brokers no translation client omits it and the hook reports
1374
+ // available:false (same convention as ctx.toast / ctx.device) rather than
1375
+ // throwing at render. Both the web Player and the Expo export inject it.
1376
+ description:
1377
+ "{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
1378
+ "@colixsystems/translation-client ({ translate(body), status() }) that backs " +
1379
+ "useTranslate() for user-generated content.",
1330
1380
  required: true,
1331
1381
  fields: { t: "function", locale: "string" },
1382
+ optionalFields: { translate: "object" },
1332
1383
  },
1333
1384
  logger: {
1334
1385
  description:
@@ -2150,7 +2201,21 @@ const CONTRACT = deepFreeze({
2150
2201
  // (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
2151
2202
  // export changed signature and a theme with no `components` key resolves to
2152
2203
  // an empty override, rendering identically to before.
2153
- version: "1.45.0",
2204
+ // 1.46.0: additive (sc-3727) — a `gradient` token type on the per-component
2205
+ // vocabulary. The `button` and `card` scopes each gain a `gradient` token
2206
+ // whose value is `{ from: "#hex", to: "#hex", angle: 0-359 }` — the
2207
+ // `backgroundGradient` grammar minus the radial variant, because a component
2208
+ // fill paints through the `<Gradient>` primitive, which is linear on both
2209
+ // hosts. `themeComponentGradient` publishes the angle bounds + default so the
2210
+ // coercion, the Studio control and the planner prompt cannot disagree. The
2211
+ // token binds to per-instance style fields the target widgets read
2212
+ // (`gradient` on Button, `cardGradient` on every card surface,
2213
+ // `submitGradient` on a form's submit button), so the theme value is a
2214
+ // DEFAULT an author overrides per instance — or suppresses with an explicit
2215
+ // null, which is how one button stays flat while the rest are gradiented.
2216
+ // Additive: no export changed signature and a theme with no gradient token
2217
+ // renders exactly as before.
2218
+ version: "1.47.0",
2154
2219
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2155
2220
  hooks: HOOKS,
2156
2221
  primitives: PRIMITIVES,
@@ -2163,6 +2228,7 @@ const CONTRACT = deepFreeze({
2163
2228
  themeTokens: DEFAULT_THEME_TOKENS,
2164
2229
  themeComponents: THEME_COMPONENTS,
2165
2230
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2231
+ themeComponentGradient: THEME_COMPONENT_GRADIENT,
2166
2232
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2167
2233
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
2168
2234
  bannedApis: BANNED_APIS,
@@ -2305,6 +2371,33 @@ function gradientAngleToVector(angle) {
2305
2371
  };
2306
2372
  }
2307
2373
 
2374
+ // Alpha is allowed here (unlike HEX_RE) because the Mason build runner already
2375
+ // persists 8-digit component colours; the host must not drop what it accepted.
2376
+ const GRADIENT_HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
2377
+
2378
+ /**
2379
+ * sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
2380
+ *
2381
+ * ONE validator for a value arriving by two routes — the theme token and the
2382
+ * author's never-coerced per-instance `props.style` — so `set_theme` and the
2383
+ * renderer cannot disagree about the same value (CLAUDE.md §3). Both stops are
2384
+ * required: a one-stop gradient would paint a fill the author never chose. The
2385
+ * hex grammar is also the injection guard — these stops are interpolated into
2386
+ * CSS and into generated export source.
2387
+ */
2388
+ function normaliseComponentGradient(raw) {
2389
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
2390
+ const from = typeof raw.from === "string" ? raw.from.trim() : "";
2391
+ const to = typeof raw.to === "string" ? raw.to.trim() : "";
2392
+ if (!GRADIENT_HEX_RE.test(from) || !GRADIENT_HEX_RE.test(to)) return null;
2393
+ const { defaultAngle } = THEME_COMPONENT_GRADIENT;
2394
+ // Read once — a second read of an accessor could return a different value and
2395
+ // land NaN in a style.
2396
+ const angle = raw.angle;
2397
+ const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
2398
+ return { from, to, angle: ((deg % 360) + 360) % 360 };
2399
+ }
2400
+
2308
2401
  module.exports = {
2309
2402
  CONTRACT,
2310
2403
  isHookAllowed,
@@ -2315,6 +2408,7 @@ module.exports = {
2315
2408
  readableTextColor,
2316
2409
  deriveAccentTints,
2317
2410
  gradientAngleToVector,
2411
+ normaliseComponentGradient,
2318
2412
  widgetTranslationPrefix,
2319
2413
  widgetTranslationKey,
2320
2414
  sharedTranslationPrefix,
package/dist/contract.js CHANGED
@@ -107,6 +107,17 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
107
107
  // cannot drift between what Mason may emit and what a host actually applies.
108
108
  const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
109
109
 
110
+ // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
111
+ // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
112
+ // variant (a component fill projects through `<Gradient>`, which is linear-only
113
+ // on both hosts). Declared once so the coercion, the Studio control and the
114
+ // planner prompt agree on the bounds.
115
+ const THEME_COMPONENT_GRADIENT = Object.freeze({
116
+ angleMin: 0,
117
+ angleMax: 359,
118
+ defaultAngle: 180,
119
+ });
120
+
110
121
  // The card-surface field names shared by every widget that paints its own card
111
122
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
112
123
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -115,6 +126,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
115
126
  radius: "cardRadius",
116
127
  padding: "cardPadding",
117
128
  shadow: "shadow",
129
+ gradient: "cardGradient",
118
130
  });
119
131
 
120
132
  // A form widget's submit button — the `button` scope reaches it through the
@@ -122,6 +134,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
122
134
  const FORM_SUBMIT_FIELDS = Object.freeze({
123
135
  background: "submitBackground",
124
136
  textColor: "submitTextColor",
137
+ gradient: "submitGradient",
125
138
  });
126
139
 
127
140
  const THEME_COMPONENTS = Object.freeze({
@@ -134,6 +147,7 @@ const THEME_COMPONENTS = Object.freeze({
134
147
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
135
148
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
136
149
  shadow: Object.freeze({ type: "shadow" }),
150
+ gradient: Object.freeze({ type: "gradient" }),
137
151
  }),
138
152
  targets: Object.freeze({
139
153
  "appstudio.button": Object.freeze({
@@ -143,6 +157,7 @@ const THEME_COMPONENTS = Object.freeze({
143
157
  radius: "radius",
144
158
  fontSize: "fontSize",
145
159
  shadow: "shadow",
160
+ gradient: "gradient",
146
161
  }),
147
162
  "appstudio.form-input": FORM_SUBMIT_FIELDS,
148
163
  "appstudio.form-builder": FORM_SUBMIT_FIELDS,
@@ -156,6 +171,7 @@ const THEME_COMPONENTS = Object.freeze({
156
171
  radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
157
172
  padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
158
173
  shadow: Object.freeze({ type: "shadow" }),
174
+ gradient: Object.freeze({ type: "gradient" }),
159
175
  }),
160
176
  targets: Object.freeze({
161
177
  "appstudio.user": CARD_SURFACE_FIELDS,
@@ -166,6 +182,7 @@ const THEME_COMPONENTS = Object.freeze({
166
182
  "appstudio.notifications": CARD_SURFACE_FIELDS,
167
183
  "appstudio.form-input": CARD_SURFACE_FIELDS,
168
184
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
185
+ "appstudio.user-management": CARD_SURFACE_FIELDS,
169
186
  }),
170
187
  }),
171
188
  text: Object.freeze({
@@ -230,6 +247,31 @@ const HOOKS = [
230
247
  requiredContextSlice: ["i18n.t", "i18n.locale"],
231
248
  scopes: null,
232
249
  },
250
+ {
251
+ name: "useTranslate",
252
+ signature: "useTranslate()",
253
+ description:
254
+ "sc-3783 — translate USER-GENERATED content (a record's text, a file name, an API payload) into the app user's selected language. " +
255
+ "NOT for the app's own copy: author-written strings belong in the workspace dictionary and are resolved for free by useI18n().t(key); " +
256
+ "reach for translate() only when there is no key because there is no author. Returns { translate, translating, error, language, available }. " +
257
+ "translate(input, options?) takes a string (resolves to a string) or an array of strings (resolves to an array, positionally aligned) and " +
258
+ "batches an array into ONE request. options.target defaults to the app user's language; options.source is optional (the provider auto-detects). " +
259
+ "Text already in the target language, blank text, and text already translated this session cost nothing and never reach the network. " +
260
+ "Rejects with a TranslateError whose .code is one of UNSUPPORTED | TRANSLATE_NOT_CONFIGURED | TRANSLATION_QUOTA_EXCEEDED | RATE_LIMITED | " +
261
+ "PAYLOAD_TOO_LARGE | VALIDATION | AUTH_REQUIRED | INTERNAL. Limits per call: 50 segments, 5 000 chars each, 20 000 chars total. " +
262
+ "`available` is false on a host that brokers no translation client; translate() then rejects UNSUPPORTED instead of throwing at render. " +
263
+ "Identical on web (Player) and the Expo export — both hosts inject the same @colixsystems/translation-client.",
264
+ returnShape: {
265
+ translate:
266
+ "(input: string | string[], options?: { target?: string, source?: string }) => Promise<string | string[]> // rejects with TranslateError",
267
+ translating: "boolean",
268
+ error: "TranslateError | null",
269
+ language: "string // the app user's selected language (the default target)",
270
+ available: "boolean",
271
+ },
272
+ requiredContextSlice: ["i18n.locale"],
273
+ scopes: null,
274
+ },
233
275
  {
234
276
  name: "useUser",
235
277
  signature: "useUser()",
@@ -1326,9 +1368,18 @@ const WIDGET_CONTEXT_SHAPE = {
1326
1368
  // ctx.datastore.records(table).permissions(record). See the `directory`
1327
1369
  // and `datastore` slices above.
1328
1370
  i18n: {
1329
- description: "{ t(key, fallback?), locale }.",
1371
+ // sc-3783 `translate` is the injected @colixsystems/translation-client
1372
+ // instance backing useTranslate(). OPTIONAL on the slice: a host that
1373
+ // brokers no translation client omits it and the hook reports
1374
+ // available:false (same convention as ctx.toast / ctx.device) rather than
1375
+ // throwing at render. Both the web Player and the Expo export inject it.
1376
+ description:
1377
+ "{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
1378
+ "@colixsystems/translation-client ({ translate(body), status() }) that backs " +
1379
+ "useTranslate() for user-generated content.",
1330
1380
  required: true,
1331
1381
  fields: { t: "function", locale: "string" },
1382
+ optionalFields: { translate: "object" },
1332
1383
  },
1333
1384
  logger: {
1334
1385
  description:
@@ -2150,7 +2201,21 @@ const CONTRACT = deepFreeze({
2150
2201
  // (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
2151
2202
  // export changed signature and a theme with no `components` key resolves to
2152
2203
  // an empty override, rendering identically to before.
2153
- version: "1.45.0",
2204
+ // 1.46.0: additive (sc-3727) — a `gradient` token type on the per-component
2205
+ // vocabulary. The `button` and `card` scopes each gain a `gradient` token
2206
+ // whose value is `{ from: "#hex", to: "#hex", angle: 0-359 }` — the
2207
+ // `backgroundGradient` grammar minus the radial variant, because a component
2208
+ // fill paints through the `<Gradient>` primitive, which is linear on both
2209
+ // hosts. `themeComponentGradient` publishes the angle bounds + default so the
2210
+ // coercion, the Studio control and the planner prompt cannot disagree. The
2211
+ // token binds to per-instance style fields the target widgets read
2212
+ // (`gradient` on Button, `cardGradient` on every card surface,
2213
+ // `submitGradient` on a form's submit button), so the theme value is a
2214
+ // DEFAULT an author overrides per instance — or suppresses with an explicit
2215
+ // null, which is how one button stays flat while the rest are gradiented.
2216
+ // Additive: no export changed signature and a theme with no gradient token
2217
+ // renders exactly as before.
2218
+ version: "1.47.0",
2154
2219
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2155
2220
  hooks: HOOKS,
2156
2221
  primitives: PRIMITIVES,
@@ -2163,6 +2228,7 @@ const CONTRACT = deepFreeze({
2163
2228
  themeTokens: DEFAULT_THEME_TOKENS,
2164
2229
  themeComponents: THEME_COMPONENTS,
2165
2230
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2231
+ themeComponentGradient: THEME_COMPONENT_GRADIENT,
2166
2232
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2167
2233
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
2168
2234
  bannedApis: BANNED_APIS,
@@ -2305,6 +2371,33 @@ function gradientAngleToVector(angle) {
2305
2371
  };
2306
2372
  }
2307
2373
 
2374
+ // Alpha is allowed here (unlike HEX_RE) because the Mason build runner already
2375
+ // persists 8-digit component colours; the host must not drop what it accepted.
2376
+ const GRADIENT_HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
2377
+
2378
+ /**
2379
+ * sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
2380
+ *
2381
+ * ONE validator for a value arriving by two routes — the theme token and the
2382
+ * author's never-coerced per-instance `props.style` — so `set_theme` and the
2383
+ * renderer cannot disagree about the same value (CLAUDE.md §3). Both stops are
2384
+ * required: a one-stop gradient would paint a fill the author never chose. The
2385
+ * hex grammar is also the injection guard — these stops are interpolated into
2386
+ * CSS and into generated export source.
2387
+ */
2388
+ function normaliseComponentGradient(raw) {
2389
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
2390
+ const from = typeof raw.from === "string" ? raw.from.trim() : "";
2391
+ const to = typeof raw.to === "string" ? raw.to.trim() : "";
2392
+ if (!GRADIENT_HEX_RE.test(from) || !GRADIENT_HEX_RE.test(to)) return null;
2393
+ const { defaultAngle } = THEME_COMPONENT_GRADIENT;
2394
+ // Read once — a second read of an accessor could return a different value and
2395
+ // land NaN in a style.
2396
+ const angle = raw.angle;
2397
+ const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
2398
+ return { from, to, angle: ((deg % 360) + 360) % 360 };
2399
+ }
2400
+
2308
2401
  export {
2309
2402
  CONTRACT,
2310
2403
  isHookAllowed,
@@ -2315,6 +2408,7 @@ export {
2315
2408
  readableTextColor,
2316
2409
  deriveAccentTints,
2317
2410
  gradientAngleToVector,
2411
+ normaliseComponentGradient,
2318
2412
  widgetTranslationPrefix,
2319
2413
  widgetTranslationKey,
2320
2414
  sharedTranslationPrefix,
package/dist/hooks.js CHANGED
@@ -368,6 +368,222 @@ export function useI18n() {
368
368
  return { t, locale };
369
369
  }
370
370
 
371
+ /**
372
+ * sc-3783 — structured error thrown by `useTranslate().translate`. Carries a
373
+ * stable `code` so widgets branch without parsing message strings; mirrors the
374
+ * shape of `NotificationError` / `DirectoryError`.
375
+ *
376
+ * `code` is one of:
377
+ * - "UNSUPPORTED" — the host brokers no translation client
378
+ * - "TRANSLATE_NOT_CONFIGURED" — 503 (platform has no provider configured)
379
+ * - "TRANSLATION_QUOTA_EXCEEDED" — 429 (workspace's monthly character cap;
380
+ * does NOT clear until the next period)
381
+ * - "RATE_LIMITED" — 429 (too many calls — retry shortly)
382
+ * - "PAYLOAD_TOO_LARGE" — 413 (batch smaller)
383
+ * - "VALIDATION" — 400 (bad segments or language code)
384
+ * - "AUTH_REQUIRED" — 401 (no signed-in app user)
385
+ * - "INTERNAL" — anything else (network, 5xx)
386
+ */
387
+ export class TranslateError extends Error {
388
+ constructor(code, message, opts) {
389
+ super(message);
390
+ this.name = "TranslateError";
391
+ this.code = code;
392
+ if (opts && opts.cause) this.cause = opts.cause;
393
+ }
394
+ }
395
+
396
+ function toTranslateError(err) {
397
+ if (err instanceof TranslateError) return err;
398
+ const status =
399
+ err && err.response && typeof err.response.status === "number"
400
+ ? err.response.status
401
+ : err && typeof err.status === "number"
402
+ ? err.status
403
+ : null;
404
+ const bodyCode =
405
+ (err && err.response && err.response.data && err.response.data.code) ||
406
+ (err && typeof err.code === "string" ? err.code : null);
407
+ const bodyMessage =
408
+ err && err.response && err.response.data && err.response.data.error;
409
+ let code = "INTERNAL";
410
+ if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
411
+ else if (status === 401) code = "AUTH_REQUIRED";
412
+ else if (status === 413) code = "PAYLOAD_TOO_LARGE";
413
+ else if (status === 429) code = "RATE_LIMITED";
414
+ else if (status === 400) code = "VALIDATION";
415
+ else if (status === 503) code = "TRANSLATE_NOT_CONFIGURED";
416
+ const message =
417
+ (typeof bodyMessage === "string" && bodyMessage) ||
418
+ (err && typeof err.message === "string" ? err.message : "Translation failed");
419
+ return new TranslateError(code, message, { cause: err });
420
+ }
421
+
422
+ // Session-lifetime memo shared by every widget instance on the page. This is
423
+ // the third cache layer (the API keeps an in-process one, the DB keeps the
424
+ // durable one) and the only one that avoids the network entirely — it matters
425
+ // because a list widget re-renders constantly with the same row text. Keyed by
426
+ // the same tuple the server keys by, so it can never answer for the wrong pair.
427
+ const TRANSLATE_MEMO_MAX = 1000;
428
+ const translateMemo = new Map();
429
+
430
+ function memoKey(text, source, target) {
431
+ return `${target}|${source || "auto"}|${text}`;
432
+ }
433
+
434
+ function memoRead(text, source, target) {
435
+ const key = memoKey(text, source, target);
436
+ if (!translateMemo.has(key)) return undefined;
437
+ const value = translateMemo.get(key);
438
+ translateMemo.delete(key);
439
+ translateMemo.set(key, value);
440
+ return value;
441
+ }
442
+
443
+ function memoWrite(text, source, target, value) {
444
+ if (translateMemo.size >= TRANSLATE_MEMO_MAX) {
445
+ const oldest = translateMemo.keys().next();
446
+ if (!oldest.done) translateMemo.delete(oldest.value);
447
+ }
448
+ translateMemo.set(memoKey(text, source, target), value);
449
+ }
450
+
451
+ /** Exposed so tests (and a host teardown) can drop the session memo. */
452
+ export function _resetTranslateMemo() {
453
+ translateMemo.clear();
454
+ }
455
+
456
+ /**
457
+ * sc-3783 — translate USER-GENERATED content into the app user's language.
458
+ * Returns `{ translate, translating, error, language, available }`.
459
+ *
460
+ * Use this for text nobody authored: a datastore record's description, a file
461
+ * name, a REST payload. The app's OWN copy belongs in the workspace dictionary
462
+ * and is resolved for free by `useI18n().t(key)` — reach for `translate` only
463
+ * when there is no key because there is no author.
464
+ *
465
+ * const { translate } = useTranslate();
466
+ * const shown = await translate(record.description); // → app user's language
467
+ * const rows = await translate(items.map((i) => i.title)); // batch, one request
468
+ * const de = await translate(text, { source: "sv", target: "de" });
469
+ *
470
+ * `translate` accepts a string (resolves to a string) or an array of strings
471
+ * (resolves to an array, positionally aligned). `target` defaults to the app
472
+ * user's selected language — the whole point of the hook, so a widget never has
473
+ * to know how the language was chosen. `source` is optional; the provider
474
+ * auto-detects when it is omitted.
475
+ *
476
+ * Text already in the target language, blank text, and text this session has
477
+ * already translated cost nothing and never reach the network.
478
+ *
479
+ * `available` is false when the host brokers no translation client (an older
480
+ * host). Calling `translate` then rejects with code "UNSUPPORTED" rather than
481
+ * throwing at render, so a widget degrades to untranslated text instead of
482
+ * breaking the page.
483
+ */
484
+ export function useTranslate() {
485
+ const ctx = useWidgetContextOrThrow("useTranslate");
486
+ const i18n = ctx.i18n || {};
487
+ const locale = typeof i18n.locale === "string" && i18n.locale ? i18n.locale : "en";
488
+ const client =
489
+ i18n.translate && typeof i18n.translate.translate === "function"
490
+ ? i18n.translate
491
+ : null;
492
+
493
+ // `ctx` is a fresh identity each host render — hold the client + locale in
494
+ // refs so the returned callback stays stable across renders.
495
+ const clientRef = useRef(client);
496
+ clientRef.current = client;
497
+ const localeRef = useRef(locale);
498
+ localeRef.current = locale;
499
+
500
+ const [translating, setTranslating] = useState(false);
501
+ const [error, setError] = useState(null);
502
+
503
+ const translate = useCallback(async (input, options) => {
504
+ const wasArray = Array.isArray(input);
505
+ const segments = wasArray ? input : [input];
506
+ for (const segment of segments) {
507
+ if (typeof segment !== "string") {
508
+ throw new TranslateError(
509
+ "VALIDATION",
510
+ "useTranslate: expected a string or an array of strings",
511
+ );
512
+ }
513
+ }
514
+ const target =
515
+ options && typeof options.target === "string" && options.target
516
+ ? options.target
517
+ : localeRef.current;
518
+ const source =
519
+ options && typeof options.source === "string" && options.source
520
+ ? options.source
521
+ : null;
522
+
523
+ // Nothing to do: same language in and out, so return the input untouched
524
+ // without waking the network or the provider.
525
+ if (source && source === target) return wasArray ? [...segments] : segments[0];
526
+
527
+ const out = new Array(segments.length);
528
+ const missing = [];
529
+ const missingIndices = [];
530
+ for (let i = 0; i < segments.length; i++) {
531
+ const text = segments[i];
532
+ if (text.trim() === "") {
533
+ out[i] = text;
534
+ continue;
535
+ }
536
+ const memoized = memoRead(text, source, target);
537
+ if (memoized !== undefined) {
538
+ out[i] = memoized;
539
+ continue;
540
+ }
541
+ missing.push(text);
542
+ missingIndices.push(i);
543
+ }
544
+ if (missing.length === 0) return wasArray ? out : out[0];
545
+
546
+ if (!clientRef.current) {
547
+ const err = new TranslateError(
548
+ "UNSUPPORTED",
549
+ "useTranslate: the host brokers no translation client",
550
+ );
551
+ setError(err);
552
+ throw err;
553
+ }
554
+
555
+ setTranslating(true);
556
+ setError(null);
557
+ try {
558
+ const res = await clientRef.current.translate({
559
+ segments: missing,
560
+ target_language: target,
561
+ source_language: source || undefined,
562
+ });
563
+ const translations = Array.isArray(res && res.translations) ? res.translations : [];
564
+ for (let m = 0; m < missingIndices.length; m++) {
565
+ // A short response must not shift results onto the wrong segment; fall
566
+ // back to the source text for anything the server did not answer.
567
+ const value =
568
+ typeof translations[m] === "string" && translations[m].length > 0
569
+ ? translations[m]
570
+ : missing[m];
571
+ out[missingIndices[m]] = value;
572
+ memoWrite(missing[m], source, target, value);
573
+ }
574
+ setTranslating(false);
575
+ return wasArray ? out : out[0];
576
+ } catch (err) {
577
+ const e = toTranslateError(err);
578
+ setError(e);
579
+ setTranslating(false);
580
+ throw e;
581
+ }
582
+ }, []);
583
+
584
+ return { translate, translating, error, language: locale, available: client !== null };
585
+ }
586
+
371
587
  /* ============================================================================
372
588
  * DEVICE — ctx.device (host-brokered device capabilities)
373
589
  *
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
@@ -909,6 +909,39 @@ export function useI18n(): {
909
909
  t(key: string, fallback?: string): string;
910
910
  };
911
911
 
912
+ /**
913
+ * sc-3783 — translate USER-GENERATED content (record text, file names, API
914
+ * payloads) into the app user's selected language.
915
+ *
916
+ * NOT for the app's own copy: author-written strings belong in the workspace
917
+ * dictionary and are resolved for free by `useI18n().t(key)`. Reach for this
918
+ * only when there is no key because there is no author.
919
+ *
920
+ * A string resolves to a string and an array resolves to an array (positionally
921
+ * aligned, sent as ONE request). `options.target` defaults to the app user's
922
+ * language. Text already in the target language, blank text, and text already
923
+ * translated this session cost nothing and never reach the network.
924
+ *
925
+ * `available` is false on a host that brokers no translation client (the Studio
926
+ * canvas preview); `translate` then rejects with code "UNSUPPORTED" rather than
927
+ * throwing at render, so the widget can show untranslated text.
928
+ */
929
+ export function useTranslate(): {
930
+ translate(input: string, options?: TranslateOptions): Promise<string>;
931
+ translate(input: string[], options?: TranslateOptions): Promise<string[]>;
932
+ translating: boolean;
933
+ error: TranslateError | null;
934
+ language: string;
935
+ available: boolean;
936
+ };
937
+
938
+ export interface TranslateOptions {
939
+ /** Target language code. Defaults to the app user's selected language. */
940
+ target?: string;
941
+ /** Source language code. Omit to let the provider auto-detect. */
942
+ source?: string;
943
+ }
944
+
912
945
  /**
913
946
  * The active end-user identity. `id` is null for anonymous visitors and on
914
947
  * the Studio canvas preview; every field is guaranteed present (the host
@@ -1079,6 +1112,29 @@ export class DirectoryError extends Error {
1079
1112
  );
1080
1113
  }
1081
1114
 
1115
+ /**
1116
+ * sc-3783 — error class thrown by `useTranslate().translate`. The `code` is a
1117
+ * stable categorisation widgets can branch on. TRANSLATION_QUOTA_EXCEEDED and
1118
+ * TRANSLATE_NOT_CONFIGURED will not clear on a retry — show the original text.
1119
+ */
1120
+ export class TranslateError extends Error {
1121
+ code:
1122
+ | "UNSUPPORTED"
1123
+ | "TRANSLATE_NOT_CONFIGURED"
1124
+ | "TRANSLATION_QUOTA_EXCEEDED"
1125
+ | "RATE_LIMITED"
1126
+ | "PAYLOAD_TOO_LARGE"
1127
+ | "VALIDATION"
1128
+ | "AUTH_REQUIRED"
1129
+ | "INTERNAL"
1130
+ | string;
1131
+ constructor(
1132
+ code: TranslateError["code"],
1133
+ message?: string,
1134
+ opts?: { cause?: unknown },
1135
+ );
1136
+ }
1137
+
1082
1138
  /**
1083
1139
  * sc-890 — error class thrown by `useSendNotification().send`. The `code` is a
1084
1140
  * stable categorisation widgets can branch on.
@@ -1432,6 +1488,22 @@ export function gradientAngleToVector(angle: number): {
1432
1488
  end: { x: number; y: number };
1433
1489
  };
1434
1490
 
1491
+ export interface ComponentGradient {
1492
+ from: string;
1493
+ to: string;
1494
+ angle: number;
1495
+ }
1496
+
1497
+ /**
1498
+ * Normalise a component `gradient` style value to `{ from, to, angle }`, or null
1499
+ * when it is unusable. Both stops are required; the angle wraps into 0-359.
1500
+ * Shared by the theme-token coercion and the widget render path, so the same
1501
+ * value cannot resolve two ways.
1502
+ */
1503
+ export function normaliseComponentGradient(
1504
+ raw: unknown,
1505
+ ): ComponentGradient | null;
1506
+
1435
1507
  // Linter
1436
1508
  export interface LintFinding {
1437
1509
  rule: string;
package/dist/index.js CHANGED
@@ -39,6 +39,8 @@ export {
39
39
  useTheme,
40
40
  useWidgetStyle,
41
41
  useI18n,
42
+ useTranslate,
43
+ TranslateError,
42
44
  useUser,
43
45
  useFill,
44
46
  useNavigation,
@@ -88,5 +90,6 @@ export {
88
90
  readableTextColor,
89
91
  deriveAccentTints,
90
92
  gradientAngleToVector,
93
+ normaliseComponentGradient,
91
94
  } from "./contract.js";
92
95
  export { normalizeLucideIconName } from "./lucideIconName.js";
@@ -39,6 +39,8 @@ export {
39
39
  useTheme,
40
40
  useWidgetStyle,
41
41
  useI18n,
42
+ useTranslate,
43
+ TranslateError,
42
44
  useUser,
43
45
  useFill,
44
46
  useNavigation,
@@ -86,5 +88,6 @@ export {
86
88
  readableTextColor,
87
89
  deriveAccentTints,
88
90
  gradientAngleToVector,
91
+ normaliseComponentGradient,
89
92
  } from "./contract.js";
90
93
  export { normalizeLucideIconName } from "./lucideIconName.js";
@@ -23,7 +23,7 @@
23
23
  // widget reads. The Mason build runner validates `set_theme` against the SAME
24
24
  // literal, so what a planner may emit and what a host applies cannot diverge.
25
25
 
26
- const { CONTRACT } = require("./contract.cjs");
26
+ const { CONTRACT, normaliseComponentGradient } = require("./contract.cjs");
27
27
 
28
28
  // 3-, 6- and 8-digit hex, matching what the build runner persists — the host
29
29
  // must never drop a colour the runner already accepted.
@@ -47,6 +47,11 @@ function coerceToken(def, value) {
47
47
  if (def.type === "shadow") {
48
48
  return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
49
49
  }
50
+ // sc-3727: the gradient value has its own normaliser on the contract, shared
51
+ // with the widget render path so both routes agree (CLAUDE.md §3).
52
+ if (def.type === "gradient") {
53
+ return normaliseComponentGradient(value) || undefined;
54
+ }
50
55
  return undefined;
51
56
  }
52
57
 
@@ -15,7 +15,7 @@
15
15
  // widget reads. The Mason build runner validates `set_theme` against the SAME
16
16
  // literal, so what a planner may emit and what a host applies cannot diverge.
17
17
 
18
- import { CONTRACT } from "./contract.js";
18
+ import { CONTRACT, normaliseComponentGradient } from "./contract.js";
19
19
 
20
20
  // 3-, 6- and 8-digit hex, matching what the build runner persists — the host
21
21
  // must never drop a colour the runner already accepted.
@@ -39,6 +39,11 @@ function coerceToken(def, value) {
39
39
  if (def.type === "shadow") {
40
40
  return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
41
41
  }
42
+ // sc-3727: the gradient value has its own normaliser on the contract, shared
43
+ // with the widget render path so both routes agree (CLAUDE.md §3).
44
+ if (def.type === "gradient") {
45
+ return normaliseComponentGradient(value) || undefined;
46
+ }
42
47
  return undefined;
43
48
  }
44
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.68.0",
3
+ "version": "0.70.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 src/__tests__/theme-depth-tokens.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__/hooks-translate.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"