# @colixsystems/widget-sdk

Common widget interface for AppStudio. This package is **core only** — it implements the contract that every widget (built-in or third-party, web or native) speaks: a `WidgetManifest`, a `WidgetContext`, a property schema, the primitives + rendering surface, the helper hooks, events, theme/i18n, and the static linter that gates submissions. **It owns no HTTP and depends on none of the data SDK packages.**

The data layer lives in **four separate domain-client packages**, each instantiated by the host and **injected into `WidgetContext`**. Widgets never import those packages — they reach the data surface only through this SDK's hooks, which read the injected client instances:

| Injected at | Package | Surface (snake_case verbatim, `list` → `{ data, meta }`) |
| ----------- | ------- | -------- |
| `ctx.datastore` | `@colixsystems/datastore-client` | `tables.{list,get}`, `schema(tableId)`, `myPermissions(tableId, { recordId? })`, `records(tableId).{ list(query), get(id), create(values), update(id,values) [PATCH], delete(id), aggregate(spec), permissions(recordId).{ list, grant, update, revoke } }` |
| `ctx.directory` | `@colixsystems/directory-client` | `me()`, `users.{list,get,invite,deactivate,reactivate}`, `groups.{list,create,remove,addMember,removeMember,listMine}`, `invites.{list,revoke,resend}` |
| `ctx.assets` | `@colixsystems/assets-client` | the Asset Manager: `get(id)`, `list(query)`, `upload(formData)` over `/files` — what `useAsset()` (single asset by id) and `useAssetsByTag()` (every asset carrying a tag) resolve |
| `ctx.payments` | `@colixsystems/payments-client` | `requestPayment(body)`, `getPayment(id)` |

**Wire / casing: snake_case end to end.** The clients send and return snake_case **verbatim** (`created_at`, `group_ids`, `can_read`, `amount_cents`, `data_type`, `is_active`, …). There is **no case transform anywhere** — not on the client and not in the backend; the only casing boundary is Prisma `@map` (snake_case field → camelCase column). Author-defined record column values pass through verbatim. Every `list(...)` returns the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you.

**Hooks read the injected clients** — they do not hold their own HTTP. This is the **complete** hook surface (20 hooks), grouped by the domain client each one reads. **CORE** hooks read host state directly off `WidgetContext` (no data client); the rest delegate to one of the four injected clients. The grouping mirrors the banner sections in [`src/hooks.js`](src/hooks.js).

| Group | Hook (signature) | Returns | Reads / scope |
| ----- | ---------------- | ------- | ------------- |
| **CORE** | `useTheme()` | `{ colors, elevation, interaction, spacing, spacingScale, radii, typography, components, widgetStyles }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`) and `loader`, the spinner colour for your own loading state. `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
| **CORE** | `useWorkspaceCurrency()` | `{ currency, formatMoney }` | `ctx.workspace.currency` — no scope. The currency this workspace charges its app users in, resolved at RENDER time. Render every price as `formatMoney(minorUnits)` and never write a currency symbol or code into a widget: the owner can change it after the widget ships, and a baked label then contradicts the charge. |
| **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
| **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope. `goTo(pageIdOrSlug, params?)` accepts a page UUID (a `pageRef` prop) **or its slug** (a row's page-key field) — both hosts resolve either (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
| **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
| **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
| **CORE** | `useWidgetRoute(initial)` | `[state, setState]` | `ctx.widgetRoute` — no scope. Where YOUR WIDGET is, persisted by the host so it survives a reload and travels in a shared link (the `w_<instanceId>` query key on web, the screen's route params natively): the folder a browser has opened, a wizard step, a selected tab, a list's sort and search. `useState` semantics over an object — writes MERGE, `null` clears a key back to its `initial`, and `initial` is read once. Values are scalars or flat arrays of scalars, size- and length-capped; anything else is not stored. NOT history (Back still leaves the page, on both platforms). Degrades to component state on the Studio canvas. |
| **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope. The hook IS the emitter: `const emitSlot = useWidgetEvent("slotChosen")`, then `emitSlot(payload)`. Never destructure the result — there is no `emit` member. |
| **CORE** | `useWidgetInput(inputName)` | the published payload, or `undefined` | `ctx.inputs` — no scope. Reads a value ANOTHER widget on the same page published with `useWidgetEvent`. Declare the input in `manifest.inputs`; the page author wires it to one sibling's declared event. The channel retains the last payload, so a widget that mounts later still reads it. `undefined` while unwired or before the first publish — always render a sensible default. Page-scoped and ephemeral: use `useRouteParams()` for state that must survive navigation, the datastore for state that must persist. |
| **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
| **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
| **CORE** | `useContainerWidth()` | `[width, onLayout]` | No context slice, no scope. Measures the width the widget's OWN box has, so it can lay itself out for the space it is in rather than for the screen. Spread the handler onto your outermost primitive. Use it for any widget with a wide form and a narrow one (a table, a toolbar, a row of tiles) — never switch on the device or window width, because a widget in a one-of-three grid cell on desktop has phone-width room and a widget filling a phone page does not. Width is 0 until the first layout: render the WIDE form then. One implementation covers web (react-native-web) and the native export. |
| **CORE** | `isNarrowWidth(width)` | `boolean` | No context slice, no scope. True when a MEASURED width is below `NARROW_WIDTH_PX` (480) — the one threshold every widget switches at, so a page reflows together rather than raggedly. An unmeasured width (0) is NOT narrow, so nothing flashes through the narrow form on first paint. |
| **CORE** | `useSectionEmpty(isEmpty)` | `void` | `ctx.section.reportEmpty` — no scope. Declares that the widget has NO content to show, so the host drops its layout slot instead of reserving space (and its parent's `gap`) for it. Returning `null` is not enough: the host wraps every widget in an entrance element, so a widget rendering nothing still leaves an empty box the parent stack gaps around. For a CONDITIONALLY ABSENT section (a per-record child collection with no rows for this record), never to suppress a genuine empty state. Stays mounted while collapsed, so passing `false` brings it back. Authoring surfaces never collapse. No-op on a host that doesn't implement it. |
| **CORE** | `useScreenTitle(title)` | `void` | `ctx.screen.reportTitle` — no scope. Names the screen the widget is on, so the host paints that name in the app header beside the back control instead of the app brand. The page's own name is the default; only the widget knows the screen is really "Order #1423" rather than "Orders". The MOST RECENT call wins — with several widgets on one screen the last to report owns the title, and on unmount (or an empty title) the host falls back to the most recent surviving report and finally to the page name. Only a screen showing the back control paints a title; a root page keeps the brand. No-op on a host that doesn't implement it. |
| **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. |
| **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
| **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 |
| **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. |
| **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`). |
| **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`). |
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
| **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
| **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 }`. |
| **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. |
| **AGENTS** | `useAgent(agentRef)` | `{ send, reset, messages, sending, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). 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. `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). **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. |
| **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. |
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
| **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) |
| **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. |
| **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
| **FILES** | `useAssetsByTag(tag, { type })` (optional: the whole argument, and `type` within it) | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
| **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
| **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
| **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
| **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
| **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
| **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: folderId) | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
| **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
| **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider, purpose, pollIntervalMs })` (optional: every key, and the whole argument) | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |

All list calls return the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you. There is no `useWorkspace()` or `useLogger()` hook — read the theme via `useTheme()` and the locale via `useI18n()`; the host logger lives on `ctx.logger` (`{ debug, info, warn, error }`).

`ctx.recordPermissions`, `ctx.users`, and `ctx.groups` **no longer exist** — they were folded into `ctx.datastore.records(t).permissions` and `ctx.directory.{users,groups}` respectively.

See the design reference for the full architecture: [`docs/architecture/widget-marketplace.md`](../../docs/architecture/widget-marketplace.md), specifically section 3.1.

## Status

`v0.150.1` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.

### What's new in 0.150.1 (contract 1.118.0 — unchanged)

**A capability's fallback belongs to the web build, not to native (sc-8381).** The barcode guidance here, in the in-Studio hooks reference and in both AI agents' prompts told you to gate the scan button on `supported` **and** keep a manual-entry path — unconditionally, while the reason given was a browser-only gap. `supported` is always `true` on the Expo export, so that read shipped a redundant "or type the code" field on every phone: the exact typing the scanner exists to remove. The rule is now scoped to the host that actually fails the gate.

- **Gate the substitute on the same flag that gates the button.** Where `supported` is `false` — the web Player in Safari and Firefox, which ship no `BarcodeDetector` — render the typed field instead of the scan button. Where it is `true`, the capability is the whole input. Nothing about the API changed; only the guidance did.
- **A native `catch` is an error state, not a web substitute.** The `react-native-nfc-manager` notes said to render "the same fallback from the `catch` that `widget.web.jsx` uses". A workspace that has not switched the NFC device capability on in Publish settings is a **misconfiguration** the widget should name, not a reason to park a permanent manual-entry field beside the tap target.
- **The web half is still required.** This narrows where a *substitute* belongs; it does not relax the parity rule. A native-only capability still ships a real way in on `widget.web.jsx` — a typed code, a QR scan, a non-sensor path — and still degrades visibly rather than rendering blank.

### What's new in 0.150.0 (contract 1.118.0)

**New primitive `<EmojiPicker>` — pick an emoji without leaving for the OS keyboard (sc-8104).** Typed emoji always worked: a plain `<TextInput>` stores and renders them on both hosts, and nothing about that changes. What was missing was an in-app way to *choose* one — for a message composer, or to react to something.

```jsx
const [open, setOpen] = useState(false);
const [recents, setRecents] = useState([]);

<EmojiPicker
  visible={open}
  recents={recents}
  onRecentsChange={setRecents}
  onRequestClose={() => setOpen(false)}
  onSelect={(char) => {
    const { value } = insertAtCaret(draft, char, selection);
    setDraft(value);
    setOpen(false);
  }}
/>
```

It presents inside `<Overlay>`, so it escapes the widget's clipping box on both hosts, and ships a curated Unicode set (categories, search, recents) as plain data — no emoji dependency to vet. `onSelect` hands you the character; `recents` / `onRecentsChange` let you persist the picks wherever your widget keeps state. Pair it with `insertAtCaret(value, char, selection)` to land the emoji mid-draft instead of appending it.

**Also new: a shared reaction model.** `groupReactions(rows, { typeField, userField, userId })` turns reaction rows into per-emoji pill groups — one row per (target, user, emoji), de-duplicated per user — and `readReactionEmoji(value)` maps a stored value to its emoji, ignoring anything it does not recognise. Widgets storing reactions should group through these rather than rolling their own, so pills behave identically wherever they appear.

### What's new in 0.149.0 (contract 1.117.0)

**Six text roles on the theme, so every widget speaks one typographic voice (sc-8058).** See the theme section above for the roles and how to read them.

### What's new in 0.148.0 (contract 1.116.0)

**New `useAgent(agentRef)` hook — a widget can talk to the WORKSPACE's own AI agent (sc-7991).** A new `agents` host client (`@colixsystems/agents-client`), a new `agentRef` propertySchema type and a new `ai.invoke:*` scope. Everything is additive; nothing existing changes shape.

A workspace **agent** is a name plus a system prompt its author wrote in the Studio. This hook lets any widget hold a conversation with one, without touching HTTP and without ever holding an API key.

```js
const { send, reset, messages, sending, error, available } = useAgent(props.agentId);
```

**What a widget deliberately cannot do.** You cannot set the system prompt, send your own transcript, or pick a model, and no API surface returns the prompt. The server assembles every turn from the stored prompt plus the stored history, and `content` is the only field that reaches the wire — not because the rest is filtered out, but because nothing else is ever sent. That guarantee is about the WIRE, not about secrecy: a model can be talked into repeating its own prompt, so an agent's prompt is not a secret store — never put credentials, or data the app's users may not see, in one. `messages` is the transcript this widget has seen; the real one is the server's.

**Imperative, like `useInterpretDraft`.** It never fires on mount, and the FIRST `send` opens the conversation — so a widget an author places and nobody types into costs the workspace nothing.

**Bind it with an `agentRef` property.** Declare `{ type: "agentRef" }` in your `propertySchema` and the Studio renders an agent picker; the value is the agent id you pass to `useAgent`. It is a bare UUID like `pageRef` / `groupRef`, so Copy Workspace carries a bound widget over to the copied workspace's own agent.

**Every send spends the workspace's AI credits**, and is rate-limited per caller *and* per workspace. Branch on `AgentError.code`: `AI_QUOTA_EXCEEDED` (429) is the credit ceiling and will **not** clear on a retry — offering "try again" there is worse than useless — where `RATE_LIMITED` (429) will. `AGENT_DISABLED` (409) means the author switched the agent off, so hide the affordance rather than retrying. Never show an app user a raw error or the workspace's billing state: they have not heard of an AI credit and cannot buy one.

**`available: false` is a render state, not an error.** It means no agent is bound, or this host brokers no agents client (an unbound canvas preview). Render a visible unbound state — never a blank box.

The linter rejects `useAgent()` unless your manifest declares `ai.invoke:*` in `requestedScopes`.

### What's new in 0.147.0 (contract 1.115.0)

**BREAKING: the bottom navigation bar is themed by `footer` alone — `resolveFooterTokens` reads no `sidebar` (sc-8033).** Every colour fell back to the rail's, so restyling the sidebar restyled a page's bottom bar and the two could not be dressed apart. That is the defect sc-7951 set out to fix and only half did: it gave the strip a Design panel, but left the panel's "Active tab style" offering a **Follow the sidebar** option and every colour still inheriting. The resolver now reads `theme.footer` and nothing else, and the Studio panel offers the strip's **eight** settings with no inherit anywhere.

- **The overloaded `activeColor` is retired** for **`footer.tabActiveBackgroundColor`** and **`footer.tabActiveTextColor`**. One key meant the FILL under `"filled"` and the LABEL under `"accent"`, which is exactly why no author could set both. They are independent now, and each style's *defaults* are what it already rendered — `"filled"`: the brand primary behind a white label; `"accent"`: no surface, a brand-primary label and top edge. So all four colours stay meaningful under both styles.
- **`activeTint` is gone from `FooterTokens`.** The returned **`activeColor`** is the active tab's label, glyph and accent edge — one colour for all three, because a mark and its label disagreeing about which colour means "you are here" would be two marks.
- **`backgroundColor` and `textColor` are resolved strings**, not raw-or-null. Both hosts kept their own `#ffffff` / `#475569` defaults for them; a default that lives in each host is the drift §8 exists to prevent, and these had already diverged once. `borderColor` stays nullable — no line, no colour.
- **`borderWidth` admits `0` here alone**, where `resolveSidebarTokens` keeps sc-7965's 1–8 clamp. The strip's thickness is a Studio control offered down to zero, so a stored zero is an author saying "no line" rather than the junk that clamp refuses; it resolves into the existing no-divider state, so neither host needed new drawing code.

**No shipped app moved.** A backfill (`20270123000000_sc8033_footer_tokens_uncouple`) materialised into each workspace's `footer` block exactly what it had been inheriting, and split every stored `activeColor` onto the key that says which half it meant. Nothing is written where the old chain ended in a default the new one also ends in.


### What's new in 0.146.0 (contract 1.114.0)

**The chrome divider's width is bounded in the resolver, so both hosts agree (sc-7965).** `resolveFooterTokens` and `resolveSidebarTokens` returned `borderWidth` as any finite number. Only the export bounded it — on its way into the StyleSheet — so a stored `1e9` ruled a **billion-pixel border across the published web app** and a normal one in the exported app. The clamp moved into the shared resolver, where both hosts inherit it.

- **Clamped, not dropped.** A value past the end lands **on** the end (1–8), the idiom `tabRadiusOr` / `tabIndicatorOr` already use. This moves the EXPORT too for such a value: it used to *drop* out-of-range and fall back to the 1px hairline, and now renders 8px like the Player.
- **Zero clamps up**, matching what the export already rendered there — the divider's COLOUR is its switch, so a coloured 0px edge is a contradiction rather than "off".
- **Every legal 1–8 value is untouched**, and the write path has always refused anything else, so only a hand-edited or pre-coercer `theme_config` can hold a value this moves.

The site header was already bounded by its own resolver and is unchanged. The compiler keeps its own `coerceBorderWidth` as defence in depth over a blob `PUT /tenant/config` still stores verbatim; a test pins the two bounds to each other behaviourally, since the SDK cannot import the backend coercer.

### What's new in 0.145.0 (contract 1.113.0)

**The app's bottom navigation bar gets its own active-tab look — `resolveFooterTokens` gains three (sc-7951).** `theme_config.footer` already carried an `activeStyle`, but no Studio control ever wrote it, so the SIDEBAR's active-item style silently decided the bottom bar too: an author who accented the rail accented the tab bar, with no way to separate them. The bar was also under-authorable — `"filled"` *bundled* the active tab's surface with its label, pinning the surface to `activeColor` and the label to a hard-coded white, so no author could paint a surface of their own.

- **`footer.activeStyle` is now the bar's**, reachable from the Design page's Bottom navigation bar panel. Unset it still falls back to `sidebar.activeStyle`, so every existing workspace renders exactly as before.
- **`footer.tabActiveBackgroundColor`** — the active tab's surface. New, and it falls back to **nothing**: a vertical rail item has no tab surface, so unlike the colours this inherits from no one.
- **`footer.tabLayout`** — `"inline"` (the row the bar has always drawn) or `"stacked"`, the glyph above its label. Also footer-only.

`resolveFooterTokens` now returns the active tab **already decided** — `activeSurface` (hex or `null`) and `activeTint` — so neither host re-derives the back-compat pairing. `"filled"` with nothing named is still the active colour behind a white label; **naming a surface unbundles the two**, and the label goes back to `activeColor` so the author owns both halves. `activeColor` is resolved in the helper now rather than by each caller, so it is a `string` rather than raw-or-null — `FooterTokens.activeColor` narrows accordingly. The inherited colours are also hex-guarded on the way **out**, not only in: the export already refused a malformed stored value while the Player handed it to a style object.

