@colixsystems/widget-sdk 0.23.0 → 0.25.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
@@ -18,6 +18,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
18
18
  | Group | Hook (signature) | Returns | Reads / scope |
19
19
  | ----- | ---------------- | ------- | ------------- |
20
20
  | **CORE** | `useTheme()` | `{ colors, spacing, radii, typography }` | `ctx.workspace.theme` — no scope |
21
+ | **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
21
22
  | **CORE** | `useUser()` | `{ id, email, display_name, roles, group_ids }` | `ctx.user` (snake_case verbatim; `id` null when anonymous) — no scope |
22
23
  | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
23
24
  | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
@@ -45,7 +46,27 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
45
46
 
46
47
  ## Status
47
48
 
48
- `v0.23.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**.
49
+ `v0.25.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**.
50
+
51
+ ### What's new in 0.25.0
52
+
53
+ **Per-widget styling via `styleSchema` (REQ-THEME-13).**
54
+
55
+ - **New optional `manifest.styleSchema` field.** Same shape and types as `propertySchema` (`color`, `number`, `select`, `boolean`, …) — it declares the styling options the widget exposes. Example:
56
+ ```js
57
+ styleSchema: {
58
+ cardBackground: { type: "color", label: "Card background" },
59
+ cardRadius: { type: "number", label: "Corner radius", validation: { min: 0, max: 48 } },
60
+ valueColor: { type: "color", label: "Value color" },
61
+ }
62
+ ```
63
+ - **The Studio renders a "Style" section** from `styleSchema` using the same `SchemaForm` engine as `propertySchema`. The author's resolved values are persisted under the node's `props.style`.
64
+ - **New `useWidgetStyle()` hook** returns `props.style` (an object keyed by your style-field names). Read `style.<field>` and apply each onto whatever element you choose — the host never auto-applies style, so the widget controls placement:
65
+ ```jsx
66
+ const style = useWidgetStyle();
67
+ <View style={[styles.card, style.cardBackground && { backgroundColor: style.cardBackground }]}>
68
+ ```
69
+ - **`CONTRACT.version` → `1.15.0`** (additive: one optional manifest field + one hook). No existing field, hook, primitive, or token changed shape.
49
70
 
50
71
  ### What's new in 0.23.0
51
72
 
package/dist/contract.cjs CHANGED
@@ -47,6 +47,23 @@ const HOOKS = [
47
47
  requiredContextSlice: ["workspace.theme"],
48
48
  scopes: null,
49
49
  },
