@colixsystems/widget-sdk 0.261.0 → 0.262.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
@@ -42,7 +42,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
42
42
  | **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. |
43
43
  | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
44
44
  | **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 |
45
- | **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. |
45
+ | **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** — `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. |
46
46
  | **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`). |
47
47
  | **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`). |
48
48
  | **CORE** | `useI18n()` | `{ t, locale, languages, canSetLocale, setLocale }` | `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. `languages` lists the app's active languages (`[{ code, name, is_base }]`); `setLocale(code)` switches the whole app live — saved to a signed-in user's account, kept on the device for a visitor, `null` for the app default. Rejects with `.code` `INVALID_LOCALE` \| `UNSUPPORTED`; `canSetLocale` is false where the host cannot switch (the Builder canvas). |
@@ -57,8 +57,9 @@ The data layer lives in **four separate domain-client packages**, each instantia
57
57
  | **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 }`. |
58
58
  | **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. |
59
59
  | **AGENTS** | `useAgent(agentRef)` | `{ send, cancel, reset, messages, sending, resuming, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). Sending is IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. On mount the hook DOES reopen the caller's most recent thread with this agent, so a refresh or a navigation keeps the conversation instead of starting a blank one; that resume only READS and spends no credit. `resuming` is true while it runs — render a loading state, not an empty thread. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance), and the new thread is the one a later mount resumes. **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries **`activity`** — what the agent DID with the user's data: the table it read (with the `search` text and `filters` it read with), or the table and columns it CHANGED (never the values, and never a row the permissions refused). **Render a write.** A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. |
60
+ | **ACTIONS** | `useAction(actionRef)` | `{ available, run, running, result, error }` | `ctx.actions` — `actions.run:*`. Run one of the workspace's Actions from an event handler (never on mount) and read the value it passed to `respond()`. The Action needs the app trigger. `available` is false when nothing is bound or the host brokers no client. Errors are `ActionError` with a stable `code`. |
60
61
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
61
- | **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"`). For a live list of the signed-in user's own rows pass `{ kind: "granted" }`: every row they read through a grant to them or one of their groups, plus `onAccessGained(record)` / `onAccessLost({ id })` as rows enter and leave that set. Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
62
+ | **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the realtime 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"`). For a live list of the signed-in user's own rows pass `{ kind: "granted" }`: every row they read through a grant to them or one of their groups, plus `onAccessGained(record)` / `onAccessLost({ id })` as rows enter and leave that set. Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
62
63
  | **DATASTORE** | `useTyping(tableId, recordId)` | `{ typingUserIds, setTyping, status }` — status `"connecting" \| "live" \| "unavailable"` | `records(table).typing` — `datastore.read:<table>`. Who else is typing in one record, over the same socket as `useDatastoreSubscription`. Call `setTyping(true)` on every keystroke (throttled to one signal per 3 s; `false` is sent after 5 s idle) and `setTyping(false)` on send, clear or blur. `typingUserIds` lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks `typing` reads `{ typingUserIds: [], status: "unavailable" }`. Re-subscribes when either id changes. |
63
64
  | **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) |
64
65
  | **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. |
@@ -85,7 +86,23 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
85
86
 
86
87
  ## Status
87
88
 
88
- `v0.261.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**.
89
+ `v0.262.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**.
90
+
91
+ ### What's new in 0.262.0 (contract 1.212.0)
92
+
93
+ **New `useAction(actionRef)` hook — a widget can run one of the workspace's Actions and show what it returned.** A new `actions` host client (`@colixsystems/actions-client`), a new `actionRef` propertySchema type, a new `actions.run:*` scope and a new `ActionError`. Everything is additive.
94
+
95
+ ```js
96
+ const { available, run, running, result, error } = useAction(props.actionId);
97
+ ```
98
+
99
+ `run(input)` is imperative: call it from an event handler, never on mount, because an Action can write data and send messages. It resolves with the value the Action passed to `respond()` (any JSON, or `null`), and `result` holds the last one. The Action needs the app trigger, the same as one a Button runs; without it the run is refused with `ACTION_NOT_APP_INVOKABLE`.
100
+
101
+ **Bind it with an `actionRef` property.** Declare `{ type: "actionRef" }` in your `propertySchema` and the Studio renders an Action picker that warns when the chosen Action lacks the app trigger. The value is a bare Action id, so Copy Workspace remaps it like `pageRef`.
102
+
103
+ When nothing is bound, or the host brokers no actions client, `available` is `false` and `run()` rejects `UNAVAILABLE`: render a visible "pick an action" state, never a blank box. A changed binding clears `result` and `error`, and a run that settles after the binding changed or the widget unmounted writes nothing. When runs overlap, `result` and `error` follow the newest one. Branch on `error.code`: `ACTION_NOT_APP_INVOKABLE` and `ACTION_NOT_FOUND` will not clear on a retry, `RATE_LIMITED` will, `INVALID_ACTION_INPUT` means the input was refused, `AUTH_REQUIRED`, `TOKEN_EXPIRED` and `TOKEN_INVALID` mean the app user must sign in, and `ACTION_FAILED` means the Action ran and failed. Never show an app user the raw message. A text answer from a bot is often markdown, so render it with `<RichText>`.
104
+
105
+ The linter rejects `useAction()` unless your manifest declares `actions.run:*` in `requestedScopes`.
89
106
 
90
107
  ### What's new in 0.261.0 (contract 1.211.0)
91
108
 