It also settles a divergence the bar carried: under `"accent"` the web Player drew a **left** border (the rail's rule on a horizontal tab) and the export drew no edge at all. Both now mark the edge **facing the page** — a bottom tab's top — reserved transparent at every tab's width so marking one cannot shift the row. A tab is also the same 6px-rounded chip on both hosts, where native used to be square.

### What's new in 0.144.0 (contract 1.112.0)

**The global mobile quick bar is retired — `quickBarMaxItems` and `quickBarCap` leave the contract (sc-7753, REQ-NAV-STRUCTURE / REQ-NAV-LOCAL).** The quick bar was a second global menu bolted to the sidebar: its own per-page flag, its own five-item cap, its own strip on the app's bottom edge. Since sc-7695 a page can carry a bottom navigation bar of its own drawing a saved menu, which is the same strip with an author-chosen list behind it — so the global one was a duplicate shape.

- `CONTRACT.themeMenuTypes.sidebar` is now `{ name, summary, maxItems }`. `menuItemCap` is unchanged; `quickBarCap` is **removed** — a page's bottom bar draws the menu the author composed, so there is no global number to state.
- `resolveFooterTokens` stays exactly as it is. It was never the quick bar's alone: it is the BOTTOM EDGE's token set, and the page's own bottom bar reads it, so a workspace's bottom-edge colours survive the retirement untouched.
- The sc-7753 backfill turns each workspace's quick-bar pages into a saved menu and points the pages that drew the strip at it BEFORE this ships, so no destination is lost.
- Host-integration surface only; no author-facing hook, prop or manifest field changed. `CONTRACT.version` → `1.112.0`.
**A widget can name the screen it is on — `useScreenTitle(title)` + the optional `ctx.screen` slice (sc-7955).** The app header now shows the PAGE title beside the back control on a sub-page, instead of repeating the app brand on every screen. The page's own name is the default, but a page is often about one record, and only the widget has it:

```js
const { data: order } = useDatastoreRecord(tableId, recordId);
useScreenTitle(order ? `Order #${order.reference}` : null);
```

- The header reads "Order #1423" rather than "Orders" as soon as the record lands; before it does, the blank title leaves the page name standing (only a non-empty string registers).
- `reportTitle` returns a release function, so the title reverts cleanly when the widget unmounts or its title empties.
- **The most recent call wins.** With two title-setting widgets on one screen the last to report owns the header; when it goes, the host falls back to the most recent surviving report, then to the page name.
- Only a screen that shows the back control paints a title — a root/menu page keeps the app brand or logo untouched.
- Optional slice: on the Studio canvas and the Agent Mode edit preview there is no app header, so the hook is a no-op rather than an error.

### What's new in 0.142.1

**A native file upload no longer crashes the app (sc-7906).** Making the part a `File` let the upload through, and then the app died on any real file: Expo's `fetch` builds a multipart body by reading the WHOLE file into the JS heap — `convertFormDataAsync` awaits `part.bytes()` and concatenates — so a send costs about three times the file's size on the JS thread. The native host now streams the file off disk through expo-file-system's upload task instead. `<FilePicker>`, `useCamera()` and `useImageEditor()` are unchanged for a widget author — the picked file stays where the picker put it, so `asset.uri` still renders, and the name the user chose travels to the backend as an explicit `file_name` field.

### What's new in 0.142.0 (contract 1.110.0)

**An action script can finally tell WHO started the run — `triggeredBy` joins `CONTRACT.actionScriptGlobals` (sc-7885).** The identity was already captured (the run history has recorded an `actorId` since REQ-ACTION-RUNLOG) but never reached the sandbox, so a script could do the work and not attribute it: no "X submitted this" notification back to the submitter, no audit row naming the actor. Authors worked around it by writing the user id into a record first and switching to a `record_*` trigger — plumbing in the app's data model, and not available to a plain button press at all.

```js
if (triggeredBy && triggeredBy.type === "app_user") {
  await notifications.notifyUser(triggeredBy.id, {
    title: "Request received",
    body: "Thanks " + triggeredBy.name + ", we have started on it.",
  });
}
```

- The shape is `{ type, id, name, email, role }`, frozen, or **`null` when nothing initiated the run** — a `schedule` tick or a `record_*` event. `null` is the normal case for the automation triggers, so guard before reading it.
- **`type` is load-bearing, not decoration.** It is `"app_user"` on an `app` button press and an `http_post` webhook, and `"studio_user"` on an operator's Run now. One id column, two directories behind it: check `type` before handing the id to `notifications.notifyUser` or a USER column, or a studio user's id will silently resolve to nobody. `role` carries the app-user vocabulary (`USER` / `INTEGRATION`) and is `null` for a studio user, whose workspace role is a different axis entirely. `email` is likewise `null` for a studio user: nothing in a script can address an operator, so shipping their real address to an installed third-party Action would be PII with no consumer.
- **It grants nothing.** The script still runs as the Action's bound integration identity, exactly as before; `triggeredBy` describes the caller and does not widen the data the script can reach. Resolution is host-side and tenant-scoped, so a stale or foreign id resolves to `null` rather than leaking a row across workspaces.
- Actions never render, so this has **no effect on Player ↔ export parity** (CLAUDE.md §8).
- Also in this release: **`properties` was added to `CONTRACT.actionScriptGlobals`**, which the `@colixsystems/action-sdk` copy of the list had carried since 1.50.0 while this one had not — the two hand-mirrored lists had drifted. A parity test now pins them together. `CONTRACT.version` → `1.110.0`. Additive for every existing manifest and script.

### What's new in 0.141.0 (contract 1.109.0)

**The app's global navigation is the sidebar alone — `top-bar` and `bottom-tabs` leave `CONTRACT.themeMenuTypes` (sc-7352, REQ-NAV-STRUCTURE).** A top bar is now a PAGE's local bar drawing a saved menu (REQ-NAV-LOCAL, sc-7103): the same tab row the `top-bar` shape used to draw, themed by the same `topBar` tokens (`resolveTopBarTokens` is unchanged), worn by one page under the sidebar. Two "top bars" competing for one word and one token set was the reason.

- `normaliseNavigation` resolves a stored `top-bar` or `bottom-tabs` to `sidebar`, so a host never has to guard the value. `menuItemCap` / `quickBarCap` keep their signatures (`quickBarCap("sidebar")` is still 5). *(`quickBarCap` was removed in 0.142.0.)*
- The global menu never lived in the shape (it is the pages' `show_in_menu` flags), so a workspace that stored a retired shape already draws the sidebar with the same menu. The sc-7352 backfill deletes the stored key BEFORE the shapes disappear; a bottom-tabs app's phone strip becomes the sidebar's quick bar. *(That strip was itself retired in 0.142.0.)*
- Host-integration surface only; no author-facing hook, prop or manifest field changed. `CONTRACT.version` → `1.109.0`.

### What's new in 0.140.0 (contract 1.108.0)

**Three rules make a generated widget's EDITABILITY checkable, and `lintEditability` joins the linter export (sc-7799).** The rules that already had a deterministic gate — the three basic style fields, `no-hardcoded-design`, `style-field-unread` — held every time. The ones left to prose drifted: `ui.group`, per-element coverage and author-editable copy were guidance only, so some widgets shipped a grouped Style section covering every element while others shipped the three basics and nothing else, and wording was routinely pinned in the source where no author could reach it.

- **`style-group-missing`** — a `styleSchema` field with no `ui.group`, or one filed under a catch-all name (`Advanced`, `Other`, `Misc`, `Style`, `Styles`, `General`) that rebuilds the flat list grouping exists to split. The three basics belong under `"Basics"`; each per-element field under the Title Case singular name of the element it styles (`"Card"`, `"Title"`, `"Row"`).
- **`style-elements-uncovered`** — a widget that renders several visual elements while its `styleSchema` carries nothing beyond `background`/`textColor`/`align`. A genuinely single-element widget is exempt by construction.
- **`text-literal-unexposed`** — a string literal sitting directly in a `<Text>`: copy that reaches neither the author nor the translation pipeline.

All three are `warning`, so `appstudio-widget lint` reports them and still exits 0 — the human author decides when to act. The AI widget agent publishes with no human in the loop and `_lintFindingsToPublishChecks` turns every finding into a publish check regardless of severity, so the same three **block** an agent publish and drive a repair turn, with no new severity plumbing.

`lintEditability(manifest, files)` is bundle-level like `lintStyleWiring`, and needs `--manifest` for the same reason. It counts rendered elements PER FILE, though: joining `widget.web.jsx` and `widget.native.jsx` would double every tag and call a single-`<Text>` split-impl widget multi-element.

### What's new in 0.139.0 (contract 1.108.0)

**An author can BIND a colour field to the workspace brand instead of freezing a hex (sc-7800).** A `type: "color"` field tracked the theme only while it was *unset*, through its `themeDefault` placeholder. The moment the author picked a colour it became a literal that outranked the theme forever, so "make this our brand colour" was inexpressible — the author had to type today's hex, and it went stale silently at the next rebrand.

A colour field's value may now be a **binding** — `"theme:colors.primary"` or `"theme:colors.secondary"` — which both hosts resolve to the workspace's current colour at render.

- **Nothing changes for a widget author.** You keep declaring `{ type: "color", … }` and reading the value off `props.style` / `props`. The host hands your code an ordinary colour string either way, so never branch on a binding, never parse the `theme:` prefix, and never add a "use theme colour" toggle beside a colour field — the Studio's picker already offers the choice, and a second control for it is a dead knob.
- **Only the two brand tokens are bindable**, listed on `CONTRACT.themeColorTokens`. The derived and contrast-paired tokens (`onPrimary`, `primarySoft`, `primaryStrong`, `loader`) are deliberately withheld: they are the host's to compute, and binding one half of a contrast pair produces unreadable text.
- **An unresolvable binding falls back to the field's `themeDefault`**, exactly as an unset field does — it never paints blank.
- **Fully additive.** Every colour already stored is a literal, is untouched, and renders byte-identically.

### What's new in 0.138.0 (contract 1.107.0)

**Every file upload from the Expo export works again.** Expo's `fetch` implements the WEB FormData spec, so a part must be a string or a Blob — but `useCamera`, `useImageEditor` and `<FilePicker>` all handed back React Native's legacy `{ uri, name, type }` triple, which it rejects with `Unsupported FormDataPart implementation` before a byte leaves the device. Web was unaffected because a browser `File` **is** a Blob, so it only ever reproduced on device. All three now hand back expo-file-system's `File`, carrying the same `uri` / `name` / `type` / `size` — so no widget needs a change.

**`useFilestoreUpload` keeps the filename, and `fileName` works on both hosts.** A Blob appended with no filename is stored as `blob`. The name travels as `append()`'s third argument for a real Blob, and is set ON the part otherwise — expo-file-system's `File` is **not** `instanceof Blob` at runtime (`implements Blob` is TypeScript-only), and Expo reads the name off the part. `upload(file, { fileName })` overrides it. The native producers set `type` the same way, because the `File` derives it from the cache URI and leaves it empty when that URI has no extension.

### What's new in 0.137.0 (contract 1.106.0)

**A `datastoreTemplate` column marked `encrypted` is now actually encrypted in the workspace that installs you (sc-7557).** The field was documented nowhere and honoured nowhere: install built its column rows one way, the republish/upgrade migration built them another way, and neither carried the flag — so a column you declared confidential was created as an ordinary plaintext column and nothing told you or your installer. Both paths now share one projection, so the flag lands on install and on upgrade alike.

```js
datastoreTemplate: {
  tables: [
    {
      suffix: "Patients",
      columns: [
        { name: "Name", dataType: "STRING", required: true },
        { name: "Ssn", dataType: "STRING", required: true, encrypted: true },
      ],
      rows: [{ Name: "Ada Lovelace", Ssn: "600101-1234" }],
    },
  ],
}
```

- **Your widget code does not change.** It reads and writes plaintext through `useDatastoreQuery` / `useDatastoreMutation` exactly as it does for any other column — the platform encrypts on write (AES-256-GCM under a per-workspace subkey) and decrypts for end users.
- **Studio users see `🔒`, not the value.** That is the point of the flag: the people authoring the app cannot read what their end users store. The column also cannot be searched, filtered or sorted on — ciphertext is opaque.
- **Sample `rows` are encrypted too.** They used to be written straight into the value table; a plaintext row under an encrypted column would have been read back as a corrupt envelope.
- **Not valid on `RELATION`.** Its value is a foreign key the backend has to resolve, so it can never be opaque — declaring it is now a publish error, as is a non-boolean `encrypted`.
- **An upgrade adds a new encrypted column, but never flips a live one.** Turning encryption on over existing plaintext (or off over existing ciphertext) would strand what is already stored, so a changed flag on a column that already exists is reported back to you rather than applied.

### What's new in 0.136.0 (contract 1.105.0)

**A `datastoreTemplate` can now say WHICH AUDIENCE gets which access — not just the two anonymous ones (sc-7530).** `publicGrant` could only ever reach `everyone` and `authenticated`, because those are the two principals that need no id. Anything naming a real user group was unexpressible: a group id belongs to the workspace that installs your widget, so it could never travel in your manifest. The practical cost was that a permission model you had already set up correctly — say an admin group with full CRUD while ordinary signed-in users only read — shipped to your installers as nothing at all, and every one of them rebuilt it by hand across both the table grant and the record permissions.

Templates now name their audiences symbolically and let the installer bind them:

```js
datastoreTemplate: {
  roles: [
    { key: "admin", label: "Administrators", description: "Manages orders end to end" },
  ],
  tables: [
    {
      suffix: "Orders",
      columns: [...],
      // the id-less audiences, unchanged
      publicGrant: { canRead: true, canWrite: false, canDelete: false },
      // the audience that needs a real group
      roleGrants: [{ role: "admin", canRead: true, canWrite: true, canDelete: true }],
    },
  ],
}
```

`roles` is declared ONCE per template and referenced by every table, so three widgets over two tables ask the installing workspace **one** question per audience rather than one per grant. At install they pick one of their own groups for each role, or have one created for them under the role's label.

- **`key`** is lowercase-kebab (`/^[a-z][a-z0-9-]*$/`), at most 8 roles per template. A `roleGrants` entry naming a role you did not declare is a publish error, as is granting the same role twice on one table or granting it no verb at all.
- **`canDelete` is row-level only.** A table-scope grant answers "may I create a record"; only a record permission answers a question about a row. Both halves are written for you from the one declaration.
- **An unbound role is an ordinary outcome, never a failed install.** If the installer skips a role, no grant is written and they are told which role is still unbound — they can bind it later from the table's permissions.
- **Purely additive.** A template declaring neither `roles` nor `roleGrants` behaves exactly as it does today.

Also tightened in this release: the `publicGrant` flags are now type-checked. They never were, so a stringy `canRead: "false"` read as truthy and silently opened the table to anonymous reads. If you have been passing anything but a real boolean there, publishing will now tell you.

### What's new in 0.135.0 (contract 1.104.0)

**NFC tags are vetted, native-only (`react-native-nfc-manager`).** A widget can now read and write NFC tags on the Expo export — NDEF over `NfcManager.requestTechnology(NfcTech.Ndef)`, plus the raw technologies a badge, asset tag or transit card uses: `NfcTech.IsoDep` on both hosts, `NfcTech.MifareClassic` on Android, `NfcTech.MifareIOS` / `FelicaIOS` / `Iso15693IOS` on iOS. (There is no `NfcTech.FeliCa` — check `index.d.ts` before naming one.) This is the last common "tap a physical thing" input the allowlist was missing; barcode and QR already had `useBarcodeScanner()`.

- **Native-only, and the browser is why.** Web NFC ships in Chrome on Android alone — Safari, Firefox and every desktop browser omit it — so there is no web build to pair with. Put the import in `widget.native.jsx` and give `widget.web.jsx` a real way in: a typed code field, or the SDK's `useBarcodeScanner()` QR path. The linter's `import-platform-mismatch` enforces the split.
- **Opt-in per workspace — and `isSupported()` cannot see that.** The export claims the iOS NFC entitlement and the Android `NFC` permission only when the publisher enables the **NFC tags** device capability in Publish settings. `isSupported()` probes the **device** (`readingAvailable` on iOS, `hasSystemFeature` on Android), not the gate: on a capable phone in an app that never asked, it returns `true` and the read then fails — Android throws a `SecurityException` from `registerTagEvent`, iOS cannot open the reader session. So wrap `start()` / `requestTechnology()` in a `try` and render a clear **error state** from the `catch` — name what is missing and say the capability has to be switched on in Publish settings. That `catch` is an error path, **not** the web build's typed-code substitute: do not give `widget.native.jsx` a permanent manual-entry field beside the tap target, because in a correctly-configured app the radio works and the field is the typing the tag was meant to replace.
- **Clean up the session.** `cancelTechnologyRequest()` in your effect cleanup; an abandoned request leaves the reader sheet up on iOS.
- **It is a write capability too.** Read with `getNdefMessage()`; write with `writeNdefMessage(bytes)`, `formatNdef(bytes)` (Android only, for a blank tag) and `transceive(bytes)` on the `IsoDep` / `NfcA` / `NfcV` handlers. One capability covers both — Android expresses read and write through the single `android.permission.NFC` — so the workspace owner who enables it is consenting to every widget in the workspace being able to rewrite the tags their users tap. Raw ISO7816 `transceive` on iOS additionally needs `select-identifiers` on the entitlement, which the export does not declare; NDEF read and write work on both hosts.
- **`makeReadOnly()` is irreversible.** It permanently locks the tag — no later write will ever succeed, on any device, by any app. Call it only where the feature genuinely wants a one-time-programmable tag, and never as a cleanup step after writing.

### What's new in 0.134.2 (contract 1.103.0 — unchanged)

**Defect fix: `useImageEditor()` now rejects the same inputs on both hosts, and two blob/state leaks are gone (sc-7205).** Review of the 0.126.0 hook found a real web↔native divergence and two bookkeeping bugs it had inherited from `useCamera`. `CONTRACT.version` does **not** move — the contract already promises identical validation across hosts; this makes the implementation honour it.

- **An out-of-bounds crop now rejects `INVALID_ACTION` on web too.** A canvas silently padded the region outside the source with transparent — black once JPEG-encoded — and reported the padded dimensions, while both native platforms threw (and the throw was relabelled `DECODE_FAILED`). Dragging a crop past the edge is the commonest thing a crop UI produces.
- **`compress` is range-checked on both hosts.** Out of range silently fell back to the browser's default quality on web and threw from `Bitmap.compress` on Android.
- **`reset()` during an edit no longer wedges `editing` at `true`**, and **an edit still running at unmount now releases its blob**. Both bugs exist in `useCamera` too and are fixed there in the same change (CLAUDE.md §3).
- A failed `base64` encode no longer leaks its object URL; a derived resize dimension is floored to match the native transformers; the web decode retries without CORS so it accepts the same sources native does at DECODE time (a cross-origin image whose host sends no `Access-Control-Allow-Origin` still taints the canvas and fails at encode with `ENCODE_FAILED`, where native succeeds — the browser's rule, not ours); and `isSupported()` no longer allocates a DOM node per render.

The web broker also gains the test file it shipped without — the previous 16 cases all drove a *fake* broker, so none of the transform maths ever ran.

### What's new in 0.134.0 (contract 1.103.0)

**Formatted content can carry links and tables (sc-7349).** `<MarkdownInput>` and `<RichText>` covered emphasis, headings, lists and images, but a link was not in the grammar at all — so a post that said "see the Booking page" had no way to get the reader there, and a comparison table could only be faked with spaces.

```jsx
<MarkdownInput value={draft} onChange={setDraft} pages={pages} />
<RichText value={post.body} />
```

- **Links: `[label](target)`.** The target is stored **verbatim** and followed through the host's `navigation.openLink`, which already decides page / external / refuse. An absolute URL leaves the app; a bare page id or slug navigates inside it; anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself, and never route one through `Linking.openURL` — it performs anything. A link label keeps its own emphasis, so `[**Book now**](booking)` reads bold.
- **Tables: a header row, a `| --- | --- |` divider, then body rows.** `:--` / `--:` / `:-:` in a divider cell sets that column's alignment, and `\|` puts a literal pipe in a cell. Cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.
- **Two new toolbar buttons.** Table inserts a skeleton with the first header cell selected. Link opens a form for the link text plus its target — and when you pass the new optional `pages` prop (`[{ id, name }]`) it offers those pages by name, the way `renderImage` is supplied for filestore images. Without `pages` the form offers only an external address, because a widget cannot know the app's pages on its own.
- **`<RichText>` gains `followLinks`** (default `true`). The editor's own preview passes `false`, so links render styled but inert and a tap while writing cannot navigate away from the draft.

Additive — one shared view module per primitive, bound per host, so both hosts gain this together. Every string already stored keeps parsing identically: a line needs a divider row to become a table, and `![alt](src)` is still read as an image, never a link. `CONTRACT.version` → `1.103.0`.

### What's new in 0.133.1 (contract 1.102.1)

**Hook signatures no longer carry doc-style optional markers (sc-6946).** `CONTRACT.hooks[].signature` used to write optionality inline — `useFilestoreFiles({ spaceType, folderId?, q?, type? })` — but a `?` inside a destructuring pattern or object literal is a parse error, and the AI widget agent renders those signatures into its prompt verbatim. Every signature is now a call form that parses; the omittable parts moved to a new `optionalArgs` array the prompt prints beside it. The hook table above was rewritten to match (the version history below is left as each release wrote it).

- **Documentation only.** No hook gained, lost, or changed an argument — `useFilestoreFiles({ spaceType })` and `useDirectory()` behave exactly as before. Only the way the contract *writes down* which arguments are optional has changed.

### What's new in 0.133.0 (contract 1.102.0)

**A required `tableRef` needs a `datastoreTemplate` table that answers to its NAME, not just another table in the list (sc-6965).** 0.57.0's publish gate `manifest.requiredTableRefsHaveTemplate` compared COUNTS — it passed as soon as `datastoreTemplate.tables` plus any host-supplied tables outnumbered the `required` `tableRef` props. So a widget with two required props published on any two template tables, including the case where both of them name the SAME prop and the other has nothing: the installer, which binds by name, then had no table for it and fell back to whichever one was left over — your widget wired to a table its code was never written against. The gate now runs the installer's own matcher over your template, per property: a table's `suffix` must answer to the property's name (`ordersTableId` needs suffix `Orders`), and the failure names only the properties nothing answers to, with the suffix each one wants. **What still passes unchanged:** the conventional bare `tableId`, which carries no name to match and takes the first table still free; a property marked `sharedTable: true`, which seeds nothing by design (0.106.0); and a standalone submit with no template at all, which fails exactly as it did. **What to change if you are newly rejected:** name the table after the property it is for — that is the pairing that was always going to decide the binding. Every first-party widget passes unchanged. No export, type, hook, or manifest field changed shape — a publish-gate tightening plus documentation. `CONTRACT` is unchanged (no new field).

### What's new in 0.132.0 (contract 1.102.0)

**BREAKING: the top bar has no divider — `resolveTopBarTokens` drops `borderColor` and `borderWidth` (sc-7360).** The bar and its tab row are one surface; a line under the bar ruled it off from its own menu, and a line under the tabs ruled the menu off from the page it navigates. Neither host draws one any more, the Design page offers no colour or width for one, and `TopBarTokens` no longer carries the two fields. A stored `topBar.borderColor` / `borderWidth` is **inert, not migrated** — nothing reads it, and the theme coercer drops it from a saved look or a Mason `set_theme`. The rail's, footer's and site header's dividers are unchanged.

- `resolveSidebarTokens` / `resolveFooterTokens` are unchanged. No `CONTRACT` change.

### What's new in 0.131.0 (contract 1.102.0)

**`styleGroup(name)` is deprecated and now inert (sc-7344).** The Widget Builder preview's click-to-select mode — the primitive's only reader — has been removed, so nothing maps a clicked element back to its style group any more. Style fields are edited through the whole-widget style editor the gear button opens.

- **No migration needed.** `styleGroup()` is still exported and still returns the same props, so existing `<View {...styleGroup("Card")}>` spreads keep building and rendering exactly as before — they are simply no-ops now. It was always a marker that changed nothing about how a widget renders.
- **Don't add it to new widgets.** The AI widget agent no longer emits it.

### What's new in 0.130.0 (contract 1.101.0)

**~~New primitive `styleGroup(name)`~~ — superseded by 0.131.0, see above (sc-7282).** A widget's `styleSchema` already names the element each field belongs to via `ui.group` ("Card", "Title", "Value"), and the Studio renders those as labelled fieldsets. But a group name says which *fieldset* a control sits in, not which *element on screen* it moves — so clicking a button in the Widget Builder preview could only ever open the whole schema.

*(Historical — the guidance below no longer applies; see 0.131.0 above.)*

- **Spread it on the element each non-Basics group paints:** `<View {...styleGroup("Card")}>`. Pass the EXACT `ui.group` string; a mismatch marks an element no group owns. Mark each group ONCE, on the outermost element an author would point at — never the `"Basics"` group (it paints the whole widget) and never a child of an already-marked element. On a list that repeats a marked element per row, mark every row.
- **One module, both hosts.** It rides the existing `dataSet` prop: `react-native-web` maps it to a `data-*` attribute, and real react-native drops it, so the marker is inert on the device rather than a second implementation. It is a marker, not a style — it changes nothing about how a widget renders.

### What's new in 0.129.0 (contract 1.100.0)

**Widgets can READ a barcode now — `useBarcodeScanner()` (sc-7228).** A widget could already *generate* a QR code (`react-native-qrcode-svg`) but never read one, so the whole class of apps that starts by pointing a phone at a label — inventory counts, asset check-in/out, warehouse picking, ticket scanning, scan-on-delivery — had no way in.

```jsx
import { useBarcodeScanner, BarcodeError } from "@colixsystems/widget-sdk";

const { result, scanning, supported, scan, reset } = useBarcodeScanner();
const hit = await scan();          // { value, format } | null
```

`scan()` is **imperative** — call it from a tap, because both hosts gate the camera permission prompt on a gesture. It resolves the first code decoded, resolves `null` when the user dismisses (a dismissal is NOT an error), and rejects a `BarcodeError` with `.code` of `PERMISSION_DENIED | UNSUPPORTED | INTERNAL`. `format` is a lowercase symbology name (`qr_code`, `code_128`, `ean_13`, …) and is a **hint**: the two hosts detect different sets, so never branch on it for correctness.

It is deliberately **one-shot** rather than a start/stop subscription — to read several codes, call `scan()` again. There is no loop to leave running and no camera to forget to release.

**Gate the button on `supported`, and keep the manual-entry path for the host that fails that gate.** `BarcodeDetector` is Chromium-only today, so the web Player reports `supported: false` in Safari and Firefox — a genuine browser gap, not a missing feature, and there the scanner must not be the only way to enter a code. The Expo export always reports `true`, so do **not** mirror the typed field onto native: on the phone the scanner is the whole input, and the twin field is the typing the scan was meant to remove. Gate the substitute on the same flag that gates the button:

```jsx
{supported
  ? <Pressable onPress={scan}><Text>Scan</Text></Pressable>
  : <TextInput value={code} onChangeText={setCode} placeholder="Enter the code" />}
{/* On the phone there is no typed field to fall back to, so a denied
    camera has to say so rather than leave a button that does nothing. */}
{error ? <Text>Camera access is off — turn it on in Settings to scan.</Text> : null}
```

That error line is not optional on native. `supported` is `true` there, so the scan button is the only way in — a `PERMISSION_DENIED` the widget swallows leaves the user tapping a control that silently does nothing.

**Host-brokered, not a vetted import.** Like the camera and speech-to-text, your widget never imports the native module: `expo-camera` is pinned by the Expo export only, so a widget that does not scan gains no native dependency and nothing new enters widget bundles.

### What's new in 0.128.0 (contract 1.99.0 — unchanged)

**New linter rule `flex-basis-percent` — a percentage `flexBasis` sizes the HEIGHT in a column (sc-7274).** A generated form carried one shared field helper, `style={{ flexGrow: 1, flexBasis: wide ? "100%" : 220 }}`, used both as a row cell (where the number is exactly right) and as a full-width field stacked in a column (where the percentage is a trap). `flex-basis` sizes the **main** axis, and a column's main axis is the **height**: each wide field was asking for its parent's entire height. It rendered correctly in the builder canvas — an auto-height ancestor leaves the percentage indefinite, so it degrades to `content` — and broke the moment the page shipped, because a Grid cell stretches its child on the web Player and made that height definite. Measured on the published page: the group was 633px tall and every wide field inside it was **also** 633px, so with `View`'s default `flex-shrink: 0` they could not shrink back and overflowed 633px and 1266px down, painting a blank band mid-form and three fields on top of the section below and the submit row.

- **`flex-basis-percent` (severity `warning`, non-blocking).** A literal percentage `flexBasis` — `"100%"`, `"50%"`, and the responsive ternary `flexBasis: stacked ? "100%" : CARD_WIDE` — is flagged. Author fix: **`width: "100%"`**, which is the direction-agnostic way to say "full width": it fills the row in a column parent AND takes its own line in a wrap row, so nothing is lost by switching. Keep a **number** (`flexBasis: 220`) where you mean a wrap threshold. It is a **warning**, not an error, because in a ROW parent the percentage IS correct and an AST-free scan cannot see the parent's `flexDirection` — the offending style usually lives in a shared cell helper far from its parent. Scope is the literal inline form; a basis threaded through a variable is beyond the scan, and the designer skill's form guidance remains the first guard. Comments are not scanned, so documenting the anti-pattern is safe.

### What's new in 0.127.0 (contract 1.99.0)

**Widgets can do maths now — five pure-JS packages join the vetted import allowlist (sc-7191).** The list had 31 entries and exactly one non-UI utility (`date-fns`), so anything numeric a widget needed had to be hand-rolled in a sibling file. Two gaps in particular:

- `decimal.js` — exact decimal arithmetic. The platform has payments, invoicing and VAT, and a total accumulated in IEEE-754 floats drifts from what the backend actually charged. `new Decimal(a).plus(b).toFixed(2)` does not.
- `d3-scale` + `d3-shape` + `d3-array` — the scale, path-generator and domain maths a bespoke chart needs. `d3-shape` emits path strings you hand straight to the already-vetted `react-native-svg`'s `<Path d={…} />`, so a custom line/area/donut chart is ONE source file that renders identically in the Player and the Expo export.
- `simple-statistics` — mean/median/quantile/regression/correlation, for summarising a datastore table without shipping a maths framework.

All five are `platforms: ["web", "native"]` with no native module, so this is **full parity**, not a §8 native-only case. Each is host-shimmed in the Player and pinned in the export for the same reason `date-fns` is: an AI-agent widget is transpiled rather than bundled, so its bare import has to resolve at runtime on both hosts.

`mathjs` was considered and deliberately left off — 9.4 MB unpacked with nine transitive dependencies, and a web-vetted package is bundled into the Studio.

Additive: no existing entry, hook, primitive or `propertySchema` type changed shape. `CONTRACT.version` → `1.99.0`.

### What's new in 0.126.0 (contract 1.98.0)

**A widget can edit an image now, not just take one — new `useImageEditor()` (sc-7193).** `useCamera()` (0.121.0) let a widget capture a photo and `ctx.assets.upload` let it send one, but nothing could *change* one: no resize before upload, no crop to an aspect ratio, no straightening a sideways shot. A profile-picture widget had to upload the full-resolution original and hope the server-side normaliser did something acceptable.

```js
const { capture } = useCamera();
const { edit } = useImageEditor();

const shot = await capture();
const small = await edit(shot.uri, [{ resize: { width: 800 } }], { format: "jpeg", compress: 0.8 });

const fd = new FormData();
fd.append("file", small.file);
await ctx.assets.upload(fd);
```

`edit(uri, actions, options?)` applies `actions` in order and resolves the SAME normalised asset shape `useCamera()` yields, so capture → edit → upload is one code path on both hosts. Actions are `{ resize: { width?, height? } }`, `{ crop: { originX, originY, width, height } }`, `{ rotate: degrees }` and `{ flip: "horizontal" | "vertical" }`; output is `{ format: "jpeg" | "png" | "webp", compress, base64? }`.

**Host-brokered, not a vetted import** — the same call `expo-image-picker` and `expo-speech-recognition` already got. Widgets reach it through the hook and never import the package, so it stays out of every widget bundle and parity is the SDK's problem rather than each author's. The web Player brokers it on a canvas *inside the host* (your widget never touches the DOM); the Expo export uses `expo-image-manipulator`.

There is deliberately **no `extent` action**. It exists only on web in `expo-image-manipulator`, and a capability the Player has but the export does not is the direction CLAUDE.md §8 forbids.

The `device.imageEditor` slice is OPTIONAL, so a host that brokers nothing degrades the hook to `supported: false` rather than throwing — gate your edit control on it. Additive: no existing hook, primitive, manifest field or `propertySchema` type changed. `CONTRACT.version` → `1.98.0`.

### What's new in 0.125.0 (contract 1.97.0)

**A `top-bar` app no longer chooses which row carries its menu — `CONTRACT.themeTopBarMenuStyles` is REMOVED and `normaliseNavigation` returns `menuType` alone (sc-7044).** 0.120.0 gave the shape two menu rows to pick between: `links`, the row of text links beside the brand it had always drawn, and `tabs`, a dedicated tab row beneath the bar. `links` turned out to be the site-mode header's own row — literally the same `header` nav variant, sitting in the Studio beside "Show a header on site-mode pages" and its "Menu links" toggle, so an author was offered three adjacent settings that render the same thing. The tab row is the one that reads as an app's global navigation, so it is now the only one.

A host branches on `menuType` alone:

- `CONTRACT.themeTopBarMenuStyles` is gone, and so is the `ThemeTopBarMenuStyle` type. `ResolvedNavigation` is `{ menuType }`.
- `resolveTopBarTokens` is UNCHANGED — the whole `topBar` tab vocabulary (`tabStyle`, `contentSurface`, `tabIndicatorWidth`, `tabCornerRadius`, `tabPaddingX` / `tabPaddingY`, `tabBackgroundColor`, `tabActiveBackgroundColor`) now applies to every `top-bar` app rather than only to one that opted into tabs.
- A stored `navigation.topBarMenuStyle` is **inert**, not migrated. Nothing reads it, so no backfill runs; a `top-bar` app that never chose `tabs` renders the tab row from this version on.

**BREAKING, host-integration surface only** — a host that destructured `topBarMenuStyle` must stop. Nothing a widget author imports moved: no hook, primitive, manifest field or `propertySchema` type changed. `CONTRACT.version` → `1.97.0`.

### What's new in 0.124.0 (contract 1.96.0)

**A `filterList` condition can scope itself to the signed-in app-user (sc-6282).** `valueMode` used to be `literal` or `relativeDate`, so every filter value was a constant chosen at build time. "Show only my rows" was therefore not something a no-code author could express — it needed a custom widget reading `ctx.user` and filtering client-side, which put the scoping decision in code the server does not enforce.

Two actor-scoped modes join the vocabulary:

- **`currentUser`** on a `USER` column — the rows whose user column is the caller.
- **`currentUserGroup`** on a `USER_GROUP` column — the rows assigned to any group the caller belongs to (live groups only; a tombstoned group surfaces nothing).

Both **carry no value**. The condition sends the `me` sentinel and the backend resolves the identity from the request actor, so a tampered widget config cannot redirect the sentinel at another user, and a caller with no session matches **nothing** rather than every row. Valid with `eq` / `neq` only — any other operator is rejected as `INVALID_FILTER`, rather than quietly answering "mine".

To be precise about what this is: it scopes what the widget **displays**. It is not an access-control boundary — a signed-in user can always ask for the unfiltered list, so row privacy has to come from the table's record ACL (a creator-only template). Use the two together. `eq` reads as "mine", `neq` as "not mine". In the Studio the author gets a signed-in-user checkbox on the row, offered only for the column type that can carry an identity.

Additive — `literal` and `relativeDate` conditions are unchanged, and an author-supplied `value` is simply ignored by the new modes. `CONTRACT.version` → `1.96.0`.

### What's new in 0.123.0 (contract unchanged at 1.95.0)

**Two new primitives, `<RichText>` and `<MarkdownInput>` — a widget can finally let an app user write FORMATTED text (sc-6970).** Widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so there was no path at all from a content string to bold, a heading or a list. A generated "manage content" widget that was asked for a formatting toolbar therefore drew a fake B/I/H2 bar over a plain `TextInput` whose buttons spliced literal `<strong>`/`<br>` tags into the value — the author read tag soup with no preview of the text a reader would get, and the reader got the tags.

Content is **markdown text** now: one primitive authors it, the other renders it, and the string that travels between them is the one you store.

```jsx
<MarkdownInput value={draft} onChange={setDraft} placeholder="Write the announcement…" />

<RichText value={post.body} />
```

`<MarkdownInput>` is a multi-line field, a toolbar whose buttons wrap the current **selection** in markdown markers (bold, italic, code, H2, H3, bullets, numbered list — pressing one again toggles it back off), and a live `<RichText>` preview of the result underneath. `previewLabel` defaults to `Preview` and `showPreview` to `true`; `minHeight` (150) and `maxHeight` (280) give the field a floor and a ceiling, so a long draft scrolls inside its box instead of growing past its card. Reach for it instead of a bare `TextInput` plus hand-rolled formatting buttons, which can only splice literal tags into the text.

`<RichText>` parses the subset — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, `#`/`##`/`###` headings, `-`/`*` bullets, `1.` ordered items, and 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. Colour, type scale and leading come from the workspace theme; never re-style them. Pass `renderImage` (`({ src, alt, size }) => node`) when images are filestore ids, because resolving one needs the scopes your own widget holds — without it only absolute `http(s)` URLs render. HTML is never interpreted: stored text that still holds markup is stripped to plain text rather than shown as tags.

The grammar is exported too, for content you need to inspect rather than render: `parseMarkdown(text)` returns the block list, `markdownToPlainText(text)` a marker-free projection for a list preview, a search index or an accessibility label, `parseMarkdownImage(line)` / `formatMarkdownImage(block)` read and write the one-line image form, `stripHtmlToMarkdown(text)` normalises a legacy HTML row on read, and `MARKDOWN_IMAGE_SIZES` is the closed size list (`small` | `medium` | `large` | `full`).

A widget that renders images itself through `renderImage` gets the image half of the same grammar, so it classifies a src exactly the way the parser does rather than re-deriving the rule: `isSafeMarkdownImageSrc(src)` (an `http(s)` URL or a filestore id — anything else, a `javascript:` scheme included, is refused), `isHttpImageSrc(src)` to tell a URL from a filestore id, `normaliseMarkdownImageSize(size)` and `readMarkdownAlt(raw)`.

The linter gains `no-html-in-content` to catch the old shape: an HTML tag inside a **string** (`"<strong>"`, `"<br>"`, `"<p>…</p>"`) is flagged, because no host will ever render it. Only string and template content is scanned, so your own `<View>` / `<Text>` JSX cannot trip it. Warning-severity for a human author, blocking for the AI widget agent.

Additive — two new primitives, ten new grammar exports, one new soft linter rule; no existing export changed signature. `CONTRACT.primitives` carries both entries; `CONTRACT.version` is unchanged at `1.95.0`.

### What's new in contract 1.95.0 (package unchanged at 0.122.0)

**`useCamera().capture()` opens a real camera on the DESKTOP web Player.** The web broker's only camera surface was `<input type="file" capture="environment">`, and that attribute is honoured by phone browsers but **silently ignored on the desktop** — so on a laptop `capture()` opened an ordinary file dialog, indistinguishable from `pick()`. It now opens a live `getUserMedia` preview with a shutter, on every web host. Nothing a widget imports changed: same signature, same normalised asset, same `PERMISSION_DENIED | UNSUPPORTED | INTERNAL` vocabulary, and the Expo export is untouched. Three things a widget can observe: `options.quality` now reaches the encoder on web (it previously reached nothing); a phone gets the same in-page preview rather than its OS camera app, which trades the phone camera's optics for one behaviour everywhere; and a camera that cannot be opened (no device, or another app holding it) rejects `UNSUPPORTED` instead of quietly showing a file dialog.

### What's new in 0.122.0 (contract 1.94.0)

**A refused filestore upload now says it is a PERMISSION problem (sc-6977).** `useFilestoreUpload` and `usePdfExport` relay whatever the destination decides, and that decision used to arrive as a bare `404 "Space not found"` no matter who asked. sc-5395 chose that masking for one good reason — an **anonymous** prober must not be able to enumerate which workspaces have opened their public space — but it applied to every caller, so a signed-in author uploading a cover image hit a dead end with nothing to act on.

The refusal now splits along what the caller can already see:

- **403** when the caller can READ the destination (the response body carries `code: "UPLOAD_NOT_PERMITTED"`, which the client puts on `err.details.code` — `err.code` is the client's own `"FORBIDDEN"`, so branch on `err.status`). The common case is a `public` space, which is world-readable but upload-**deny-by-default**: the workspace operator has to switch upload access on (Studio → File library → the space → upload access). Naming that discloses nothing the caller could not already see.
- **404 `"Space not found"`** wherever saying more would leak: an anonymous caller, or a position hidden from this one (a restricted folder, a foreign `owner_id`, another tenant). A masked refusal carries no `code`.

No hook signature moved, so nothing has to change to keep working — but a widget that branched on `404` alone should accept **both**, which is what the built-in Files widget now does. Folder creation and a folder move report the same split (`FOLDER_CREATE_NOT_PERMITTED` / `FOLDER_MOVE_NOT_PERMITTED`).

### What's new in 0.121.0 (contract 1.93.0)

**An unset nav-chrome surface follows the PAGE, not a flat white — `resolveSidebarTokens` / `resolveTopBarTokens` (sc-6596).** 1.84.0 converged the two hosts' separately-written chrome defaults onto the web Player's `#ffffff`. Converging was right; the value was not. An app with a themed page and no explicit `sidebar`/`topBar` `backgroundColor` got bright white chrome beside the colour its author had picked — consistently on both hosts, and consistently wrong.

Both resolvers now resolve an unset `backgroundColor` to the colour the page actually shows: the app's `backgroundColor`, or a configured `backgroundGradient`'s start colour, falling back to the app default (`#f8fafc`) when the theme names neither. A gradient counts only when BOTH its stops are flat hex, which is what the hosts' own page readers require — a half-configured gradient paints nothing, so the chrome must not adopt its start colour. Per REQ-THEME-17 the page is read at FULL STRENGTH: a stored `#RRGGBBAA` reaches the chrome as its opaque base, because an alpha on the app's bottom layer composites against the host's canvas rather than anything the author chose.

This is deliberately NOT the rule an `attached` top-bar tab follows. A tab JOINED to the page refuses a translucent page rather than approximate it; chrome sitting BESIDE the page follows what the page renders. Both read one shared chain, which differs by exactly that rule.

An explicitly coloured rail or bar is unaffected. An app that themed nothing moves from `#ffffff` to the `#f8fafc` its page already was. The footer strip is untouched and still floors at white — it keeps `resolveFooterTokens`' raw-or-null shape and wants this same treatment next.

**Behaviour change, not additive:** no signature, field or export moved — only what a host renders for an unset chrome surface. Host-integration surface only; nothing a widget imports changed. `CONTRACT.version` → `1.93.0`.

### What's new in 0.120.0 (contract 1.92.0)

**A `top-bar` app chooses which ROW its menu lives in, and how that row looks — `CONTRACT.themeTopBarMenuStyles` plus the `topBar` tab vocabulary (REQ-NAV-STRUCTURE).** The shape drew its menu as text links beside the brand, sharing the bar's one row wherever there was space. That reads as part of the header rather than as the app's global navigation. `topBarMenuStyle` now picks between `links` (that row, unchanged) and `tabs` — a dedicated tab row under the bar at every width, icon and label per page, scrolling sideways rather than dropping one. `normaliseNavigation` returns it beside `menuType`, so one resolver still answers both questions and the Player and the export cannot disagree about which row an app draws. Absent, unknown, or set on any other shape resolves to `links`.

`resolveTopBarTokens` gains the vocabulary that row is painted with:

- **`activeColor`** — the current page's mark: the active link's label, and an active tab's label and indicator. Its fallback chain is the FOOTER's rather than a new one: the bar's own value, else the **rail's**, else the brand. An app should not have to state the same navigation colour three times, and the rail is where an author already sets it. Unlike the footer's, this chain ends in the brand rather than in null — an active mark has no null state.
- **`tabStyle`** (`underline` | `attached`) and **`contentSurface`**. `attached` makes the current tab a folder tab joined to the page. The join is made by CONSTRUCTION rather than by matching: an opaque tab cannot track a page that is not flat, so the content area takes the same surface and becomes the panel the tab sits in. `contentSurface` is that shared value — always opaque, following a gradient to its start colour, and honouring an authored active surface so moving the tab moves the page with it.
- **`tabBackgroundColor`** / **`tabActiveBackgroundColor`** — a tab's own surface per state, authored or null. Null paints none and the bar shows through.
- **`tabIndicatorWidth`** (0-8, default 2) — the underline under the current tab. Zero draws none, so the WIDTH is its switch: the indicator shares `activeColor` with the label and has no null colour to switch on.
- **`tabCornerRadius`** (0-24) rounds the tab's TOP corners only — its feet stay square whatever the value, because a rounded foot notches the join — and **`tabPaddingX`** / **`tabPaddingY`** (0-32) replace the frozen 12/8, which remain the defaults.

Every measurement is CLAMPED rather than dropped: landing on the end of the range is what an author dragging a slider means. Host-integration surface only — no author-facing hook, prop, primitive, or manifest field changed.


### What's new in 0.119.0 (contract 1.91.0)

**`useGeolocation()` can now track location while the app is BACKGROUNDED (sc-6450).** The hook only ever read a position while the app was in the foreground, so the whole class of field-work apps — delivery tracking, site visits, mileage and timesheet logging — could not be built. Its result gains four members; the existing foreground API is untouched:

| Member | Shape | Notes |
| --- | --- | --- |
| `backgroundSupported` | `boolean` | **Check this before rendering the control.** |
| `backgroundWatching` | `boolean` | Whether a watch is running on this device. |
| `startBackgroundWatch` | `(options?) => Promise<void>` | `options`: `{ enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }`. |
| `stopBackgroundWatch` | `() => Promise<void>` | Releases the OS subscription. |

```jsx
const { latitude, longitude, backgroundSupported, backgroundWatching, startBackgroundWatch, stopBackgroundWatch } = useGeolocation();

{backgroundSupported && (
  <Button
    label={backgroundWatching ? "Stop trip" : "Start trip"}
    onPress={() => (backgroundWatching ? stopBackgroundWatch() : startBackgroundWatch({ distanceIntervalMeters: 50 }))}
  />
)}
```

The watch is **native-only and opt-in per app**. `backgroundSupported` is `false` on the web Player — a browser tab genuinely cannot track in the background, the same capability-gated honesty `useSpeechToText` applies on Firefox — and it is also `false` in an exported app whose workspace has not enabled background location in **Publishing Settings**. That opt-in exists so an app that never uses the capability declares no background mode and stays clear of the extra store review.

Three behaviours to design around. Tracking covers the app **running in the background**; it does not survive the OS terminating the app, so don't promise an unattended log. The watch **outlives the widget's mount** — that is the point — so it is released only by `stopBackgroundWatch()`, never on unmount; `backgroundWatching` is seeded from the host so a remounted widget reports a running watch honestly. And background positions land in the **same** `latitude` / `longitude` / `accuracy` slots as the foreground read, so a widget renders one position regardless of how it arrived.

`startBackgroundWatch()` rejects with the existing `GeolocationError`: `.code` `UNSUPPORTED` when the host or build does not offer the capability, `PERMISSION_DENIED` when the user refuses always-on location. On Android 11+ the always-on grant cannot be made from a runtime dialog — the user has to pick "Allow all the time" in system Settings — so treat `PERMISSION_DENIED` as a prompt to explain that, not as a dead end.

`backgroundWatching` is a mirror of the host, not of your calls: the OS can end the watch on its own (a permission downgrade, a killed foreground service) and a sibling widget can start or stop it, so render from the flag rather than from whether you called `start`.

**Store review:** Apple and Google both require a *visible user benefit* plus a justification for background location. Your app must show the user that tracking is running and let them stop it, and your store listing must explain why the app needs it. A build that turns the opt-in on without that is rejected at review.

`CONTRACT.version` → `1.91.0`. Additive — four new result members and six new optional `ctx.device.geolocation` broker members; no existing export changed signature.
### What's new in 0.118.0 (contract 1.90.0)

**A `styleSchema` field's `default` now actually applies.** Declaring `default` on a style field wrote it into the manifest and nothing ever read it back, so a widget's own styling baseline — and any design saved from the Widget Builder preview — was silently dropped on the next render. The host now resolves it onto `props.style`.

It is the **weakest** layer, deliberately: it applies only when nothing above it sets that field.

```
styleSchema `default`  ->  palette / components.<scope>  ->  widgetStyles[manifestId]  ->  per-instance props.style
```

So a workspace theme still outranks a widget's own baseline, and a field you leave undefaulted keeps following the theme exactly as before. Nothing changes for a widget that declares no style defaults.

The host still does **not** apply style to elements — your widget owns placement and keeps reading `props.style.<field>` (or `useWidgetStyle()`) and applying each value where it chooses:

```jsx
const style = useWidgetStyle();
<View style={[styles.card, style.cardBackground && { backgroundColor: style.cardBackground }]}>
```

Keep `themeDefault` for a fallback that IS a theme token: it stays display-only (a greyed placeholder in the Style panel) so the field tracks the workspace theme. Use `default` for a literal constant your code genuinely falls back to — that value now reaches `props.style`, so it must match what your code applies.

### What's new in 0.117.0 (contract 1.89.0)

**`expo-sensors` is a vetted import — widgets can read device motion.** The allowlist held no sensor package, so a step counter, a shake control, a tilt/level, or a compass had nothing to read the hardware with. `expo-sensors` (Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer, LightSensor) is now on the list as **native-only** (`platforms: ["native"]`, category `sensors`) and pinned by the Expo export.

Native-only is deliberate rather than a gap: the package's own web build derives "acceleration" from `deviceorientation` **angles**, not real motion, so a shake threshold tuned in the Player would behave differently in the exported app. Split the widget and read the browser API directly on web:

```jsx
// widget.native.jsx
import { Accelerometer } from "expo-sensors";

Accelerometer.setUpdateInterval(100);
const sub = Accelerometer.addListener(({ x, y, z }) => setReading({ x, y, z }));
return () => sub.remove();          // ALWAYS remove it — a live sensor drains the battery
```

```jsx
// widget.web.jsx — window.DeviceMotionEvent exposes the same hardware
const onMotion = (e) => setReading(e.accelerationIncludingGravity);
window.addEventListener("devicemotion", onMotion);
return () => window.removeEventListener("devicemotion", onMotion);
```

Start the reading from a **user gesture** on both hosts — iOS Safari also needs an explicit `DeviceMotionEvent.requestPermission()` grant, and neither host delivers readings to a listener attached on mount.

**Fixed: a `expo-haptics` widget failed the native build.** `expo-haptics` has been vetted since the package expansion but was never pinned in the exported app's `package.json`, so a widget importing it rendered in the web Player and broke the Expo bundle with "Unable to resolve module expo-haptics". It is pinned now, and the pairing is no longer hand-maintained: every native-capable entry on the vetted list is checked against the export's dependency set by a contract-derived test.

### What's new in 0.116.0 (contract 1.88.0)

**New primitive `<Overlay>` — a widget can finally open something over the SCREEN (sc-6607).** Until now a widget could only paint an overlay inside its own root, where the host's layout containers clip it: a PDF preview, a lightbox, or a confirm dialog opened trapped inside the widget's tile, and no amount of `zIndex` fixed it (`overflow: "hidden"` clips regardless, and `position: "fixed"` does not exist on native).

```jsx
<Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">
  <ScrollView>{renderPreview(preview)}</ScrollView>
</Overlay>
```

`<Overlay>` renders its children OUTSIDE the widget's layout box on both hosts — the web Player portals them to the document root, the exported Expo app hands them to the OS modal — while they stay in your own React tree, so their state and hooks are untouched. `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 or popover is the one overlay kind that still belongs inside your own root — `<Overlay>` centres on the screen rather than on a trigger.

### What's new in 0.115.0 (contract unchanged)

**`write-not-gated-on-user` now accepts a `useCanWrite()` gate — a widget may be opened to logged-out visitors (sc-6593).** The rule (added in 0.89.0, below) flagged any `useDatastoreMutation` write that carried no identity guard, and only a `.id` / `groupIds` / `roles` check counted as one. That encoded "a write needs a signed-in app user" as a platform fact, which it is not: a table whose permissions grant **Create** to *Everyone (anonymous + signed-in)* accepts a write from a logged-out visitor, and `useCanWrite(tableId)` answers `true` for them.

So a widget gated on `useCanWrite` alone — the correct shape for a public tally, a guest sign-up sheet, or an open feedback form — used to trip the warning that steers the AI widget agent's repair loop back to identity gating, making the sign-in requirement impossible for an author to remove. It is now recognised as a gate, and its finding label names it first.

Nothing else changes: a widget with **no** gate at all is still flagged, `useUser().id` read purely as a VALUE still does not satisfy the rule, and the severity is still `warning` (never publish-blocking). Identity gating remains the right default for almost every write — this only stops the linter from arguing against the one case where it isn't.

When you take the `useCanWrite`-only route, omit the USER column for a guest (`if (user.id) payload[byField] = user.id;`) — an anonymous row records no author, so per-person limits and "my entries" views cannot work for one.

### What's new in 0.113.0 (contract 1.86.0)

**New `useCamera()` hook — take a photo or pick one from the device library.** A new CORE hook reading a new `camera` capability on the existing `ctx.device` slice. Returns `{ asset, loading, error, supported, capture, pick, reset }`. Capture is **imperative** — call `capture()` or `pick()` from a user gesture (a `Pressable.onPress`); the browser and the mobile OS gate the permission prompt on a gesture, so it NEVER opens on mount. `options` (`{ allowsEditing, quality }`) pass through to the host. It needs **no manifest scope** and **no `requestedScopes` entry**.

**Dismissing the picker resolves `null`, not an error.** Backing out is the most common outcome, so it is deliberately not a rejection — your happy path needs no `try/catch`. A genuine failure (permission refused, no host broker) rejects with a structured `CameraError` (new named export) carrying a stable `.code` (`PERMISSION_DENIED` / `UNSUPPORTED` / `INTERNAL`).

**One upload path on both hosts.** The resolved asset is normalised to `{ uri, name, mimeType, width, height, size, file }`, where `uri` is directly displayable (`<Image source={{ uri }} />`) and `file` is already the right upload part for the platform — a browser `File` on web, expo-file-system's `File` on native. (Never React Native's legacy `{ uri, name, type }` part: the native host streams the file off disk, and Expo's fetch rejects that part outright anyway. Do not branch on the part's type — an expo-file-system `File` is NOT `instanceof Blob` at runtime.) So the same three lines work everywhere:

```js
const fd = new FormData();
fd.append("file", asset.file);
await ctx.assets.upload(fd);
```

`options` (`{ allowsEditing, quality }`) are **hints**: the Expo export applies both, the web camera applies `quality` only — so never depend on a cropped result. `reset()` clears the asset and releases it (on web that revokes the blob URL, which otherwise leaks for the life of the document). **Gate your camera button on `supported`** — a host that brokers no camera reports `false` rather than throwing. The web Player brokers it via `getUserMedia` — a live preview with a shutter — on every web host: it is the only camera a DESKTOP browser will open, and a phone reaches the same preview rather than its own camera app. It falls back to a file input (`capture="environment"`) only where getUserMedia is absent up front: an insecure origin or an older browser. Once the permission prompt has been shown the user gesture is spent, so a camera that then fails to open rejects (`PERMISSION_DENIED` if refused, `UNSUPPORTED` if there is no usable device) rather than falling back to a file dialog the browser would refuse to open. The Expo export uses `expo-image-picker`, whose config plugin declares the camera and photo-library permissions the runtime needs.

Additive — one new hook, one new optional context-slice member; no existing export changed signature.


### What's new in 0.112.0 (contract unchanged at 1.85.0)

**Two linter rules make the styling contract checkable, and `lintStyleWiring` joins the linter export (sc-6455).** Every visual value a widget writes reaches the app's owner through one of exactly two channels — a **theme token** (`useTheme()`), which is the app-wide default and follows a look change, or a **`styleSchema` field** read off `props.style`, which the Studio offers per instance in the widget editor *and* app-wide under **Design → Widget appearance**. A literal reaches neither: it outranks the theme permanently and no control on either surface can move it, so the owner finds a corner of their app they cannot restyle. Until now that rule was documentation only.

- **`no-hardcoded-design`** flags a colour literal (`#rgb` / `#rrggbb` / `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`, `hsla()`), a `fontFamily` string literal, or a numeric `fontSize`. A `fontSize` resolved off a theme token or a style field (`style.valueSize ?? 18`) is *not* flagged — a literal `default` beside a declared field is the contract working. Raw `padding` / `margin` / `borderRadius` numbers are deliberately out of scope: measured layout legitimately carries them, so a spacing rule would be noise.
- **`style-field-unread`** flags a `styleSchema` field whose name appears nowhere in the widget's source — a dead control the author moves to no effect. It runs over the WHOLE bundle, not per file, so a split-impl widget that reads a field in `widget.web.jsx` and not in `widget.native.jsx` is correctly counted as wired.

A value that genuinely cannot be a token — a categorical series palette, a video letterbox, a scannable QR plate — is licensed with a preceding comment. **The reason is mandatory**; a bare marker licenses nothing. A marker on its own line covers the whole statement below it (bracket-balanced, so one marker covers a multi-line palette); a trailing marker covers only its own line.

```js
// appstudio-design-ok: categorical series identity cannot come from one accent
const SERIES = ["#ff6b5b", "#3b82f6", "#10b981"];

const letterbox = { backgroundColor: "#000" }; // appstudio-design-ok: video letterbox
```

Both rules are **warning** severity, so `appstudio-widget lint` reports them and still exits 0 — you decide when to act on them. They are **blocking** for the AI widget agent, which publishes with no human in the loop.

`lint` now takes a whole bundle, and `--manifest` enables the wiring check:

```sh
npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
```

```js
import { lintStyleWiring } from "@colixsystems/widget-sdk/linter";
const report = lintStyleWiring(manifest, { "widget.jsx": source });
```
### What's new in 0.111.0 (contract 1.85.0)

**Each side can be spaced on its own — the `spacing` property type (sc-6447).** Padding and margin were single numbers, so every inset applied to all four sides at once: a hero with generous top padding and none at the bottom, or a card held off only its left neighbour, had no expression. `cornerRadius` already offered each corner (0.104.0); padding and margin were the last four-valued members of the box model that did not.

Declare `{ type: "spacing", label: "Padding", validation: { min: 0, max: 64 } }` in your `propertySchema` or `styleSchema`. The Studio renders a slider with a typeable number that sets all four sides, plus a disclosure for setting each one. The authored value is `number | { top, right, bottom, left }` — the scalar form is unchanged, so every value stored before is still valid.

```js
import { normaliseSpacing, spacingStyle } from "@colixsystems/widget-sdk";

const padding = normaliseSpacing(props.style?.padding, 0, 64);
return <View style={[styles.card, spacingStyle(padding, "padding")]} />;
```

`normaliseSpacing(value, fallback, max)` returns all four sides resolved and clamped; `spacingStyle(spacing, property, format)` emits the `padding`/`margin` shorthand when the sides agree and the four long-hand props when they differ (pass `` n => `${n}px` `` for the DOM). `mapSpacing` pushes each side through your own scaling, `isUniformSpacing` and `isZeroSpacing` round out the set. `CONTRACT.version` → `1.85.0`. Additive: every value accepted before is accepted now.

### What's new in 0.110.0 (contract 1.84.0)

**`resolveSidebarTokens` and `resolveTopBarTokens` — the rail's and the app bar's tokens, resolved once for both hosts (sc-6289).** The footer strip got a shared resolver in 1.71.0; the two chrome parts beside it did not, so their defaults lived inline in the web Player and again in the compiler — and had drifted. An unset `sidebar.backgroundColor` painted the app background in the Expo export where the Player painted it white, so a dark app shipped a dark drawer beside a white rail; an unset `topBar.textColor` painted the app name slate in the export where the Player has always used the brand colour.

Both resolvers now own their defaults, which at this version are what the web Player renders, so the export follows the appearance the author approved in the Studio rather than the other way round. (**Superseded in 1.93.0** for `backgroundColor` alone: the shared default became the page's own colour, moving both hosts rather than only the export.) Unlike `resolveFooterTokens`, the colours are never `null` — a default that lives in the resolver cannot drift, and one that lived in each host already had. `borderColor` stays nullable: the colour is the divider's switch.

The top bar resolves **two** text colours. An unthemed bar paints its icons slate and its app name in the brand colour, and React Navigation's single `headerTintColor` cannot say both — so `tintColor` and `titleColor` are separate, and an authored `topBar.textColor` drives both. `show` is deliberately not among them: it depends on the menu type rather than the theme, and REQ-THEME-14 makes it a no-op on native.

Host-integration surface only — nothing a widget imports changed. `CONTRACT.version` → `1.84.0`.

### What's new in 0.108.0 (contract 1.82.0)

**`themeTokens.colors` gains `loader` — the surface's spinner colour (sc-6095).** A widget that drew its own loading state had no token for it, so it reached for a literal grey. On a dark theme that grey vanished into the background and the widget looked frozen rather than busy — the same bug the host's own loading holds had.

`loader` tracks `onSurfaceMuted`, which means it rides the existing REQ-THEME-SURFACE derivation: it flips light on a dark page background, and it re-derives for a container that paints its own dark fill, exactly like the text colours around it. So reading it is enough — there is nothing to branch on:

```js
const t = useTheme();
if (loading) return <ActivityIndicator color={t.colors.loader} />;
```

The workspace's *Design → Loading Indicator* panel can override it app-wide; unset, it stays derived. Additive — the full `colors` shape is now `{ primary, onPrimary, primarySoft, onPrimarySoft, primaryStrong, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, loader, danger, success, warning, info }`.

### What's new in 0.107.0 (contract 1.81.0)

**Widgets can render QR codes — `react-native-qrcode-svg` is now a vetted import (sc-6105).** There was no QR encoder on the allowlist, so a check-in code, an install link, a table-ordering code, or a payment hand-off could not be built at all. The only QR the platform produced was a PNG the backend renders for one specific BankID sign order, which encodes nothing else.

The package is vetted for **both platforms** and needs no split file: it draws with the already-vetted `react-native-svg`, so one component covers the web Player and the Expo export.

```js
import QRCode from "react-native-qrcode-svg";
import { View } from "@colixsystems/widget-sdk";

export default function TicketCode({ ticketUrl }) {
  return (
    <View>
      <QRCode value={ticketUrl} size={180} ecl="M" />
    </View>
  );
}
```

`value` is the encoded string; `size`, `color`, `backgroundColor`, `quietZone`, `ecl` (`"L" | "M" | "Q" | "H"`) and the `logo*` props shape the render. Encoding happens on-device — no network call, so it works offline in both hosts.

One caveat carried over from the package: it documents a Metro transformer that injects a `TextEncoder` polyfill for React Native **below 0.75**. The export ships RN 0.85.3, which has `TextEncoder` as a global, so that transformer is intentionally not wired up — don't add it.

### What's new in 0.106.0 (contract 1.80.0)

**A `required` `tableRef` can now say the table is the APP'S, not the widget's — `sharedTable: true` (sc-5982).** sc-2791's publish gate `manifest.requiredTableRefsHaveTemplate` counted only two kinds of satisfying table: one the widget seeds via `datastoreTemplate`, and one the host hands in when the in-Studio agent delegates a build. A widget that legitimately reads a table it does not own — a chart, a metric, a map, a manager view over another widget's table — could satisfy neither when publishing on its own, so the only way through the gate was to drop `required`. That trades a publish error for a worse runtime one: an unbound widget renders an empty tile and the author gets no warning. (In this repo it also took down every first-party publish for twelve days.)

`sharedTable: true` on a `tableRef` property says "required, but the author supplies the table; I seed nothing for it". It exempts the property from the seeding duty only — the property stays `required`, the author must still bind a table, and the host still refuses to place the widget unbound. Pick between the two shapes by asking who owns the data:

```js
propertySchema: {
  // The widget owns its data → seed it.
  messagesTableId: { type: "tableRef", label: "Messages", required: true },
  // The app owns the data, the author points at it → declare it shared.
  sourceTableId: { type: "tableRef", label: "Source table", required: true, sharedTable: true },
}
```

Leaving a `required` `tableRef` with neither a seeded table nor `sharedTable` is still rejected at publish, and the rejection still names the offending properties. Additive: every manifest that passed before passes now.

### What's new in 0.105.0 (contract unchanged)

**New linter rule `sdk-export-not-imported` — self-containment now covers the SDK surface, not just `React` (sc-5897).** `react-not-imported` (0.62.0) enforced that a referenced `React` global must be imported, but there was no equivalent rule for the SDK's own exports: source that called `useFilestoreFile(id)` — or rendered `<View>` — without naming it in `import { … } from "@colixsystems/widget-sdk"` linted clean, bundled clean, and threw `ReferenceError: useFilestoreFile is not defined` at render. The two failures are one bug, so they now share one detector, keyed off the names `CONTRACT.hooks` and `CONTRACT.primitives` already enumerate — a hook added to the contract is covered with no second list to maintain.

The scan is AST-free, so it flags only the three reference forms text can decide with certainty: a call `NAME(`, a member read `NAME.` (`StyleSheet.create`), and a JSX element `<NAME`. Any other occurrence of the word — an import specifier, a local declaration or destructure, a property key, JSX text, a comment or string — means a binding the scan cannot see may exist, and the name is abandoned rather than flagged. `error` severity with no opt-out, matching `react-not-imported`. Author fix: add the name to your SDK import. `CONTRACT` is unchanged (no new field).
### What's new in 0.104.0 (contract 1.79.0)

**Each corner can be rounded on its own — the `cornerRadius` property type.** A radius was a single number, so every rounded surface was rounded on all four corners: a card that meets the screen edge, a tab rounded only on top, or a bubble with one squared corner had no expression.

Declare `{ type: "cornerRadius", label: "Corner radius", validation: { min: 0, max: 48 } }` in your `propertySchema` or `styleSchema`. The Studio renders a slider with a typeable number that sets all four corners, plus a disclosure for setting each one. The authored value is `number | { topLeft, topRight, bottomRight, bottomLeft }` — the scalar form is unchanged, so every value stored before is still valid.

Resolve it with the two new exports rather than reading the raw value, because they hide the shape and spell the style props the way BOTH hosts accept:

```js
import { normaliseCornerRadius, cornerRadiusStyle } from "@colixsystems/widget-sdk";

const radius = normaliseCornerRadius(style.radius, 0, 48);
return <View style={[styles.card, cornerRadiusStyle(radius)]} />;
```

`normaliseCornerRadius(value, fallback, max)` returns all four corners resolved and clamped; `cornerRadiusStyle(radius, format)` emits the `borderRadius` shorthand when they agree and the four long-hand props when they differ (pass `n => `+"`${n}px`"+`` for the DOM). `isUniformCornerRadius` and `hasCornerRadius` round out the set. `CONTRACT.version` → `1.79.0`. Additive: every value accepted before is accepted now.

### What's new in 0.103.0 (contract 1.78.0)

**An app-wide style value may have SHAPE — `widgetStyles` is no longer scalars-only.** Your `styleSchema` is offered in two places: the widget editor (per placed instance) and Theme Settings (app-wide, under your widget's own name). A field holding a structured value — an overlay object, a list of ids — persisted in the first and was silently dropped by the second, so the same edit behaved two ways depending on where the author made it.

`normaliseWidgetStyles` now carries objects and arrays, bounded by `CONTRACT.themeWidgetStyles`: `maxValueDepth` (3), `maxValueEntries` (24 per level), `maxValueBytes` (512 per field), and a new `maxBytes` (64000) over the whole map — a ceiling the per-scalar limits never stated, so the worst-case payload of that unauthenticated cold-start read is now *smaller* than before. `__proto__`-style keys are refused at every level.

Host-integration surface only — nothing a widget imports changed, and your widget still reads `props.style` without learning which layer supplied a value. `CONTRACT.version` → `1.78.0`. Additive: every value accepted before is accepted now.

### What's new in 0.95.0 (contract 1.68.0)

**One number for the quick bar's cap — `quickBarMaxItems` + `quickBarCap`.** The sidebar's secondary mobile quick bar caps at five, and that five lived in two places: a named constant in the web chrome and a bare literal in the compiler. Nothing stopped them drifting, and no Studio surface could state the number at all.

`CONTRACT.themeMenuTypes.*.quickBarMaxItems` now carries it (5 on `sidebar`, `null` on the two shapes that draw no quick bar), read through the new `quickBarCap(menuType)` host export.

The distinction from `menuItemCap` is the point and is worth keeping straight: **`quickBarCap` may drop a page** — the rail and the drawer still list every menu page, so the bar is a shortcut. **`menuItemCap` may not** — where the chrome IS the menu (`bottom-tabs`), the surplus moves behind a More sheet instead.

Host-integration surface only. `CONTRACT.version` → `1.68.0`.

### What's new in 0.94.0 (contract 1.67.0)

**The footer strip is themeable — `resolveFooterTokens` (REQ-NAV-STRUCTURE).** The bottom strip's surface was hard-coded white on both hosts and its items read the SIDEBAR's tokens. That is fine while the strip is the sidebar's secondary quick bar, and untenable once it IS the menu: the `bottom-tabs` shape hides the sidebar panel, so those tokens have nowhere to be set.

A `theme_config.footer` block now carries `backgroundColor`, `textColor`, `activeColor` and the opt-in divider `borderColor` + `borderWidth`. **Every field falls back to the sidebar's**, so a workspace that never touches it renders exactly as before and only an explicit value moves anything.

`resolveFooterTokens(theme)` (from `@colixsystems/widget-sdk/host`) is the one resolver both hosts read it with. It also settles two divergences the strip carried: the native bar ruled a permanent `#e2e8f0` hairline the theme could not reach — REQ-THEME-LOOK's rule is that the divider's **colour** is its switch — and it tinted the active tab's *label* where the web painted a filled pill, so `activeStyle: "filled"` meant two different things per host.

Host-integration surface only: no author-facing hook, prop, primitive, or manifest field changed. `CONTRACT.version` → `1.67.0`.

### What's new in 0.93.0 (contract 1.66.0)

**An app picks the SHAPE its navigation takes — `CONTRACT.themeMenuTypes` (REQ-NAV-STRUCTURE).** Until now the chrome was always a sidebar: a persistent left rail on desktop, a hamburger drawer plus an optional bottom quick bar on mobile. That is the right default for an admin tool and the wrong one for a phone-first app or a site, and there was no way to say so. `CONTRACT.themeMenuTypes` publishes the closed catalogue — `sidebar`, `top-bar`, `bottom-tabs` — each entry carrying `{ name, summary, maxItems }`.

`maxItems` caps how many menu pages the chrome draws at once, and its meaning differs per type deliberately: the sidebar's mobile quick bar is a **secondary** curated bar whose cap may drop a page (the rail still lists every one), while a `bottom-tabs` strip **is** the menu, so its cap must never drop one — the surplus moves behind a More sheet instead.

**New host exports (`@colixsystems/widget-sdk/host`)** — `normaliseNavigation(navigation)` resolves a stored `theme_config.navigation` block to the `{ menuType }` a host switches its chrome on, and `menuItemCap(menuType)` states that shape's cap. One implementation for both hosts, so a menu type cannot mean one thing in the web Player and another in the exported Expo app. An absent or unknown value resolves to `sidebar`, so every app authored before menu types existed renders and compiles byte-identically.

Host-integration surface only: no author-facing hook, prop, primitive, or manifest field changed. `CONTRACT.version` → `1.66.0`.

### What's new in 0.101.0 (contract 1.75.0)

**Render an image at the size you actually show it — `file.urls` (sc-5699).** Every Filestore file record now carries a delivery **size ladder** beside `url`:

```js
const { files } = useFilestoreFiles({ spaceType: 'public' });
// a 128px grid downloads 128px images, not the 4096px originals
files.map((f) => <Image key={f.id} source={{ uri: f.urls.thumbnail }} />);

const { url, urls } = useFilestoreFile(fileId);
<Image source={{ uri: urls?.large }} />;   // a detail view
<a href={url}>Download original</a>;       // the full-size bytes
```

The rungs are `thumbnail` (128px), `card` (512px), `large` (1024px), and `hero` (2048px), each a **longest-edge** target. Pick the next rung **up** from the size you render at, so a 2x/3x screen still has enough pixels — a 100px avatar wants `thumbnail`, a 400px card wants `card`.

`urls` is **always fully populated**, so it never needs a fallback branch: a type with no ladder — SVG, an animated GIF, a PDF, a video — points every rung at the original. A rung is also never *upscaled*: ask for `hero` on a 300px image and you get the 300px original rather than a blurry 2048px copy.

`useFilestoreFile` returns `urls` alongside `url`, and — like `url` — it is `null` until the fetch resolves, so read the top-level value rather than `file.urls`.

Requires `@colixsystems/filestore-client` ≥ 0.8.0. `CONTRACT.version` → `1.75.0`. Additive — `url` and `presigned_url` are unchanged, so a widget that ignores `urls` behaves exactly as before.

### What's new in 0.100.0 (contract 1.74.0)

**Opt out of image compression on upload — `useFilestoreUpload({ compress })` (sc-5402).** A file uploaded through `POST /api/v1/filestore/files` now has its raster images compressed to WebP again (EXIF-stripped, longest edge capped at 4096 px), which is the right default for anything the app renders. When the ORIGINAL bytes matter — a document archive, a photo the user re-downloads, anything with an exact-bytes requirement — pass `compress: false`, either on the hook or per call:

```js
const { upload } = useFilestoreUpload({ spaceType: 'personal', compress: false });
// …or per upload, which wins over the hook default:
await upload(file, { compress: false });
```

Only raster images are ever compressed. SVG keeps its vector, and video, audio, and documents are stored verbatim under both values — an upload is never queued for background transcoding. The opt-out is the only value put on the wire, so the backend stays the single source of the default.

`CONTRACT.version` → `1.74.0`. Additive — existing callers are byte-for-byte unchanged on the wire.

### What's new in 0.98.0 (contract 1.70.0)

**Three new hooks close the biggest gaps in the write-gating and query-authoring surface (sc-5206).**

- **`useBoundColumns(tableId, shape, props)`** — resolve author-bound column NAMES from a widget's own props, built on `useDatastoreSchema`. Falls back name → case-insensitive name → first unclaimed column matching `dataType`, so `record[props.titleField]` reading `undefined` after a tenant renames a column is no longer a widget's problem: read `record[bound.titleField]` (via `const { columns: bound } = useBoundColumns(tableId, shape, props)`) and it keeps resolving.
- **`useStableQuery(buildQuery)`** — the same content-diffed stable-reference trick `useDatastoreQuery` already applies to its own `query` argument, generalised into a reusable hook: `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` and its easy-to-get-wrong deps array. Reads no `ctx` — safe outside a `WidgetContextProvider`.
- **`useCanWrite(tableId, options?)`** — a write-permission FLOOR reading a new `ctx.datastore.myPermissions` client method. `{ canWrite, loading, error, refetch }`; pass `{ recordId }` for a per-row check. Not a replacement for a MORE SPECIFIC domain rule (still hand-check "only the assigned user" in addition), and not a replacement for `useUser()` when the UI needs to tell "not signed in" apart from "signed in but forbidden".

The SDK linter gained two matching soft-warning rules: `raw-useMemo-into-datastore-query` (a `useDatastoreQuery` argument built from a raw `useMemo` when the file hasn't reached for `useStableQuery`) and `hand-rolled-write-gate` (a write gated on `useUser().groupIds`/`.roles` when the file hasn't reached for `useCanWrite`). Both are steering nudges (`severity: "warning"`), never publish-blocking.

- **`CONTRACT.version` → `1.70.0`** (additive: three new hooks + the new `datastore.myPermissions` context field + two linter rules). No existing export changed signature.
### What's new in 0.97.0 (contract 1.69.0)
**`CONTRACT.themeComponents` gains five scopes: `accent`, `destructive`, `muted`, `popover`, `ring` (sc-5392).** The vocabulary shipped with exactly `button`/`card`/`text` (sc-1497), so a shadcn/Tailwind app import's `--accent`, `--destructive`, `--muted`, `--popover` and `--ring` custom properties had no `themeConfig` home and were reported "no theme home" on every import. Each new scope binds to real `styleSchema` fields on the built-ins that already had a matching surface — `accent` (a highlight/tag surface: `background`/`borderColor`/`radius`) to the Label widget's own fields, `destructive` (a themed danger/delete action: `background`/`textColor`/`borderColor`) to a new "Danger" Button variant, `muted` (a subtle/secondary surface: `background`/`textColor`/`borderColor`/`radius`) to Form Input's and Form Builder's pre-existing input fields, `popover` (a dropdown/menu surface: `background`/`textColor`/`borderColor`) to the same two widgets' choice-field option list, and `ring` (the app-wide focus-visible outline: `color`/`width`) to a new emphasis border on Button. **This is HOST-ONLY plumbing, exactly like the three scopes before it** — `useTheme()`'s documented `components` slice is unchanged, no widget-authoring hook or `propertySchema` type moved, and no scope declares `universalFields`, so a third-party or AI-generated widget's contract is unaffected; the Developer guide and `DEFAULT_SYSTEM_PROMPT` need no update because neither ever documented this internal vocabulary. Fully additive: a theme with no `components` key, or one using only `button`/`card`/`text`, resolves exactly as before.
- **`CONTRACT.version` → `1.69.0`** (additive: five new `themeComponents` scopes + their target-field bindings). No existing scope, token, or export changed shape.

### What's new in 0.96.0 (contract 1.68.0)

**A manifest action can declare `manual`, and every action script gains a `request` global (sc-5366).** The backend Action model grew three ways of *reaching* a script to sit beside the ones that *fire* it: `manual` (nothing starts it — the workspace runs it on demand), `app` (a published app's button `onPress`) and `http_post` (an inbound webhook at `POST /api/v1/action-hooks/:actionId`, authenticated with one of the workspace's integration API keys). The same change retired the separate `appInvokable` boolean, so one column now answers "what starts this action?".

Only `manual` joins `CONTRACT.actionTriggerTypes`, and that is deliberate. `app` and `http_post` expose a script to a caller **outside** the Studio, which is the installing workspace's decision about running someone else's code — not the author's. A manifest that declares either is rejected by `validateManifest`, the CLI linter and the backend alike; the operator grants them in the Actions admin page after install, on top of whatever triggers your manifest declared. Nothing about an already-published manifest changes.

`CONTRACT.actionScriptGlobals` gains **`request`**: `{ body }` — the JSON an inbound webhook caller sent — on an `http_post` run, and `null` on every other trigger. Request *headers* are never passed through, because they carry the caller's API key. `triggerType` now also reports `"http_post"` alongside `"manual"` and `"app"`, so one script can tell a webhook apart from its nightly schedule:

```js
if (triggerType === "http_post") {
  const order = request?.body;
  if (!order?.id) return; // never trust the caller's shape
  await datastore.records("Orders").create({ externalId: order.id });
}
```

`CONTRACT.version` → `1.68.0`. Additive for every existing manifest.

### What's new in 0.95.0 (contract 1.67.0)

**An admin can mail a locked-out member a password-reset link — `useUsers().sendPasswordReset(userId)` (sc-5335).** An app user who forgot their password could only recover it themselves, from the app's own login screen. The admin they actually ask — the one already able to invite, deactivate and remove them — had no way to help, and the workaround in the field was to remove and re-invite the account, which discards its group memberships and history.

`sendPasswordReset(userId)` mails the **same** self-serve link `POST /auth/app/forgot-password` sends, to that user's **own registered address**, and resolves `{ sent, email_masked }`. It deliberately returns neither the token nor the link, so it is not an account-takeover primitive: an admin can start the recovery, only the user can finish it. `email_masked` (`ad**********@example.com`) is there because a widget caller reads the roster through the privacy-reduced directory projection, which omits email — the confirmation says where the mail went without becoming a new way to read addresses.

- **Scope**: `users.write:*`, alongside `invite` / `deactivate` / `reactivate` — the same grant, since mailing someone a link they must act on is strictly less powerful than deactivating them. The `scope-required-for-user-mutation` linter rule covers the new method, so calling it without the scope fails the lint.
- **Refusals are typed, not silent.** Rejects with a `DirectoryError` coded `USER_INACTIVE` (deactivated — reactivate first) or `NO_PASSWORD_CREDENTIAL` (an INTEGRATION service account authenticates by API key and holds no password). Branch on `code` and render `err.message`; don't offer the action on those rows at all.
- Issuing a link supersedes any outstanding one for that user, and the send is rate-limited per acting admin.
- The built-in **User Management** widget gains the row action and a new `onPasswordResetSent` event.
- **`CONTRACT.version` → `1.67.0`** (additive: one hook method). No existing signature changed, and both hosts get it from the same injected `@colixsystems/directory-client`.

### What's new in 0.93.0 (contract 1.66.0)

**BREAKING: a widget no longer declares server-side actions.** `manifest.actions` is removed from the contract and **refused** by `validateManifest` — an author who declares it now fails the publish with a message naming the replacement, rather than shipping a widget that quietly carries no automation.

- **Why.** `actions` made a widget two products in one package: a React component that renders, and a script that never renders at all — different runtime, different lifecycle, different review concerns, one manifest. Automation is now its own marketplace deliverable, an **Action** (`@colixsystems/action-sdk`), with its own manifest, starter kit, developer guide, submit button and platform-admin review. A workspace installs and configures it separately from any widget.
- **What to do instead.** Move the script into an Action manifest (`appstudio-action lint` / `pack`), publish it, and let the workspace install it. Its `propertySchema` is filled in by the **installing operator** rather than a page author, and the values reach the script as the `properties` global.
- **Removed from the contract**: the `actions` manifest field. `CONTRACT.actionTriggerTypes` / `actionScriptGlobals` / `actionScriptMaxBytes` remain exported for now but describe a surface no widget field uses — the action-sdk owns that vocabulary.
- **Existing installs are unaffected.** A tenant Action row materialised from a widget manifest keeps running, keeps its bindings and keeps its run history; the platform migrated those rows onto the Action that ships the automation. What is gone is the ability to declare a NEW one on a widget.
- **No parity impact.** Actions never ran in the rendered app, so nothing about the Player or the export changes.

### What's new in 0.92.0 (contract 1.65.0)

**New `useInterpretDraft(tableId)` hook — turn one sentence into DRAFT record values.** A new DATASTORE hook reading a new `interpret` method on the existing `ctx.datastore` slice (`@colixsystems/datastore-client` 0.13.0). Returns `{ interpret, interpreting, error, result, available }`. Call `interpret(text, { fields, timeZone })` **imperatively** from an event handler — never on mount or in a render loop — and it resolves to `{ values, unresolved }`, where `values` is keyed by column NAME (the same shape `useDatastoreMutation().create` takes) and `unresolved` names the fields the sentence did not state.

**It DRAFTS and writes nothing.** Prefill your inputs from `values`, let the person review and correct them, then submit as usual. A model reading free text must never create a record on its own.

Only columns a sentence can honestly produce are drafted — string, text, number, float, boolean, date, datetime and array. `FILE`, `RELATION`, `USER` and `USER_GROUP` are never guessed because they carry identifiers, and encrypted columns are skipped. Every value is coerced against its column's `data_type` and dropped when it does not fit, so a value the model got wrong is reported `unresolved` rather than written through.

**Every call spends the workspace's AI credits** and is rate-limited per actor. Once the workspace runs out, the call is refused with a **generic** 429: the person filling the form is never told the workspace's billing state — they have not heard of an AI credit and cannot buy one. Never surface a raw error to them; say drafting is unavailable and keep every field editable by hand. `available` is `false` where the host brokers no interpreter (an unbound preview, or an export with no reachable backend) — hide the affordance rather than rendering a dead button.

Additive — one new hook, one new client method, one new context-slice function; no existing export changed signature.

### What's new in 0.91.0 (contract 1.64.0)

**New `useSpeechToText()` hook — dictate into text with the device's on-device recogniser.** A new CORE hook reading a new `speech` capability on the existing `ctx.device` slice. Returns `{ transcript, partial, listening, supported, error, start, stop, abort, reset }`. Capture is **imperative** — call `start()` from a user gesture (a `Pressable.onPress`); the browser and the mobile OS gate the microphone prompt on a gesture, so it NEVER listens on mount. `transcript` accumulates finalised speech across utterances; `partial` holds the guess the recogniser has not committed yet (empty unless `options.interimResults`). `stop()` finalises and keeps what was heard, `abort()` discards the current utterance, `reset()` clears both. `options` (`{ lang, continuous, interimResults }`) pass through to the host. Rejections surface as a structured `SpeechToTextError` (new named export) with a stable `.code` (`PERMISSION_DENIED` / `NO_SPEECH` / `LANGUAGE_UNSUPPORTED` / `NETWORK` / `ABORTED` / `UNSUPPORTED` / `INTERNAL`). It needs **no manifest scope** and **no `requestedScopes` entry**.

Recognition runs **on device**: no audio is uploaded, nothing reaches our servers, and no AI credit is spent — so the hook is available to every workspace regardless of its AI data-residency policy. The web Player brokers it via the browser's `SpeechRecognition`; the Expo export via `expo-speech-recognition`, whose config plugin declares the microphone and speech permissions the runtime needs. Both hosts emit the same Web Speech error vocabulary, so one mapping serves both.

**Always gate your mic affordance on `supported`.** Firefox ships no `SpeechRecognition` at all, so `supported` is `false` there and `start()` rejects with `UNSUPPORTED` — render the plain text field instead of a dead button. The `speech` capability is optional and forwarded independently of `geolocation`, so a host that brokers one still brokers the other.

Additive — one new hook, one new optional device capability, one new error class; no existing export changed signature.

`v0.90.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**.

### What's new in 0.89.0 (contract unchanged)

**New linter rule `write-not-gated-on-user` — a widget that writes must decide what a signed-OUT visitor sees (sc-4985).**

- **`write-not-gated-on-user` (severity `warning`, non-blocking).** A widget that
  writes with `useDatastoreMutation` but carries no identity guard is flagged. A
  write needs a signed-in app user, so an anonymous visitor handed a live
  "Save" / "Book" / "Delete" button can only ever tap it and fail. Author fix:
  read `useUser()` and branch **before** rendering the control — when `!user.id`
  keep the affordance as a visibly-inactive signpost with a translated "sign in"
  line and **no press handler** (a widget cannot open the login surface; that is
  a built-in Button's `sign-in` action, wired by the page author), and when the
  user is signed in but not permitted, leave the control out entirely.
- **Reading `useUser().id` as a VALUE does not satisfy it.** The canonical
  USER-column write pattern (`create({ [memberField]: user.id })`) calls
  `useUser()` without ever branching on it — the case most easily mistaken for a
  gate — so the rule requires an operator after `.id` (a negation, a ternary,
  `&&`, a comparison) or a `groupIds` / `roles` check.
- **Why a warning.** It is conservative on purpose: an unrelated `.id`
  comparison elsewhere in the source silences it. A rule that occasionally stays
  quiet is far cheaper than one that cries wolf on correct code, and gating is an
  affordance decision — the server remains the only authority, so the `catch`
  stays either way.

`CONTRACT` is unchanged (no new field), and no export changed signature.

### What's new in 0.88.0 (contract 1.62.0)

**A refused datastore / directory / permission call now reaches the widget as its real reason (sc-4986).**

- **The reason was being thrown away.** Each `@colixsystems/*-client` throws typed
  errors carrying `.code` / `.status` / `.details` (the parsed envelope) and **no
  `.response`** — but `toDatastoreError`, `toDirectoryError` and
  `toPermissionError` read `err.response.*` only. Every typed client rejection
  fell through every branch and arrived as `code: "INTERNAL"`, so a 403 the
  workspace owner has to lift was indistinguishable from a dropped socket, and
  `DatastoreError.fieldErrors` never populated at all. `toPaymentError` was fixed
  for exactly this in 0.83.0; these three were not.
- **All three mappers now read both shapes**, preferring the envelope's own
  `message` (the canonical `{ statusCode, message, code }` field — the old code
  read a `.error` key the envelope has never carried). The documented `code`
  vocabularies are unchanged, so a widget already branching on
  `code === "FORBIDDEN"` starts working rather than having to change.
- **`DatastoreError` / `DirectoryError` / `PermissionError` gain `retryable`**
  (and `status`). `retryable === false` for a refusal only the caller, the record
  or the workspace can clear — 403 / 404 / 400 / 422 / 409 — and `true` for a
  timeout, a rate limit, a 5xx or a dropped socket. Branch on it instead of
  offering a blanket "try again". This is deliberately *not* the payments rule:
  a 402 `DECLINED` card IS worth another attempt, so that contract differs.
- **`fieldErrors` works again** — a 400/422 carrying
  `errors: [{ field, code, message }]` becomes the flat `{ field: message }` map
  the type has always advertised, so a form can mark the offending input.
- **New soft lint rule `datastore-error-not-branched`** (severity `warning`,
  never blocks a publish): a widget that writes with `useDatastoreMutation` but
  never reads `retryable`, branches on `code ===`, or renders the error's own
  `.message` is flagged, so the AI widget agent's repair loop closes the gap.
- `CONTRACT.version` → `1.62.0`: the three hooks' `returnShape` entries now name
  the `{ code, message, retryable }` triple. No export or signature changed —
  additive fields on three error classes.

### What's new in 0.87.0 (contract 1.61.1)

**Widget toasts are actually rendered now — both hosts wire `ctx.toast` (sc-4939).**

- **The host half of `useToast()` shipped.** The hook has existed since 0.15.0 and
  the AI widget agent has always been told to confirm a write with
  `showToast({ kind: "success", … })` — but no host ever populated the
  `WidgetContext.toast` slot. So the web variant dispatched an
  `appstudio:widget-toast` CustomEvent that nothing listened for, and native fell
  through to `console.log`. Every write confirmation an app raised was invisible:
  a user tapped Save and got nothing back. The web Player and the exported Expo
  app now both paint a workspace-themed stack, so a confirmation you raise is a
  confirmation the user sees.
- **New host exports (`@colixsystems/widget-sdk/host`)** — `createToastController()`
  (the queue, the auto-dismiss timing, newest-first stacking, injectable timers)
  and `resolveToastTokens(theme, kind)` (the themed values a toast is painted
  with, `error` mapping to the theme's `danger` role), plus `normalizeToastKind`
  and `TOAST_DEFAULTS`. Both hosts drive these, so only the JSX differs and the
  two cannot drift. This entry point is host integration, not the author API —
  a widget author still just calls `useToast()`.
- **No author-facing change.** No export, signature, hook or manifest field
  moved; a widget already calling `showToast` is unchanged and simply becomes
  visible. `CONTRACT.version` → `1.61.1` for the corrected `useToast` /
  `widgetContextShape.toast` descriptions, which used to imply a host might not
  render the toast at all.
- **An authoring preview still wires nothing.** The Studio canvas leaves the slot
  unset on purpose — a confirmation belongs to the running app, not to
  design-time — so `showToast` is a no-op there, as `navigation` and `events`
  already are.

### What's new in 0.86.0 (contract unchanged)

**New linter rule `measured-width-ignores-padding` — a measured width includes the measuring element's own padding (sc-4913).**

- **`measured-width-ignores-padding` (severity `warning`, non-blocking).** A widget that puts `onLayout` on an element which sets its own `padding` (or `paddingHorizontal`/`Left`/`Right`), and then sizes grid cells from the measured number, is flagged. `onLayout` reports the element's **frame** width and padding sits inside that frame, so the space the children really get is `width - paddingLeft - paddingRight`. Cells sized to fill the raw measurement overflow the content box, the last one wraps, and the widget ships a whole empty column of whitespace beside its cards — on both the web Player and the native Expo export, with nothing in the console. Author fix: spread `onLayout` on an **unpadded** element (keep the padding on a parent, or measure an inner `<View>` inside the padded root) so the number you hold is the usable width. Better still for content-sized cells: skip the measurement entirely and wrap with flex — a `{ flexDirection: "row", flexWrap: "wrap", gap }` row whose cards take `{ flexGrow: 1, flexBasis: CARD_MIN }` splits the row's real content width itself and can never leave a leftover band.
- **Why a warning.** The rule fires only when the measured value feeds sizing arithmetic — a padded box measured just to pick a wide/narrow form (`isNarrowWidth(width)`) is off by one padding pair and stays silent. A widget that already subtracts its own padding by hand matches too, because no text scan can verify the subtraction; that is deliberate — the remediation is correct for it as well and retires the arithmetic. Comments are not scanned, so documenting the anti-pattern is safe.

`CONTRACT` is unchanged (no new field), and no export changed signature.

### What's new in 0.86.0 (contract 1.61.0)

- **The workspace theme now reaches the elements an app is built from.** Three things that used to be unreachable are now themeable: an element inside YOUR widget, the structural card container a page is made of, and any style field whose name the platform does not know. For a widget author the practical change is that **your `styleSchema` is the contract**: every field you declare becomes a knob the workspace owner can set once for the whole app, so declare the fields that describe your widget's appearance and give them clear `label`s and `ui.group`s — those labels are what the owner reads.
- **A field name you invented is as reachable as a canonical one.** A theme may carry values keyed by your widget's manifest id and then by your own field names, so `panelFill` is adjustable app-wide exactly like `cardBackground`. Separately, the unambiguous card names (`cardBackground`, `cardBorderColor`, `cardRadius`, `cardPadding`, `cardGradient`) bind by NAME to any widget that declares them, so naming a genuine card surface canonically opts it into the workspace's Cards controls for free.
- **`useTheme().colors` describes the surface your widget SITS ON, not the page.** A layout container that paints its own background re-derives the surface roles for everything inside it, so reading `colors.onSurface` for your text is readable whether your widget lands on the page, in a dark hero, or in a light card nested inside that hero. `colors.loader` is that same surface's spinner colour — paint your loading state with it rather than a literal grey. Nothing to opt into.
- **Precedence, unchanged in spirit.** Contract default → workspace palette → component scope → per-widget-type value → the app author's per-instance `props.style`. Most specific wins, and the Properties Panel is still the final word. Your widget reads `props.style` exactly as before and never learns which layer supplied a value.
- **A colour may carry OPACITY.** `isHexColor` accepts the 8-digit `#RRGGBBAA` form alongside 3 and 6 digits, so a theme colour with an alpha reaches `useTheme()` with its transparency intact. It used to be rejected and the host dropped the key outright, which is why a translucent page background never reached the dark-surface derivation and every panel fell back to white.
- **The colour maths ignores alpha, on purpose.** `hexChannels` reads the R/G/B pair and skips any alpha, so contrast, readable text and the derived accent tints reason about the opaque colour. None of them can composite without knowing the backdrop, which a token table does not have — so transparency lives in the VALUE your widget renders, not in the decision about whether that colour reads as light or dark.
- **`CONTRACT.version` → `1.61.0`** (additive: `themeTokens.spacingScale` + `widgetStyles` and their bounds, `themeComponents.card.universalFields`, and the `normaliseWidgetStyles` / `deriveSurfaceTokens` host exports). No author-facing export changed signature, and a theme that sets none of it resolves exactly as before.

### What's new in 0.90.0 (contract 1.63.0)

**A manifest action declares `triggerTypes` — a set — and the script learns which one fired (sc-4915).** An action could carry exactly one trigger, so a widget that needed the same work done on create *and* on delete had to ship the script twice: two `actions` entries, two operator bindings, two run histories, and the usual drift between the copies. `triggerTypes` replaces `triggerType`: a non-empty array of unique values from `schedule`, `record_created`, `record_updated`, `record_deleted`, freely combined (`scheduleCron` is required iff the array contains `schedule`). The pre-0.90.0 scalar `triggerType` is still read and normalised into the array, so a widget already published against it keeps validating and nothing needs republishing. What makes the combination useful is the other half: the script's `triggerType` global now names the trigger that **actually fired this run** — including `"manual"` (an operator's Run now) and `"app"` (a button press) — instead of echoing the row's configuration, so one script can branch on whether its record was created or deleted. `CONTRACT.version` → `1.63.0`. Additive for every existing manifest.

### What's new in 0.85.1 (contract 1.60.1)

**`useWidgetEvent(name)` returns the emitter FUNCTION — the declared contract said otherwise (sc-4753).** `CONTRACT.hooks`'s entry for the hook declared `returnShape: { emit }`, so every surface derived from it — chiefly the Widget Builder Agent's hooks table — told authors the hook resolves to an object. It never did: `useWidgetEvent("slotChosen")` hands back the callable you invoke directly (`emitSlot({ courtId })`), exactly as the typings and the Developer guide have always documented. A widget written against the declared shape destructured a function, got `undefined`, and threw the moment a user interacted — a cross-widget wire that rendered perfectly and only failed on click. The declaration is now a bare callable and the publish-time render harness models the same shape, so a wrong destructure is caught instead of waved through. `CONTRACT.version` → `1.60.1`. Documentation-only correction: no export, signature, or runtime behaviour changed — a widget already calling the result is unaffected.

### What's new in 0.85.0 (contract 1.60.0)

**A widget must never name a currency — `useWorkspaceCurrency()` resolves it at render time (sc-4686).** A workspace picks the currency it charges its app users in, and the owner usually sets that *after* the app is built (the normal order is prompt first, billing later). So anything a widget wrote down — a `"kr"` in JSX, a `€` in a `manifest.translations` string, a `currency` argument on `requestPayment` — kept displaying the old currency over a charge that had correctly followed the change: one price shown, another taken. Currency is workspace configuration that changes after authoring, exactly like `theme` and `locale`, so it now joins them on the host-resolved `ctx.workspace` slice. `useWorkspaceCurrency()` returns `{ currency, formatMoney }`; `formatMoney(45000)` renders `"450,00 kr"` or `"450,00 €"` from `CONTRACT.currencyFormats` — an explicit table, not `Intl.NumberFormat`, which does not agree between the exported Expo app and react-native-web. Omit `currency` on `requestPayment` and the platform applies the workspace's own, so it can never be wrong. Enforcement tightened to match: `payment-currency` now rejects **any** currency literal (one that matches today still lies tomorrow) and so needs no per-workspace option — it fires in a bare `appstudio-widget lint`, and `lintSource`'s `paymentCurrency` option is removed; a new `no-hardcoded-currency-label` warning catches a symbol or code beside a price in a charging widget. `CONTRACT.version` → `1.60.0`. Additive for a widget that already omits `currency`.

### What's new in 0.84.0 (contract 1.59.0)

**A charge is denominated in the WORKSPACE's currency, and a widget that hardcodes a different one no longer publishes (sc-4649).** Every workspace picks the currency it charges its app users in, and `POST /payments/widget-charge` refuses any other code with `UNSUPPORTED_CURRENCY` — but nothing told a widget author which one that was. A widget priced in EUR for a workspace selling in SEK compiled, rendered, and looked finished, then failed every single checkout; the buyer read that as a generic "payment failed" and retried forever. Two changes: `currency` on `requestPayment` is best **omitted** (the platform applies the workspace's own, so it can never be wrong), and a literal that disagrees is now a publish-blocking `payment-currency` finding. Because the expected code is **per-workspace**, the SDK cannot know it: the rule fires only when the caller supplies `lintSource(source, { paymentCurrency })`, which the platform's publish gate does and a local `appstudio-widget lint` does not — it stays silent rather than guessing and flagging correct code. The rule is scoped to the argument of a `requestPayment(...)` call, so a `currency` field elsewhere (a datastore column, an `Intl.NumberFormat` option) is untouched. `CONTRACT.version` → `1.59.0`. Additive; a widget that omits `currency` or already matches its workspace is unaffected.

### What's new in 0.83.0 (contract 1.58.0)

**`PaymentError` tells you WHY a charge was refused, and whether retrying could ever help (sc-4650).** `usePayments()` mapped its rejections by reading `err.response`, but `@colixsystems/payments-client` throws typed errors carrying `.code` / `.status` / `.details` (the parsed error envelope) and no `.response` at all — so every server refusal arrived as `code: "INTERNAL"` and the real reason was buried on `err.cause`. A widget could not tell a workspace that has not declared its business identity yet (`BUSINESS_IDENTITY_REQUIRED`, which no retry clears) from a declined card.

- **Both error shapes are read, and the envelope wins.** The mapper takes the server's own `code` (`BUSINESS_IDENTITY_REQUIRED`, `UNSUPPORTED_CURRENCY`, `PAYMENTS_SCOPE_NOT_GRANTED`, …) over the client's status-derived class code, and keeps the server's user-safe `message`. The host's local `.response` rejections (no install bound → `PAYMENTS_UNAVAILABLE`) still map exactly as before.
- **New `retryable` flag.** `false` for any refusal only the workspace owner, the manifest, or the amount can lift; `true` for a decline, a provider blip, or an unknown failure. Branch on it: render `err.message` and drop the retry control when it is `false`, and keep "try again" for the retryable case only.
- **New lint warning `payment-error-not-branched`.** A widget that calls `requestPayment()` but never reads `retryable` (or branches on an explicit `err.code`) is flagged — non-blocking, so it never fails a publish.

### What's new in 0.82.0 (contract 1.57.0)

**Server-action scripts can notify a record's permission subjects (REQ-ACTION-NOTIFY-SUBJECTS, sc-4586).** `await notifications.notifyRecordSubjects(tableId, recordId, { title, body, link, emit_email, emit_push, exclude_user_id })` resolves to `{ recipients }` and notifies every app user whose per-record grant lets them **read** that record. This addresses the one audience the other two primitives cannot name: when membership *is* the ACL, there is no recipient column to read. The Chat widget is the case in point — a channel's participants ARE that channel record's grants, so neither a `recipient_expr` (which resolves only a fixed id or a single user/group reference column) nor a `notifyUser` loop over a column that does not exist can reach them. Subject kinds follow REQ-ACL-09: a `user` grant notifies its user, a `group` grant expands through its memberships, and the two synthetic kinds (`authenticated`, `everyone`) are skipped because they address the whole workspace rather than a membership. Recipients are **deduplicated**, so someone reachable through both a direct grant and a granted group is notified once, and `exclude_user_id` drops one — pass the author so nobody is notified of their own write. Dispatch goes through the same `notifyUser` path as before, so the always-written inbox row, the preference-gated email + push mirrors, the link sanitiser and the title/body caps are inherited unchanged, and written rows count toward the same per-run cap of 500. The tenant is bound host-side and the table/record pair is verified against it, so a foreign or missing id resolves to `{ recipients: 0 }` rather than distinguishing "absent" from "not yours". **The script never receives the member list — only the count.** `CONTRACT.version` → `1.57.0`. Additive; no widget hook, primitive, manifest field, or token changed shape.

### 0.82.0 also carries (contract 1.56.0)

**Server-action scripts gain a `notifications` global (REQ-ACTION-NOTIFY, sc-4514).** A `scriptSource` action can now send a real notification to a recipient it resolves at run time: `await notifications.notifyUser(userId, { title, body, link, emit_email, emit_push })` and `await notifications.notifyGroup(groupId, opts)`. Options are **snake_case**, matching every other shape a script sees. `notifyUser` resolves to the created notification row (or `null` when the recipient was skipped); `notifyGroup` resolves to `{ recipients }`. This is a directness change rather than a new capability: an action could already notify indirectly by writing into a table carrying an enabled `NotificationRule`, but that costs a throwaway table, a per-table rule, a recipient expressible only as a fixed id or one reference column — and it fails silently, since with no rule attached the row just lands and the run still reports success. `POST /notifications/send` is no alternative (it needs an app-user JWT an action cannot hold). The direct call removes the intermediary and makes a non-delivery throw. Both methods delegate to the platform's one notification dispatch path, so the inbox row is always written, the email + push mirrors respect the recipient's channel preferences, and the push ping never carries the title or body. The tenant is bound host-side: a recipient in another workspace, a soft-deleted group, or a deactivated user is a silent skip, never a cross-tenant write. A blank `title`, a non-string `body`, or exceeding the per-run cap of 500 written notifications throws a catchable Error. New entry in `CONTRACT.actionScriptGlobals`; `CONTRACT.version` → `1.56.0`. Additive — no widget hook, primitive, manifest field, or token changed shape.

### What's new in 0.78.0

**New `useIdentification()` hook — identify a visitor who is NOT signed in (REQ-IDENT, sc-4313).** A new IDENTIFICATION hook reading a newly-injected `ctx.identification` slice (the new `@colixsystems/identification-client`, constructed by both the web Player and the native Expo export). Returns `{ available, availabilityLoading, status, qr, autoStartToken, message, identity, identificationId, loading, error, start, refresh, cancel, reset }`.

It exists to **prove presence and keep the result** — an attestation on a record, a consent line, an identity check before a submit. It creates **no account and no session**: to sign someone *in* use the app's login, to attach BankID to an existing account use `useBankIdLink()`, and to e-sign a file's bytes use `useFileSignature()`.

**BankID is the first provider**, and the API is provider-abstracted — a future provider becomes available without a widget change (`options.provider` defaults to `"bankid"`).

Gate the UI on `available`: when it is `false` the provider is not configured on the deployment and no QR can ever complete, so render nothing rather than a dead button. `start()` opens an order and **the hook polls it to completion for you** (`pollIntervalMs`, default `1000`; pass `0` to drive `refresh()` yourself), clearing its timer on unmount — a widget renders state instead of owning a loop. Render `qr` with the `Image` primitive and show `message` (a display-ready instruction); `autoStartToken` opens the provider app on the same device.

**No raw personal number is reachable from a widget.** On completion `identity` is `{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at }` — `personal_number_masked` is `"19900101-****"` and `subject_hash` is stable for the same person, so a returning visitor is recognisable without the number. The full value stays server-side behind a studio-admin endpoint, so it can never end up in page JSON or a datastore column by accident. Write the masked string (and `identificationId`, to trace the proof) into your column.

`options.purpose` is a short audit label ("attest", "age_check"), capped at 120 characters. Orders expire five minutes after `start()`. It needs **no manifest scope** and **no `requestedScopes` entry** — requiring one would defeat a flow whose whole point is an anonymous visitor. Rejections surface as a structured `IdentificationError` (new named export) with a stable `.code` (`NOT_CONFIGURED` / `UNKNOWN_PROVIDER` / `NOT_FOUND` / `RATE_LIMITED` / `UNAVAILABLE` / `INTERNAL`). `CONTRACT.version` → `1.52.0`. Additive — one new hook, one new context slice, one new error class, one new client package; no existing export changed signature.

### What's new in 0.77.0

**`ui.group` is a layout hint, not a visibility rule (sc-4176).** 0.75.0 gave `"Basics"` a reserved meaning: Agent Mode's in-preview edit panel rendered only that group and pointed the author at the Builder for the rest. That withheld styling from an author already editing the widget in front of them, so the reserved behaviour is **retired**.

- **Both Style surfaces now render your whole `styleSchema`** — the Builder's Properties panel and the in-preview edit panel show the same fields, as one labelled fieldset per group.
- **Keep grouping.** It is what makes a 10–12 field Style section readable: "Basics / Card / Title" is three scannable decisions where a flat list is a wall of inputs. Group all your fields or none, name each group after an element the author can see, and keep `"Basics"` for the three whole-widget fields so they read first.
- **No group name is special any more.** Nothing you put in — or leave out of — a group changes whether an author can reach a field. Do not try to hide an advanced knob by grouping it.
- **Nothing else changes.** `themeDefault` / `default` placeholders (0.76.0) apply to every field in every group, and an ungrouped `styleSchema` renders exactly as it always has.

Docs-only correction of documented host behaviour: no export, type, runtime, or `CONTRACT` field changed; `CONTRACT.version` stays `1.51.0`.

### What's new in 0.76.0

**`themeDefault` publishes the value a style field falls back to (sc-4164).** An unset `styleSchema` field rendered as an empty box, so an author adjusting `Title size` could not see the size they were changing. `WidgetPropertyDef` now documents the key the Studio has read since sc-1807:

- **`themeDefault: "<dotted path into the resolved widget theme>"`** — e.g. `"typography.sizes.lg"`, `"radii.md"`, `"colors.onSurface"`. The Studio renders it as greyed placeholder text, resolved against the **workspace's** theme, so the hint stays truthful after a rebrand. Prefer it over a literal `default` whenever your fallback is a theme token — which it usually is, since you style from `useTheme()`.
- **A literal `default`** still works and is the right choice for a hard-coded constant your code carries (`default: 1.4` for a line height you wrote yourself).
- **Both are DISPLAY-ONLY.** Neither is ever written into `props.style`, so a style field keeps its only-when-set contract and continues to inherit the theme. Declare the value your code actually applies — a baseline that disagrees with the render is worse than none.
- **Optional.** A field whose fallback has no fixed value (a `background` that inherits whatever the container paints) declares neither and renders blank.

Types-only addition: no export, runtime behaviour, or `CONTRACT` field changed; `CONTRACT.version` stays `1.51.0`.

### What's new in 0.75.0

**`ui.group: "Basics"` marks a widget's quick style knobs (sc-4100).** A `styleSchema` field has always accepted the `propertySchema` `ui.group` key; the Studio now reads one reserved group name from it, so a widget with several styleable elements can expose per-element controls without burying the two or three an author reaches for first.

- **Group every style field, or none of them.** In a grouped `styleSchema`, put the whole-widget basics (`background`, `textColor`, `align`) under the exact group `"Basics"` and each per-element field under a group named for the element it styles (`"Card"`, `"Title"`, `"Chip"`, …). Keep the total under about 12 fields.
- **Where each surface renders.** ~~The Builder's Properties panel renders **every** group. Agent Mode's in-preview edit panel — a quick-tweak surface — renders **only** `"Basics"` and points the author at the Builder for the rest.~~ **Superseded in 0.77.0:** both surfaces render every group. `ui.group` is a layout hint, not a visibility rule.
- **Nothing changes for an ungrouped `styleSchema`.** A flat schema (no `ui.group` anywhere) renders in full in both surfaces exactly as before, so every already-published widget is unaffected. A grouped schema that declares no `"Basics"` group also renders in full — the trim needs a group to trim *to*.

No export, type, hook, or `CONTRACT` field changed; `CONTRACT.version` stays `1.51.0`. This is an additive host convention over an existing manifest key.

### What's new in 0.74.0

**Calling a translation vendor directly is now a blocking lint finding (sc-4085).** `useTranslate()` has brokered content translation since 0.70.0, but nothing stopped a widget fetching a free public endpoint instead — and generated widgets routinely did, reaching for `api.mymemory.translated.net`. That call skips the workspace's provider, the shared cache and the metered budget, and ships an unvetted third-party endpoint into every app the widget is installed in.

- **New rule `no-external-translation-api`.** `severity: "error"`, so it fails `appstudio-widget lint` and blocks submission. There is no `appstudio-lint-ignore` opt-out: a hostname match is unambiguous, so unlike a relative `/api/` path there is no correct code for a directive to rescue.
- **`CONTRACT.translationApiHosts`** publishes the refused vendor list (MyMemory, LibreTranslate, DeepL, Google, Microsoft, Yandex, Lingvanex), matched case-insensitively as host substrings against code. Comments are blanked first, so documenting the rule is safe.
- **Nothing changes for a widget that was already correct.** `useTranslate()` for content the workspace's users typed, `useI18n().t()` for text you author, and `fetch`/`axios` for genuine third-party APIs all lint exactly as before.

`CONTRACT.version` → `1.51.0`. Additive: one new contract field and one new lint rule; no existing export changed signature.

### What's new in 0.71.0

**A theme can set leading, tracking and case for text app-wide (sc-3857).** The `text` scope of `themeConfig.components` gains three tokens beside `color` and `fontSize`:

- **`lineHeight`** — leading as a MULTIPLE of the font size (0.8–3), so one app-wide value stays right at every size. Tight leading (1.0–1.15) is what makes a 32px+ headline read as a headline rather than as oversized body text.
- **`letterSpacing`** — tracking in pixels (−2 to 20). Wide positive tracking is what makes a small uppercase kicker read as a kicker.
- **`textTransform`** — `none` | `uppercase` | `capitalize`, published as `CONTRACT.themeComponentTextTransforms`.

Two new token value types back them: **`decimal`** (a clamped, 2-decimal number — `size` rounds, so it cannot carry a 1.05 multiplier) and **`textTransform`**. Each token binds to the identically named per-instance style field the target widgets read, so the author rule is unchanged: read `props.style` / `useWidgetStyle()`, and a per-instance value still wins over the app-wide token.

`CONTRACT.version` → `1.48.0`. Additive: no existing export changed signature, and a theme with none of the new tokens renders exactly as before.

### What's new in 0.70.0

**Translate content the user typed — `useTranslate()` (sc-3783).** The workspace dictionary only covers strings *you* authored; content living in the app's data — a record's description, a file name, a REST payload — has no translation key because nobody assigned it one, so an app user who picked English still read it in whatever language it was entered. `useTranslate()` closes that gap:

```jsx
import { useState, useEffect } from "react";
import { Text, useTranslate } from "@colixsystems/widget-sdk";

const { translate, available, language } = useTranslate();
const [shown, setShown] = useState(rows.map((r) => r.notes));
useEffect(() => {
  if (!available) return;
  let live = true;
  // ONE request for the whole batch. On failure keep the original text.
  translate(rows.map((r) => r.notes))
    .then((out) => { if (live) setShown(out); })
    .catch(() => {});
  return () => { live = false; };
  // `language` is in the deps on purpose: the app user can switch language
  // at any time, and this must re-translate when they do.
}, [rows, available, translate, language]);
```

- **The target language is the app user's selected language** by default — that is the point of the hook, so a widget never has to know how the language was chosen. Pass `{ target }` only when the widget's purpose is translating into a language the user picks, and `{ source }` when you know the content's language (otherwise the provider auto-detects).
- **A string in, a string out; an array in, an array out** — positionally aligned, and an array is ONE request. Calling it per row is the mistake to avoid. Limits per call: 50 segments, 5 000 characters each, 20 000 total.
- **Repeat text is free.** Three caches sit behind it: the hook memoizes per session, the API keeps a short-lived in-process cache, and the platform keeps a durable per-workspace cache keyed by the content itself. Text already in the target language and blank text never reach the network at all.
- **It costs a metered budget, so use it deliberately.** Each workspace has a monthly translated-character cap (only cache misses count). Exhausting it rejects with `TranslateError` code `TRANSLATION_QUOTA_EXCEEDED`, which — unlike a rate limit — will not clear until the next period; cached translations keep working. `TRANSLATE_NOT_CONFIGURED` means the platform has no provider at all.
- **Never block a render on it.** Show the original text and swap in the translation when it resolves; always `catch` and fall back. `available` is `false` on a host that brokers no translation client (the Studio canvas preview), where `translate` rejects `UNSUPPORTED` instead of throwing at render — so hide any translate affordance when it is false.
- **Identical on both hosts.** The web Player and the exported Expo app inject the same new `@colixsystems/translation-client` into `ctx.i18n.translate`, so the hook behaves the same in the browser and on a device.

`CONTRACT.version` → `1.47.0`. Additive: one new hook + its error class, and one optional `ctx.i18n.translate` slice field. No existing export changed signature.

### What's new in 0.67.0

**The theme can restyle ONE component type — buttons, cards or text — without moving the global palette (sc-1497).** A workspace theme may now carry `themeConfig.components` (`{ button, card, text }`), and the host resolves each scope onto the `styleSchema` fields the target widgets already read. **Nothing changes for a widget author:** you keep reading `props.style` / `useWidgetStyle()`, and an author's per-instance value still wins over a theme token — the theme is the app-wide default underneath it.

- **`useTheme()` gains `spacingScale`.** The app-wide spacing multiplier (default `1`) the workspace sets from Theme Settings. HOST-OWNED for layout: the host already scales every container's `padding` / `gap` / `margin` by it, so do not re-apply it to anything the host laid out. Read it only when your widget draws spacing of its own and you want that to breathe with the rest of the app — multiply your own paddings by it and leave radii and font sizes alone.
- **`useTheme()` gains a `components` slice.** It is HOST-OWNED plumbing, not an author API: by the time your component renders, the host has already folded the matching tokens into `props.style`. Do not read `theme.components` and do not re-apply it — you would double-apply the theme and defeat the author's own styling.
- **New host-only exports on `@colixsystems/widget-sdk/host`:** `normaliseThemeComponents(raw)` and `applyThemeComponentStyle(manifestId, theme, props)`. These are the platform-host surface (the web Player / Studio canvas and the exported Expo app), never the author API — one implementation, so the two hosts cannot diverge.
- **`CONTRACT.themeComponents` / `CONTRACT.themeComponentShadows` / `CONTRACT.themeComponentGradient`** publish the vocabulary: each scope's tokens, their value types and ranges, and the widget → style-field bindings. `themeTokens.components` defaults to `{}`.
- **The `button` and `card` scopes carry a `gradient` token (sc-3727)** — `{ from: "#hex", to: "#hex", angle: 0-359 }`, painted through the `<Gradient>` primitive. It reaches a widget as an ordinary style field (`gradient`, `cardGradient`, `submitGradient`), so the author rule is unchanged: read `props.style`, and treat an explicit `null` as "this instance opted out of the app-wide gradient" rather than as unset.

`CONTRACT.version` → `1.46.0`. Additive; no existing export changed signature, and an unthemed app renders identically.

### What's new in 0.66.0

**New linter rule `image-percent-height`, and `appstudio-widget lint` finally prints warnings (sc-3493).**

- **`image-percent-height` (severity `warning`, non-blocking).** An `<Image>` / `<ImageBackground>` sized with a literal percentage `height` — `style={{ width: "100%", height: "47%" }}` — is flagged. React Native / Yoga resolves a percentage height against the **parent's** height, so under a content-sized parent it collapses to 0: the `uri` still fetches, but the image is invisible on both the web Player and the native Expo export, with nothing in the console to trace. Author fix: size it with `aspectRatio` (`{ width: "100%", aspectRatio: 1 }`) or a numeric pixel height. It is a **warning**, not an error, precisely because `height: "100%"` *is* correct inside a parent with a definite height (a fixed-height hero) and a text scan cannot tell the two apart — so the rule informs without rejecting a valid widget. Scope is the literal inline form only; a height threaded through a variable or a `StyleSheet` object is beyond an AST-free scan, and the guidance in the `useFilestoreFile` note below remains the primary guard. Comments are not scanned, so documenting the anti-pattern is safe.
- **The CLI no longer swallows warnings.** `runLint` reported `clean` and dropped every `severity: "warning"` finding whenever there were no errors, which made the existing `no-host-api-url` warning (and this new one) invisible to anyone using `appstudio-widget lint`. It now prints an `N error(s), M warning(s)` header and one line per finding tagged `error` / `warning`. **Exit codes are unchanged:** `0` when there are no error-severity findings (warnings included), `1` otherwise — so a warning still never blocks a build. `clean` is printed only when there are genuinely zero findings.

`CONTRACT` is unchanged (no new field), and no export changed signature.

### What's new in 0.65.0

**Every file record carries `url`, and hand-built file URLs are linted (sc-3589 follow-up).** Two fixes for the same real-world failure: a `FILE` column's image silently not rendering.

- **`url` is now on every file record**, aliasing the absolutized `presigned_url`. The wire field is `presigned_url`, so the intuitive `file.url` read was `undefined` — and because the usual guard is `if (!file.url) return null`, the widget rendered *nothing*, with no error to trace. The alias is added in the filestore client's one shared normalizer, so it applies to `useFilestoreFile(id)` **and every row of `useFilestoreFiles`** — a gallery can render `files.map(f => f.url)` directly. `presigned_url` is unchanged and still present. Requires `@colixsystems/filestore-client` ≥ 0.7.0.
- **Prefer the top-level `url`:** `const { url } = useFilestoreFile(id)`. The returned `file` is `null` until the fetch resolves (and stays null for an empty `FILE` cell), so `const { file } = …; file.url` throws on the first render. `file.url` is correct only *after* you null-check `file`.
- **`no-host-api-url` now flags hand-built host paths.** The needles were only `/api/v1`, `/uploads/` and `Authorization: Bearer`, so `` `/api/files/${id}` `` — a route that does not exist — passed clean and shipped. The rule now matches `/api/files/` anywhere (so the origin-prefixed `` `${location.origin}/api/files/${id}` `` is caught too) plus a *quoted* relative `/api/` path for invented prefixes generally. Two deliberate carve-outs: **comments are not scanned**, so documenting the rule in a comment is safe; and an absolute third-party URL that merely contains `/api/` (`https://api.example.com/api/x`) never matches. If a third-party call genuinely needs a *relative* `/api/…` path against an axios `baseURL`, add `// appstudio-lint-ignore no-host-api-url` on that line or the line above.

**Never build a file URL from an id.** Filestore bytes are only reachable through a server-signed token URL, so a client-composed path can never work — always go through `useFilestoreFile`. `CONTRACT.version` → `1.44.0`. Both changes additive; no export changed signature.

### What's new in 0.64.0

**`<DateTimePicker>` themes itself — legible on dark surfaces (sc-3370).** The primitive now derives its colours from the workspace theme on BOTH hosts instead of hardcoding them: the text uses `colors.onSurface`, the border uses `colors.border`, and on web the input's `color-scheme` follows the theme so the browser's built-in date UI (the `yyyy-mm-dd` edit segments and the calendar icon) stays legible on a dark surface. Previously the web input used `color: inherit` with no `color-scheme` and the native trigger set no text colour, so on a dark-themed app the field rendered dark-on-dark and unreadable — with no prop an author could set to fix it. **No prop changed**: the seven-prop contract (`value, onChange, mode, minimumDate, maximumDate, disabled, accessibilityLabel`) is unchanged, and you do NOT style the field yourself — never reach for raw CSS or `document`, which the widget linter rejects. `CONTRACT.version` → `1.43.0`. Behavioural fix, additive.

### What's new in 0.63.0

**New optional manifest field `rendersOwnChrome` (sc-3331).** A boolean (default `false`) that declares whether your widget renders its OWN section header — a heading (and optional subtitle), plus any primary action for its section — making it a self-contained section. Set it `true` when your widget draws its own title (from a `title`/`subtitle` prop with a real default, so the author can still retitle it in the Properties Panel), and the AppStudio app-builder will place the widget as the WHOLE section: it will NOT add a standalone heading or a duplicate action button above it, so the section is never double-titled. Leave it `false`/omitted for a content-only widget whose heading the page supplies. `CONTRACT.version` → `1.42.0`. Additive — existing manifests omit it and read `false`, so no widget needs changing.

### What's new in 0.62.0

**`usePayments()` — the host owns the hosted-checkout redirect, and the documented contract is corrected to snake_case (sc-3290).** Two things that had drifted are now aligned with the runtime:

- **The host opens Checkout; the widget never does.** When a hosted-checkout provider (Mollie) is active, `requestPayment(...)` now makes the host open Checkout itself — a **same-tab** redirect on web, the **in-app browser** on native (the identical model the paid-signup flow uses). The result no longer carries a `checkout_url`, and widgets must **not** call `Linking.openURL(...)` for payments: `react-native-web`'s `Linking.openURL` opens a `_blank`, `noopener` tab, which left the app user stranded on an orphan tab after paying. `return_path` now defaults to the current page, so the web return lands the user back where they started. Confirm completion from server-authoritative state (the Mollie webhook flips your datastore record) or by polling `getPayment(id)`.
- **`PaymentRequest` / `PaymentResult` types are snake_case.** The TypeScript types and the `CONTRACT` return-shape strings described `amountCents` / `checkoutUrl` (plus a `metadata` field the client never forwarded), but the wire — and the runtime client — have always been snake_case. They now read `amount_cents`, `currency?`, `description`, `return_path?` in, `{ id, status, amount_cents, ... }` out. A TS widget that passed `amountCents` was silently sending `undefined`; update to `amount_cents`.
- No `CONTRACT.version` change — the `ctx.payments` context shape (`{ requestPayment, getPayment }`) is unchanged; only its documented request/return shape and the host-owned redirect behaviour changed.

### What's new in 0.60.0

**The host fills `propertySchema` defaults onto props (sc-3228).** The platform host now applies a widget's manifest `default`s at its render boundary: for every leaf a page left unset it substitutes the declared `default`; an explicitly-bound value passes through untouched. The web Player and the native Expo export both do this against the widget's `propertySchema`, so an unset `columnRef` / field binding arrives as its declared default (e.g. `"Title"`) on BOTH hosts instead of `undefined`. **For widget authors this means: read `props.<field>` directly — the in-code `props.titleField || "Title"` fallback pattern is no longer needed and should be removed.** This is done for you by the host; there is no author API to call. (The resolver lives at the host-only subpath `@colixsystems/widget-sdk/host`, consumed by the platform hosts, not by widgets.) `CONTRACT.version` → `1.41.0`. Additive — no existing export changed signature.

### What's new in 0.59.0

**Display a stored file by id — `useFilestoreFile(fileId)` (sc-3031).** A new FILESTORE read hook that resolves ONE file id to a displayable URL. Given a file id the app stored — chiefly a datastore `FILE` column, which holds a filestore file id (a string), never bytes — it fetches the record via `ctx.filestore.files.get(id)` and returns `{ file, url, loading, error, refetch }`, where `url` is the record's `presigned_url` absolutized by the client so it renders on the web Player **and** the native Expo export alike. Drop `url` straight into `<Image source={{ uri: url }} />`. An empty id makes no round-trip (`{ file: null, url: null }`); a deleted / not-found id degrades the same way (`url` stays null, `error` carries the wire error) so a display widget shows its fallback instead of crashing. This closes the upload→store→display loop: `useFilestoreUpload(...).upload(file)` → write `file.id` into the `FILE` column → `useFilestoreFile(id).url` to show it. Requires the `files.read:*` scope. `CONTRACT.version` → `1.40.0`. Additive — one new read hook; no existing export changed signature.

### What's new in 0.58.0

**Filter the directory by group (sc-2964).** `useDirectory(query?)` and `useUsers(query?)` gain an optional `group_id` on the query object (`{ q?, role?, is_active?, group_id?, limit?, offset? }`). Pass the `id` of a `useGroups()` row to list only that group's members — pair it with a group picker to build "members of group X". A `group_id` from another tenant or a non-existent one returns an empty roster, never a cross-tenant member. The `DirectoryQuery` / `UsersQuery` types add `group_id?: string`; existing callers pass no `group_id` and see the full roster. No scope, manifest field, import, or hook signature changed — `CONTRACT` is unchanged; the query flows verbatim through the injected `@colixsystems/directory-client` on both the web Player and the native Expo export.

### What's new in 0.57.0

**A `required: true` `tableRef` must ship a matching `datastoreTemplate` table (sc-2791).** A widget property that the author cannot skip — a `tableRef` you mark `required: true` — must have a table to bind on install, or the end user has nothing to pick and the widget can't load its data. The marketplace analyzer now enforces this at publish: the check `manifest.requiredTableRefsHaveTemplate` rejects a manifest whose `datastoreTemplate.tables` count is fewer than its `required` `tableRef` property count, naming the offending properties. Two ways to clear it: seed a `datastoreTemplate` table for each required `tableRef` (the default — the widget owns its data), OR mark the property `sharedTable: true` when it binds a table the app already owns (0.106.0 — it stays `required`, so the author must still bind one). Dropping `required` also clears the gate but is the wrong fix: an optional table prop lets the widget ship unbound and render an empty tile. This tightens the existing "always ship a `datastoreTemplate` for a data widget" guidance into an enforced rule for the `required` case. No export, type, or hook changed shape — additive publish-gate + documentation. `CONTRACT` is unchanged (no new field).

### What's new in 0.55.0

**Dynamic record selection for `valueRef` (sc-2327).** The `valueRef` binding gains an optional `mode` field: `"static"` (the default — pin a specific `recordId`, the only prior behaviour) or `"latest"` (resolve the most recently created row live, sorting on the host-managed `created_at` descending with `limit: 1`; `recordId` is ignored). The built-in Data Value widget reads it to offer a "Latest entry" that updates as records are added, with no per-host code — the same baked widget source and the same injected `@colixsystems/datastore-client` run on the web Player and the native Expo export. The `ValueRefBinding` type adds `mode?: "static" | "latest"`. Existing bindings carry no `mode` and read as static. `CONTRACT.version` → `1.39.0`. Additive — no existing field changed shape.

### What's new in 0.56.0

**New linter rule `react-not-imported` — widget source must be self-contained (sc-2353).** The automatic JSX runtime binds `jsx`/`jsxs` from `react/jsx-runtime` but never `React` itself, so source that reaches for the bare `React` global (`React.createElement` / `React.Fragment` / `React.memo` / `React.useMemo`) without importing it bundles cleanly, then throws `ReferenceError: React is not defined` the moment a non-initial code path hits the reference. The platform does **not** inject a `React` binding — earlier behaviour that auto-injected one left source that broke as soon as it was downloaded and re-uploaded through a path that doesn't inject. The linter now flags a bare-`React` reference with no `import React from "react"` (or `import * as React`) as an error, so the failure becomes a publish/upload finding (and an AI-agent repair-loop finding) instead of a broken shipped widget. Plain JSX, which needs no React import, is never flagged; a `React` mention inside a comment or string is masked. Author fix: add `import React from "react";` (`react` is already vetted), or prefer a JSX fragment `<>…</>` plus the SDK hooks/primitives over reaching for `React` directly. `CONTRACT` is unchanged (no new field).

### What's new in 0.73.0

**`secrets` is now listed in `CONTRACT.actionScriptGlobals`.** The action runner has always exposed a frozen `secrets` object to server-action scripts — the tenant's stored key/value configuration, read as `secrets["STRIPE_API_KEY"]` (REQ-ACTION-SECRET) — but the contract never listed it, so the Developer guide and the AI widget agent's prompt, both of which derive their globals list from this array, never told an author it existed. That omission is why generated scripts inline a plaintext credential instead of reading one. **Never put an API key, token, or password in `scriptSource`** — a manifest is distributed with the widget, so a literal credential is published with it. Name the key, read it from `secrets`, and fail loudly when it is missing; the workspace operator supplies the value in the Studio. `CONTRACT.version` → `1.50.0`. Documentation-only against the runtime — the global was already there — but additive to the published contract, so it moves the minor.

### What's new in 0.54.0

**Generate & save PDFs from a widget (sc-2314).** New `usePdfExport({ spaceType, folderId? })` hook. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF **server-side** (the platform's headless-Chromium pipeline) and saves it into the end-user's Filestore via `ctx.filestore.files.exportPdf`, resolving to the created file row (`application/pdf`). It reuses the filestore owner_id resolution + per-folder write gate and the existing `files.write:*` scope. Because the rendering is server-side, the capability behaves identically on the web Player and the native Expo export — no browser-only PDF library is added to the vetted set. Pairs with `@colixsystems/filestore-client@0.6.0`'s new `files.exportPdf(...)`. `CONTRACT.version` → `1.38.0`. Additive — no existing hook, primitive, manifest field, or token changed shape.

### What's new in 0.53.0

**Server-action scripts gain a `connectors` global (REQ-ACTION-CONNECTORS, sc-2162).** A `scriptSource` action can now call `connectors.call(slug, { method, path, query, body, headers })` to invoke a tenant-configured REST connector by slug; it returns the upstream's `{ status, headers, body }`. Auth and SSRF protection are handled by the platform host — the script names only a slug (never a tenant or base URL), and the tenant is bound host-side. An unknown slug / SSRF rejection / timeout throws a catchable Error. New entry in `CONTRACT.actionScriptGlobals`; `CONTRACT.version` → `1.37.0`. Additive — no widget hook, primitive, manifest field, or token changed shape.

### What's new in 0.52.0

**Lucide icon names accept any case (sc-2088).** A new `normalizeLucideIconName(name)` export maps a human-typed icon name to the PascalCase form Lucide exports its components under — `arrow-right`, `arrow_right`, `arrow right`, `arrowRight`, and `ArrowRight` all resolve to `ArrowRight`; `building-2` → `Building2`. The lucide.dev gallery and its copy button hand you kebab-case, so authors no longer have to hand-convert. The `<Icon name="…">` primitive normalizes its `name` before lookup, and the `icon` propertySchema type accepts any case. Additive — one new pure export; no existing export changed signature. `CONTRACT` is unchanged.

### What's new in 0.51.0

**`datastoreTemplate` tables can ship sample `rows` (sc-2070).** Each `WidgetDatastoreTemplateTable` now takes an optional `rows` array — sample data seeded into the table at install time so the widget renders with real content instead of an empty state. Each entry is an object keyed by column `name`; only the scalar/array column types are seedable (`STRING`, `TEXT`, `NUMBER`, `FLOAT`, `BOOL`, `DATE`, `STRING_ARRAY`, `INT_ARRAY`). RELATION, FILE, USER, and USER_GROUP columns are rejected at validation time (a sample row has no way to express their ids). A `null` value skips that cell; at most 25 rows per table. The "Seed data" action and every install path seed these rows in the same transaction that creates the tables. Additive — `rows` is optional and existing templates that omit it behave exactly as before.

### What's new in 0.49.0

**New `useGeolocation()` hook — read the device's current position (sc-1584).** A new CORE hook reading a newly-injected `ctx.device` slice (host-brokered device capabilities). Returns `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }`. Capture is **imperative** — call `getCurrentPosition()` from a user gesture (a `Pressable.onPress`); browsers and the mobile OS gate the permission prompt on a gesture, so it NEVER fires on mount. The promise resolves to `{ latitude, longitude, accuracy }` and mirrors the same values onto the hook; `options` (`{ enableHighAccuracy, timeout, maximumAge }`) pass through to the host. Rejections surface as a structured `GeolocationError` (new named export) with a stable `.code` (`PERMISSION_DENIED` / `UNAVAILABLE` / `TIMEOUT` / `UNSUPPORTED` / `INTERNAL`). It needs **no manifest scope** and **no `requestedScopes` entry**. The web Player brokers it via `navigator.geolocation`; the Expo export via `expo-location` — so device access is identical on both platforms. The `ctx.device` slice is optional: a host that can't broker the sensor omits it and the hook degrades to an `UNSUPPORTED` error rather than throwing at render. `CONTRACT.version` → `1.36.0`. Additive — one new hook, one new optional context slice, one new error class; no existing export changed signature.

### What's new in 0.48.0

**New `useSendNotification()` hook — send an in-app notification to an app user (sc-890).** A new NOTIFICATIONS hook reading a newly-injected `ctx.notifications` slice (the `@colixsystems/notifications-client`, now constructed by both the web Player and the native Expo export). Returns `{ send, sending, error }`. `send({ recipient_user_id, title, body, link?, payload? })` posts a notification to one app user **in the same workspace** and resolves to the created row; `recipient_user_id` must be a member of the tenant or the call is rejected (cross-workspace targets never resolve). Call it from an **event handler** (a `Pressable.onPress`, a submit, a mutation callback) — never in render, where the abuse/rate-limit guard would fire on every paint. Gated by the new `notifications.send:appUser` scope, which the widget declares in its manifest `requestedScopes`. Rejections surface as a structured `NotificationError` (new named export) with a stable `.code` (`INVALID_TITLE` / `INVALID_BODY` / `INVALID_RECIPIENT` / `INVALID_PAYLOAD` / `VALIDATION` / `AUTH_REQUIRED` / `FORBIDDEN` / `RECIPIENT_NOT_FOUND` / `RATE_LIMITED` / `INTERNAL`). `CONTRACT.version` → `1.35.0`. Additive — one new hook, one new context slice, one new scope, one new error class; no existing export changed signature.

### What's new in 0.47.0

**`<FilePicker>` native variant is real (sc-1378 follow-up).** The Expo-export shell of `<FilePicker>` now wraps the vetted `expo-document-picker` and reports `FilePicker.isSupported = true`. Result: the Files widget's `allowUpload` toggle works on **both** the web Player and the native Expo export — no more disabled stub on mobile. `expo-document-picker` is added to `CONTRACT.vettedImports` (`platforms: ["native"]`); the compiler's `generatePackageJson` was already pinning it for the export build, so no new export-pin work was needed. `useFilestoreUpload` accepts whatever the host's FormData reads as a binary part — a browser `File` on web, the React Native `{ uri, name, type }` shape from the picker on native — so the same hook code path feeds the multipart on both platforms. `CONTRACT.version` → `1.34.0`, additive.

### What's new in 0.46.0

**End-user upload primitive + hook (sc-1378).** Widgets can now let an end user upload a file to the Filestore from the published app. Two pieces ship together:

- **New SDK hook `useFilestoreUpload({ spaceType, folderId? })`.** Resolves `owner_id` the same way the read filestore hooks do, builds a multipart `FormData` with the snake_case fields the backend reads verbatim (`space_type`, `owner_id`, `folder_id`, plus the binary `file`), and POSTs through `ctx.filestore.files.upload`. Returns `{ upload(file, { folderId? }), uploading, error, lastUploaded }`. A 404 from the backend means the destination folder denied a write (REQ-FSH `canWrite` gate).
- **New SDK primitive `<FilePicker accept onPick>`.** Web wraps a hidden DOM `<input type="file">` (children render as the visible click target inside a `<label>` so the click reaches the file dialog without ref plumbing). Native renders a disabled trigger and exposes `FilePicker.isSupported = false` until a vetted Expo picker pin lands.
- **Files widget `allowUpload`.** The built-in Files widget grew an `Allow uploading files` toggle (manifest v2.2.0). When enabled, the toolbar renders an Upload trigger that pipes the picked file through the new hook into the currently-open folder; the `typeFilter` setting narrows the `accept` MIME so an "Images" filter shows only images in the OS dialog.
- **`CONTRACT.version` → `1.33.0`.** Additive — new hook + new primitive; no existing hook, primitive, manifest field, or token changed shape.

### What's new in 0.45.1

**Fix `lucide-unknown-icon` false positive across adjacent imports (sc-1373).** The rule's import regex matched the brace block lazily (`[\s\S]*?`), so when another braced import preceded the lucide one — e.g. `import { View, Text, Pressable } from "react-native"` then `import { Sparkles } from "lucide-react-native"` — the capture spanned both and validated the `react-native` names (`Pressable`, …) as lucide icons, blocking a near-universal widget pattern. The capture is now `[^}]*`, which cannot cross a `}` into a neighbouring import. Fix-only; the rule's intent and the committed name set are unchanged.

### What's new in 0.45.0

**New linter rule `lucide-unknown-icon` (sc-1366).** `lucide-react-native` is a vetted import the bundler **externalises**, so esbuild never checks the *named* imports — a widget that imports an icon name absent from the pinned `lucide-react-native@0.368.0` (e.g. `import { House } from "lucide-react-native"`, when that version only ships `Home` — `House` was added to lucide later) bundles and publishes cleanly, then fails only at **runtime load** ("does not provide an export named 'House'") and renders blank. The linter now rejects such imports at publish so the failure becomes a repair-loop finding instead of a broken shipped widget. The valid set is committed data (`src/lucideIconNames.{cjs,js}` — the 1451 base export names + the generated-from `LUCIDE_VERSION`) because the linter runs in zero-dependency contexts where `lucide-react-native` (and its `react-native` peer) is not installed; alias forms (`<Name>Icon`, `Lucide<Name>`) are normalised. Regenerate after a lucide bump with `node scripts/generate-lucide-icon-names.cjs`; a test asserts `LUCIDE_VERSION` matches the frontend pin so the set can't go stale. The `CONTRACT` object is unchanged (no new field), so `CONTRACT.version` stays at `1.32.0`. The author/agent-facing mirror of this rule is the "Icons" note in the AI agent's `DEFAULT_SYSTEM_PROMPT` (shipped in the companion sc-1273/sc-1362 prompt PR).

### What's new in 0.44.0

**AI-generated widgets are bundled at publish (sc-1265).** Until this release the AI agent's publish path served the validated LLM source **verbatim** (transpiled, not bundled), which meant an AI widget could only import the host-shimmed specifiers — a draft that imported, say, `react-native-gesture-handler` published cleanly but threw "unresolvable bare imports" at load. Marketplace widgets, in contrast, are esbuild-**bundled** by the publish packer and get the full vetted-import surface. This release closes that gap: every AI publish now ships through the **`@appstudio/widget-bundler`** sidecar (`services/widget-bundler` — internal HTTP service that wraps the SAME `bundleWebEntry` the marketplace packer uses), so the bundled output is byte-shape identical regardless of who published.

- **`react-native` is now host-resolved on web.** A bundled widget — AI or marketplace — that imports `react-native` (directly, or via a vetted RN package like `react-native-gesture-handler` that imports it internally) leaves the specifier as a bare import; the Studio loader shims it to the host's **single** `react-native-web` instance. That keeps the bundle small (gesture-handler probes dropped from 762 KB to ~248 KB) and avoids two RN-web copies in the same page (`StyleSheet` + context conflicts).
- **`hostExternalSpecifiers()` grew `react-native`.** The canonical "host-resolved at runtime" set the bundler externalises now lists react family + `react-dom` + `react-dom/client` + `@colixsystems/widget-sdk` + the vetted shimmed packages (lucide / svg / date-fns) + `react-native`.
- **`bundleWebEntry` resolves RN web builds.** `resolveExtensions` is `.web.js`-first, `mainFields` is `[browser, module, main]`, and the `browser` export condition is active — so esbuild picks the web build of an RN-shape package automatically. No alias step; `react-native` stays external.
- **Contract description fix.** The vetted-imports entry for `react-native` previously claimed "the host bundler aliases this to react-native-web" — that was never true and the misclaim leaked into the AI system prompt. The description now reads: "`react-native` is host-resolved to the host's single react-native-web instance (external, shimmed) — NOT aliased at bundle time".
- **AI publish storage shape.** Every AI publish now writes the `bundleFiles` JSONB column with the bundled web entry under `widget.web.jsx` and the transpiled RN source under `widget.native.jsx`. `bundleSource` (the legacy single-file column) stays NULL on AI rows; `resolveWebBundleSource` / `resolveNativeBundleSource` pick the right file at load time.
- **Bundle failure → repair loop.** A bundler error is now a `bundle.web` finding the existing publish repair loop folds into the next attempt's prompt, so the model can self-correct a draft whose imports the bundler refused.
- **`CONTRACT.version` → `1.32.0`** (additive: the `react-native` host-resolution behaviour + a corrected vetted-imports description; the SET of vetted imports is unchanged).

**Vetted `@shopify/react-native-skia` for canvas-style graphics & games (sc-1270).** Widgets that need true 2D/GPU canvas drawing (games, custom visualisations) can now `import` Skia. It is **native-only** (`platforms: ["native"]`, like `react-native-maps` / `lottie-react-native`): author it in `widget.native.jsx` and pair it with a web variant in `widget.web.jsx` — a browser `<canvas>` or `react-native-svg`. This closes the gap where a raw `<canvas>` rendered in the web Player but crashed the Expo export ("View config getter callback for component `canvas` … received undefined") because React Native has no `<canvas>`. The compiler pins `@shopify/react-native-skia` in the exported app's `package.json`; no host shim is added (native-only packages are never shimmed, and there is no Skia web build wired into the Player). Unified Skia-on-web (CanvasKit/WASM) is a documented follow-up. Additive — `CONTRACT.version` bumped to 1.31.0.

### What's new in 0.43.0

**New `useAssetsByTag(tag, { type? })` hook (sc-1241).** Lists every tenant asset carrying a given tag — backs the built-in Gallery widget's "All images with a tag" source mode (which used to render a stub) and is a general SDK primitive any widget can call (an audio playlist filtered by mood, a document index by category, an image wall by topic). Reads `ctx.assets.list({ tag, type, limit })` (already injected by both the web Player and the native Expo export) and unwraps `{ data, meta }`. `type` defaults to `"image"` so the common Gallery case gets only images back; pass `"all"` (or `"audio"` / `"video"` / `"document"`) to widen. Falsy `tag` collapses to `assets: []` without a network round-trip. Additive — `CONTRACT.version` bumped to 1.30.0. The `assets` context slice grows a new required field `list: "function"` (both hosts already inject it).

### What's new in 0.42.0

**RELATION columns hydrate with a display label (sc-1181).** Record reads now return `{ id, label }` for ONE_TO_ONE / ONE_TO_MANY and `[{ id, label }, ...]` for MANY_TO_MANY (empty array when no links) — `label` is the value of the column pointed at by the new optional `display_column_id` on `DatastoreSchemaColumn`, or, when unset, the first STRING/TEXT column on the target table. Widgets should render `record.<rel>.label` (or `record.<rel>.map(r => r.label).join(", ")` for M:M) directly; `.id` is still there for the foreign-key case. The cell-formatting helpers in the built-in `DataList` and `DataValue` widgets already walk arrays and prefer `label` over `name` / `id` — author widgets that need the same can copy that pattern. `CONTRACT.version` is unchanged.

### What's new in 0.41.0

**New `useContainerWidth()` hook + `isNarrowWidth(width)` / `NARROW_WIDTH_PX` (sc-4399).** A widget can now measure the width of its OWN box and lay itself out for the space it is in. This is the capability that was missing for every widget except Gallery, which had hand-rolled the same `onLayout` measurement for its carousel — that copy is now gone and Gallery reads the hook. It matters because the screen is the wrong question: a widget in a one-of-three grid cell on a desktop page has phone-width room, and a widget filling a phone page does not, so a table-shaped widget that switches on the device is wrong in both directions. `isNarrowWidth` gives every widget one threshold (480) to switch at, so a page of them reflows together instead of raggedly, and an unmeasured width (0) is deliberately not narrow so nothing flashes through its narrow form on first paint. `onLayout` is a react-native primitive callback, so ONE implementation serves the web Player and the exported app. Additive — `CONTRACT.version` bumped to the next minor for two new hooks.

**New `useSectionEmpty(isEmpty)` hook + optional `ctx.section` slice (sc-4416).** A widget can now tell the host it has no content to show, and the host removes its layout slot rather than reserving space for it. This closes a gap that `null` alone could not: the host wraps every widget node in an entrance element, so a widget that rendered nothing still left an empty box its parent stack put `gap` around — a dead band of whitespace exactly where the content would have been. It matters most for a per-record child collection (a policy detail page whose quiz section only exists for policies that have questions): the widget owns the rows, so only the widget can say, and `visibleWhen` cannot reach it because "has related rows" is not a field the record carries. The widget stays MOUNTED while collapsed, so when rows arrive it reports `false` and the section returns on its own — no measurement, no second pass. The slot (`ctx.section.reportEmpty`) is optional and deliberately omitted on authoring surfaces: on the Studio canvas and in the Agent Mode edit preview an empty widget must stay visible and selectable, or an absent section could never be edited. Additive — `CONTRACT.version` bumped to the next minor for a new hook plus a new optional context slice.

**New `useRefresh(handler)` hook + page-level refresh signal (sc-1179).** Pull-to-refresh on the mobile web Player + the native Expo export's `RefreshControl` now fans a page-level refresh tick out to every widget on the page. The three datastore hooks — `useDatastoreQuery`, `useDatastoreRecord`, `useAsset` — auto-subscribe their own `refetch`, so a widget built on those hooks gets refreshed for free. Widgets that need to re-run other work (a third-party `fetch`, a derived calculation) call `useRefresh(async () => { … })` directly. The handler may return a Promise — the host waits on `Promise.allSettled` of every subscriber before clearing the spinner. The slot (`ctx.refresh.subscribe`) is optional on the WidgetContext: a host that does not implement refresh (the Studio canvas preview) simply omits it and the hook collapses to a no-op. Additive — `CONTRACT.version` bumped to the next minor since the contract grew a new hook + a new (optional) context slice.

### What's new in 0.40.2

**`useI18n().t()` no longer leaks the host's `{{t:key}}` miss placeholder.** Resolution steps 1–2 (per-widget and shared namespaces) already treated a `{{t:…}}` return from the host as a miss, but the final raw-key step did not — on the web Player (whose host resolver returns the placeholder form on a miss and ignores the fallback argument) a key absent from the tenant dictionary rendered as literal `{{t:key}}` text instead of degrading to `fallback ?? key`. The same guard now applies to all three steps. **The public contract is unchanged** — this is the behaviour `useI18n` always documented. `CONTRACT.version` is unchanged.

### What's new in 0.50.0

**`<DateTimePicker>` — tap the formatted date to open the picker (sc-1878).** The displayed date is now the affordance to change it on both hosts: on web, clicking the `<input>` text (or focusing it and pressing Enter / Space) calls `showPicker()` so the calendar opens from the text, not only the small built-in calendar icon; on native, the primitive renders an accessible, tappable formatted-date trigger that opens `@react-native-community/datetimepicker` on press (the RN library is imperative on Android, so the primitive owns the open/closed state — this also fixes the dialog auto-opening on mount). Both builds gained an `accessibilityLabel` prop that names the field for screen readers / test tooling. **The value contract is unchanged** — same `mode` values and ISO 8601 wire format on `value` / `onChange`; `CONTRACT.version` is unchanged.

### What's new in 0.40.1

**`<DateTimePicker>` actually renders on web (sc-1118).** The primitive was a single source that wrapped `@react-native-community/datetimepicker`, but that library ships iOS / Android only and has no react-native-web mapping — on the web Player and Studio the primitive rendered nothing, so date columns in the built-in **Form Input** / **Form Builder** widgets showed a label and required-asterisk with no input beneath them. The implementation is now split: native (`./datetimepicker.native.js`) still wraps the RN library; web (`./datetimepicker.js`) renders the browser's native `<input type="date|time|datetime-local">` directly. **The public contract is unchanged** — same component name, same `{ value, onChange, mode, minimumDate, maximumDate, disabled }` props, same ISO 8601 wire format on the value and the `onChange` callback. `CONTRACT.version` is unchanged.

### What's new in 0.39.0

**Web entry is bundled, not just transpiled (sc-1064).** A first-party / dev widget's WEB entry (`widget.web.jsx`, or the cross-platform `widget.jsx`) is now esbuild-**bundled** by both `appstudio-widget dev` and the publish packer, so vetted web-only deps the host doesn't shim — `react-leaflet`, `leaflet`, its CSS, and its marker PNG assets — are inlined instead of left as bare imports the Studio loader can't resolve. The native entry (`widget.native.jsx`) is still Sucrase transpile-only (Metro bundles its native deps in the export).

- **New `./dev-shims` exports**: `hostExternalSpecifiers()` — the canonical, single-source list of bare specifiers the runtime web host resolves (react family, `react-dom` + `react-dom/client`, `@colixsystems/widget-sdk`, and the vetted shimmed packages), i.e. exactly what the bundler externalises; and `REACT_DOM_NAMED_EXPORTS` — the react-dom named surface (`createPortal`, …) the host shim re-exports so `react-leaflet`'s portal-based Popup/Pane share the host's single react-dom instance.
- **New optional dependency `esbuild`** — lazily imported only on the `dev`/pack bundle paths (same pattern as the optional `sucrase`); the published runtime never forces it.

### What's new in 0.38.0

**Predefined SHARED translation keys (REQ-L10N-SHARED).** The standard strings the default widgets repeat ("Submit", "Cancel", "Save", "Loading…", …) now have a tenant-wide shared namespace `shared.<key>` so an identical string is translated **once** and every widget that uses it inherits the translation.

- **New contract field `CONTRACT.sharedTranslationKeys`** — the predefined map (`{ <key>: { en } }`) and the single source the host seeder reads.
- **New exported helpers** `sharedTranslationPrefix()` / `sharedTranslationKey(key)` / `isSharedTranslationKey(key)` (from `@colixsystems/widget-sdk/contract`).
- **`useI18n().t(key)` resolution is now three-step**: the per-widget key (`widget.<id>.<key>`) first, then the shared key (`shared.<key>`) when `key` is one of the predefined shared keys, then the raw key / fallback. So a default widget that calls `t("submit")` picks up the shared translation with no manual key entry, and an author who sets `widget.<id>.submit` in the Translations admin still overrides **that instance only**. An author-invented bare key is never silently shared.
- **The host auto-registers the shared keys** for a tenant at workspace-content seed time and whenever a marketplace or AI-generated widget is added — idempotent and non-destructive (an admin edit is never overwritten). Authors manage / translate them in the Studio Translations screen like any other key.
- **`CONTRACT.version` → `1.28.0`** (additive: one new contract field + three helper exports + the `useI18n` shared-key step). No existing export changed signature.

### What's new in 0.37.0

**`react/jsx-runtime` + `react/jsx-dev-runtime` are now vetted imports.** A widget bundle compiled with React's *automatic* JSX runtime (Vite/esbuild's default) emits `import { jsx, jsxs, Fragment } from "react/jsx-runtime"` — code the author never writes by hand. The runtime already treated these as host-provided (the web loader shims both, the AI-agent sandbox stubs them, and the Developer guide documents them as externalized), but the linter's vetted list did not list them, so such a bundle failed publish static analysis with `import-not-vetted` on `react/jsx-runtime`. Both are now on `CONTRACT.vettedImports` as `core` subpaths of the already-vetted `react`. **`CONTRACT.version` → `1.27.0`** (additive: two vetted core subpaths). No existing entry changed shape.

### What's new in 0.36.0

**Explicit `users.delete:*` scope for destructive user removal (SC-902).** `useUsers().remove(userId)` now requires `users.delete:*` in the manifest's `requestedScopes` — separated from the edit-style `users.write:*`. The backend gates `DELETE /app/users/:id` on a dedicated SystemAcl `users.delete` capability, so an operator can authorise (or withhold) permanent removal independently of inviting / deactivating. New linter rule **`scope-required-for-user-delete`** flags a widget that calls `.remove()` without declaring `users.delete:*` (`.invite()` / `.deactivate()` / `.reactivate()` still map to `users.write:*`). The built-in **User Management** widget adds the scope. **`CONTRACT.version` → `1.26.0`** (additive: one new scope verb + one linter rule). No hook signature changed.

### What's new in 0.35.0

**Folder-ACL + signer-roster hooks (REQ-FSH / REQ-SIGN).** Two admin/MANAGE-gated hooks reading the existing `ctx.filestore`:
- `useFileRoster(fileId, { limit?, offset?, enabled? })` → `{ roster, total, signedCount, loading, error, refetch }` — the signer roster for one file: the folder audience (every member of an open project folder, or the granted users/groups of a restricted one), each annotated `signed` with `signer_name` / `signed_at`. Reads `ctx.filestore.signatures.roster` (new `@colixsystems/filestore-client` **0.5.0** method); the host 403s a non-manager, so use `error` to hide the panel for non-admins.
- `useFolderPermissions(folderId, { enabled? })` → `{ grants, loading, busy, error, refetch, grant, revoke, setVisibility }` — manage a folder's permissions (the Filestore ACL): `grant(subjectType, subjectId, 'VIEW'|'DOWNLOAD'|'MANAGE')`, `revoke(shareId)`, `setVisibility('INHERIT'|'RESTRICTED')`. Pair the user/group picker with `useUsers` / `useGroups`.

Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own signatures only) — the backend tightened; the hook's shape is unchanged. **`CONTRACT.version` → `1.25.0`** (additive: two new hooks; the underlying `filestore-client` → `0.5.0`, shares folder-scoped).

### What's new in 0.34.0

**`useBankIdLink()` — link / unlink BankID from a widget (REQ-BANKID-AUTH).** A new DIRECTORY hook reading the new `ctx.directory.bankid` namespace on `@colixsystems/directory-client` **0.2.0** (`status` / `startLink` / `collect` / `cancel` / `unlink`). It lets a signed-in app-user attach a BankID identity to their account (or remove it) via an animated-QR poll — `startLink()` opens the order, `refresh()` polls it (drive on a ~2s interval while `status === "pending"`; render `qr` with the `Image` primitive), `unlink()` removes it. `available` is `false` when BankID can't be used in the app (provider disabled or no platform cert) — hide the affordance then. Self-service + JWT-gated, so **no `requestedScopes` entry is required**. Backs the built-in **User** widget's "Link BankID" section. `CONTRACT.version` → `1.24.0`. Additive — no existing export or type changed.

### What's new in 0.33.0

**New `filterList` propertySchema type + `ui.resetOnFieldChange` / `ui.defaultFactory` hints (REQ-WBLT-03, Tab Layout migration / #140).** `filterList` is a multi-condition record-filter builder — per-row column + operator + value with a "Relative date (N days ago)" toggle for DATE + ordering operators. The persisted value is `Array<{ column, operator, value, valueMode }>` matching the records-filter contract (`?filter[col]=op:value`). Columns resolve from a sibling tableRef (defaults to `tableId`; override with `ui.tableProp`). Pair with `ui.resetOnFieldChange: "tableId"` so the chain wipes when the source table changes. `ui.resetOnFieldChange: "<sibling>"` resets a property to its default when the named sibling changes — used to clear stale `columnRef`/`filterList` values nested inside an `array<object>` when the form-root tableId switches. `ui.defaultFactory: "tabId"` seeds a unique stable id when a new array item is added (Tab Layout's `tabs[*].id`). The Tab Layout built-in widget moved fully onto the manifest-driven Properties Panel; its hand-rolled per-tab editor is gone. `CONTRACT.version` → `1.23.0`. Additive — no existing export or type changed.

### What's new in 0.32.0

**New `fieldList` propertySchema type (REQ-WBLT-03 / #139).** A per-field repeater that composes a form from author-picked columns of a sibling `tableRef` (default `tableId`, override via `ui.tableProp`). Each row stores `{ id, columnId, kind, label, required, optionsSource, inlineOptions, optionsTableId, optionsValueColumn, optionsLabelColumn }`; `kind` is one of `auto` / `singleChoice` / `multiChoice` and is gated by the column's `data_type` so an array column can't render as a scalar picker. The Studio renders the built-in **Form Builder** widget's editor through `SchemaForm` instead of the hand-rolled block it shipped with — removes the last data widget from `LEGACY_EDITOR_TYPES`. Persisted field shape is unchanged so existing pages keep rendering. **`CONTRACT.version` → `1.22.0`** (additive: one new propertySchema type). No existing export or type changed signature.

### What's new in 0.31.0

**Already-signed file state (REQ-SIGN).** So a file browser can show which files are signed (and not re-sign blindly):
- `useFileSignatures(fileIds)` → `{ signaturesByFileId, loading, error, refetch }` — a batch "is this file signed?" lookup. `signaturesByFileId` maps each signed, accessible file id to its latest `{ signature_id, signer_name, signed_at }`; unsigned files are absent. One request, no N+1. Reads `ctx.filestore.signatures.list` (new `@colixsystems/filestore-client` **0.3.0** method).
- `useFileSignature(fileId, existingSignatureId?)` gained an optional second arg: pass an already-signed file's signature id to seed the `complete` state and `verify()` it **without** opening a new order. Re-signing then requires an explicit `initiate()`.

`CONTRACT.version` → `1.21.0` (additive — the new arg is optional, no existing hook changed).

### What's new in 0.30.0

**Filestore browsing + BankID file signing for widgets (REQ-FS / REQ-SIGN).** Three new hooks read a newly-injected `ctx.filestore` (the `@colixsystems/filestore-client`, now constructed by both the web and native hosts):
- `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space. Every row carries a ready-to-render `url` (its absolutized `presigned_url`), so a gallery renders `files.map(f => f.url)` directly — no per-row `useFilestoreFile` call. Every row ALSO carries `urls` — the size ladder `{ thumbnail, card, large, hero }` at 128 / 512 / 1024 / 2048px longest edge — so a thumbnail grid should render `f.urls.thumbnail` rather than pulling the full-size original for every tile.
- `useFilestoreFile(fileId)` → `{ file, url, urls, loading, error, refetch }` — resolves ONE file id to a displayable `url` (its `presigned_url`, absolutized for web + native) via `ctx.filestore.files.get(id)`. **Read the top-level `url`** — the returned `file` is `null` until the fetch resolves (and for an empty id), so `file.url` throws on the first render; it is correct only after a null check. **Never compose a file URL yourself** — the bytes are served only from a server-signed token URL, so a hand-built path like `/api/files/<id>` can never resolve (the linter's `no-host-api-url` rule flags it, including the `${location.origin}/api/files/…` form). A datastore `FILE` column holds — and reads back as — that **bare file-id string**; it is NOT hydrated into an object the way a `RELATION` (`{ id, label }`) or `USER` (`{ id, name }`) column is, so pass the value straight to the hook (no `{ id }` / `{ url }` unwrapping guard). Empty / deleted / not-found ids resolve to `url: null` so a display widget shows a fallback. When you render `url` in an `<Image>` that fills its container, size it with `aspectRatio` (e.g. `{ width: "100%", aspectRatio: 1 }`) or pixels — never a percentage `height`, which React Native collapses to 0 against a content-sized parent, so the image loads but is invisible. Requires `files.read:*`.
- `useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })` → `{ folders, loading, error, refetch }` — the folder-navigation companion to `useFilestoreFiles`; pass `enabled:false` to suspend fetching.
- `useFilestoreUpload({ spaceType, folderId?, compress? })` → `{ upload, uploading, error, lastUploaded }` — POSTs a multipart upload to `ctx.filestore.files.upload`. Resolves `owner_id` from the host context (like the read hooks) so the widget only picks the space + destination folder. Uploaded images are compressed to WebP by the backend; pass `compress: false` (on the hook, or per `upload(file, { compress })`) to store the file byte-for-byte — use it whenever the original matters. Pair with the `<FilePicker>` primitive for the visible trigger. Requires the `files.write:*` scope.
- `useFileSignature(fileId)` → `{ status, qr, signerName, verdict, initiate, refresh, cancel, verify, … }` — drives a BankID signing flow for a file (the backend hashes the bytes server-side, binds the digest into the signature, and verifies the proof offline).

`CONTRACT.version` → `1.20.0` (additive — no existing hook changed).

### What's new in 0.29.0

**The SIGNATURE column type and its hook are retired (REQ-SIGN).** `useSignature(tableId, recordId)` and the datastore-client `sign(recordId)` namespace are **removed** — signing a single record/column was the wrong granularity. BankID signing is being rebuilt around a standalone, polymorphic **Signature subject model** (sign a file first, then whole records and policy text), where the signature is cryptographically bound to the exact bytes signed (the file's SHA-256 embedded in the BankID signature) and verified offline against the pinned `BankID Root CA v1`. New SDK hooks for that model will land as the backend ships. `CONTRACT.version` → `1.19.0` (pre-1.0 breaking removal of unreleased hooks; kept monotonic).

### What's new in 0.25.0

**Per-widget styling via `styleSchema` (REQ-THEME-13).**

- **New optional `manifest.styleSchema` field.** Same shape and types as `propertySchema` (`color`, `number`, `select`, `boolean`, …) — it declares the styling options the widget exposes. Example:
  ```js
  styleSchema: {
    cardBackground: { type: "color", label: "Card background" },
    cardRadius:     { type: "number", label: "Corner radius", validation: { min: 0, max: 48 } },
    valueColor:     { type: "color", label: "Value color" },
  }
  ```
- **The Studio renders a "Style" section** from `styleSchema` using the same `SchemaForm` engine as `propertySchema`. The author's resolved values are persisted under the node's `props.style`.
- **New `useWidgetStyle()` hook** returns `props.style` (an object keyed by your style-field names). Read `style.<field>` and apply each onto whatever element you choose — the host never auto-applies style, so the widget controls placement:
  ```jsx
  const style = useWidgetStyle();
  <View style={[styles.card, style.cardBackground && { backgroundColor: style.cardBackground }]}>
  ```
- **`CONTRACT.version` → `1.15.0`** (additive: one optional manifest field + one hook). No existing field, hook, primitive, or token changed shape.

### What's new in 0.23.0

**Curated cross-platform package expansion to the vetted import allowlist (REQ-WSDK-PKG-EXPAND).**

- The linter's vetted import allowlist (`CONTRACT.vettedImports`) gains a curated set of popular React Native packages, each of which runs on **both** web and native (directly, or via a documented platform-split counterpart):
  - **`react-native-reanimated`** (`web`/`native`) — declarative animations.
  - **`react-native-gesture-handler`** (`web`/`native`) — native-driven touch gestures.
  - **`react-native-safe-area-context`** (`web`/`native`) — safe-area insets.
  - **`@shopify/flash-list`** (`web`/`native`) — high-performance virtualised list.
  - **`react-native-paper`** (`web`/`native`) — Material Design components.
  - **`react-native-vector-icons`** (`web`/`native`) — icon font families.
  - **`@react-native-community/slider`** (`web`/`native`) — slider input.
  - **`expo-linear-gradient`** (`web`/`native`) — the cross-platform gradient.
  - **`lottie-react-native`** (`native`) + **`lottie-react`** (`web`) — Lottie animations, split-impl.
  - **`react-native-webview`** (`native`) — embedded web content (pair with an `<iframe>` on web).
- **Parity (widget-parity skill):** the compiler now pins the native-module members in the exported Expo app's `package.json` and emits the **`react-native-reanimated/plugin`** in `babel.config.js` **unconditionally** (previously only for the sidebar-drawer shell), so a baked widget that imports any of these resolves and bundles on native exactly as it renders in the web Player.
- **`CONTRACT.version` → `1.13.0`** (additive: new vetted import entries only). No existing export, type, manifest field, hook, or banned-API list changed.

### What's new in 0.22.0

**New `valueRef` propertySchema type — bind a widget to a single value in the datastore (REQ-WDG-VALUEREF).**

- **`valueRef`** is a composite picker: the Studio Properties Panel renders three cascading dropdowns — pick a **table**, then a **record**, then a **column** — and the bound widget resolves the one cell. It is the discoverable replacement for hand-typing a `tableRef` + a raw record-id `string` + a `columnRef` separately (the old Data Value shape).
- **Persisted value is an object** `{ tableId, recordId, column }` (new `ValueRefBinding` type), unlike every other ref type, which is a bare string. A widget reads it with `useDatastoreRecord(value.tableId, value.recordId)` then `record[value.column]`; any missing piece means "no value".
- **tenant-copy** remaps `tableId` to the copied table and **nulls `recordId`** (records are business data and are never copied), so a copied workspace shows the widget's fallback until the new operator re-picks a record. `column` (a name) is preserved verbatim.
- The built-in **Data Value** widget now uses `valueRef` (back-compatible: already-saved Data Value widgets that stored `tableId`/`recordId`/`column` as separate props keep rendering).
- **`CONTRACT.version` → `1.12.0`** (additive: one new optional propertySchema type). No existing export or type changed signature.

### What's new in 0.21.1

**Default theme tokens corrected to the product's advertised brand (fix).**

- **`themeTokens.colors.primary` → `#3b82f6` (blue), `colors.secondary` → `#10b981` (green).** Previously these defaulted to a stale coral/slate that no other surface used — the Theme Settings tab and the Player chrome already advertised the blue/green default. A tenant that never customised its theme therefore rendered widgets (`useTheme().colors.primary`) in coral until a first save persisted the blue, a visible divergence between the unsaved and saved-defaults render. No export, signature, type, or token shape changed — default values only.
- **`CONTRACT.version` → `1.11.1`.** Patch: a default-value fix; the documented contract (token names + shape) is unchanged.

### What's new in 0.21.0

**Widgets can fill their page-grid tile's height (REQ-LAY-08).**

- **New `useFill()` hook.** Returns a `boolean` — `true` when the host has sized this widget to fill the available height of its layout slot (a page-grid tile whose author chose "Fill tile height", or a widget type that fills by default: the layout containers + the media widgets Image / Chart / Map / Video). A widget that has a meaningful filled form switches to a stretch layout (`flex: 1` / `height: "100%"`) when it reads `true`; others ignore it. Defaults to `false`, so calling it is always safe.
- **New optional `WidgetContext.fill` slice** backs the hook. It is optional (defaults `false`), so existing hosts and widgets are unaffected. The web Player host and the native export host inject the SAME value, so a widget's fill behaviour is identical on both platforms.
- **`CONTRACT.version` → `1.11.0`** (additive: one new hook + one new optional context slice). No existing export changed signature.

### What's new in 0.20.0

**Widgets can ship their own translations (REQ-L10N-WIDGET).**

- **New optional `manifest.translations` field.** Shape `{ <key>: { en: string, <locale>?: string } }` — `en` is required per key; additional locales are optional. `validateManifest` structurally validates it (≤100 keys, key matches `/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`, value ≤1 KB, namespaced key ≤128 chars); the marketplace analyzer enforces the same caps.
- **`useI18n().t(key)` now auto-namespaces.** The host derives a per-widget prefix from the widget id and resolves `widget.<id>.<key>` first, then falls back to the raw key (so shared app keys and pre-1.10 widgets are unaffected). Authors call `t("greeting")` and never type the prefix — the same behaviour on web and in the exported native app (both hosts inject `ctx.widget.id`).
- **Install-time seeding.** When a widget is installed, the host merges its `translations` into the tenant's localization dictionary under that namespace — non-destructively (it never overwrites an admin's edit, never creates a language the tenant didn't add, and seeds the tenant's base language from the widget's `en` so every key renders). Keys persist across uninstalls; admins prune them with the Translations screen's bulk delete (by id or by `widget.<id>.` prefix).
- **New exported helpers** `widgetTranslationPrefix(id)` / `widgetTranslationKey(id, key)` (from `@colixsystems/widget-sdk/contract`) are the single source of the key format, shared by `useI18n` and the host seeder.
- **`CONTRACT.version` → `1.10.0`** (additive: one new optional manifest field + the `useI18n` namespacing behaviour). No existing export changed signature.

### What was in 0.19.0

**The data layer splits into four injected domain clients; the SDK becomes core-only.**

- **The SDK no longer owns any data facade.** The bespoke per-hook facades that used to live on `WidgetContext` (`ctx.datastore` as an opaque host object, `ctx.directory.listUsers`, `ctx.users`, `ctx.groups`, `ctx.recordPermissions`, `ctx.assets.get`) are replaced by four host-instantiated, host-injected domain clients: `ctx.datastore` (`@colixsystems/datastore-client`), `ctx.directory` (`@colixsystems/directory-client`), `ctx.assets` (`@colixsystems/assets-client`, flattened), `ctx.payments` (`@colixsystems/payments-client`). The SDK imports none of them and ships no HTTP.
- **`ctx.recordPermissions`, `ctx.users`, `ctx.groups` are removed.** Per-record permission management moved under `ctx.datastore.records(tableId).permissions(recordId)`; user / group administration moved under `ctx.directory.users` / `ctx.directory.groups`. The hooks (`useRecordPermissions`, `useUsers`, `useGroups`) keep the same names and signatures — only the client slice they read changed.
- **snake_case end to end, no client-side transform.** Clients send and return snake_case verbatim (`group_ids`, `can_read`, `is_active`, `amount_cents`, `data_type`, `created_at`, …). The SDK passes bodies straight through and unwraps the `{ data, meta }` list envelope without renaming a single field. Wire-payload hook return rows are therefore snake_case (`is_active`, `member_count`, and `useRecordPermissions` rows carry `user_id` / `group_id` / `can_read` / `can_write` / `can_delete` / `can_grant`). The one exception is `useUser()`: it reads the host-built `ctx.user` context object, not a wire payload, so its fields are **camelCase** (`displayName`, `groupIds`).
- **Companion package versions:** `datastore-client 0.5.0`, `assets-client 0.4.0`, `directory-client 0.1.0`, `payments-client 0.1.0`.
- **`CONTRACT.version` → `1.9.0`.** Breaking for `WidgetContext` consumers (removed slices, renamed wire fields); the hook export surface is unchanged.

### What was in 0.18.0

Two additive features land in this version.

**A widget may declare server-side actions in its manifest.**

- **`WidgetManifest.actions` is now part of the public contract.** An optional array; each entry is `{ key, name, description?, triggerTypes, scheduleCron?, timeoutMs?, scriptSource }`. `triggerTypes` is a non-empty array of unique values from `schedule`, `record_created`, `record_updated`, `record_deleted` — combine them so one script serves several events; `scheduleCron` is required when it contains `schedule`. The `scriptSource` (≤ 200 KiB) runs in the **shared isolated-vm action runner** — against `datastore` / `fetch` / `connectors` / `console` / `record` / `tenantId` (the runner surface, **not** the React/SDK widget surface, so SDK imports and hooks are unavailable there and the component linter does not scan it). `connectors.call(slug, { method, path, query, body, headers })` resolves a tenant-configured REST connector by slug and returns `{ status, headers, body }` (auth + SSRF handled by the platform; an unknown slug / SSRF / timeout throws a catchable Error). Actions never run in the rendered app, so they have **no effect on Player ↔ export parity**.
- **Operators enable actions per tenant** from the Properties Panel. An enabled action materialises a tenant `Action` row **DISABLED** until the operator binds an integration API key (and, for `record_*` triggers, a target table) in the Actions admin page — those bindings are tenant-local, so `triggerTableId` / `apiKeyId` must **not** appear in the manifest (the validator and linter reject them).
- **New contract fields** `CONTRACT.actionTriggerTypes`, `CONTRACT.actionScriptGlobals`, `CONTRACT.actionScriptMaxBytes` expose the grammar so the Developer page, the AI agent prompt, and `validateManifest` derive it from one source. `validateManifest` now structurally validates `actions`; the marketplace linter rejects malformed / oversized declarations.
- **New manifest category `ADMINISTRATION`** for app-administration widgets such as User Management. Added to `CONTRACT.manifestCategories`, `validateManifest`, the `WidgetCategory` type, the marketplace category list, and the master-DB `WidgetCategory` enum.

**Per-record permission management from inside a widget.**

- **`useRecordPermissions(tableId, recordId)` is wired** + the new `WidgetContext.recordPermissions` slice + the new `acl.write:records` scope. Returns `{ permissions, loading, error, grant, revoke, update, refetch }` where `permissions` is `Array<{ id, principalType: "USER" | "GROUP" | "PUBLIC", principalId, canRead, canWrite, canDelete, canGrant }>`. Rejections from `grant` / `revoke` / `update` surface as a structured `PermissionError` (named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | CONFLICT | INTERNAL`. When `tableId` or `recordId` is null/empty the hook collapses to a stable empty no-op result. Mutating requires `acl.write:records` AND `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. Backs the built-in Chat widget's "+ New channel" + DM-create flows. Additive.
- **`CONTRACT.version` → `1.8.0`** (additive: a new optional `actions` manifest field, three new contract fields, the `ADMINISTRATION` category, the `useRecordPermissions` hook, the `recordPermissions` context slice, the `acl.write:records` scope, and the `PermissionError` class). No existing export changed signature.

### What was in 0.17.0

A runtime schema resolver so widgets can render by column type.

- **`useDatastoreSchema(tableId)` is wired** + the new `WidgetContext.datastore.schema` slice. Returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }` (`null` until loaded). Reads structure only, never row data, so a public-grant table resolves for anonymous visitors like a record read. Use it to resolve a stored `columnId` to its name / dataType / relation target at runtime. Reads need the `datastore.read:<table>` scope. Additive.
- **`CONTRACT.version` → `1.7.0`** (additive: one new hook, one new `datastore.schema` context field). No existing export changed signature.

### What's new in 0.16.0

The tenant's **Theme Settings** now flow all the way into `useTheme()`.

- **`themeTokens.colors` gains `secondary` + `onSecondary`.** `useTheme().colors.secondary` reflects the tenant's *Secondary Color* picker (with `onSecondary` as its readable contrast color), alongside the existing `primary` / `onPrimary`. Built-in widgets like Button use it for their secondary variant; third-party widgets can use it for a branded second accent. The full `colors` shape is now `{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }`.
- **`colors.primary` / `colors.secondary` / `typography.fontFamily` / `typography.headingFontFamily` are tenant-resolved.** The host maps the Studio Theme Settings blob (Primary Color, Secondary Color, Global Font, Heading Font) onto the default tokens before handing them to `useTheme()`, on both the live Player and the exported app — so a widget that reads tokens re-themes automatically. Both families are loaded by the Player and bundled into the exported app, so they render the same on web and native.
- **`CONTRACT.version` → `1.6.0`** (additive: two new `themeTokens.colors` keys). No existing export changed signature.

### What's new in 0.15.0

The "split-implementation + vetted package list" pivot.

- **`CONTRACT.vettedImports` (new).** A curated allowlist of bare specifiers a widget may import — `react`, `@colixsystems/widget-sdk`, `react-native`, `axios`, `date-fns`, `react-native-svg`, `lucide-react-native`, `react-native-maps`, `leaflet`, `react-leaflet`, `expo-audio`, `expo-video`, `@react-native-community/datetimepicker`, `expo-clipboard`, `expo-haptics`. Each entry carries `platforms` (one or both of `"web"` / `"native"`) and a `category` so the linter and the marketplace listing can render honest platform badges. `CONTRACT.allowedBareImports` (the existing field) is now derived from `vettedImports` and stays a plain `string[]` for back-compat.
- **`fetch` and `XMLHttpRequest` come off `CONTRACT.bannedApis`.** Widgets may call third-party APIs directly. Calls to the host's own `/api/*` surface will 401 because the JWT token is never shared with widget code; the linter emits a soft `no-host-api-url` warning when it sees host-URL substrings so authors learn the rule statically. Use SDK hooks (`useDatastoreQuery`, `useUsers`, `useAsset`, …) for workspace data; use `axios` / `fetch` for third-party APIs.
- **`import-not-vetted` linter rule (new).** Every bare `import` specifier is validated against `CONTRACT.vettedImports`. Relative imports inside the bundle (`./shared.js`) are allowed so split-impl widgets can share helpers; `../` and absolute paths are rejected.
- **`import-platform-mismatch` linter rule (new).** A single-source widget that imports a native-only package while `manifest.supportedPlatforms` includes `"web"` fails the lint. The author either drops the platform from the manifest OR ships a `widget.web.jsx` + `widget.native.jsx` pair where the platform-specific import lives in the file that targets its platform.
- **`react-not-imported` linter rule (new).** Widget source must be self-contained: a reference to the bare `React` global (`React.createElement` / `React.Fragment` / `React.memo` / …) with no `import React from "react"` (or `import * as React`) fails the lint — it would throw `ReferenceError: React is not defined` at render. Plain JSX needs no React import and is never flagged. Add the import, or prefer a JSX fragment `<>…</>` plus the SDK hooks/primitives.
- **Lint findings carry `severity`.** `"error"` (default) blocks publish; `"warning"` (currently only `no-host-api-url`) surfaces to reviewers without blocking. The `lintSource(...)` return shape stays `{ ok, findings }` — `ok` is true iff no error-severity findings exist.
- **Four Tier A SDK additions:**
  - `<Icon>` primitive — `<Icon name="check" size={16} color={theme.colors.primary} />`. Wraps `lucide-react-native`; works on both platforms.
  - `<DateTimePicker>` primitive — `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. Wraps `@react-native-community/datetimepicker` and normalizes the value to ISO 8601 strings (the datastore wire format).
  - `useClipboard()` hook — `{ copy, paste, hasContent }`. Web via `navigator.clipboard`; native via `expo-clipboard`. Rejections are a structured `ClipboardError` with `.code` in `PERMISSION_DENIED | INTERNAL`.
  - `useToast()` hook — `{ showToast }`. The host installs a workspace-themed renderer at `WidgetContext.toast.showToast`; if omitted, the web variant dispatches an `appstudio:widget-toast` CustomEvent and native logs to the console.
- **`CONTRACT.version` → `1.5.0`** (additive: two new contract fields — `vettedImports`, `hostApiUrlPatterns` — two banned APIs removed, two primitives + two hooks added, one optional `widgetContextShape.toast` slot). No existing export changed signature.

### What was in 0.14.1

- **`groupRef` property type.** Authors can declare `{ type: 'groupRef', label: 'Group' }` in their `propertySchema` to render a Group picker in the Studio Properties Panel. The widget receives a bare `AppUserGroup` UUID, so tenant-copy walks the value transparently. Used by the built-in `appstudio.user-management` widget for its `defaultGroupId` prop and available to third-party widgets that need to anchor behaviour on a specific group.
- **Patch bump** — additive enumeration entry, no exported function signature changed. `CONTRACT.version` stays `1.4.0`.

### What's new in 0.14.0

- **`useUsers()` + `useGroups()` — AppUser administration hooks.** A widget can invite, deactivate, reactivate, and remove members, and create / delete groups + add / remove members, from a published-app surface. Returns `{ users | groups, loading, error, refetch, ... }` plus imperative mutation methods. Reads gated by `users.read:*` / `groups.read:*`; mutations by `users.write:*` / `groups.write:*`. Rejections surface as a structured `DirectoryError` (new named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY`. The host signs an `X-Widget-Scopes` header against `JWT_SECRET` so an APP_USER cannot forge a scope set, and the backend additionally gates the request behind a SystemAcl `users.*` / `groups.*` capability grant.
- **Managing app users from a widget — see the section below.**
- **New linter rule `no-scope-mismatch-useUsers` / `no-scope-mismatch-useGroups`.** Calling `useUsers().invite()` / `.deactivate()` / `.reactivate()` / `.remove()` without `users.write:*` in the manifest's `requestedScopes` fails the lint; calling `useGroups()` mutation methods without `groups.write:*` fails the lint. Keeps the manifest honest at submission time.
- **`CONTRACT.version` → `1.4.0`** (additive: two new hooks, two new context slices, six new scope verbs, one new error class). No existing export changed signature.

### What's new in 0.13.0

- **`WidgetTree` component + `useChildRenderer()` hook** wired. Container widgets (Tabs, Card, …) can now render arbitrary author-authored child page-tree nodes through the SDK without importing the host renderer. The host pre-binds the surrounding render context (breakpoint, page ctx, parent) into a closure on `ctx.renderer.renderNode(node)` — the widget just passes the child node. Additive.

### What's new in 0.12.0

- **`useDatastoreRecord(tableId, recordId)` is wired.** Returns `{ data, loading, error, refetch }` for a single record fetched through the host's `records(table).get(id)`. Sister to `useDatastoreQuery`; mirrors its ref discipline so `refetch` stays a stable callback identity. A 404 surfaces as `DatastoreError.code === "NOT_FOUND"`. Additive.
- **`useAsset(fileId)` is wired** + new `WidgetContext.assets` slice. Returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL the widget can drop straight into `<Image source>`. Backed by a new `files.get(fileId)` host facade (web: `widgetHostFiles` through `api/client`; native: `hostFiles` in the export's `widgetHost.js`). Additive.

### What's new in 0.102.0

- **`useWidgetRoute(initial)` persists your widget's own internal position.** A widget's internal navigation lived in bare `useState`, so it was neither linkable nor durable: a reload dropped the visitor back at the opening view. Use the hook exactly like `useState` with an object — `const [view, setView] = useWidgetRoute({ path: [], sort: 'name' })` — and the host persists the bag: the view survives a reload and a copied link opens the widget where the sender was. Writes MERGE, `null` clears a key back to the `initial` you declared (which is what keeps the address clean), and `initial` is read once like `useState`'s argument. Values are scalars or flat arrays of scalars — a path, a list of visited steps — and are size- and length-capped; nothing else is stored. Scoped to the PLACEMENT, so the same widget placed twice on a page keeps two independent views. Three rules worth reading twice: it is **not history** (Back still leaves the page on both platforms, because native has no equivalent stack to mirror an in-widget one onto), it is **opt-in per key** (transient UI — an open dropdown, a half-typed form — stays in ordinary `useState`), and for a **text input** you should keep the box in `useState` and push it in on a short debounce rather than rewriting the address once per keystroke. Degrades to plain component state on the Studio canvas, so it is always safe to call. Additive (v0.102.0).

### What's new in 0.11.0

- **`useNavigation()` is wired.** Returns the host-provided navigation surface `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page-to-page navigation. Missing methods degrade to no-ops on the Studio canvas preview. Additive.
- **`openLink(link)` follows a link whose shape you do NOT control** — a value off a datastore row, a notification's `link`, anything author- or user-supplied. It resolves the string through the host's shared resolver and then acts: an in-app page routes internally, an off-app `http(s)` URL opens outside, and anything unsafe (`javascript:`, `data:`, scheme-relative `//host`, a scheme split by a control character) is refused. Returns `true` when it acted. **Prefer it over `Linking.openURL` for untrusted values** — `Linking.openURL` performs whatever it is handed, so passing it a stored string is how a `javascript:` URL reaches the browser. Reach for `goTo(pageId)` when you already know the page, and `Linking.openURL` only for a URL your own code constructed.
- **`usePageContext()` reads the page's DECLARED parameters.** When a page declares parameters (Page Settings → Parameters), the host resolves them ONCE before any widget renders and fetches each `record` parameter's row for the whole page. `params` holds the values coerced to their declared types (a `number` parameter is a number, not the string `useRouteParams()` returns); `records` maps each `record` parameter to its already-loaded row — read it rather than issuing the same request from every widget. A required parameter that is absent, malformed, or whose record does not resolve never reaches the widget: the host renders one page-level state instead. Both bags are empty on a page that declares nothing, so fall back to `useRouteParams()` for a page you do not control. Additive (v0.77.0).
- **`useRouteParams()` reads the nav params.** Returns `currentRoute.params` — the bag a `goTo(pageId, params)` carried to this page. The flat accessor for master→detail: navigate with `goTo(detailPageId, { recordId: row.id })`, then read `const { recordId } = useRouteParams()`. It is an OBJECT — read a param off it, never call it. Empty object when the page was opened without params. Additive (v0.61.0).
- **`Linking` primitive re-exported.** `Linking.openURL(url)` opens an external URL with the OS handler — web (`react-native-web`) maps to `window.open` / `location.href`; native hands off to the system. Use this for external URLs; use `useNavigation().goTo(pageId)` for internal pages.

### What's new in 0.10.0

- **`useUser()` is wired.** Returns the active end-user identity `{ id, email, displayName, roles, groupIds }` from the host-provided `WidgetContext`. `id` is `null` for anonymous visitors and on the Studio canvas preview. All fields are guaranteed present (the host fills safe defaults), so widgets read them without optional chaining. Additive — no migration needed for existing widgets.

### What's new in 0.9.0

> **Superseded in 0.62.0** — the wire was always snake_case (`amount_cents`, `return_path`), never the camelCase shown below, and the HOST now opens hosted Checkout itself (the widget does not). See the 0.62.0 entry for the current contract.

- **`usePayments()` — incoming app-user payments.** Returns `{ requestPayment, getPayment }`. `requestPayment({ amountCents, currency?, description, metadata?, returnPath? })` triggers a one-time charge from the signed-in app user and resolves to `{ id, status, checkoutUrl? }`: when `checkoutUrl` is present the widget opens it (web: navigate; native: `expo-web-browser`) — the user pays in the provider's **hosted checkout**; when absent (the platform's built-in **mock** provider, the default until a real provider is configured) the charge auto-confirms (`status: "PAID"`). `getPayment(id)` polls the terminal status. Backed by a new `WidgetContext.payments` slice and gated by the new `payments.charge:appUser` scope. **No card data ever touches the widget** — never collect card fields yourself. The charge settles to the workspace owner; the amount is bounded by a platform per-charge cap. Rejections are a structured `PaymentError` (also a new named export) with a stable `.code`.
- **`CONTRACT.version` → `1.2.0`** (additive: one new hook, one new context slice, one new scope, one new error class). No existing export changed signature.

### What was in 0.8.0

- **`useDirectory()` — read-only user directory hook.** Returns `{ users, loading, error, refetch }` where each user is `{ id, name, role }`. Gated by the new `directory.read:users` scope. Use it for a chat people-list, an @-mention picker, or to resolve an author id to a display name. Player callers get the reduced `{ id, name, role }` projection — email and other admin fields never leave the server for an app end-user. `query` is an optional `{ q, role, isActive, limit, offset }` (`q` substring-matches the display name; `role` is `"USER"` (default), `"INTEGRATION"`, or `"ALL"`). The directory is read-only.
- **`CONTRACT.version` → `1.1.0`** (additive: one new hook, one new context slice, one new scope). No existing export changed signature.

### What was in 0.7.0

- **`datastoreTemplate` is now part of the public manifest contract.** `CONTRACT.manifestSchema` carries an optional `datastoreTemplate` entry alongside the existing fields. The TypeScript `WidgetManifest` already declared this since 0.3.0, but the runtime contract did not — the agent's system prompt only generated the field set advertised by `CONTRACT.manifestSchema`, which silently omitted `datastoreTemplate` from every AI-generated draft. The mismatch meant most AI-generated DATA widgets shipped without a seeded table, forcing the end-user to hand-build it before the widget would render anything useful. With the schema entry in place, the agent now defaults to including a `datastoreTemplate` whenever the widget reads or writes data, matching the no-code experience the platform promises.
- **Agent system prompt** ([backend/src/core/services/ai-widget-agent.service.js](../../backend/src/core/services/ai-widget-agent.service.js)) gains a dedicated `===== DATASTORE TEMPLATE =====` section, an updated DATA-widget example with a template, and a CONVERSATION BEHAVIOUR rule pinning the default. The note-list example (TextInput + create + update + delete) now shows the matching template too, including the column-name-match rule (`record.Body` ↔ `"name": "Body"`).

### What was in 0.6.0

- **Primitives are now React Native through-and-through.** Web (Studio + Player) bundles with `react-native-web`; native (exported Expo app) uses the real `react-native`. The hand-written DOM wrappers in `primitives.js` are gone — both web and native files are now one-line re-exports from `react-native`. The widget API surface is unchanged for the components that already existed (`Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`), so existing 0.5.0 widgets keep running without source edits.
- **More primitives available for free.** `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, and `StyleSheet` are now exported alongside the original five. Authors who want them just import from `@colixsystems/widget-sdk` like any other primitive.
- **Per-component API docs link out to React Native.** The Developer Widgets page and the AI agent's system prompt now point at https://reactnative.dev/docs/<component> for prop details instead of duplicating them in the SDK contract. Less drift, more breadth (every RN prop — `accessibilityLabel`, `testID`, gesture handlers, …, works without explicit allowlisting).
- **Adding a new primitive is now a single edit.** Append the export name to `primitives.js` + `primitives.native.js` + the `CONTRACT.primitives` array. No web/native implementation split.
- **New peer dep.** `react-native-web >=0.19.0` (optional, marked under `peerDependenciesMeta`). The web bundler picks it up via an alias (`react-native` → `react-native-web` in the host's Vite / webpack config); the native bundler ignores it.

### What was in 0.5.0

- **`TextInput` primitive.** Cross-platform controlled text field — web maps to `<input type="text">` (or `<textarea>` when `multiline` is true), native maps to `react-native` `TextInput`. Props: `value`, `onChangeText`, `placeholder`, `multiline`, `rows`, `disabled`, `style`. Fixes the gap that forced widgets to fall back to raw `<textarea>` (web-only). The AI Widget Agent's system prompt now documents this primitive.
- **`useDatastoreMutation(...).update(id, partial)` is wired.** Partial-update semantics — only the supplied columns are mutated, the rest of the row is left intact. MANY_TO_MANY relations follow replace-set semantics when supplied; omitting the column leaves the existing links alone. Constraint enforcement runs against the merged row, excluding self so a re-affirm doesn't trip its own UNIQUE.

### What was in 0.4.1

- **`useDatastoreQuery` returns a stable `refetch` identity.** The hook no longer rebinds the underlying callback when the host's `WidgetContext` value (a fresh object identity on every render in Studio + PageRenderer) changes. Widgets that put `refetch` in a `useEffect` dep array no longer loop.
- **Keep the `useDatastoreQuery` argument stable across renders.** The hook re-fetches whenever `[table, JSON.stringify(query)]` changes, so a query whose serialized value differs every render refetches forever and the widget is stuck on its loading state. The classic trap is a time-relative filter built with `new Date()` / `Date.now()` inline in the query (a "this week" / "today" range) — the timestamp advances each render, so the key is never the same twice. Compute the date/time bound once with `useMemo(() => …, [])` (round to the day if you only need day granularity) and pass the stable value in. The same applies to any per-render value (a freshly built object, `Math.random()`): memoize it before it enters the query.

### What's new in 0.4.0

- **`CONTRACT` is now a named export.** A frozen object literal describing the SDK surface every consumer (LLM system prompt, static analyzer, host builder, contract tests) derives from instead of declaring its own copy. See `docs/design/ai-widget-contract.md` for the full design and `src/contract.js` for the source. The contract carries:

  - `hooks` — name, signature, return shape, required `WidgetContext` slices, manifest scopes.
  - `primitives` — the cross-platform primitive list (names + the React Native component each one backs).
  - `manifestSchema` — the authoritative `WidgetManifest` field list (with `id` and `minAppStudioVersion`, not the legacy `manifestId` / `minAppStudioVer`).
  - `themeTokens` — the default `useTheme()` payload.
  - `widgetContextShape` — what the host must populate.
  - `bundleExportContract` — the two default-export shapes the loader accepts.
  - `bannedApis` — the global allowlist gate.
  - `allowedBareImports` — `react`, `@colixsystems/widget-sdk`.

- **`useDatastoreQuery` is now stateful.** Returns `{ data, loading, error, refetch }`. Previously the hook returned the raw `list(...)` promise; widgets that called `.map(...)` synchronously on the result threw on first render. Migration: replace `const rows = useDatastoreQuery(...)` with `const { data, loading, error } = useDatastoreQuery(...)`. `data` is always an array (empty when the table is unbound or loading).

- **`DatastoreError` is now a named export.** Mutations throw a structured `DatastoreError` with `.code` (`VALIDATION` / `CONSTRAINT_VIOLATION` / `FORBIDDEN` / `NOT_FOUND` / `INTERNAL`) and an optional `.fieldErrors` map populated from the host's 422 payload. Widgets can branch on `err.code` without parsing axios messages.

- **`useI18n().t` now honours `fallback`.** `t(key, fallback)` returns `fallback` when the host's translation table has no entry for `key`.

### What was in 0.3.0

Additive: `WidgetManifest` carries an optional `datastoreTemplate` field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the built-in template semantics (auto-suffixed naming, creator-grants, public grants, cross-relation inheritance) and persist when the widget is later uninstalled. See `WidgetDatastoreTemplate` in `src/index.d.ts` for the constraints — at most 8 tables per widget, 24 columns per table, RELATION columns address siblings by `suffix`.

## Public API

```js
import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@colixsystems/widget-sdk";
```

- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
- `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, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `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).
- `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.
- `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.
- `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.
- `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.
- `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`.
- `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`).
- `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.
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.

## Design & visual polish

A widget that works but looks unfinished is only half done. `useTheme()` is the styling contract — compose **with** it rather than just reading from it. This is the same guidance the AI Widget Builder follows when it generates a widget.

**Decide before you build:** the widget's shape (card, list, form, control), its hierarchy (the one thing the eye lands on first), its single accent moment, and its empty/loading/error look — then compose. Specific decisions make a distinctive widget; leaving them to default makes a generic one.

- **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. Don't hardcode raw pixel values.
- **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
- **Style text by its ROLE, not by inventing a size and weight.** `theme.typography.roles` carries the workspace's type scale as six semantic tiers — `display`, `title`, `subtitle`, `body`, `caption`, `overline` — each already resolved to a `fontSize`, `fontWeight`, `fontStyle`, `lineHeight` (a MULTIPLE of the size), `letterSpacing`, `textTransform` and a `colorToken`. Pick the tier the text IS, rather than choosing `fontSize: 16, fontWeight: "600"` yourself: your widget's section title is `title`, each card or row title is `subtitle`, running text is `body`, timestamps and helper text are `caption`, a small kicker or column label is `overline`, and a headline stat is `display`. This is what stops one widget rendering a subtitle bold while the widget beside it renders one italic, and it lets the workspace re-tune every subtitle in the app from Theme Settings in one edit. **Two tokens need converting rather than copying** — `lineHeight` is a multiple (React Native wants absolute pixels, so the raw `1.15` gives you a one-pixel line box) and `colorToken` names a colour rather than being one:

  ```js
  const r = theme.typography.roles.subtitle;
  const titleStyle = {
    fontSize: r.fontSize,
    fontWeight: r.fontWeight,
    letterSpacing: r.letterSpacing,
    textTransform: r.textTransform,
    lineHeight: Math.round(r.fontSize * r.lineHeight),
    color: r.color || theme.colors[r.colorToken],
  };
  ```
- **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place). A role already names its face for you — `display` and `title` take `theme.typography.headingFontFamily`, the rest take `fontFamily` — so a workspace that pairs a display face with a body face gets that pairing inside your widget without you naming a family. `headingFontFamily` defaults to `fontFamily`, so an unpaired workspace looks identical.
- **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.lg` + `...theme.elevation.sm`. Give it the elevation **or** a `colors.border` hairline, not both — and prefer the elevation, because a hairline-only card reads as a wireframe. `theme.elevation` is a token table you spread into a style (`...theme.elevation.md`), covering `none / sm / md / lg / xl`; never hand-write `shadowOpacity` / `shadowRadius` / `boxShadow`. Use the status roles (`danger / success / warning / info`) for state.
- **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.
- **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.
- **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.
- **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget.
- **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
- **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.
- **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **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` below `isNarrowWidth`, 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.
- **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).
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead. The frame is your decision, never the picture's: size it for the role (a 40–56 square avatar, a 72–96 square row thumbnail, a `16 / 9` card cover, a 160–240 tall band) and let `resizeMode="cover"` crop the photo into it — photos arrive at every size and ratio, so one left to its own proportions breaks the layout. Keep `contain` for art whose whole subject must stay visible (a logo, a diagram), inside a fixed frame.
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.

**Honest ceilings:** the styling surface is React Native style objects, not full CSS. Gradients come from the `<Gradient>` primitive (not a CSS `linear-gradient` string), depth comes from `theme.elevation` (not arbitrary `box-shadow` stacks), and there are no custom CSS keyframe animations or `transition` strings (the hover/press lift comes built into `pressableLift` — never write your own), no `filter` / `backdrop-filter` / `clip-path` / `mask` / blend modes, and no opacity-faked tints (that's what `primarySoft` is for). Aim for clean, confident, professional polish within those bounds — lifted surfaces, generous corners, one accent moment.

## Managing app users from a widget

`useUsers()` and `useGroups()` let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. Two gates apply: the manifest must declare the scope (the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller lacks the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that as a "you do not have permission" message.

```js
import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";

export default function MemberManager() {
  const { users, loading, invite, deactivate, remove } = useUsers({ q: "" });
  const { groups, addMember } = useGroups();
  if (loading) return <Text>Loading…</Text>;
  return (
    <View>
      {users.map((u) => (
        <View key={u.id}>
          <Text>{u.name} — {u.is_active ? "active" : "inactive"}</Text>
          <Pressable onPress={() => deactivate(u.id)}><Text>Deactivate</Text></Pressable>
          <Pressable onPress={() => remove(u.id)}><Text>Remove</Text></Pressable>
        </View>
      ))}
      <Pressable
        onPress={async () => {
          try {
            await invite({ email: "a@b.com", name: "New User", group_ids: [groups[0]?.id].filter(Boolean) });
          } catch (err) {
            // err.code is one of FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY
          }
        }}
      >
        <Text>Invite</Text>
      </Pressable>
    </View>
  );
}
```

The matching manifest declares the scopes:

```js
{
  // ...
  // `remove(u.id)` above needs `users.delete:*` (SC-902) — the destructive
  // delete is gated separately from the edit-style `users.write:*`.
  requestedScopes: ["users.read:*", "users.write:*", "users.delete:*", "groups.read:*", "groups.write:*"],
}
```

The host rejects calls whose scope is not declared in the manifest (the SDK linter catches this statically too). Declaring a write scope is also a consent prompt the Studio admin sees at install time — the wider the scope set, the more careful the admin is about granting the install.

## Listing pending invitations from a widget

`useInvites(query?)` lists the workspace's invites and resends / revokes them:

```jsx
import { useInvites, View, Text, Pressable } from '@colixsystems/widget-sdk';

// Manifest: requestedScopes: ['users.read:*', 'users.write:*']
function PendingInvites() {
  const { invites, loading, error, resend, revoke } = useInvites({ status: 'pending' });
  if (loading) return null;
  if (error) return <Text>{error.message}</Text>;   // code === 'FORBIDDEN' when the capability is missing
  return (
    <View>
      {invites.map((i) => (
        <View key={i.id}>
          <Text>{i.email} · {i.status}</Text>
          <Pressable onPress={() => resend(i.id).catch(() => {})}><Text>Resend</Text></Pressable>
          <Pressable onPress={() => revoke(i.id).catch(() => {})}><Text>Revoke</Text></Pressable>
        </View>
      ))}
    </View>
  );
}
```

Rows are snake_case: `{ id, email, name, group_ids, status, expires_at, accepted_at,
revoked_at, created_at }`. Trust the server-computed `status` rather than comparing
`expires_at` against the device clock.

**The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
someone who is not a member yet, so the backend gates the entire invite surface —
listing included — on the `users.write` capability plus a signed `users.write:*`
scope. The `invites.read:*` / `invites.write:*` scope names mint but no route
enforces them, so declaring only those yields `FORBIDDEN`. Both mutations refetch
the list on success.
## Managing per-record permissions from a widget

`useRecordPermissions(tableId, recordId)` is the in-app surface for sharing a single record with another user or group. The chat widget uses it to invite members into a channel — the channel record's per-record grants ARE the membership list (messages inherit those grants). The hook also covers project-workspace, document-sharing, and team-roster widgets that grant access record-by-record.

The rows and bodies are snake_case verbatim. A row carries `user_id` OR `group_id` (both null = a public grant) plus the `can_read` / `can_write` / `can_delete` / `can_grant` flags; the hook reads `ctx.datastore.records(tableId).permissions(recordId)`.

```js
import { Text, View, Pressable, useRecordPermissions, PermissionError } from "@colixsystems/widget-sdk";

export default function ShareRecord({ tableId, recordId, partnerUserId }) {
  const { permissions, loading, grant, revoke } = useRecordPermissions(tableId, recordId);
  if (loading) return <Text>Loading members…</Text>;
  return (
    <View>
      {permissions.map((p) => (
        <View key={p.id}>
          <Text>{p.user_id || p.group_id || "public"} — {p.can_write ? "writer" : "reader"}</Text>
          <Pressable onPress={() => revoke(p.id)}><Text>Remove</Text></Pressable>
        </View>
      ))}
      <Pressable
        onPress={async () => {
          try {
            await grant({
              user_id: partnerUserId,
              can_read: true,
              can_write: true,
              can_grant: true,
            });
          } catch (err) {
            if (err instanceof PermissionError && err.code === "FORBIDDEN") {
              // The signed-in user lacks can_grant on this record.
            }
          }
        }}
      >
        <Text>Invite partner</Text>
      </Pressable>
    </View>
  );
}
```

The manifest declares the matching scope:

```js
{
  // ...
  requestedScopes: ["acl.write:records"],
}
```

The server-side gate is `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. A caller without `canGrant` receives `PermissionError { code: "FORBIDDEN" }`. The hook collapses to a stable no-op when `tableId` or `recordId` is null/empty — so a widget can render its picker first, then bind to the picked record without conditional hook tricks.

## Resolving bound columns, a stable query, and a write-permission floor

`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.

```js
import {
  Text,
  View,
  Pressable,
  useBoundColumns,
  useStableQuery,
  useDatastoreQuery,
  useCanWrite,
  useUser,
} from "@colixsystems/widget-sdk";

export default function OpenTasks({ tableId, titleField, statusField }) {
  // Resolves to the CURRENT column names even if the author's binding is
  // stale (a column renamed after install), instead of reading `undefined`.
  const { columns: bound, loading: schemaLoading } = useBoundColumns(
    tableId,
    { titleField: { dataType: "STRING" }, statusField: { dataType: "STRING" } },
    { titleField, statusField },
  );

  // A binding is undefined until the schema resolves, so hold the query back
  // rather than filtering on an undefined column name.
  const ready = Boolean(bound.statusField);
  // A stable query argument with no useMemo deps array to get wrong.
  const query = useStableQuery(() => ({
    filter: ready ? { [bound.statusField]: "eq:open" } : {},
    sort: { field: "created_at", dir: "desc" },
  }));
  const { data, loading } = useDatastoreQuery(ready ? tableId : null, query);

  const user = useUser();
  const { canWrite } = useCanWrite(tableId);

  if (schemaLoading || loading) return <Text>Loading…</Text>;
  return (
    <View>
      {data.map((row) => <Text key={row.id}>{row[bound.titleField]}</Text>)}
      {!user.id ? (
        <Text>Sign in to add a task</Text>
      ) : canWrite ? (
        <Pressable onPress={() => {/* … */}}><Text>Add task</Text></Pressable>
      ) : null /* signed in but not permitted — no control, not a disabled one */}
    </View>
  );
}
```

`useCanWrite` answers "is this table's ACL open to me" — it is a FLOOR, not the whole rule. A widget whose own logic is more specific ("only the assignee may edit this row") still hand-checks that in addition, typically with `options.recordId` passed to a second `useCanWrite` call or a plain `user.id === record[assigneeField]` comparison.

## Cross-platform widgets (single-file vs split)

Every widget runs in **both** the web Player and the exported native (Expo) app.
There are two ways to author one, and the **file set you ship decides
`supportedPlatforms`** — you don't hand-declare it, the platform derives it.

**1. Single file — `widget.jsx` (the default).** Works on web AND native. Pick
this when the widget needs only the SDK primitives + hooks, or only packages
that support both platforms (every `["web", "native"]` entry in
`CONTRACT.vettedImports` — `react-native`, `react-native-svg`, `date-fns`,
`react-native-paper`, `@shopify/flash-list`, `react-native-reanimated`,
`react-native-gesture-handler`, `expo-linear-gradient`, …). Most widgets land
here. Derives `supportedPlatforms: ["web", "native"]`.

**2. Split implementation — `widget.web.jsx` + `widget.native.jsx`.** Pick this
when the widget needs a package that only runs on ONE platform. Each file
targets its platform and imports the package that works there; shared logic
lives in a sibling `./helper.js` both files import. This is the **canonical
pattern** for graphics, media, and any platform-divergent library:

| Capability | `widget.native.jsx` | `widget.web.jsx` |
| ---------- | ------------------- | ---------------- |
| Maps | `react-native-maps` | `react-leaflet` / `leaflet` |
| Canvas / 2D-GPU drawing | `@shopify/react-native-skia` | `<canvas>` or `react-native-svg` |
| Video | `expo-video` | the browser `<video>` element |
| Audio | `expo-audio` | the browser `<audio>` element |
| Lottie animation | `lottie-react-native` | `lottie-react` |
| Embedded web content | `react-native-webview` | an `<iframe>` |

A few native-only packages (`@react-native-community/datetimepicker`,
`expo-clipboard`) are already wrapped by an SDK primitive/hook
(`<DateTimePicker>`, `useClipboard()`) that resolves the right implementation on
each platform — reach for those first and you stay single-file.

**The linter enforces honesty at publish.** A single-file widget that imports a
native-only package while claiming web (or vice-versa) fails the
`import-platform-mismatch` rule — move the import into the file that targets its
platform, or ship the split pair. So you can't accidentally publish a widget
that renders on one platform and blanks on the other.

## Linter

```sh
npx appstudio-widget lint path/to/widget.jsx
npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
```

Scans for banned patterns (`eval`, `new Function`, dynamic `import()`, direct imports of host stores, raw axios) and for the styling rules below. **Only error-severity findings change the exit code**; warnings print and exit 0.

```js
import {
  lintSource,
  lintStyleWiring,
  lintEditability,
} from "@colixsystems/widget-sdk/linter";
const report = lintSource(source, { manifest });
// Bundle-level — pass every file the widget ships:
const wiring = lintStyleWiring(manifest, { "widget.jsx": source });
const editability = lintEditability(manifest, { "widget.jsx": source });
```

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*.

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*.

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.

`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.

