@colixsystems/widget-sdk 0.69.0 → 0.71.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,51 @@ 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.71.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.71.0
60
+
61
+ **A theme can set leading, tracking and case for text app-wide (sc-3857).** The `text` scope of `themeConfig.components` gains three tokens beside `color` and `fontSize`:
62
+
63
+ - **`lineHeight`** — leading as a MULTIPLE of the font size (0.8–3), so one app-wide value stays right at every size. Tight leading (1.0–1.15) is what makes a 32px+ headline read as a headline rather than as oversized body text.
64
+ - **`letterSpacing`** — tracking in pixels (−2 to 20). Wide positive tracking is what makes a small uppercase kicker read as a kicker.
65
+ - **`textTransform`** — `none` | `uppercase` | `capitalize`, published as `CONTRACT.themeComponentTextTransforms`.
66
+
67
+ Two new token value types back them: **`decimal`** (a clamped, 2-decimal number — `size` rounds, so it cannot carry a 1.05 multiplier) and **`textTransform`**. Each token binds to the identically named per-instance style field the target widgets read, so the author rule is unchanged: read `props.style` / `useWidgetStyle()`, and a per-instance value still wins over the app-wide token.
68
+
69
+ `CONTRACT.version` → `1.48.0`. Additive: no existing export changed signature, and a theme with none of the new tokens renders exactly as before.
70
+
71
+ ### What's new in 0.70.0
72
+
73
+ **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:
74
+
75
+ ```jsx
76
+ import { useState, useEffect } from "react";
77
+ import { Text, useTranslate } from "@colixsystems/widget-sdk";
78
+
79
+ const { translate, available, language } = useTranslate();
80
+ const [shown, setShown] = useState(rows.map((r) => r.notes));
81
+ useEffect(() => {
82
+ if (!available) return;
83
+ let live = true;
84
+ // ONE request for the whole batch. On failure keep the original text.
85
+ translate(rows.map((r) => r.notes))
86
+ .then((out) => { if (live) setShown(out); })
87
+ .catch(() => {});
88
+ return () => { live = false; };
89
+ // `language` is in the deps on purpose: the app user can switch language
90
+ // at any time, and this must re-translate when they do.
91
+ }, [rows, available, translate, language]);
92
+ ```
93
+
94
+ - **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).
95
+ - **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.
96
+ - **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.
97
+ - **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.
98
+ - **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.
99
+ - **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.
100
+
101
+ `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
102
 
58
103
  ### What's new in 0.67.0
59
104
 
@@ -523,6 +568,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
523
568
  - **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.
524
569
  - **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).
525
570
  - **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
571
+ - **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`.
526
572
  - **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).
527
573
  - **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.
528
574
  - **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,14 @@ 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-3857 — the `textTransform` token's closed enum, matching the per-instance
111
+ // Text/Label/Data Value style field and React Native's own vocabulary.
112
+ const THEME_COMPONENT_TEXT_TRANSFORMS = Object.freeze([
113
+ "none",
114
+ "uppercase",
115
+ "capitalize",
116
+ ]);
117
+
110
118
  // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
111
119
  // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
112
120
  // variant (a component fill projects through `<Gradient>`, which is linear-only
@@ -129,6 +137,16 @@ const CARD_SURFACE_FIELDS = Object.freeze({
129
137
  gradient: "cardGradient",
130
138
  });
131
139
 
140
+ // The text field names every text-bearing built-in reads
141
+ // (frontend/src/components/widgets/_shared/textStyle.js TYPOGRAPHY_STYLE_FIELDS).
142
+ const TEXT_TYPOGRAPHY_FIELDS = Object.freeze({
143
+ color: "color",
144
+ fontSize: "fontSize",
145
+ lineHeight: "lineHeight",
146
+ letterSpacing: "letterSpacing",
147
+ textTransform: "textTransform",
148
+ });
149
+
132
150
  // A form widget's submit button — the `button` scope reaches it through the
133
151
  // form's own submit* fields, so "make the buttons coral" does not skip forms.
