@colixsystems/widget-sdk 0.132.0 → 0.133.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,32 +35,32 @@ The data layer lives in **four separate domain-client packages**, each instantia
35
35
  | **CORE** | `useRefresh(handler)` | `void` | `ctx.refresh.subscribe` — no scope. Subscribes the handler to the page-level refresh tick (pull-to-refresh on mobile). Handler may return a Promise — the host waits for `allSettled` before clearing the spinner. The three datastore hooks auto-subscribe their own `refetch`; widgets only call this directly to re-run non-datastore work. No-op on a host that doesn't implement refresh. |
36
36
  | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
37
37
  | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` — wired by the Player and the Expo export; an authoring preview omits it and the call is a no-op — no scope |
38
- | **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition, backgroundSupported, backgroundWatching, startBackgroundWatch, stopBackgroundWatch }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). **Background watch (sc-6450)** — `startBackgroundWatch({ enableHighAccuracy, distanceIntervalMeters, timeIntervalMs })` keeps positions arriving while the app is backgrounded; `stopBackgroundWatch()` releases it. NATIVE-ONLY and opt-in per app: gate the control on `backgroundSupported` (false on web, and in an export whose workspace did not opt in). The watch outlives the widget's mount, and its positions land in the same `latitude`/`longitude`/`accuracy` slots. |
39
- | **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`). |
40
- | **CORE** | `useCamera(options?)` | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (a live `getUserMedia` preview, phones included; a file input only where getUserMedia is absent) and the Expo export (`expo-image-picker`). |
38
+ | **CORE** | `useGeolocation(options)` (optional: options) | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition, backgroundSupported, backgroundWatching, startBackgroundWatch, stopBackgroundWatch }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). **Background watch (sc-6450)** — `startBackgroundWatch({ enableHighAccuracy, distanceIntervalMeters, timeIntervalMs })` keeps positions arriving while the app is backgrounded; `stopBackgroundWatch()` releases it. NATIVE-ONLY and opt-in per app: gate the control on `backgroundSupported` (false on web, and in an export whose workspace did not opt in). The watch outlives the widget's mount, and its positions land in the same `latitude`/`longitude`/`accuracy` slots. |
39
+ | **CORE** | `useSpeechToText(options)` (optional: 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`). |
40
+ | **CORE** | `useCamera(options)` (optional: options) | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (a live `getUserMedia` preview, phones included; a file input only where getUserMedia is absent) and the Expo export (`expo-image-picker`). |
41
41
  | **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. |
42
42
  | **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. |
43
43
  | **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". |
44
- | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
44
+ | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
45
45
  | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
46
46
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
47
47
  | **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 }`. |
48
48
  | **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. |
49
49
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*` |
50
- | **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. |
50
+ | **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: 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. |
51
51
  | **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) |
52
- | **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 "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. 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. |
52
+ | **DATASTORE** | `useCanWrite(tableId, options)` (optional: options) | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. 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. |
53
53
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
54
- | **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. |
55
- | **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
56
- | **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
57
- | **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
58
- | **DIRECTORY** | `useInvites(query?)` | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
54
+ | **FILES** | `useAssetsByTag(tag, { type })` (optional: the whole argument, and `type` within it) | `{ 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. |
55
+ | **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
56
+ | **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
57
+ | **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
58
+ | **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
59
59
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
60
- | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId? })` | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
60
+ | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: folderId) | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
61
61
  | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
62
62
  | **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
63
- | **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider?, purpose?, pollIntervalMs? })` | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
63
+ | **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider, purpose, pollIntervalMs })` (optional: every key, and the whole argument) | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
64
64
 
65
65
  All list calls return the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you. There is no `useWorkspace()` or `useLogger()` hook — read the theme via `useTheme()` and the locale via `useI18n()`; the host logger lives on `ctx.logger` (`{ debug, info, warn, error }`).
66
66
 
@@ -70,7 +70,17 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
70
70
 
71
71
  ## Status
72
72
 
73
- `v0.132.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
+ `v0.133.1` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
74
+
75
+ ### What's new in 0.133.1 (contract 1.102.1)
76
+
77
+ **Hook signatures no longer carry doc-style optional markers (sc-6946).** `CONTRACT.hooks[].signature` used to write optionality inline — `useFilestoreFiles({ spaceType, folderId?, q?, type? })` — but a `?` inside a destructuring pattern or object literal is a parse error, and the AI widget agent renders those signatures into its prompt verbatim. Every signature is now a call form that parses; the omittable parts moved to a new `optionalArgs` array the prompt prints beside it. The hook table above was rewritten to match (the version history below is left as each release wrote it).
78
+
79
+ - **Documentation only.** No hook gained, lost, or changed an argument — `useFilestoreFiles({ spaceType })` and `useDirectory()` behave exactly as before. Only the way the contract *writes down* which arguments are optional has changed.
80
+
81
+ ### What's new in 0.133.0 (contract 1.102.0)
82
+
83
+ **A required `tableRef` needs a `datastoreTemplate` table that answers to its NAME, not just another table in the list (sc-6965).** 0.57.0's publish gate `manifest.requiredTableRefsHaveTemplate` compared COUNTS — it passed as soon as `datastoreTemplate.tables` plus any host-supplied tables outnumbered the `required` `tableRef` props. So a widget with two required props published on any two template tables, including the case where both of them name the SAME prop and the other has nothing: the installer, which binds by name, then had no table for it and fell back to whichever one was left over — your widget wired to a table its code was never written against. The gate now runs the installer's own matcher over your template, per property: a table's `suffix` must answer to the property's name (`ordersTableId` needs suffix `Orders`), and the failure names only the properties nothing answers to, with the suffix each one wants. **What still passes unchanged:** the conventional bare `tableId`, which carries no name to match and takes the first table still free; a property marked `sharedTable: true`, which seeds nothing by design (0.106.0); and a standalone submit with no template at all, which fails exactly as it did. **What to change if you are newly rejected:** name the table after the property it is for — that is the pairing that was always going to decide the binding. Every first-party widget passes unchanged. No export, type, hook, or manifest field changed shape — a publish-gate tightening plus documentation. `CONTRACT` is unchanged (no new field).
74
84
 
