// Hand-written ambient types for @colixsystems/widget-sdk. // The package itself is plain ESM JavaScript; this file is shipped through the // `types` field for IDE IntelliSense without forcing consumers to install // TypeScript. Keep in sync with src/index.js when adding exports. import type { ReactNode, JSX } from "react"; export type WidgetCategory = | "input" | "display" | "layout" | "data" | "media" | "communication" | "administration" | "custom"; export type WidgetScope = string; // e.g. "datastore.read:orders" export type WidgetPropertyType = | "string" | "number" | "boolean" | "color" | "icon" | "image" | "select" | "multiselect" | "tableRef" | "columnRef" | "recordBinding" // REQ-WDG-VALUEREF: composite "single value from the datastore" picker. // Persisted value is `{ tableId, recordId, column }`; the bound widget // resolves the one cell. | "valueRef" // REQ-USERMGMT M4 / §4.8: Group picker that emits a bare // AppUserGroup UUID into the page JSON. | "groupRef" // REQ-WDG-PAGEREF: page picker that emits a bare AppPage UUID; a widget // reads it and navigates via useNavigation().goTo(pageId, params). | "pageRef" // REQ-WDG-RICHTEXT: rich-text (HTML string) edited via a Tiptap editor. | "richText" // REQ-WDG-ASSET: file/asset picker → bare File UUID (Asset Manager picker). | "asset" // REQ-WDG-ASSET: ordered multi-file picker → Array. | "assetList" // REQ-WDG-FOLDERREF: Filestore folder picker → folder UUID (or null = root). | "folderRef" // REQ-WBLT-03 / #139: per-field repeater that composes a form from columns // of a sibling tableRef (default `tableId`, override via `ui.tableProp`). // Each row: { id, columnId, kind: "auto"|"singleChoice"|"multiChoice", // label, required, optionsSource, inlineOptions, optionsTableId, // optionsValueColumn, optionsLabelColumn }. | "fieldList" // REQ-LAY-16 (sc-5890): corner-radius picker. Value is // `number | { topLeft, topRight, bottomRight, bottomLeft }` — a scalar rounds // all four corners, the object rounds each independently. | "cornerRadius" | "expression" | "eventBinding" | "object" | "array" // REQ-WBLT-03 (Tab Layout migration): multi-condition record filter // builder. Persisted value is `Array<{ column, operator, value, valueMode }>` // matching the ?filter[col]=op:value contract; columns resolve from a // sibling tableRef (defaults to the form-root `tableId`, override via // `ui.tableProp`). `valueMode` is `literal` (the default), `relativeDate` // (ordering operators on a DATE column — `value` is "N days ago"), or the // sc-6282 actor-scoped pair `currentUser` (USER column) / `currentUserGroup` // (USER_GROUP column), which drop `value` and let the SERVER scope the // condition to the signed-in app-user. | "filterList"; export interface WidgetPropertyDef { type: WidgetPropertyType; label: string; description?: string; default?: unknown; required?: boolean; enum?: Array<{ value: unknown; label: string }>; items?: WidgetPropertyDef; properties?: Record; // sc-1807 / sc-4164 — the value the widget falls back to when this field is // unset, as a dotted path into the resolved widget theme // (`"typography.sizes.lg"`, `"radii.md"`, `"colors.onSurface"`). The Studio // shows it as greyed placeholder text so the author can see what they are // adjusting from; it resolves against the WORKSPACE's theme, so the hint stays // truthful after a rebrand. DISPLAY-ONLY — never written into props.style, so // a styleSchema field keeps its only-when-set contract. Prefer this over a // literal `default` whenever the fallback is a theme token. themeDefault?: string; ui?: { widget?: "textarea" | "slider" | "code"; group?: string; order?: number; // REQ-WBLT-03: when the named sibling field changes, reset this // property to its schema default. Used to wipe stale columnRef / // filterList bindings nested inside array when the form-root // tableId switches. resetOnFieldChange?: string; // REQ-WBLT-03: per-instance dynamic default (can't be expressed as // a static `default`). Today: `"tabId"` → seed a unique stable id // for newly added Tab Layout tabs. defaultFactory?: "tabId"; }; validation?: { min?: number; max?: number; pattern?: string }; } export type WidgetPropertySchema = Record; /** * REQ-WDG-VALUEREF: the persisted value of a `valueRef` property. Each * field is optional while the author is mid-pick; a widget treats any * missing piece as "no value". */ export interface ValueRefBinding { tableId?: string; recordId?: string; column?: string; /** * How the record is chosen. "static" (default, may be omitted) pins * `recordId`; "latest" resolves the most recently created row live and * ignores `recordId`. */ mode?: "static" | "latest"; } export interface WidgetEventDescriptor { name: string; description?: string; payloadSchema?: WidgetPropertySchema; } /** * A value the widget CONSUMES from a sibling widget on the same page. The page * author wires each input to one sibling event; the widget reads the latest * published payload with `useWidgetInput(name)`. */ export interface WidgetInputDescriptor { name: string; description?: string; schema?: WidgetPropertySchema; } /** * Optional datastore template a widget can ship in its manifest. When the * tenant installs the widget, every declared table is created in their * workspace alongside the WidgetInstallation row, named * `_` (auto-suffixed `_2`/`_3`/... on name * collisions). The tables persist when the widget is uninstalled — the * tenant may have authored records into them. * * The shape mirrors the built-in template registry on the backend, with * two limits a third-party widget must satisfy (enforced at submission * time by the static analyzer): * * - At most 8 tables per widget. Templates exist to seed schema, not * to be a full database design tool. * - At most 24 columns per table. Same reason. * * RELATION columns reference siblings within the same template via * `targetSuffix` — that suffix MUST belong to a table declared earlier * in the array. There is intentionally no way to reference a table * outside the widget's own template. */ export interface WidgetDatastoreTemplateColumn { name: string; dataType: | "STRING" | "TEXT" | "NUMBER" | "FLOAT" | "BOOL" | "DATE" | "FILE" | "STRING_ARRAY" | "INT_ARRAY" | "RELATION" | "USER" | "USER_GROUP"; required?: boolean; /** For RELATION columns only — points at a sibling table's `suffix`. */ targetSuffix?: string; /** For RELATION columns only. */ relationType?: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY"; /** REQ-ACL-RELINHERIT: opt this RELATION column into row-level ACL inheritance. */ inheritAcl?: boolean; /** * REQ-DDL-ENCRYPT: store this column's values encrypted at rest (AES-256-GCM * under a per-workspace subkey). The widget still reads and writes plaintext * — the platform encrypts on write and decrypts for end users — but studio * users see `🔒` instead of the value, and the column cannot be searched, * filtered or sorted on. Not valid on `RELATION` (its value is a foreign key * the backend must resolve); publishing one is rejected. */ encrypted?: boolean; } export interface WidgetDatastoreTemplateTable { /** Stable identifier within the template; appears in the created table name. */ suffix: string; /** REQ-ACL-05: when true, the row creator gets full RWD+G on the new record. */ grantCreatorPermissions?: boolean; /** * REQ-TEMPLATES-ACL: optional table-level public grant. Omitted means * the table starts purely per-record (record-level grants are the only * access path). canRead opens reads to everyone (including anonymous); * canWrite only lets authenticated APP_USERs insert; canDelete remains * studio-owner only regardless. */ publicGrant?: { canRead?: boolean; canWrite?: boolean; canDelete?: boolean }; /** * sc-7530: group-scoped grants, named symbolically. Each entry's `role` must * be a `key` declared on the template's `roles`. Unlike `publicGrant` — which * can only reach the two synthetic principals (`everyone` / `authenticated`) * — these express "this audience gets these verbs" and are bound to a real * group by the workspace that installs the widget. */ roleGrants?: WidgetDatastoreTemplateRoleGrant[]; columns: WidgetDatastoreTemplateColumn[]; /** * Optional sample rows seeded into the table at install time so the widget * renders with real data instead of an empty state. Each key is a column * `name`; only non-relation/file/user columns are seedable (RELATION, FILE, * USER, USER_GROUP are rejected). `null` skips that cell. Max 25 rows. */ rows?: Array< Record >; } /** * sc-7530: a role the template's `roleGrants` name. Declared once per template * and referenced by every table, so several widgets over several tables ask the * installing workspace ONE question per audience rather than one per grant. * * Symbolic on purpose. A group id is tenant-local, so it could only ever be * wrong in the workspace that installs the widget — the installer binds each * role to one of their OWN groups (or has one created) at install time. */ export interface WidgetDatastoreTemplateRole { /** Stable within the template; matches /^[a-z][a-z0-9-]*$/. */ key: string; /** What the installing workspace sees in the binding step. */ label: string; /** Why the role exists — shown under the label. */ description?: string; } /** sc-7530: the verbs one role holds over one table. */ export interface WidgetDatastoreTemplateRoleGrant { /** A `key` from the template's `roles`. */ role: string; /** Read the table and the rows in it. */ canRead?: boolean; /** Create records, and edit existing ones. */ canWrite?: boolean; /** Delete records. Row-level only — table scope has no delete verb. */ canDelete?: boolean; } export interface WidgetDatastoreTemplate { tables: WidgetDatastoreTemplateTable[]; /** * sc-7530: the audiences this template's tables grant access to. Bound to * real `AppUserGroup`s by the installing workspace; an unbound role simply * writes no grant. */ roles?: WidgetDatastoreTemplateRole[]; } /** * REQ-WIDGET-ACTION: a server-side action the widget declares. Each runs in * the shared isolated-vm action runner (cron- or record-triggered) — never in * the rendered app, so it has no effect on Player ↔ export parity. Operators * enable it per tenant from the Properties Panel; it materialises DISABLED * until they bind an integration API key (and, for `record_*` triggers, a * target table) in the Actions admin page. `triggerTableId` / `apiKeyId` are * deliberately absent — those are tenant-local and bound after install. */ export interface WidgetManifestAction { /** Stable, unique within the manifest — the idempotency key for materialise. */ key: string; name: string; description?: string; /** * sc-4915 — a non-empty set of unique triggers. Combine them so one script * serves several events; the script's `triggerType` global names the one * that actually fired. */ triggerTypes: Array< | "schedule" | "record_created" | "record_updated" | "record_deleted" // sc-5366 — nothing fires it; the workspace runs it on demand. The // `"app"` and `"http_post"` triggers are operator-granted, not declarable. | "manual" >; /** Required iff `triggerTypes` contains `"schedule"`. node-cron syntax. */ scheduleCron?: string; /** 100–300000. Defaults to 30000 on materialise. */ timeoutMs?: number; /** * Runs against `datastore`, `fetch`, `console`, `record`, `request`, * `tenantId`, `triggerType`, `triggerTableId` — NOT the React/SDK surface. * ≤ 200 KiB. `triggerType` is the trigger that fired THIS run — one of the * declared `triggerTypes`, or `"manual"` / `"app"` / `"http_post"` for an * operator, button or webhook run. `request` is `{ body }` on a webhook run * and `null` otherwise (sc-5366). */ scriptSource: string; } export interface WidgetManifest { id: string; name: string; version: string; category: WidgetCategory; icon: string; description: string; author: { name: string; url?: string; email?: string }; supportedPlatforms: Array<"web" | "native">; minAppStudioVersion: string; requestedScopes: WidgetScope[]; propertySchema: WidgetPropertySchema; /** * REQ-THEME-13 — optional per-widget styling schema. Same shape and types as * `propertySchema`; declares the styling options the widget exposes. The * Studio renders a "Style" section from it and delivers the author's resolved * values to the widget under `props.style` (read them via `useWidgetStyle()` * or the `style` prop). The widget applies each value itself. */ styleSchema?: WidgetPropertySchema; events: WidgetEventDescriptor[]; /** * Optional (sc-4505). Values this widget reads from a sibling widget on the * same page, wired by the page author. Read each with `useWidgetInput(name)`. */ inputs?: WidgetInputDescriptor[]; /** * Optional datastore template seeded into the tenant's workspace when * the widget is installed. The author wires the resulting tables into * the widget's `tableRef` properties via the Properties Panel — the * SDK does not auto-bind them. See `WidgetDatastoreTemplate` for the * structural constraints enforced at submission time. */ datastoreTemplate?: WidgetDatastoreTemplate; /** * Optional server-side actions (REQ-WIDGET-ACTION). Operators enable them * per tenant from the Properties Panel; each runs in the existing * isolated-vm action runner. See `WidgetManifestAction`. */ actions?: WidgetManifestAction[]; /** * Optional translation strings the widget ships (REQ-L10N-WIDGET). Maps a * relative key to its per-locale strings; `en` is required per key. At * install the host merges these into the tenant's localization dictionary * under a per-widget namespace (`widget..`), so `useI18n().t(key)` * resolves the namespaced key automatically — the author never types the * prefix. Non-destructive (admin edits win; absent languages are not * created) and persists across uninstalls. Caps: ≤100 keys, key matches * /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, value ≤1 KB. */ translations?: Record>; } /** A React Native shadow style object — one level of `ThemeTokens.elevation`. */ export interface ElevationLevel { shadowColor?: string; shadowOffset?: { width: number; height: number }; shadowOpacity?: number; shadowRadius?: number; /** Android elevation. */ elevation?: number; } export interface ThemeTokens { colors: { primary: string; onPrimary: string; /** sc-3696: tint of the accent over the surface — chips, quiet buttons. */ primarySoft: string; /** sc-3696: contrast-guaranteed text/icon colour on `primarySoft`. */ onPrimarySoft: string; /** sc-3696: the accent's deeper end — a gradient's far stop. */ primaryStrong: string; secondary: string; onSecondary: string; surface: string; onSurface: string; danger: string; [k: string]: string; }; /** sc-3696: the shared elevation vocabulary. Spread a level into a style. */ elevation: { none: ElevationLevel; sm: ElevationLevel; md: ElevationLevel; lg: ElevationLevel; xl: ElevationLevel; }; spacing: { xs: number; sm: number; md: number; lg: number; xl: number }; radii: { sm: number; md: number; lg: number; pill: number }; typography: { fontFamily: string; /** The display face for heading-tier text. Defaults to `fontFamily`. */ headingFontFamily: string; sizes: { xs: number; sm: number; md: number; lg: number; xl: number }; }; } // ----------------------------------------------------- injected data clients // // REQ-WSDK-DOMAIN-CLIENTS. @colixsystems/widget-sdk is CORE ONLY — manifest // contract, primitives, rendering, hooks, events, theme/i18n. It owns NO HTTP // and depends on NONE of the data SDK packages. The data layer is FOUR domain // client packages, each instantiated by the host and injected into // WidgetContext. **Widgets never import these packages** — they reach the data // surface only through the SDK hooks, which read the injected instances. The // shapes below are declared STRUCTURALLY here (widget-sdk must not import the // data SDKs); the authoritative typings ship with each client package. // // Wire/casing: snake_case end to end. The clients send/return snake_case // VERBATIM (e.g. `created_at`, `group_ids`, `can_read`, `amount_cents`, // `data_type`, `is_active`). There is NO client-side case transform anywhere. // Author-defined record column values are passed through verbatim. List // methods return the `{ data, meta }` envelope. /** A `{ data, meta }` list envelope, returned verbatim by every `list(...)`. */ export interface ListEnvelope { data: T[]; meta?: Record; } /** * Structural shape of the injected `@colixsystems/datastore-client` * (`ctx.datastore`). Backs `useDatastoreQuery` / `useDatastoreRecord` / * `useDatastoreSchema` / `useDatastoreMutation` / `useRecordPermissions`. * Rows and bodies are snake_case verbatim; `list` returns `{ data, meta }`. */ export interface DatastoreClient { tables: { list(): Promise; get(tableIdOrName: string): Promise; }; schema(tableId: string): Promise; /** * sc-5206 — REQ-ACL-COMPOSER-GATING: the caller's effective table-level * (+ optional per-record) permissions. Backs `useCanWrite`. */ myPermissions( tableId: string, options?: { recordId?: string }, ): Promise<{ can_read_schema: boolean; can_create: boolean; can_read_records: boolean; can_read: boolean; can_write: boolean; can_delete: boolean; record?: { can_read: boolean; can_write: boolean; can_delete: boolean; can_grant: boolean; }; }>; records(tableId: string): { list(query?: Query): Promise; get(recordId: string): Promise; create(values: Record): Promise; /** PATCH semantics — only the supplied columns are mutated. */ update(recordId: string, values: Record): Promise; delete(recordId: string): Promise; aggregate(spec: unknown): Promise; permissions(recordId: string): { list(): Promise; grant(body: Record): Promise; update( permissionId: string, patch: Record, ): Promise; revoke(permissionId: string): Promise; }; }; } /** * Structural shape of the injected `@colixsystems/directory-client` * (`ctx.directory`). Backs `useDirectory` / `useUsers` (via `.users`) and * `useGroups` (via `.groups`). Rows and bodies are snake_case verbatim; * `list` returns `{ data, meta }`. */ export interface DirectoryClient { me(): Promise; users: { list(query?: DirectoryQuery): Promise; get(userId: string): Promise; invite(body: Record): Promise; deactivate(userId: string): Promise; reactivate(userId: string): Promise; }; groups: { list(query?: GroupsQuery): Promise; create(body: Record): Promise; remove(groupId: string): Promise; addMember(groupId: string, userId: string): Promise; removeMember(groupId: string, userId: string): Promise; listMine(): Promise; }; invites: { list(): Promise; revoke(inviteId: string): Promise; resend(inviteId: string): Promise; }; /** REQ-BANKID-AUTH — backs `useBankIdLink`. */ bankid: { status(): Promise<{ linked: boolean; available: boolean }>; startLink(): Promise<{ order_ref: string; auto_start_token: string | null; qr: string | null; status: "pending"; }>; collect(orderRef: string): Promise<{ status: "pending" | "complete" | "failed" | "cancelled"; qr?: string | null; message?: string; linked?: boolean; }>; cancel(orderRef: string): Promise<{ status: "cancelled" }>; unlink(): Promise<{ linked: boolean }>; }; } /** * Structural shape of the injected Asset-Manager client (`ctx.assets`). * Slimmed to the three top-level ops the host injects — `get` / `list` / * `upload`. Backs `useAsset`, which calls `ctx.assets.get(id)`. The returned * asset carries an absolute `url` safe to drop into ``. */ export interface AssetsClient { get(assetId: string): Promise; list(query?: Record): Promise; upload(formData: unknown): Promise; } /** * Structural shape of the injected `@colixsystems/payments-client` * (`ctx.payments`, REQ-BILL-07-WIDGETPAY). Backs `usePayments`. */ export interface PaymentsClient { requestPayment(body: PaymentRequest): Promise; getPayment(paymentId: string): Promise; } /** * A verified person a completed identification resolved to (REQ-IDENT). * * There is deliberately NO raw personal number: the full value stays * server-side. `personal_number_masked` is e.g. `"19900101-****"`, and * `subject_hash` is stable for the same person so a returning visitor can be * recognised without it. */ export interface Identity { provider: string; name: string | null; given_name: string | null; surname: string | null; personal_number_masked: string | null; subject_hash: string | null; identified_at: string | null; } /** * Structural shape of the injected `@colixsystems/identification-client` * (`ctx.identification`, REQ-IDENT). Backs `useIdentification`. Identifies a * visitor who is NOT signed in; creates no account and no session. */ export interface IdentificationClient { available(): Promise<{ available: boolean; providers: Array<{ provider: string; available: boolean }>; }>; start(body?: { provider?: string; purpose?: string }): Promise<{ identification_id: string; provider: string; purpose: string | null; status: "pending"; auto_start_token: string | null; qr: string | null; expires_at: string; }>; get(identificationId: string): Promise<{ identification_id: string; provider: string; purpose: string | null; status: "pending" | "complete" | "failed" | "cancelled"; hint_code?: string | null; message?: string | null; qr?: string | null; identity?: Identity; }>; cancel(identificationId: string): Promise<{ identification_id: string; status: "pending" | "complete" | "failed" | "cancelled"; }>; } /** * Structural shape of the injected `@colixsystems/notifications-client` * (`ctx.notifications`, sc-890). Backs `useSendNotification`. `send` POSTs the * body snake_case verbatim to `/notifications/send` and resolves to the created * notification row. */ export interface NotificationsClient { send(body: SendNotificationRequest): Promise; } export interface WidgetContext { props: TProps; widget: { id: string; instanceId: string; version: string }; /** * REQ-LAY-08 — optional host layout hint backing `useFill()`. `true` when * the host sized this widget to fill its layout slot's available height (a * page-grid tile set to "Fill tile height", or a default-fill widget type). * Absent / `false` everywhere the host has not opted the widget into filling. */ fill?: boolean; /** Active end-user identity from the host-built context (camelCase, not a wire payload). `id` is null when anonymous. */ user: { id: string | null; email: string | null; displayName: string | null; roles: string[]; groupIds: string[]; }; workspace: { id: string; slug: string; locale: string; theme: ThemeTokens; }; navigation: { goTo(pageId: string, params?: Record): void; goBack(): void; push(pageId: string, params?: Record): void; replace(pageId: string, params?: Record): void; back(): void; currentRoute: { pageId: string; params: Record }; }; /** Injected @colixsystems/datastore-client. */ datastore: DatastoreClient; /** Injected @colixsystems/directory-client (users + groups + invites). */ directory: DirectoryClient; /** Injected @colixsystems/assets-client: { get, list, upload }. */ assets: AssetsClient; /** Injected @colixsystems/payments-client. */ payments: PaymentsClient; /** Injected @colixsystems/notifications-client; backs useSendNotification. */ notifications: NotificationsClient; /** Injected @colixsystems/identification-client; backs useIdentification. */ identification: IdentificationClient; /** sc-7991 — injected @colixsystems/agents-client backing useAgent(). */ agents?: { conversations(agentId: string): unknown }; /** Host child-node renderer; backs WidgetTree / useChildRenderer. */ renderer: { renderNode(node: unknown): unknown }; events: { emit(eventName: string, payload?: unknown): void }; i18n: { locale: string; t(key: string, fallback?: string): string; }; logger: { debug: (...args: unknown[]) => void; info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; }; /** Optional host toast slot; backs useToast. */ toast?: { showToast(args: { kind?: string; message: string }): void; }; /** * Optional host-brokered device capabilities; backs useGeolocation and * useSpeechToText. */ device?: { speech?: { isSupported?(): boolean; start( options: SpeechToTextOptions, handlers: { onResult(result: { transcript: string; isFinal: boolean }): void; onError(error: unknown): void; onEnd(): void; }, ): Promise<{ stop(): void | Promise; abort(): void }>; }; geolocation?: { getCurrentPosition(options?: GeolocationOptions): Promise<{ latitude: number; longitude: number; accuracy: number; }>; /** sc-6450 — false on web and on an export that did not opt in. */ isBackgroundSupported?(): boolean; startBackgroundWatch?( options?: BackgroundLocationOptions, ): Promise; stopBackgroundWatch?(): Promise; isBackgroundWatching?(): boolean; /** Attach to the running watch; starts no sensor and prompts for nothing. */ subscribeBackgroundPositions?( onPosition: (pos: { latitude: number; longitude: number; accuracy: number; }) => void, ): () => void; /** * sc-6450 — the host pushes whether a watch is running: it is primed * asynchronously after a cold relaunch, the OS can end it on its own, and * a sibling widget may start or stop it. */ subscribeBackgroundWatchState?( onChange: (watching: boolean) => void, ): () => void; }; }; } /** * The widget's component receives the author-authored props **as React * props** — the same shape and calling convention every built-in widget * uses (``). Everything * else from `WidgetContext` (datastore, user, workspace, navigation, * i18n, …) is reachable via the SDK hooks (`useDatastoreMutation`, * `useDatastoreQuery`, `useTheme`, `useI18n`, `useWidgetEvent`), which * read from the host-mounted `WidgetContextProvider`. * * function Counter({ tableId }) { // ← props * const mut = useDatastoreMutation(tableId); // ← rest via hook * const { t } = useI18n(); * return ...; * } * * Note: earlier versions of this typing passed the entire * `WidgetContext` as the first argument. That was changed to the props- * spread form so marketplace widgets follow the same interface as * built-ins. Widgets that previously did `function Widget(ctx) { const * { table } = ctx.props; }` should rewrite to * `function Widget({ table }) { … }` and reach for hooks for anything * else they need. */ export interface WidgetModule> { manifest: WidgetManifest; component: (props: TProps) => JSX.Element; _kind: "appstudio-widget-module"; } export function defineWidget>(opts: { manifest: WidgetManifest; component: (props: TProps) => JSX.Element; }): WidgetModule; export function validateManifest( manifest: unknown, ): { ok: true } | { ok: false; errors: string[] }; export function validatePropertySchema( schema: unknown, ): { ok: true } | { ok: false; errors: string[] }; export function validateProps>( schema: WidgetPropertySchema, props: unknown, ): { ok: true; value: T } | { ok: false; errors: string[] }; /** * REQ-LAY-16 (sc-5890): the per-corner radius vocabulary. A `cornerRadius` * field's authored value is a scalar or a per-corner object; resolve it with * `normaliseCornerRadius` and turn it into style props with `cornerRadiusStyle` * — both hosts spell the long-hand props identically, so one call serves the * web Player and the Expo export. */ export type CornerRadiusKey = | "topLeft" | "topRight" | "bottomRight" | "bottomLeft"; export type CornerRadiusValue = number | Partial>; export type ResolvedCornerRadius = Record; export const CORNER_RADIUS_KEYS: readonly CornerRadiusKey[]; export function normaliseCornerRadius( value: CornerRadiusValue | null | undefined, fallback?: number, max?: number, ): ResolvedCornerRadius; export function isUniformCornerRadius( radius: ResolvedCornerRadius | null | undefined, ): boolean; export function cornerRadiusStyle( radius: ResolvedCornerRadius | null | undefined, format?: (n: number) => T, ): Record | null; export function hasCornerRadius( radius: ResolvedCornerRadius | null | undefined, ): boolean; export interface Query { filter?: Record; sort?: Array<{ field: string; dir: "asc" | "desc" }>; limit?: number; cursor?: string; } export interface QueryResult { data: T[]; loading: boolean; error: Error | null; refetch(): Promise; } export interface MutationApi { create(values: Partial): Promise; update(id: string, values: Partial): Promise; delete(id: string): Promise; } export function useDatastoreQuery( table: string, query?: Query, ): QueryResult; export function useDatastoreMutation( table: string, ): MutationApi; /** * A single row from the read-only user directory. `role` is `"USER"` * for a human end-user or `"INTEGRATION"` for a service account. The * directory deliberately omits email and other admin-only fields. */ export interface DirectoryUser { id: string; name: string; role: "USER" | "INTEGRATION"; } export interface DirectoryQuery { /** Case-insensitive substring match on the display name. */ q?: string; /** `"USER"` (default), `"INTEGRATION"`, or `"ALL"`. */ role?: "USER" | "INTEGRATION" | "ALL"; /** Filter by active state (snake_case on the wire). */ is_active?: boolean; /** * Restrict the roster to members of one app-user group — the `id` of a * `useGroups()` row. Pair with a group picker to build "members of group X". */ group_id?: string; limit?: number; offset?: number; } export interface DirectoryResult { users: DirectoryUser[]; loading: boolean; error: DatastoreError | null; refetch(): Promise; } /** * Read-only user directory hook. Reads the injected directory-client at * `ctx.directory.users.list(query)` (returns the `{ data, meta }` envelope; * the hook unwraps `res.data`). Resolves the tenant's app users to snake_case * `{ id, name, role }` rows for chat people-lists, @-mention pickers, or * author-id → display-name resolution. Requires the * `directory.read:users` scope in the widget manifest. */ export function useDirectory(query?: DirectoryQuery): DirectoryResult; export function useWidgetEvent(name: string): (payload?: unknown) => void; /** * Reads the latest payload published on the sibling event this widget input is * wired to. `undefined` until the producer publishes, and for an unwired input * — so always render a sensible default. Page-scoped and ephemeral. */ export function useWidgetInput(inputName: string): unknown; /** * Arguments for `usePayments().requestPayment(...)`. snake_case VERBATIM — * this is the wire contract (REQ-GEN-09). `amount_cents` is the charge in the * currency's minor unit; the app user confirms it in hosted Checkout. */ export interface PaymentRequest { amount_cents: number; currency?: string; description: string; /** * Site-relative path to return to after Checkout (e.g. "/cart"). Defaults * to the current page, so the user lands back where they started. */ return_path?: string; } export interface PaymentResult { id: string; status: "PENDING" | "PAID" | "FAILED" | "REFUNDED" | "CANCELLED"; amount_cents?: number; currency?: string; description?: string; } export interface PaymentsApi { requestPayment(args: PaymentRequest): Promise; getPayment(paymentId: string): Promise; } /** * Incoming app-user payments (REQ-BILL-07-WIDGETPAY). Requires the * `payments.charge:appUser` scope in the widget manifest. When a hosted- * checkout provider is active the HOST opens Checkout for you (a same-tab * redirect on web, the in-app browser on native) — you never open a URL and * never collect card data. Confirm completion from server state (the webhook * flips your record) or poll `getPayment(id)`. The charge settles to the * workspace owner. */ export function usePayments(): PaymentsApi; /** * Rejection thrown by both `usePayments()` callbacks. `code` is the server's * own reason when it sent one; `message` is its user-safe sentence. Branch on * `retryable`: false means the same charge can never succeed until the * workspace, the manifest, or the amount changes — show the reason, not a * "try again" that loops the payer forever. */ export class PaymentError extends Error { code: | "AUTH_REQUIRED" | "BUSINESS_IDENTITY_REQUIRED" | "PAYMENTS_SCOPE_NOT_GRANTED" | "PAYMENTS_UNAVAILABLE" | "UNSUPPORTED_CURRENCY" | "INVALID_AMOUNT" | "AMOUNT_TOO_LARGE" | "VALIDATION" | "CONNECT_NOT_READY" | "PAYMENTS_DISABLED" | "DECLINED" | "FORBIDDEN" | "NOT_FOUND" | "RATE_LIMITED" | "INTERNAL" | string; /** False when retrying the same charge can never succeed. */ retryable: boolean; constructor( code: string, message: string, opts?: { cause?: unknown }, ); } /** * Arguments for `useSendNotification().send(...)`. snake_case VERBATIM — this * is the wire contract (REQ-GEN-09). `recipient_user_id`, `title`, and `body` * are required; `link` and `payload` are optional. */ export interface SendNotificationRequest { recipient_user_id: string; title: string; body: string; link?: string | null; payload?: Record | null; } /** * A notification row, snake_case verbatim as returned by the backend * (`POST /notifications/send`, 201). The shape is open-ended; the well-known * fields are listed. */ export interface NotificationRow { id: string; tenant_id?: string; user_id?: string; title?: string; body?: string; link?: string | null; payload?: Record | null; read_at?: string | null; created_at?: string; [key: string]: unknown; } export interface SendNotificationApi { send(body: SendNotificationRequest): Promise; sending: boolean; error: NotificationError | null; } /** * sc-890 — send an in-app notification to one app user in the tenant. Returns * `{ send, sending, error }`. The hook is imperative — `send` never fires on * mount; the widget calls it from an event handler. Rejects with a * `NotificationError`. Requires the `notifications.send:appUser` scope in the * widget manifest. */ export function useSendNotification(): SendNotificationApi; export interface WorkspaceCurrency { /** The ISO code this workspace charges its app users in. */ currency: string; /** MINOR units in, a display string out: 45000 -> "450,00 kr". */ formatMoney: (minorUnits: number) => string; } /** * sc-4686 — the workspace charge currency + the only sanctioned way to render * money. Never write a currency symbol or code into a widget: the owner can * change it after the widget ships. */ export function useWorkspaceCurrency(): WorkspaceCurrency; export function useTheme(): ThemeTokens; /** * REQ-THEME-13 — the author-set per-widget style values (the `props.style` * object), keyed by the names declared in `manifest.styleSchema`. Returns an * empty object when nothing is set. Apply each value onto whatever element you * choose; the host never auto-applies style. */ export function useWidgetStyle(): Record; /** * Stateful single-record fetch hook. Returns `{ data, loading, error, * refetch }`. `data` is one row or `null` (never an array). */ export function useDatastoreRecord( tableId: string | null | undefined, recordId: string | null | undefined, ): { data: unknown | null; loading: boolean; error: DatastoreError | null; refetch(): Promise; }; /** * One column in a table's schema, as returned by `useDatastoreSchema`. * Structural metadata only — never row data. */ // Returned by `ctx.datastore.schema(tableId)` — wire data, snake_case verbatim. export interface DatastoreSchemaColumn { id: string; name: string; data_type: | "STRING" | "TEXT" | "NUMBER" | "FLOAT" | "BOOL" | "DATE" | "FILE" | "STRING_ARRAY" | "INT_ARRAY" | "RELATION" | "USER" | "USER_GROUP"; required: boolean; /** For RELATION columns only. */ relation_type?: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY" | null; /** For RELATION columns only — the id of the table this column points at. */ target_table_id?: string | null; /** * For RELATION columns only — id of a column on `target_table_id` whose * value supplies the `label` hydrated next to the related record id in * record responses. `null` falls back to the first STRING/TEXT column on * the target table. */ display_column_id?: string | null; /** True when this column is the table's display/identification column. */ is_identification?: boolean; } export interface DatastoreSchema { id: string; name: string; columns: DatastoreSchemaColumn[]; } export interface SchemaResult { schema: DatastoreSchema | null; loading: boolean; error: DatastoreError | null; refetch(): Promise; } /** * Stateful table-schema resolver hook. Returns `{ schema, loading, error, * refetch }` where `schema` is the bound table's column structure (`null` * until loaded). Reads the existing ACL-gated `GET /tables/:id` — structure * only, no row data. Use it to resolve a stored `columnId` to its column * name / dataType / relation target at runtime. */ export function useDatastoreSchema( tableId: string | null | undefined, ): SchemaResult; /** sc-5206 — one prop key `useBoundColumns` resolves to a schema column. */ export interface BoundColumnSpec { dataType?: string | string[]; /** When true, an unresolved key is omitted from `missing`. */ optional?: boolean; } export type BoundColumnShape = Record; export interface BoundColumnsResult { /** Resolved column NAME per key — drop-in for `record[props.titleField]`. */ columns: Record; /** The full Column object per key, as `useDatastoreSchema` returns it. */ resolved: Record; /** Keys whose spec is not `optional: true` and did not resolve. */ missing: string[]; loading: boolean; error: DatastoreError | null; } /** * sc-5206 — 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 a column an author renamed * after install still resolves. Never throws for a missing column — an * unresolved key reads `undefined` and, when not `optional: true`, is named * in `missing`. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, * missing: Object.keys(shape), loading: false, error: null }`. */ export function useBoundColumns>( tableId: string | null | undefined, shape: BoundColumnShape, props: TProps, ): BoundColumnsResult; /** * sc-5206 — keep whatever `buildQuery()` returns at a STABLE reference across * renders when its (JSON-serialised) content hasn't changed, so it can be * passed straight into `useDatastoreQuery`'s second argument instead of a * hand-rolled `useMemo` with an easy-to-get-wrong deps array. Pure React * state — no host / WidgetContext required. Never throws: a `buildQuery` * that throws degrades to a stable `undefined`; a result that can't be * diffed (e.g. circular) degrades to "always a new reference". */ export function useStableQuery(buildQuery: () => T): T | undefined; /** sc-4932 — one field a draft may target, with a dropdown's closed option set. */ export interface InterpretDraftField { column: string; options?: Array; } /** sc-4932 — options for `useInterpretDraft().interpret`. */ export interface InterpretDraftOptions { fields?: Array; /** IANA zone the server resolves relative times against. Defaults to UTC. */ timeZone?: string; } /** * sc-4932 — a drafted record. `values` is keyed by column NAME (the shape * `useDatastoreMutation().create` takes); `unresolved` names the fields the * sentence did not state. */ export interface InterpretDraftResult { values: Record; unresolved: string[]; } export interface InterpretDraftApi { interpret( text: string, options?: InterpretDraftOptions, ): Promise; interpreting: boolean; error: DatastoreError | null; result: InterpretDraftResult | null; /** False when the host brokers no interpreter (e.g. an unbound preview). */ available: boolean; } /** * sc-4932 — draft record values for `tableId` from one sentence the user typed * ("walk at 11 am tomorrow"). IMPERATIVE: never fires on mount, and DRAFTS * only — the user reviews the values before the record is created. */ export function useInterpretDraft( tableId: string | null | undefined, ): InterpretDraftApi; export interface AgentMessage { id: string; conversation_id: string; /** The system prompt is NOT part of the transcript — it lives on the agent. */ role: "user" | "assistant"; content: string; created_at: string; } export interface AgentApi { /** Imperative — call from an event handler. The first send opens the thread. */ send(text: string): Promise; /** Drop the open thread and clear the transcript this widget has seen. */ reset(): void; /** Oldest first. The SERVER owns the real transcript. */ messages: AgentMessage[]; sending: boolean; error: AgentError | null; /** False when no agent is bound, or the host brokers no agents client. */ available: boolean; } /** * sc-7991 — hold a conversation with a workspace-authored AI agent. * IMPERATIVE: never fires on mount, so an unused widget costs nothing. The * widget sends ONE message; the system prompt and the real transcript stay * server-side and are never readable or settable from here. */ export function useAgent(agentRef: string | null | undefined): AgentApi; // REQ-RT-07 realtime subscription transport state. export type DatastoreSubscriptionStatus = | "connecting" | "live" | "reconnecting" | "fallback"; export interface DatastoreSubscriptionHandlers { onCreated?: (record: Record) => void; onUpdated?: (record: Record) => void; onDeleted?: (record: Record) => void; } // sc-6270: see SubscriptionScope in @colixsystems/datastore-client. A scoped // subscribe is gated on the per-record read check instead of read-every-row, // which is what lets a per-record-ACL table stream at all. export type DatastoreSubscriptionScope = | { kind: "record"; record_id: string } | { kind: "parent"; relation_column: string; record_id: string }; export interface DatastoreSubscriptionOptions { fallbackAfterMs?: number; scope?: DatastoreSubscriptionScope; } /** * REQ-RT-07: subscribe to a table's realtime change stream via * `ctx.datastore.records(tableId).subscribe(...)`. Opens on mount, re-opens on * `tableId` change, tears down on unmount. Returns `{ status }`; when status * is `"fallback"` (no socket support, ACL-rejected, or connect timed out) run * REST polling instead. Never throws — degrades to `{ status: "fallback" }` on * a host whose datastore client predates realtime. * * `options.scope` narrows the stream to one record or to a parent's * inheriting children; re-subscribes when the scope's values change, not on * every render. */ export function useDatastoreSubscription( tableId: string | null | undefined, handlers?: DatastoreSubscriptionHandlers, options?: DatastoreSubscriptionOptions, ): { status: DatastoreSubscriptionStatus }; /** * Stateful file-asset resolver hook. Returns `{ url, file, loading, error, * refetch }`. The `url` is an absolute URL composed against the host's API * base; safe to pass straight to ``. */ /** * The host's child-node renderer surface. `renderNode(node)` returns a * React element rendered with the same dispatch the top-level page uses; * the closure pre-binds breakpoint / page ctx / parent. */ export function useChildRenderer(): { renderNode(node: unknown): unknown; }; /** * Renders an author-authored page-tree node through the host's child * renderer. Prefer this over `useChildRenderer()` for the common case * (``). */ export const WidgetTree: (props: { node: unknown }) => unknown; export function useAsset(assetId: string | null | undefined): { url: string | null; asset: { id: string; url: string; stored_filename?: string; mime_type?: string; size_bytes?: number; [k: string]: unknown; } | null; loading: boolean; error: DatastoreError | null; refetch(): Promise; }; /** * List every tenant asset carrying a given tag. Reads `ctx.assets.list({ * tag, type, limit })` and unwraps the `{ data, meta }` envelope. * * `type` defaults to `"image"` so the common Gallery case gets only images * back; pass `"all"` (or `"audio"` / `"video"` / `"document"`) to widen. * When `tag` is falsy the hook collapses to an empty result without a * network round-trip. */ export function useAssetsByTag( tag: string | null | undefined, options?: { type?: "image" | "audio" | "video" | "document" | "all"; }, ): { assets: Array<{ id: string; url: string; stored_filename?: string; mime_type?: string; size_bytes?: number; tags?: string[]; [k: string]: unknown; }>; loading: boolean; error: DatastoreError | null; refetch(): Promise; }; export function useI18n(): { locale: string; t(key: string, fallback?: string): string; }; /** * sc-3783 — translate USER-GENERATED content (record text, file names, API * payloads) into the app user's selected language. * * NOT for the app's own copy: author-written strings belong in the workspace * dictionary and are resolved for free by `useI18n().t(key)`. Reach for this * only when there is no key because there is no author. * * A string resolves to a string and an array resolves to an array (positionally * aligned, sent as ONE request). `options.target` defaults to the app user's * language. Text already in the target language, blank text, and text already * translated this session cost nothing and never reach the network. * * `available` is false on a host that brokers no translation client (the Studio * canvas preview); `translate` then rejects with code "UNSUPPORTED" rather than * throwing at render, so the widget can show untranslated text. */ export function useTranslate(): { translate(input: string, options?: TranslateOptions): Promise; translate(input: string[], options?: TranslateOptions): Promise; translating: boolean; error: TranslateError | null; language: string; available: boolean; }; export interface TranslateOptions { /** Target language code. Defaults to the app user's selected language. */ target?: string; /** Source language code. Omit to let the provider auto-detect. */ source?: string; } /** * The active end-user identity. `id` is null for anonymous visitors and on * the Studio canvas preview; every field is guaranteed present (the host * fills safe defaults), so widgets read them without optional chaining. */ export function useUser(): { id: string | null; email: string | null; displayName: string | null; roles: string[]; groupIds: string[]; }; /** * REQ-LAY-08 — returns `true` when the host has sized this widget to fill its * layout slot's available height (a page-grid tile set to "Fill tile height", * or a default-fill widget type — containers + media). Widgets that can * stretch (Image, Chart, Map, Video, …) should switch to a fill style * (`flex: 1` / `height: "100%"`) when this is `true`; others may ignore it. * Defaults to `false`, so calling it is always safe, and the same value is * injected on web and native so fill behaviour is identical on both platforms. */ export function useFill(): boolean; /** * The host-provided navigation surface. `goTo(pageId, params?)` navigates * to an internal app page; `goBack()` pops the stack. Missing methods * degrade to no-ops on the Studio canvas preview where no live router is * mounted. For external URLs use the `Linking` primitive. */ export function useNavigation(): { goTo(pageId: string, params?: Record): void; goBack(): void; push(pageId: string, params?: Record): void; replace(pageId: string, params?: Record): void; back(): void; currentRoute: { pageId: string; params: Record }; }; /** * Returns the current route's navigation params — the bag a `goTo(pageId, params)` * carried to this page. The flat accessor for master→detail: navigate with * `goTo(detailPageId, { recordId: row.id })`, then read * `const { recordId } = useRouteParams()`. Empty object when the page was opened * without params. Equivalent to `useNavigation().currentRoute.params`. */ export function useRouteParams(): Record; /** * REQ-NAV-05 — the PAGE's resolved context. When the page declares typed * parameters, the host resolves them ONCE before any widget renders: `params` * holds the values coerced to their declared types (a `number` parameter is a * number here, not the string `useRouteParams()` returns), and `records` maps * each `record` parameter to the row the host already fetched — read it instead * of fetching the same record again. Both bags are empty on a page that * declares nothing. */ export function usePageContext(): { params: Record; records: Record | null>; }; /** A value `useWidgetRoute` can persist: a scalar, or a flat array of them. */ export type WidgetRouteScalar = string | number | boolean; export type WidgetRouteValue = WidgetRouteScalar | WidgetRouteScalar[]; /** * sc-5717 — `[state, setState]` for the widget's OWN internal position, which * the host persists so it survives a reload and travels in a shared link: the * folder a browser is in, a wizard's step, a list's sort and search. * * Use it like `useState` with an object. Writes MERGE, `null` clears a key back * to the value you declared in `initial`, and `initial` itself is read once. * Only put state here that a visitor would reasonably link to or expect to * survive a reload — transient UI belongs in ordinary `useState`. * * It is not history: pressing Back leaves the page, on both platforms. */ export function useWidgetRoute< T extends Record, >( initial: T, ): [T, (patch: Partial | ((current: T) => Partial)) => void]; /** * Static API for external URLs. `openURL(url)` opens a URL with the OS * handler (web: react-native-web maps to `window.open` / `location.href`; * native: hands off to the system). `canOpenURL` reports whether the * scheme is registered. */ export const Linking: { openURL(url: string): Promise; canOpenURL(url: string): Promise; }; /** * sc-1179 — subscribe to the page-level refresh tick (pull-to-refresh on * mobile, manual refresh button, etc.). The handler is called every time * the host triggers a refresh; it may return a Promise — the host waits * on all settled subscribers before clearing the refresh indicator. * * `useDatastoreQuery` / `useDatastoreRecord` / `useAsset` already * auto-subscribe their own `refetch`, so widgets only call this directly * to re-run non-datastore work. Safe to call on a host that does not * implement refresh — collapses to a no-op there. */ export function useRefresh( handler: () => void | Promise, ): void; /** * sc-4416 — declare that this widget currently has NO content to show, so the * host removes its layout slot rather than reserving space for it. * * Returning `null` alone is not enough: the host wraps every widget node in an * entrance wrapper, so a widget rendering nothing still leaves an empty box * that its parent stack puts `gap` around. Use it for a CONDITIONALLY ABSENT * section (a per-record child collection with no rows for this record), never * to suppress a genuine empty state. The widget stays mounted while collapsed, * so passing `false` later brings the section back. Authoring surfaces never * collapse. Safe on a host that does not implement it — a no-op there. */ export function useSectionEmpty(isEmpty: boolean): void; /** sc-7955 — name this screen for the header title slot; most recent call wins. */ export function useScreenTitle(title: string | null | undefined): void; /** The layout event a react-native primitive passes to `onLayout`. */ export interface WidgetLayoutEvent { nativeEvent: { layout: { width: number; height: number; x: number; y: number } }; } /** * sc-4399 — measure 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 returned * handler onto the widget's outermost primitive; ONE implementation serves the * web Player (via react-native-web) and the native export. The width is 0 * before the first layout — treat that as "not yet measured" and render the * wide form, which is what `isNarrowWidth` does. */ export function useContainerWidth(): [ number, (event: WidgetLayoutEvent) => void, ]; /** The width below which a widget should adopt its narrow form (480). */ export const NARROW_WIDTH_PX: number; /** * True when a MEASURED width is below `NARROW_WIDTH_PX`. An unmeasured width * (0) is deliberately NOT narrow, so a widget that never lays out keeps its * historical wide rendering. */ export function isNarrowWidth(width: number): boolean; /** Pass-through options for `useGeolocation().getCurrentPosition(...)`. */ export interface GeolocationOptions { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number; } /** sc-6450 — pass-through options for `startBackgroundWatch(...)`. */ export interface BackgroundLocationOptions { enableHighAccuracy?: boolean; /** Report only after the device has moved this far, in metres. */ distanceIntervalMeters?: number; /** Report no more often than this, in milliseconds. */ timeIntervalMs?: number; } export interface GeolocationResult { latitude: number | null; longitude: number | null; /** Best-effort accuracy in metres. */ accuracy: number | null; loading: boolean; error: GeolocationError | null; /** * Imperatively read the device position — call from a user gesture. Resolves * to `{ latitude, longitude, accuracy }` and stores the same on the hook; * rejects with a `GeolocationError`. */ getCurrentPosition(): Promise<{ latitude: number; longitude: number; accuracy: number; }>; /** * sc-6450 — whether this host can track location while BACKGROUNDED. False * on the web Player and on an exported app whose workspace did not opt into * background location. Check it before rendering the control. */ backgroundSupported: boolean; /** Whether a background watch is currently running on this device. */ backgroundWatching: boolean; /** * Start tracking while backgrounded — call from a user gesture. The watch * OUTLIVES the widget's mount; only `stopBackgroundWatch()` releases it. * Delivered positions land in the same `latitude`/`longitude`/`accuracy` * slots. Rejects with a `GeolocationError`. * * Tracking continues while the app RUNS in the background; it does not * survive the OS terminating the app. */ startBackgroundWatch(options?: BackgroundLocationOptions): Promise; /** Release the OS subscription. */ stopBackgroundWatch(): Promise; } /** * sc-1584 — read the device's current position. Capture is imperative (call * `getCurrentPosition()` from a user gesture; it never fires on mount). The * same hook drives both platforms — the web Player brokers it via * `navigator.geolocation`, the Expo export via `expo-location`. Safe to call on * a host that doesn't broker geolocation: `getCurrentPosition()` then rejects * with `code: "UNSUPPORTED"`. * * sc-6450 — the same hook also drives the native-only background watch; see * `backgroundSupported` / `startBackgroundWatch` on the result. */ export function useGeolocation(options?: GeolocationOptions): GeolocationResult; /** * sc-1584 — error thrown by `useGeolocation().getCurrentPosition()` and * surfaced in the hook's `error` slot. `code` is a stable categorisation. */ export class GeolocationError extends Error { code: | "PERMISSION_DENIED" | "UNAVAILABLE" | "TIMEOUT" | "UNSUPPORTED" | "INTERNAL"; constructor( code: GeolocationError["code"], message: string, opts?: { cause?: unknown }, ); } /** Pass-through options for `useSpeechToText(...)`. */ export interface SpeechToTextOptions { /** BCP-47 tag, e.g. "sv-SE". Defaults to the host's UI language. */ lang?: string; /** Keep listening across pauses instead of stopping at the first result. */ continuous?: boolean; /** Emit uncommitted guesses to `partial` while the user is still speaking. */ interimResults?: boolean; } export interface SpeechToTextResult { /** Finalised speech, accumulated across utterances in this session. */ transcript: string; /** The uncommitted guess; `""` unless `interimResults` was requested. */ partial: string; listening: boolean; /** False when the host brokers no recogniser (e.g. Firefox). */ supported: boolean; error: SpeechToTextError | null; /** Begin listening — call from a user gesture. Rejects with SpeechToTextError. */ start(): Promise; /** Stop listening and keep what was heard. */ stop(): Promise; /** Cancel listening and discard the current utterance. */ abort(): void; /** Clear `transcript`, `partial`, and `error`. */ reset(): void; } /** * Dictate into text with the device's ON-DEVICE speech recogniser. Capture is * imperative (call `start()` from a user gesture; it never listens on mount). * The same hook drives both platforms — the web Player brokers it via * `window.SpeechRecognition`, the Expo export via `expo-speech-recognition`. * No audio is uploaded and no AI credit is spent. Safe to call on a host that * brokers no recogniser: `supported` is then false and `start()` rejects with * `code: "UNSUPPORTED"`, so gate the mic button on `supported`. */ export function useSpeechToText( options?: SpeechToTextOptions, ): SpeechToTextResult; /** * Error surfaced by `useSpeechToText()` — thrown by `start()` and stored in the * hook's `error` slot. `code` is a stable categorisation. */ export class SpeechToTextError extends Error { code: | "PERMISSION_DENIED" | "NO_SPEECH" | "LANGUAGE_UNSUPPORTED" | "NETWORK" | "ABORTED" | "UNSUPPORTED" | "INTERNAL"; constructor( code: SpeechToTextError["code"], message: string, opts?: { cause?: unknown }, ); } /** * Options for `useCamera(...)`. Both are HINTS the host honours where it can: * the Expo export applies them, the web file input applies neither — so never * depend on a cropped result or a capped file size. */ export interface CameraOptions { /** Let the user crop/rotate before returning. Native only. Defaults to false. */ allowsEditing?: boolean; /** 0–1 compression quality. Native only. Defaults to 0.8. */ quality?: number; } /** A photo taken or picked through `useCamera()`, normalised across hosts. */ export interface CameraAsset { /** Displayable source — `` / ``. */ uri: string; /** File name, derived from the source when the host supplies none. */ name: string; mimeType: string; width: number | null; height: number | null; /** Bytes, when the host reports it. */ size: number | null; /** * Ready-to-upload part — a Blob on both hosts: a `File` on web, * expo-file-system's `File` on native. Expo's fetch rejects any other shape. * Append it to a FormData and hand that to `ctx.assets.upload(...)`. */ file: unknown; } export interface CameraResult { /** The most recent asset, or null before the first capture / after reset. */ asset: CameraAsset | null; loading: boolean; error: CameraError | null; /** False when the host brokers no camera. */ supported: boolean; /** Open the camera. Resolves null if the user dismisses it. */ capture(): Promise; /** Open the photo library. Resolves null if the user dismisses it. */ pick(): Promise; /** Clear `asset` and `error`, releasing the held asset. */ reset(): void; } /** * Take a photo or choose one from the device library. Capture is imperative * (call `capture()` / `pick()` from a user gesture; it never opens on mount). * The same hook drives both platforms — the web Player brokers it via a file * input, the Expo export via `expo-image-picker`. Dismissing the picker * resolves `null` rather than rejecting; a genuine failure rejects with a * `CameraError`. Safe to call on a host that brokers no camera: `supported` is * then false, so gate the camera button on it. */ export function useCamera(options?: CameraOptions): CameraResult; /** * Error surfaced by `useCamera()` — thrown by `capture()` / `pick()` and stored * in the hook's `error` slot. `code` is a stable categorisation. */ export class CameraError extends Error { code: "PERMISSION_DENIED" | "UNSUPPORTED" | "INTERNAL"; constructor( code: CameraError["code"], message: string, opts?: { cause?: unknown }, ); } /** One edit step. Exactly one key per entry; the array applies in order. */ export type ImageEditAction = | { resize: { width?: number; height?: number } } | { crop: { originX: number; originY: number; width: number; height: number } } | { rotate: number } | { flip: "horizontal" | "vertical" }; /** Output settings for `useImageEditor().edit(...)`. */ export interface ImageEditOptions { /** Encoding of the result. Defaults to "jpeg". */ format?: "jpeg" | "png" | "webp"; /** 0–1 quality for the lossy formats. Defaults to 0.8. Ignored for png. */ compress?: number; /** Also return the bytes as base64. Off by default — it is expensive. */ base64?: boolean; } /** * An edited image, normalised across hosts. Structurally the same shape * `useCamera()` yields, so capture → edit → upload is one code path. */ export interface EditedImage { uri: string; name: string; mimeType: string; width: number | null; height: number | null; size: number | null; /** Present only when `base64` was requested. */ base64?: string; /** Ready-to-upload part — a Blob on both: a `File` on web, expo-file-system's `File` on native. */ file: unknown; } export interface ImageEditorResult { /** The most recent edit, or null before the first call / after reset. */ result: EditedImage | null; editing: boolean; error: ImageEditorError | null; /** False when the host brokers no image editor. */ supported: boolean; /** Apply `actions` in order and encode per `options`. */ edit( uri: string, actions: ImageEditAction[], options?: ImageEditOptions, ): Promise; /** Clear `result` and `error`, releasing the held image. */ reset(): void; } /** * Resize, crop, rotate or flip an image. Imperative — call `edit()` from an * event handler, never during render. The web Player brokers it on a canvas, * the Expo export via `expo-image-manipulator`; both implement the same four * actions and the same output formats. There is deliberately no `extent` * action — it exists only on web, and a web-only capability is the direction * CLAUDE.md §8 forbids. Safe to call on a host that brokers no editor: * `supported` is then false, so gate the control on it. */ export function useImageEditor(): ImageEditorResult; /** * Error surfaced by `useImageEditor()` — thrown by `edit()` and stored in the * hook's `error` slot. `code` is a stable categorisation. */ export class ImageEditorError extends Error { code: | "UNSUPPORTED" | "INVALID_ACTION" | "DECODE_FAILED" | "ENCODE_FAILED" | "INTERNAL"; constructor( code: ImageEditorError["code"], message: string, opts?: { cause?: unknown }, ); } /** A code decoded by `useBarcodeScanner().scan()`. */ export interface BarcodeScan { /** The decoded text. */ value: string; /** * Lowercase symbology name (`qr_code`, `code_128`, `ean_13`, …). A HINT, not * a promise: the two hosts detect different sets, so never branch on it for * correctness. */ format: string; } /** Options for `useBarcodeScanner(...)`. */ export interface BarcodeScannerOptions { /** Narrow which symbologies to look for. A hint the host honours where it can. */ formats?: string[]; } export interface BarcodeScannerResult { /** The most recent scan, or null before the first call / after reset. */ result: BarcodeScan | null; scanning: boolean; error: BarcodeError | null; /** * False where the host brokers no scanner — notably any browser without * `BarcodeDetector` (Safari, Firefox). GATE THE SCAN BUTTON ON THIS. */ supported: boolean; /** Resolves the first code decoded, or null if the user dismisses. */ scan(): Promise; /** Clear `result` and `error`. */ reset(): void; } /** * Read a barcode or QR code with the device camera. Imperative — call `scan()` * from a user gesture; the OS and the browser gate the camera prompt on one, so * it never opens on mount. One-shot by design: to read several codes, call * `scan()` again rather than leaving a subscription running. * * The Expo export scans via `expo-camera`; the web Player via `BarcodeDetector` * over the same `getUserMedia` preview `useCamera()` uses. Where the browser * ships no `BarcodeDetector` this is a genuine platform gap (CLAUDE.md §8), so * `supported` is false and the widget must offer manual entry instead. */ export function useBarcodeScanner( options?: BarcodeScannerOptions, ): BarcodeScannerResult; /** * Error surfaced by `useBarcodeScanner()` — thrown by `scan()` and stored in * the hook's `error` slot. A dismissal is not an error; `scan()` resolves null. */ export class BarcodeError extends Error { code: "PERMISSION_DENIED" | "UNSUPPORTED" | "INTERNAL"; constructor( code: BarcodeError["code"], message: string, opts?: { cause?: unknown }, ); } /** * Error class thrown by useDatastoreMutation callbacks (and surfaced by * useDatastoreQuery in its `error` slot). The `code` is a stable * categorisation widgets can branch on. */ export class DatastoreError extends Error { code: | "VALIDATION" | "CONSTRAINT_VIOLATION" | "FORBIDDEN" | "NOT_FOUND" | "INTERNAL"; fieldErrors?: Record; /** sc-4986 — the HTTP status behind the code; null for a transport failure. */ status: number | null; /** * sc-4986 — whether retrying the SAME call could plausibly succeed. False * for a refusal only the caller, the record or the workspace can clear * (403/404/400/422/409); true for a timeout, a rate limit, a 5xx or a * dropped socket. Branch on this instead of offering a blanket retry. */ retryable: boolean; constructor( code: DatastoreError["code"], message: string, opts?: { fieldErrors?: Record; status?: number | null; retryable?: boolean; cause?: unknown; }, ); } /** * REQ-USERMGMT / REQ-ACL-SYS M3 — error class thrown by `useUsers` and * `useGroups` callbacks. The `code` is a stable categorisation widgets * can branch on. */ export class DirectoryError extends Error { code: | "FORBIDDEN" | "VALIDATION" | "NOT_FOUND" | "INVITE_ONLY" | "INTERNAL"; /** sc-4986 — the HTTP status behind the code; null for a transport failure. */ status: number | null; /** sc-4986 — whether retrying the SAME call could plausibly succeed. */ retryable: boolean; constructor( code: DirectoryError["code"], message: string, opts?: { status?: number | null; retryable?: boolean; cause?: unknown; }, ); } /** * sc-7991 — structured error thrown by `useAgent().send`. Branch on `code`: * `AI_QUOTA_EXCEEDED` is the workspace's credit ceiling and will NOT clear on * a retry, unlike `RATE_LIMITED`; `AGENT_DISABLED` means the author switched * the agent off; `AI_AGENTS_DISABLED` means the feature is not released for * this workspace. Never show an app user a raw code or the billing state. */ export class AgentError extends Error { code: | "UNAVAILABLE" | "AGENT_NOT_FOUND" | "AGENT_DISABLED" | "CONVERSATION_NOT_FOUND" | "INVALID_MESSAGE" | "AI_QUOTA_EXCEEDED" | "RATE_LIMITED" | "AI_UNAVAILABLE" | "AI_GENERATION_FAILED" | "AUTH_REQUIRED" | "INTERNAL" | (string & {}); constructor(code: string, message: string, opts?: { cause?: unknown }); } export class TranslateError extends Error { code: | "UNSUPPORTED" | "TRANSLATE_NOT_CONFIGURED" | "TRANSLATION_QUOTA_EXCEEDED" | "RATE_LIMITED" | "PAYLOAD_TOO_LARGE" | "VALIDATION" | "AUTH_REQUIRED" | "INTERNAL" | string; constructor( code: TranslateError["code"], message?: string, opts?: { cause?: unknown }, ); } /** * sc-890 — error class thrown by `useSendNotification().send`. The `code` is a * stable categorisation widgets can branch on. */ export class NotificationError extends Error { code: | "INVALID_TITLE" | "INVALID_BODY" | "INVALID_RECIPIENT" | "INVALID_PAYLOAD" | "VALIDATION" | "AUTH_REQUIRED" | "FORBIDDEN" | "RECIPIENT_NOT_FOUND" | "RATE_LIMITED" | "INTERNAL" | string; constructor( code: NotificationError["code"], message: string, opts?: { cause?: unknown }, ); } // --------------------------------------------------------------- useUsers // // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook. export interface AppUserRow { id: string; name: string; email?: string; role: "USER" | "INTEGRATION"; is_active: boolean; } export interface UsersQuery { q?: string; role?: "USER" | "INTEGRATION" | "ALL"; is_active?: boolean; /** Restrict to members of one app-user group — the `id` of a `useGroups()` row. */ group_id?: string; limit?: number; offset?: number; } export interface InviteArgs { email: string; name?: string; group_ids?: string[]; } export type AppUserInviteStatus = | "pending" | "accepted" | "revoked" | "expired"; export interface AppUserInviteRow { id: string; email: string; /** Server-computed lifecycle value — prefer it over re-deriving expiry. */ status: AppUserInviteStatus; name?: string; group_ids?: string[]; invited_at?: string; expires_at?: string; accepted_at?: string | null; revoked_at?: string | null; created_at?: string; tenant_id?: string; invited_by_studio_user_id?: string | null; } export interface UsersApi { users: AppUserRow[]; loading: boolean; error: DirectoryError | null; refetch(): Promise; invite(args: InviteArgs): Promise; deactivate(userId: string): Promise; reactivate(userId: string): Promise; remove(userId: string): Promise; } /** * AppUser administration. Reads through the injected directory-client at * `ctx.directory.users.{list,get,invite,deactivate,reactivate}`. Reads * require the `users.read:*` scope in the manifest; mutations additionally * require `users.write:*`. Widgets that declare the scopes but whose calling * APP_USER lacks the corresponding SystemAcl `users.*` capability grant get a * `FORBIDDEN` DirectoryError. */ export function useUsers(query?: UsersQuery): UsersApi; // --------------------------------------------------------------- useGroups export interface AppUserGroupRow { id: string; name: string; member_count?: number; } export interface GroupsQuery { q?: string; limit?: number; offset?: number; } export interface GroupsApi { groups: AppUserGroupRow[]; loading: boolean; error: DirectoryError | null; refetch(): Promise; create(args: { name: string }): Promise; remove(groupId: string): Promise; addMember(groupId: string, userId: string): Promise; removeMember(groupId: string, userId: string): Promise; } /** * AppUserGroup administration. Reads through the injected directory-client at * `ctx.directory.groups.{list,create,remove,addMember,removeMember,listMine}`. * Reads require `groups.read:*`; mutations require `groups.write:*`. Same * SystemAcl gating as `useUsers`. */ export function useGroups(query?: GroupsQuery): GroupsApi; // --------------------------------------------------------------- useInvites // // sc-5097 — pending AppUser invite administration. export interface InvitesQuery { /** Defaults to "all" server-side; pass "pending" for outstanding invites. */ status?: AppUserInviteStatus | "all"; limit?: number; offset?: number; } export interface InvitesApi { invites: AppUserInviteRow[]; loading: boolean; error: DirectoryError | null; refetch(): Promise; /** Re-send the invitation email. Refetches on success. */ resend(inviteId: string): Promise; /** Cancel a pending invitation. Refetches on success. */ revoke(inviteId: string): Promise; } /** * Pending AppUser invite administration through the injected directory-client * at `ctx.directory.invites.{list,resend,revoke}`. * * The WHOLE surface — listing included — requires the `users.write:*` scope * AND the SystemAcl `users.write` capability, because a pending invite exposes * the email of someone who is not a member yet. The `invites.read:*` / * `invites.write:*` scope names mint but no route enforces them, so declaring * only those yields a `FORBIDDEN` DirectoryError. */ export function useInvites(query?: InvitesQuery): InvitesApi; // ----------------------------------------------------- useBankIdLink // // REQ-BANKID-AUTH — link / unlink a BankID identity to the signed-in app-user. // Reads the injected directory-client at `ctx.directory.bankid`. Self-service, // JWT-gated — no requestedScopes entry needed. export interface BankIdLinkApi { /** Whether the user currently has BankID linked (null until the status loads). */ linked: boolean | null; /** Whether BankID linking can be used in this app (provider enabled + platform cert). */ available: boolean; /** The active link order's state, or null when no order is in flight. */ status: "pending" | "complete" | "failed" | "cancelled" | null; /** PNG data-URL of the animated BankID QR while pending (render with the Image primitive). */ qr: string | null; message: string | null; loading: boolean; statusLoading: boolean; error: DirectoryError | null; /** Open a link order → sets status "pending" + qr. */ startLink(): Promise; /** Poll the open order; on completion sets linked=true. Drive on an interval while pending. */ refresh(): Promise; /** Abort the in-flight order. */ cancel(): Promise; /** Remove the link (rejects with DirectoryError code LAST_AUTH_METHOD when it is the only method). */ unlink(): Promise<{ linked: boolean }>; /** Re-read { linked, available }. */ refetchStatus(): Promise; } /** * Link / unlink a BankID identity to the signed-in app-user and read the * current link + availability state. Reads `ctx.directory.bankid`. No * requestedScopes entry is required — the endpoints are gated by the app-user * session the host holds. Hide the Link affordance when `available` is false. */ export function useBankIdLink(): BankIdLinkApi; // ----------------------------------------------------- useIdentification // // REQ-IDENT — identify a visitor who is NOT signed in and keep the result. // Reads the injected identification-client at `ctx.identification`. Anonymous by // design — no requestedScopes entry and no session needed. /** Stable machine codes on an IdentificationError. */ export type IdentificationErrorCode = | "NOT_CONFIGURED" | "UNKNOWN_PROVIDER" | "NOT_FOUND" | "RATE_LIMITED" | "UNAVAILABLE" | "INTERNAL"; export class IdentificationError extends Error { code: IdentificationErrorCode; cause?: unknown; } export interface UseIdentificationOptions { /** Provider to identify with. Defaults to "bankid". */ provider?: string; /** Short audit label ("attest", "age_check"). Capped at 120 chars server-side. */ purpose?: string; /** Poll cadence while pending, in ms. Defaults to 1000; 0 disables auto-polling. */ pollIntervalMs?: number; } export interface IdentificationApi { /** Whether identification can be used here (provider configured + enabled). Gate the UI on this. */ available: boolean; availabilityLoading: boolean; /** The active order's state, or null when no order is in flight. */ status: "pending" | "complete" | "failed" | "cancelled" | null; /** PNG data-URL of the animated QR while pending (render with the Image primitive). */ qr: string | null; /** Same-device deeplink token: `bankid:///?autostarttoken=&redirect=null`. */ autoStartToken: string | null; /** Display-ready instruction for the current step. */ message: string | null; /** The verified person, set once status is "complete". */ identity: Identity | null; /** The order id — store it alongside an attestation to trace back to the proof. */ identificationId: string | null; loading: boolean; error: IdentificationError | null; /** Open an order -> sets status "pending" + qr, then polls to completion. */ start(): Promise; /** Poll the open order once by hand (for pollIntervalMs: 0). */ refresh(): Promise; /** Abort the in-flight order. */ cancel(): Promise; /** Clear the flow so the visitor can start over. */ reset(): void; } /** * Identify a visitor who is NOT signed in, so the app can keep the result (an * attestation on a record, a consent line, a pre-submit identity check). Polls * the order for you while pending and clears the timer on unmount. * * 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 use `useFileSignature()`. Hide the affordance when `available` is false. */ export function useIdentification( options?: UseIdentificationOptions, ): IdentificationApi; // ----------------------------------------------------- useRecordPermissions // // REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission // management for a single record. Reads the injected datastore-client at // `ctx.datastore.records(tableId).permissions(recordId)`. Rows and bodies are // snake_case VERBATIM — the SDK does NOT transform them. A row carries // `user_id` OR `group_id` (both null = a public grant) plus the `can_*` flags. export interface RecordPermission { id: string; /** Set when the grant targets a user; null otherwise. */ user_id: string | null; /** Set when the grant targets a group; null otherwise. Both null = public. */ group_id: string | null; can_read: boolean; can_write: boolean; can_delete: boolean; can_grant: boolean; [k: string]: unknown; } export interface RecordPermissionGrantInput { /** Target a user (omit / null `group_id`). */ user_id?: string | null; /** Target a group (omit / null `user_id`). Both omitted = a public grant. */ group_id?: string | null; can_read?: boolean; can_write?: boolean; can_delete?: boolean; can_grant?: boolean; } export interface RecordPermissionUpdateInput { can_read?: boolean; can_write?: boolean; can_delete?: boolean; can_grant?: boolean; } export interface RecordPermissionsResult { permissions: RecordPermission[]; loading: boolean; error: PermissionError | null; /** * Grant a new permission on the active record. Resolves to the created * row. Rejects with `PermissionError` on HTTP failure. */ grant(body: RecordPermissionGrantInput): Promise; /** Revoke an existing permission row. Rejects with `PermissionError`. */ revoke(permissionId: string): Promise; /** * Patch the flags on an existing permission row. Resolves to the * updated row. Rejects with `PermissionError`. */ update( permissionId: string, body: RecordPermissionUpdateInput, ): Promise; refetch(): Promise; } export class PermissionError extends Error { code: | "FORBIDDEN" | "VALIDATION" | "NOT_FOUND" | "CONFLICT" | "INTERNAL" | string; status?: number; /** sc-4986 — whether retrying the SAME call could plausibly succeed. */ retryable: boolean; constructor( code: PermissionError["code"], message: string, opts?: { status?: number | null; retryable?: boolean; cause?: unknown; }, ); } /** * REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission * management. Reads the injected datastore-client at * `ctx.datastore.records(tableId).permissions(recordId).{list,grant,update,revoke}`. * Requires `acl.write:records` in the manifest's `requestedScopes`. The * backend gates the call on `can_grant` for the target record; a widget that * declares the scope but whose caller lacks the grant receives * `PermissionError { code: "FORBIDDEN" }`. Rows and bodies are snake_case * verbatim. * * When `tableId` OR `recordId` is null / empty, the hook collapses to a * stable empty result without a network round-trip; mutation methods * are safe no-ops in that state. */ export function useRecordPermissions( tableId: string | null | undefined, recordId: string | null | undefined, ): RecordPermissionsResult; export interface CanWriteOptions { recordId?: string; } export interface CanWriteResult { canWrite: boolean; loading: boolean; error: DatastoreError | null; refetch(): Promise; } /** * sc-5206 — is the signed-in caller permitted to write to `tableId` (or, with * `options.recordId`, to that one row)? A FLOOR, not a full replacement for * domain-specific write rules: a widget whose 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 to this hook. Answers "signed in AND * permitted" — pair with `useUser()` to also tell "not signed in" apart from * "signed in but forbidden". Falsy `tableId`, or a host that has not * injected `ctx.datastore.myPermissions`, collapses to `{ canWrite: false, * loading: false, error: null, refetch: async () => undefined }` rather than * throwing. */ export function useCanWrite( tableId: string | null | undefined, options?: CanWriteOptions, ): CanWriteResult; export function WidgetContextProvider(props: { value: WidgetContext; children?: ReactNode; }): JSX.Element; // Primitives — re-exported from `react-native` (web build aliases to // `react-native-web`). Typed as opaque components here; widget authors // targeting TypeScript should install `@types/react-native` for full // prop typing. export const Text: any; export const View: any; export const Pressable: any; export const Image: any; export const ScrollView: any; export const TextInput: any; export const FlatList: any; export const SectionList: any; export const ActivityIndicator: any; export const Switch: any; export const StyleSheet: any; /** * REQ-WSDK-PLATFORM §6 — Lucide icon primitive. Unknown names fall back * to the `Square` glyph so the canvas always shows something visible. * * @example * import { Icon } from '@colixsystems/widget-sdk'; * */ export const Icon: (props: { name?: string; size?: number; color?: string; }) => any; /** * REQ-AI-AGENT-DESIGN (sc-3696) — linear-gradient surface. A `View` that paints * a gradient behind its children, so it replaces the `View` you would otherwise * give a flat `backgroundColor`. Web paints a CSS `linear-gradient`; native * delegates to expo-linear-gradient through the same angle projection. * * @example * * Log a glass * */ export const Gradient: (props: { /** Two or more colour stops. Use theme colour roles, never raw hex. */ colors: string[]; /** CSS degrees: 0 = to top, 90 = to right. Defaults to 180 (to bottom). */ angle?: number; style?: any; children?: ReactNode; }) => any; /** * sc-6607 — screen-level overlay. Renders OUTSIDE the widget's layout box on * both hosts (web portals it to the document root, native uses the OS modal), * so no clipping card, scroll container or neighbouring widget can cut it off. * Use it for anything that takes over the screen — a document/media preview, a * lightbox, a confirm dialog. An anchored dropdown still belongs inside the * widget's own root. * * @example * setPreview(null)} size="full"> * {renderPreview(preview)} * */ export const Overlay: (props: { /** Nothing renders (and nothing is mounted) while this is false. */ visible?: boolean; /** Backdrop press, Escape (web) and the Android back button all fire this. */ onRequestClose?: () => void; /** Panel width tier. `full` runs edge to edge. Defaults to `md`. */ size?: "sm" | "md" | "lg" | "full"; /** Set false when only an explicit control may close the overlay. */ dismissOnBackdropPress?: boolean; accessibilityLabel?: string; /** Label for the backdrop's dismiss target. Defaults to "Close". */ closeAccessibilityLabel?: string; /** Extra styles merged onto the themed panel. */ style?: any; children?: ReactNode; }) => any; // ----------------------------------------------------- formatted content // sc-6970 — widgets have no HTML path: they render through React Native // primitives, which have no `dangerouslySetInnerHTML` on either host. Content // with formatting is therefore markdown text, authored with `` // and displayed with ``. /** One inline run of a parsed markdown line. */ export interface MarkdownSpan { text: string; bold?: boolean; italic?: boolean; code?: boolean; } /** One parsed markdown block. Image blocks carry no spans. */ export type MarkdownBlock = | { type: "heading"; level: 1 | 2 | 3; spans: MarkdownSpan[] } | { type: "paragraph"; spans: MarkdownSpan[] } | { type: "bullet"; marker: string; spans: MarkdownSpan[] } | { type: "ordered"; marker: string; spans: MarkdownSpan[] } | { type: "image"; src: string; alt: string; size: MarkdownImageSize }; export type MarkdownImageSize = "small" | "medium" | "large" | "full"; export const MARKDOWN_IMAGE_SIZES: readonly MarkdownImageSize[]; /** Parses the markdown subset into blocks. Non-string input yields `[]`. */ export function parseMarkdown(text: unknown): MarkdownBlock[]; /** Marker-free projection of `text`, for search, previews and a11y labels. */ export function markdownToPlainText(text: unknown): string; /** Reads one line as an image block, or `null` when it is not one. */ export function parseMarkdownImage( line: unknown, ): { src: string; alt: string; size: MarkdownImageSize } | null; /** Writes an image block back to its one-line markdown form. */ export function formatMarkdownImage(block: { src?: string; alt?: string; size?: string; }): string; /** * Normalises stored content to markdown: HTML-bearing text has its tags * stripped to line breaks, anything else is returned unchanged. HTML is never * interpreted. */ export function stripHtmlToMarkdown(text: unknown): string; /** Un-escapes an alt captured by the image grammar. */ export function readMarkdownAlt(raw: unknown): string; /** True when `src` is an http(s) URL rather than a filestore id. */ export function isHttpImageSrc(src: unknown): boolean; /** * True when `src` is renderable — an http(s) URL or a filestore id. Content is * author-supplied, so anything else (a `javascript:` scheme, say) is refused. */ export function isSafeMarkdownImageSrc(src: unknown): boolean; /** Coerces any value to a known image size, defaulting to `"full"`. */ export function normaliseMarkdownImageSize(size: unknown): MarkdownImageSize; /** * sc-6970 — renders the markdown subset with SDK primitives, so formatted * content reads the same in the web Player and the exported Expo app. `value` * is markdown, never HTML. * * @example * */ export const RichText: (props: { /** Markdown text. Legacy HTML is stripped to plain text, never rendered. */ value?: string; /** * Resolves an image block to a node. Supply it when images are filestore * ids — without it only absolute http(s) URLs render. */ renderImage?: (block: { src: string; alt: string; size: MarkdownImageSize; }) => ReactNode; style?: any; testID?: string; }) => any; /** * sc-6970 — the authoring half of ``: a multi-line field whose * toolbar wraps the current selection in markdown markers, with a live preview * of the rendered result. Use it instead of a bare `TextInput` plus hand-rolled * formatting buttons, which can only splice literal tags into the text. * * @example * */ export const MarkdownInput: (props: { /** Markdown text. */ value?: string; /** Receives the next markdown string on every edit. */ onChange?: (next: string) => void; placeholder?: string; /** Heading above the live preview. Defaults to "Preview". */ previewLabel?: string; /** Set false to hide the live preview. Defaults to true. */ showPreview?: boolean; /** Passed through to the preview's ``. */ renderImage?: (block: { src: string; alt: string; size: MarkdownImageSize; }) => ReactNode; /** Field floor, so it opens at a usable height. Defaults to 150. */ minHeight?: number; /** Field ceiling, so it scrolls internally instead of growing. Defaults to 280. */ maxHeight?: number; accessibilityLabel?: string; style?: any; testID?: string; }) => any; // ------------------------------------------------------- theme derivation // sc-3696: the colour maths both hosts resolve `useTheme()` with. Exported so // the Player (frontend/src/services/widgetTheme.js) and the exported app's // generated theme module share ONE implementation instead of mirrored copies. /** True when `value` is a 3- or 6-digit hex colour string. */ export function isHexColor(value: unknown): boolean; /** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */ export function mixHex(a: string, b: string, t: number): string; /** WCAG contrast ratio between two hex colours. Always >= 1. */ export function contrastRatio(a: string, b: string): number; /** Pick `dark` or `light` — whichever is readable on the given background. */ export function readableTextColor( hex: string, dark: string, light: string, ): string; /** * Derive the accent's quiet tiers from a resolved palette. `onPrimarySoft` is * stepped toward `onSurface` until it clears WCAG AA against the tint, so a chip * label is legible for every tenant accent. `null` when the inputs aren't hex. */ export function deriveAccentTints( primary: string, surface: string, onSurface: string, ): { primarySoft: string; onPrimarySoft: string; primaryStrong: string; } | null; /** * Map a CSS gradient angle (0 = to top, 90 = to right) to the `{ start, end }` * unit vectors expo-linear-gradient expects. */ export function gradientAngleToVector(angle: number): { start: { x: number; y: number }; end: { x: number; y: number }; }; export interface ComponentGradient { from: string; to: string; angle: number; } /** * Normalise a component `gradient` style value to `{ from, to, angle }`, or null * when it is unusable. Both stops are required; the angle wraps into 0-359. * Shared by the theme-token coercion and the widget render path, so the same * value cannot resolve two ways. */ export function normaliseComponentGradient( raw: unknown, ): ComponentGradient | null; // Linter export interface LintFinding { rule: string; /** * REQ-WSDK-PLATFORM update: findings can now carry a `severity`. The * lint's `ok` flag is true iff no `severity: "error"` finding exists. * `severity: "warning"` findings surface to reviewers but do not block. */ severity?: "error" | "warning"; label: string; line: number; snippet: string; } export interface LintOptions { manifest?: { requestedScopes?: string[]; supportedPlatforms?: string[] }; } export function lintSource( source: string, options?: LintOptions, ): { ok: boolean; findings: LintFinding[]; }; // --------------------------------------------------------------- CONTRACT // // Single-source-of-truth contract artefact. See docs/design/ai-widget-contract.md. // The runtime value is `Object.freeze`d; the type below describes the // public shape consumers can read. export interface ContractHookEntry { name: string; signature: string; /** Arguments and option keys in `signature` that may be omitted (sc-6946). */ optionalArgs?: string[]; returnShape: Record; requiredContextSlice: string[]; scopes: string[] | null; } export interface ContractPrimitiveEntry { name: string; description: string; props: Record< string, { type: string; required?: boolean; description?: string } >; } export interface ContractManifestField { type: string; required: boolean; description?: string; default?: unknown; values?: readonly string[]; example?: unknown; } export interface ContractBundleShape { name: string; description: string; predicate: string; manifestSource: string; audience: string; } export interface ContractBannedApi { identifier: string; reason: string; } export interface AiWidgetContract { readonly version: string; readonly hooks: ReadonlyArray; readonly primitives: ReadonlyArray; readonly manifestSchema: Readonly>; readonly manifestCategories: ReadonlyArray; readonly manifestPlatforms: ReadonlyArray; readonly themeTokens: ThemeTokens; /** sc-7800 — the brand colour tokens a `type: "color"` field may bind to. */ readonly themeColorTokens: ReadonlyArray<{ path: string; label: string }>; readonly widgetContextShape: Readonly< Record< string, { description: string; required: boolean; fields: Record; } > >; readonly bundleExportContract: ReadonlyArray; readonly bannedApis: ReadonlyArray; readonly allowedBareImports: ReadonlyArray; /** sc-4686 — the render-time fallback currency when the host supplies none. */ readonly chargeCurrency: string; /** sc-4686 — how each currency is written; read by `formatMoney`, never Intl. */ readonly currencyFormats: Readonly>>; } export const CONTRACT: AiWidgetContract; export function isHookAllowed(name: string): boolean; export function requiredContextKeys(): string[]; /** * sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — interaction feedback for a tappable * surface. Call inside a Pressable's style function and spread the result: * ` [styles.card, ...pressableLift(state)]}>`. * Web raises the surface while hovered or pressed (with the transition built * in); native raises it while pressed. */ export function pressableLift(state?: { hovered?: boolean; pressed?: boolean; }): Array | null>; /** * @deprecated (sc-7344) — inert; nothing reads the marker. It marked which * rendered element a styleSchema `ui.group` painted, for a Widget Builder * preview click-to-select that no longer exists. Still exported and safe: * existing spreads keep working as no-ops, so no migration is needed. * * A missing or blank name returns `undefined`, so spreading is always safe. */ export function styleGroup( name: string, ): { dataSet: Record } | undefined;