134
152
  const FORM_SUBMIT_FIELDS = Object.freeze({
@@ -190,14 +208,16 @@ const THEME_COMPONENTS = Object.freeze({
190
208
  tokens: Object.freeze({
191
209
  color: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
192
210
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.md" }),
211
+ // sc-3857 — leading as a MULTIPLE of the font size, so one app-wide value
212
+ // is right at every size; `size` cannot carry it because it rounds.
213
+ lineHeight: Object.freeze({ type: "decimal", min: 0.8, max: 3, step: 0.05 }),
214
+ letterSpacing: Object.freeze({ type: "decimal", min: -2, max: 20, step: 0.1 }),
215
+ textTransform: Object.freeze({ type: "textTransform" }),
193
216
  }),
194
217
  targets: Object.freeze({
195
- "appstudio.text": Object.freeze({ color: "color", fontSize: "fontSize" }),
196
- "appstudio.label": Object.freeze({ color: "color", fontSize: "fontSize" }),
197
- "appstudio.data-value": Object.freeze({
198
- color: "color",
199
- fontSize: "fontSize",
200
- }),
218
+ "appstudio.text": TEXT_TYPOGRAPHY_FIELDS,
219
+ "appstudio.label": TEXT_TYPOGRAPHY_FIELDS,
220
+ "appstudio.data-value": TEXT_TYPOGRAPHY_FIELDS,
201
221
  }),
202
222
  }),
203
223
  });
@@ -247,6 +267,31 @@ const HOOKS = [
247
267
  requiredContextSlice: ["i18n.t", "i18n.locale"],
248
268
  scopes: null,
249
269
  },