50
+ {
51
+ name: "useWidgetStyle",
52
+ signature: "useWidgetStyle()",
53
+ description:
54
+ "Returns the author-set per-widget style values (REQ-THEME-13) — the " +
55
+ "resolved object the host delivers under props.style, keyed by the " +
56
+ "style-field names the widget declares in manifest.styleSchema. Read " +
57
+ "props.style.<field> and apply each onto whatever element you choose " +
58
+ "(spread AFTER your intrinsic styles so author choices win). Returns an " +
59
+ "empty object when nothing is set, so reads are always safe. Equivalent " +
60
+ "to reading the `style` prop directly.",
61
+ returnShape: {
62
+ "<styleFieldName>": "the author-set value (string/number/… per the styleSchema def)",
63
+ },
64
+ requiredContextSlice: ["props"],
65
+ scopes: null,
66
+ },
50
67
  {
51
68
  name: "useI18n",
52
69
  signature: "useI18n()",
@@ -314,6 +331,31 @@ const HOOKS = [
314
331
  requiredContextSlice: ["datastore.records"],
315
332
  scopes: ["acl.write:records"],
316
333
  },
334
+ // REQ-RT-07 — realtime table subscription.
335
+ {
336
+ name: "useDatastoreSubscription",
337
+ signature: "useDatastoreSubscription(tableId, handlers, options?)",
338
+ description:
339
+ "Subscribe to a table's realtime change stream via the injected " +
340
+ "datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
341
+ "handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
342
+ "snake_case record verbatim (same shape REST returns). The socket opens " +
343
+ "on mount, re-opens when tableId changes, and is torn down on unmount. " +
344
+ "Returns { status } where status is 'connecting' | 'live' | " +
345
+ "'reconnecting' | 'fallback'. The server gates each subscribe by the " +
346
+ "same read ACL REST honours; on an ACL reject, a missing WebSocket, or a " +
347
+ "host whose client predates realtime, the hook resolves to " +
348
+ "{ status: 'fallback' } WITHOUT throwing so the widget can poll instead. " +
349
+ "Reads the same datastore.read scope as useDatastoreQuery — declare " +
350
+ "datastore.read for the table you subscribe to. Pair with " +
351
+ "useDatastoreQuery for the initial load and merge the streamed envelopes.",
352
+ returnShape: {
353
+ status:
354
+ "'connecting' | 'live' | 'reconnecting' | 'fallback' // transport state; 'fallback' → poll",
355
+ },
356
+ requiredContextSlice: ["datastore.records"],
357
+ scopes: ["datastore.read:<table>"],
358
+ },
317
359
  // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
318
360
  {
319
361
  name: "useClipboard",
@@ -576,6 +618,20 @@ const MANIFEST_SCHEMA = {
576
618
  description: "Map of prop name to property definition.",
577
619
  default: {},
578
620
  },
621
+ styleSchema: {
622
+ type: "object",
623
+ required: false,
624
+ description:
625
+ "Optional (REQ-THEME-13). Map of style-field name to definition — the " +
626
+ "SAME shape and types as propertySchema (color, number, select, " +
627
+ "boolean, …). Declares the per-instance styling options the widget " +
628
+ "exposes; the Studio Properties Panel renders a \"Style\" section from " +
629
+ "it. The author's resolved values are delivered to the widget under " +
630
+ "props.style (one object keyed by style-field name); the widget reads " +
631
+ "props.style.<field> and applies each wherever it chooses. The host " +
632
+ "never auto-applies style — the widget owns placement.",
633
+ default: {},
634
+ },
579
635
  events: {
580
636
  type: "object[]",
581
637
  required: true,
@@ -1136,7 +1192,24 @@ const CONTRACT = deepFreeze({
1136
1192
  // is additive — minor bump on the pre-1.0 channel. The compiler pins the
1137
1193
  // native-module members in the exported Expo app's package.json and now
1138
1194
  // always emits the react-native-reanimated/plugin (CLAUDE.md §3 parity).
1139
- version: "1.13.0",
1195
+ //
1196
+ // 1.14.0: additive (REQ-RT-07) — new `useDatastoreSubscription(tableId,
1197
+ // handlers, options?)` hook reading ctx.datastore.records(t).subscribe.
1198
+ // Backed by the datastore-client 0.6.0 `subscribe` method (WebSocket to
1199
+ // `/datastore/ws`). Safe-by-default: resolves to { status: 'fallback' }
1200
+ // when the host's client predates realtime or no WebSocket is available,
1201
+ // so widgets degrade to polling on both platforms. No existing hook,
1202
+ // primitive, or contract field changed — minor bump on the pre-1.0 channel.
1203
+ //
1204
+ // 1.15.0: additive (REQ-THEME-13) — per-widget styling via `styleSchema`. New
1205
+ // optional manifest field `styleSchema`: the SAME shape as propertySchema
1206
+ // (color/number/select/… fields), declaring the per-instance styling
1207
+ // options a widget exposes. The Studio renders a "Style" section from it;
1208
+ // the resolved values are delivered to the widget under props.style and
1209
+ // the widget applies them itself (the host never auto-styles). New
1210
+ // `useWidgetStyle()` hook returns props.style. No existing field, hook,
1211
+ // primitive, or token changed shape — minor bump on the pre-1.0 channel.
1212
+ version: "1.15.0",
1140
1213
  hooks: HOOKS,
1141
1214
  primitives: PRIMITIVES,
1142
1215
  manifestSchema: MANIFEST_SCHEMA,
package/dist/contract.js CHANGED
@@ -47,6 +47,23 @@ const HOOKS = [
47
47
  requiredContextSlice: ["workspace.theme"],
48
48
  scopes: null,
49
49
  },
50
+ {
51
+ name: "useWidgetStyle",
52
+ signature: "useWidgetStyle()",
53
+ description:
54
+ "Returns the author-set per-widget style values (REQ-THEME-13) — the " +
55
+ "resolved object the host delivers under props.style, keyed by the " +
56
+ "style-field names the widget declares in manifest.styleSchema. Read " +
57
+ "props.style.<field> and apply each onto whatever element you choose " +
58
+ "(spread AFTER your intrinsic styles so author choices win). Returns an " +
59
+ "empty object when nothing is set, so reads are always safe. Equivalent " +
60
+ "to reading the `style` prop directly.",
61
+ returnShape: {
62
+ "<styleFieldName>": "the author-set value (string/number/… per the styleSchema def)",
63
+ },
64
+ requiredContextSlice: ["props"],
65
+ scopes: null,
66
+ },
50
67
  {
51
68
  name: "useI18n",
52
69
  signature: "useI18n()",
@@ -177,8 +194,7 @@ const HOOKS = [
177
194
  signature: "useDatastoreMutation(tableId)",
178
195
  returnShape: {
179
196
  create: "(record) => Promise<Record> // rejects with DatastoreError",
180
- update:
181
- "(id, partial) => Promise<Record> // rejects with DatastoreError",
197
+ update: "(id, partial) => Promise<Record> // rejects with DatastoreError",
182
198
  delete: "(id) => Promise<void> // rejects with DatastoreError",
183
199
  },
184
200
  requiredContextSlice: ["datastore.records"],
@@ -281,13 +297,7 @@ const HOOKS = [
281
297
  scopes: ["groups.read:*"],
282
298
  },
283
299
  // REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
284
- // management for a single record. Reads the injected datastore-client at
285
- // ctx.datastore.records(tableId).permissions(recordId) (the recordPermissions
286
- // facade was folded into the datastore-client). The backend gates the call
287
- // on `can_grant` for the target record (Studio owners short-circuit;
288
- // APP_USER actors must hold `can_grant` via REQ-ACL-05 / REQ-ACL-06). A
289
- // widget that declares the scope but whose caller lacks the grant receives
290
- // `PermissionError { code: 'FORBIDDEN' }`.
300
+ // management for a single record. Mirror of contract.js.
291
301
  {
292
302
  name: "useRecordPermissions",
293
303
  signature: "useRecordPermissions(tableId, recordId)",
@@ -321,7 +331,32 @@ const HOOKS = [
321
331
  requiredContextSlice: ["datastore.records"],
322
332
  scopes: ["acl.write:records"],
323
333
  },
324
- // REQ-WSDK-PLATFORM §6 Tier A SDK hooks.
334
+ // REQ-RT-07realtime table subscription.
335
+ {
336
+ name: "useDatastoreSubscription",
337
+ signature: "useDatastoreSubscription(tableId, handlers, options?)",
338
+ description:
339
+ "Subscribe to a table's realtime change stream via the injected " +
340
+ "datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
341
+ "handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
342
+ "snake_case record verbatim (same shape REST returns). The socket opens " +
343
+ "on mount, re-opens when tableId changes, and is torn down on unmount. " +
344
+ "Returns { status } where status is 'connecting' | 'live' | " +
345
+ "'reconnecting' | 'fallback'. The server gates each subscribe by the " +
346
+ "same read ACL REST honours; on an ACL reject, a missing WebSocket, or a " +
347
+ "host whose client predates realtime, the hook resolves to " +
348
+ "{ status: 'fallback' } WITHOUT throwing so the widget can poll instead. " +
349
+ "Reads the same datastore.read scope as useDatastoreQuery — declare " +
350
+ "datastore.read for the table you subscribe to. Pair with " +
351
+ "useDatastoreQuery for the initial load and merge the streamed envelopes.",
352
+ returnShape: {
353
+ status:
354
+ "'connecting' | 'live' | 'reconnecting' | 'fallback' // transport state; 'fallback' → poll",
355
+ },
356
+ requiredContextSlice: ["datastore.records"],
357
+ scopes: ["datastore.read:<table>"],
358
+ },
359
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
325
360
  {
326
361
  name: "useClipboard",
327
362
  signature: "useClipboard()",
@@ -359,7 +394,13 @@ const HOOKS = [
359
394
  },
360
395
  ];
361
396
 
362
- // REQ-WSDK-RN-WEB: see contract.cjs for the source-of-truth comment.
397
+ // REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
398
+ // (backed by react-native-web on the browser, by react-native on the
399
+ // exported Expo app). The contract describes the *cross-platform
400
+ // subset* the SDK re-exports; the per-prop reference for each
401
+ // component is whatever React Native documents. Adding a new primitive
402
+ // = add the name to `./primitives.js` + `./primitives.native.js` (both
403
+ // one-line re-exports) and append it to this list.
363
404
  const PRIMITIVES = [
364
405
  {
365
406
  name: "Text",
@@ -577,6 +618,20 @@ const MANIFEST_SCHEMA = {
577
618
  description: "Map of prop name to property definition.",
578
619
  default: {},
579
620
  },
621
+ styleSchema: {
622
+ type: "object",
623
+ required: false,
624
+ description:
625
+ "Optional (REQ-THEME-13). Map of style-field name to definition — the " +
626
+ "SAME shape and types as propertySchema (color, number, select, " +
627
+ "boolean, …). Declares the per-instance styling options the widget " +
628
+ "exposes; the Studio Properties Panel renders a \"Style\" section from " +
629
+ "it. The author's resolved values are delivered to the widget under " +
630
+ "props.style (one object keyed by style-field name); the widget reads " +
631
+ "props.style.<field> and applies each wherever it chooses. The host " +
632
+ "never auto-applies style — the widget owns placement.",
633
+ default: {},
634
+ },
580
635
  events: {
581
636
  type: "object[]",
582
637
  required: true,
@@ -611,7 +666,8 @@ const MANIFEST_SCHEMA = {
611
666
 
612
667
  const WIDGET_CONTEXT_SHAPE = {
613
668
  props: {
614
- description: "Resolved property values, shape per manifest.propertySchema.",
669
+ description:
670
+ "Resolved property values, shape per manifest.propertySchema.",
615
671
  required: true,
616
672
  fields: {},
617
673
  },
@@ -725,10 +781,7 @@ const WIDGET_CONTEXT_SHAPE = {
725
781
  error: "function",
726
782
  },
727
783
  },
728
- // REQ-WSDK-PLATFORM §6 — backs useToast(). The host installs its own
729
- // workspace-themed toast renderer here; if omitted, the SDK's useToast
730
- // hook falls back to a CustomEvent on web / console.log on native so
731
- // widget code still runs without a host integration.
784
+ // REQ-WSDK-PLATFORM §6 — backs useToast(). Mirror of contract.js.
732
785
  toast: {
733
786
  description:
734
787
  "Optional host toast slot. { showToast({ kind, message }): void }. " +
@@ -762,8 +815,14 @@ const BUNDLE_EXPORT_CONTRACT = [
762
815
  ];
763
816
 
764
817
  // REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
765
- // §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. See contract.cjs
766
- // for the source-of-truth comment.
818
+ // §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. Widgets may call
819
+ // third-party APIs directly. Same-origin requests to the host's own
820
+ // `/api/*` surface are rejected at runtime by the WidgetContextProvider's
821
+ // network gate (`no host-api access from widgets`) — the JWT token is
822
+ // never shared with widget code, so the call would 401 anyway; the runtime
823
+ // gate makes the failure mode "blocked" instead of "401 noise". A soft
824
+ // linter warning (`no-host-api-url`) flags obvious host-URL substrings at
825
+ // submission so authors learn the rule statically.
767
826
  const BANNED_APIS = [
768
827
  { identifier: "eval", reason: "Arbitrary code evaluation." },
769
828
  {
@@ -798,8 +857,22 @@ const BANNED_APIS = [
798
857
  { identifier: "globalThis", reason: "Host environment escape." },
799
858
  ];
800
859
 
801
- // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. See contract.cjs
802
- // for the source-of-truth comment.
860
+ // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
861
+ // specifier the widget bundle may import as a bare module specifier. The
862
+ // linter validates every `import ... from "<spec>"` line against this
863
+ // list; specifiers not on the list fail the lint. The compiler reads it
864
+ // to know which packages to add to the exported Expo app's package.json.
865
+ //
866
+ // `platforms` is one or both of "web" / "native". A widget that imports a
867
+ // native-only package implicitly drops "web" from the package's effective
868
+ // `supportedPlatforms` (and vice versa) — the marketplace upload pipeline
869
+ // surfaces the derived set so the listing shows honest badges.
870
+ //
871
+ // Adding a package is a CONTRACT change. Review burden: confirm the
872
+ // package does what it claims, has a credible maintainer, and the
873
+ // import shape (named exports, default export) is stable. After that,
874
+ // every widget using the package inherits the review — the marketplace
875
+ // reviewer only spot-checks usage, not the package source.
803
876
  const VETTED_IMPORTS = [
804
877
  {
805
878
  specifier: "react",
@@ -981,8 +1054,19 @@ const VETTED_IMPORTS = [
981
1054
  },
982
1055
  ];
983
1056
 
1057
+ // Back-compat shape — every existing consumer (widgetLoader, the static
1058
+ // analyzer's DEPENDENCY_ALLOWLIST, the AI prompt's "Allowed imports" line)
1059
+ // reads a plain `string[]` from `CONTRACT.allowedBareImports`. Derive it
1060
+ // from the rich list so a single edit in VETTED_IMPORTS propagates to
1061
+ // every surface.
984
1062
  const ALLOWED_BARE_IMPORTS = VETTED_IMPORTS.map((v) => v.specifier);
985
1063
 
1064
+ // REQ-WSDK-PLATFORM §3.5: host-API URL patterns the linter scans for as a
1065
+ // soft warning. None of these block the lint by themselves — they prompt
1066
+ // the marketplace reviewer to spot-check, and they help authors learn the
1067
+ // rule statically. Strings appear as literal substring matches (the URL
1068
+ // is reachable in widget code by composing it at runtime, so this is
1069
+ // best-effort).
986
1070
  const HOST_API_URL_PATTERNS = [
987
1071
  "/api/v1",
988
1072
  "/uploads/",
@@ -1011,24 +1095,43 @@ function widgetTranslationKey(id, key) {
1011
1095
  }
1012
1096
 
1013
1097
  const CONTRACT = deepFreeze({
1014
- // 1.7.0: additive — new useDatastoreSchema(tableId) hook + the
1015
- // datastore.schema host-context slice it reads (resolves a table's column
1016
- // structure at runtime via the existing ACL-gated GET /tables/:id).
1098
+ // REQ-WSDK-PLATFORM bump:
1099
+ // - `vettedImports` is a new field (rich allowlist with platforms +
1100
+ // category + description).
1101
+ // - `hostApiUrlPatterns` is a new field (soft-warning substrings).
1102
+ // - `bannedApis` no longer lists `fetch` / `XMLHttpRequest`.
1103
+ // - `allowedBareImports` is now derived from `vettedImports` (same
1104
+ // shape; same contents grow with each vetted addition).
1105
+ // Permissive-direction change: minor bump on the contract's own
1106
+ // versioning (per CLAUDE.md §4, pre-1.0 minor is the breaking channel —
1107
+ // the package.json version bumps accordingly).
1108
+ //
1109
+ // 1.6.0: additive — `themeTokens.colors` gains `secondary` + `onSecondary`
1110
+ // so the tenant's Theme Settings "Secondary Color" flows through
1111
+ // `useTheme().colors.secondary` (Button secondary variant + any widget
1112
+ // that wants the brand's second accent).
1113
+ //
1114
+ // 1.7.0: additive — new `useDatastoreSchema(tableId)` hook + the
1115
+ // `datastore.schema` host-context slice it reads. Lets a widget resolve a
1116
+ // stored columnId to its column name / dataType / relation target at
1117
+ // runtime (Form Builder renders inputs by column type). Reads the existing
1118
+ // ACL-gated `GET /tables/:id` — structure only, no row data.
1017
1119
  //
1018
1120
  // 1.8.0: two additive features.
1019
- // - REQ-WIDGET-ACTION — manifests may declare an optional `actions` array:
1020
- // server-side cron/record-triggered scripts the operator enables per
1021
- // tenant, run in the existing isolated-vm action runner (never in the
1022
- // rendered app). New fields actionTriggerTypes / actionScriptGlobals /
1023
- // actionScriptMaxBytes feed the docs + agent prompt. New ADMINISTRATION
1024
- // manifest category (REQ-USERMGMT-06).
1025
- // - REQ-ACL-06 — new useRecordPermissions(tableId, recordId) hook + the
1026
- // recordPermissions host-context slice it reads + the acl.write:records
1121
+ // - REQ-WIDGET-ACTION — manifests may declare an optional `actions` array.
1122
+ // Each entry is a server-side action (cron- or record-triggered JS) the
1123
+ // operator enables per tenant; it runs in the existing isolated-vm action
1124
+ // runner, never in the rendered app. New contract fields
1125
+ // `actionTriggerTypes`, `actionScriptGlobals`, and `actionScriptMaxBytes`
1126
+ // let the docs + agent prompt + validator derive the grammar from one
1127
+ // source. New `ADMINISTRATION` manifest category (REQ-USERMGMT-06).
1128
+ // - REQ-ACL-06 new `useRecordPermissions(tableId, recordId)` hook + the
1129
+ // `recordPermissions` host-context slice it reads + the `acl.write:records`
1027
1130
  // scope it gates on. Hits the existing REQ-ACL-06
1028
- // /api/v1/tables/:tableId/records/:recordId/permissions REST surface;
1029
- // the host normalises the wire shape into a single principalType+
1030
- // principalId pair widgets branch on. Caller still needs canGrant on
1031
- // the target record (REQ-ACL-RELINHERIT-05).
1131
+ // /api/v1/tables/:tableId/records/:recordId/permissions REST surface; the
1132
+ // host normalises the wire shape into a single principalType+principalId
1133
+ // pair widgets branch on. Caller still needs canGrant on the target
1134
+ // record (REQ-ACL-RELINHERIT-05).
1032
1135
  //
1033
1136
  // 1.9.0: host-contract change (REQ-WSDK-DOMAIN-CLIENTS) — the host now
1034
1137
  // injects domain-client INSTANCES on the WidgetContext rather than
@@ -1089,7 +1192,24 @@ const CONTRACT = deepFreeze({
1089
1192
  // is additive — minor bump on the pre-1.0 channel. The compiler pins the
1090
1193
  // native-module members in the exported Expo app's package.json and now
1091
1194
  // always emits the react-native-reanimated/plugin (CLAUDE.md §3 parity).
1092
- version: "1.13.0",
1195
+ //
1196
+ // 1.14.0: additive (REQ-RT-07) — new `useDatastoreSubscription(tableId,
1197
+ // handlers, options?)` hook reading ctx.datastore.records(t).subscribe.
1198
+ // Backed by the datastore-client 0.6.0 `subscribe` method (WebSocket to
1199
+ // `/datastore/ws`). Safe-by-default: resolves to { status: 'fallback' }
1200
+ // when the host's client predates realtime or no WebSocket is available,
1201
+ // so widgets degrade to polling on both platforms. No existing hook,
1202
+ // primitive, or contract field changed — minor bump on the pre-1.0 channel.
1203
+ //
1204
+ // 1.15.0: additive (REQ-THEME-13) — per-widget styling via `styleSchema`. New
1205
+ // optional manifest field `styleSchema`: the SAME shape as propertySchema
1206
+ // (color/number/select/… fields), declaring the per-instance styling
1207
+ // options a widget exposes. The Studio renders a "Style" section from it;
1208
+ // the resolved values are delivered to the widget under props.style and
1209
+ // the widget applies them itself (the host never auto-styles). New
1210
+ // `useWidgetStyle()` hook returns props.style. No existing field, hook,
1211
+ // primitive, or token changed shape — minor bump on the pre-1.0 channel.
1212
+ version: "1.15.0",
1093
1213
  hooks: HOOKS,
1094
1214
  primitives: PRIMITIVES,
1095
1215
  manifestSchema: MANIFEST_SCHEMA,
package/dist/hooks.js CHANGED
@@ -83,6 +83,26 @@ export function useTheme() {
83
83
  return ctx.workspace.theme;
84
84
  }
85
85
 
86
+ /**
87
+ * REQ-THEME-13 — returns the author-set per-widget style values: the object the
88
+ * host delivers under `props.style`, keyed by the style-field names the widget
89
+ * declares in its `manifest.styleSchema`. Read `style.<field>` and apply each
90
+ * onto whatever element you choose (spread AFTER your intrinsic styles so the
91
+ * author's choice wins):
92
+ *
93
+ * const style = useWidgetStyle();
94
+ * <View style={[styles.card, style.cardBackground && { backgroundColor: style.cardBackground }]}>
95
+ *
96
+ * Returns an empty object when nothing is set, so reads are always safe. This
97
+ * is equivalent to reading the `style` prop directly — the hook is a
98
+ * convenience so a widget needn't thread `style` through its prop signature.
99
+ */
100
+ export function useWidgetStyle() {
101
+ const ctx = useWidgetContextOrThrow("useWidgetStyle");
102
+ const style = ctx.props ? ctx.props.style : undefined;
103
+ return style && typeof style === "object" ? style : {};
104
+ }
105
+
86
106
  /**
87
107
  * Returns the active end-user identity VERBATIM from the host, e.g.
88
108
  * `{ id, email, display_name, roles, group_ids }` (snake_case fields).
@@ -921,6 +941,93 @@ export function useRecordPermissions(tableId, recordId) {
921
941
  return { permissions, loading, error, grant, revoke, update, refetch };
922
942
  }
923
943
 
944
+ /**
945
+ * REQ-RT-07: subscribe to a table's realtime change stream. Reads
946
+ * `ctx.datastore.records(table).subscribe({ onCreated, onUpdated, onDeleted,
947
+ * onStatus })` and wires it to a React lifecycle: the socket is opened on
948
+ * mount (and re-opened when `table` changes) and torn down on unmount via the
949
+ * unsubscribe function the client returns. The author's `handlers` callbacks
950
+ * are held in a ref so re-renders with fresh callback identities never tear
951
+ * the socket down — only a `table` change does.
952
+ *
953
+ * Returns `{ status }` where status is "connecting" | "live" | "reconnecting"
954
+ * | "fallback". A widget typically pairs this with `useDatastoreQuery` for the
955
+ * initial load and merges the streamed create/update/delete envelopes into its
956
+ * local list; when `status === "fallback"` (no socket support on the host, the
957
+ * subscribe was ACL-rejected, or the connect timed out) the widget should run
958
+ * its own REST polling instead.
959
+ *
960
+ * Cross-platform + safe-by-default: if the host's datastore client doesn't
961
+ * expose `subscribe` (an older host, or a runtime with no WebSocket), the hook
962
+ * resolves to `{ status: "fallback" }` WITHOUT throwing, so a widget that
963
+ * subscribes degrades to polling on both the web Player and the native export
964
+ * rather than crashing at render (CLAUDE.md §11).
965
+ *
966
+ * @param {string} table Bound table id (falsy → no subscription, status "fallback").
967
+ * @param {{ onCreated?, onUpdated?, onDeleted? }} [handlers] Per-event callbacks; each receives the snake_case record.
968
+ * @param {{ fallbackAfterMs?: number }} [options]
969
+ * @returns {{ status: "connecting" | "live" | "reconnecting" | "fallback" }}
970
+ */
971
+ export function useDatastoreSubscription(table, handlers, options) {
972
+ const ctx = useWidgetContextOrThrow("useDatastoreSubscription");
973
+ const [status, setStatus] = useState("connecting");
974
+
975
+ // Author callbacks live in a ref so a new closure each render does not
976
+ // re-run the effect (which would tear down + re-open the socket).
977
+ const handlersRef = useRef(handlers);
978
+ handlersRef.current = handlers;
979
+ // `ctx` is a fresh object identity per host render; hold the records
980
+ // factory in a ref so the effect depends only on `table`.
981
+ const recordsRef = useRef(
982
+ ctx.datastore && typeof ctx.datastore.records === "function"
983
+ ? ctx.datastore.records
984
+ : null,
985
+ );
986
+ recordsRef.current =
987
+ ctx.datastore && typeof ctx.datastore.records === "function"
988
+ ? ctx.datastore.records
989
+ : null;
990
+
991
+ const fallbackAfterMs =
992
+ options && Number.isFinite(options.fallbackAfterMs)
993
+ ? options.fallbackAfterMs
994
+ : undefined;
995
+
996
+ useEffect(() => {
997
+ if (!table || typeof recordsRef.current !== "function") {
998
+ setStatus("fallback");
999
+ return undefined;
1000
+ }
1001
+ let ns;
1002
+ try {
1003
+ ns = recordsRef.current(table);
1004
+ } catch (_) {
1005
+ setStatus("fallback");
1006
+ return undefined;
1007
+ }
1008
+ if (!ns || typeof ns.subscribe !== "function") {
1009
+ // Host's datastore client predates realtime — degrade to polling.
1010
+ setStatus("fallback");
1011
+ return undefined;
1012
+ }
1013
+ const stop = ns.subscribe(
1014
+ {
1015
+ onCreated: (r) => handlersRef.current?.onCreated?.(r),
1016
+ onUpdated: (r) => handlersRef.current?.onUpdated?.(r),
1017
+ onDeleted: (r) => handlersRef.current?.onDeleted?.(r),
1018
+ onStatus: (s) => setStatus(s),
1019
+ },
1020
+ fallbackAfterMs != null ? { fallbackAfterMs } : undefined,
1021
+ );
1022
+ return () => {
1023
+ if (typeof stop === "function") stop();
1024
+ };
1025
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1026
+ }, [table, fallbackAfterMs]);
1027
+
1028
+ return { status };
1029
+ }
1030
+
924
1031
  /* ============================================================================
925
1032
  * FILES CLIENT — ctx.files (@colixsystems/files-client)
926
1033
  *
package/dist/index.d.ts CHANGED
@@ -184,6 +184,14 @@ export interface WidgetManifest {
184
184
  minAppStudioVersion: string;
185
185
  requestedScopes: WidgetScope[];
186
186
  propertySchema: WidgetPropertySchema;
187
+ /**
188
+ * REQ-THEME-13 — optional per-widget styling schema. Same shape and types as
189
+ * `propertySchema`; declares the styling options the widget exposes. The
190
+ * Studio renders a "Style" section from it and delivers the author's resolved
191
+ * values to the widget under `props.style` (read them via `useWidgetStyle()`
192
+ * or the `style` prop). The widget applies each value itself.
193
+ */
194
+ styleSchema?: WidgetPropertySchema;
187
195
  events: WidgetEventDescriptor[];
188
196
  /**
189
197
  * Optional datastore template seeded into the tenant's workspace when
@@ -556,6 +564,14 @@ export function usePayments(): PaymentsApi;
556
564
 
557
565
  export function useTheme(): ThemeTokens;
558
566
 
567
+ /**
568
+ * REQ-THEME-13 — the author-set per-widget style values (the `props.style`
569
+ * object), keyed by the names declared in `manifest.styleSchema`. Returns an
570
+ * empty object when nothing is set. Apply each value onto whatever element you
571
+ * choose; the host never auto-applies style.
572
+ */
573
+ export function useWidgetStyle(): Record<string, unknown>;
574
+
559
575
  /**
560
576
  * Stateful single-record fetch hook. Returns `{ data, loading, error,
561
577
  * refetch }`. `data` is one row or `null` (never an array).
@@ -624,6 +640,37 @@ export function useDatastoreSchema(
624
640
  tableId: string | null | undefined,
625
641
  ): SchemaResult;
626
642
 
643
+ // REQ-RT-07 realtime subscription transport state.
644
+ export type DatastoreSubscriptionStatus =
645
+ | "connecting"
646
+ | "live"
647
+ | "reconnecting"
648
+ | "fallback";
649
+
650
+ export interface DatastoreSubscriptionHandlers {
651
+ onCreated?: (record: Record<string, unknown>) => void;
652
+ onUpdated?: (record: Record<string, unknown>) => void;
653
+ onDeleted?: (record: Record<string, unknown>) => void;
654
+ }
655
+
656
+ export interface DatastoreSubscriptionOptions {
657
+ fallbackAfterMs?: number;
658
+ }
659
+
660
+ /**
661
+ * REQ-RT-07: subscribe to a table's realtime change stream via
662
+ * `ctx.datastore.records(tableId).subscribe(...)`. Opens on mount, re-opens on
663
+ * `tableId` change, tears down on unmount. Returns `{ status }`; when status
664
+ * is `"fallback"` (no socket support, ACL-rejected, or connect timed out) run
665
+ * REST polling instead. Never throws — degrades to `{ status: "fallback" }` on
666
+ * a host whose datastore client predates realtime.
667
+ */
668
+ export function useDatastoreSubscription(
669
+ tableId: string | null | undefined,
670
+ handlers?: DatastoreSubscriptionHandlers,
671
+ options?: DatastoreSubscriptionOptions,
672
+ ): { status: DatastoreSubscriptionStatus };
673
+
627
674
  /**
628
675
  * Stateful file-asset resolver hook. Returns `{ url, file, loading, error,
629
676
  * refetch }`. The `url` is an absolute URL composed against the host's API
package/dist/index.js CHANGED
@@ -20,9 +20,11 @@ export {
20
20
  useUsers,
21
21
  useGroups,
22
22
  useRecordPermissions,
23
+ useDatastoreSubscription,
23
24
  useWidgetEvent,
24
25
  usePayments,
25
26
  useTheme,
27
+ useWidgetStyle,
26
28
  useI18n,
27
29
  useUser,
28
30
  useFill,
@@ -20,9 +20,11 @@ export {
20
20
  useUsers,
21
21
  useGroups,
22
22
  useRecordPermissions,
23
+ useDatastoreSubscription,
23
24
  useWidgetEvent,
24
25
  usePayments,
25
26
  useTheme,
27
+ useWidgetStyle,
26
28
  useI18n,
27
29
  useUser,
28
30
  useFill,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.23.0",
3
+ "version": "0.25.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",
@@ -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 src/__tests__/widget-translations.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__/hooks-subscription.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"