@colixsystems/widget-sdk 0.107.0 → 0.109.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
@@ -17,7 +17,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
17
17
 
18
18
  | Group | Hook (signature) | Returns | Reads / scope |
19
19
  | ----- | ---------------- | ------- | ------------- |
20
- | **CORE** | `useTheme()` | `{ colors, elevation, spacing, spacingScale, radii, typography, components, widgetStyles }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`). `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
20
+ | **CORE** | `useTheme()` | `{ colors, elevation, spacing, spacingScale, radii, typography, components, widgetStyles }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`) and `loader`, the spinner colour for your own loading state. `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
21
21
  | **CORE** | `useWorkspaceCurrency()` | `{ currency, formatMoney }` | `ctx.workspace.currency` — no scope. The currency this workspace charges its app users in, resolved at RENDER time. Render every price as `formatMoney(minorUnits)` and never write a currency symbol or code into a widget: the owner can change it after the widget ships, and a baked label then contradicts the charge. |
22
22
  | **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. |
23
23
  | **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
@@ -46,6 +46,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
46
46
  | **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
47
47
  | **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
48
48
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*` |
49
+ | **DATASTORE** | `useDatastoreSubscription(table, handlers, options?)` | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
49
50
  | **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
50
51
  | **DATASTORE** | `useCanWrite(tableId, options?)` | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "is this caller signed in AND permitted", reading the same table-ACL answer the write endpoint enforces. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
51
52
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
@@ -68,7 +69,20 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
68
69
 
69
70
  ## Status
70
71
 
71
- `v0.107.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**.
72
+ `v0.108.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**.
73
+
74
+ ### What's new in 0.108.0 (contract 1.82.0)
75
+
76
+ **`themeTokens.colors` gains `loader` — the surface's spinner colour (sc-6095).** A widget that drew its own loading state had no token for it, so it reached for a literal grey. On a dark theme that grey vanished into the background and the widget looked frozen rather than busy — the same bug the host's own loading holds had.
77
+
78
+ `loader` tracks `onSurfaceMuted`, which means it rides the existing REQ-THEME-SURFACE derivation: it flips light on a dark page background, and it re-derives for a container that paints its own dark fill, exactly like the text colours around it. So reading it is enough — there is nothing to branch on:
79
+
80
+ ```js
81
+ const t = useTheme();
82
+ if (loading) return <ActivityIndicator color={t.colors.loader} />;
83
+ ```
84
+
85
+ The workspace's *Design → Loading Indicator* panel can override it app-wide; unset, it stays derived. Additive — the full `colors` shape is now `{ primary, onPrimary, primarySoft, onPrimarySoft, primaryStrong, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, loader, danger, success, warning, info }`.
72
86
 
73
87
  ### What's new in 0.107.0 (contract 1.81.0)
74
88
 
@@ -388,7 +402,7 @@ Additive — one new hook, one new optional device capability, one new error cla
388
402
 
389
403
  - **The workspace theme now reaches the elements an app is built from.** Three things that used to be unreachable are now themeable: an element inside YOUR widget, the structural card container a page is made of, and any style field whose name the platform does not know. For a widget author the practical change is that **your `styleSchema` is the contract**: every field you declare becomes a knob the workspace owner can set once for the whole app, so declare the fields that describe your widget's appearance and give them clear `label`s and `ui.group`s — those labels are what the owner reads.
390
404
  - **A field name you invented is as reachable as a canonical one.** A theme may carry values keyed by your widget's manifest id and then by your own field names, so `panelFill` is adjustable app-wide exactly like `cardBackground`. Separately, the unambiguous card names (`cardBackground`, `cardBorderColor`, `cardRadius`, `cardPadding`, `cardGradient`) bind by NAME to any widget that declares them, so naming a genuine card surface canonically opts it into the workspace's Cards controls for free.
391
- - **`useTheme().colors` describes the surface your widget SITS ON, not the page.** A layout container that paints its own background re-derives the surface roles for everything inside it, so reading `colors.onSurface` for your text is readable whether your widget lands on the page, in a dark hero, or in a light card nested inside that hero. Nothing to opt into.
405
+ - **`useTheme().colors` describes the surface your widget SITS ON, not the page.** A layout container that paints its own background re-derives the surface roles for everything inside it, so reading `colors.onSurface` for your text is readable whether your widget lands on the page, in a dark hero, or in a light card nested inside that hero. `colors.loader` is that same surface's spinner colour — paint your loading state with it rather than a literal grey. Nothing to opt into.
392
406
  - **Precedence, unchanged in spirit.** Contract default → workspace palette → component scope → per-widget-type value → the app author's per-instance `props.style`. Most specific wins, and the Properties Panel is still the final word. Your widget reads `props.style` exactly as before and never learns which layer supplied a value.
393
407
  - **A colour may carry OPACITY.** `isHexColor` accepts the 8-digit `#RRGGBBAA` form alongside 3 and 6 digits, so a theme colour with an alpha reaches `useTheme()` with its transparency intact. It used to be rejected and the host dropped the key outright, which is why a translucent page background never reached the dark-surface derivation and every panel fell back to white.
