@colixsystems/widget-sdk 0.97.0 → 0.99.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
@@ -6,7 +6,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
6
6
 
7
7
  | Injected at | Package | Surface (snake_case verbatim, `list` → `{ data, meta }`) |
8
8
  | ----------- | ------- | -------- |
9
- | `ctx.datastore` | `@colixsystems/datastore-client` | `tables.{list,get}`, `schema(tableId)`, `records(tableId).{ list(query), get(id), create(values), update(id,values) [PATCH], delete(id), aggregate(spec), permissions(recordId).{ list, grant, update, revoke } }` |
9
+ | `ctx.datastore` | `@colixsystems/datastore-client` | `tables.{list,get}`, `schema(tableId)`, `myPermissions(tableId, { recordId? })`, `records(tableId).{ list(query), get(id), create(values), update(id,values) [PATCH], delete(id), aggregate(spec), permissions(recordId).{ list, grant, update, revoke } }` |
10
10
  | `ctx.directory` | `@colixsystems/directory-client` | `me()`, `users.{list,get,invite,deactivate,reactivate}`, `groups.{list,create,remove,addMember,removeMember,listMine}`, `invites.{list,revoke,resend}` |
11
11
  | `ctx.assets` | `@colixsystems/assets-client` | the Asset Manager: `get(id)`, `list(query)`, `upload(formData)` over `/files` — what `useAsset()` (single asset by id) and `useAssetsByTag()` (every asset carrying a tag) resolve |
12
12
  | `ctx.payments` | `@colixsystems/payments-client` | `requestPayment(body)`, `getPayment(id)` |
@@ -38,12 +38,15 @@ The data layer lives in **four separate domain-client packages**, each instantia
38
38
  | **CORE** | `useSpeechToText(options?)` | `{ transcript, partial, listening, supported, error, start, stop, abort, reset }` | `ctx.device.speech` — no scope. Dictation with the device's **on-device** recogniser: no audio is uploaded and no AI credit is spent. Capture is IMPERATIVE: call `start()` from a user gesture (a tap), never on mount. `transcript` accumulates finalised speech, `partial` holds the uncommitted guess (needs `options.interimResults`); `stop()` keeps it, `abort()` discards it. Rejects with `SpeechToTextError` (`.code` in `PERMISSION_DENIED \| NO_SPEECH \| LANGUAGE_UNSUPPORTED \| NETWORK \| ABORTED \| UNSUPPORTED \| INTERNAL`). **Gate your mic button on `supported`** — Firefox ships no `SpeechRecognition`. Identical on web (`SpeechRecognition`) and the Expo export (`expo-speech-recognition`). |
39
39
  | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
40
40
  | **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
41
+ | **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
41
42
  | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
42
43
  | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
43
44
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
45
+ | **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 }`. |
44
46
  | **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. |
45
47
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*` |
46
48
  | **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) |
49
+ | **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. |
47
50
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
48
51
  | **FILES** | `useAssetsByTag(tag, { type? })` | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
49
52
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
@@ -64,10 +67,50 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
64
67
 
65
68
  ## Status
66
69
 
67
- `v0.91.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**.
70
+ `v0.95.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**.
68
71
 
