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.240.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.widget.assets | — (built by the host) | the files the widget package bundles: a map of file name → absolute URL, {} when it bundles nothing — what useBundledAsset() resolves | | 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, 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, photoUrl, roles, groupIds } | ctx.user (host-built context, camelCase — not a wire payload; id null when anonymous) — no scope. photoUrl is the user's own profile photo as an absolute URL, or null. | | CORE | useUserPhoto() | { url, canEdit, upload, remove, uploading, error } | ctx.user + the optional ctx.account — no scope. Changes the signed-in user's OWN profile photo. canEdit is false when nobody is signed in or the host cannot change it; upload / remove then reject. | | CORE | useAnonymousAccount() | { supported, canCreate, creating, error, create } | The optional ctx.account.anonymous — no scope. Offers an account with a generated login code and password and no email. create() opens the app's own dialog, which confirms, creates the account, shows the pair once and signs the visitor in; the widget never receives the pair and create() resolves { created: true }. supported is false where the workspace has not turned anonymous accounts on, on the Builder canvas, in a preview and for a badge counter; canCreate is false while someone is signed in. | | CORE | useNavigation() | { goTo, goToStartPage, scrollToSection, 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; goToStartPage() opens the signed-in user's own start page; scrollToSection(id) scrolls this page to a section picked with a sectionRef prop (openLink for a link of unknown shape; a known external URL can also use the Linking primitive) | | CORE | useFilePreview(file) | { uri } | No scope. A temporary address an <Image> can show for a file the person picked but has not uploaded yet (a photo in a composer). Native picks use their own uri; on the web the SDK creates and releases an object URL, which widgets may not do themselves. uri is null for no file. | | CORE | usePageBadge(count) | { supported } | ctx.navigation.setBadge — no scope. Puts count on the menu entry or tab of the page the widget is on (a chat's unread messages). The host sums the page's widgets and keeps the last count after the widget leaves the screen, until it reports again; 0 clears this widget's share. supported is false where the host draws no page badges (the Builder canvas). | | 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. A sibling's input reads the payload, and a page action the author binds to the event (Run action, Navigate) runs with it — fired from one control, only that control runs it. | | 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 a box of definite height: a widget in the section a fit-to-screen page stretches to the bottom of the screen, or one given a fixed slot height. Media-style widgets, and a chat or feed that scrolls its own content, switch to a flex: 1 / height: "100%" layout and scroll inside it; 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 | widthTier(width) | "narrow" \| "medium" \| "wide" | No context slice, no scope. Which of THREE layouts a measured width calls for: narrow below NARROW_WIDTH_PX (480), wide at or above WIDE_WIDTH_PX (960), medium between. widthTier(w) === "narrow" exactly when isNarrowWidth(w); an unmeasured width (0) is "wide". | | 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, languages, canSetLocale, setLocale } | ctx.i18n — no scope. t(key) resolves the widget-namespaced key (widget.<id>.<key>, declared in manifest.translations) first, then a predefined shared key (shared.<key>) when key is one of the standard strings (submit, cancel, save, loading, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance widget.<id>.<key> override still wins. languages lists the app's active languages ([{ code, name, is_base }]); setLocale(code) switches the whole app live — saved to a signed-in user's account, kept on the device for a visitor, null for the app default. Rejects with .code INVALID_LOCALE | UNSUPPORTED; canSetLocale is false where the host cannot switch (the Builder canvas). | | 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 | useLinkPreview(url) | { preview, loading, error, available } — preview is { url, title, description, site_name, image_url } \| null | ctx.linkPreviews.get — no scope. A link's title, description, site name and image, fetched by the platform (the device never contacts the linked site). A page with nothing to show resolves with null fields, not an error — render the plain link. A non-http(s) url makes no request. Memoized for up to an hour across widgets; concurrent calls for one URL share a request. error is a LinkPreviewError (VALIDATION | AUTH_REQUIRED | RATE_LIMITED | NETWORK | INTERNAL). available is false on a host with no link-preview client | | 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, total, loading, fetching, error, refetch } — loading only until the first result for the table; a changed query / refetch keeps data while fetching; total is the envelope's meta.total | records(table).list (unwraps { data, meta } to data: []) — datastore.read:* | | DATASTORE | useDatastoreFigure(table, { aggregate, column, filter }) (optional: options) | { value, text, truncated, loading, fetching, error, refetch } | records(table).list (a count reads the envelope total; a sum or average up to 1000 rows) — datastore.read:*. One count / sum / average for a <Stat>, computed exactly as the built-in Stat widget does. | | DATASTORE | useDatastoreRecord(table, id, options) (optional: options) | { data, loading, fetching, error, refetch } — a refetch of the same record keeps data while fetching; { includeCapabilities: true } adds data._capabilities | 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, cancel, reset, messages, sending, resuming, error, available } | ctx.agents — ai.invoke:*. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). Sending is IMPERATIVE: call send(text) from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. On mount the hook DOES reopen the caller's most recent thread with this agent, so a refresh or a navigation keeps the conversation instead of starting a blank one; that resume only READS and spends no credit. resuming is true while it runs — render a loading state, not an empty thread. messages is the running transcript, oldest first. You cannot set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and content is the only thing that reaches the wire. reset() drops the thread (a "new chat" affordance), and the new thread is the one a later mount resumes. Every send spends the workspace's AI credits and is rate-limited per caller AND per workspace. Rejects with AgentError; branch on .code — AI_QUOTA_EXCEEDED (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike RATE_LIMITED, so never offer "try again" for it, and AGENT_DISABLED (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. available is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries activity — what the agent DID with the user's data: the table it read, or the table and columns it CHANGED (never the values, and never a row the permissions refused). Render a write. A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. | | 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"). For a live list of the signed-in user's own rows pass { kind: "granted" }: every row they read through a grant to them or one of their groups, plus onAccessGained(record) / onAccessLost({ id }) as rows enter and leave that set. Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. | | DATASTORE | useTyping(tableId, recordId) | { typingUserIds, setTyping, status } — status "connecting" \| "live" \| "unavailable" | records(table).typing — datastore.read:<table>. Who else is typing in one record, over the same socket as useDatastoreSubscription. Call setTyping(true) on every keystroke (throttled to one signal per 3 s; false is sent after 5 s idle) and setTyping(false) on send, clear or blur. typingUserIds lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks typing reads { typingUserIds: [], status: "unavailable" }. Re-subscribes when either id changes. | | 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, asset, 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. | | FILES (ctx.widget) | useBundledAsset(fileName) | string \| null | ctx.widget.assets — no scope. The URL of an image or sound the widget package ships in its assets/ directory, or null when it has no file with that name. See Bundled images and sounds. | | 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, update } | directory.users.* — users.read:* plus the users.read (or users.write) capability, else FORBIDDEN (edits, incl. sendPasswordReset() and update(), 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?, q?, app_user_id?, limit?, offset? } with status ∈ pending \| accepted \| revoked \| expired \| open \| all (endpoint default all; open = pending or expired); q matches the invite's name or email, app_user_id returns the invites reserving that user id. | | 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: spaceType, folderId) | { openPdf, opening, canOpenPdf, exportToPdf, exporting, error, lastExported } | ctx.filestore.files.renderPdf + ctx.device.pdfViewer, ctx.filestore.files.exportPdf — files.write:*. openPdf(html, { fileName? }) renders the HTML to a PDF server-side and OPENS it (web: PDF tab; native: print / share sheet) without saving. exportToPdf(html, { fileName?, folderId? }) renders it 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. | | NOTIFICATIONS (ctx.notifications) | useRecordMutes(tableId) | { isMuted, loading, error, refetch } | ctx.notifications.mutes — no scope. Which records of one table the signed-in user has muted, in one request for a whole list; isMuted(recordId) turns false when a timed mute lapses, and a useRecordMute write shows at once. | | NOTIFICATIONS (ctx.notifications) | useRecordMute(tableId, recordId) | { muted, mutedUntil, loading, error, mute, unmute, refetch } | ctx.notifications.mutes — no scope. Mutes one record's notifications for the signed-in user, mute(until) with an ISO time, a Date, or null for until unmuted. Needs read access to the record (RECORD_NOT_FOUND otherwise). | | 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, specifically section 3.1.

Status

v0.240.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.240.0 (contract 1.194.0)

A datastoreTemplate can describe a whole system. A template may now declare up to 100 tables of up to 200 columns, up to 100 sample rows per table and up to 32 roles. Sample rows have one more bound: the whole template seeds at most 20000 cells, where each row counts the keys it sets and at least one, summed over every row of every table. A template past any limit is refused at publish with a message naming the limit and the count. Every template valid under 0.239.0 is still valid.

What's new in 0.239.0 (contract 1.193.0)

A widget can scroll its page to a section — useNavigation().scrollToSection(sectionId). A one-page site jumps from "See pricing" to the pricing section further down. Bind the target with a sectionRef property: the Studio renders a picker of the page's sections (its root sections and every layout container), and the value is the id you pass to scrollToSection.

import { useNavigation, Button } from "@colixsystems/widget-sdk";

export default function JumpButton({ label, target }) {
  const { scrollToSection } = useNavigation();
  return <Button title={label} onPress={() => scrollToSection(target)} />;
}
// propertySchema: { target: { type: "sectionRef", label: "Section" } }

The web Player and the exported app both scroll smoothly (instantly under reduced motion) and stop with the section just below a pinned site header. An unknown, deleted or hidden section does nothing, and so does the Studio canvas. Additive.

What's new in 0.238.0 (contract 1.192.0)

A standard upload field: <FileUpload>. Widgets wrapped the headless <FilePicker> round buttons they drew themselves, so every upload control looked different. <FileUpload> is one view bound to each host's own FilePicker: the app's button inside the input field (Design > Components > Buttons and Inputs), the chosen files listed with their size and a remove control, and the form controls' label, helper, error and disabled. A file that accept rules out is refused with an error. It never uploads itself: call upload(file) from useFilestoreUpload in onPick and pass uploading, which shows an indeterminate spinner and locks the field. With multiple, onPick receives an array of the new files; files + onFilesChange let you own the list. On the web a file can also be dropped onto the field; React Native has no file drop, so on a device the button, which opens the system document picker, is the control. <FilePicker> gains onFocus / onBlur, and FilePicker.isSupported is documented as true on both hosts.

const { upload, uploading } = useFilestoreUpload({ spaceType: "project" });
<FileUpload label="Attachment" accept="application/pdf,image/*" uploading={uploading} onPick={(file) => upload(file)} />

Lists of cards and row actions. <List variant="card"> turns every <ListRow> into its own card: the surface follows Design > Components > Cards and the inside follows Lists, so a list of cards matches the rest of the app without wrapping rows in <Card> by hand. Buttons that act on one record go in <ListRow actions>, which stay outside the row's own tap target. defaults on <List> / <ListRow> is a widget's own row look, used only where the theme's Lists settings are unset. Existing rows render unchanged.

Pick several with <Select multiple>. Select takes multiple with values / onValuesChange(values, options): the same field and option sheet, a tick box beside each option, and a Done button that hands you the new array in listed order (only when it changed). Closing the sheet by the backdrop, Escape or Android back keeps the previous choice. The trigger shows the picked labels as compact chips while they fit on its line, otherwise "N selected" (selectedCountText; doneText translates the button). On the web the list is multiselectable and Space or Enter toggles the focused option. For a handful of options always on show, use <ChipGroup selection="multiple"> instead.

<Select multiple label={t("tags", "Tags")} values={tags} onValuesChange={setTags}
  doneText={t("done", "Done")} selectedCountText={(n) => `${n} ${t("picked", "picked")}`}
  options={[{ value: "red", label: "Red" }, { value: "blue", label: "Blue" }]} />

What's new in 0.237.0 (contract 1.191.0)

Search boxes have a look of their own. A new search scope on CONTRACT.themeComponents (Design > Components > Search fields) restyles every <SearchField>: iconPosition ("start" | "end" | "none"), radius (0–48, so a pill), background, borderColor and height. Each token falls back to the input look (muted, Design > Components > Inputs) when unset, so with none set every search box looks exactly as before. <SearchField iconPosition> overrides the theme for one field; any TextField look prop you pass still wins over the theme. At the end, the clear control takes the icon's place while the field has text, so the two never overlap. Leave the look to the theme: do not pass your own radius, background, border or height, and never add a magnifier icon yourself. searchScopeDefaults(theme) on @colixsystems/widget-sdk/host gives the Studio the input look each unset token keeps.

Clearer section names. Design > Components now names the scopes for what they restyle: muted is Inputs, popover Dropdown menus, destructive Danger buttons, ring Button outline (a permanent border on the Button widget, not a focus style) and accent Labels. Only the labels changed; the scope keys are the same.

What's new in 0.236.0 (contract 1.190.0)

Host sign-in screen: the heading and subheading can be hidden. resolveLoginTextVisibility(login) answers whether the built-in sign-in card shows its heading and its subheading: each is false only when the author set login.hideHeading / login.hideSubheading to true, so a theme saved before this keeps both. A hidden subheading hides only the sign-in line; the "Create your account" and "Reset your password" step lines still show. It is kept apart from normaliseLoginDesign because hidden text is not an authored backdrop. Widgets are unaffected: this is the host surface (@colixsystems/widget-sdk/host), and the widget contract version does not change.

What's new in 0.235.0 (contract 1.190.0)

<DateTimePicker> is a themed field with its own calendar on the web. The web build rendered the browser's <input type="date">, whose popup cannot be styled: it ignored the workspace theme and looked out of place in every app. Both hosts now render one shared field — a calendar (or clock) icon, the value in the app user's language (1 okt. 2026, Oct 1, 2026, 2:30 PM) and a clear button. On the web it opens a calendar sheet in <Overlay>, painted from the theme: a month grid with the chosen day in colors.primary and today ringed, weeks starting on the locale's first day, a month and year jump for far dates, hour and minute columns for time and datetime, and a Today / Now button. The grid is keyboard-driven (arrows, Home/End, PageUp/PageDown, Space/Enter) and minimumDate / maximumDate disable what lies outside them. Native keeps the operating system's picker behind the same field. The value contract is unchanged; new optional props: placeholder, clearLabel, doneLabel, closeAccessibilityLabel and testID. formatDateTimeLabel from /host takes an optional locale and now returns that localised label.

What's new in 0.234.0 (contract 1.189.0)

The space around a bottom bar's tabs is the author's (host side only). resolveFooterTokens from @colixsystems/widget-sdk/host now also resolves padding, 0–12px with a default of 4: the bar is its 40px tabs plus that space above and below, so 48px by default (it was a fixed 56) and 40px at zero. A floating bar's itemRadius follows the curve one padding in. FOOTER_SIZES.padding carries the bound. Nothing a widget imports changes.

What's new in 0.233.0 (contract 1.188.0)

A floating bottom navigation bar is frosted glass by default (host side only). resolveFooterTokens from @colixsystems/widget-sdk/host now also resolves glass, true for a floating bar unless the theme's footer.glass is false (a docked bar is never frosted, as nothing scrolls behind it), and glassTint, the 8-digit hex surface laid over the blur: the bar's backgroundColor at 72%, or its own alpha when it already has one. Nothing a widget imports changes.

What's new in 0.232.0 (contract 1.187.0)

The app's bottom navigation bar floats by default (host side only). resolveFooterTokens from @colixsystems/widget-sdk/host now also resolves the bar's shape: shape is "floating" (a rounded bar inset from the screen's sides, with the page visible around and beneath it) unless the theme's footer.shape is "docked" (the edge-to-edge strip). radius (0–40, default 28), sideMargin (0–48, default 12) and bottomSpacing (0–32, default 8) are px, clamped onto their ranges, and itemRadius is a tab's corner radius inside that bar. FOOTER_SIZES exports each measurement's bound and default, clampFooterSize(value, key) clamps one onto it, and FOOTER_HOME_LINE_CLEARANCE_PX (14) is how much of the iOS bottom inset a bar keeps clear. Nothing a widget imports changes.

What's new in 0.231.0 (contract 1.186.0)

A whole-screen widget can run to the bottom edge of a phone — useFitScreenInsets(). In the exported app, a Fit to screen section whose widget scrolls itself (prefersFitToScreen: true) and calls the new useFitScreenInsets() runs all the way to the screen's bottom edge, under the iPhone home indicator or the Android navigation bar, so your widget's background fills the screen with no strip of page colour below it. Keep your bottom-anchored controls clear of that area with the new useFitScreenInsets(): it returns { top, right, bottom, left } in px, the parts of the device's safe area your surface runs under. Pad your composer, a pinned bar or the end of a list by bottom, with your background behind the padding.

import * as sdk from "@colixsystems/widget-sdk";
import { View, TextInput } from "@colixsystems/widget-sdk";

// A host older than 0.231.0 has no useFitScreenInsets.
const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
const useFitScreenInsets =
  typeof sdk.useFitScreenInsets === "function" ? sdk.useFitScreenInsets : () => NO_INSETS;

function ComposerBar({ theme }) {
  const { bottom } = useFitScreenInsets();
  return (
    <View style={{ backgroundColor: theme.colors.surface, padding: 10, paddingBottom: 10 + bottom }}>
      <TextInput placeholder="Message" />
    </View>
  );
}

The host decides the value, so you never subtract anything yourself: it is 0 while the keyboard is up (the keyboard covers that area, and your composer sits right on it), when the app's bottom bar already holds the inset, on web (the browser keeps the page inside its own safe area), and for every widget that is not a self-scrolling fit surface ending the page. Calling the hook is the opt-in: a widget that never calls it, or does not scroll itself, keeps the inset below its section, as before, so an existing widget's controls never move under the indicator. Nor does a widget with something drawn below it inside the section, such as a bottom tab bar. Use this hook rather than useSafeAreaInsets() from react-native-safe-area-context: the device's raw inset would also pad a composer that sits on a bottom bar or on the keyboard.

Additive: a host without the fitScreenFoot hint reads zeros, so a widget that pads by it looks exactly as before there.

What's new in 0.230.0 (contract 1.185.0)

A widget can switch the app's language — useI18n().setLocale(code). useI18n() now also returns languages, the app's active languages as [{ code, name, is_base }], and setLocale(code), which switches the whole app to that language at once, with no reload. A signed-in user's choice is saved to their account, so it follows them to other devices; a visitor who is not signed in keeps it on this device. setLocale(null) returns to the app's default language. It rejects with an Error whose .code is INVALID_LOCALE (not one of languages) or UNSUPPORTED (the host cannot switch — the Builder canvas). Gate your controls on canSetLocale and render them disabled when it is false. The same on the web Player and the exported app.

import { useI18n, Pressable, Text } from "@colixsystems/widget-sdk";

export default function Languages() {
  const { languages, locale, setLocale, canSetLocale } = useI18n();
  return languages.map((l) => (
    <Pressable
      key={l.code}
      disabled={!canSetLocale}
      onPress={() => setLocale(l.code).catch(() => { /* show "try again" */ })}
    >
      <Text>{l.code === locale ? `✓ ${l.name}` : l.name}</Text>
    </Pressable>
  ));
}

AppStudio also ships a ready-made Language picker built-in widget on the same hook.

What's new in 0.229.0 (contract unchanged)

A field's focus ring and a card's border stay inside the widget. The hosts clip a widget at its own edge, so anything painted outside it is cut off.

  • <TextField> and the <Select> trigger draw focus inside their own box. A focused field shows a 2px ring in the accent: its border plus an inset 1px boxShadow, in the error colour while the field is invalid. On the web it turns the browser's outer focus outline off, because that outline sat outside the field and was cut off wherever the field touched the widget's edge. <SearchField> and the built-in form fields use the same field, so they get it too. The text field also takes minWidth: 0, so it shrinks in a row instead of pushing its neighbour past the edge. A caller's style still comes last.
  • New linter rule full-width-margin-overflow (severity warning, non-blocking). It flags a style object that sets width: "100%" together with a non-zero side margin (margin, marginHorizontal, marginLeft/Right/Start/End, marginInline/InlineStart/InlineEnd; a zero, undefined or null value is not counted). The percentage is already the parent's whole width, so the margin pushes the box past its parent and the host cuts off its right edge, border included. Drop the width, because a View already stretches, and inset with the parent's padding. The scan reads one style literal, not the tree, so it stays a warning. Comments are not scanned.

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

What's new in 0.228.0 (contract 1.184.0)

A widget can ask for the whole screen — manifest prefersFitToScreen and useFitScreen(). A page section can be set to "Fit to screen": it runs edge to edge across the app's content area (no page margins, no width cap) and takes the rest of the screen below the sections above it, on every screen size, in the Player and in the exported app. Set prefersFitToScreen: true in your manifest when your widget is built for that — it scrolls its own content inside the height it is given and keeps its controls in view, like a messenger, a map or a full-height board. In a fit section the host then gives your widget a box exactly the height left; a widget without the field grows with its content there, and the page scrolls. A maker who drops your widget at the top level of a page then gets the setting turned on for that section (they can switch it off, and it stays off), and the AppStudio app-builder fits the section it plans your widget into. Leave it false or omit it otherwise.

When your widget is that section's surface, useFill() is true and so is the new useFitScreen(). Drop your own frame then — border, rounded corners, shadow — so the widget meets the screen's edges cleanly; the host has already removed your outer margin.

import { useFill, useFitScreen, View } from "@colixsystems/widget-sdk";

export default function Board() {
  const fill = useFill();
  const edgeToEdge = useFitScreen();
  return (
    <View style={[fill ? { flex: 1, minHeight: 0 } : { height: 480 }, !edgeToEdge && { borderWidth: 1, borderRadius: 12 }]}>
      {/* ... */}
    </View>
  );
}

Additive: existing manifests omit the field and read false, and a host that never fits a widget leaves both hooks false. A published widget that must also run in an app whose Player is older than 0.228.0 reads the hook off a namespace import and treats a missing one as false (import * as sdk from "@colixsystems/widget-sdk"; const useFitScreen = typeof sdk.useFitScreen === "function" ? sdk.useFitScreen : () => false;, at module level): a named import of an export the host does not have stops the widget loading.

What's new in 0.227.0 (contract 1.183.0)

Columns the server keeps, and rules every client meets. A datastoreTemplate can now say what a widget used to write itself and the server could not check:

  • latestVia: "<relation column on the target that points back>" on a ONE_TO_ONE / ONE_TO_MANY relation (with onDelete left at SET_NULL; the relation back must have inheritAcl: true and immutable: true) keeps it pointing at the newest related row — the newest message of a conversation.
  • copyOf: { via, column } keeps a column equal to column on the row that latest-row column points at — the last message's text, time and author on the conversation row. Text copied into a STRING column is cut to 140 characters; a copy of an encrypted column must be encrypted too, and a copy cannot inheritAcl.

Both are written only by the server, in the same write as the related row, so a list reads who wrote last, and when, without trusting whoever wrote the parent row. A client value for them is ignored; read them back.

A table may also declare constraints, checked on every write and refused as CONSTRAINT_VIOLATION: unique (now with unlessRelated, waived for rows whose related row has a yes/no column set), no-overlap, related-open (no row is added, changed or removed while the related row's column is filled — votes on a closed poll) and related-index (a whole number that must be a position in the related row's list — a poll option). Additive.

What's new in 0.226.0 (contract 1.182.0)

Rank by a column — useDatastoreAggregate(tableId, { groupBy, order, limit }). Group a table by an ordinary column (text, number, yes/no, date or user) and get groups: [{ group, count, sum }], counted on the server over every row the viewer can read, so it stays exact past the 1000-row page a list read returns. A user column's group is the user id, so a ranking is one call:

const { groups } = useDatastoreAggregate(props.tableId, {
  groupBy: "Reporter",
  order: "count:desc",
  limit: 10,
  filter: { Reporter: "nempty:" },
});
// groups: [{ group: "<user id>", count: 42, sum: 0 }, …]

order is count, sum or group, then :asc or :desc (ascending when left out; by value when there is no order); limit is 1–1000. Rows with no value form one Uncategorized group. Encrypted, array, file and relation columns cannot be grouped.

What's new in 0.225.0 (contract 1.181.0)

Chart a table over time — useDatastoreAggregate(tableId, spec). Counts rows per UTC day or ISO week (Monday) on the server, over every row the viewer may read, so the series stays exact past the 1000-row page a list read returns. Every bucket of the range comes back, empty ones as count: 0, so a chart has no gaps. groupBy is "created_at" (new rows, the default) or "updated_at" (changed rows); any other column is refused with a VALIDATION error. sumColumn adds up a plain number column per bucket. At most 31 buckets.

const { buckets, loading } = useDatastoreAggregate(props.tableId, { bucket: "week", from: twelveWeeksAgo });
// buckets: [{ date: "2026-09-28", count: 14, sum: 0 }, …]

What's new in 0.224.0 (contract 1.180.0)

A logged-out visitor can attach files to the row they just created. On a table that lets everyone create rows, the create → upload(file, { recordId: row.id }) → update(row.id, { File: stored.id }) flow now works without signing in, for a few minutes after the create. The host carries a grant for that one row from the create to its upload and update, so widget code does not change. The visitor cannot edit the row's other fields, read the file back on a private table, attach more than ten files, or attach to any other row; editing an existing row's files still needs a signed-in user. Additive.

What's new in 0.223.0 (contract 1.179.0)

A widget can send the user back to their start page — useNavigation().goToStartPage(). An app opens on a start page that can differ per user, because page access is restricted per group. A "Back to start" or "Home" button should take each user to their own start page rather than to one hard-coded page they may not be allowed to open.

import { useNavigation, Button } from "@colixsystems/widget-sdk";

export default function BackToStart() {
  const { goToStartPage } = useNavigation();
  return <Button title="Back to the start page" onPress={() => goToStartPage()} />;
}

The web Player and the exported app both go to the page the app opens on for the signed-in user. It takes no arguments and carries no params. It is a no-op where there is no live router (the Studio canvas, a badge counter). Additive.

What's new in 0.222.0 (contract 1.178.0)

A card can carry its picture as its background — <Card image>. A small image with its title in a box underneath reads as a caption under a thumbnail. The same content with the picture filling the card and the title on it reads as designed, and until now a widget could only build that by hand: an absolutely positioned Image, its own translucent overlay, hand-picked light text. <Card> now does it.

<Card
  as={Pressable}
  onPress={open}
  image={urls ? urls.card : null}
  scrim="bottom"
  style={{ aspectRatio: 1, justifyContent: "flex-end" }}
>
  {({ colors }) => <Text style={{ color: colors.onSurface }}>{record.name}</Text>}
</Card>
  • image takes a URL or { uri }. The picture fills the card to every edge, inside the card's own corner radius; the card's padding insets the content, not the picture.
  • scrim names the edge the text sits on: "bottom" (default), "top", "left", "right", "full" for centred text, or "none". It is a light gradient that is clear over most of the picture.
  • imagePosition ("center" default, "top", "bottom", "left", "right") keeps that part of the picture in frame when the crop is tight.
  • Children may be a function. It receives { colors, onImage }: the theme colours as they read where the content sits. Over a picture colors.onSurface and colors.onSurfaceMuted are light; on a card with no image they are the theme's own. A <Button> inside the card reads its backdrop without help.
  • No image, no change. image={null} renders the ordinary card, so a record with an empty file cell needs no branch.
  • A photo card is a View or a Pressable. Put a scroller inside it, never as it (as={ScrollView}): the picture would scroll away with the content.
  • Give it a size. Nothing picture-shaped sizes a photo card, so style carries an aspectRatio or a minHeight. The keys that arrange content (justifyContent, alignItems, gap, flexDirection) apply to the content laid over the picture, also under as={Pressable}.

Both hosts paint it: the web Player with a CSS background, the exported app with expo-image, which every export already bundles. Additive: a card given no image renders exactly as before.

What's new in 0.221.1 (contract 1.177.1)

useFileSignature: one failed poll no longer outlives the next answer. error is the last call that failed, and a refresh() or verify() that succeeds now clears it. While status is 'pending' an error means a poll failed: keep polling and keep showing the order. Fix only; nothing was added or removed.

What's new in 0.221.0 (contract 1.177.0)

A row's file goes in the row's record space, and upload() names the record per call. A file a widget keeps in a FILE column belongs in that row's record space: exactly the people who may read the row may read the file, so a private table's attachments stay private, and a table that lets everyone read still serves them to logged-out visitors. A public file, by contrast, is readable by anyone holding its link whatever the table's permissions say. A row must exist before a file can be attached to it, and a create form only learns the new row's id inside its submit handler, after the hook rendered. So upload(file, { recordId }) now takes the record per call, the way it already took folderId:

const { create, update } = useDatastoreMutation(tableId);
const { upload } = useFilestoreUpload({ spaceType: "record" });

const row = await create({ Title: title });            // without the file
const stored = await upload(file, { recordId: row.id }); // into that row's space
await update(row.id, { Receipt: stored.id });

A per-call recordId wins over the hook's; outside a record space it is ignored. A record upload that names no record now rejects with useFilestoreUpload: a record space needs a recordId instead of the misleading Sign in to upload. A logged-out visitor cannot upload into a record space, so ask them to sign in to attach a file. useCamera() and useImageEditor() now point at useFilestoreUpload for the upload: their descriptions named ctx.assets.upload, which widget code cannot reach. Additive.

What's new in 0.220.0 (contract 1.176.0)

An app can let a visitor continue without an email — useAnonymousAccount(). Some apps must let a person act without identifying themselves and still come back later: someone files a report and returns to follow it. The new hook lets a widget offer an account that has no email address. The server generates a login code and a password, and the visitor signs in again later with that pair on the app's sign-in screen ("Sign in with a login code").

import { useAnonymousAccount, useUser, Button, Text, View } from "@colixsystems/widget-sdk";

export default function ContinueWithoutEmail() {
  const user = useUser();
  const { supported, canCreate, creating, error, create } = useAnonymousAccount();

  if (!supported) {
    return <Text>Continuing without an email is not available here.</Text>;
  }
  if (user.id) {
    return <Text>You are signed in.</Text>;
  }
  if (!canCreate) return null;

  return (
    <View>
      <Button
        title="Continue without an email"
        disabled={creating}
        onPress={() => create().catch(() => {})}
      />
      {error && error.code !== "CANCELLED" ? (
        <Text>Could not create the account. Try again.</Text>
      ) : null}
    </View>
  );
}
  • The widget only asks; the app does the rest. create() takes no arguments and opens the app's own dialog. It asks the visitor to confirm, shows the captcha notice when a captcha will run, creates the account, shows the login code and password once with the warning that a lost password cannot be recovered, and signs the visitor in when they confirm they saved them (or opens the sign-in screen's two-factor step when the workspace requires one). Do not build a confirmation, a captcha notice or a credentials screen of your own.
  • The widget never receives the login code or the password. create() resolves { created: true } and nothing else. Widgets on a page share one context, so a credential that cannot be reset is kept out of it altogether. This is a contract boundary, not a sandbox: the SDK never hands the pair to a widget, but every widget on a page still runs inside the app.
  • Call create() from a user action. A button's onPress, never on mount or in an effect. Nothing is created until the visitor confirms in the dialog.
  • Failures carry a code. create() rejects with an Error whose code is the server's refusal (ANONYMOUS_ACCOUNT_LIMIT_REACHED, RATE_LIMITED, CAPTCHA_FAILED, …), CAPTCHA_UNAVAILABLE when the captcha could not run (there is no fallback), ALREADY_SIGNED_IN, CANCELLED when the visitor closes the dialog, or UNSUPPORTED. The dialog shows a failure itself and lets the visitor retry, so a rejection means they gave up; treat CANCELLED as no error. error holds the last one.
  • supported and canCreate. supported is false where the workspace has not turned anonymous accounts on, on the Builder canvas, in a preview and for a badge counter: render a visible "not available" state. canCreate is false while someone is signed in. The hook never throws at render and needs no scope.
  • What the account can do. It is not treated as a signed-in member: it gets only what the workspace grants to everyone or to the group chosen for these accounts, plus the records it owns. useUser().email is null for it.

A host backs the hook with the new optional WidgetContext.account.anonymous member, { canCreate, create() }. account's photo actions are now optional members, so a host may publish account with either or both. Additive.

What's new in 0.219.1

useFill() says when it is true today. It described a page-grid tile with a "Fill tile height" choice, a layout that no longer exists. A host now fills a widget in two cases: the widget sits in the section a fit-to-screen page stretches to the bottom of the screen, or the widget has a fixed slot height. A chat or feed that scrolls its own content is one of the widgets that should honour it. A documentation correction; CONTRACT.version is unchanged.

What's new in 0.219.0 (contract 1.175.0)

Signing rounds — useSigningRounds(fileIds) and useSigningRound(fileId). A signing round is how a PDF gets signed by a fixed list of people. Each signer gets a place on the signature page, in the order they were listed; they may sign in any order. Someone who manages the file's folder prepares the document for the signers they pick, looks at the prepared document, and confirms it; from then on each signer signs once with BankID through useFileSignature(), and the round is complete when the last one has signed. While a round is being prepared or signed the file cannot be changed, and once it is complete its content cannot be replaced.

| Hook | Returns | Use it for | | --- | --- | --- | | useSigningRounds(fileIds) | { byFileId, roundsEnabled, loading, error, refresh } | A file list: what to show beside each file, in one request for the whole list. | | useSigningRound(fileId) | { round, loading, error, refresh, prepare, preparedFile, confirm, cancel, busy } | One file: its round, and starting, confirming or cancelling it. |

  • byFileId[fileId] is { file_id, can_manage, can_prepare, round, locked }. A file the user cannot see has no entry. can_manage says the user manages the file's folder; can_prepare says a round can be offered for it (a shared PDF with no round yet — preparing can still be refused once the document is read). locked is "signing_round" while the file cannot be changed and "signed" once its content cannot be replaced.
  • Who sees what. round is { id, status, signer_count, signed_count, my_place, can_sign }. Someone who manages the folder gets it in every state (DRAFT, OPEN, COMPLETED), with signed_count. A signer gets it once it is confirmed, with their own place in my_place ({ position, signed, signed_at }) and can_sign while the round is open and their place is unsigned; signed_count is null for them, because a signer does not learn how many of the others have signed. Anyone else gets round: null and learns only locked.
  • A workspace may not have signing rounds turned on. roundsEnabled is then false (it is null until the first answer): offer no way to start a round. If prepare() is called anyway it is refused with code `SIGNING_ROUNDS_NO