@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 +38 -3
- package/dist/contract.cjs +96 -2
- package/dist/contract.js +96 -2
- package/dist/hooks.js +216 -0
- package/dist/host.d.ts +11 -1
- package/dist/index.d.ts +72 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/dist/theme-components.cjs +6 -1
- package/dist/theme-components.js +6 -1
- package/package.json +2 -2
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/dist/index.native.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,
|
|
@@ -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
|
|
package/dist/theme-components.js
CHANGED
|
@@ -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.
|
|
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"
|