69
- ### What's new in 0.97.0 (contract 1.69.0)
72
+ ### What's new in 0.95.0 (contract 1.68.0)
73
+
74
+ **One number for the quick bar's cap — `quickBarMaxItems` + `quickBarCap`.** The sidebar's secondary mobile quick bar caps at five, and that five lived in two places: a named constant in the web chrome and a bare literal in the compiler. Nothing stopped them drifting, and no Studio surface could state the number at all.
75
+
76
+ `CONTRACT.themeMenuTypes.*.quickBarMaxItems` now carries it (5 on `sidebar`, `null` on the two shapes that draw no quick bar), read through the new `quickBarCap(menuType)` host export.
77
+
78
+ The distinction from `menuItemCap` is the point and is worth keeping straight: **`quickBarCap` may drop a page** — the rail and the drawer still list every menu page, so the bar is a shortcut. **`menuItemCap` may not** — where the chrome IS the menu (`bottom-tabs`), the surplus moves behind a More sheet instead.
79
+
80
+ Host-integration surface only. `CONTRACT.version` → `1.68.0`.
81
+
82
+ ### What's new in 0.94.0 (contract 1.67.0)
83
+
84
+ **The footer strip is themeable — `resolveFooterTokens` (REQ-NAV-STRUCTURE).** The bottom strip's surface was hard-coded white on both hosts and its items read the SIDEBAR's tokens. That is fine while the strip is the sidebar's secondary quick bar, and untenable once it IS the menu: the `bottom-tabs` shape hides the sidebar panel, so those tokens have nowhere to be set.
85
+
86
+ A `theme_config.footer` block now carries `backgroundColor`, `textColor`, `activeColor` and the opt-in divider `borderColor` + `borderWidth`. **Every field falls back to the sidebar's**, so a workspace that never touches it renders exactly as before and only an explicit value moves anything.
87
+
88
+ `resolveFooterTokens(theme)` (from `@colixsystems/widget-sdk/host`) is the one resolver both hosts read it with. It also settles two divergences the strip carried: the native bar ruled a permanent `#e2e8f0` hairline the theme could not reach — REQ-THEME-LOOK's rule is that the divider's **colour** is its switch — and it tinted the active tab's *label* where the web painted a filled pill, so `activeStyle: "filled"` meant two different things per host.
89
+
90
+ Host-integration surface only: no author-facing hook, prop, primitive, or manifest field changed. `CONTRACT.version` → `1.67.0`.
91
+
92
+ ### What's new in 0.93.0 (contract 1.66.0)
93
+
94
+ **An app picks the SHAPE its navigation takes — `CONTRACT.themeMenuTypes` (REQ-NAV-STRUCTURE).** Until now the chrome was always a sidebar: a persistent left rail on desktop, a hamburger drawer plus an optional bottom quick bar on mobile. That is the right default for an admin tool and the wrong one for a phone-first app or a site, and there was no way to say so. `CONTRACT.themeMenuTypes` publishes the closed catalogue — `sidebar`, `top-bar`, `bottom-tabs` — each entry carrying `{ name, summary, maxItems }`.
95
+
96
+ `maxItems` caps how many menu pages the chrome draws at once, and its meaning differs per type deliberately: the sidebar's mobile quick bar is a **secondary** curated bar whose cap may drop a page (the rail still lists every one), while a `bottom-tabs` strip **is** the menu, so its cap must never drop one — the surplus moves behind a More sheet instead.
97
+
98
+ **New host exports (`@colixsystems/widget-sdk/host`)** — `normaliseNavigation(navigation)` resolves a stored `theme_config.navigation` block to the `{ menuType }` a host switches its chrome on, and `menuItemCap(menuType)` states that shape's cap. One implementation for both hosts, so a menu type cannot mean one thing in the web Player and another in the exported Expo app. An absent or unknown value resolves to `sidebar`, so every app authored before menu types existed renders and compiles byte-identically.
99
+
100
+ Host-integration surface only: no author-facing hook, prop, primitive, or manifest field changed. `CONTRACT.version` → `1.66.0`.
101
+
102
+ ### What's new in 0.98.0 (contract 1.70.0)
103
+
104
+ **Three new hooks close the biggest gaps in the write-gating and query-authoring surface (sc-5206).**
70
105
 
106
+ - **`useBoundColumns(tableId, shape, props)`** — resolve author-bound column NAMES from a widget's own props, built on `useDatastoreSchema`. Falls back name → case-insensitive name → first unclaimed column matching `dataType`, so `record[props.titleField]` reading `undefined` after a tenant renames a column is no longer a widget's problem: read `record[bound.titleField]` (via `const { columns: bound } = useBoundColumns(tableId, shape, props)`) and it keeps resolving.
107
+ - **`useStableQuery(buildQuery)`** — the same content-diffed stable-reference trick `useDatastoreQuery` already applies to its own `query` argument, generalised into a reusable hook: `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` and its easy-to-get-wrong deps array. Reads no `ctx` — safe outside a `WidgetContextProvider`.
108
+ - **`useCanWrite(tableId, options?)`** — a write-permission FLOOR reading a new `ctx.datastore.myPermissions` client method. `{ canWrite, loading, error, refetch }`; pass `{ recordId }` for a per-row check. Not a replacement for a MORE SPECIFIC domain rule (still hand-check "only the assigned user" in addition), and not a replacement for `useUser()` when the UI needs to tell "not signed in" apart from "signed in but forbidden".
109
+
110
+ The SDK linter gained two matching soft-warning rules: `raw-useMemo-into-datastore-query` (a `useDatastoreQuery` argument built from a raw `useMemo` when the file hasn't reached for `useStableQuery`) and `hand-rolled-write-gate` (a write gated on `useUser().groupIds`/`.roles` when the file hasn't reached for `useCanWrite`). Both are steering nudges (`severity: "warning"`), never publish-blocking.
111
+
112
+ - **`CONTRACT.version` → `1.70.0`** (additive: three new hooks + the new `datastore.myPermissions` context field + two linter rules). No existing export changed signature.
113
+ ### What's new in 0.97.0 (contract 1.69.0)
71
114
  **`CONTRACT.themeComponents` gains five scopes: `accent`, `destructive`, `muted`, `popover`, `ring` (sc-5392).** The vocabulary shipped with exactly `button`/`card`/`text` (sc-1497), so a shadcn/Tailwind app import's `--accent`, `--destructive`, `--muted`, `--popover` and `--ring` custom properties had no `themeConfig` home and were reported "no theme home" on every import. Each new scope binds to real `styleSchema` fields on the built-ins that already had a matching surface — `accent` (a highlight/tag surface: `background`/`borderColor`/`radius`) to the Label widget's own fields, `destructive` (a themed danger/delete action: `background`/`textColor`/`borderColor`) to a new "Danger" Button variant, `muted` (a subtle/secondary surface: `background`/`textColor`/`borderColor`/`radius`) to Form Input's and Form Builder's pre-existing input fields, `popover` (a dropdown/menu surface: `background`/`textColor`/`borderColor`) to the same two widgets' choice-field option list, and `ring` (the app-wide focus-visible outline: `color`/`width`) to a new emphasis border on Button. **This is HOST-ONLY plumbing, exactly like the three scopes before it** — `useTheme()`'s documented `components` slice is unchanged, no widget-authoring hook or `propertySchema` type moved, and no scope declares `universalFields`, so a third-party or AI-generated widget's contract is unaffected; the Developer guide and `DEFAULT_SYSTEM_PROMPT` need no update because neither ever documented this internal vocabulary. Fully additive: a theme with no `components` key, or one using only `button`/`card`/`text`, resolves exactly as before.
