@colixsystems/widget-sdk 0.18.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -29
- package/dist/contract.cjs +228 -113
- package/dist/contract.js +238 -122
- package/dist/hooks.js +781 -570
- package/dist/index.d.ts +257 -65
- package/dist/index.js +1 -0
- package/dist/index.native.js +1 -0
- package/dist/linter.cjs +56 -0
- package/dist/linter.js +57 -0
- package/dist/manifest.cjs +157 -2
- package/dist/manifest.js +157 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,30 +1,113 @@
|
|
|
1
1
|
# @colixsystems/widget-sdk
|
|
2
2
|
|
|
3
|
-
Common widget interface for [AppStudio](https://github.com/appstudio). This package implements the contract that every widget
|
|
3
|
+
Common widget interface for [AppStudio](https://github.com/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.**
|
|
4
|
+
|
|
5
|
+
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:
|
|
6
|
+
|
|
7
|
+
| Injected at | Package | Surface (snake_case verbatim, `list` → `{ data, meta }`) |
|
|
8
|
+
| ----------- | ------- | -------- |
|
|
9
|
+
| `ctx.datastore` | `@colixsystems/datastore-client` | `tables.{list,get}`, `schema(tableId)`, `records(tableId).{ list(query), get(id), create(values), update(id,values) [PATCH], delete(id), aggregate(spec), permissions(recordId).{ list, grant, update, revoke } }` |
|
|
10
|
+
| `ctx.directory` | `@colixsystems/directory-client` | `me()`, `users.{list,get,invite,deactivate,reactivate}`, `groups.{list,create,remove,addMember,removeMember,listMine}`, `invites.{list,revoke,resend}` |
|
|
11
|
+
| `ctx.files` | `@colixsystems/files-client` | the Asset Manager: `get(id)`, `list(query)`, `upload(formData)` over `/files` — what `useFile()` resolves |
|
|
12
|
+
| `ctx.payments` | `@colixsystems/payments-client` | `requestPayment(body)`, `getPayment(id)` |
|
|
13
|
+
|
|
14
|
+
**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 client-side case transform anywhere** — the only transform left in the system is the backend's wire ↔ Prisma middleware. Author-defined record column values pass through verbatim. Every `list(...)` returns the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you.
|
|
15
|
+
|
|
16
|
+
**Hooks read the injected clients** — they do not hold their own HTTP. This is the **complete** hook surface (18 hooks), grouped by the domain client each one reads. **CORE** hooks read host state directly off `WidgetContext` (no data client); the rest delegate to one of the four injected clients. The grouping mirrors the banner sections in [`src/hooks.js`](src/hooks.js).
|
|
17
|
+
|
|
18
|
+
| Group | Hook (signature) | Returns | Reads / scope |
|
|
19
|
+
| ----- | ---------------- | ------- | ------------- |
|
|
20
|
+
| **CORE** | `useTheme()` | `{ colors, spacing, radii, typography }` | `ctx.workspace.theme` — no scope |
|
|
21
|
+
| **CORE** | `useUser()` | `{ id, email, display_name, roles, group_ids }` | `ctx.user` (snake_case verbatim; `id` null when anonymous) — no scope |
|
|
22
|
+
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
|
|
23
|
+
| **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
|
|
24
|
+
| **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
|
|
25
|
+
| **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
|
|
26
|
+
| **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
|
|
27
|
+
| **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
|
|
28
|
+
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) then falls back to the raw key. |
|
|
29
|
+
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
30
|
+
| **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
|
|
31
|
+
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
32
|
+
| **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*` |
|
|
33
|
+
| **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) |
|
|
34
|
+
| **FILES** (`ctx.files`) | `useFile(id)` | `{ url, file, loading, error, refetch }` | `ctx.files.get` — no scope |
|
|
35
|
+
| **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
|
|
36
|
+
| **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` | `directory.users.*` — `users.read:*` (mutations also `users.write:*`) |
|
|
37
|
+
| **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
|
|
38
|
+
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
|
|
39
|
+
|
|
40
|
+
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 }`).
|
|
41
|
+
|
|
42
|
+
`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.
|
|
4
43
|
|
|
5
44
|
See the design reference for the full architecture: [`docs/architecture/widget-marketplace.md`](../../docs/architecture/widget-marketplace.md), specifically section 3.1.
|
|
6
45
|
|
|
7
46
|
## Status
|
|
8
47
|
|
|
9
|
-
`v0.
|
|
48
|
+
`v0.21.1` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
10
49
|
|
|
11
|
-
### What's new in 0.
|
|
50
|
+
### What's new in 0.21.1
|
|
12
51
|
|
|
13
|
-
|
|
52
|
+
**Default theme tokens corrected to the product's advertised brand (fix).**
|
|
14
53
|
|
|
15
|
-
- **`
|
|
16
|
-
- **`CONTRACT.version` → `1.
|
|
54
|
+
- **`themeTokens.colors.primary` → `#3b82f6` (blue), `colors.secondary` → `#10b981` (green).** Previously these defaulted to a stale coral/slate that no other surface used — the Theme Settings tab and the Player chrome already advertised the blue/green default. A tenant that never customised its theme therefore rendered widgets (`useTheme().colors.primary`) in coral until a first save persisted the blue, a visible divergence between the unsaved and saved-defaults render. No export, signature, type, or token shape changed — default values only.
|
|
55
|
+
- **`CONTRACT.version` → `1.11.1`.** Patch: a default-value fix; the documented contract (token names + shape) is unchanged.
|
|
56
|
+
|
|
57
|
+
### What's new in 0.21.0
|
|
58
|
+
|
|
59
|
+
**Widgets can fill their page-grid tile's height (REQ-LAY-08).**
|
|
60
|
+
|
|
61
|
+
- **New `useFill()` hook.** Returns a `boolean` — `true` when the host has sized this widget to fill the available height of its layout slot (a page-grid tile whose author chose "Fill tile height", or a widget type that fills by default: the layout containers + the media widgets Image / Chart / Map / Video). A widget that has a meaningful filled form switches to a stretch layout (`flex: 1` / `height: "100%"`) when it reads `true`; others ignore it. Defaults to `false`, so calling it is always safe.
|
|
62
|
+
- **New optional `WidgetContext.fill` slice** backs the hook. It is optional (defaults `false`), so existing hosts and widgets are unaffected. The web Player host and the native export host inject the SAME value, so a widget's fill behaviour is identical on both platforms.
|
|
63
|
+
- **`CONTRACT.version` → `1.11.0`** (additive: one new hook + one new optional context slice). No existing export changed signature.
|
|
64
|
+
|
|
65
|
+
### What's new in 0.20.0
|
|
66
|
+
|
|
67
|
+
**Widgets can ship their own translations (REQ-L10N-WIDGET).**
|
|
68
|
+
|
|
69
|
+
- **New optional `manifest.translations` field.** Shape `{ <key>: { en: string, <locale>?: string } }` — `en` is required per key; additional locales are optional. `validateManifest` structurally validates it (≤100 keys, key matches `/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`, value ≤1 KB, namespaced key ≤128 chars); the marketplace analyzer enforces the same caps.
|
|
70
|
+
- **`useI18n().t(key)` now auto-namespaces.** The host derives a per-widget prefix from the widget id and resolves `widget.<id>.<key>` first, then falls back to the raw key (so shared app keys and pre-1.10 widgets are unaffected). Authors call `t("greeting")` and never type the prefix — the same behaviour on web and in the exported native app (both hosts inject `ctx.widget.id`).
|
|
71
|
+
- **Install-time seeding.** When a widget is installed, the host merges its `translations` into the tenant's localization dictionary under that namespace — non-destructively (it never overwrites an admin's edit, never creates a language the tenant didn't add, and seeds the tenant's base language from the widget's `en` so every key renders). Keys persist across uninstalls; admins prune them with the Translations screen's bulk delete (by id or by `widget.<id>.` prefix).
|
|
72
|
+
- **New exported helpers** `widgetTranslationPrefix(id)` / `widgetTranslationKey(id, key)` (from `@colixsystems/widget-sdk/contract`) are the single source of the key format, shared by `useI18n` and the host seeder.
|
|
73
|
+
- **`CONTRACT.version` → `1.10.0`** (additive: one new optional manifest field + the `useI18n` namespacing behaviour). No existing export changed signature.
|
|
74
|
+
|
|
75
|
+
### What was in 0.19.0
|
|
76
|
+
|
|
77
|
+
**The data layer splits into four injected domain clients; the SDK becomes core-only.**
|
|
78
|
+
|
|
79
|
+
- **The SDK no longer owns any data facade.** The bespoke per-hook facades that used to live on `WidgetContext` (`ctx.datastore` as an opaque host object, `ctx.directory.listUsers`, `ctx.users`, `ctx.groups`, `ctx.recordPermissions`, `ctx.files.get`) are replaced by four host-instantiated, host-injected domain clients: `ctx.datastore` (`@colixsystems/datastore-client`), `ctx.directory` (`@colixsystems/directory-client`), `ctx.files` (`@colixsystems/files-client`, flattened), `ctx.payments` (`@colixsystems/payments-client`). The SDK imports none of them and ships no HTTP.
|
|
80
|
+
- **`ctx.recordPermissions`, `ctx.users`, `ctx.groups` are removed.** Per-record permission management moved under `ctx.datastore.records(tableId).permissions(recordId)`; user / group administration moved under `ctx.directory.users` / `ctx.directory.groups`. The hooks (`useRecordPermissions`, `useUsers`, `useGroups`) keep the same names and signatures — only the client slice they read changed.
|
|
81
|
+
- **snake_case end to end, no client-side transform.** Clients send and return snake_case verbatim (`group_ids`, `can_read`, `is_active`, `amount_cents`, `data_type`, `created_at`, …). The SDK passes bodies straight through and unwraps the `{ data, meta }` list envelope without renaming a single field. Hook return rows and `useUser()` fields are therefore snake_case (`display_name`, `group_ids`, `is_active`, `member_count`, and `useRecordPermissions` rows carry `user_id` / `group_id` / `can_read` / `can_write` / `can_delete` / `can_grant`).
|
|
82
|
+
- **Companion package versions:** `datastore-client 0.5.0`, `files-client 0.3.0`, `directory-client 0.1.0`, `payments-client 0.1.0`.
|
|
83
|
+
- **`CONTRACT.version` → `1.9.0`.** Breaking for `WidgetContext` consumers (removed slices, renamed wire fields); the hook export surface is unchanged.
|
|
84
|
+
|
|
85
|
+
### What was in 0.18.0
|
|
86
|
+
|
|
87
|
+
Two additive features land in this version.
|
|
88
|
+
|
|
89
|
+
**A widget may declare server-side actions in its manifest.**
|
|
90
|
+
|
|
91
|
+
- **`WidgetManifest.actions` is now part of the public contract.** An optional array; each entry is `{ key, name, description?, triggerType, scheduleCron?, timeoutMs?, scriptSource }`. `triggerType` is one of `schedule`, `record_created`, `record_updated`, `record_deleted`; `scheduleCron` is required when `triggerType === "schedule"`. The `scriptSource` (≤ 200 KiB) runs in the **shared isolated-vm action runner** — against `datastore` / `fetch` / `console` / `record` / `tenantId` (the runner surface, **not** the React/SDK widget surface, so SDK imports and hooks are unavailable there and the component linter does not scan it). Actions never run in the rendered app, so they have **no effect on Player ↔ export parity**.
|
|
92
|
+
- **Operators enable actions per tenant** from the Properties Panel. An enabled action materialises a tenant `Action` row **DISABLED** until the operator binds an integration API key (and, for `record_*` triggers, a target table) in the Actions admin page — those bindings are tenant-local, so `triggerTableId` / `apiKeyId` must **not** appear in the manifest (the validator and linter reject them).
|
|
93
|
+
- **New contract fields** `CONTRACT.actionTriggerTypes`, `CONTRACT.actionScriptGlobals`, `CONTRACT.actionScriptMaxBytes` expose the grammar so the Developer page, the AI agent prompt, and `validateManifest` derive it from one source. `validateManifest` now structurally validates `actions`; the marketplace linter rejects malformed / oversized declarations.
|
|
94
|
+
- **New manifest category `ADMINISTRATION`** for app-administration widgets such as User Management. Added to `CONTRACT.manifestCategories`, `validateManifest`, the `WidgetCategory` type, the marketplace category list, and the master-DB `WidgetCategory` enum.
|
|
95
|
+
|
|
96
|
+
**Per-record permission management from inside a widget.**
|
|
97
|
+
|
|
98
|
+
- **`useRecordPermissions(tableId, recordId)` is wired** + the new `WidgetContext.recordPermissions` slice + the new `acl.write:records` scope. Returns `{ permissions, loading, error, grant, revoke, update, refetch }` where `permissions` is `Array<{ id, principalType: "USER" | "GROUP" | "PUBLIC", principalId, canRead, canWrite, canDelete, canGrant }>`. Rejections from `grant` / `revoke` / `update` surface as a structured `PermissionError` (named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | CONFLICT | INTERNAL`. When `tableId` or `recordId` is null/empty the hook collapses to a stable empty no-op result. Mutating requires `acl.write:records` AND `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. Backs the built-in Chat widget's "+ New channel" + DM-create flows. Additive.
|
|
99
|
+
- **`CONTRACT.version` → `1.8.0`** (additive: a new optional `actions` manifest field, three new contract fields, the `ADMINISTRATION` category, the `useRecordPermissions` hook, the `recordPermissions` context slice, the `acl.write:records` scope, and the `PermissionError` class). No existing export changed signature.
|
|
17
100
|
|
|
18
101
|
### What was in 0.17.0
|
|
19
102
|
|
|
20
|
-
|
|
103
|
+
A runtime schema resolver so widgets can render by column type.
|
|
21
104
|
|
|
22
|
-
- **`useDatastoreSchema(tableId)` is wired** + the new `WidgetContext.datastore.schema` slice. Returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }` (`null` until loaded).
|
|
105
|
+
- **`useDatastoreSchema(tableId)` is wired** + the new `WidgetContext.datastore.schema` slice. Returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }` (`null` until loaded). Reads structure only, never row data, so a public-grant table resolves for anonymous visitors like a record read. Use it to resolve a stored `columnId` to its name / dataType / relation target at runtime. Reads need the `datastore.read:<table>` scope. Additive.
|
|
23
106
|
- **`CONTRACT.version` → `1.7.0`** (additive: one new hook, one new `datastore.schema` context field). No existing export changed signature.
|
|
24
107
|
|
|
25
108
|
### What's new in 0.16.0
|
|
26
109
|
|
|
27
|
-
|
|
110
|
+
The tenant's **Theme Settings** now flow all the way into `useTheme()`.
|
|
28
111
|
|
|
29
112
|
- **`themeTokens.colors` gains `secondary` + `onSecondary`.** `useTheme().colors.secondary` reflects the tenant's *Secondary Color* picker (with `onSecondary` as its readable contrast color), alongside the existing `primary` / `onPrimary`. Built-in widgets like Button use it for their secondary variant; third-party widgets can use it for a branded second accent. The full `colors` shape is now `{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }`.
|
|
30
113
|
- **`colors.primary` / `colors.secondary` / `typography.fontFamily` are tenant-resolved.** The host maps the Studio Theme Settings blob (Primary Color, Secondary Color, Global Font) onto the default tokens before handing them to `useTheme()`, on both the live Player and the exported app — so a widget that reads tokens re-themes automatically. (Custom Google fonts render in the Player today; the exported app falls back to the system face for non-system fonts until font bundling lands.)
|
|
@@ -32,7 +115,7 @@ REQ-THEME — the tenant's **Theme Settings** now flow all the way into `useThem
|
|
|
32
115
|
|
|
33
116
|
### What's new in 0.15.0
|
|
34
117
|
|
|
35
|
-
|
|
118
|
+
The "split-implementation + vetted package list" pivot.
|
|
36
119
|
|
|
37
120
|
- **`CONTRACT.vettedImports` (new).** A curated allowlist of bare specifiers a widget may import — `react`, `@colixsystems/widget-sdk`, `react-native`, `axios`, `date-fns`, `react-native-svg`, `lucide-react-native`, `react-native-maps`, `leaflet`, `react-leaflet`, `expo-av`, `@react-native-community/datetimepicker`, `expo-clipboard`, `expo-haptics`. Each entry carries `platforms` (one or both of `"web"` / `"native"`) and a `category` so the linter and the marketplace listing can render honest platform badges. `CONTRACT.allowedBareImports` (the existing field) is now derived from `vettedImports` and stays a plain `string[]` for back-compat.
|
|
38
121
|
- **`fetch` and `XMLHttpRequest` come off `CONTRACT.bannedApis`.** Widgets may call third-party APIs directly. Calls to the host's own `/api/*` surface will 401 because the JWT token is never shared with widget code; the linter emits a soft `no-host-api-url` warning when it sees host-URL substrings so authors learn the rule statically. Use SDK hooks (`useDatastoreQuery`, `useUsers`, `useFile`, …) for workspace data; use `axios` / `fetch` for third-party APIs.
|
|
@@ -48,12 +131,12 @@ REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. Se
|
|
|
48
131
|
|
|
49
132
|
### What was in 0.14.1
|
|
50
133
|
|
|
51
|
-
- **`groupRef` property type
|
|
134
|
+
- **`groupRef` property type.** Authors can declare `{ type: 'groupRef', label: 'Group' }` in their `propertySchema` to render a Group picker in the Studio Properties Panel. The widget receives a bare `AppUserGroup` UUID, so tenant-copy walks the value transparently. Used by the built-in `appstudio.user-management` widget for its `defaultGroupId` prop and available to third-party widgets that need to anchor behaviour on a specific group.
|
|
52
135
|
- **Patch bump** — additive enumeration entry, no exported function signature changed. `CONTRACT.version` stays `1.4.0`.
|
|
53
136
|
|
|
54
137
|
### What's new in 0.14.0
|
|
55
138
|
|
|
56
|
-
- **`useUsers()` + `useGroups()` — AppUser administration hooks
|
|
139
|
+
- **`useUsers()` + `useGroups()` — AppUser administration hooks.** A widget can invite, deactivate, reactivate, and remove members, and create / delete groups + add / remove members, from a published-app surface. Returns `{ users | groups, loading, error, refetch, ... }` plus imperative mutation methods. Reads gated by `users.read:*` / `groups.read:*`; mutations by `users.write:*` / `groups.write:*`. Rejections surface as a structured `DirectoryError` (new named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY`. The host signs an `X-Widget-Scopes` header against `JWT_SECRET` so an APP_USER cannot forge a scope set, and the backend additionally gates the request behind a SystemAcl `users.*` / `groups.*` capability grant.
|
|
57
140
|
- **Managing app users from a widget — see the section below.**
|
|
58
141
|
- **New linter rule `no-scope-mismatch-useUsers` / `no-scope-mismatch-useGroups`.** Calling `useUsers().invite()` / `.deactivate()` / `.reactivate()` / `.remove()` without `users.write:*` in the manifest's `requestedScopes` fails the lint; calling `useGroups()` mutation methods without `groups.write:*` fails the lint. Keeps the manifest honest at submission time.
|
|
59
142
|
- **`CONTRACT.version` → `1.4.0`** (additive: two new hooks, two new context slices, six new scope verbs, one new error class). No existing export changed signature.
|
|
@@ -78,12 +161,12 @@ REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. Se
|
|
|
78
161
|
|
|
79
162
|
### What's new in 0.9.0
|
|
80
163
|
|
|
81
|
-
- **`usePayments()` — incoming app-user payments
|
|
164
|
+
- **`usePayments()` — incoming app-user payments.** Returns `{ requestPayment, getPayment }`. `requestPayment({ amountCents, currency?, description, metadata?, returnPath? })` triggers a one-time charge from the signed-in app user and resolves to `{ id, status, checkoutUrl? }`: when `checkoutUrl` is present the widget opens it (web: navigate; native: `expo-web-browser`) — the user pays in **hosted Stripe Checkout**; when absent (the platform's built-in **mock** provider, the default until Stripe is configured) the charge auto-confirms (`status: "PAID"`). `getPayment(id)` polls the terminal status. Backed by a new `WidgetContext.payments` slice and gated by the new `payments.charge:appUser` scope. **No card data ever touches the widget** — never collect card fields yourself. The charge settles to the workspace owner; the amount is bounded by a platform per-charge cap. Rejections are a structured `PaymentError` (also a new named export) with a stable `.code`.
|
|
82
165
|
- **`CONTRACT.version` → `1.2.0`** (additive: one new hook, one new context slice, one new scope, one new error class). No existing export changed signature.
|
|
83
166
|
|
|
84
167
|
### What was in 0.8.0
|
|
85
168
|
|
|
86
|
-
- **`useDirectory()` — read-only user directory hook
|
|
169
|
+
- **`useDirectory()` — read-only user directory hook.** Returns `{ users, loading, error, refetch }` where each user is `{ id, name, role }`. Gated by the new `directory.read:users` scope. Use it for a chat people-list, an @-mention picker, or to resolve an author id to a display name. Player callers get the reduced `{ id, name, role }` projection — email and other admin fields never leave the server for an app end-user. `query` is an optional `{ q, role, isActive, limit, offset }` (`q` substring-matches the display name; `role` is `"USER"` (default), `"INTEGRATION"`, or `"ALL"`). The directory is read-only.
|
|
87
170
|
- **`CONTRACT.version` → `1.1.0`** (additive: one new hook, one new context slice, one new scope). No existing export changed signature.
|
|
88
171
|
|
|
89
172
|
### What was in 0.7.0
|
|
@@ -102,7 +185,7 @@ REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. Se
|
|
|
102
185
|
### What was in 0.5.0
|
|
103
186
|
|
|
104
187
|
- **`TextInput` primitive.** Cross-platform controlled text field — web maps to `<input type="text">` (or `<textarea>` when `multiline` is true), native maps to `react-native` `TextInput`. Props: `value`, `onChangeText`, `placeholder`, `multiline`, `rows`, `disabled`, `style`. Fixes the gap that forced widgets to fall back to raw `<textarea>` (web-only). The AI Widget Agent's system prompt now documents this primitive.
|
|
105
|
-
- **`useDatastoreMutation(...).update(id, partial)` is wired.**
|
|
188
|
+
- **`useDatastoreMutation(...).update(id, partial)` is wired.** Partial-update semantics — only the supplied columns are mutated, the rest of the row is left intact. MANY_TO_MANY relations follow replace-set semantics when supplied; omitting the column leaves the existing links alone. Constraint enforcement runs against the merged row, excluding self so a re-affirm doesn't trip its own UNIQUE.
|
|
106
189
|
|
|
107
190
|
### What was in 0.4.1
|
|
108
191
|
|
|
@@ -129,7 +212,7 @@ REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. Se
|
|
|
129
212
|
|
|
130
213
|
### What was in 0.3.0
|
|
131
214
|
|
|
132
|
-
Additive: `WidgetManifest` carries an optional `datastoreTemplate` field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the
|
|
215
|
+
Additive: `WidgetManifest` carries an optional `datastoreTemplate` field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the built-in template semantics (auto-suffixed naming, creator-grants, public grants, cross-relation inheritance) and persist when the widget is later uninstalled. See `WidgetDatastoreTemplate` in `src/index.d.ts` for the constraints — at most 8 tables per widget, 24 columns per table, RELATION columns address siblings by `suffix`.
|
|
133
216
|
|
|
134
217
|
## Public API
|
|
135
218
|
|
|
@@ -139,14 +222,30 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
139
222
|
|
|
140
223
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
141
224
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
142
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email,
|
|
225
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, display_name, roles, group_ids }` (snake_case verbatim; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useFile(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
|
|
143
226
|
- `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
|
|
144
227
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`, `DateTimePicker` wraps `@react-native-community/datetimepicker`). The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
145
228
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
146
229
|
|
|
230
|
+
## Design & visual polish
|
|
231
|
+
|
|
232
|
+
A widget that works but looks unfinished is only half done. `useTheme()` is the styling contract — compose **with** it rather than just reading from it. This is the same guidance the AI Widget Builder follows when it generates a widget.
|
|
233
|
+
|
|
234
|
+
**Decide before you build:** the widget's shape (card, list, form, control), its hierarchy (the one thing the eye lands on first), its single accent moment, and its empty/loading/error look — then compose. Specific decisions make a distinctive widget; leaving them to default makes a generic one.
|
|
235
|
+
|
|
236
|
+
- **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners. Don't hardcode raw pixel values.
|
|
237
|
+
- **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
|
|
238
|
+
- **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.md` + a `colors.border` hairline or a subtle shadow. Use the status roles (`danger / success / warning / info`) for state.
|
|
239
|
+
- **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
|
|
240
|
+
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme.
|
|
241
|
+
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useFile()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead.
|
|
242
|
+
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
|
|
243
|
+
|
|
244
|
+
**Honest ceilings:** the styling surface is React Native style objects, not full CSS. There are no per-widget gradients, no custom CSS keyframe animations or `transition` strings, and shadows are limited to the five elevation presets (`none / sm / md / lg / xl`). Aim for clean, confident, professional polish within those bounds.
|
|
245
|
+
|
|
147
246
|
## Managing app users from a widget
|
|
148
247
|
|
|
149
|
-
|
|
248
|
+
`useUsers()` and `useGroups()` let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. Two gates apply: the manifest must declare the scope (the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller lacks the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that as a "you do not have permission" message.
|
|
150
249
|
|
|
151
250
|
```js
|
|
152
251
|
import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";
|
|
@@ -159,7 +258,7 @@ export default function MemberManager() {
|
|
|
159
258
|
<View>
|
|
160
259
|
{users.map((u) => (
|
|
161
260
|
<View key={u.id}>
|
|
162
|
-
<Text>{u.name} — {u.
|
|
261
|
+
<Text>{u.name} — {u.is_active ? "active" : "inactive"}</Text>
|
|
163
262
|
<Pressable onPress={() => deactivate(u.id)}><Text>Deactivate</Text></Pressable>
|
|
164
263
|
<Pressable onPress={() => remove(u.id)}><Text>Remove</Text></Pressable>
|
|
165
264
|
</View>
|
|
@@ -167,7 +266,7 @@ export default function MemberManager() {
|
|
|
167
266
|
<Pressable
|
|
168
267
|
onPress={async () => {
|
|
169
268
|
try {
|
|
170
|
-
await invite({ email: "a@b.com", name: "New User",
|
|
269
|
+
await invite({ email: "a@b.com", name: "New User", group_ids: [groups[0]?.id].filter(Boolean) });
|
|
171
270
|
} catch (err) {
|
|
172
271
|
// err.code is one of FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY
|
|
173
272
|
}
|
|
@@ -193,7 +292,9 @@ The host rejects calls whose scope is not declared in the manifest (the SDK lint
|
|
|
193
292
|
|
|
194
293
|
## Managing per-record permissions from a widget
|
|
195
294
|
|
|
196
|
-
|
|
295
|
+
`useRecordPermissions(tableId, recordId)` is the in-app surface for sharing a single record with another user or group. The chat widget uses it to invite members into a channel — the channel record's per-record grants ARE the membership list (messages inherit those grants). The hook also covers project-workspace, document-sharing, and team-roster widgets that grant access record-by-record.
|
|
296
|
+
|
|
297
|
+
The rows and bodies are snake_case verbatim. A row carries `user_id` OR `group_id` (both null = a public grant) plus the `can_read` / `can_write` / `can_delete` / `can_grant` flags; the hook reads `ctx.datastore.records(tableId).permissions(recordId)`.
|
|
197
298
|
|
|
198
299
|
```js
|
|
199
300
|
import { Text, View, Pressable, useRecordPermissions, PermissionError } from "@colixsystems/widget-sdk";
|
|
@@ -205,7 +306,7 @@ export default function ShareRecord({ tableId, recordId, partnerUserId }) {
|
|
|
205
306
|
<View>
|
|
206
307
|
{permissions.map((p) => (
|
|
207
308
|
<View key={p.id}>
|
|
208
|
-
<Text>{p.
|
|
309
|
+
<Text>{p.user_id || p.group_id || "public"} — {p.can_write ? "writer" : "reader"}</Text>
|
|
209
310
|
<Pressable onPress={() => revoke(p.id)}><Text>Remove</Text></Pressable>
|
|
210
311
|
</View>
|
|
211
312
|
))}
|
|
@@ -213,15 +314,14 @@ export default function ShareRecord({ tableId, recordId, partnerUserId }) {
|
|
|
213
314
|
onPress={async () => {
|
|
214
315
|
try {
|
|
215
316
|
await grant({
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
canGrant: true,
|
|
317
|
+
user_id: partnerUserId,
|
|
318
|
+
can_read: true,
|
|
319
|
+
can_write: true,
|
|
320
|
+
can_grant: true,
|
|
221
321
|
});
|
|
222
322
|
} catch (err) {
|
|
223
323
|
if (err instanceof PermissionError && err.code === "FORBIDDEN") {
|
|
224
|
-
// The signed-in user lacks
|
|
324
|
+
// The signed-in user lacks can_grant on this record.
|
|
225
325
|
}
|
|
226
326
|
}
|
|
227
327
|
}}
|
|
@@ -242,7 +342,7 @@ The manifest declares the matching scope:
|
|
|
242
342
|
}
|
|
243
343
|
```
|
|
244
344
|
|
|
245
|
-
The server-side gate is `canGrant` on the target record — Studio owners
|
|
345
|
+
The server-side gate is `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. A caller without `canGrant` receives `PermissionError { code: "FORBIDDEN" }`. The hook collapses to a stable no-op when `tableId` or `recordId` is null/empty — so a widget can render its picker first, then bind to the picked record without conditional hook tricks.
|
|
246
346
|
|
|
247
347
|
## Linter
|
|
248
348
|
|