@@ -956,7 +973,7 @@ export default function TaskList(props) {
956
973
  ```
957
974
 
958
975
  - **The hook** is called with the placement's resolved props, inside the widget's own context. Return a whole number, or `null` while the count is not known yet. It must only read: no rendering, no data writes, no navigation, no events or toasts, nothing marked read, and no `usePageBadge`, `useScreenTitle`, `useSectionEmpty` or `useWidgetRoute` setter.
959
- - **The host enforces read-only.** A counter's datastore and directory offer only their read methods and realtime subscriptions, and notification mutes only `get`/`list`. Writes, typing signals, payments, `notifications.send`, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable, exactly as on a host that does not provide them.
976
+ - **The host enforces read-only.** A counter's datastore and directory offer only their read methods and realtime subscriptions, and notification mutes only `get`/`list`. Writes, typing signals, payments, `notifications.send`, files, assets, AI agents, translation, link previews, device capabilities, identification, account actions and Actions (useAction) all answer as unavailable, exactly as on a host that does not provide them.
960
977
  - **Stay live.** The counter runs for as long as the app is open, so subscribe to changes (as above) or refetch on a slow interval; a counter that reads once goes stale.
961
978
  - **Share one implementation.** Count with the same function the widget badges with, so the background count and the open widget never disagree.
962
979
  - **`usePageBadge(null)` is a new signal.** An explicit `null` means "not known yet": nothing is written and the current badge stays. Pass `null` until your first load settles, so opening the page never flashes the badge to 0. `undefined`, `0` and any other value that is not a positive number still clear this widget's share, exactly as before, so `usePageBadge(data?.count)` behaves as it always has.
@@ -3210,11 +3227,11 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
3210
3227
  - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useWidgetRoute`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goToStartPage, scrollToSection, scrollIntoView, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `goToStartPage()` opens the signed-in user's own start page (the page the app opens on for them); `scrollToSection(sectionId)` scrolls the current page to one of its sections (bind the id with a `sectionRef` prop); `scrollIntoView(ref)` scrolls it to an element inside the calling widget; `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
3211
3228
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
3212
3229
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
3213
- - `Overlay` — the screen-level overlay (sc-6607). `<Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">…</Overlay>` renders its children OUTSIDE the widget's layout box on both hosts (web portals it to the document root, native uses the OS modal), so no clipping card, `ScrollView` or neighbouring widget can cut off a document/media preview, lightbox or confirm dialog — which absolute positioning inside the widget cannot achieve on either platform. Children stay in your own React tree, so state and hooks work normally. `onRequestClose` carries the backdrop press, Escape on web, and the Android back button; `size` is `sm` | `md` (default) | `lg` | `full`; the scrim, surface, radius, padding and elevation come from the workspace theme. An anchored dropdown still belongs inside your own root.
3230
+ - `Overlay` — the screen-level overlay. `<Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">…</Overlay>` renders its children OUTSIDE the widget's layout box on both hosts (web portals it to the document root, native uses the OS modal), so no clipping card, `ScrollView` or neighbouring widget can cut off a document/media preview, lightbox or confirm dialog — which absolute positioning inside the widget cannot achieve on either platform. Children stay in your own React tree, so state and hooks work normally. `onRequestClose` carries the backdrop press, Escape on web, and the Android back button; `size` is `sm` | `md` (default) | `lg` | `full`; the scrim, surface, radius, padding and elevation come from the workspace theme. An anchored dropdown still belongs inside your own root.
3214
3231
  - `ConfirmDialog` — the standard confirmation prompt, a preset of `Modal` and `Button`. `<ConfirmDialog visible={asking} tone="destructive" title="Delete this order?" message="This cannot be undone." confirmLabel="Delete" onCancel={() => setAsking(false)} onConfirm={removeOrder} />` asks before a delete or any other action that cannot be undone. Cancel, the close button, the backdrop, Escape and the Android back button fire `onCancel`; a promise returned from `onConfirm` keeps it busy (buttons disabled, a second press ignored) until it settles. Props: `visible`, `title`, `message`, `confirmLabel`, `cancelLabel`, `tone` (`default` | `destructive`), `busy`, `onConfirm`, `onCancel`, `closeAccessibilityLabel`, `testID`. Use it instead of building a confirmation from `Modal` yourself.
3215
3232
  - `Modal` — the standard dialog, built on `Overlay`. `<Modal visible={open} onRequestClose={() => setOpen(false)} title="Edit order" footer={<Button title="Save" onPress={save} />}>…</Modal>` draws a title, a close button, a body that scrolls when it is long and a footer action row laid out below it. Its background, corner radius, padding, scrim, shadow and title colour come from the theme's `modal` scope (Design > Components > Dialogs), so every dialog in an app matches. Props: `visible`, `onRequestClose`, `dismissible`, `title`, `footer`, `presentation` (`dialog` | `sheet`), `size` (`sm` | `md` | `lg` | `full`), `dismissOnBackdropPress`, `role`, `initialFocusRef`, `closeAccessibilityLabel`, `accessibilityLabel`, `testID`. Use it for every dialog instead of hand-building a header and an action row inside `Overlay`.
3216
- - `RichText` — the formatted-content renderer (sc-6970). `<RichText value={post.body} />` parses the markdown subset (`**bold**`, `*italic*` / `_italic_`, `` `code` ``, `#`/`##`/`###` headings, `-`/`*` bullets, `1.` ordered items, one-line `![alt](src "large")` images) and renders it with SDK primitives, so formatted content reads the same in the web Player and the exported Expo app. `value` is markdown, NOT HTML — widgets have no `dangerouslySetInnerHTML` on either host, so tags in a content string render as literal tag soup; stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value`, `renderImage` (`({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving one needs the scopes your own widget holds; without it only absolute `http(s)` URLs render), `style`, `testID`. Colour, type scale and leading come from the workspace theme — never re-style them.
3217
- - `MarkdownInput` — the authoring half of `<RichText>` (sc-6970). `<MarkdownInput value={draft} onChange={setDraft} />` is a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list, each toggling back off), and a live `<RichText>` preview of the result underneath — the way to let an app USER write formatted text, instead of a bare `TextInput` plus hand-rolled buttons that can only splice literal tags into the value. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default `Preview`), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (150) and `maxHeight` (280) so a long draft scrolls inside its box, `accessibilityLabel`, `style`, `testID`.
3233
+ - `RichText` — the formatted-content renderer. `<RichText value={post.body} />` parses the markdown subset (`**bold**`, `*italic*` / `_italic_`, `` `code` ``, `#`/`##`/`###` headings, `-`/`*` bullets, `1.` ordered items, one-line `![alt](src "large")` images) and renders it with SDK primitives, so formatted content reads the same in the web Player and the exported Expo app. `value` is markdown, NOT HTML — widgets have no `dangerouslySetInnerHTML` on either host, so tags in a content string render as literal tag soup; stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value`, `renderImage` (`({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving one needs the scopes your own widget holds; without it only absolute `http(s)` URLs render), `style`, `testID`. Colour, type scale and leading come from the workspace theme — never re-style them.
3234
+ - `MarkdownInput` — the authoring half of `<RichText>`. `<MarkdownInput value={draft} onChange={setDraft} />` is a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list, each toggling back off), and a live `<RichText>` preview of the result underneath — the way to let an app USER write formatted text, instead of a bare `TextInput` plus hand-rolled buttons that can only splice literal tags into the value. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default `Preview`), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (150) and `maxHeight` (280) so a long draft scrolls inside its box, `accessibilityLabel`, `style`, `testID`.
3218
3235
  - `parseMarkdown`, `markdownToPlainText`, `parseMarkdownImage`, `formatMarkdownImage`, `stripHtmlToMarkdown`, `MARKDOWN_IMAGE_SIZES` — the same grammar as pure helpers, for content you inspect rather than render: the block list, a marker-free projection (list previews, search, a11y labels), the one-line image form read and written, a legacy-HTML row normalised to markdown on read, and the closed size list (`small` | `medium` | `large` | `full`).
3219
3236
  - `isSafeMarkdownImageSrc`, `isHttpImageSrc`, `normaliseMarkdownImageSize`, `readMarkdownAlt` — the image half of that grammar, for a widget rendering images itself via `renderImage`. `isSafeMarkdownImageSrc` is the same allowlist the parser applies (an `http(s)` URL or a filestore id; a `javascript:` scheme is refused), so the widget and the parser cannot disagree about which src is renderable.