`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.

## Local dev loop (`appstudio-widget dev`)

Author a marketplace widget with live reload instead of the publish → submit →
review → install round-trip:

```sh
appstudio-widget dev path/to/widget.jsx --port 4400
```

This transpiles the entry (the **same** Sucrase JSX pass the publish path runs)
and serves it over `http://127.0.0.1:4400`:

| Route | Purpose |
| ----- | ------- |
| `GET /widget.mjs` | the transpiled entry — an ES module the Studio loads exactly like a published `widget.mjs` |
| `GET /manifest.json` | the manifest (from `--manifest`, else a sibling `manifest.json`); only consulted for a bare-component bundle |
| `GET /__dev/events` | Server-Sent-Events stream that emits `reload` on every source change |
| `GET /__dev/health` | `{ ok, manifestId }` |

Then, in the Studio Builder (dev mode only), open the **Dev widgets** panel in
the palette and paste `http://127.0.0.1:4400`. The widget loads through the
real runtime loader (same import-rewrite, host-shim, and export-shape gates a
published bundle hits) and hot-reloads onto the canvas on every save.

- **v1 serves a single-file entry.** Split-impl widgets (`widget.web.jsx` +
  `widget.native.jsx` with shared `./relative.jsx` helpers) need a real build
  step; the dev server reports relative imports rather than serving a module
  the browser can't resolve.
- `dev` lazily requires the optional `sucrase` dependency. In this monorepo it
  is already present; in a standalone widget project run `npm i -D sucrase`.
- Lint findings print to the terminal on each change but never block serving.

## Why no TypeScript dependency?

The package is plain ESM JavaScript with hand-written `.d.ts` ambient types. Consumers using TypeScript get full IntelliSense; consumers using JavaScript pay nothing.
