@colixsystems/widget-sdk 0.69.0 → 0.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
30
30
  | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
31
31
  | **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). |
32
32
  | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
33
+ | **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
33
34
  | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
34
35
  | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
35
36
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
@@ -53,7 +54,39 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
53
54
 
54
55
  ## Status
55
56
 
56
- `v0.67.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
57
+ `v0.70.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
58
+
59
+ ### What's new in 0.70.0
60
+
61
+ **Translate content the user typed — `useTranslate()` (sc-3783).** The workspace dictionary only covers strings *you* authored; content living in the app's data — a record's description, a file name, a REST payload — has no translation key because nobody assigned it one, so an app user who picked English still read it in whatever language it was entered. `useTranslate()` closes that gap:
62
+
63
+ ```jsx
64
+ import { useState, useEffect } from "react";
65
+ import { Text, useTranslate } from "@colixsystems/widget-sdk";
66
+
67
+ const { translate, available, language } = useTranslate();
68
+ const [shown, setShown] = useState(rows.map((r) => r.notes));
69
+ useEffect(() => {
70
+ if (!available) return;
71
+ let live = true;
72
+ // ONE request for the whole batch. On failure keep the original text.
73
+ translate(rows.map((r) => r.notes))
74
+ .then((out) => { if (live) setShown(out); })
75
+ .catch(() => {});
76
+ return () => { live = false; };
77
+ // `language` is in the deps on purpose: the app user can switch language
78
+ // at any time, and this must re-translate when they do.
79
+ }, [rows, available, translate, language]);
80
+ ```
81
+
82
+ - **The target language is the app user's selected language** by default — that is the point of the hook, so a widget never has to know how the language was chosen. Pass `{ target }` only when the widget's purpose is translating into a language the user picks, and `{ source }` when you know the content's language (otherwise the provider auto-detects).
83
+ - **A string in, a string out; an array in, an array out** — positionally aligned, and an array is ONE request. Calling it per row is the mistake to avoid. Limits per call: 50 segments, 5 000 characters each, 20 000 total.
84
+ - **Repeat text is free.** Three caches sit behind it: the hook memoizes per session, the API keeps a short-lived in-process cache, and the platform keeps a durable per-workspace cache keyed by the content itself. Text already in the target language and blank text never reach the network at all.
85
+ - **It costs a metered budget, so use it deliberately.** Each workspace has a monthly translated-character cap (only cache misses count). Exhausting it rejects with `TranslateError` code `TRANSLATION_QUOTA_EXCEEDED`, which — unlike a rate limit — will not clear until the next period; cached translations keep working. `TRANSLATE_NOT_CONFIGURED` means the platform has no provider at all.
86
+ - **Never block a render on it.** Show the original text and swap in the translation when it resolves; always `catch` and fall back. `available` is `false` on a host that brokers no translation client (the Studio canvas preview), where `translate` rejects `UNSUPPORTED` instead of throwing at render — so hide any translate affordance when it is false.
87
+ - **Identical on both hosts.** The web Player and the exported Expo app inject the same new `@colixsystems/translation-client` into `ctx.i18n.translate`, so the hook behaves the same in the browser and on a device.
88
+
89
+ `CONTRACT.version` → `1.47.0`. Additive: one new hook + its error class, and one optional `ctx.i18n.translate` slice field. No existing export changed signature.
57
90
 
58
91
  ### What's new in 0.67.0
59
92
 
@@ -523,6 +556,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
523
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.
524
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).
525
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`.
526
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).
527
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.
528
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
@@ -247,6 +247,31 @@ const HOOKS = [
247
247
  requiredContextSlice: ["i18n.t", "i18n.locale"],
248
248
  scopes: null,
249
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
+ },
250
275
  {
251
276
  name: "useUser",
252
277
  signature: "useUser()",
@@ -1343,9 +1368,18 @@ const WIDGET_CONTEXT_SHAPE = {
1343
1368
  // ctx.datastore.records(table).permissions(record). See the `directory`
1344
1369
  // and `datastore` slices above.
1345
1370
  i18n: {
1346
- description: "{ t(key, fallback?), locale }.",
1371
+ // sc-3783 `translate` is the injected @colixsystems/translation-client
1372
+ // instance backing useTranslate(). OPTIONAL on the slice: a host that
1373
+ // brokers no translation client omits it and the hook reports
1374
+ // available:false (same convention as ctx.toast / ctx.device) rather than
1375
+ // throwing at render. Both the web Player and the Expo export inject it.
1376
+ description:
1377
+ "{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
1378
+ "@colixsystems/translation-client ({ translate(body), status() }) that backs " +
1379
+ "useTranslate() for user-generated content.",
1347
1380
  required: true,
1348
1381
  fields: { t: "function", locale: "string" },
1382
+ optionalFields: { translate: "object" },
1349
1383
  },
1350
1384
  logger: {
1351
1385
  description:
@@ -2181,7 +2215,7 @@ const CONTRACT = deepFreeze({
2181
2215
  // null, which is how one button stays flat while the rest are gradiented.
2182
2216
  // Additive: no export changed signature and a theme with no gradient token
2183
2217
  // renders exactly as before.
2184
- version: "1.46.0",
2218
+ version: "1.47.0",
2185
2219
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2186
2220
  hooks: HOOKS,
2187
2221
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -247,6 +247,31 @@ const HOOKS = [
247
247
  requiredContextSlice: ["i18n.t", "i18n.locale"],
248
248
  scopes: null,
249
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
+ },
250
275
  {
251
276
  name: "useUser",
252
277
  signature: "useUser()",
@@ -1343,9 +1368,18 @@ const WIDGET_CONTEXT_SHAPE = {
1343
1368
  // ctx.datastore.records(table).permissions(record). See the `directory`
1344
1369
  // and `datastore` slices above.
1345
1370
  i18n: {
1346
- description: "{ t(key, fallback?), locale }.",
1371
+ // sc-3783 `translate` is the injected @colixsystems/translation-client
1372
+ // instance backing useTranslate(). OPTIONAL on the slice: a host that
1373
+ // brokers no translation client omits it and the hook reports
1374
+ // available:false (same convention as ctx.toast / ctx.device) rather than
1375
+ // throwing at render. Both the web Player and the Expo export inject it.
1376
+ description:
1377
+ "{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
1378
+ "@colixsystems/translation-client ({ translate(body), status() }) that backs " +
1379
+ "useTranslate() for user-generated content.",
1347
1380
  required: true,
1348
1381
  fields: { t: "function", locale: "string" },
1382
+ optionalFields: { translate: "object" },
1349
1383
  },
1350
1384
  logger: {
1351
1385
  description:
@@ -2181,7 +2215,7 @@ const CONTRACT = deepFreeze({
2181
2215
  // null, which is how one button stays flat while the rest are gradiented.
2182
2216
  // Additive: no export changed signature and a theme with no gradient token
2183
2217
  // renders exactly as before.
2184
- version: "1.46.0",
2218
+ version: "1.47.0",
2185
2219
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2186
2220
  hooks: HOOKS,
2187
2221
  primitives: PRIMITIVES,
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.69.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"