npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@colixsystems/widget-sdk

v0.103.0

Published

Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.

Readme

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

| Group | Hook (signature) | Returns | Reads / scope | | ----- | ---------------- | ------- | ------------- | | CORE | useTheme() | { colors, elevation, spacing, spacingScale, radii, typography, components, widgetStyles } | ctx.workspace.theme — no scope. elevation is the shared depth scale (none / sm / md / lg / xl) you spread into a style; colors includes the accent's quiet tiers (primarySoft / onPrimarySoft / primaryStrong). components is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your props.style, so read useWidgetStyle() and ignore this slice. | | 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 | 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?) | { latitude, longitude, accuracy, loading, error, getCurrentPosition } | 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). | | CORE | useSpeechToText(options?) | { transcript, partial, listening, supported, error, start, stop, abort, reset } | ctx.device.speech — no scope. Dictation with the device's on-device recogniser: no audio is uploaded and no AI credit is spent. Capture is IMPERATIVE: call start() from a user gesture (a tap), never on mount. transcript accumulates finalised speech, partial holds the uncommitted guess (needs options.interimResults); stop() keeps it, abort() discards it. Rejects with SpeechToTextError (.code in PERMISSION_DENIED \| NO_SPEECH \| LANGUAGE_UNSUPPORTED \| NETWORK \| ABORTED \| UNSUPPORTED \| INTERNAL). Gate your mic button on supported — Firefox ships no SpeechRecognition. Identical on web (SpeechRecognition) and the Expo export (expo-speech-recognition). | | 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?) | { data, loading, error, refetch } | records(table).list (unwraps { data, meta } to data: []) — datastore.read:* | | DATASTORE | useDatastoreRecord(table, id) | { data, loading, error, refetch } | records(table).getdatastore.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. | | DATASTORE | useDatastoreMutation(table) | { create, update, delete } | records(table).{ create, update (PATCH), delete }datastore.write:* | | 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?) | { canWrite, loading, error, refetch } | myPermissions(tableId, { recordId? }) — scope datastore.read:<table>. A FLOOR, not a full replacement for domain-specific write rules: answers "is this caller signed in AND permitted", reading the same table-ACL answer the write endpoint enforces. Pass { recordId } for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with useUser() to also tell "not signed in" apart from "signed in but forbidden" — both resolve canWrite: false here. Falsy tableId, or a host that hasn't injected myPermissions (an older host), collapses to { canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. | | FILES (ctx.assets) | useAsset(id) | { url, file, loading, error, refetch } | ctx.assets.get — no scope | | FILES | useAssetsByTag(tag, { type? }) | { assets, loading, error, refetch } | ctx.assets.list (unwraps { data, meta } to assets) — no scope. type defaults to "image"; pass "all" / "audio" / "video" / "document" to widen. Falsy tag collapses to assets: [] without a round-trip. | | DIRECTORY (ctx.directory) | useDirectory(query?) | { users, loading, error, refetch } | directory.users.listdirectory.read:users | | DIRECTORY | useUsers(query?) | { users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset } | directory.users.*users.read:* (edits, incl. sendPasswordReset(), also users.write:*; remove() also users.delete:*) | | DIRECTORY | useGroups(query?) | { groups, loading, error, refetch, create, remove, addMember, removeMember } | directory.groups.*groups.read:* (mutations also groups.write:*) | | DIRECTORY | useInvites(query?) | { invites, loading, error, refetch, resend, revoke } | directory.invites.*users.write:* + the SystemAcl users.write capability (the whole invite surface, list included). query is { status?, limit?, offset? } with statuspending \| 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? }) | { exportToPdf, exporting, error, lastExported } | ctx.filestore.files.exportPdffiles.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.sendnotifications.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? }) | { 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, specifically section 3.1.

Status

v0.103.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.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.version1.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.version1.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.version1.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.version1.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:

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.version1.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:

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.version1.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.version1.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 ituseTheme()'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.version1.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:

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.version1.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.version1.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.version1.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.version1.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 labels and ui.groups — 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. 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.version1.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.version1.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.version1.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.version1.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.version1.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.version1.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.version1.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). `C