3220
3237
  - `List`, `ListRow`, `ListHeader`, `EmptyState` — the themed list building blocks. `<ListRow title subtitle leading trailing onPress selected divider>` draws one record row from the theme's `list` scope (padding, height, gap, radius, background, divider); `<List>` spaces its rows and hands overrides down; `<ListHeader>` labels a table's columns on the rows' own inset; `<EmptyState icon title message>` is the "nothing here" panel. Use them by default for record rows and a table's header; build your own row when it needs what `ListRow` cannot do (a swipe-to-delete row, a row that expands in place).
@@ -3263,8 +3280,8 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
3263
3280
  - **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
3264
3281
  - **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.
3265
3282
  - **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.
3266
- - **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 `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a count or size computed from the width (a calendar day, a chart, a card grid's cards per line), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers** (sc-8508): `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **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 in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? '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. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead — but a card grid or a tile row is never capped. **Lay out a card grid, a tile row or a grid of capped cells with `<Grid>` by default** — it measures its own unpadded box and applies the rules below; lay one out yourself when it needs what `<Grid>` cannot express, such as masonry or cells that span lines. **A card grid fills its lines, it never wraps freely** — a free `flexWrap` strands the remainder (six cards that fit five render 5 + 1). Count the cards that fit the measured width (`const max = Math.max(1, Math.floor((width + gap) / (CARD_MIN + gap)));`), take `const lines = Math.ceil(count / max); const cols = Math.ceil(count / lines);`, and render each slice of `cols` cards as a row of `{ flex: 1, minWidth: 0 }` cells, padding the last line with empty `{ flex: 1 }` spacers so its cards keep the width of the cards above (six that fit five are 3 + 3, ten that fit four are 4 + 4 + 2). **A short value never breaks across lines** — a time, date, price, count, percentage or code takes `numberOfLines={1}` and `flexShrink: 0`, never a fixed width narrower than it, and a narrow slot re-arranges or steps the font down instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
3267
- - **Compose forms — pair fields into rows, don't stack one per row.** Lay a form out with `<Form>` by default (above), which does the following for you; when it needs what `<Form>` cannot do (a multi-step wizard, a form beside a live preview, fields laid over a picture), lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. 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. A search box is `<SearchField>`, which already carries its glyph and clear control. Any other icon goes inside the field through `leadingIcon` / `trailingIcon`; never wrap a `<TextField>` in a bordered row, since it draws its own border. Put a unit beside the field as a sibling in a row (`flex: 1, minWidth: 0` on the field, so a long value shrinks instead of pushing the row past its container) rather than rebuilding the field to hold it: a hand-built bordered wrapper around a bare `TextInput` loses the app's input look and its label, focus and error handling.
3283
+ - **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 `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a count or size computed from the width (a calendar day, a chart, a card grid's cards per line), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers**: `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **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 in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? '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. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead — but a card grid or a tile row is never capped. **Lay out a card grid, a tile row or a grid of capped cells with `<Grid>` by default** — it measures its own unpadded box and applies the rules below; lay one out yourself when it needs what `<Grid>` cannot express, such as masonry or cells that span lines. **A card grid fills its lines, it never wraps freely** — a free `flexWrap` strands the remainder (six cards that fit five render 5 + 1). Count the cards that fit the measured width (`const max = Math.max(1, Math.floor((width + gap) / (CARD_MIN + gap)));`), take `const lines = Math.ceil(count / max); const cols = Math.ceil(count / lines);`, and render each slice of `cols` cards as a row of `{ flex: 1, minWidth: 0 }` cells, padding the last line with empty `{ flex: 1 }` spacers so its cards keep the width of the cards above (six that fit five are 3 + 3, ten that fit four are 4 + 4 + 2). **A short value never breaks across lines** — a time, date, price, count, percentage or code takes `numberOfLines={1}` and `flexShrink: 0`, never a fixed width narrower than it, and a narrow slot re-arranges or steps the font down instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
3284
+ - **Compose forms — pair fields into rows, don't stack one per row.** Lay a form out with `<Form>` by default (above), which does the following for you; when it needs what `<Form>` cannot do (a multi-step wizard, a form beside a live preview, fields laid over a picture), lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. 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 — 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. A search box is `<SearchField>`, which already carries its glyph and clear control. Any other icon goes inside the field through `leadingIcon` / `trailingIcon`; never wrap a `<TextField>` in a bordered row, since it draws its own border. Put a unit beside the field as a sibling in a row (`flex: 1, minWidth: 0` on the field, so a long value shrinks instead of pushing the row past its container) rather than rebuilding the field to hold it: a hand-built bordered wrapper around a bare `TextInput` loses the app's input look and its label, focus and error handling.
3268
3285
  - **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.
3269
3286
  - **Drag and drop — show what is being dragged.** Use `<SortableList>` for a list the user reorders and `<Board>` for cards moved between columns by default: they do everything this item describes, on both hosts, and give a user who cannot drag the same moves from the handle. When the request needs what they cannot do (free-form positioning on a canvas, a drag between two widgets), build your own as follows. 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`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` when `widthTier(width) === "narrow"`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
3270
3287
  - **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).
@@ -3422,7 +3439,7 @@ The server-side gate is `canGrant` on the target record — Studio owners pass a
3422
3439
 
3423
3440
  ## Resolving bound columns, a stable query, and a write-permission floor
3424
3441
 
3425
- `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.
3442
+ `useBoundColumns`, `useStableQuery`, and `useCanWrite` 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.
3426
3443
 
3427
3444
  ```js