394
408
  - **The colour maths ignores alpha, on purpose.** `hexChannels` reads the R/G/B pair and skips any alpha, so contrast, readable text and the derived accent tints reason about the opaque colour. None of them can composite without knowing the backdrop, which a token table does not have — so transparency lives in the VALUE your widget renders, not in the decision about whether that colour reads as light or dark.
package/dist/contract.cjs CHANGED
@@ -64,6 +64,11 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
64
64
  onSurface: "#111827",
65
65
  surfaceMuted: "#f8fafc",
66
66
  onSurfaceMuted: "#475569",
67
+ // sc-6095: the colour a loading spinner is painted with. A spinner is a
68
+ // muted foreground mark on the surface it sits on, so it tracks
69
+ // `onSurfaceMuted`, which deriveSurfaceTokens already flips for a dark
70
+ // background — so a dark theme gets a visible spinner with no new maths.
71
+ loader: "#475569",
67
72
  border: "#e2e8f0",
68
73
  danger: "#dc2626",
69
74
  success: "#059669",
@@ -457,12 +462,13 @@ const HOOKS = [
457
462
  signature: "useTheme()",
458
463
  returnShape: {
459
464
  colors:
460
- "{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info } — " +
465
+ "{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, loader, danger, success, warning, info } — " +
461
466
  "REQ-THEME-SURFACE: the surface group (surface / surfaceMuted / onSurface / " +
462
- "onSurfaceMuted / border) describes the surface your widget SITS ON, not the " +
467
+ "onSurfaceMuted / border / loader) describes the surface your widget SITS ON, not the " +
463
468
  "page: a container painting its own background re-derives them for its " +
464
469
  "subtree. Read them and your text is readable wherever the widget lands; " +
465
- "there is nothing to opt into.",
470
+ "there is nothing to opt into. `loader` is that surface's spinner colour " +
471
+ "(sc-6095) — paint your own loading state with it rather than a literal grey.",
466
472
  spacing: "{ xs, sm, md, lg, xl }",
467
473
  spacingScale:
468
474
  "number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
@@ -1463,7 +1469,15 @@ const HOOKS = [
1463
1469
  "{ status: 'fallback' } WITHOUT throwing so the widget can poll instead. " +
1464
1470
  "Reads the same datastore.read scope as useDatastoreQuery — declare " +
1465
1471
  "datastore.read for the table you subscribe to. Pair with " +
1466
- "useDatastoreQuery for the initial load and merge the streamed envelopes.",
1472
+ "useDatastoreQuery for the initial load and merge the streamed " +
1473
+ "envelopes. `options.scope` narrows the subscription: " +
1474
+ "{ kind: 'record', record_id } streams one record, and " +
1475
+ "{ kind: 'parent', relation_column, record_id } streams the rows whose " +
1476
+ "RELATION column points at that parent — the only way to subscribe to a " +
1477
+ "table governed by per-record grants, since a scope-less subscribe must " +
1478
+ "prove read-every-row. A `parent` scope requires the column be an " +
1479
+ "inheritAcl RELATION, else the subscribe resolves to " +
1480
+ "{ status: 'fallback' }.",
1467
1481
  returnShape: {
1468
1482
  status:
1469
1483
  "'connecting' | 'live' | 'reconnecting' | 'fallback' // transport state; 'fallback' → poll",
@@ -3326,7 +3340,29 @@ const CONTRACT = deepFreeze({
3326
3340
  // through the already-vetted react-native-svg and carries no native
3327
3341
  // module of its own, so it is host-shimmed on web and pinned in the
3328
3342
  // export like date-fns. No existing entry changed shape.
3329
- version: "1.81.0",
3343
+ //
3344
+ // 1.82.0: additive (sc-6095) — `themeTokens.colors` gains `loader`, the
3345
+ // colour a loading spinner is painted with, and `deriveSurfaceTokens`
3346
+ // returns it alongside the surface/text/border set. A spinner is a muted
3347
+ // foreground mark on the surface it sits on, so it tracks `onSurfaceMuted`
3348
+ // and inherits that set's dark-background flip — which is what makes a
3349
+ // dark theme's spinner visible instead of grey-on-grey.
3350
+ // `useTheme().colors.loader` is the value a widget reads for its own
3351
+ // loading state; both hosts paint their chrome spinners from the same key.
3352
+ //
3353
+ // 1.83.0: additive (sc-6270) — `useDatastoreSubscription` gains
3354
+ // `options.scope`, narrowing a subscription from the whole table to one
3355
+ // record (`{ kind: "record", record_id }`) or to a parent's inheriting
3356
+ // children (`{ kind: "parent", relation_column, record_id }`). Without it
3357
+ // a table whose rows are governed by PER-RECORD grants could not stream
3358
+ // at all: the plane fans one envelope to every subscriber, so a
3359
+ // scope-less subscribe must prove read-every-row (sc-4311) and a channel
3360
+ // member never can. A scoped subscribe is gated on the per-record read
3361
+ // check instead — the same predicate REST applies per row — and `parent`
3362
+ // additionally requires an `inheritAcl` RELATION column, which is what
3363
+ // makes the stream a strict subset of what `list()` already returns.
3364
+ // Backed by datastore-client 0.15.0. Omitting `scope` is unchanged.
3365
+ version: "1.83.0",
3330
3366
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3331
3367
  hooks: HOOKS,
3332
3368
  primitives: PRIMITIVES,
@@ -3511,7 +3547,7 @@ const DARK_SURFACE_TEXT = Object.freeze({
3511
3547
  * page, which is in turn what lets a built app follow its theme instead of
3512
3548
  * carrying a baked-in hex for every heading.
3513
3549
  *
3514
- * Returns all five keys together, never a subset: layering a dark card's text
3550
+ * Returns all six keys together, never a subset: layering a dark card's text
3515
3551
  * colour over a light ancestor's surface is exactly how light-on-light happens.
3516
3552
  * `null` only when the colour is unusable, letting the caller keep what it had.
3517
3553
  */
@@ -3528,6 +3564,7 @@ function deriveSurfaceTokens(backgroundColor) {
3528
3564
  surfaceMuted: base.surfaceMuted,
3529
3565
  onSurface: base.onSurface,
3530
3566
  onSurfaceMuted: base.onSurfaceMuted,
3567
+ loader: base.loader,
3531
3568
  border: base.border,
3532
3569
  };
3533
3570
  }
@@ -3536,6 +3573,7 @@ function deriveSurfaceTokens(backgroundColor) {
3536
3573
  surfaceMuted: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.surfaceMuted),
3537
3574
  onSurface: DARK_SURFACE_TEXT.onSurface,
3538
3575
  onSurfaceMuted: DARK_SURFACE_TEXT.onSurfaceMuted,
3576
+ loader: DARK_SURFACE_TEXT.onSurfaceMuted,
3539
3577
  border: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.border),
3540
3578
  };
3541
3579
  }
package/dist/contract.js CHANGED
@@ -64,6 +64,11 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
64
64
  onSurface: "#111827",
65
65
  surfaceMuted: "#f8fafc",
66
66
  onSurfaceMuted: "#475569",
67
+ // sc-6095: the colour a loading spinner is painted with. A spinner is a
68
+ // muted foreground mark on the surface it sits on, so it tracks
69
+ // `onSurfaceMuted`, which deriveSurfaceTokens already flips for a dark
70
+ // background — so a dark theme gets a visible spinner with no new maths.
71
+ loader: "#475569",
67
72
  border: "#e2e8f0",
68
73
  danger: "#dc2626",
69
74
  success: "#059669",
@@ -457,12 +462,13 @@ const HOOKS = [
457
462
  signature: "useTheme()",
458
463
  returnShape: {
459
464
  colors:
460
- "{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info } — " +
465
+ "{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, loader, danger, success, warning, info } — " +
461
466
  "REQ-THEME-SURFACE: the surface group (surface / surfaceMuted / onSurface / " +
462
- "onSurfaceMuted / border) describes the surface your widget SITS ON, not the " +
467
+ "onSurfaceMuted / border / loader) describes the surface your widget SITS ON, not the " +
463
468
  "page: a container painting its own background re-derives them for its " +
464
469
  "subtree. Read them and your text is readable wherever the widget lands; " +
465
- "there is nothing to opt into.",
470
+ "there is nothing to opt into. `loader` is that surface's spinner colour " +
471
+ "(sc-6095) — paint your own loading state with it rather than a literal grey.",
466
472
  spacing: "{ xs, sm, md, lg, xl }",
467
473
  spacingScale:
468
474
  "number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
@@ -1463,7 +1469,15 @@ const HOOKS = [
1463
1469
  "{ status: 'fallback' } WITHOUT throwing so the widget can poll instead. " +
1464
1470
  "Reads the same datastore.read scope as useDatastoreQuery — declare " +
1465
1471
  "datastore.read for the table you subscribe to. Pair with " +
1466
- "useDatastoreQuery for the initial load and merge the streamed envelopes.",
1472
+ "useDatastoreQuery for the initial load and merge the streamed " +
1473
+ "envelopes. `options.scope` narrows the subscription: " +
1474
+ "{ kind: 'record', record_id } streams one record, and " +
1475
+ "{ kind: 'parent', relation_column, record_id } streams the rows whose " +
1476
+ "RELATION column points at that parent — the only way to subscribe to a " +
1477
+ "table governed by per-record grants, since a scope-less subscribe must " +
1478
+ "prove read-every-row. A `parent` scope requires the column be an " +
1479
+ "inheritAcl RELATION, else the subscribe resolves to " +
1480
+ "{ status: 'fallback' }.",
1467
1481
  returnShape: {
1468
1482
  status:
1469
1483
  "'connecting' | 'live' | 'reconnecting' | 'fallback' // transport state; 'fallback' → poll",
@@ -3326,7 +3340,29 @@ const CONTRACT = deepFreeze({
3326
3340
  // through the already-vetted react-native-svg and carries no native
3327
3341
  // module of its own, so it is host-shimmed on web and pinned in the
3328
3342
  // export like date-fns. No existing entry changed shape.
3329
- version: "1.81.0",
3343
+ //
3344
+ // 1.82.0: additive (sc-6095) — `themeTokens.colors` gains `loader`, the
3345
+ // colour a loading spinner is painted with, and `deriveSurfaceTokens`
3346
+ // returns it alongside the surface/text/border set. A spinner is a muted
3347
+ // foreground mark on the surface it sits on, so it tracks `onSurfaceMuted`
3348
+ // and inherits that set's dark-background flip — which is what makes a
3349
+ // dark theme's spinner visible instead of grey-on-grey.
3350
+ // `useTheme().colors.loader` is the value a widget reads for its own
3351
+ // loading state; both hosts paint their chrome spinners from the same key.
3352
+ //
3353
+ // 1.83.0: additive (sc-6270) — `useDatastoreSubscription` gains
3354
+ // `options.scope`, narrowing a subscription from the whole table to one
3355
+ // record (`{ kind: "record", record_id }`) or to a parent's inheriting
3356
+ // children (`{ kind: "parent", relation_column, record_id }`). Without it
3357
+ // a table whose rows are governed by PER-RECORD grants could not stream
3358
+ // at all: the plane fans one envelope to every subscriber, so a
3359
+ // scope-less subscribe must prove read-every-row (sc-4311) and a channel
3360
+ // member never can. A scoped subscribe is gated on the per-record read
3361
+ // check instead — the same predicate REST applies per row — and `parent`
3362
+ // additionally requires an `inheritAcl` RELATION column, which is what
3363
+ // makes the stream a strict subset of what `list()` already returns.
3364
+ // Backed by datastore-client 0.15.0. Omitting `scope` is unchanged.
3365
+ version: "1.83.0",
3330
3366
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3331
3367
  hooks: HOOKS,
3332
3368
  primitives: PRIMITIVES,
@@ -3511,7 +3547,7 @@ const DARK_SURFACE_TEXT = Object.freeze({
3511
3547
  * page, which is in turn what lets a built app follow its theme instead of
3512
3548
  * carrying a baked-in hex for every heading.
3513
3549
  *
3514
- * Returns all five keys together, never a subset: layering a dark card's text
3550
+ * Returns all six keys together, never a subset: layering a dark card's text
3515
3551
  * colour over a light ancestor's surface is exactly how light-on-light happens.
3516
3552
  * `null` only when the colour is unusable, letting the caller keep what it had.
3517
3553
  */
@@ -3528,6 +3564,7 @@ function deriveSurfaceTokens(backgroundColor) {
3528
3564
  surfaceMuted: base.surfaceMuted,
3529
3565
  onSurface: base.onSurface,
3530
3566
  onSurfaceMuted: base.onSurfaceMuted,
3567
+ loader: base.loader,
3531
3568
  border: base.border,
3532
3569
  };
3533
3570
  }
@@ -3536,6 +3573,7 @@ function deriveSurfaceTokens(backgroundColor) {
3536
3573
  surfaceMuted: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.surfaceMuted),
3537
3574
  onSurface: DARK_SURFACE_TEXT.onSurface,
3538
3575
  onSurfaceMuted: DARK_SURFACE_TEXT.onSurfaceMuted,
3576
+ loader: DARK_SURFACE_TEXT.onSurfaceMuted,
3539
3577
  border: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.border),
3540
3578
  };
3541
3579
  }
package/dist/hooks.js CHANGED
@@ -2482,7 +2482,9 @@ export function useCanWrite(tableId, options) {
2482
2482
  *
2483
2483
  * @param {string} table Bound table id (falsy → no subscription, status "fallback").
2484
2484
  * @param {{ onCreated?, onUpdated?, onDeleted? }} [handlers] Per-event callbacks; each receives the snake_case record.
2485
- * @param {{ fallbackAfterMs?: number }} [options]
2485
+ * @param {{ fallbackAfterMs?: number, scope?: { kind: "record"|"parent", relation_column?: string, record_id: string } }} [options]
2486
+ * `scope` narrows the stream below whole-table — required for a table
2487
+ * governed by per-record grants. Re-subscribes on its VALUES, not identity.
2486
2488
  * @returns {{ status: "connecting" | "live" | "reconnecting" | "fallback" }}
2487
2489
  */
2488
2490
  export function useDatastoreSubscription(table, handlers, options) {
@@ -2510,7 +2512,23 @@ export function useDatastoreSubscription(table, handlers, options) {
2510
2512
  ? options.fallbackAfterMs
2511
2513
  : undefined;
2512
2514
 
2515
+ // sc-6270: a scope narrows the subscription to one record or to a parent's
2516
+ // inheriting children. Authors pass a fresh object literal every render, so
2517
+ // the effect keys on the scope's VALUES — depending on its identity would
2518
+ // tear down and reopen the socket on each render.
2519
+ const scope = options && options.scope ? options.scope : null;
2520
+ const scopeKey = scope
2521
+ ? [scope.kind, scope.relation_column, scope.record_id]
2522
+ .map((part) => part || "")
2523
+ .join("|")
2524
+ : "";
2525
+ const scopeRef = useRef(scope);
2526
+ scopeRef.current = scope;
2527
+
2513
2528
  useEffect(() => {
2529
+ // Re-scoping starts a new connection, so a stale "live" from the previous
2530
+ // scope must not mask the gap while it opens.
2531
+ setStatus("connecting");
2514
2532
  if (!table || typeof recordsRef.current !== "function") {
2515
2533
  setStatus("fallback");
2516
2534
  return undefined;
@@ -2527,6 +2545,9 @@ export function useDatastoreSubscription(table, handlers, options) {
2527
2545
  setStatus("fallback");
2528
2546
  return undefined;
2529
2547
  }
2548
+ const subscribeOptions = {};
2549
+ if (fallbackAfterMs != null) subscribeOptions.fallbackAfterMs = fallbackAfterMs;
2550
+ if (scopeRef.current) subscribeOptions.scope = scopeRef.current;
2530
2551
  const stop = ns.subscribe(
2531
2552
  {
2532
2553
  onCreated: (r) => handlersRef.current?.onCreated?.(r),
@@ -2534,13 +2555,13 @@ export function useDatastoreSubscription(table, handlers, options) {
2534
2555
  onDeleted: (r) => handlersRef.current?.onDeleted?.(r),
2535
2556
  onStatus: (s) => setStatus(s),
2536
2557
  },
2537
- fallbackAfterMs != null ? { fallbackAfterMs } : undefined,
2558
+ Object.keys(subscribeOptions).length > 0 ? subscribeOptions : undefined,
2538
2559
  );
2539
2560
  return () => {
2540
2561
  if (typeof stop === "function") stop();
2541
2562
  };
2542
2563
  // eslint-disable-next-line react-hooks/exhaustive-deps
2543
- }, [table, fallbackAfterMs]);
2564
+ }, [table, fallbackAfterMs, scopeKey]);
2544
2565
 
2545
2566
  return { status };
2546
2567
  }
package/dist/index.d.ts CHANGED
@@ -1137,8 +1137,16 @@ export interface DatastoreSubscriptionHandlers {
1137
1137
  onDeleted?: (record: Record<string, unknown>) => void;
1138
1138
  }
1139
1139
 
1140
+ // sc-6270: see SubscriptionScope in @colixsystems/datastore-client. A scoped
1141
+ // subscribe is gated on the per-record read check instead of read-every-row,
1142
+ // which is what lets a per-record-ACL table stream at all.
1143
+ export type DatastoreSubscriptionScope =
1144
+ | { kind: "record"; record_id: string }
1145
+ | { kind: "parent"; relation_column: string; record_id: string };
1146
+
1140
1147
  export interface DatastoreSubscriptionOptions {
1141
1148
  fallbackAfterMs?: number;
1149
+ scope?: DatastoreSubscriptionScope;
1142
1150
  }
1143
1151
 
1144
1152
  /**
@@ -1148,6 +1156,10 @@ export interface DatastoreSubscriptionOptions {
1148
1156
  * is `"fallback"` (no socket support, ACL-rejected, or connect timed out) run
1149
1157
  * REST polling instead. Never throws — degrades to `{ status: "fallback" }` on
1150
1158
  * a host whose datastore client predates realtime.
1159
+ *
1160
+ * `options.scope` narrows the stream to one record or to a parent's
1161
+ * inheriting children; re-subscribes when the scope's values change, not on
1162
+ * every render.
1151
1163
  */
1152
1164
  export function useDatastoreSubscription(
1153
1165
  tableId: string | null | undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.107.0",
3
+ "version": "0.109.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",