270
+ {
271
+ name: "useTranslate",
272
+ signature: "useTranslate()",
273
+ description:
274
+ "sc-3783 — translate USER-GENERATED content (a record's text, a file name, an API payload) into the app user's selected language. " +
275
+ "NOT for the app's own copy: author-written strings belong in the workspace dictionary and are resolved for free by useI18n().t(key); " +
276
+ "reach for translate() only when there is no key because there is no author. Returns { translate, translating, error, language, available }. " +
277
+ "translate(input, options?) takes a string (resolves to a string) or an array of strings (resolves to an array, positionally aligned) and " +
278
+ "batches an array into ONE request. options.target defaults to the app user's language; options.source is optional (the provider auto-detects). " +
279
+ "Text already in the target language, blank text, and text already translated this session cost nothing and never reach the network. " +
280
+ "Rejects with a TranslateError whose .code is one of UNSUPPORTED | TRANSLATE_NOT_CONFIGURED | TRANSLATION_QUOTA_EXCEEDED | RATE_LIMITED | " +
281
+ "PAYLOAD_TOO_LARGE | VALIDATION | AUTH_REQUIRED | INTERNAL. Limits per call: 50 segments, 5 000 chars each, 20 000 chars total. " +
282
+ "`available` is false on a host that brokers no translation client; translate() then rejects UNSUPPORTED instead of throwing at render. " +
283
+ "Identical on web (Player) and the Expo export — both hosts inject the same @colixsystems/translation-client.",
284
+ returnShape: {
285
+ translate:
286
+ "(input: string | string[], options?: { target?: string, source?: string }) => Promise<string | string[]> // rejects with TranslateError",
287
+ translating: "boolean",
288
+ error: "TranslateError | null",
289
+ language: "string // the app user's selected language (the default target)",
290
+ available: "boolean",
291
+ },
292
+ requiredContextSlice: ["i18n.locale"],
293
+ scopes: null,
294
+ },
250
295
  {
251
296
  name: "useUser",
252
297
  signature: "useUser()",
@@ -1343,9 +1388,18 @@ const WIDGET_CONTEXT_SHAPE = {
1343
1388
  // ctx.datastore.records(table).permissions(record). See the `directory`
1344
1389
  // and `datastore` slices above.
1345
1390
  i18n: {
1346
- description: "{ t(key, fallback?), locale }.",
1391
+ // sc-3783 `translate` is the injected @colixsystems/translation-client
1392
+ // instance backing useTranslate(). OPTIONAL on the slice: a host that
1393
+ // brokers no translation client omits it and the hook reports
1394
+ // available:false (same convention as ctx.toast / ctx.device) rather than
1395
+ // throwing at render. Both the web Player and the Expo export inject it.
1396
+ description:
1397
+ "{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
1398
+ "@colixsystems/translation-client ({ translate(body), status() }) that backs " +
1399
+ "useTranslate() for user-generated content.",
1347
1400
  required: true,
1348
1401
  fields: { t: "function", locale: "string" },
1402
+ optionalFields: { translate: "object" },
1349
1403
  },
1350
1404
  logger: {
1351
1405
  description:
@@ -2181,7 +2235,17 @@ const CONTRACT = deepFreeze({
2181
2235
  // null, which is how one button stays flat while the rest are gradiented.
2182
2236
  // Additive: no export changed signature and a theme with no gradient token
2183
2237
  // renders exactly as before.
2184
- version: "1.46.0",
2238
+ // 1.48.0: additive (sc-3857) — leading, tracking and case on the `text` scope.
2239
+ // `lineHeight` (a MULTIPLE of the font size), `letterSpacing` (pixels) and
2240
+ // `textTransform` join the existing `color` / `fontSize` tokens and bind to
2241
+ // the identically named per-instance style fields Text, Label and Data Value
2242
+ // now read, so a theme can set app-wide typography and an author still
2243
+ // overrides it per instance. Two new token value types back them: `decimal`
2244
+ // (a clamped 2-decimal number — `size` rounds, so it cannot carry a 1.05
2245
+ // multiplier) and `textTransform`, whose closed enum is published as
2246
+ // `themeComponentTextTransforms`. Additive: no export changed signature and
2247
+ // a theme with none of the new tokens renders exactly as before.
2248
+ version: "1.48.0",
2185
2249
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2186
2250
  hooks: HOOKS,
2187
2251
  primitives: PRIMITIVES,
@@ -2194,6 +2258,7 @@ const CONTRACT = deepFreeze({
2194
2258
  themeTokens: DEFAULT_THEME_TOKENS,
2195
2259
  themeComponents: THEME_COMPONENTS,
2196
2260
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2261
+ themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
2197
2262
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
2198
2263
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2199
2264
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
package/dist/contract.js CHANGED
@@ -107,6 +107,14 @@ 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-3857 — the `textTransform` token's closed enum, matching the per-instance
111
+ // Text/Label/Data Value style field and React Native's own vocabulary.
112
+ const THEME_COMPONENT_TEXT_TRANSFORMS = Object.freeze([
113
+ "none",
114
+ "uppercase",
115
+ "capitalize",
116
+ ]);
117
+
110
118
  // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
111
119
  // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
112
120
  // variant (a component fill projects through `<Gradient>`, which is linear-only
@@ -129,6 +137,16 @@ const CARD_SURFACE_FIELDS = Object.freeze({
129
137
  gradient: "cardGradient",
130
138
  });
131
139
 
140
+ // The text field names every text-bearing built-in reads
141
+ // (frontend/src/components/widgets/_shared/textStyle.js TYPOGRAPHY_STYLE_FIELDS).
142
+ const TEXT_TYPOGRAPHY_FIELDS = Object.freeze({
143
+ color: "color",
144
+ fontSize: "fontSize",
145
+ lineHeight: "lineHeight",
146
+ letterSpacing: "letterSpacing",
147
+ textTransform: "textTransform",
148
+ });
149
+
132
150
  // A form widget's submit button — the `button` scope reaches it through the
133
151
  // form's own submit* fields, so "make the buttons coral" does not skip forms.
134
152
  const FORM_SUBMIT_FIELDS = Object.freeze({
@@ -190,14 +208,16 @@ const THEME_COMPONENTS = Object.freeze({
190
208
  tokens: Object.freeze({
191
209
  color: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
192
210
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.md" }),
211
+ // sc-3857 — leading as a MULTIPLE of the font size, so one app-wide value
212
+ // is right at every size; `size` cannot carry it because it rounds.
213
+ lineHeight: Object.freeze({ type: "decimal", min: 0.8, max: 3, step: 0.05 }),
214
+ letterSpacing: Object.freeze({ type: "decimal", min: -2, max: 20, step: 0.1 }),
215
+ textTransform: Object.freeze({ type: "textTransform" }),
193
216
  }),
194
217
  targets: Object.freeze({
195
- "appstudio.text": Object.freeze({ color: "color", fontSize: "fontSize" }),
196
- "appstudio.label": Object.freeze({ color: "color", fontSize: "fontSize" }),
197
- "appstudio.data-value": Object.freeze({
198
- color: "color",
199
- fontSize: "fontSize",
200
- }),
218
+ "appstudio.text": TEXT_TYPOGRAPHY_FIELDS,
219
+ "appstudio.label": TEXT_TYPOGRAPHY_FIELDS,
220
+ "appstudio.data-value": TEXT_TYPOGRAPHY_FIELDS,
201
221
  }),
202
222
  }),
203
223
  });
@@ -247,6 +267,31 @@ const HOOKS = [
247
267
  requiredContextSlice: ["i18n.t", "i18n.locale"],
248
268
  scopes: null,
249
269
  },
270
+ {
271
+ name: "useTranslate",
272
+ signature: "useTranslate()",
273
+ description:
274
+ "sc-3783 — translate USER-GENERATED content (a record's text, a file name, an API payload) into the app user's selected language. " +
275
+ "NOT for the app's own copy: author-written strings belong in the workspace dictionary and are resolved for free by useI18n().t(key); " +
276
+ "reach for translate() only when there is no key because there is no author. Returns { translate, translating, error, language, available }. " +
277
+ "translate(input, options?) takes a string (resolves to a string) or an array of strings (resolves to an array, positionally aligned) and " +
278
+ "batches an array into ONE request. options.target defaults to the app user's language; options.source is optional (the provider auto-detects). " +
279
+ "Text already in the target language, blank text, and text already translated this session cost nothing and never reach the network. " +
280
+ "Rejects with a TranslateError whose .code is one of UNSUPPORTED | TRANSLATE_NOT_CONFIGURED | TRANSLATION_QUOTA_EXCEEDED | RATE_LIMITED | " +
281
+ "PAYLOAD_TOO_LARGE | VALIDATION | AUTH_REQUIRED | INTERNAL. Limits per call: 50 segments, 5 000 chars each, 20 000 chars total. " +
282
+ "`available` is false on a host that brokers no translation client; translate() then rejects UNSUPPORTED instead of throwing at render. " +
283
+ "Identical on web (Player) and the Expo export — both hosts inject the same @colixsystems/translation-client.",
284
+ returnShape: {
285
+ translate:
286
+ "(input: string | string[], options?: { target?: string, source?: string }) => Promise<string | string[]> // rejects with TranslateError",
287
+ translating: "boolean",
288
+ error: "TranslateError | null",
289
+ language: "string // the app user's selected language (the default target)",
290
+ available: "boolean",
291
+ },
292
+ requiredContextSlice: ["i18n.locale"],
293
+ scopes: null,
294
+ },
250
295
  {
251
296
  name: "useUser",
252
297
  signature: "useUser()",
@@ -1343,9 +1388,18 @@ const WIDGET_CONTEXT_SHAPE = {
1343
1388
  // ctx.datastore.records(table).permissions(record). See the `directory`
1344
1389
  // and `datastore` slices above.
1345
1390
  i18n: {
1346
- description: "{ t(key, fallback?), locale }.",
1391
+ // sc-3783 `translate` is the injected @colixsystems/translation-client
1392
+ // instance backing useTranslate(). OPTIONAL on the slice: a host that
1393
+ // brokers no translation client omits it and the hook reports
1394
+ // available:false (same convention as ctx.toast / ctx.device) rather than
1395
+ // throwing at render. Both the web Player and the Expo export inject it.
1396
+ description:
1397
+ "{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
1398
+ "@colixsystems/translation-client ({ translate(body), status() }) that backs " +
1399
+ "useTranslate() for user-generated content.",
1347
1400
  required: true,
1348
1401
  fields: { t: "function", locale: "string" },
1402
+ optionalFields: { translate: "object" },
1349
1403
  },
1350
1404
  logger: {
1351
1405
  description:
@@ -2181,7 +2235,17 @@ const CONTRACT = deepFreeze({
2181
2235
  // null, which is how one button stays flat while the rest are gradiented.
2182
2236
  // Additive: no export changed signature and a theme with no gradient token
2183
2237
  // renders exactly as before.
2184
- version: "1.46.0",
2238
+ // 1.48.0: additive (sc-3857) — leading, tracking and case on the `text` scope.
2239
+ // `lineHeight` (a MULTIPLE of the font size), `letterSpacing` (pixels) and
2240
+ // `textTransform` join the existing `color` / `fontSize` tokens and bind to
2241
+ // the identically named per-instance style fields Text, Label and Data Value
2242
+ // now read, so a theme can set app-wide typography and an author still
2243
+ // overrides it per instance. Two new token value types back them: `decimal`
2244
+ // (a clamped 2-decimal number — `size` rounds, so it cannot carry a 1.05
2245
+ // multiplier) and `textTransform`, whose closed enum is published as
2246
+ // `themeComponentTextTransforms`. Additive: no export changed signature and
2247
+ // a theme with none of the new tokens renders exactly as before.
2248
+ version: "1.48.0",
2185
2249
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2186
2250
  hooks: HOOKS,
2187
2251
  primitives: PRIMITIVES,
@@ -2194,6 +2258,7 @@ const CONTRACT = deepFreeze({
2194
2258
  themeTokens: DEFAULT_THEME_TOKENS,
2195
2259
  themeComponents: THEME_COMPONENTS,
2196
2260
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2261
+ themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
2197
2262
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
2198
2263
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2199
2264
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
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/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.
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,
@@ -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,
@@ -44,9 +44,22 @@ function coerceToken(def, value) {
44
44
  if (!Number.isFinite(n)) return undefined;
45
45
  return Math.min(def.max, Math.max(def.min, Math.round(n)));
46
46
  }
47
+ // sc-3857: a leading multiplier / tracking value must survive the decimals
48
+ // `size` rounds away — 1.05 leading is the whole point of the token.
49
+ if (def.type === "decimal") {
50
+ const n = typeof value === "number" ? value : Number(value);
51
+ if (!Number.isFinite(n)) return undefined;
52
+ const clamped = Math.min(def.max, Math.max(def.min, n));
53
+ return Math.round(clamped * 100) / 100;
54
+ }
47
55
  if (def.type === "shadow") {
48
56
  return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
49
57
  }
58
+ if (def.type === "textTransform") {
59
+ return CONTRACT.themeComponentTextTransforms.includes(value)
60
+ ? value
61
+ : undefined;
62
+ }
50
63
  // sc-3727: the gradient value has its own normaliser on the contract, shared
51
64
  // with the widget render path so both routes agree (CLAUDE.md §3).
52
65
  if (def.type === "gradient") {
@@ -36,9 +36,22 @@ function coerceToken(def, value) {
36
36
  if (!Number.isFinite(n)) return undefined;
37
37
  return Math.min(def.max, Math.max(def.min, Math.round(n)));
38
38
  }
39
+ // sc-3857: a leading multiplier / tracking value must survive the decimals
40
+ // `size` rounds away — 1.05 leading is the whole point of the token.
41
+ if (def.type === "decimal") {
42
+ const n = typeof value === "number" ? value : Number(value);
43
+ if (!Number.isFinite(n)) return undefined;
44
+ const clamped = Math.min(def.max, Math.max(def.min, n));
45
+ return Math.round(clamped * 100) / 100;
46
+ }
39
47
  if (def.type === "shadow") {
40
48
  return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
41
49
  }
50
+ if (def.type === "textTransform") {
51
+ return CONTRACT.themeComponentTextTransforms.includes(value)
52
+ ? value
53
+ : undefined;
54
+ }
42
55
  // sc-3727: the gradient value has its own normaliser on the contract, shared
43
56
  // with the widget render path so both routes agree (CLAUDE.md §3).
44
57
  if (def.type === "gradient") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.69.0",
3
+ "version": "0.71.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"