3428
3445
  import {
@@ -3599,17 +3616,17 @@ const wiring = lintStyleWiring(manifest, { "widget.jsx": source });
3599
3616
  const editability = lintEditability(manifest, { "widget.jsx": source });
3600
3617
  ```
3601
3618
 
3602
- Two rules keep a widget's look reachable from the Studio (sc-6455). `no-hardcoded-design` flags a colour literal, a `fontFamily` string, or a numeric `fontSize` — values that outrank the theme permanently and that no control can move. `style-field-unread` flags a `styleSchema` field the source never reads, which renders a control that does nothing. Both are warnings for a human author and blocking for the AI widget agent. License a genuinely un-tokenizable value with a preceding `// appstudio-design-ok: <reason>` (the reason is required); see *What's new in 0.110.0*.
3619
+ Two rules keep a widget's look reachable from the Studio. `no-hardcoded-design` flags a colour literal, a `fontFamily` string, or a numeric `fontSize` — values that outrank the theme permanently and that no control can move. `style-field-unread` flags a `styleSchema` field the source never reads, which renders a control that does nothing. Both are warnings for a human author and blocking for the AI widget agent. License a genuinely un-tokenizable value with a preceding `// appstudio-design-ok: <reason>` (the reason is required); see *What's new in 0.110.0*.
3603
3620
 
3604
- Three more rules keep a widget EDITABLE once it ships (sc-7799), via the bundle-level `lintEditability(manifest, files)`. `style-group-missing` flags a `styleSchema` field with no `ui.group` — or one under a catch-all name — so a long Style section reads as labelled per-element fieldsets. `style-elements-uncovered` flags a multi-element widget whose Style section still only restyles the widget as a whole. `text-literal-unexposed` flags copy pinned in a `<Text>` — wording no control can change and no locale can translate. All three are warnings here and blocking for the AI widget agent; see *What's new in 0.140.0*.
3621
+ Three more rules keep a widget EDITABLE once it ships, via the bundle-level `lintEditability(manifest, files)`. `style-group-missing` flags a `styleSchema` field with no `ui.group` — or one under a catch-all name — so a long Style section reads as labelled per-element fieldsets. `style-elements-uncovered` flags a multi-element widget whose Style section still only restyles the widget as a whole. `text-literal-unexposed` flags copy pinned in a `<Text>` — wording no control can change and no locale can translate. All three are warnings here and blocking for the AI widget agent; see *What's new in 0.140.0*.
3605
3622
 
3606
3623
  Pass `--manifest` to enable `style-field-unread` and the three editability rules — they need the manifest, and every source at once so a split-impl widget's per-host reads are seen together.
3607
3624
 
3608
- `flex-basis-percent` flags a literal percentage `flexBasis` (sc-7274) — `"100%"`, `"50%"`, or the ternary a responsive cell writes. `flex-basis` sizes the MAIN axis, so in a column parent it asks for the parent's HEIGHT: under an ancestor that makes that height definite (a Grid cell stretches its child on the web Player) the child fills the container and, since a `View` never shrinks, overflows over everything below it. Say full-width with `width: "100%"` — correct in a column AND in a wrap row — and keep a NUMBER for a wrap threshold. A warning, because the percentage is right in a row and a text scan cannot see the parent.
3625
+ `flex-basis-percent` flags a literal percentage `flexBasis` — `"100%"`, `"50%"`, or the ternary a responsive cell writes. `flex-basis` sizes the MAIN axis, so in a column parent it asks for the parent's HEIGHT: under an ancestor that makes that height definite (a Grid cell stretches its child on the web Player) the child fills the container and, since a `View` never shrinks, overflows over everything below it. Say full-width with `width: "100%"` — correct in a column AND in a wrap row — and keep a NUMBER for a wrap threshold. A warning, because the percentage is right in a row and a text scan cannot see the parent.
3609
3626
 
3610
- `row-action-not-gated` flags a widget that calls `update` or `delete` from `useDatastoreMutation` without a per-row permission check (sc-9132): no `_capabilities` read and no `useCanWrite(tableId, { recordId })`. The table-level `useCanWrite(tableId)` only answers "may create", so a list gated on it shows Edit / Delete on rows the server will refuse. A warning.
3627
+ `row-action-not-gated` flags a widget that calls `update` or `delete` from `useDatastoreMutation` without a per-row permission check: no `_capabilities` read and no `useCanWrite(tableId, { recordId })`. The table-level `useCanWrite(tableId)` only answers "may create", so a list gated on it shows Edit / Delete on rows the server will refuse. A warning.
3611
3628
 
3612
- `no-html-in-content` flags an HTML tag inside a **string** (sc-6970) — `"<strong>"`, `"<br>"`, `"<p>…</p>"`. There is no HTML renderer on either host, so the tag reaches the reader as literal text; author formatted text with `<MarkdownInput>` and render it with `<RichText>` instead. Only string and template content is scanned, so your own `<View>` / `<Text>` JSX can never trip it. A warning for a human author, blocking for the AI widget agent.
3629
+ `no-html-in-content` flags an HTML tag inside a **string** — `"<strong>"`, `"<br>"`, `"<p>…</p>"`. There is no HTML renderer on either host, so the tag reaches the reader as literal text; author formatted text with `<MarkdownInput>` and render it with `<RichText>` instead. Only string and template content is scanned, so your own `<View>` / `<Text>` JSX can never trip it. A warning for a human author, blocking for the AI widget agent.
3613
3630
 
3614
3631
  ## Local dev loop (`appstudio-widget dev`)
3615
3632
 
package/dist/contract.cjs CHANGED
@@ -2158,6 +2158,41 @@ const HOOKS = [
2158
2158
  requiredContextSlice: ["agents.conversations"],
2159
2159
  scopes: ["ai.invoke:*"],
2160
2160
  },
2161
+ {
2162
+ name: "useAction",
2163
+ signature: "useAction(actionRef)",
2164
+ description:
2165
+ "sc-13843 — run one of the WORKSPACE's Actions (a server-side script) " +
2166
+ "and read what it returned. IMPERATIVE: call run(input) from an event " +
2167
+ "handler, never on mount or in render — an Action can write data and " +
2168
+ "send messages, so placing a widget must not run it. run resolves with " +
2169
+ "the value the Action passed to respond() (any JSON, or null when it " +
2170
+ "responded with nothing) and `result` holds the last one. The Action " +
2171
+ "must carry the app trigger, or the server refuses the run with " +
2172
+ "ACTION_NOT_APP_INVOKABLE — the same gate a Button's Run action meets. " +
2173
+ "`actionRef` is an Action id an author bound through an `actionRef` " +
2174
+ "property — when it is missing, or the host brokers no actions client, " +
2175
+ "`available` is false and run() rejects UNAVAILABLE; render a visible " +
2176
+ "'pick an action' state, NEVER a blank box. A changed binding clears " +
2177
+ "`result` and `error`, and a run that settles after the binding changed " +
2178
+ "(or after unmount) writes nothing. ActionError.code: " +
2179
+ "INVALID_ACTION_INPUT (400) — the input was refused; " +
2180
+ "ACTION_NOT_APP_INVOKABLE (403) and ACTION_NOT_FOUND (404) will NOT " +
2181
+ "clear on a retry, so hide the affordance; RATE_LIMITED (429) will; " +
2182
+ "ACTION_FAILED — the Action ran and failed, or no answer arrived. NEVER " +
2183
+ "show an app user a raw error. A text answer from a bot is often " +
2184
+ "MARKDOWN, so render it with <RichText value={result} />, not <Text>. " +
2185
+ "Reads ctx.actions.",
2186
+ returnShape: {
2187
+ available: "boolean // false when no Action is bound, or the host brokers none",
2188
+ run: "(input) => Promise<unknown> // input is optional; resolves with the Action's respond() value, or null; rejects with ActionError",
2189
+ running: "boolean // true while any run is in flight",
2190
+ result: "unknown // the last successful run's result; null before the first run",
2191
+ error: "ActionError | null",
2192
+ },
2193
+ requiredContextSlice: ["actions.run"],
2194
+ scopes: ["actions.run:*"],
2195
+ },
2161
2196
  {
2162
2197
  name: "useDatastoreMutation",
2163
2198
  signature: "useDatastoreMutation(tableId)",
@@ -3728,6 +3763,8 @@ const ACTION_SCRIPT_GLOBALS = [
3728
3763
  "triggeredBy",
3729
3764
  // sc-9031 — a Run action event's parameters; null on every other trigger.
3730
3765
  "input",
3766
+ // sc-13843 — hands the app run its one JSON result.
3767
+ "respond",
3731
3768
  ];
3732
3769
  // Mirrors action.service.js SCRIPT_MAX_BYTES.
3733
3770
  const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
@@ -3881,7 +3918,7 @@ const MANIFEST_SCHEMA = {
3881
3918
  type: "boolean",
3882
3919
  required: false,
3883
3920
  description:
3884
- "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3921
+ "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification, account actions and Actions (useAction) all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3885
3922
  default: false,
3886
3923
  },
3887
3924
  prefersFitToScreen: {
@@ -4082,6 +4119,17 @@ const WIDGET_CONTEXT_SHAPE = {
4082
4119
  required: false,
4083
4120
  fields: { conversations: "function" },
4084
4121
  },
4122
+ actions: {
4123
+ description:
4124
+ "sc-13843 — injected @colixsystems/actions-client instance. " +
4125
+ "{ run(actionId, input?) -> Promise<{ ok, result }> }. Backs useAction(). " +
4126
+ "Runs one Action as the signed-in caller; the server refuses an Action " +
4127
+ "without the app trigger. Errors carry the server's `code` and `status`. " +
4128
+ "Absent on a host that brokers no actions client, where useAction() " +
4129
+ "degrades to available: false rather than throwing.",
4130
+ required: false,
4131
+ fields: { run: "function" },
4132
+ },
4085
4133
  linkPreviews: {
4086
4134
  description:
4087
4135
  "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
@@ -6379,7 +6427,10 @@ const CONTRACT = deepFreeze({
6379
6427
  // initials when its picture fails to load. No API change.
6380
6428
  // 1.211.0: additive (sc-9168) — `maxFontSizeMultiplier`: native `<Text>` /
6381
6429
  // `<TextInput>` follow the device font scale up to this bound.
6382
- version: "1.211.0",
6430
+ // 1.212.0: additive (sc-13843) — `useAction(actionRef)` + the `actions` host
6431
+ // client + the `actionRef` propertySchema type + the `actions.run:*`
6432
+ // scope. A widget runs one of the workspace's Actions and reads its result.
6433
+ version: "1.212.0",
6383
6434
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6384
6435
  hooks: HOOKS,
6385
6436
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -2155,6 +2155,41 @@ const HOOKS = [
2155
2155
  requiredContextSlice: ["agents.conversations"],
2156
2156
  scopes: ["ai.invoke:*"],
2157
2157
  },
2158
+ {
2159
+ name: "useAction",
2160
+ signature: "useAction(actionRef)",
2161
+ description:
2162
+ "sc-13843 — run one of the WORKSPACE's Actions (a server-side script) " +
2163
+ "and read what it returned. IMPERATIVE: call run(input) from an event " +
2164
+ "handler, never on mount or in render — an Action can write data and " +
2165
+ "send messages, so placing a widget must not run it. run resolves with " +
2166
+ "the value the Action passed to respond() (any JSON, or null when it " +
2167
+ "responded with nothing) and `result` holds the last one. The Action " +
2168
+ "must carry the app trigger, or the server refuses the run with " +
2169
+ "ACTION_NOT_APP_INVOKABLE — the same gate a Button's Run action meets. " +
2170
+ "`actionRef` is an Action id an author bound through an `actionRef` " +
2171
+ "property — when it is missing, or the host brokers no actions client, " +
2172
+ "`available` is false and run() rejects UNAVAILABLE; render a visible " +
2173
+ "'pick an action' state, NEVER a blank box. A changed binding clears " +
2174
+ "`result` and `error`, and a run that settles after the binding changed " +
2175
+ "(or after unmount) writes nothing. ActionError.code: " +
2176
+ "INVALID_ACTION_INPUT (400) — the input was refused; " +
2177
+ "ACTION_NOT_APP_INVOKABLE (403) and ACTION_NOT_FOUND (404) will NOT " +
2178
+ "clear on a retry, so hide the affordance; RATE_LIMITED (429) will; " +
2179
+ "ACTION_FAILED — the Action ran and failed, or no answer arrived. NEVER " +
2180
+ "show an app user a raw error. A text answer from a bot is often " +
2181
+ "MARKDOWN, so render it with <RichText value={result} />, not <Text>. " +
2182
+ "Reads ctx.actions.",
2183
+ returnShape: {
2184
+ available: "boolean // false when no Action is bound, or the host brokers none",
2185
+ run: "(input) => Promise<unknown> // input is optional; resolves with the Action's respond() value, or null; rejects with ActionError",
2186
+ running: "boolean // true while any run is in flight",
2187
+ result: "unknown // the last successful run's result; null before the first run",
2188
+ error: "ActionError | null",
2189
+ },
2190
+ requiredContextSlice: ["actions.run"],
2191
+ scopes: ["actions.run:*"],
2192
+ },
2158
2193
  {
2159
2194
  name: "useDatastoreMutation",
2160
2195
  signature: "useDatastoreMutation(tableId)",
@@ -3725,6 +3760,8 @@ const ACTION_SCRIPT_GLOBALS = [
3725
3760
  "triggeredBy",
3726
3761
  // sc-9031 — a Run action event's parameters; null on every other trigger.
3727
3762
  "input",
3763
+ // sc-13843 — hands the app run its one JSON result.
3764
+ "respond",
3728
3765
  ];
3729
3766
  // Mirrors action.service.js SCRIPT_MAX_BYTES.
3730
3767
  const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
@@ -3878,7 +3915,7 @@ const MANIFEST_SCHEMA = {
3878
3915
  type: "boolean",
3879
3916
  required: false,
3880
3917
  description:
3881
- "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3918
+ "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification, account actions and Actions (useAction) all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3882
3919
  default: false,
3883
3920
  },
3884
3921
  prefersFitToScreen: {
@@ -4079,6 +4116,17 @@ const WIDGET_CONTEXT_SHAPE = {
4079
4116
  required: false,
4080
4117
  fields: { conversations: "function" },
4081
4118
  },
4119
+ actions: {
4120
+ description:
4121
+ "sc-13843 — injected @colixsystems/actions-client instance. " +
4122
+ "{ run(actionId, input?) -> Promise<{ ok, result }> }. Backs useAction(). " +
4123
+ "Runs one Action as the signed-in caller; the server refuses an Action " +
4124
+ "without the app trigger. Errors carry the server's `code` and `status`. " +
4125
+ "Absent on a host that brokers no actions client, where useAction() " +
4126
+ "degrades to available: false rather than throwing.",
4127
+ required: false,
4128
+ fields: { run: "function" },
4129
+ },
4082
4130
  linkPreviews: {
4083
4131
  description:
4084
4132
  "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
@@ -6376,7 +6424,10 @@ const CONTRACT = deepFreeze({
6376
6424
  // initials when its picture fails to load. No API change.
6377
6425
  // 1.211.0: additive (sc-9168) — `maxFontSizeMultiplier`: native `<Text>` /
6378
6426
  // `<TextInput>` follow the device font scale up to this bound.
6379
- version: "1.211.0",
6427
+ // 1.212.0: additive (sc-13843) — `useAction(actionRef)` + the `actions` host
6428
+ // client + the `actionRef` propertySchema type + the `actions.run:*`
6429
+ // scope. A widget runs one of the workspace's Actions and reads its result.
6430
+ version: "1.212.0",
6380
6431
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6381
6432
  hooks: HOOKS,
6382
6433
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -3843,6 +3843,140 @@ export function useAgent(agentRef) {
3843
3843
  };
3844
3844
  }
3845
3845
 
3846
+ /**
3847
+ * sc-13843 — structured error thrown by `useAction().run`. Carries a stable
3848
+ * `code` so a widget branches without parsing message strings.
3849
+ *
3850
+ * `code` is one of:
3851
+ * - "UNAVAILABLE" — no Action is bound, or the host brokers no
3852
+ * actions client (render the unbound state)
3853
+ * - "INVALID_ACTION_INPUT" — 400 (the input is not plain JSON within the
3854
+ * limits, or the Action's own check refused it)
3855
+ * - "ACTION_NOT_APP_INVOKABLE" — 403 (the workspace has not let the app run
3856
+ * this Action; retrying will not help)
3857
+ * - "ACTION_NOT_FOUND" — 404 (the bound Action was deleted)
3858
+ * - "RATE_LIMITED" — 429 (too many runs — retry shortly)
3859
+ * - "AUTH_REQUIRED" — 401 (no signed-in app user)
3860
+ * - "TOKEN_EXPIRED" / "TOKEN_INVALID" — 401 (the app user's session ended)
3861
+ * - "ACTION_FAILED" — the Action ran and failed, or no answer came
3862
+ *
3863
+ * The actions client owns these codes; any other server code passes through.
3864
+ */
3865
+ export class ActionError extends Error {
3866
+ constructor(code, message, opts) {
3867
+ super(message);
3868
+ this.name = "ActionError";
3869
+ this.code = code;
3870
+ if (opts && opts.cause) this.cause = opts.cause;
3871
+ }
3872
+ }
3873
+
3874
+ function toActionError(err) {
3875
+ if (err instanceof ActionError) return err;
3876
+ const code = err && typeof err.code === "string" && err.code ? err.code : "ACTION_FAILED";
3877
+ const message = (err && err.message) || "The action could not run";
3878
+ return new ActionError(code, message, { cause: err });
3879
+ }
3880
+
3881
+ /**
3882
+ * sc-13843 — run one of the workspace's Actions and read what it returned.
3883
+ * Returns `{ available, run, running, result, error }`.
3884
+ *
3885
+ * run(input?) → Promise<result> the Action's `respond()` value, or null
3886
+ *
3887
+ * IMPERATIVE — it never runs on mount or render; call `run` from an event
3888
+ * handler. An Action can write data and send messages, so running it because
3889
+ * a widget was placed would be a side effect nobody asked for.
3890
+ *
3891
+ * `actionRef` is the Action id an author bound through an `actionRef`
3892
+ * property. When it is absent, or the host brokers no actions client,
3893
+ * `available` is false and `run` rejects UNAVAILABLE rather than throwing at
3894
+ * render — the widget must render a visible unbound state (CLAUDE.md §8).
3895
+ *
3896
+ * Routes through the injected `@colixsystems/actions-client` at `ctx.actions`,
3897
+ * so the Player and the Expo export resolve against the identical client.
3898
+ */
3899
+ export function useAction(actionRef) {
3900
+ const ctx = useWidgetContextOrThrow("useAction");
3901
+ const available =
3902
+ Boolean(actionRef) &&
3903
+ Boolean(ctx.actions) &&
3904
+ typeof ctx.actions.run === "function";
3905
+
3906
+ const clientRef = useRef(null);
3907
+ clientRef.current = available ? ctx.actions : null;
3908
+ const actionIdRef = useRef(actionRef);
3909
+ actionIdRef.current = actionRef;
3910
+
3911
+ // Bumped in render on a rebind, so an A→B→A rebind never resurfaces A's result.
3912
+ const generationRef = useRef(0);
3913
+ const boundRef = useRef(actionRef);
3914
+ if (boundRef.current !== actionRef) {
3915
+ boundRef.current = actionRef;
3916
+ generationRef.current += 1;
3917
+ }
3918
+
3919
+ const mountedRef = useRef(true);
3920
+ useEffect(() => {
3921
+ mountedRef.current = true;
3922
+ return () => {
3923
+ mountedRef.current = false;
3924
+ };
3925
+ }, []);
3926
+
3927
+ const latestRunRef = useRef(0);
3928
+ // `pending` counts this binding's runs, so an old binding's run never shows.
3929
+ const [outcome, setOutcome] = useState({ generation: 0, pending: 0, result: null, error: null });
3930
+
3931
+ const run = useCallback(async (input) => {
3932
+ const client = clientRef.current;
3933
+ const actionId = actionIdRef.current;
3934
+ if (!client || !actionId) {
3935
+ throw new ActionError("UNAVAILABLE", "No action is available for this host");
3936
+ }
3937
+ const generation = generationRef.current;
3938
+ const runId = ++latestRunRef.current;
3939
+ // Only the newest run on the shown binding may write its outcome.
3940
+ const settle = (next) => {
3941
+ if (!mountedRef.current) return;
3942
+ const latest = runId === latestRunRef.current;
3943
+ setOutcome((prior) => {
3944
+ if (prior.generation !== generation) return prior;
3945
+ const pending = Math.max(0, prior.pending - 1);
3946
+ return latest ? { ...prior, ...next, pending } : { ...prior, pending };
3947
+ });
3948
+ };
3949
+
3950
+ setOutcome((prior) =>
3951
+ prior.generation === generation
3952
+ ? { ...prior, pending: prior.pending + 1, error: null }
3953
+ : { generation, pending: 1, result: null, error: null },
3954
+ );
3955
+ try {
3956
+ const response = await client.run(actionId, input);
3957
+ if (!response || response.ok !== true) {
3958
+ throw new ActionError("ACTION_FAILED", "The action did not complete");
3959
+ }
3960
+ const value = response.result === undefined ? null : response.result;
3961
+ settle({ result: value, error: null });
3962
+ return value;
3963
+ } catch (err) {
3964
+ const e = toActionError(err);
3965
+ settle({ error: e });
3966
+ throw e;
3967
+ }
3968
+ }, []);
3969
+
3970
+ const current = outcome.generation === generationRef.current;
3971
+ return {
3972
+ available,
3973
+ run,
3974
+ running: current && outcome.pending > 0,
3975
+ result: current ? outcome.result : null,
3976
+ error: current ? outcome.error : null,
3977
+ };
3978
+ }
3979
+
3846
3980
  /**
3847
3981
  * Datastore mutation hook. Returns { create, update, delete }, each method
3848
3982
  * returning a Promise. Routes through the injected
package/dist/index.d.ts CHANGED
@@ -885,6 +885,10 @@ export interface WidgetContext<TProps = unknown> {
885
885
  identification: IdentificationClient;
886
886
  /** sc-7991 — injected @colixsystems/agents-client backing useAgent(). */
887
887
  agents?: { conversations(agentId: string): unknown };
888
+ /** sc-13843 — injected @colixsystems/actions-client backing useAction(). */
889
+ actions?: {
890
+ run(actionId: string, input?: Record<string, unknown> | null): Promise<{ ok: boolean; result: unknown }>;
891
+ };
888
892
  /** Injected @colixsystems/link-preview-client backing useLinkPreview(). */
889
893
  linkPreviews?: { get(url: string): Promise<LinkPreview> };
890
894
  /** Host child-node renderer; backs WidgetTree / useChildRenderer. */
@@ -1858,6 +1862,28 @@ export interface AgentApi {
1858
1862
  */
1859
1863
  export function useAgent(agentRef: string | null | undefined): AgentApi;
1860
1864
 
1865
+ export interface ActionApi {
1866
+ /** False when no Action is bound, or the host brokers no actions client. */
1867
+ available: boolean;
1868
+ /**
1869
+ * Imperative — call from an event handler, never on mount. Resolves with the
1870
+ * value the Action passed to `respond()`, or null; rejects with ActionError.
1871
+ */
1872
+ run(input?: Record<string, unknown> | null): Promise<unknown>;
1873
+ /** True while any run is in flight. */
1874
+ running: boolean;
1875
+ /** The last successful run's result; cleared when the binding changes. */
1876
+ result: unknown;
1877
+ error: ActionError | null;
1878
+ }
1879
+
1880
+ /**
1881
+ * sc-13843 — run one of the workspace's Actions and read its result. The
1882
+ * Action must have the app trigger; the widget needs `actions.run:*` in
1883
+ * `requestedScopes`. Never runs on mount.
1884
+ */
1885
+ export function useAction(actionRef: string | null | undefined): ActionApi;
1886
+
1861
1887
  // REQ-RT-07 realtime subscription transport state.
1862
1888
  export type DatastoreSubscriptionStatus =
1863
1889
  | "connecting"
@@ -3140,6 +3166,27 @@ export class AgentError extends Error {
3140
3166
  constructor(code: string, message: string, opts?: { cause?: unknown });
3141
3167
  }
3142
3168
 
3169
+ /**
3170
+ * sc-13843 — structured error thrown by `useAction().run`. Branch on `code`:
3171
+ * `ACTION_NOT_APP_INVOKABLE` and `ACTION_NOT_FOUND` will not clear on a retry,
3172
+ * `RATE_LIMITED` will. `AUTH_REQUIRED`, `TOKEN_EXPIRED` and `TOKEN_INVALID`
3173
+ * mean the app user must sign in. Never show an app user the raw message.
3174
+ */
3175
+ export class ActionError extends Error {
3176
+ code:
3177
+ | "UNAVAILABLE"
3178
+ | "INVALID_ACTION_INPUT"
3179
+ | "ACTION_NOT_APP_INVOKABLE"
3180
+ | "ACTION_NOT_FOUND"
3181
+ | "RATE_LIMITED"
3182
+ | "AUTH_REQUIRED"
3183
+ | "TOKEN_EXPIRED"
3184
+ | "TOKEN_INVALID"
3185
+ | "ACTION_FAILED"
3186
+ | (string & {});
3187
+ constructor(code: string, message: string, opts?: { cause?: unknown });
3188
+ }
3189
+
3143
3190
  /**
3144
3191
  * Error surfaced by `useLinkPreview().error`. A page that simply has no
3145
3192
  * preview is not an error. `retryAfter` (seconds) is set on RATE_LIMITED.
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ export {
29
29
  DirectoryError,
30
30
  PermissionError,
31
31
  AgentError,
32
+ ActionError,
32
33
  useDatastoreQuery,
33
34
  useDatastoreRecord,
34
35
  useDatastoreSchema,
@@ -37,6 +38,7 @@ export {
37
38
  useCanWrite,
38
39
  useInterpretDraft,
39
40
  useAgent,
41
+ useAction,
40
42
  useAsset,
41
43
  useAssetsByTag,
42
44
  useBundledAsset,
@@ -29,6 +29,7 @@ export {
29
29
  DirectoryError,
30
30
  PermissionError,
31
31
  AgentError,
32
+ ActionError,
32
33
  useDatastoreQuery,
33
34
  useDatastoreRecord,
34
35
  useDatastoreSchema,
@@ -37,6 +38,7 @@ export {
37
38
  useCanWrite,
38
39
  useInterpretDraft,
39
40
  useAgent,
41
+ useAction,
40
42
  useAsset,
41
43
  useAssetsByTag,
42
44
  useBundledAsset,
package/dist/linter.cjs CHANGED
@@ -740,6 +740,17 @@ function _scopeRules(source, manifest) {
740
740
  });
741
741
  }
742
742
  }
743
+
744
+ // sc-13843 — DISCLOSURE, like ai.invoke: the server gate is the app-user JWT
745
+ // plus the Action's app trigger, never this scope.
746
+ if (/\buseAction\s*\(/.test(source) && !declared.has("actions.run:*")) {
747
+ findings.push({
748
+ rule: "scope-required-for-useAction",
749
+ label: "useAction() requires `actions.run:*` in manifest.requestedScopes",
750
+ line: 0,
751
+ snippet: "",
752
+ });
753
+ }
743
754
  const lines = source.split(/\r?\n/);
744
755
  let firedUsersWrite = false;
745
756
  let firedUsersDelete = false;
package/dist/linter.js CHANGED
@@ -695,6 +695,17 @@ function _scopeRules(source, manifest) {
695
695
  });
696
696
  }
697
697
  }
698
+
699
+ // sc-13843 — DISCLOSURE, like ai.invoke: the server gate is the app-user JWT
700
+ // plus the Action's app trigger, never this scope.
701
+ if (/\buseAction\s*\(/.test(source) && !declared.has("actions.run:*")) {
702
+ findings.push({
703
+ rule: "scope-required-for-useAction",
704
+ label: "useAction() requires `actions.run:*` in manifest.requestedScopes",
705
+ line: 0,
706
+ snippet: "",
707
+ });
708
+ }
698
709
  const lines = source.split(/\r?\n/);
699
710
  let firedUsersWrite = false;
700
711
  let firedUsersDelete = false;
@@ -28,6 +28,9 @@ const VALID_TYPES = new Set([
28
28
  // and calls `useAgent(agentRef)`. Bare uuid string like `groupRef`/`pageRef`,
29
29
  // so tenant-copy remaps it through the layout walk's merged id map.
30
30
  "agentRef",
31
+ // sc-13843: `actionRef` picks one of the workspace's Actions, a bare
32
+ // Action UUID a widget passes to `useAction(actionRef)`. Remapped like agentRef.
33
+ "actionRef",
31
34
  // REQ-WDG-PAGEREF: `pageRef` is a page picker that emits a bare AppPage
32
35
  // UUID into the page JSON. Renders a page `<select>` in the Studio
33
36
  // Properties Panel; a widget reads it and navigates via
@@ -160,6 +163,7 @@ function coerceLeaf(def, value, path, errors) {
160
163
  case "pageRef":
161
164
  case "sectionRef":
162
165
  case "agentRef":
166
+ case "actionRef":
163
167
  case "richText":
164
168
  case "asset":
165
169
  case "folderRef":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.261.0",
3
+ "version": "0.262.0",
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",
package/src/contract.js CHANGED
@@ -2155,6 +2155,41 @@ const HOOKS = [
2155
2155
  requiredContextSlice: ["agents.conversations"],
2156
2156
  scopes: ["ai.invoke:*"],
2157
2157
  },
2158
+ {
2159
+ name: "useAction",
2160
+ signature: "useAction(actionRef)",
2161
+ description:
2162
+ "sc-13843 — run one of the WORKSPACE's Actions (a server-side script) " +
2163
+ "and read what it returned. IMPERATIVE: call run(input) from an event " +
2164
+ "handler, never on mount or in render — an Action can write data and " +
2165
+ "send messages, so placing a widget must not run it. run resolves with " +
2166
+ "the value the Action passed to respond() (any JSON, or null when it " +
2167
+ "responded with nothing) and `result` holds the last one. The Action " +
2168
+ "must carry the app trigger, or the server refuses the run with " +
2169
+ "ACTION_NOT_APP_INVOKABLE — the same gate a Button's Run action meets. " +
2170
+ "`actionRef` is an Action id an author bound through an `actionRef` " +
2171
+ "property — when it is missing, or the host brokers no actions client, " +
2172
+ "`available` is false and run() rejects UNAVAILABLE; render a visible " +
2173
+ "'pick an action' state, NEVER a blank box. A changed binding clears " +
2174
+ "`result` and `error`, and a run that settles after the binding changed " +
2175
+ "(or after unmount) writes nothing. ActionError.code: " +
2176
+ "INVALID_ACTION_INPUT (400) — the input was refused; " +
2177
+ "ACTION_NOT_APP_INVOKABLE (403) and ACTION_NOT_FOUND (404) will NOT " +
2178
+ "clear on a retry, so hide the affordance; RATE_LIMITED (429) will; " +
2179
+ "ACTION_FAILED — the Action ran and failed, or no answer arrived. NEVER " +
2180
+ "show an app user a raw error. A text answer from a bot is often " +
2181
+ "MARKDOWN, so render it with <RichText value={result} />, not <Text>. " +
2182
+ "Reads ctx.actions.",
2183
+ returnShape: {
2184
+ available: "boolean // false when no Action is bound, or the host brokers none",
2185
+ run: "(input) => Promise<unknown> // input is optional; resolves with the Action's respond() value, or null; rejects with ActionError",
2186
+ running: "boolean // true while any run is in flight",
2187
+ result: "unknown // the last successful run's result; null before the first run",
2188
+ error: "ActionError | null",
2189
+ },
2190
+ requiredContextSlice: ["actions.run"],
2191
+ scopes: ["actions.run:*"],
2192
+ },
2158
2193
  {
2159
2194
  name: "useDatastoreMutation",
2160
2195
  signature: "useDatastoreMutation(tableId)",
@@ -3725,6 +3760,8 @@ const ACTION_SCRIPT_GLOBALS = [
3725
3760
  "triggeredBy",
3726
3761
  // sc-9031 — a Run action event's parameters; null on every other trigger.
3727
3762
  "input",
3763
+ // sc-13843 — hands the app run its one JSON result.
3764
+ "respond",
3728
3765
  ];
3729
3766
  // Mirrors action.service.js SCRIPT_MAX_BYTES.
3730
3767
  const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
@@ -3878,7 +3915,7 @@ const MANIFEST_SCHEMA = {
3878
3915
  type: "boolean",
3879
3916
  required: false,
3880
3917
  description:
3881
- "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3918
+ "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification, account actions and Actions (useAction) all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3882
3919
  default: false,
3883
3920
  },
3884
3921
  prefersFitToScreen: {
@@ -4079,6 +4116,17 @@ const WIDGET_CONTEXT_SHAPE = {
4079
4116
  required: false,
4080
4117
  fields: { conversations: "function" },
4081
4118
  },
4119
+ actions: {
4120
+ description:
4121
+ "sc-13843 — injected @colixsystems/actions-client instance. " +
4122
+ "{ run(actionId, input?) -> Promise<{ ok, result }> }. Backs useAction(). " +
4123
+ "Runs one Action as the signed-in caller; the server refuses an Action " +
4124
+ "without the app trigger. Errors carry the server's `code` and `status`. " +
4125
+ "Absent on a host that brokers no actions client, where useAction() " +
4126
+ "degrades to available: false rather than throwing.",
4127
+ required: false,
4128
+ fields: { run: "function" },
4129
+ },
4082
4130
  linkPreviews: {
4083
4131
  description:
4084
4132
  "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
@@ -6376,7 +6424,10 @@ const CONTRACT = deepFreeze({
6376
6424
  // initials when its picture fails to load. No API change.
6377
6425
  // 1.211.0: additive (sc-9168) — `maxFontSizeMultiplier`: native `<Text>` /
6378
6426
  // `<TextInput>` follow the device font scale up to this bound.
6379
- version: "1.211.0",
6427
+ // 1.212.0: additive (sc-13843) — `useAction(actionRef)` + the `actions` host
6428
+ // client + the `actionRef` propertySchema type + the `actions.run:*`
6429
+ // scope. A widget runs one of the workspace's Actions and reads its result.
6430
+ version: "1.212.0",
6380
6431
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6381
6432
  hooks: HOOKS,
6382
6433
  primitives: PRIMITIVES,