72
115
  - **`CONTRACT.version` → `1.69.0`** (additive: five new `themeComponents` scopes + their target-field bindings). No existing scope, token, or export changed shape.
73
116
 
@@ -1002,6 +1045,60 @@ The manifest declares the matching scope:
1002
1045
 
1003
1046
  The server-side gate is `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. A caller without `canGrant` receives `PermissionError { code: "FORBIDDEN" }`. The hook collapses to a stable no-op when `tableId` or `recordId` is null/empty — so a widget can render its picker first, then bind to the picked record without conditional hook tricks.
1004
1047
 
1048
+ ## Resolving bound columns, a stable query, and a write-permission floor
1049
+
1050
+ `useBoundColumns`, `useStableQuery`, and `useCanWrite` (sc-5206) target the three most common ways a widget goes wrong against a table it doesn't fully control: a renamed column reading `undefined`, a query argument that refetches in a loop, and a write control offered to someone the table forbids.
1051
+
1052
+ ```js
1053
+ import {
1054
+ Text,
1055
+ View,
1056
+ Pressable,
1057
+ useBoundColumns,
1058
+ useStableQuery,
1059
+ useDatastoreQuery,
1060
+ useCanWrite,
1061
+ useUser,
1062
+ } from "@colixsystems/widget-sdk";
1063
+
1064
+ export default function OpenTasks({ tableId, titleField, statusField }) {
1065
+ // Resolves to the CURRENT column names even if the author's binding is
1066
+ // stale (a column renamed after install), instead of reading `undefined`.
1067
+ const { columns: bound, loading: schemaLoading } = useBoundColumns(
1068
+ tableId,
1069
+ { titleField: { dataType: "STRING" }, statusField: { dataType: "STRING" } },
1070
+ { titleField, statusField },
1071
+ );
1072
+
1073
+ // A binding is undefined until the schema resolves, so hold the query back
1074
+ // rather than filtering on an undefined column name.
1075
+ const ready = Boolean(bound.statusField);
1076
+ // A stable query argument with no useMemo deps array to get wrong.
1077
+ const query = useStableQuery(() => ({
1078
+ filter: ready ? { [bound.statusField]: "eq:open" } : {},
1079
+ sort: { field: "created_at", dir: "desc" },
1080
+ }));
1081
+ const { data, loading } = useDatastoreQuery(ready ? tableId : null, query);
1082
+
1083
+ const user = useUser();
1084
+ const { canWrite } = useCanWrite(tableId);
1085
+
1086
+ if (schemaLoading || loading) return <Text>Loading…</Text>;
1087
+ return (
1088
+ <View>
1089
+ {data.map((row) => <Text key={row.id}>{row[bound.titleField]}</Text>)}
1090
+ {!user.id ? (
1091
+ <Text>Sign in to add a task</Text>
1092
+ ) : canWrite ? (
1093
+ <Pressable onPress={() => {/* … */}}><Text>Add task</Text></Pressable>
1094
+ ) : null /* signed in but not permitted — no control, not a disabled one */}
1095
+ </View>
1096
+ );
1097
+ }
1098
+ ```
1099
+
1100
+ `useCanWrite` answers "is this table's ACL open to me" — it is a FLOOR, not the whole rule. A widget whose own logic is more specific ("only the assignee may edit this row") still hand-checks that in addition, typically with `options.recordId` passed to a second `useCanWrite` call or a plain `user.id === record[assigneeField]` comparison.
1101
+
1005
1102
  ## Cross-platform widgets (single-file vs split)
1006
1103
 
1007
1104
  Every widget runs in **both** the web Player and the exported native (Expo) app.
package/dist/contract.cjs CHANGED
@@ -159,6 +159,42 @@ const THEME_WIDGET_STYLES = Object.freeze({
159
159
  // Matches the styleSchema field cap the widget agent is held to.
160
160
  maxFieldsPerWidget: 12,
161
161
  });
162
+ // REQ-NAV-STRUCTURE: the SHAPE an app's navigation takes. One catalogue, four
163
+ // consumers -- the Studio's Navigation page, Mason's set_theme coercion, the web
164
+ // PlayerChrome and the compiler's navigator -- so a type cannot be offered to an
165
+ // author without every host actually drawing it.
166
+ //
167
+ // `maxItems` caps how many menu pages the chrome draws at once, and its meaning
168
+ // differs per type deliberately. The sidebar's mobile quick bar is a SECONDARY
169
+ // curated bar, so its own cap may drop a page -- the rail still lists every one.
170
+ // A bottom-tabs strip IS the menu, so its cap must never drop one: the surplus
171
+ // moves behind a "More" sheet instead.
172
+ const THEME_MENU_TYPES = Object.freeze({
173
+ sidebar: Object.freeze({
174
+ name: "Sidebar",
175
+ summary:
176
+ "A persistent left rail on desktop; a hamburger drawer plus the optional bottom quick bar on mobile.",
177
+ maxItems: null,
178
+ // The SECONDARY mobile quick bar's cap. It may drop a page past it, because
179
+ // the rail and the drawer still list every one -- the opposite of
180
+ // `bottom-tabs`' `maxItems`, where the strip IS the menu.
181
+ quickBarMaxItems: 5,
182
+ }),
183
+ "top-bar": Object.freeze({
184
+ name: "Top bar",
185
+ summary:
186
+ "A horizontal row of links in the app header, scrolling sideways when it runs out of room. No rail at any width.",
187
+ maxItems: null,
188
+ quickBarMaxItems: null,
189
+ }),
190
+ "bottom-tabs": Object.freeze({
191
+ name: "Bottom tabs",
192
+ summary:
193
+ "A sticky bottom strip at every width -- the phone-native shape. Pages past the cap move behind a More sheet.",
194
+ maxItems: 4,
195
+ quickBarMaxItems: null,
196
+ }),
197
+ });
162
198
  const THEME_SPACING_SCALE = Object.freeze({
163
199
  min: 0.5,
164
200
  max: 2,
@@ -474,6 +510,23 @@ const HOOKS = [
474
510
  requiredContextSlice: ["i18n.locale"],
475
511
  scopes: null,
476
512
  },
513
+ {
514
+ name: "useStableQuery",
515
+ signature: "useStableQuery(buildQuery)",
516
+ description:
517
+ "sc-5206 — keep whatever buildQuery() returns at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so it can " +
518
+ "be passed straight into useDatastoreQuery's second argument (useDatastoreQuery(tableId, useStableQuery(() => ({...})))) instead of a hand-rolled " +
519
+ "useMemo with an easy-to-get-wrong deps array. This generalises the same trick useDatastoreQuery already applies internally to its own query " +
520
+ "argument. buildQuery is a ZERO-ARG function called every render; pure React state — no ctx, no host required, safe outside a " +
521
+ "WidgetContextProvider. Never throws: a buildQuery that itself throws degrades to a stable undefined, and a result JSON.stringify can't diff " +
522
+ "(e.g. a circular structure) degrades to \"always a new reference\".",
523
+ returnShape: {
524
+ "(returns)":
525
+ "T | undefined // the hook returns buildQuery()'s result directly, not a wrapper object",
526
+ },
527
+ requiredContextSlice: [],
528
+ scopes: null,
529
+ },
477
530
  {
478
531
  name: "useUser",
479
532
  signature: "useUser()",
@@ -924,6 +977,29 @@ const HOOKS = [
924
977
  requiredContextSlice: ["datastore.schema"],
925
978
  scopes: ["datastore.read:<table>"],
926
979
  },
980
+ {
981
+ name: "useBoundColumns",
982
+ signature: "useBoundColumns(tableId, shape, props)",
983
+ description:
984
+ "sc-5206 — resolve author-bound column NAMES from a widget's own props, built on useDatastoreSchema. shape is " +
985
+ "{ [propKey]: { dataType?, optional? } }; props is the widget's own props carrying author-bound column names (e.g. props.titleField === " +
986
+ "'Title'). Resolution order per key: (1) an exact name match on props[key], (2) a case-insensitive name match, (3) the first column whose " +
987
+ "data_type matches shape[key].dataType that no EARLIER key in this call already claimed, (4) unresolved. Unresolved NEVER throws — the key " +
988
+ "reads undefined, so a column an author renamed after install still resolves instead of crashing the widget. Returns { columns, resolved, " +
989
+ "missing, loading, error }: columns holds the resolved column NAME — a drop-in replacement for record[props.titleField] -> " +
990
+ "record[columns.titleField]; resolved holds the full Column object (snake_case verbatim, as useDatastoreSchema returns it) so a caller can " +
991
+ "inspect resolved.<key>.data_type; missing lists every key whose spec is not optional:true and did not resolve. Falsy tableId collapses to " +
992
+ "{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null } without a network round-trip.",
993
+ returnShape: {
994
+ columns: "{ [propKey]: string | undefined }",
995
+ resolved: "{ [propKey]: Column | undefined }",
996
+ missing: "string[] // required (non-optional) keys that did not resolve",
997
+ loading: "boolean",
998
+ error: "DatastoreError | null",
999
+ },
1000
+ requiredContextSlice: ["datastore.schema"],
1001
+ scopes: ["datastore.read:<table>"],
1002
+ },
927
1003
  {
928
1004
  name: "useInterpretDraft",
929
1005
  signature: "useInterpretDraft(tableId)",
@@ -1295,6 +1371,28 @@ const HOOKS = [
1295
1371
  requiredContextSlice: ["datastore.records"],
1296
1372
  scopes: ["acl.write:records"],
1297
1373
  },
1374
+ // sc-5206 — table/record write-permission floor, reading the injected
1375
+ // ctx.datastore.myPermissions.
1376
+ {
1377
+ name: "useCanWrite",
1378
+ signature: "useCanWrite(tableId, options?)",
1379
+ description:
1380
+ "sc-5206 — is the signed-in caller permitted to write to tableId (or, with options.recordId, to that one row)? Returns { canWrite, loading, " +
1381
+ "error, refetch }, reading the injected ctx.datastore.myPermissions(tableId, { recordId }). This is a FLOOR, not a full replacement for " +
1382
+ "domain-specific write rules: a widget whose rule is more specific than the table ACL (e.g. 'only the assigned user may edit this row') must " +
1383
+ "still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
1384
+ "NOT distinguish 'not signed in' from 'signed in but forbidden' (both resolve canWrite:false); pair it with useUser() when the UI needs to " +
1385
+ "tell those apart. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
1386
+ "{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
1387
+ returnShape: {
1388
+ canWrite: "boolean",
1389
+ loading: "boolean",
1390
+ error: "DatastoreError | null",
1391
+ refetch: "() => Promise<void>",
1392
+ },
1393
+ requiredContextSlice: ["datastore.myPermissions"],
1394
+ scopes: ["datastore.read:<table>"],
1395
+ },
1298
1396
  // REQ-RT-07 — realtime table subscription.
1299
1397
  {
1300
1398
  name: "useDatastoreSubscription",
@@ -1826,13 +1924,19 @@ const WIDGET_CONTEXT_SHAPE = {
1826
1924
  datastore: {
1827
1925
  description:
1828
1926
  "Injected @colixsystems/datastore-client instance. " +
1829
- "{ tables: { list(), get(idOrName), interpret(idOrName, body) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
1927
+ "{ tables: { list(), get(idOrName), interpret(idOrName, body), myPermissions(idOrName, { recordId? }) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
1830
1928
  "records(tableId) -> { list(query) -> Promise<{ data, meta }>, get(id), create(values), update(id, values), delete(id), aggregate(spec), " +
1831
1929
  "permissions(recordId) -> { list() -> Promise<{ data, meta }>, grant(body), update(permId, patch), revoke(permId) } } }. " +
1832
- "`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing). " +
1930
+ "`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema() and useBoundColumns(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing); `myPermissions` backs useCanWrite() (sc-5206 — the caller's effective table/record write permission). " +
1833
1931
  "List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case (author column values keep their author-given names).",
