@colixsystems/widget-sdk 0.19.0 → 0.21.1

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
@@ -22,9 +22,10 @@ The data layer lives in **four separate domain-client packages**, each instantia
22
22
  | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
23
23
  | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
24
24
  | **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
25
+ | **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
25
26
  | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
26
27
  | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
27
- | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope |
28
+ | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) then falls back to the raw key. |
28
29
  | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
29
30
  | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
30
31
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
@@ -44,9 +45,34 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
44
45
 
45
46
  ## Status
46
47
 
47
- `v0.19.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**.
48
+ `v0.21.1` — 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**.
48
49
 
49
- ### What's new in 0.19.0
50
+ ### What's new in 0.21.1
51
+
52
+ **Default theme tokens corrected to the product's advertised brand (fix).**
53
+
54
+ - **`themeTokens.colors.primary` → `#3b82f6` (blue), `colors.secondary` → `#10b981` (green).** Previously these defaulted to a stale coral/slate that no other surface used — the Theme Settings tab and the Player chrome already advertised the blue/green default. A tenant that never customised its theme therefore rendered widgets (`useTheme().colors.primary`) in coral until a first save persisted the blue, a visible divergence between the unsaved and saved-defaults render. No export, signature, type, or token shape changed — default values only.
55
+ - **`CONTRACT.version` → `1.11.1`.** Patch: a default-value fix; the documented contract (token names + shape) is unchanged.
56
+
57
+ ### What's new in 0.21.0
58
+
59
+ **Widgets can fill their page-grid tile's height (REQ-LAY-08).**
60
+
61
+ - **New `useFill()` hook.** Returns a `boolean` — `true` when the host has sized this widget to fill the available height of its layout slot (a page-grid tile whose author chose "Fill tile height", or a widget type that fills by default: the layout containers + the media widgets Image / Chart / Map / Video). A widget that has a meaningful filled form switches to a stretch layout (`flex: 1` / `height: "100%"`) when it reads `true`; others ignore it. Defaults to `false`, so calling it is always safe.
62
+ - **New optional `WidgetContext.fill` slice** backs the hook. It is optional (defaults `false`), so existing hosts and widgets are unaffected. The web Player host and the native export host inject the SAME value, so a widget's fill behaviour is identical on both platforms.
63
+ - **`CONTRACT.version` → `1.11.0`** (additive: one new hook + one new optional context slice). No existing export changed signature.
64
+
65
+ ### What's new in 0.20.0
66
+
67
+ **Widgets can ship their own translations (REQ-L10N-WIDGET).**
68
+
69
+ - **New optional `manifest.translations` field.** Shape `{ <key>: { en: string, <locale>?: string } }` — `en` is required per key; additional locales are optional. `validateManifest` structurally validates it (≤100 keys, key matches `/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`, value ≤1 KB, namespaced key ≤128 chars); the marketplace analyzer enforces the same caps.
70
+ - **`useI18n().t(key)` now auto-namespaces.** The host derives a per-widget prefix from the widget id and resolves `widget.<id>.<key>` first, then falls back to the raw key (so shared app keys and pre-1.10 widgets are unaffected). Authors call `t("greeting")` and never type the prefix — the same behaviour on web and in the exported native app (both hosts inject `ctx.widget.id`).
71
+ - **Install-time seeding.** When a widget is installed, the host merges its `translations` into the tenant's localization dictionary under that namespace — non-destructively (it never overwrites an admin's edit, never creates a language the tenant didn't add, and seeds the tenant's base language from the widget's `en` so every key renders). Keys persist across uninstalls; admins prune them with the Translations screen's bulk delete (by id or by `widget.<id>.` prefix).
72
+ - **New exported helpers** `widgetTranslationPrefix(id)` / `widgetTranslationKey(id, key)` (from `@colixsystems/widget-sdk/contract`) are the single source of the key format, shared by `useI18n` and the host seeder.
73
+ - **`CONTRACT.version` → `1.10.0`** (additive: one new optional manifest field + the `useI18n` namespacing behaviour). No existing export changed signature.
74
+
75
+ ### What was in 0.19.0
50
76
 
51
77
  **The data layer splits into four injected domain clients; the SDK becomes core-only.**
52
78
 
