@colixsystems/widget-sdk 0.170.0 → 0.173.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 CHANGED
@@ -25,7 +25,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
25
25
  | **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
26
26
  | **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
27
27
  | **CORE** | `useWidgetRoute(initial)` | `[state, setState]` | `ctx.widgetRoute` — no scope. Where YOUR WIDGET is, persisted by the host so it survives a reload and travels in a shared link (the `w_<instanceId>` query key on web, the screen's route params natively): the folder a browser has opened, a wizard step, a selected tab, a list's sort and search. `useState` semantics over an object — writes MERGE, `null` clears a key back to its `initial`, and `initial` is read once. Values are scalars or flat arrays of scalars, size- and length-capped; anything else is not stored. NOT history (Back still leaves the page, on both platforms). Degrades to component state on the Studio canvas. |
28
- | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope. The hook IS the emitter: `const emitSlot = useWidgetEvent("slotChosen")`, then `emitSlot(payload)`. Never destructure the result — there is no `emit` member. |
28
+ | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope. The hook IS the emitter: `const emitSlot = useWidgetEvent("slotChosen")`, then `emitSlot(payload)`. Never destructure the result — there is no `emit` member. A sibling's input reads the payload, and a page action the author binds to the event (Run action, Navigate) runs with it — fired from one control, only that control runs it. |
29
29
  | **CORE** | `useWidgetInput(inputName)` | the published payload, or `undefined` | `ctx.inputs` — no scope. Reads a value ANOTHER widget on the same page published with `useWidgetEvent`. Declare the input in `manifest.inputs`; the page author wires it to one sibling's declared event. The channel retains the last payload, so a widget that mounts later still reads it. `undefined` while unwired or before the first publish — always render a sensible default. Page-scoped and ephemeral: use `useRouteParams()` for state that must survive navigation, the datastore for state that must persist. |
30
30
  | **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
31
31
  | **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`. |
@@ -73,7 +73,72 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
73
73
 
74
74
  ## Status
75
75
 
76
- `v0.170.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
76
+ `v0.173.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**.
77
+
78
+ ### What's new in 0.173.1 (contract 1.134.1)
79
+
80
+ **An event your widget fires can run a page action, from the one control that fires it.** The author (or Mason) can bind Run action or Navigate to any event your manifest declares, and it runs when `useWidgetEvent` fires it. Fire the event from one control and only that control runs the action:
81
+
82
+ ```jsx
83
+ // manifest: events: [{ name: "onBookPressed", payloadSchema: { classId: { type: "string" } } }]
84
+ const emitBook = useWidgetEvent("onBookPressed");
85
+ <Button title="Book" onPress={() => emitBook({ classId })} />
86
+ ```
87
+
88
+ Never declare your own event as `onPress`: an `onPress` binding makes the whole widget one tap target, so a tap anywhere on it runs the action. Documentation only — both hosts already dispatched a named event's binding.
89
+
90
+ ### What's new in 0.173.0 (contract 1.134.0)
91
+
92
+ **A `datastoreTemplate` RELATION may name any table in the template.** The installer now creates every table before any column, so `targetSuffix` may point at a table declared later in `tables`, and two tables may relate to each other both ways. A suffix no table in the template has is still rejected at publish. No change is needed to an existing template.
93
+
94
+ ### What's new in 0.172.0 (contract 1.133.0)
95
+
96
+ **Tables whose rows use their full width, under a header that lines up: `ListHeader`, and a `ListRow` whose `View` child spans the row.** A `ListRow` given its own content used that content as-is, beside `leading` and `trailing` in a horizontal row. So a single row `View` of flex cells shrank to the width of its text and squeezed its cells into the left of the row, while a hand-built header above spread across the full width. A single `View` child now gets the same flexible slot the title block has, spanning the row between `leading` and `trailing`. Anything else (several cells, a component that renders cells, a lone `Text`) sits in the row exactly as before.
97
+
98
+ `<ListHeader>` labels a table's columns. It takes the same horizontal padding, gap and side edges as `<ListRow>`, from the same Theme Settings > Lists values, so a label given the same `flex` as its row cell sits over that column however the theme changes. A header `View` with its own padding cannot do that.
99
+
100
+ ```jsx
101
+ const label = textRoleStyle("overline", theme.typography, theme.colors);
102
+ const body = textRoleStyle("body", theme.typography, theme.colors);
103
+ <ScrollView style={{ flex: 1 }} stickyHeaderIndices={[0]}>
104
+ <ListHeader style={{ backgroundColor: theme.colors.surface }}>
105
+ <Text numberOfLines={1} style={[label, { flex: 2, minWidth: 0 }]}>Customer</Text>
106
+ <Text numberOfLines={1} style={[label, { flex: 1, minWidth: 0 }]}>Status</Text>
107
+ </ListHeader>
108
+ <List>
109
+ {orders.map((o) => (
110
+ <ListRow key={o.id} onPress={() => open(o)}>
111
+ <Text numberOfLines={1} style={[body, { flex: 2, minWidth: 0 }]}>{o.customer}</Text>
112
+ <Text numberOfLines={1} style={[body, { flex: 1, minWidth: 0 }]}>{o.status}</Text>
113
+ </ListRow>
114
+ ))}
115
+ </List>
116
+ </ScrollView>
117
+ ```
118
+
119
+ Give the header and the rows the same shape: the same cells, the same `flex` per column, and `minWidth: 0` on every cell. Pass the header the same list overrides you pass the `<List>` (`rowPadding`, `gap`, `dividerColor`, `dividerWidth`). Make it the first child of the rows' `ScrollView` with `stickyHeaderIndices={[0]}`: it stays in view, and it shares the rows' width beside a scrollbar, which a header outside the scroller does not. Give it the surface it sits on as its background so rows do not show through. When rows carry a `leading` or `trailing` node, pass the header a spacer of the same width in that slot. A selected row's accent bar now takes its width from the row's inset, so its cells stay under their labels too (unless the widget sets the row's padding through `style`, which is left exactly as it asked).
120
+
121
+ Installed widgets pick up the row fix with no rebuild on the web: the Player hands every widget the host's own SDK. An exported app picks it up on its next export.
122
+
123
+ ### What's new in 0.171.0 (contract 1.132.0)
124
+
125
+ **`Badge`, `Avatar`, `Progress`, `Stat` and `Divider` — the small parts a record row is made of.** A custom widget listing records had no themed status pill, avatar or progress bar, so it drew its own and never matched the built-ins. These are now SDK components, and the built-in Badge, Avatar, Progress, Stat and Divider widgets render through them:
126
+
127
+ ```jsx
128
+ <ListRow
129
+ title={order.title}
130
+ leading={<Avatar name={order.owner.label} size="sm" />}
131
+ trailing={<Badge label={order.status} tone={order.status === "Overdue" ? "danger" : "success"} />}
132
+ />
133
+ ```
134
+
135
+ - **`<Badge>`** is a record's state (Active, Paid, Overdue) beside the thing it describes. `tone` is `neutral` | `info` | `success` | `warning` | `danger`, tinted from the app's theme; the label always shows.
136
+ - **`<Avatar>`** is a person: a picture, else the name's initials, else a person glyph. `size` is `sm` | `md` | `lg` | `xl` or a number of px.
137
+ - **`<Progress>`** is real progress toward a goal, as a `bar` or a `ring`; the percent is clamped to 0-100 and printed.
138
+ - **`<Stat>`** displays a headline figure you computed, with an optional caption, icon and delta. An empty value prints `emptyValue` (default "—"), never a fake 0.
139
+ - **`<Divider>`** splits two groups inside one surface, optionally captioned. A `<List>` and a `<Card>` already draw their own edges.
140
+
141
+ `initialsFor(name)` is exported too. Additive: the built-in widgets render as before.
77
142
 
78
143
  ### What's new in 0.170.0 (contract 1.131.0)
79
144
 
@@ -1869,7 +1934,8 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
1869
1934
  - `MarkdownInput` — the authoring half of `<RichText>` (sc-6970). `<MarkdownInput value={draft} onChange={setDraft} />` is a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list, each toggling back off), and a live `<RichText>` preview of the result underneath — the way to let an app USER write formatted text, instead of a bare `TextInput` plus hand-rolled buttons that can only splice literal tags into the value. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default `Preview`), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (150) and `maxHeight` (280) so a long draft scrolls inside its box, `accessibilityLabel`, `style`, `testID`.
1870
1935
  - `parseMarkdown`, `markdownToPlainText`, `parseMarkdownImage`, `formatMarkdownImage`, `stripHtmlToMarkdown`, `MARKDOWN_IMAGE_SIZES` — the same grammar as pure helpers, for content you inspect rather than render: the block list, a marker-free projection (list previews, search, a11y labels), the one-line image form read and written, a legacy-HTML row normalised to markdown on read, and the closed size list (`small` | `medium` | `large` | `full`).
1871
1936
  - `isSafeMarkdownImageSrc`, `isHttpImageSrc`, `normaliseMarkdownImageSize`, `readMarkdownAlt` — the image half of that grammar, for a widget rendering images itself via `renderImage`. `isSafeMarkdownImageSrc` is the same allowlist the parser applies (an `http(s)` URL or a filestore id; a `javascript:` scheme is refused), so the widget and the parser cannot disagree about which src is renderable.
1872
- - `List`, `ListRow`, `EmptyState` — the themed list building blocks. `<ListRow title subtitle leading trailing onPress selected divider>` draws one record row from the theme's `list` scope (padding, height, gap, radius, background, divider); `<List>` spaces its rows and hands overrides down; `<EmptyState icon title message>` is the "nothing here" panel. Never hand-roll a row from `View` + `Pressable`.
1937
+ - `List`, `ListRow`, `ListHeader`, `EmptyState` — the themed list building blocks. `<ListRow title subtitle leading trailing onPress selected divider>` draws one record row from the theme's `list` scope (padding, height, gap, radius, background, divider); `<List>` spaces its rows and hands overrides down; `<ListHeader>` labels a table's columns on the rows' own inset; `<EmptyState icon title message>` is the "nothing here" panel. Never hand-roll a row or a header row from `View` + `Pressable`.
1938
+ - `Badge`, `Avatar`, `Progress`, `Stat`, `Divider` — the themed status pill, person picture, completion bar or ring, headline figure and rule the built-in widgets of the same names render through. Reach for them inside a record row instead of hand-rolling a pill or a circle from `View`.
1873
1939
  - `Card`, `Button`, `TextField` — the themed card surface, button and text field the built-in widgets render through. With no props each takes the app's look from Theme Settings (Cards, Buttons, Muted); never hand-roll one from `View` / `Pressable` / a bare `TextInput`.
1874
1940
  - `Tabs` — the themed tab strip. `<Tabs items value onChange>` draws a segmented control, pills or an underline as the theme's `tabs` scope says; render the selected panel yourself. Never hand-roll tabs from `Pressable` chips.
1875
1941
  - `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
package/dist/contract.cjs CHANGED
@@ -1558,6 +1558,12 @@ const HOOKS = [
1558
1558
  {
1559
1559
  name: "useWidgetEvent",
1560
1560
  signature: "useWidgetEvent(eventName)",
1561
+ description:
1562
+ "Fires one of the events the manifest declares. The page wires it two " +
1563
+ "ways: a sibling widget's input reads the payload, and an author-bound " +
1564
+ "page action (Run action, Navigate) runs with it. Fire a named event " +
1565
+ "from ONE control to have only that control run the action; an onPress " +
1566
+ "binding instead makes the whole widget the tap target.",
1561
1567
  // sc-4753 — the hook IS the emitter, so its return is declared as a bare
1562
1568
  // callable. Naming an `emit` member here made every derived surface tell
1563
1569
  // authors to destructure a function, which yields undefined at run time.
@@ -2345,7 +2351,14 @@ const PRIMITIVES = [
2345
2351
  {
2346
2352
  name: "ListRow",
2347
2353
  description:
2348
- 'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block, for a multi-column row), `onPress` (makes it a button with a press state), `selected`, `disabled`, `divider` (default true; pass false on the last row), `accessibilityLabel`, `style`, `testID`, plus the same per-row overrides `<List>` takes. Declare `listRowPadding` / `listRowRadius` / … style fields and pass them through to let an author override one widget.',
2354
+ 'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block: a single `View` child — your own row of cells — spans the full width between `leading` and `trailing`; anything else, such as several cells, sits in the row as given, which is the shape a table row takes under a `<ListHeader>`), `onPress` (makes it a button with a press state), `selected`, `disabled`, `divider` (default true; pass false on the last row), `accessibilityLabel`, `style`, `testID`, plus the same per-row overrides `<List>` takes. Declare `listRowPadding` / `listRowRadius` / … style fields and pass them through to let an author override one widget.',
2355
+ rnComponent: null,
2356
+ docsUrl: null,
2357
+ },
2358
+ {
2359
+ name: "ListHeader",
2360
+ description:
2361
+ "Themed column header for a table of `<ListRow>`s. `<ListHeader><Text style={[overline, { flex: 2, minWidth: 0 }]}>Name</Text><Text style={[overline, { flex: 1, minWidth: 0 }]}>Status</Text></ListHeader>` above `<List>{rows.map((r) => <ListRow key={r.id}><Text numberOfLines={1} style={[body, { flex: 2, minWidth: 0 }]}>{r.name}</Text><Text numberOfLines={1} style={[body, { flex: 1, minWidth: 0 }]}>{r.status}</Text></ListRow>)}</List>`. THE way to label a table's columns: it takes the SAME horizontal padding, gap and side edges as `<ListRow>` from the theme's Lists settings, so a label given the same `flex` as its row cell sits exactly over that column — never hand-build a header `View` with its own padding, which cannot line up with the rows. Give the header and the rows the same shape (the same cells as children, each column the same `flex`, `minWidth: 0` on every cell). Props: `children` (the labels), `leading` / `trailing` (a spacer as wide as the rows' leading / trailing node, so the columns still line up), `divider` (default true), `accessibilityLabel`, `style`, `testID`, and the list overrides that shape it (`rowPadding`, `gap`, `dividerColor`, `dividerWidth`) — pass it the SAME overrides you pass the `<List>`. To keep it in view, make it the FIRST child of the rows' `ScrollView` with `stickyHeaderIndices={[0]}`, and give it the surface it sits on (`style={{ backgroundColor: theme.colors.surface }}`) so rows do not show through it: inside the scroller it shares the rows' width beside a scrollbar, which a header outside it cannot.",
2349
2362
  rnComponent: null,
2350
2363
  docsUrl: null,
2351
2364
  },
@@ -2356,6 +2369,41 @@ const PRIMITIVES = [
2356
2369
  rnComponent: null,
2357
2370
  docsUrl: null,
2358
2371
  },
2372
+ {
2373
+ name: "Badge",
2374
+ description:
2375
+ 'Themed status pill. `<Badge label={row.status} tone={row.status === "Overdue" ? "danger" : "success"} />`. THE way to show a record\'s STATE (Active, Paid, Overdue, New) beside the thing it describes: in a `<ListRow>` trailing slot, next to a title, in a detail header. The label always renders, so the meaning never rides on colour alone. Keep it to one to three words; a name, amount, date or sentence is plain text, not a badge. Its fill and text colour come from the app\'s theme per tone, so never hand-roll a pill from `View` + radius + a hex. Props: `label` (string or number), `tone` ("neutral" | "info" | "success" | "warning" | "danger", default "neutral"), `icon` (optional lucide name before the label), `style`, `testID`. It hugs its label (`alignSelf: "flex-start"`); pass `style={{ alignSelf: "auto" }}` to let the parent place it.',
2376
+ rnComponent: null,
2377
+ docsUrl: null,
2378
+ },
2379
+ {
2380
+ name: "Avatar",
2381
+ description:
2382
+ 'Themed picture of a person. `<Avatar name={row.owner.label} url={photoUrl} size="sm" />`. THE way to show a PERSON: in a `<ListRow>` leading slot, a profile header, a list of members. It falls back from the picture to the name\'s initials to a person glyph, so the slot is never blank. Never use it for a place, product or topic. Props: `name` (the initials and the accessibility label come from it), `url` (an absolute image URL; resolve a file field to a URL first), `size` ("sm" 32 | "md" 40 | "lg" 56 | "xl" 80, or a number of px), `shape` ("circle" | "rounded"), `accessibilityLabel`, `style`, `testID`.',
2383
+ rnComponent: null,
2384
+ docsUrl: null,
2385
+ },
2386
+ {
2387
+ name: "Progress",
2388
+ description:
2389
+ 'Themed completion bar or ring. `<Progress value={(done / total) * 100} label="Sessions booked" />`. THE way to show real progress toward a known goal. `value` is clamped to 0-100, and the percent renders as text unless `showValue={false}`. Never decoration, a rating or a score. Props: `value`, `variant` ("bar" | "ring", default "bar"), `label`, `showValue` (default true), `tone` ("primary" | "success" | "warning" | "danger", default "primary"), `style`, `testID`.',
2390
+ rnComponent: null,
2391
+ docsUrl: null,
2392
+ },
2393
+ {
2394
+ name: "Stat",
2395
+ description:
2396
+ 'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure, so compute it yourself (e.g. from `useDatastoreQuery` rows). An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
2397
+ rnComponent: null,
2398
+ docsUrl: null,
2399
+ },
2400
+ {
2401
+ name: "Divider",
2402
+ description:
2403
+ 'Themed rule. `<Divider />`, or `<Divider label="or" />` for a captioned one. It splits two groups INSIDE one surface that nothing else separates: a card\'s summary from its details, or two alternatives. A `<List>` already draws its own row dividers and a `<Card>` its own edge, so never add one between rows or around a card. Props: `label` (optional caption centred in the rule), `thickness` (1-8 px), `spacing` ("sm" | "md" | "lg" breathing room), `color` (defaults to the theme border), `style`, `testID`.',
2404
+ rnComponent: null,
2405
+ docsUrl: null,
2406
+ },
2359
2407
  {
2360
2408
  name: "Tabs",
2361
2409
  description:
@@ -2591,7 +2639,10 @@ const MANIFEST_SCHEMA = {
2591
2639
  "{ type, label? } — the SAME shape as propertySchema, NOT JSON Schema — " +
2592
2640
  "where type is one of string, number, boolean, object, array. The payload reaches " +
2593
2641
  "any sibling widget on the page whose inputs entry the page author wired " +
2594
- "to this event.",
2642
+ "to this event. An event can also run a page action the author binds to " +
2643
+ "it (Run action, Navigate): fired from one control with useWidgetEvent, " +
2644
+ "only that control runs the action. Never declare it as onPress, which " +
2645
+ "the host binds as a tap target over the whole widget.",
2595
2646
  default: [],
2596
2647
  },
2597
2648
  inputs: {
@@ -2610,7 +2661,7 @@ const MANIFEST_SCHEMA = {
2610
2661
  type: "object",
2611
2662
  required: false,
2612
2663
  description:
2613
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (must be declared earlier in the array). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected.",
2664
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected.",
2614
2665
  },
2615
2666
  translations: {
2616
2667
  type: "object",
@@ -4613,7 +4664,14 @@ const CONTRACT = deepFreeze({
4613
4664
  // parameters a Run action event sent (the binding's `params` merged over
4614
4665
  // the emitting widget's payload), deep-frozen, null on every non-`app`
4615
4666
  // trigger. Additive: a script that never reads it runs unchanged.
4616
- version: "1.131.0",
4667
+ // 1.132.0: additive (sc-9086) — the `Badge`, `Avatar`, `Progress`, `Stat`
4668
+ // and `Divider` primitives. The stock widgets of the same names render
4669
+ // through them, so a custom widget's status pill matches a built-in's.
4670
+ // 1.133.0: additive (sc-9002) — `ListHeader` (column labels on `ListRow`'s frame);
4671
+ // a `ListRow`'s lone `View` child spans the row; a selected row's bar
4672
+ // no longer shifts its content. Other rows render as before.
4673
+ // 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
4674
+ version: "1.134.1",
4617
4675
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4618
4676
  hooks: HOOKS,
4619
4677
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1558,6 +1558,12 @@ const HOOKS = [
1558
1558
  {
1559
1559
  name: "useWidgetEvent",
1560
1560
  signature: "useWidgetEvent(eventName)",
1561
+ description:
1562
+ "Fires one of the events the manifest declares. The page wires it two " +
1563
+ "ways: a sibling widget's input reads the payload, and an author-bound " +
1564
+ "page action (Run action, Navigate) runs with it. Fire a named event " +
1565
+ "from ONE control to have only that control run the action; an onPress " +
1566
+ "binding instead makes the whole widget the tap target.",
1561
1567
  // sc-4753 — the hook IS the emitter, so its return is declared as a bare
1562
1568
  // callable. Naming an `emit` member here made every derived surface tell
1563
1569
  // authors to destructure a function, which yields undefined at run time.
@@ -2345,7 +2351,14 @@ const PRIMITIVES = [
2345
2351
  {
2346
2352
  name: "ListRow",
2347
2353
  description:
2348
- 'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block, for a multi-column row), `onPress` (makes it a button with a press state), `selected`, `disabled`, `divider` (default true; pass false on the last row), `accessibilityLabel`, `style`, `testID`, plus the same per-row overrides `<List>` takes. Declare `listRowPadding` / `listRowRadius` / … style fields and pass them through to let an author override one widget.',
2354
+ 'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block: a single `View` child — your own row of cells — spans the full width between `leading` and `trailing`; anything else, such as several cells, sits in the row as given, which is the shape a table row takes under a `<ListHeader>`), `onPress` (makes it a button with a press state), `selected`, `disabled`, `divider` (default true; pass false on the last row), `accessibilityLabel`, `style`, `testID`, plus the same per-row overrides `<List>` takes. Declare `listRowPadding` / `listRowRadius` / … style fields and pass them through to let an author override one widget.',
2355
+ rnComponent: null,
2356
+ docsUrl: null,
2357
+ },
2358
+ {
2359
+ name: "ListHeader",
2360
+ description:
2361
+ "Themed column header for a table of `<ListRow>`s. `<ListHeader><Text style={[overline, { flex: 2, minWidth: 0 }]}>Name</Text><Text style={[overline, { flex: 1, minWidth: 0 }]}>Status</Text></ListHeader>` above `<List>{rows.map((r) => <ListRow key={r.id}><Text numberOfLines={1} style={[body, { flex: 2, minWidth: 0 }]}>{r.name}</Text><Text numberOfLines={1} style={[body, { flex: 1, minWidth: 0 }]}>{r.status}</Text></ListRow>)}</List>`. THE way to label a table's columns: it takes the SAME horizontal padding, gap and side edges as `<ListRow>` from the theme's Lists settings, so a label given the same `flex` as its row cell sits exactly over that column — never hand-build a header `View` with its own padding, which cannot line up with the rows. Give the header and the rows the same shape (the same cells as children, each column the same `flex`, `minWidth: 0` on every cell). Props: `children` (the labels), `leading` / `trailing` (a spacer as wide as the rows' leading / trailing node, so the columns still line up), `divider` (default true), `accessibilityLabel`, `style`, `testID`, and the list overrides that shape it (`rowPadding`, `gap`, `dividerColor`, `dividerWidth`) — pass it the SAME overrides you pass the `<List>`. To keep it in view, make it the FIRST child of the rows' `ScrollView` with `stickyHeaderIndices={[0]}`, and give it the surface it sits on (`style={{ backgroundColor: theme.colors.surface }}`) so rows do not show through it: inside the scroller it shares the rows' width beside a scrollbar, which a header outside it cannot.",
2349
2362
  rnComponent: null,
2350
2363
  docsUrl: null,
2351
2364
  },
@@ -2356,6 +2369,41 @@ const PRIMITIVES = [
2356
2369
  rnComponent: null,
2357
2370
  docsUrl: null,
2358
2371
  },
2372
+ {
2373
+ name: "Badge",
2374
+ description:
2375
+ 'Themed status pill. `<Badge label={row.status} tone={row.status === "Overdue" ? "danger" : "success"} />`. THE way to show a record\'s STATE (Active, Paid, Overdue, New) beside the thing it describes: in a `<ListRow>` trailing slot, next to a title, in a detail header. The label always renders, so the meaning never rides on colour alone. Keep it to one to three words; a name, amount, date or sentence is plain text, not a badge. Its fill and text colour come from the app\'s theme per tone, so never hand-roll a pill from `View` + radius + a hex. Props: `label` (string or number), `tone` ("neutral" | "info" | "success" | "warning" | "danger", default "neutral"), `icon` (optional lucide name before the label), `style`, `testID`. It hugs its label (`alignSelf: "flex-start"`); pass `style={{ alignSelf: "auto" }}` to let the parent place it.',
2376
+ rnComponent: null,
2377
+ docsUrl: null,
2378
+ },
2379
+ {
2380
+ name: "Avatar",
2381
+ description:
2382
+ 'Themed picture of a person. `<Avatar name={row.owner.label} url={photoUrl} size="sm" />`. THE way to show a PERSON: in a `<ListRow>` leading slot, a profile header, a list of members. It falls back from the picture to the name\'s initials to a person glyph, so the slot is never blank. Never use it for a place, product or topic. Props: `name` (the initials and the accessibility label come from it), `url` (an absolute image URL; resolve a file field to a URL first), `size` ("sm" 32 | "md" 40 | "lg" 56 | "xl" 80, or a number of px), `shape` ("circle" | "rounded"), `accessibilityLabel`, `style`, `testID`.',
2383
+ rnComponent: null,
2384
+ docsUrl: null,
2385
+ },
2386
+ {
2387
+ name: "Progress",
2388
+ description:
2389
+ 'Themed completion bar or ring. `<Progress value={(done / total) * 100} label="Sessions booked" />`. THE way to show real progress toward a known goal. `value` is clamped to 0-100, and the percent renders as text unless `showValue={false}`. Never decoration, a rating or a score. Props: `value`, `variant` ("bar" | "ring", default "bar"), `label`, `showValue` (default true), `tone` ("primary" | "success" | "warning" | "danger", default "primary"), `style`, `testID`.',
2390
+ rnComponent: null,
2391
+ docsUrl: null,
2392
+ },
2393
+ {
2394
+ name: "Stat",
2395
+ description:
2396
+ 'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure, so compute it yourself (e.g. from `useDatastoreQuery` rows). An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
2397
+ rnComponent: null,
2398
+ docsUrl: null,
2399
+ },
2400
+ {
2401
+ name: "Divider",
2402
+ description:
2403
+ 'Themed rule. `<Divider />`, or `<Divider label="or" />` for a captioned one. It splits two groups INSIDE one surface that nothing else separates: a card\'s summary from its details, or two alternatives. A `<List>` already draws its own row dividers and a `<Card>` its own edge, so never add one between rows or around a card. Props: `label` (optional caption centred in the rule), `thickness` (1-8 px), `spacing` ("sm" | "md" | "lg" breathing room), `color` (defaults to the theme border), `style`, `testID`.',
2404
+ rnComponent: null,
2405
+ docsUrl: null,
2406
+ },
2359
2407
  {
2360
2408
  name: "Tabs",
2361
2409
  description:
@@ -2587,7 +2635,10 @@ const MANIFEST_SCHEMA = {
2587
2635
  "{ type, label? } — the SAME shape as propertySchema, NOT JSON Schema — " +
2588
2636
  "where type is one of string, number, boolean, object, array. The payload reaches " +
2589
2637
  "any sibling widget on the page whose inputs entry the page author wired " +
2590
- "to this event.",
2638
+ "to this event. An event can also run a page action the author binds to " +
2639
+ "it (Run action, Navigate): fired from one control with useWidgetEvent, " +
2640
+ "only that control runs the action. Never declare it as onPress, which " +
2641
+ "the host binds as a tap target over the whole widget.",
2591
2642
  default: [],
2592
2643
  },
2593
2644
  inputs: {
@@ -2606,7 +2657,7 @@ const MANIFEST_SCHEMA = {
2606
2657
  type: "object",
2607
2658
  required: false,
2608
2659
  description:
2609
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (must be declared earlier in the array). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected.",
2660
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected.",
2610
2661
  },
2611
2662
  translations: {
2612
2663
  type: "object",
@@ -4609,7 +4660,14 @@ const CONTRACT = deepFreeze({
4609
4660
  // parameters a Run action event sent (the binding's `params` merged over
4610
4661
  // the emitting widget's payload), deep-frozen, null on every non-`app`
4611
4662
  // trigger. Additive: a script that never reads it runs unchanged.
4612
- version: "1.131.0",
4663
+ // 1.132.0: additive (sc-9086) — the `Badge`, `Avatar`, `Progress`, `Stat`
4664
+ // and `Divider` primitives. The stock widgets of the same names render
4665
+ // through them, so a custom widget's status pill matches a built-in's.
4666
+ // 1.133.0: additive (sc-9002) — `ListHeader` (column labels on `ListRow`'s frame);
4667
+ // a `ListRow`'s lone `View` child spans the row; a selected row's bar
4668
+ // no longer shifts its content. Other rows render as before.
4669
+ // 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
4670
+ version: "1.134.1",
4613
4671
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4614
4672
  hooks: HOOKS,
4615
4673
  primitives: PRIMITIVES,
@@ -0,0 +1,91 @@
1
+ // The pure rules behind `<Badge>`, `<Avatar>`, `<Progress>`, `<Stat>` and
2
+ // `<Divider>`: which theme colour a tone reads, and the clamps that keep a
3
+ // bad prop from reaching a native style as NaN (a crash on the export).
4
+
5
+ import { deriveAccentTints } from "./contract.js";
6
+
7
+ export const BADGE_TONES = Object.freeze(["neutral", "info", "success", "warning", "danger"]);
8
+ export const PROGRESS_TONES = Object.freeze(["primary", "success", "warning", "danger"]);
9
+ export const STAT_DELTA_TONES = Object.freeze(["neutral", "positive", "negative"]);
10
+ export const AVATAR_SIZES = Object.freeze({ sm: 32, md: 40, lg: 56, xl: 80 });
11
+ export const DIVIDER_SPACINGS = Object.freeze(["sm", "md", "lg"]);
12
+
13
+ const AVATAR_PX_BOUNDS = Object.freeze({ min: 16, max: 160 });
14
+ const DIVIDER_THICKNESS_BOUNDS = Object.freeze({ min: 1, max: 8 });
15
+ const STAT_FONT_SIZE_BOUNDS = Object.freeze({ min: 12, max: 96 });
16
+
17
+ const DELTA_TONE_COLORS = Object.freeze({
18
+ neutral: "onSurfaceMuted",
19
+ positive: "success",
20
+ negative: "danger",
21
+ });
22
+
23
+ function clamp(n, { min, max }) {
24
+ return Math.min(max, Math.max(min, n));
25
+ }
26
+
27
+ /**
28
+ * The soft fill + readable text pair for a badge tone, derived from the theme's
29
+ * own colours. `neutral` (or an unknown tone) is the muted surface pair.
30
+ */
31
+ export function badgeTint(colors, tone) {
32
+ const muted = { backgroundColor: colors.surfaceMuted, color: colors.onSurfaceMuted };
33
+ const accent = tone !== "neutral" && BADGE_TONES.includes(tone) ? colors[tone] : null;
34
+ if (!accent) return muted;
35
+ const tints = deriveAccentTints(accent, colors.surface, colors.onSurface);
36
+ return tints ? { backgroundColor: tints.primarySoft, color: tints.onPrimarySoft } : muted;
37
+ }
38
+
39
+ /** A 0-100 percent; anything unreadable is 0. */
40
+ export function clampPercent(value) {
41
+ const n = Number(value);
42
+ return Number.isFinite(n) ? clamp(n, { min: 0, max: 100 }) : 0;
43
+ }
44
+
45
+ /** The fill colour a progress tone reads; an unknown tone is the primary. */
46
+ export function progressColor(colors, tone) {
47
+ return colors[PROGRESS_TONES.includes(tone) ? tone : "primary"];
48
+ }
49
+
50
+ /**
51
+ * An avatar's edge in px: a size token, or a number clamped to a sane range.
52
+ * Object.hasOwn, because an inherited key ("constructor") is truthy.
53
+ */
54
+ export function avatarPixelSize(size) {
55
+ if (typeof size === "number" && Number.isFinite(size)) {
56
+ return Math.round(clamp(size, AVATAR_PX_BOUNDS));
57
+ }
58
+ return Object.hasOwn(AVATAR_SIZES, size) ? AVATAR_SIZES[size] : AVATAR_SIZES.md;
59
+ }
60
+
61
+ /** A divider's line height in whole px, 1-8. */
62
+ export function dividerThickness(value) {
63
+ const n = Number(value);
64
+ if (!Number.isFinite(n)) return DIVIDER_THICKNESS_BOUNDS.min;
65
+ return clamp(Math.round(n), DIVIDER_THICKNESS_BOUNDS);
66
+ }
67
+
68
+ /** The spacing token a divider's breathing room reads; unknown is "md". */
69
+ export function dividerSpacing(spacing) {
70
+ return DIVIDER_SPACINGS.includes(spacing) ? spacing : "md";
71
+ }
72
+
73
+ /** A stat's value size: the caller's, when it is in range, else the theme's xxl. */
74
+ export function statFontSize(fontSize, typography) {
75
+ const inRange =
76
+ typeof fontSize === "number" &&
77
+ fontSize >= STAT_FONT_SIZE_BOUNDS.min &&
78
+ fontSize <= STAT_FONT_SIZE_BOUNDS.max;
79
+ return inRange ? fontSize : typography.sizes.xxl;
80
+ }
81
+
82
+ /** The colour a stat's delta reads; an unknown tone is neutral. */
83
+ export function statDeltaColor(colors, tone) {
84
+ return colors[DELTA_TONE_COLORS[STAT_DELTA_TONES.includes(tone) ? tone : "neutral"]];
85
+ }
86
+
87
+ /** A value worth printing: a finite number or non-blank text. */
88
+ export function hasStatValue(value) {
89
+ if (typeof value === "number") return Number.isFinite(value);
90
+ return typeof value === "string" && value.trim() !== "";
91
+ }