75
85
  ### What's new in 0.132.0 (contract 1.102.0)
76
86
 
@@ -1388,7 +1398,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
1388
1398
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
1389
1399
  - **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
1390
1400
  - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget.
1391
- - **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them.
1401
+ - **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
1392
1402
  - **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
1393
1403
  - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`.
1394
1404
  - **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
package/dist/contract.cjs CHANGED
@@ -793,7 +793,8 @@ const HOOKS = [
793
793
  },
794
794
  {
795
795
  name: "useAssetsByTag",
796
- signature: "useAssetsByTag(tag, { type? } = {})",
796
+ signature: "useAssetsByTag(tag, { type })",
797
+ optionalArgs: ["type"],
797
798
  description:
798
799
  "List every tenant asset carrying a given tag. Backs the Gallery " +
799
800
  "widget's tag-source mode but is a general SDK primitive — any widget " +
@@ -815,7 +816,8 @@ const HOOKS = [
815
816
  },
816
817
  {
817
818
  name: "useFilestoreFiles",
818
- signature: "useFilestoreFiles({ spaceType, folderId?, q?, type? })",
819
+ signature: "useFilestoreFiles({ spaceType, folderId, q, type })",
820
+ optionalArgs: ["folderId", "q", "type"],
819
821
  description:
820
822
  "Browse the end-user's Filestore files in a project, personal, or public " +
821
823
  "space. The hook resolves owner_id from the host context (tenant for " +
@@ -876,7 +878,8 @@ const HOOKS = [
876
878
  },
877
879
  {
878
880
  name: "useFilestoreUpload",
879
- signature: "useFilestoreUpload({ spaceType, folderId?, compress? })",
881
+ signature: "useFilestoreUpload({ spaceType, folderId, compress })",
882
+ optionalArgs: ["folderId", "compress"],
880
883
  description:
881
884
  "Upload a file into the end-user's Filestore space. The widget passes " +
882
885
  "the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
@@ -914,7 +917,8 @@ const HOOKS = [
914
917
  },
915
918
  {
916
919
  name: "usePdfExport",
917
- signature: "usePdfExport({ spaceType, folderId? })",
920
+ signature: "usePdfExport({ spaceType, folderId })",
921
+ optionalArgs: ["folderId"],
918
922
  description:
919
923
  "Render an HTML string to a PDF server-side and SAVE it as a file in " +
920
924
  "the end-user's Filestore space. The widget passes the SPACE " +
@@ -939,7 +943,8 @@ const HOOKS = [
939
943
  },
940
944
  {
941
945
  name: "useFilestoreFolders",
942
- signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
946
+ signature: "useFilestoreFolders({ spaceType, parentFolderId, q, enabled })",
947
+ optionalArgs: ["parentFolderId", "q", "enabled"],
943
948
  description:
944
949
  "Browse the end-user's Filestore folders in a project, personal, or public " +
945
950
  "space, mirroring useFilestoreFiles for subfolder navigation. The hook " +
@@ -956,7 +961,8 @@ const HOOKS = [
956
961
  },
957
962
  {
958
963
  name: "useFileSignature",
959
- signature: "useFileSignature(fileId, existingSignatureId?)",
964
+ signature: "useFileSignature(fileId, existingSignatureId)",
965
+ optionalArgs: ["existingSignatureId"],
960
966
  description:
961
967
  "Drive a BankID signing flow for one Filestore file. initiate() opens a " +
962
968
  "sign order (the backend hashes the bytes + binds the digest), refresh() " +
@@ -1003,7 +1009,8 @@ const HOOKS = [
1003
1009
  },
1004
1010
  {
1005
1011
  name: "useFileRoster",
1006
- signature: "useFileRoster(fileId, { limit?, offset?, enabled? }?)",
1012
+ signature: "useFileRoster(fileId, { limit, offset, enabled })",
1013
+ optionalArgs: ["limit", "offset", "enabled"],
1007
1014
  description:
1008
1015
  "MANAGE-gated signer roster for one Filestore file: the people expected " +
1009
1016
  "to be able to sign it (the file's folder audience) each annotated " +
@@ -1025,7 +1032,8 @@ const HOOKS = [
1025
1032
  },
1026
1033
  {
1027
1034
  name: "useFolderPermissions",
1028
- signature: "useFolderPermissions(folderId, { enabled? }?)",
1035
+ signature: "useFolderPermissions(folderId, { enabled })",
1036
+ optionalArgs: ["enabled"],
1029
1037
  description:
1030
1038
  "Manage a folder's permissions (the Filestore ACL — folder-scoped). Lists " +
1031
1039
  "the folder's grants and exposes grant(subjectType, subjectId, permission) " +
@@ -1048,7 +1056,8 @@ const HOOKS = [
1048
1056
  },
1049
1057
  {
1050
1058
  name: "useDatastoreQuery",
1051
- signature: "useDatastoreQuery(tableId, options?)",
1059
+ signature: "useDatastoreQuery(tableId, options)",
1060
+ optionalArgs: ["options"],
1052
1061
  returnShape: {
1053
1062
  data: "Record[]",
1054
1063
  loading: "boolean",
@@ -1155,7 +1164,8 @@ const HOOKS = [
1155
1164
  },
1156
1165
  {
1157
1166
  name: "useDirectory",
1158
- signature: "useDirectory(query?)",
1167
+ signature: "useDirectory(query)",
1168
+ optionalArgs: ["query"],
1159
1169
  returnShape: {
1160
1170
  users: "Array<{ id, name, role }> // snake_case rows; unwrapped from { data, meta }",
1161
1171
  loading: "boolean",
@@ -1250,7 +1260,8 @@ const HOOKS = [
1250
1260
  // that only call read methods need only `users.read:*`.
1251
1261
  {
1252
1262
  name: "useUsers",
1253
- signature: "useUsers(query?)",
1263
+ signature: "useUsers(query)",
1264
+ optionalArgs: ["query"],
1254
1265
  description:
1255
1266
  "AppUser administration via the injected directory-client at " +
1256
1267
  "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
@@ -1292,7 +1303,8 @@ const HOOKS = [
1292
1303
  // affects the user's effective access.
1293
1304
  {
1294
1305
  name: "useGroups",
1295
- signature: "useGroups(query?)",
1306
+ signature: "useGroups(query)",
1307
+ optionalArgs: ["query"],
1296
1308
  description:
1297
1309
  "AppUserGroup administration via the injected directory-client at " +
1298
1310
  "ctx.directory.groups.{list,create,remove,addMember,removeMember,listMine}. " +
@@ -1322,7 +1334,8 @@ const HOOKS = [
1322
1334
  // caller's `users.write` SystemAcl capability.
1323
1335
  {
1324
1336
  name: "useInvites",
1325
- signature: "useInvites(query?)",
1337
+ signature: "useInvites(query)",
1338
+ optionalArgs: ["query"],
1326
1339
  description:
1327
1340
  "Pending AppUser invite administration via the injected " +
1328
1341
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
@@ -1394,7 +1407,8 @@ const HOOKS = [
1394
1407
  // result. Anonymous by design (no widget scope, no session). Mirror of contract.js.
1395
1408
  {
1396
1409
  name: "useIdentification",
1397
- signature: "useIdentification(options?)",
1410
+ signature: "useIdentification(options)",
1411
+ optionalArgs: ["options"],
1398
1412
  description:
1399
1413
  "Identify a visitor who is NOT signed in via the injected " +
1400
1414
  "identification-client at ctx.identification.{available,start,get,cancel}. " +
@@ -1478,7 +1492,8 @@ const HOOKS = [
1478
1492
  // ctx.datastore.myPermissions.
1479
1493
  {
1480
1494
  name: "useCanWrite",
1481
- signature: "useCanWrite(tableId, options?)",
1495
+ signature: "useCanWrite(tableId, options)",
1496
+ optionalArgs: ["options"],
1482
1497
  description:
1483
1498
  "sc-5206 — is the signed-in caller permitted to write to tableId (or, with options.recordId, to that one row)? Returns { canWrite, loading, " +
1484
1499
  "error, refetch }, reading the injected ctx.datastore.myPermissions(tableId, { recordId }). This is a FLOOR, not a full replacement for " +
@@ -1499,7 +1514,8 @@ const HOOKS = [
1499
1514
  // REQ-RT-07 — realtime table subscription.
1500
1515
  {
1501
1516
  name: "useDatastoreSubscription",
1502
- signature: "useDatastoreSubscription(tableId, handlers, options?)",
1517
+ signature: "useDatastoreSubscription(tableId, handlers, options)",
1518
+ optionalArgs: ["options"],
1503
1519
  description:
1504
1520
  "Subscribe to a table's realtime change stream via the injected " +
1505
1521
  "datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
@@ -1571,7 +1587,8 @@ const HOOKS = [
1571
1587
  // degrades to an UNSUPPORTED error rather than throwing at render.
1572
1588
  {
1573
1589
  name: "useGeolocation",
1574
- signature: "useGeolocation(options?)",
1590
+ signature: "useGeolocation(options)",
1591
+ optionalArgs: ["options"],
1575
1592
  description:
1576
1593
  "Read the device's current position. Returns { latitude, longitude, accuracy, loading, error, getCurrentPosition }. " +
1577
1594
  "Capture is IMPERATIVE — call getCurrentPosition() from a user gesture (a tap); browsers and the mobile OS gate the " +
@@ -1612,7 +1629,8 @@ const HOOKS = [
1612
1629
  // reports supported:false rather than throwing at render.
1613
1630
  {
1614
1631
  name: "useSpeechToText",
1615
- signature: "useSpeechToText(options?)",
1632
+ signature: "useSpeechToText(options)",
1633
+ optionalArgs: ["options"],
1616
1634
  description:
1617
1635
  "Dictate into text with the device's ON-DEVICE speech recogniser. Returns { transcript, partial, listening, supported, " +
1618
1636
  "error, start, stop, abort, reset }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a mic button); " +
@@ -1641,7 +1659,8 @@ const HOOKS = [
1641
1659
  // the hook reports supported:false rather than throwing at render.
1642
1660
  {
1643
1661
  name: "useCamera",
1644
- signature: "useCamera(options?)",
1662
+ signature: "useCamera(options)",
1663
+ optionalArgs: ["options"],
1645
1664
  description:
1646
1665
  "Take a photo or choose one from the device library. Returns { asset, loading, error, supported, capture, pick, reset }. " +
1647
1666
  "Capture is IMPERATIVE — call capture() or pick() from a user gesture (a tap); the browser and the mobile OS gate the " +
@@ -1674,7 +1693,8 @@ const HOOKS = [
1674
1693
  // reports supported:false rather than throwing at render.
1675
1694
  {
1676
1695
  name: "useBarcodeScanner",
1677
- signature: "useBarcodeScanner(options?)",
1696
+ signature: "useBarcodeScanner(options)",
1697
+ optionalArgs: ["options"],
1678
1698
  description:
1679
1699
  "Read a barcode or QR code with the device camera. Returns { result, scanning, error, supported, scan, reset }. " +
1680
1700
  "Scanning is IMPERATIVE — call scan() from a user gesture (a tap); the browser and the mobile OS gate the camera " +
@@ -3851,7 +3871,15 @@ const CONTRACT = deepFreeze({
3851
3871
  // §7: a breaking change to a shipped package needs a reason, and there is
3852
3872
  // none here). Only the MANDATE to emit it is gone, from the AI widget
3853
3873
  // agent prompt and the designer skill. Minor bump: nothing removed.
3854
- version: "1.102.0",
3874
+ // 1.102.1: fix (sc-6946) — hook signatures carried doc-style OPTIONAL
3875
+ // markers (`useFilestoreFiles({ spaceType, folderId?, q?, type? })`).
3876
+ // A signature invites a literal copy, and a `?` inside a destructuring
3877
+ // pattern or object literal is a parse error — so the AI widget agent
3878
+ // prompt, which renders these verbatim, was itself teaching source that
3879
+ // fails the publish gate. Every signature is now a valid call and the
3880
+ // omittable parts moved to a new `optionalArgs` array the prompt prints
3881
+ // beside it. Documentation only; no hook changed shape or behaviour.
3882
+ version: "1.102.1",
3855
3883
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3856
3884
  hooks: HOOKS,
3857
3885
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -793,7 +793,8 @@ const HOOKS = [
793
793
  },
794
794
  {
795
795
  name: "useAssetsByTag",
796
- signature: "useAssetsByTag(tag, { type? } = {})",
796
+ signature: "useAssetsByTag(tag, { type })",
797
+ optionalArgs: ["type"],
797
798
  description:
798
799
  "List every tenant asset carrying a given tag. Backs the Gallery " +
799
800
  "widget's tag-source mode but is a general SDK primitive — any widget " +
@@ -815,7 +816,8 @@ const HOOKS = [
815
816
  },
816
817
  {
817
818
  name: "useFilestoreFiles",
818
- signature: "useFilestoreFiles({ spaceType, folderId?, q?, type? })",
819
+ signature: "useFilestoreFiles({ spaceType, folderId, q, type })",
820
+ optionalArgs: ["folderId", "q", "type"],
819
821
  description:
820
822
  "Browse the end-user's Filestore files in a project, personal, or public " +
821
823
  "space. The hook resolves owner_id from the host context (tenant for " +
@@ -876,7 +878,8 @@ const HOOKS = [
876
878
  },
877
879
  {
878
880
  name: "useFilestoreUpload",
879
- signature: "useFilestoreUpload({ spaceType, folderId?, compress? })",
881
+ signature: "useFilestoreUpload({ spaceType, folderId, compress })",
882
+ optionalArgs: ["folderId", "compress"],
880
883
  description:
881
884
  "Upload a file into the end-user's Filestore space. The widget passes " +
882
885
  "the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
@@ -914,7 +917,8 @@ const HOOKS = [
914
917
  },
915
918
  {
916
919
  name: "usePdfExport",
917
- signature: "usePdfExport({ spaceType, folderId? })",
920
+ signature: "usePdfExport({ spaceType, folderId })",
921
+ optionalArgs: ["folderId"],
918
922
  description:
919
923
  "Render an HTML string to a PDF server-side and SAVE it as a file in " +
920
924
  "the end-user's Filestore space. The widget passes the SPACE " +
@@ -939,7 +943,8 @@ const HOOKS = [
939
943
  },
940
944
  {
941
945
  name: "useFilestoreFolders",
942
- signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
946
+ signature: "useFilestoreFolders({ spaceType, parentFolderId, q, enabled })",
947
+ optionalArgs: ["parentFolderId", "q", "enabled"],
943
948
  description:
944
949
  "Browse the end-user's Filestore folders in a project, personal, or public " +
945
950
  "space, mirroring useFilestoreFiles for subfolder navigation. The hook " +
@@ -956,7 +961,8 @@ const HOOKS = [
956
961
  },
957
962
  {
958
963
  name: "useFileSignature",
959
- signature: "useFileSignature(fileId, existingSignatureId?)",
964
+ signature: "useFileSignature(fileId, existingSignatureId)",
965
+ optionalArgs: ["existingSignatureId"],
960
966
  description:
961
967
  "Drive a BankID signing flow for one Filestore file. initiate() opens a " +
962
968
  "sign order (the backend hashes the bytes + binds the digest), refresh() " +
@@ -1003,7 +1009,8 @@ const HOOKS = [
1003
1009
  },
1004
1010
  {
1005
1011
  name: "useFileRoster",
1006
- signature: "useFileRoster(fileId, { limit?, offset?, enabled? }?)",
1012
+ signature: "useFileRoster(fileId, { limit, offset, enabled })",
1013
+ optionalArgs: ["limit", "offset", "enabled"],
1007
1014
  description:
1008
1015
  "MANAGE-gated signer roster for one Filestore file: the people expected " +
1009
1016
  "to be able to sign it (the file's folder audience) each annotated " +
@@ -1025,7 +1032,8 @@ const HOOKS = [
1025
1032
  },
1026
1033
  {
1027
1034
  name: "useFolderPermissions",
1028
- signature: "useFolderPermissions(folderId, { enabled? }?)",
1035
+ signature: "useFolderPermissions(folderId, { enabled })",
1036
+ optionalArgs: ["enabled"],
1029
1037
  description:
1030
1038
  "Manage a folder's permissions (the Filestore ACL — folder-scoped). Lists " +
1031
1039
  "the folder's grants and exposes grant(subjectType, subjectId, permission) " +
@@ -1048,7 +1056,8 @@ const HOOKS = [
1048
1056
  },
1049
1057
  {
1050
1058
  name: "useDatastoreQuery",
1051
- signature: "useDatastoreQuery(tableId, options?)",
1059
+ signature: "useDatastoreQuery(tableId, options)",
1060
+ optionalArgs: ["options"],
1052
1061
  returnShape: {
1053
1062
  data: "Record[]",
1054
1063
  loading: "boolean",
@@ -1155,7 +1164,8 @@ const HOOKS = [
1155
1164
  },
1156
1165
  {
1157
1166
  name: "useDirectory",
1158
- signature: "useDirectory(query?)",
1167
+ signature: "useDirectory(query)",
1168
+ optionalArgs: ["query"],
1159
1169
  returnShape: {
1160
1170
  users: "Array<{ id, name, role }> // snake_case rows; unwrapped from { data, meta }",
1161
1171
  loading: "boolean",
@@ -1250,7 +1260,8 @@ const HOOKS = [
1250
1260
  // that only call read methods need only `users.read:*`.
1251
1261
  {
1252
1262
  name: "useUsers",
1253
- signature: "useUsers(query?)",
1263
+ signature: "useUsers(query)",
1264
+ optionalArgs: ["query"],
1254
1265
  description:
1255
1266
  "AppUser administration via the injected directory-client at " +
1256
1267
  "ctx.directory.users.{list,get,invite,deactivate,reactivate,sendPasswordReset}. " +
@@ -1292,7 +1303,8 @@ const HOOKS = [
1292
1303
  // affects the user's effective access.
1293
1304
  {
1294
1305
  name: "useGroups",
1295
- signature: "useGroups(query?)",
1306
+ signature: "useGroups(query)",
1307
+ optionalArgs: ["query"],
1296
1308
  description:
1297
1309
  "AppUserGroup administration via the injected directory-client at " +
1298
1310
  "ctx.directory.groups.{list,create,remove,addMember,removeMember,listMine}. " +
@@ -1322,7 +1334,8 @@ const HOOKS = [
1322
1334
  // caller's `users.write` SystemAcl capability.
1323
1335
  {
1324
1336
  name: "useInvites",
1325
- signature: "useInvites(query?)",
1337
+ signature: "useInvites(query)",
1338
+ optionalArgs: ["query"],
1326
1339
  description:
1327
1340
  "Pending AppUser invite administration via the injected " +
1328
1341
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
@@ -1394,7 +1407,8 @@ const HOOKS = [
1394
1407
  // result. Anonymous by design (no widget scope, no session). Mirror of contract.cjs.
1395
1408
  {
1396
1409
  name: "useIdentification",
1397
- signature: "useIdentification(options?)",
1410
+ signature: "useIdentification(options)",
1411
+ optionalArgs: ["options"],
1398
1412
  description:
1399
1413
  "Identify a visitor who is NOT signed in via the injected " +
1400
1414
  "identification-client at ctx.identification.{available,start,get,cancel}. " +
@@ -1478,7 +1492,8 @@ const HOOKS = [
1478
1492
  // ctx.datastore.myPermissions.
1479
1493
  {
1480
1494
  name: "useCanWrite",
1481
- signature: "useCanWrite(tableId, options?)",
1495
+ signature: "useCanWrite(tableId, options)",
1496
+ optionalArgs: ["options"],
1482
1497
  description:
1483
1498
  "sc-5206 — is the signed-in caller permitted to write to tableId (or, with options.recordId, to that one row)? Returns { canWrite, loading, " +
1484
1499
  "error, refetch }, reading the injected ctx.datastore.myPermissions(tableId, { recordId }). This is a FLOOR, not a full replacement for " +
@@ -1499,7 +1514,8 @@ const HOOKS = [
1499
1514
  // REQ-RT-07 — realtime table subscription.
1500
1515
  {
1501
1516
  name: "useDatastoreSubscription",
1502
- signature: "useDatastoreSubscription(tableId, handlers, options?)",
1517
+ signature: "useDatastoreSubscription(tableId, handlers, options)",
1518
+ optionalArgs: ["options"],
1503
1519
  description:
1504
1520
  "Subscribe to a table's realtime change stream via the injected " +
1505
1521
  "datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
@@ -1571,7 +1587,8 @@ const HOOKS = [
1571
1587
  // degrades to an UNSUPPORTED error rather than throwing at render.
1572
1588
  {
1573
1589
  name: "useGeolocation",
1574
- signature: "useGeolocation(options?)",
1590
+ signature: "useGeolocation(options)",
1591
+ optionalArgs: ["options"],
1575
1592
  description:
1576
1593
  "Read the device's current position. Returns { latitude, longitude, accuracy, loading, error, getCurrentPosition }. " +
1577
1594
  "Capture is IMPERATIVE — call getCurrentPosition() from a user gesture (a tap); browsers and the mobile OS gate the " +
@@ -1612,7 +1629,8 @@ const HOOKS = [
1612
1629
  // reports supported:false rather than throwing at render.
1613
1630
  {
1614
1631
  name: "useSpeechToText",
1615
- signature: "useSpeechToText(options?)",
1632
+ signature: "useSpeechToText(options)",
1633
+ optionalArgs: ["options"],
1616
1634
  description:
1617
1635
  "Dictate into text with the device's ON-DEVICE speech recogniser. Returns { transcript, partial, listening, supported, " +
1618
1636
  "error, start, stop, abort, reset }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a mic button); " +
@@ -1641,7 +1659,8 @@ const HOOKS = [
1641
1659
  // the hook reports supported:false rather than throwing at render.
1642
1660
  {
1643
1661
  name: "useCamera",
1644
- signature: "useCamera(options?)",
1662
+ signature: "useCamera(options)",
1663
+ optionalArgs: ["options"],
1645
1664
  description:
1646
1665
  "Take a photo or choose one from the device library. Returns { asset, loading, error, supported, capture, pick, reset }. " +
1647
1666
  "Capture is IMPERATIVE — call capture() or pick() from a user gesture (a tap); the browser and the mobile OS gate the " +
@@ -1674,7 +1693,8 @@ const HOOKS = [
1674
1693
  // reports supported:false rather than throwing at render.
1675
1694
  {
1676
1695
  name: "useBarcodeScanner",
1677
- signature: "useBarcodeScanner(options?)",
1696
+ signature: "useBarcodeScanner(options)",
1697
+ optionalArgs: ["options"],
1678
1698
  description:
1679
1699
  "Read a barcode or QR code with the device camera. Returns { result, scanning, error, supported, scan, reset }. " +
1680
1700
  "Scanning is IMPERATIVE — call scan() from a user gesture (a tap); the browser and the mobile OS gate the camera " +
@@ -3851,7 +3871,15 @@ const CONTRACT = deepFreeze({
3851
3871
  // §7: a breaking change to a shipped package needs a reason, and there is
3852
3872
  // none here). Only the MANDATE to emit it is gone, from the AI widget
3853
3873
  // agent prompt and the designer skill. Minor bump: nothing removed.
3854
- version: "1.102.0",
3874
+ // 1.102.1: fix (sc-6946) — hook signatures carried doc-style OPTIONAL
3875
+ // markers (`useFilestoreFiles({ spaceType, folderId?, q?, type? })`).
3876
+ // A signature invites a literal copy, and a `?` inside a destructuring
3877
+ // pattern or object literal is a parse error — so the AI widget agent
3878
+ // prompt, which renders these verbatim, was itself teaching source that
3879
+ // fails the publish gate. Every signature is now a valid call and the
3880
+ // omittable parts moved to a new `optionalArgs` array the prompt prints
3881
+ // beside it. Documentation only; no hook changed shape or behaviour.
3882
+ version: "1.102.1",
3855
3883
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3856
3884
  hooks: HOOKS,
3857
3885
  primitives: PRIMITIVES,
package/dist/index.d.ts CHANGED
@@ -2600,6 +2600,8 @@ export function lintSource(
2600
2600
  export interface ContractHookEntry {
2601
2601
  name: string;
2602
2602
  signature: string;
2603
+ /** Arguments and option keys in `signature` that may be omitted (sc-6946). */
2604
+ optionalArgs?: string[];
2603
2605
  returnShape: Record<string, string>;
2604
2606
  requiredContextSlice: string[];
2605
2607
  scopes: string[] | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.132.0",
3
+ "version": "0.133.1",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",