1834
1932
  required: true,
1835
- fields: { records: "function", schema: "function", tables: "object", interpret: "function" },
1933
+ fields: {
1934
+ records: "function",
1935
+ schema: "function",
1936
+ tables: "object",
1937
+ interpret: "function",
1938
+ myPermissions: "function",
1939
+ },
1836
1940
  },
1837
1941
  directory: {
1838
1942
  description:
@@ -3065,7 +3169,37 @@ const CONTRACT = deepFreeze({
3065
3169
  // shadcn/Tailwind import's matching custom properties have a themeConfig
3066
3170
  // home instead of being reported lost. Host-only plumbing: no scope
3067
3171
  // declares `universalFields`, so a third-party manifest is unaffected.
3068
- version: "1.69.0",
3172
+ // 1.70.0: additive (REQ-NAV-STRUCTURE) -- `themeMenuTypes`, the closed
3173
+ // catalogue of navigation shapes an app can take (`sidebar`, `top-bar`,
3174
+ // `bottom-tabs`) with the per-type item cap. Read from `theme_config`
3175
+ // as `navigation.menuType`; absent or unknown resolves to `sidebar`, so
3176
+ // every app that exists today renders and compiles byte-identically.
3177
+ // Resolved through the new `normaliseNavigation` host export rather than
3178
+ // a literal per host.
3179
+ // 1.71.0: additive (REQ-NAV-STRUCTURE) -- `resolveFooterTokens` (host
3180
+ // export). The footer strip's surface was hard-coded white on BOTH hosts
3181
+ // and its items read the SIDEBAR's tokens, which stops being tenable the
3182
+ // moment the strip IS the menu (`bottom-tabs` hides the sidebar panel, so
3183
+ // those tokens have nowhere to be set). A `theme_config.footer` block now
3184
+ // carries its own, every field falling back to the sidebar's so an
3185
+ // untouched workspace is unchanged.
3186
+ // 1.72.0: additive (REQ-NAV-STRUCTURE) -- `themeMenuTypes.*.quickBarMaxItems`
3187
+ // and the `quickBarCap` host export. The sidebar's secondary quick bar
3188
+ // capped at five in TWO places -- a named constant on web and a bare `5`
3189
+ // literal in the compiler -- so the number could drift between the hosts
3190
+ // and no Studio surface could state it at all.
3191
+ // 1.73.0: additive (sc-5206) — three new hooks: `useBoundColumns(tableId,
3192
+ // shape, props)` resolves author-bound column NAMES by name -> case-
3193
+ // insensitive name -> dataType fallback, so a renamed column still
3194
+ // resolves instead of the widget reading `record[props.titleField]`
3195
+ // directly and getting `undefined`; `useStableQuery(buildQuery)`
3196
+ // generalises `useDatastoreQuery`'s own content-diffed stable-reference
3197
+ // trick so a widget stops hand-rolling `useMemo` with a wrong deps
3198
+ // array; `useCanWrite(tableId, { recordId? })` reads a new
3199
+ // `ctx.datastore.myPermissions` client method as a write-permission
3200
+ // FLOOR, replacing the previous prompt-only "read useUser().groupIds /
3201
+ // .roles" guidance. No existing export changed signature.
3202
+ version: "1.73.0",
3069
3203
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3070
3204
  hooks: HOOKS,
3071
3205
  primitives: PRIMITIVES,
@@ -3081,6 +3215,7 @@ const CONTRACT = deepFreeze({
3081
3215
  themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
3082
3216
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
3083
3217
  themeSpacingScale: THEME_SPACING_SCALE,
3218
+ themeMenuTypes: THEME_MENU_TYPES,
3084
3219
  themeWidgetStyles: THEME_WIDGET_STYLES,
3085
3220
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
3086
3221
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
package/dist/contract.js CHANGED
@@ -159,6 +159,42 @@ const THEME_WIDGET_STYLES = Object.freeze({
159
159
  // Matches the styleSchema field cap the widget agent is held to.
160
160
  maxFieldsPerWidget: 12,
161
161
  });
162
+ // REQ-NAV-STRUCTURE: the SHAPE an app's navigation takes. One catalogue, four
163
+ // consumers -- the Studio's Navigation page, Mason's set_theme coercion, the web
164
+ // PlayerChrome and the compiler's navigator -- so a type cannot be offered to an
165
+ // author without every host actually drawing it.
166
+ //
167
+ // `maxItems` caps how many menu pages the chrome draws at once, and its meaning
168
+ // differs per type deliberately. The sidebar's mobile quick bar is a SECONDARY
169
+ // curated bar, so its own cap may drop a page -- the rail still lists every one.
170
+ // A bottom-tabs strip IS the menu, so its cap must never drop one: the surplus
171
+ // moves behind a "More" sheet instead.
172
+ const THEME_MENU_TYPES = Object.freeze({
173
+ sidebar: Object.freeze({
174
+ name: "Sidebar",
175
+ summary:
176
+ "A persistent left rail on desktop; a hamburger drawer plus the optional bottom quick bar on mobile.",
177
+ maxItems: null,
178
+ // The SECONDARY mobile quick bar's cap. It may drop a page past it, because
179
+ // the rail and the drawer still list every one -- the opposite of
180
+ // `bottom-tabs`' `maxItems`, where the strip IS the menu.
181
+ quickBarMaxItems: 5,
182
+ }),
183
+ "top-bar": Object.freeze({
184
+ name: "Top bar",
185
+ summary:
186
+ "A horizontal row of links in the app header, scrolling sideways when it runs out of room. No rail at any width.",
187
+ maxItems: null,
188
+ quickBarMaxItems: null,
189
+ }),
190
+ "bottom-tabs": Object.freeze({
191
+ name: "Bottom tabs",
192
+ summary:
193
+ "A sticky bottom strip at every width -- the phone-native shape. Pages past the cap move behind a More sheet.",
194
+ maxItems: 4,
195
+ quickBarMaxItems: null,
196
+ }),
197
+ });
162
198
  const THEME_SPACING_SCALE = Object.freeze({
163
199
  min: 0.5,
164
200
  max: 2,
@@ -474,6 +510,23 @@ const HOOKS = [
474
510
  requiredContextSlice: ["i18n.locale"],
475
511
  scopes: null,
476
512
  },
513
+ {
514
+ name: "useStableQuery",
515
+ signature: "useStableQuery(buildQuery)",
516
+ description:
517
+ "sc-5206 — keep whatever buildQuery() returns at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so it can " +
518
+ "be passed straight into useDatastoreQuery's second argument (useDatastoreQuery(tableId, useStableQuery(() => ({...})))) instead of a hand-rolled " +
519
+ "useMemo with an easy-to-get-wrong deps array. This generalises the same trick useDatastoreQuery already applies internally to its own query " +
520
+ "argument. buildQuery is a ZERO-ARG function called every render; pure React state — no ctx, no host required, safe outside a " +
521
+ "WidgetContextProvider. Never throws: a buildQuery that itself throws degrades to a stable undefined, and a result JSON.stringify can't diff " +
522
+ "(e.g. a circular structure) degrades to \"always a new reference\".",
523
+ returnShape: {
524
+ "(returns)":
525
+ "T | undefined // the hook returns buildQuery()'s result directly, not a wrapper object",
526
+ },
527
+ requiredContextSlice: [],
528
+ scopes: null,
529
+ },
477
530
  {
478
531
  name: "useUser",
479
532
  signature: "useUser()",
@@ -924,6 +977,29 @@ const HOOKS = [
924
977
  requiredContextSlice: ["datastore.schema"],
925
978
  scopes: ["datastore.read:<table>"],
926
979
  },
980
+ {
981
+ name: "useBoundColumns",
982
+ signature: "useBoundColumns(tableId, shape, props)",
983
+ description:
984
+ "sc-5206 — resolve author-bound column NAMES from a widget's own props, built on useDatastoreSchema. shape is " +
985
+ "{ [propKey]: { dataType?, optional? } }; props is the widget's own props carrying author-bound column names (e.g. props.titleField === " +
986
+ "'Title'). Resolution order per key: (1) an exact name match on props[key], (2) a case-insensitive name match, (3) the first column whose " +
987
+ "data_type matches shape[key].dataType that no EARLIER key in this call already claimed, (4) unresolved. Unresolved NEVER throws — the key " +
988
+ "reads undefined, so a column an author renamed after install still resolves instead of crashing the widget. Returns { columns, resolved, " +
989
+ "missing, loading, error }: columns holds the resolved column NAME — a drop-in replacement for record[props.titleField] -> " +
990
+ "record[columns.titleField]; resolved holds the full Column object (snake_case verbatim, as useDatastoreSchema returns it) so a caller can " +
991
+ "inspect resolved.<key>.data_type; missing lists every key whose spec is not optional:true and did not resolve. Falsy tableId collapses to " +
992
+ "{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null } without a network round-trip.",
993
+ returnShape: {
994
+ columns: "{ [propKey]: string | undefined }",
995
+ resolved: "{ [propKey]: Column | undefined }",
996
+ missing: "string[] // required (non-optional) keys that did not resolve",
997
+ loading: "boolean",
998
+ error: "DatastoreError | null",
999
+ },
1000
+ requiredContextSlice: ["datastore.schema"],
1001
+ scopes: ["datastore.read:<table>"],
1002
+ },
927
1003
  {
928
1004
  name: "useInterpretDraft",
929
1005
  signature: "useInterpretDraft(tableId)",
@@ -1295,6 +1371,28 @@ const HOOKS = [
1295
1371
  requiredContextSlice: ["datastore.records"],
1296
1372
  scopes: ["acl.write:records"],
1297
1373
  },
1374
+ // sc-5206 — table/record write-permission floor, reading the injected
1375
+ // ctx.datastore.myPermissions.
1376
+ {
1377
+ name: "useCanWrite",
1378
+ signature: "useCanWrite(tableId, options?)",
1379
+ description:
1380
+ "sc-5206 — is the signed-in caller permitted to write to tableId (or, with options.recordId, to that one row)? Returns { canWrite, loading, " +
1381
+ "error, refetch }, reading the injected ctx.datastore.myPermissions(tableId, { recordId }). This is a FLOOR, not a full replacement for " +
1382
+ "domain-specific write rules: a widget whose rule is more specific than the table ACL (e.g. 'only the assigned user may edit this row') must " +
1383
+ "still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
1384
+ "NOT distinguish 'not signed in' from 'signed in but forbidden' (both resolve canWrite:false); pair it with useUser() when the UI needs to " +
1385
+ "tell those apart. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
1386
+ "{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
1387
+ returnShape: {
1388
+ canWrite: "boolean",
1389
+ loading: "boolean",
1390
+ error: "DatastoreError | null",
1391
+ refetch: "() => Promise<void>",
1392
+ },
1393
+ requiredContextSlice: ["datastore.myPermissions"],
1394
+ scopes: ["datastore.read:<table>"],
1395
+ },
1298
1396
  // REQ-RT-07 — realtime table subscription.
1299
1397
  {
1300
1398
  name: "useDatastoreSubscription",
@@ -1826,13 +1924,19 @@ const WIDGET_CONTEXT_SHAPE = {
1826
1924
  datastore: {
1827
1925
  description:
1828
1926
  "Injected @colixsystems/datastore-client instance. " +
1829
- "{ tables: { list(), get(idOrName), interpret(idOrName, body) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
1927
+ "{ tables: { list(), get(idOrName), interpret(idOrName, body), myPermissions(idOrName, { recordId? }) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
1830
1928
  "records(tableId) -> { list(query) -> Promise<{ data, meta }>, get(id), create(values), update(id, values), delete(id), aggregate(spec), " +
1831
1929
  "permissions(recordId) -> { list() -> Promise<{ data, meta }>, grant(body), update(permId, patch), revoke(permId) } } }. " +
1832
- "`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing). " +
1930
+ "`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema() and useBoundColumns(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing); `myPermissions` backs useCanWrite() (sc-5206 — the caller's effective table/record write permission). " +
1833
1931
  "List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case (author column values keep their author-given names).",
1834
1932
  required: true,
1835
- fields: { records: "function", schema: "function", tables: "object", interpret: "function" },
1933
+ fields: {
1934
+ records: "function",
1935
+ schema: "function",
1936
+ tables: "object",
1937
+ interpret: "function",
1938
+ myPermissions: "function",
1939
+ },
1836
1940
  },
1837
1941
  directory: {
1838
1942
  description:
@@ -3065,7 +3169,37 @@ const CONTRACT = deepFreeze({
3065
3169
  // shadcn/Tailwind import's matching custom properties have a themeConfig
3066
3170
  // home instead of being reported lost. Host-only plumbing: no scope
3067
3171
  // declares `universalFields`, so a third-party manifest is unaffected.
3068
- version: "1.69.0",
3172
+ // 1.70.0: additive (REQ-NAV-STRUCTURE) -- `themeMenuTypes`, the closed
3173
+ // catalogue of navigation shapes an app can take (`sidebar`, `top-bar`,
3174
+ // `bottom-tabs`) with the per-type item cap. Read from `theme_config`
3175
+ // as `navigation.menuType`; absent or unknown resolves to `sidebar`, so
3176
+ // every app that exists today renders and compiles byte-identically.
3177
+ // Resolved through the new `normaliseNavigation` host export rather than
3178
+ // a literal per host.
3179
+ // 1.71.0: additive (REQ-NAV-STRUCTURE) -- `resolveFooterTokens` (host
3180
+ // export). The footer strip's surface was hard-coded white on BOTH hosts
3181
+ // and its items read the SIDEBAR's tokens, which stops being tenable the
3182
+ // moment the strip IS the menu (`bottom-tabs` hides the sidebar panel, so
3183
+ // those tokens have nowhere to be set). A `theme_config.footer` block now
3184
+ // carries its own, every field falling back to the sidebar's so an
3185
+ // untouched workspace is unchanged.
3186
+ // 1.72.0: additive (REQ-NAV-STRUCTURE) -- `themeMenuTypes.*.quickBarMaxItems`
3187
+ // and the `quickBarCap` host export. The sidebar's secondary quick bar
3188
+ // capped at five in TWO places -- a named constant on web and a bare `5`
3189
+ // literal in the compiler -- so the number could drift between the hosts
3190
+ // and no Studio surface could state it at all.
3191
+ // 1.73.0: additive (sc-5206) — three new hooks: `useBoundColumns(tableId,
3192
+ // shape, props)` resolves author-bound column NAMES by name -> case-
3193
+ // insensitive name -> dataType fallback, so a renamed column still
3194
+ // resolves instead of the widget reading `record[props.titleField]`
3195
+ // directly and getting `undefined`; `useStableQuery(buildQuery)`
3196
+ // generalises `useDatastoreQuery`'s own content-diffed stable-reference
3197
+ // trick so a widget stops hand-rolling `useMemo` with a wrong deps
3198
+ // array; `useCanWrite(tableId, { recordId? })` reads a new
3199
+ // `ctx.datastore.myPermissions` client method as a write-permission
3200
+ // FLOOR, replacing the previous prompt-only "read useUser().groupIds /
3201
+ // .roles" guidance. No existing export changed signature.
3202
+ version: "1.73.0",
3069
3203
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3070
3204
  hooks: HOOKS,
3071
3205
  primitives: PRIMITIVES,
@@ -3081,6 +3215,7 @@ const CONTRACT = deepFreeze({
3081
3215
  themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
3082
3216
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
3083
3217
  themeSpacingScale: THEME_SPACING_SCALE,
3218
+ themeMenuTypes: THEME_MENU_TYPES,
3084
3219
  themeWidgetStyles: THEME_WIDGET_STYLES,
3085
3220
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
3086
3221
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,