@@ -201,6 +227,22 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
201
227
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`, `DateTimePicker` wraps `@react-native-community/datetimepicker`). The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
202
228
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
203
229
 
230
+ ## Design & visual polish
231
+
232
+ A widget that works but looks unfinished is only half done. `useTheme()` is the styling contract — compose **with** it rather than just reading from it. This is the same guidance the AI Widget Builder follows when it generates a widget.
233
+
234
+ **Decide before you build:** the widget's shape (card, list, form, control), its hierarchy (the one thing the eye lands on first), its single accent moment, and its empty/loading/error look — then compose. Specific decisions make a distinctive widget; leaving them to default makes a generic one.
235
+
236
+ - **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners. Don't hardcode raw pixel values.
237
+ - **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
238
+ - **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.md` + a `colors.border` hairline or a subtle shadow. Use the status roles (`danger / success / warning / info`) for state.
239
+ - **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
240
+ - **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme.
241
+ - **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useFile()`. 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.
242
+ - **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.
243
+
244
+ **Honest ceilings:** the styling surface is React Native style objects, not full CSS. There are no per-widget gradients, no custom CSS keyframe animations or `transition` strings, and shadows are limited to the five elevation presets (`none / sm / md / lg / xl`). Aim for clean, confident, professional polish within those bounds.
245
+
204
246
  ## Managing app users from a widget
205
247
 
206
248
  `useUsers()` and `useGroups()` let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. Two gates apply: the manifest must declare the scope (the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller lacks the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that as a "you do not have permission" message.
package/dist/contract.cjs CHANGED
@@ -10,9 +10,9 @@
10
10
 
11
11
  const DEFAULT_THEME_TOKENS = Object.freeze({
12
12
  colors: Object.freeze({
13
- primary: "#ff6b5b",
13
+ primary: "#3b82f6",
14
14
  onPrimary: "#ffffff",
15
- secondary: "#475569",
15
+ secondary: "#10b981",
16
16
  onSecondary: "#ffffff",
17
17
  surface: "#ffffff",
18
18
  onSurface: "#111827",
@@ -70,6 +70,26 @@ const HOOKS = [
70
70
  requiredContextSlice: ["user"],
71
71
  scopes: null,
72
72
  },
73
+ {
74
+ name: "useFill",
75
+ signature: "useFill()",
76
+ description:
77
+ "Returns true when the host has sized this widget to fill its layout " +
78
+ 'slot\'s available height — a page-grid tile whose author chose "Fill ' +
79
+ 'tile height", or a widget type that fills by default (containers and ' +
80
+ "media). When true, a widget that has a meaningful filled form (Image, " +
81
+ "Chart, Map, Video) should switch from its intrinsic height to a stretch " +
82
+ 'style (flex: 1 / height: "100%"); widgets with no useful filled form ' +
83
+ "may ignore it. Defaults to false wherever the host has not opted the " +
84
+ "widget into filling, so calling it is always safe. The SAME value is " +
85
+ "injected by the web Player and the native export (CLAUDE.md §3), so a " +
86
+ "widget's fill behaviour is identical on both platforms.",
87
+ returnShape: {
88
+ "(returns)": "boolean",
89
+ },
90
+ requiredContextSlice: ["fill"],
91
+ scopes: null,
92
+ },
73
93
  {
74
94
  name: "useChildRenderer",
75
95
  signature: "useChildRenderer()",
@@ -579,6 +599,13 @@ const MANIFEST_SCHEMA = {
579
599
  " — NOT the React surface, so SDK imports/hooks are unavailable) }. Do NOT include triggerTableId or apiKeyId — those are tenant-local and bound after install.",
580
600
  default: [],
581
601
  },
602
+ translations: {
603
+ type: "object",
604
+ required: false,
605
+ description:
606
+ "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤100 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
607
+ default: {},
608
+ },
582
609
  };
583
610
 
584
611
  const WIDGET_CONTEXT_SHAPE = {
@@ -594,6 +621,19 @@ const WIDGET_CONTEXT_SHAPE = {
594
621
  required: true,
595
622
  fields: { id: "manifest.id", version: "manifest.version" },
596
623
  },
624
+ // REQ-LAY-08 — optional host layout hint backing useFill(). True when the
625
+ // host sized this widget to fill its layout slot's available height (a
626
+ // page-grid tile set to "Fill tile height", or a default-fill widget type).
627
+ // Optional: a host that never fills a widget simply omits it and useFill()
628
+ // returns false, so existing hosts need no change.
629
+ fill: {
630
+ description:
631
+ "Optional host layout hint (boolean). True when the host sized this " +
632
+ "widget to fill its layout slot's available height (page-grid tile " +
633
+ "fill). Backs useFill(); defaults to false when absent.",
634
+ required: false,
635
+ fields: {},
636
+ },
597
637
  user: {
598
638
  description:
599
639
  "Signed-in user, host-provided VERBATIM (snake_case: { id, email, display_name, roles, group_ids }). Not a data-client.",
@@ -901,6 +941,20 @@ function deepFreeze(value) {
901
941
  return Object.freeze(value);
902
942
  }
903
943
 
944
+ // REQ-L10N-WIDGET — a widget's manifest `translations` are merged into the
945
+ // tenant dictionary under a per-widget namespace so they never collide with
946
+ // app keys or another widget's keys. The prefix is DERIVED from the widget id
947
+ // (it "follows the widget"), so authors call `t("greeting")` and the host
948
+ // resolves `widget.<id>.greeting`. This is the ONE definition of the key
949
+ // format: the SDK `useI18n` hook (lookup) and the backend seeder (write) both
950
+ // call it, so the wire key and the lookup key can never drift.
951
+ function widgetTranslationPrefix(id) {
952
+ return `widget.${id}.`;
953
+ }
954
+ function widgetTranslationKey(id, key) {
955
+ return `widget.${id}.${key}`;
956
+ }
957
+
904
958
  const CONTRACT = deepFreeze({
905
959
  // REQ-WSDK-PLATFORM bump:
906
960
  // - `vettedImports` is a new field (rich allowlist with platforms +
@@ -952,7 +1006,35 @@ const CONTRACT = deepFreeze({
952
1006
  // snake_case. Hooks now unwrap the list envelope (res.data ?? []). The
953
1007
  // SDK stays duck-typed — it imports none of the four data SDKs. Minor
954
1008
  // bump on the contract's pre-1.0 versioning (the breaking channel).
955
- version: "1.9.0",
1009
+ //
1010
+ // 1.10.0: additive (REQ-L10N-WIDGET) — manifests may declare an optional
1011
+ // `translations` map ({ <key>: { en, <locale>? } }, en required). The
1012
+ // host seeds them into the tenant l10n dictionary under a per-widget
1013
+ // namespace at install; `useI18n().t(key)` now auto-prefixes the lookup
1014
+ // with `widget.<ctx.widget.id>.` (then falls back to the raw key, so
1015
+ // existing widgets and shared app keys keep resolving). New exported
1016
+ // helpers widgetTranslationPrefix / widgetTranslationKey are the single
1017
+ // source of the key format, shared by the hook and the backend seeder.
1018
+ //
1019
+ // 1.11.0: additive (REQ-LAY-08) — new useFill() hook + the optional `fill`
1020
+ // host-context slice it reads. The host sets ctx.fill=true when it has
1021
+ // sized a widget to fill its layout slot's available height (a page-grid
1022
+ // tile whose author chose "Fill tile height", or a default-fill widget
1023
+ // type — containers + media). Widgets that can stretch (Image, Chart,
1024
+ // Map, Video) switch to flex:1/height:"100%" when useFill() is true; all
1025
+ // others ignore it. The slice is OPTIONAL (defaults false) so existing
1026
+ // hosts need no change, and the same value is injected on web and native
1027
+ // so fill behaviour cannot diverge between the Player and the export.
1028
+ //
1029
+ // 1.11.1: fix — the canonical default `themeTokens.colors` now match the
1030
+ // product's advertised default brand (primary #3b82f6 blue, secondary
1031
+ // #10b981 green) instead of the stale coral/slate that no other surface
1032
+ // used. The Theme Settings tab and Player chrome already defaulted to
1033
+ // these values, so a tenant that never customised its theme rendered
1034
+ // widgets in coral until a first save persisted the blue — a divergence
1035
+ // between the unsaved and saved-defaults render. Aligning the canonical
1036
+ // tokens removes it (no shape/signature change — default-value fix only).
1037
+ version: "1.11.1",
956
1038
  hooks: HOOKS,
957
1039
  primitives: PRIMITIVES,
958
1040
  manifestSchema: MANIFEST_SCHEMA,
@@ -984,4 +1066,10 @@ function requiredContextKeys() {
984
1066
  return [...keys];
985
1067
  }
986
1068
 
987
- module.exports = { CONTRACT, isHookAllowed, requiredContextKeys };
1069
+ module.exports = {
1070
+ CONTRACT,
1071
+ isHookAllowed,
1072
+ requiredContextKeys,
1073
+ widgetTranslationPrefix,
1074
+ widgetTranslationKey,
1075
+ };
package/dist/contract.js CHANGED
@@ -10,9 +10,9 @@
10
10
 
11
11
  const DEFAULT_THEME_TOKENS = Object.freeze({
12
12
  colors: Object.freeze({
13
- primary: "#ff6b5b",
13
+ primary: "#3b82f6",
14
14
  onPrimary: "#ffffff",
15
- secondary: "#475569",
15
+ secondary: "#10b981",
16
16
  onSecondary: "#ffffff",
17
17
  surface: "#ffffff",
18
18
  onSurface: "#111827",
@@ -70,6 +70,26 @@ const HOOKS = [
70
70
  requiredContextSlice: ["user"],
71
71
  scopes: null,
72
72
  },
73
+ {
74
+ name: "useFill",
75
+ signature: "useFill()",
76
+ description:
77
+ "Returns true when the host has sized this widget to fill its layout " +
78
+ 'slot\'s available height — a page-grid tile whose author chose "Fill ' +
79
+ 'tile height", or a widget type that fills by default (containers and ' +
80
+ "media). When true, a widget that has a meaningful filled form (Image, " +
81
+ "Chart, Map, Video) should switch from its intrinsic height to a stretch " +
82
+ 'style (flex: 1 / height: "100%"); widgets with no useful filled form ' +
83
+ "may ignore it. Defaults to false wherever the host has not opted the " +
84
+ "widget into filling, so calling it is always safe. The SAME value is " +
85
+ "injected by the web Player and the native export (CLAUDE.md §3), so a " +
86
+ "widget's fill behaviour is identical on both platforms.",
87
+ returnShape: {
88
+ "(returns)": "boolean",
89
+ },
90
+ requiredContextSlice: ["fill"],
91
+ scopes: null,
92
+ },
73
93
  {
74
94
  name: "useChildRenderer",
75
95
  signature: "useChildRenderer()",
@@ -580,6 +600,13 @@ const MANIFEST_SCHEMA = {
580
600
  " — NOT the React surface, so SDK imports/hooks are unavailable) }. Do NOT include triggerTableId or apiKeyId — those are tenant-local and bound after install.",
581
601
  default: [],
582
602
  },
603
+ translations: {
604
+ type: "object",
605
+ required: false,
606
+ description:
607
+ "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤100 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
608
+ default: {},
609
+ },
583
610
  };
584
611
 
585
612
  const WIDGET_CONTEXT_SHAPE = {
@@ -594,6 +621,19 @@ const WIDGET_CONTEXT_SHAPE = {
594
621
  required: true,
595
622
  fields: { id: "manifest.id", version: "manifest.version" },
596
623
  },
624
+ // REQ-LAY-08 — optional host layout hint backing useFill(). True when the
625
+ // host sized this widget to fill its layout slot's available height (a
626
+ // page-grid tile set to "Fill tile height", or a default-fill widget type).
627
+ // Optional: a host that never fills a widget simply omits it and useFill()
628
+ // returns false, so existing hosts need no change.
629
+ fill: {
630
+ description:
631
+ "Optional host layout hint (boolean). True when the host sized this " +
632
+ "widget to fill its layout slot's available height (page-grid tile " +
633
+ "fill). Backs useFill(); defaults to false when absent.",
634
+ required: false,
635
+ fields: {},
636
+ },
597
637
  user: {
598
638
  description:
599
639
  "Signed-in user, host-provided VERBATIM (snake_case: { id, email, display_name, roles, group_ids }). Not a data-client.",
@@ -873,6 +913,20 @@ function deepFreeze(value) {
873
913
  return Object.freeze(value);
874
914
  }
875
915
 
916
+ // REQ-L10N-WIDGET — a widget's manifest `translations` are merged into the
917
+ // tenant dictionary under a per-widget namespace so they never collide with
918
+ // app keys or another widget's keys. The prefix is DERIVED from the widget id
919
+ // (it "follows the widget"), so authors call `t("greeting")` and the host
920
+ // resolves `widget.<id>.greeting`. This is the ONE definition of the key
921
+ // format: the SDK `useI18n` hook (lookup) and the backend seeder (write) both
922
+ // call it, so the wire key and the lookup key can never drift.
923
+ function widgetTranslationPrefix(id) {
924
+ return `widget.${id}.`;
925
+ }
926
+ function widgetTranslationKey(id, key) {
927
+ return `widget.${id}.${key}`;
928
+ }
929
+
876
930
  const CONTRACT = deepFreeze({
877
931
  // 1.7.0: additive — new useDatastoreSchema(tableId) hook + the
878
932
  // datastore.schema host-context slice it reads (resolves a table's column
@@ -905,7 +959,35 @@ const CONTRACT = deepFreeze({
905
959
  // snake_case. Hooks now unwrap the list envelope (res.data ?? []). The
906
960
  // SDK stays duck-typed — it imports none of the four data SDKs. Minor
907
961
  // bump on the contract's pre-1.0 versioning (the breaking channel).
908
- version: "1.9.0",
962
+ //
963
+ // 1.10.0: additive (REQ-L10N-WIDGET) — manifests may declare an optional
964
+ // `translations` map ({ <key>: { en, <locale>? } }, en required). The
965
+ // host seeds them into the tenant l10n dictionary under a per-widget
966
+ // namespace at install; `useI18n().t(key)` now auto-prefixes the lookup
967
+ // with `widget.<ctx.widget.id>.` (then falls back to the raw key, so
968
+ // existing widgets and shared app keys keep resolving). New exported
969
+ // helpers widgetTranslationPrefix / widgetTranslationKey are the single
970
+ // source of the key format, shared by the hook and the backend seeder.
971
+ //
972
+ // 1.11.0: additive (REQ-LAY-08) — new useFill() hook + the optional `fill`
973
+ // host-context slice it reads. The host sets ctx.fill=true when it has
974
+ // sized a widget to fill its layout slot's available height (a page-grid
975
+ // tile whose author chose "Fill tile height", or a default-fill widget
976
+ // type — containers + media). Widgets that can stretch (Image, Chart,
977
+ // Map, Video) switch to flex:1/height:"100%" when useFill() is true; all
978
+ // others ignore it. The slice is OPTIONAL (defaults false) so existing
979
+ // hosts need no change, and the same value is injected on web and native
980
+ // so fill behaviour cannot diverge between the Player and the export.
981
+ //
982
+ // 1.11.1: fix — the canonical default `themeTokens.colors` now match the
983
+ // product's advertised default brand (primary #3b82f6 blue, secondary
984
+ // #10b981 green) instead of the stale coral/slate that no other surface
985
+ // used. The Theme Settings tab and Player chrome already defaulted to
986
+ // these values, so a tenant that never customised its theme rendered
987
+ // widgets in coral until a first save persisted the blue — a divergence
988
+ // between the unsaved and saved-defaults render. Aligning the canonical
989
+ // tokens removes it (no shape/signature change — default-value fix only).
990
+ version: "1.11.1",
909
991
  hooks: HOOKS,
910
992
  primitives: PRIMITIVES,
911
993
  manifestSchema: MANIFEST_SCHEMA,
@@ -937,4 +1019,10 @@ function requiredContextKeys() {
937
1019
  return [...keys];
938
1020
  }
939
1021
 
940
- export { CONTRACT, isHookAllowed, requiredContextKeys };
1022
+ export {
1023
+ CONTRACT,
1024
+ isHookAllowed,
1025
+ requiredContextKeys,
1026
+ widgetTranslationPrefix,
1027
+ widgetTranslationKey,
1028
+ };
package/dist/hooks.js CHANGED
@@ -26,6 +26,10 @@ import React, {
26
26
  useRef,
27
27
  useState,
28
28
  } from "react";
29
+ // REQ-L10N-WIDGET: the per-widget translation key format lives in ONE place
30
+ // (contract.js). useI18n (lookup) and the backend seeder (write) both call it
31
+ // so the namespaced key can never drift between the two sides.
32
+ import { widgetTranslationKey } from "./contract.js";
29
33
 
30
34
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
31
35
  const HostWidgetContext = createContext(null);
@@ -97,6 +101,25 @@ export function useUser() {
97
101
  return ctx.user;
98
102
  }
99
103
 
104
+ /**
105
+ * Returns `true` when the host has sized this widget to fill the available
106
+ * height of its layout slot — today that means a page-grid tile whose author
107
+ * chose "Fill tile height" (or a widget type that fills by default, like
108
+ * containers and media). When `true`, a widget that has a meaningful filled
109
+ * form (Image, Chart, Map, Video, …) should switch from its intrinsic height
110
+ * to a stretch style (`flex: 1` / `height: "100%"`); widgets with no useful
111
+ * filled form may ignore it. Defaults to `false` everywhere the host has not
112
+ * opted the widget into filling, so calling it is always safe.
113
+ *
114
+ * The SAME value is injected by the web Player host and the native export
115
+ * host (CLAUDE.md §3), so a widget's fill behaviour is identical on both
116
+ * platforms — there is one source file and one `fill` flag driving it.
117
+ */
118
+ export function useFill() {
119
+ const ctx = useWidgetContextOrThrow("useFill");
120
+ return ctx.fill === true;
121
+ }
122
+
100
123
  /**
101
124
  * Returns the host's child-node renderer:
102
125
  * { renderNode(node) }.
@@ -166,14 +189,39 @@ export function useI18n() {
166
189
  const i18n = ctx.i18n || {};
167
190
  const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
168
191
  const hostT = typeof i18n.t === "function" ? i18n.t : null;
192
+ // REQ-L10N-WIDGET: the widget's own manifest translations are stored in the
193
+ // tenant dictionary under `widget.<id>.<key>`. Derive the namespace from the
194
+ // host-provided widget id so the author calls `t("greeting")` and never
195
+ // types the prefix — and so the SAME hook gives this behaviour on web and in
196
+ // the exported app (both hosts set ctx.widget.id).
197
+ const widgetId =
198
+ ctx.widget && typeof ctx.widget.id === "string" && ctx.widget.id
199
+ ? ctx.widget.id
200
+ : null;
169
201
  const t = useCallback(
170
202
  (key, fallback) => {
171
203
  if (typeof key !== "string" || !key) {
172
204
  return typeof fallback === "string" ? fallback : "";
173
205
  }
174
206
  if (hostT) {
175
- // Try the two-arg form first; if the host's `t` ignores `fallback`
176
- // and returns the bare key on a miss, swap in the fallback.
207
+ // 1) Try the widget-namespaced key first. A genuine hit is a string
208
+ // that is neither the bare key nor a `{{t:…}}` placeholder (the
209
+ // miss form both hosts emit). No fallback is passed here so a miss
210
+ // is unambiguous and we can fall through to the raw key.
211
+ if (widgetId) {
212
+ const namespaced = widgetTranslationKey(widgetId, key);
213
+ const scoped = hostT(namespaced);
214
+ if (
215
+ typeof scoped === "string" &&
216
+ scoped.length > 0 &&
217
+ scoped !== namespaced &&
218
+ !scoped.startsWith("{{t:")
219
+ ) {
220
+ return scoped;
221
+ }
222
+ }
223
+ // 2) Fall back to the raw key (shared app keys + widgets with no
224
+ // manifest translations — unchanged from pre-1.10 behaviour).
177
225
  const out = hostT(key, fallback);
178
226
  if (typeof out === "string" && out.length > 0 && out !== key) {
179
227
  return out;
@@ -183,7 +231,7 @@ export function useI18n() {
183
231
  }
184
232
  return typeof fallback === "string" ? fallback : key;
185
233
  },
186
- [hostT],
234
+ [hostT, widgetId],
187
235
  );
188
236
  return { t, locale };
189
237
  }
package/dist/index.d.ts CHANGED
@@ -184,6 +184,17 @@ export interface WidgetManifest {
184
184
  * isolated-vm action runner. See `WidgetManifestAction`.
185
185
  */
186
186
  actions?: WidgetManifestAction[];
187
+ /**
188
+ * Optional translation strings the widget ships (REQ-L10N-WIDGET). Maps a
189
+ * relative key to its per-locale strings; `en` is required per key. At
190
+ * install the host merges these into the tenant's localization dictionary
191
+ * under a per-widget namespace (`widget.<id>.<key>`), so `useI18n().t(key)`
192
+ * resolves the namespaced key automatically — the author never types the
193
+ * prefix. Non-destructive (admin edits win; absent languages are not
194
+ * created) and persists across uninstalls. Caps: ≤100 keys, key matches
195
+ * /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, value ≤1 KB.
196
+ */
197
+ translations?: Record<string, { en: string } & Record<string, string>>;
187
198
  }
188
199
 
189
200
  export interface ThemeTokens {
@@ -314,6 +325,13 @@ export interface PaymentsClient {
314
325
  export interface WidgetContext<TProps = unknown> {
315
326
  props: TProps;
316
327
  widget: { id: string; instanceId: string; version: string };
328
+ /**
329
+ * REQ-LAY-08 — optional host layout hint backing `useFill()`. `true` when
330
+ * the host sized this widget to fill its layout slot's available height (a
331
+ * page-grid tile set to "Fill tile height", or a default-fill widget type).
332
+ * Absent / `false` everywhere the host has not opted the widget into filling.
333
+ */
334
+ fill?: boolean;
317
335
  /** Active end-user identity, snake_case verbatim. `id` is null when anonymous. */
318
336
  user: {
319
337
  id: string | null;
@@ -645,6 +663,17 @@ export function useUser(): {
645
663
  group_ids: string[];
646
664
  };
647
665
 
666
+ /**
667
+ * REQ-LAY-08 — returns `true` when the host has sized this widget to fill its
668
+ * layout slot's available height (a page-grid tile set to "Fill tile height",
669
+ * or a default-fill widget type — containers + media). Widgets that can
670
+ * stretch (Image, Chart, Map, Video, …) should switch to a fill style
671
+ * (`flex: 1` / `height: "100%"`) when this is `true`; others may ignore it.
672
+ * Defaults to `false`, so calling it is always safe, and the same value is
673
+ * injected on web and native so fill behaviour is identical on both platforms.
674
+ */
675
+ export function useFill(): boolean;
676
+
648
677
  /**
649
678
  * The host-provided navigation surface. `goTo(pageId, params?)` navigates
650
679
  * to an internal app page; `goBack()` pops the stack. Missing methods
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  useTheme,
26
26
  useI18n,
27
27
  useUser,
28
+ useFill,
28
29
  useNavigation,
29
30
  useChildRenderer,
30
31
  WidgetTree,
@@ -25,6 +25,7 @@ export {
25
25
  useTheme,
26
26
  useI18n,
27
27
  useUser,
28
+ useFill,
28
29
  useNavigation,
29
30
  useChildRenderer,
30
31
  WidgetTree,
package/dist/manifest.cjs CHANGED
@@ -24,10 +24,86 @@ const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
24
24
  const ACTION_TIMEOUT_MIN_MS = 100;
25
25
  const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
26
26
 
27
+ // REQ-L10N-WIDGET — caps + patterns for manifest-declared translations.
28
+ // The relative key pattern is deliberately tighter than the dictionary's
29
+ // own KEY_RE: once the host namespaces it (`widget.<id>.<key>`) the result
30
+ // must still satisfy the dictionary cap (128 chars / KEY_RE in
31
+ // backend/src/core/services/l10n.service.js). The locale pattern mirrors that
32
+ // service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
33
+ // would reject.
34
+ const TRANSLATIONS_MAX_KEYS = 100;
35
+ const TRANSLATION_VALUE_MAX_BYTES = 1024;
36
+ const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
37
+ const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
38
+ const TRANSLATION_FULL_KEY_MAX = 128;
39
+
27
40
  function utf8ByteLength(s) {
28
41
  return new TextEncoder().encode(s).length;
29
42
  }
30
43
 
44
+ function validateManifestTranslations(translations, manifestId, errors) {
45
+ if (
46
+ translations === null ||
47
+ typeof translations !== "object" ||
48
+ Array.isArray(translations)
49
+ ) {
50
+ errors.push(
51
+ "manifest.translations must be an object mapping key -> { en, <locale>? }",
52
+ );
53
+ return;
54
+ }
55
+ const keys = Object.keys(translations);
56
+ if (keys.length > TRANSLATIONS_MAX_KEYS) {
57
+ errors.push(
58
+ `manifest.translations may declare at most ${TRANSLATIONS_MAX_KEYS} keys`,
59
+ );
60
+ }
61
+ for (const key of keys) {
62
+ if (!TRANSLATION_KEY_RE.test(key)) {
63
+ errors.push(
64
+ `manifest.translations key "${key}" must match /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`,
65
+ );
66
+ continue;
67
+ }
68
+ if (isNonEmptyString(manifestId)) {
69
+ const fullKey = `widget.${manifestId}.${key}`;
70
+ if (fullKey.length > TRANSLATION_FULL_KEY_MAX) {
71
+ errors.push(
72
+ `manifest.translations key "${key}" is too long once namespaced (${fullKey.length} > ${TRANSLATION_FULL_KEY_MAX} chars)`,
73
+ );
74
+ }
75
+ }
76
+ const entry = translations[key];
77
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
78
+ errors.push(
79
+ `manifest.translations["${key}"] must be an object of locale -> string`,
80
+ );
81
+ continue;
82
+ }
83
+ if (!isNonEmptyString(entry.en)) {
84
+ errors.push(
85
+ `manifest.translations["${key}"].en is required and must be a non-empty string`,
86
+ );
87
+ }
88
+ for (const [locale, value] of Object.entries(entry)) {
89
+ if (!TRANSLATION_LOCALE_RE.test(locale)) {
90
+ errors.push(
91
+ `manifest.translations["${key}"] has invalid locale "${locale}"`,
92
+ );
93
+ }
94
+ if (typeof value !== "string") {
95
+ errors.push(
96
+ `manifest.translations["${key}"]["${locale}"] must be a string`,
97
+ );
98
+ } else if (utf8ByteLength(value) > TRANSLATION_VALUE_MAX_BYTES) {
99
+ errors.push(
100
+ `manifest.translations["${key}"]["${locale}"] exceeds 1 KB`,
101
+ );
102
+ }
103
+ }
104
+ }
105
+ }
106
+
31
107
  function validateManifestActions(actions, errors) {
32
108
  if (!Array.isArray(actions)) {
33
109
  errors.push("manifest.actions must be an array (omit it or use [] for none)");
@@ -208,10 +284,16 @@ function validateManifest(m) {
208
284
  validateManifestActions(manifest.actions, errors);
209
285
  }
210
286
 
287
+ // `translations` is optional (additive in SDK 1.10.0 / REQ-L10N-WIDGET).
288
+ if (manifest.translations !== undefined) {
289
+ validateManifestTranslations(manifest.translations, manifest.id, errors);
290
+ }
291
+
211
292
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
212
293
  }
213
294
 
214
295
  module.exports = {
215
296
  validateManifest,
297
+ validateManifestTranslations,
216
298
  canonicalCategory,
217
299
  };
package/dist/manifest.js CHANGED
@@ -24,10 +24,86 @@ const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
24
24
  const ACTION_TIMEOUT_MIN_MS = 100;
25
25
  const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
26
26
 
27
+ // REQ-L10N-WIDGET — caps + patterns for manifest-declared translations.
28
+ // The relative key pattern is deliberately tighter than the dictionary's
29
+ // own KEY_RE: once the host namespaces it (`widget.<id>.<key>`) the result
30
+ // must still satisfy the dictionary cap (128 chars / KEY_RE in
31
+ // backend/src/core/services/l10n.service.js). The locale pattern mirrors that
32
+ // service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
33
+ // would reject.
34
+ const TRANSLATIONS_MAX_KEYS = 100;
35
+ const TRANSLATION_VALUE_MAX_BYTES = 1024;
36
+ const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
37
+ const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
38
+ const TRANSLATION_FULL_KEY_MAX = 128;
39
+
27
40
  function utf8ByteLength(s) {
28
41
  return new TextEncoder().encode(s).length;
29
42
  }
30
43
 
44
+ function validateManifestTranslations(translations, manifestId, errors) {
45
+ if (
46
+ translations === null ||
47
+ typeof translations !== "object" ||
48
+ Array.isArray(translations)
49
+ ) {
50
+ errors.push(
51
+ "manifest.translations must be an object mapping key -> { en, <locale>? }",
52
+ );
53
+ return;
54
+ }
55
+ const keys = Object.keys(translations);
56
+ if (keys.length > TRANSLATIONS_MAX_KEYS) {
57
+ errors.push(
58
+ `manifest.translations may declare at most ${TRANSLATIONS_MAX_KEYS} keys`,
59
+ );
60
+ }
61
+ for (const key of keys) {
62
+ if (!TRANSLATION_KEY_RE.test(key)) {
63
+ errors.push(
64
+ `manifest.translations key "${key}" must match /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`,
65
+ );
66
+ continue;
67
+ }
68
+ if (isNonEmptyString(manifestId)) {
69
+ const fullKey = `widget.${manifestId}.${key}`;
70
+ if (fullKey.length > TRANSLATION_FULL_KEY_MAX) {
71
+ errors.push(
72
+ `manifest.translations key "${key}" is too long once namespaced (${fullKey.length} > ${TRANSLATION_FULL_KEY_MAX} chars)`,
73
+ );
74
+ }
75
+ }
76
+ const entry = translations[key];
77
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
78
+ errors.push(
79
+ `manifest.translations["${key}"] must be an object of locale -> string`,
80
+ );
81
+ continue;
82
+ }
83
+ if (!isNonEmptyString(entry.en)) {
84
+ errors.push(
85
+ `manifest.translations["${key}"].en is required and must be a non-empty string`,
86
+ );
87
+ }
88
+ for (const [locale, value] of Object.entries(entry)) {
89
+ if (!TRANSLATION_LOCALE_RE.test(locale)) {
90
+ errors.push(
91
+ `manifest.translations["${key}"] has invalid locale "${locale}"`,
92
+ );
93
+ }
94
+ if (typeof value !== "string") {
95
+ errors.push(
96
+ `manifest.translations["${key}"]["${locale}"] must be a string`,
97
+ );
98
+ } else if (utf8ByteLength(value) > TRANSLATION_VALUE_MAX_BYTES) {
99
+ errors.push(
100
+ `manifest.translations["${key}"]["${locale}"] exceeds 1 KB`,
101
+ );
102
+ }
103
+ }
104
+ }
105
+ }
106
+
31
107
  function validateManifestActions(actions, errors) {
32
108
  if (!Array.isArray(actions)) {
33
109
  errors.push("manifest.actions must be an array (omit it or use [] for none)");
@@ -208,10 +284,16 @@ function validateManifest(m) {
208
284
  validateManifestActions(manifest.actions, errors);
209
285
  }
210
286
 
287
+ // `translations` is optional (additive in SDK 1.10.0 / REQ-L10N-WIDGET).
288
+ if (manifest.translations !== undefined) {
289
+ validateManifestTranslations(manifest.translations, manifest.id, errors);
290
+ }
291
+
211
292
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
212
293
  }
213
294
 
214
295
  export {
215
296
  validateManifest,
297
+ validateManifestTranslations,
216
298
  canonicalCategory,
217
299
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.19.0",
3
+ "version": "0.21.1",
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",
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "build": "node scripts/build.js",
38
- "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-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/linter-users-scope.test.js src/__tests__/manifest-actions.test.js"
38
+ "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-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/linter-users-scope.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js"
39
39
  },
40
40
  "engines": {
41
41
  "node": ">=18"