@colixsystems/widget-sdk 0.160.0 → 0.162.0
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 +37 -3
- package/dist/contract.cjs +66 -2
- package/dist/contract.js +66 -2
- package/dist/hooks.js +141 -4
- package/dist/index.d.ts +100 -4
- package/dist/index.js +15 -0
- package/dist/index.native.js +15 -0
- package/dist/list-tokens.js +68 -0
- package/dist/list-view.js +215 -0
- package/dist/list.js +7 -0
- package/dist/list.native.js +7 -0
- package/dist/text-roles.js +102 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
48
48
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
49
49
|
| **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
|
|
50
50
|
| **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
|
|
51
|
-
| **AGENTS** | `useAgent(agentRef)` | `{ send, reset, messages, sending, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance). **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries **`activity`** — what the agent DID with the user's data: the table it read, or the table and columns it CHANGED (never the values, and never a row the permissions refused). **Render a write.** A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. |
|
|
51
|
+
| **AGENTS** | `useAgent(agentRef)` | `{ send, cancel, reset, messages, sending, resuming, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). Sending is IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. On mount the hook DOES reopen the caller's most recent thread with this agent, so a refresh or a navigation keeps the conversation instead of starting a blank one; that resume only READS and spends no credit. `resuming` is true while it runs — render a loading state, not an empty thread. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance), and the new thread is the one a later mount resumes. **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries **`activity`** — what the agent DID with the user's data: the table it read, or the table and columns it CHANGED (never the values, and never a row the permissions refused). **Render a write.** A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. |
|
|
52
52
|
| **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
|
|
53
53
|
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
|
|
54
54
|
| **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) |
|
|
@@ -73,7 +73,39 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
73
73
|
|
|
74
74
|
## Status
|
|
75
75
|
|
|
76
|
-
`v0.
|
|
76
|
+
`v0.162.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**.
|
|
77
|
+
|
|
78
|
+
### What's new in 0.162.0 (contract 1.125.0)
|
|
79
|
+
|
|
80
|
+
**Themed list rows: `List`, `ListRow`, `EmptyState`, and the `list` theme scope.** Each widget that showed records used to hand-roll its rows from `View` + `Pressable`, so two lists in one app disagreed on padding, dividers and corners. `<ListRow>` draws one row whose padding, minimum height, divider, corner radius and background come from **Theme Settings > Lists** (`themeConfig.components.list`), and `<List>` spaces its rows and passes any override down to them. Unthemed, a row is the flush, hairline band the built-in Data List always drew. When the theme sets a `gap`, rows become separate tiles on the surface, outlined by the divider. A selected row carries an accent bar as well as the tint, the last row of a `<List>` has no divider, a pressable row never drops below 44px, and every override passes the same bounds a theme value does. `<EmptyState>` is the matching "nothing here" panel.
|
|
81
|
+
|
|
82
|
+
```jsx
|
|
83
|
+
<List>
|
|
84
|
+
{items.map((item) => (
|
|
85
|
+
<ListRow key={item.id} title={item.name} subtitle={item.email} onPress={() => open(item)} selected={item.id === selectedId} />
|
|
86
|
+
))}
|
|
87
|
+
</List>
|
|
88
|
+
{items.length === 0 ? <EmptyState icon="inbox" title="No orders yet" message="New orders appear here." /> : null}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
To let an author restyle one widget's rows, declare the style fields `listRowPadding`, `listRowMinHeight`, `listRowGap`, `listRowRadius`, `listRowBackground`, `listDividerColor` and `listDividerWidth`. They follow the theme until set, like the `card*` fields do, and you pass them through as the matching `ListRow` props (`rowPadding`, …).
|
|
92
|
+
|
|
93
|
+
**`textRoleStyle(role, theme.typography, theme.colors)`** returns the complete style of a semantic text tier (`display`, `title`, `subtitle`, `body`, `caption`, `overline`) — size, absolute line height, weight, tracking, case, the tier's face and its colour. The built-in widgets use the same function, so your text matches theirs.
|
|
94
|
+
|
|
95
|
+
Additive: a theme without a `list` scope renders exactly as before.
|
|
96
|
+
|
|
97
|
+
### What's new in 0.161.0 (contract 1.124.0)
|
|
98
|
+
|
|
99
|
+
**`useAgent()` resumes the conversation after a refresh or a navigation (sc-8867).** The backend has always persisted the thread, but the hook held the conversation id and the transcript in component state, so every remount opened a blank one. An app user who refreshed the page lost the conversation on screen — and re-explaining the context to the agent spent another paid turn.
|
|
100
|
+
|
|
101
|
+
On mount the hook now reopens the caller's most recent thread with the bound agent and restores its transcript. Two things are worth knowing:
|
|
102
|
+
|
|
103
|
+
- **It spends nothing.** The resume is two READ-ONLY GETs of the caller's own rows (`conversations.list` then `messages.list`). Sending stays imperative — no paid call is made on mount, so a widget nobody types into still costs the workspace nothing.
|
|
104
|
+
- **New `resuming` flag** — true only while the transcript is being reopened. Render a loading state: an empty thread that is about to fill reads as a lost conversation. It is never true for a turn in flight; that is still `sending`.
|
|
105
|
+
|
|
106
|
+
`reset()` is unchanged as a "new chat" affordance, and the new thread it starts is the one a later mount resumes. A transcript that fails to load renders no error — the widget is fully usable without it and the next send opens a fresh thread.
|
|
107
|
+
|
|
108
|
+
Additive: a widget that ignores `resuming` behaves as before, except that it now shows the conversation the user actually left.
|
|
77
109
|
|
|
78
110
|
### What's new in 0.160.0 (contract 1.123.0)
|
|
79
111
|
|
|
@@ -201,7 +233,7 @@ It presents inside `<Overlay>`, so it escapes the widget's clipping box on both
|
|
|
201
233
|
A workspace **agent** is a name plus a system prompt its author wrote in the Studio. This hook lets any widget hold a conversation with one, without touching HTTP and without ever holding an API key.
|
|
202
234
|
|
|
203
235
|
```js
|
|
204
|
-
const { send, reset, messages, sending, error, available } = useAgent(props.agentId);
|
|
236
|
+
const { send, reset, messages, sending, resuming, error, available } = useAgent(props.agentId);
|
|
205
237
|
```
|
|
206
238
|
|
|
207
239
|
**What a widget deliberately cannot do.** You cannot set the system prompt, send your own transcript, or pick a model, and no API surface returns the prompt. The server assembles every turn from the stored prompt plus the stored history, and `content` is the only field that reaches the wire — not because the rest is filtered out, but because nothing else is ever sent. That guarantee is about the WIRE, not about secrecy: a model can be talked into repeating its own prompt, so an agent's prompt is not a secret store — never put credentials, or data the app's users may not see, in one. `messages` is the transcript this widget has seen; the real one is the server's.
|
|
@@ -1754,6 +1786,8 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
1754
1786
|
- `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`.
|
|
1755
1787
|
- `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`).
|
|
1756
1788
|
- `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.
|
|
1789
|
+
- `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`.
|
|
1790
|
+
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
1757
1791
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
1758
1792
|
|
|
1759
1793
|
## Design & visual polish
|
package/dist/contract.cjs
CHANGED
|
@@ -433,6 +433,18 @@ const DANGER_BUTTON_FIELDS = Object.freeze({
|
|
|
433
433
|
borderColor: "dangerBorderColor",
|
|
434
434
|
});
|
|
435
435
|
|
|
436
|
+
// The list-row vocabulary. Bound by name as well as to the Data List, so a
|
|
437
|
+
// custom widget that declares `listRowPadding` follows "Lists" like a built-in.
|
|
438
|
+
const LIST_ROW_FIELDS = Object.freeze({
|
|
439
|
+
rowPadding: "listRowPadding",
|
|
440
|
+
rowMinHeight: "listRowMinHeight",
|
|
441
|
+
gap: "listRowGap",
|
|
442
|
+
radius: "listRowRadius",
|
|
443
|
+
background: "listRowBackground",
|
|
444
|
+
dividerColor: "listDividerColor",
|
|
445
|
+
dividerWidth: "listDividerWidth",
|
|
446
|
+
});
|
|
447
|
+
|
|
436
448
|
const THEME_COMPONENTS = Object.freeze({
|
|
437
449
|
button: Object.freeze({
|
|
438
450
|
label: "Buttons",
|
|
@@ -584,6 +596,24 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
584
596
|
"appstudio.button": Object.freeze({ color: "ringColor", width: "ringWidth" }),
|
|
585
597
|
}),
|
|
586
598
|
}),
|
|
599
|
+
// Record rows: `<ListRow>` reads this scope directly; the fields bind it
|
|
600
|
+
// onto widgets. No uiDefault on the height: it has no theme token.
|
|
601
|
+
list: Object.freeze({
|
|
602
|
+
label: "Lists",
|
|
603
|
+
universalFields: LIST_ROW_FIELDS,
|
|
604
|
+
tokens: Object.freeze({
|
|
605
|
+
rowPadding: Object.freeze({ type: "size", min: 0, max: 32, uiDefault: "spacing.md" }),
|
|
606
|
+
rowMinHeight: Object.freeze({ type: "size", min: 32, max: 96 }),
|
|
607
|
+
gap: Object.freeze({ type: "size", min: 0, max: 24 }),
|
|
608
|
+
radius: Object.freeze({ type: "size", min: 0, max: 32 }),
|
|
609
|
+
background: Object.freeze({ type: "color" }),
|
|
610
|
+
dividerColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
|
|
611
|
+
dividerWidth: Object.freeze({ type: "size", min: 0, max: 4 }),
|
|
612
|
+
}),
|
|
613
|
+
targets: Object.freeze({
|
|
614
|
+
"appstudio.data-list": LIST_ROW_FIELDS,
|
|
615
|
+
}),
|
|
616
|
+
}),
|
|
587
617
|
});
|
|
588
618
|
|
|
589
619
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -1315,6 +1345,11 @@ const HOOKS = [
|
|
|
1315
1345
|
"agents (an author-written name + system prompt). IMPERATIVE: call " +
|
|
1316
1346
|
"send(text) from an event handler, never on mount — the first send " +
|
|
1317
1347
|
"opens the conversation, so a widget nobody types into costs nothing. " +
|
|
1348
|
+
"On mount the hook DOES reopen the caller's most recent thread with " +
|
|
1349
|
+
"this agent, so a refresh or a navigation keeps the conversation " +
|
|
1350
|
+
"instead of starting a blank one; that resume only READS and spends no " +
|
|
1351
|
+
"credit. `resuming` is true while it runs — render a loading state, " +
|
|
1352
|
+
"not an empty thread, or the transcript appears to pop in from nowhere. " +
|
|
1318
1353
|
"`messages` is the running transcript, oldest first, each " +
|
|
1319
1354
|
"{ id, role, content, created_at } with role 'user' | 'assistant'. " +
|
|
1320
1355
|
"An assistant message that used a tool also carries `activity`: what " +
|
|
@@ -1332,7 +1367,8 @@ const HOOKS = [
|
|
|
1332
1367
|
"`agentRef` property — when it is missing, or the host brokers no " +
|
|
1333
1368
|
"agents client, `available` is false and send() rejects UNAVAILABLE; " +
|
|
1334
1369
|
"render a visible unbound state, NEVER a blank box. reset() drops the " +
|
|
1335
|
-
"thread and clears the transcript (a 'new chat' affordance)
|
|
1370
|
+
"thread and clears the transcript (a 'new chat' affordance) and the " +
|
|
1371
|
+
"NEW thread is the one a later mount resumes. " +
|
|
1336
1372
|
"Every send spends the workspace's AI CREDITS and is rate-limited per " +
|
|
1337
1373
|
"caller AND per workspace, so call it once per user action. " +
|
|
1338
1374
|
"AgentError.code distinguishes the refusals that share a status: " +
|
|
@@ -1352,6 +1388,8 @@ const HOOKS = [
|
|
|
1352
1388
|
reset: "() => void",
|
|
1353
1389
|
messages: "Array<{ id, conversation_id, role, content, created_at }>",
|
|
1354
1390
|
sending: "boolean",
|
|
1391
|
+
resuming:
|
|
1392
|
+
"boolean // true only while the stored transcript is being reopened on mount; show a loading state, not an empty thread",
|
|
1355
1393
|
error: "AgentError | null",
|
|
1356
1394
|
available: "boolean // false when no agent is bound, or the host brokers none",
|
|
1357
1395
|
},
|
|
@@ -2165,6 +2203,27 @@ const PRIMITIVES = [
|
|
|
2165
2203
|
rnComponent: null,
|
|
2166
2204
|
docsUrl: null,
|
|
2167
2205
|
},
|
|
2206
|
+
{
|
|
2207
|
+
name: "List",
|
|
2208
|
+
description:
|
|
2209
|
+
"Themed list container. `<List>{rows.map((r) => <ListRow key={r.id} title={r.name} />)}</List>`. Spaces and insets its `<ListRow>` children the way the app's look says (flush rows with hairlines, or separated rounded rows), and hands any row override you pass (`rowPadding`, `rowMinHeight`, `gap`, `radius`, `background`, `dividerColor`, `dividerWidth`) down to every row. Props: those overrides, `style`, `testID`. Put it inside a `ScrollView` (or bound its height) for long lists.",
|
|
2210
|
+
rnComponent: null,
|
|
2211
|
+
docsUrl: null,
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
name: "ListRow",
|
|
2215
|
+
description:
|
|
2216
|
+
'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.',
|
|
2217
|
+
rnComponent: null,
|
|
2218
|
+
docsUrl: null,
|
|
2219
|
+
},
|
|
2220
|
+
{
|
|
2221
|
+
name: "EmptyState",
|
|
2222
|
+
description:
|
|
2223
|
+
'Themed empty / zero-data state. `<EmptyState icon="inbox" title="No orders yet" message="New orders appear here." />`. THE way to say "nothing here" — a muted panel with an icon, a title and a caption, centred, in the app\'s own surface and text tiers. Props: `icon` (a lucide icon name, default "inbox"; pass null for none), `title`, `message`, `children` (an action, e.g. a button), `style`, `testID`.',
|
|
2224
|
+
rnComponent: null,
|
|
2225
|
+
docsUrl: null,
|
|
2226
|
+
},
|
|
2168
2227
|
];
|
|
2169
2228
|
|
|
2170
2229
|
const CATEGORIES = [
|
|
@@ -4349,7 +4408,12 @@ const CONTRACT = deepFreeze({
|
|
|
4349
4408
|
// 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
|
|
4350
4409
|
// keep their data while a changed query or refetch runs; `loading` only
|
|
4351
4410
|
// until the first result for the table/record, new `fetching` flag.
|
|
4352
|
-
|
|
4411
|
+
// 1.125.0: additive — the `list` component scope (row padding, height, gap,
|
|
4412
|
+
// radius, background, divider) and the `List` / `ListRow` / `EmptyState`
|
|
4413
|
+
// primitives that read it, plus `textRoleStyle` and its role helpers.
|
|
4414
|
+
// Widgets each hand-rolled their rows, so two lists in one app disagreed.
|
|
4415
|
+
// Additive: a theme without `list` paints the rows exactly as before.
|
|
4416
|
+
version: "1.125.0",
|
|
4353
4417
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4354
4418
|
hooks: HOOKS,
|
|
4355
4419
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -433,6 +433,18 @@ const DANGER_BUTTON_FIELDS = Object.freeze({
|
|
|
433
433
|
borderColor: "dangerBorderColor",
|
|
434
434
|
});
|
|
435
435
|
|
|
436
|
+
// The list-row vocabulary. Bound by name as well as to the Data List, so a
|
|
437
|
+
// custom widget that declares `listRowPadding` follows "Lists" like a built-in.
|
|
438
|
+
const LIST_ROW_FIELDS = Object.freeze({
|
|
439
|
+
rowPadding: "listRowPadding",
|
|
440
|
+
rowMinHeight: "listRowMinHeight",
|
|
441
|
+
gap: "listRowGap",
|
|
442
|
+
radius: "listRowRadius",
|
|
443
|
+
background: "listRowBackground",
|
|
444
|
+
dividerColor: "listDividerColor",
|
|
445
|
+
dividerWidth: "listDividerWidth",
|
|
446
|
+
});
|
|
447
|
+
|
|
436
448
|
const THEME_COMPONENTS = Object.freeze({
|
|
437
449
|
button: Object.freeze({
|
|
438
450
|
label: "Buttons",
|
|
@@ -584,6 +596,24 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
584
596
|
"appstudio.button": Object.freeze({ color: "ringColor", width: "ringWidth" }),
|
|
585
597
|
}),
|
|
586
598
|
}),
|
|
599
|
+
// Record rows: `<ListRow>` reads this scope directly; the fields bind it
|
|
600
|
+
// onto widgets. No uiDefault on the height: it has no theme token.
|
|
601
|
+
list: Object.freeze({
|
|
602
|
+
label: "Lists",
|
|
603
|
+
universalFields: LIST_ROW_FIELDS,
|
|
604
|
+
tokens: Object.freeze({
|
|
605
|
+
rowPadding: Object.freeze({ type: "size", min: 0, max: 32, uiDefault: "spacing.md" }),
|
|
606
|
+
rowMinHeight: Object.freeze({ type: "size", min: 32, max: 96 }),
|
|
607
|
+
gap: Object.freeze({ type: "size", min: 0, max: 24 }),
|
|
608
|
+
radius: Object.freeze({ type: "size", min: 0, max: 32 }),
|
|
609
|
+
background: Object.freeze({ type: "color" }),
|
|
610
|
+
dividerColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
|
|
611
|
+
dividerWidth: Object.freeze({ type: "size", min: 0, max: 4 }),
|
|
612
|
+
}),
|
|
613
|
+
targets: Object.freeze({
|
|
614
|
+
"appstudio.data-list": LIST_ROW_FIELDS,
|
|
615
|
+
}),
|
|
616
|
+
}),
|
|
587
617
|
});
|
|
588
618
|
|
|
589
619
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -1315,6 +1345,11 @@ const HOOKS = [
|
|
|
1315
1345
|
"agents (an author-written name + system prompt). IMPERATIVE: call " +
|
|
1316
1346
|
"send(text) from an event handler, never on mount — the first send " +
|
|
1317
1347
|
"opens the conversation, so a widget nobody types into costs nothing. " +
|
|
1348
|
+
"On mount the hook DOES reopen the caller's most recent thread with " +
|
|
1349
|
+
"this agent, so a refresh or a navigation keeps the conversation " +
|
|
1350
|
+
"instead of starting a blank one; that resume only READS and spends no " +
|
|
1351
|
+
"credit. `resuming` is true while it runs — render a loading state, " +
|
|
1352
|
+
"not an empty thread, or the transcript appears to pop in from nowhere. " +
|
|
1318
1353
|
"`messages` is the running transcript, oldest first, each " +
|
|
1319
1354
|
"{ id, role, content, created_at } with role 'user' | 'assistant'. " +
|
|
1320
1355
|
"An assistant message that used a tool also carries `activity`: what " +
|
|
@@ -1332,7 +1367,8 @@ const HOOKS = [
|
|
|
1332
1367
|
"`agentRef` property — when it is missing, or the host brokers no " +
|
|
1333
1368
|
"agents client, `available` is false and send() rejects UNAVAILABLE; " +
|
|
1334
1369
|
"render a visible unbound state, NEVER a blank box. reset() drops the " +
|
|
1335
|
-
"thread and clears the transcript (a 'new chat' affordance)
|
|
1370
|
+
"thread and clears the transcript (a 'new chat' affordance) and the " +
|
|
1371
|
+
"NEW thread is the one a later mount resumes. " +
|
|
1336
1372
|
"Every send spends the workspace's AI CREDITS and is rate-limited per " +
|
|
1337
1373
|
"caller AND per workspace, so call it once per user action. " +
|
|
1338
1374
|
"AgentError.code distinguishes the refusals that share a status: " +
|
|
@@ -1352,6 +1388,8 @@ const HOOKS = [
|
|
|
1352
1388
|
reset: "() => void",
|
|
1353
1389
|
messages: "Array<{ id, conversation_id, role, content, created_at }>",
|
|
1354
1390
|
sending: "boolean",
|
|
1391
|
+
resuming:
|
|
1392
|
+
"boolean // true only while the stored transcript is being reopened on mount; show a loading state, not an empty thread",
|
|
1355
1393
|
error: "AgentError | null",
|
|
1356
1394
|
available: "boolean // false when no agent is bound, or the host brokers none",
|
|
1357
1395
|
},
|
|
@@ -2165,6 +2203,27 @@ const PRIMITIVES = [
|
|
|
2165
2203
|
rnComponent: null,
|
|
2166
2204
|
docsUrl: null,
|
|
2167
2205
|
},
|
|
2206
|
+
{
|
|
2207
|
+
name: "List",
|
|
2208
|
+
description:
|
|
2209
|
+
"Themed list container. `<List>{rows.map((r) => <ListRow key={r.id} title={r.name} />)}</List>`. Spaces and insets its `<ListRow>` children the way the app's look says (flush rows with hairlines, or separated rounded rows), and hands any row override you pass (`rowPadding`, `rowMinHeight`, `gap`, `radius`, `background`, `dividerColor`, `dividerWidth`) down to every row. Props: those overrides, `style`, `testID`. Put it inside a `ScrollView` (or bound its height) for long lists.",
|
|
2210
|
+
rnComponent: null,
|
|
2211
|
+
docsUrl: null,
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
name: "ListRow",
|
|
2215
|
+
description:
|
|
2216
|
+
'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.',
|
|
2217
|
+
rnComponent: null,
|
|
2218
|
+
docsUrl: null,
|
|
2219
|
+
},
|
|
2220
|
+
{
|
|
2221
|
+
name: "EmptyState",
|
|
2222
|
+
description:
|
|
2223
|
+
'Themed empty / zero-data state. `<EmptyState icon="inbox" title="No orders yet" message="New orders appear here." />`. THE way to say "nothing here" — a muted panel with an icon, a title and a caption, centred, in the app\'s own surface and text tiers. Props: `icon` (a lucide icon name, default "inbox"; pass null for none), `title`, `message`, `children` (an action, e.g. a button), `style`, `testID`.',
|
|
2224
|
+
rnComponent: null,
|
|
2225
|
+
docsUrl: null,
|
|
2226
|
+
},
|
|
2168
2227
|
];
|
|
2169
2228
|
|
|
2170
2229
|
const CATEGORIES = [
|
|
@@ -4345,7 +4404,12 @@ const CONTRACT = deepFreeze({
|
|
|
4345
4404
|
// 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
|
|
4346
4405
|
// keep their data while a changed query or refetch runs; `loading` only
|
|
4347
4406
|
// until the first result for the table/record, new `fetching` flag.
|
|
4348
|
-
|
|
4407
|
+
// 1.125.0: additive — the `list` component scope (row padding, height, gap,
|
|
4408
|
+
// radius, background, divider) and the `List` / `ListRow` / `EmptyState`
|
|
4409
|
+
// primitives that read it, plus `textRoleStyle` and its role helpers.
|
|
4410
|
+
// Widgets each hand-rolled their rows, so two lists in one app disagreed.
|
|
4411
|
+
// Additive: a theme without `list` paints the rows exactly as before.
|
|
4412
|
+
version: "1.125.0",
|
|
4349
4413
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4350
4414
|
hooks: HOOKS,
|
|
4351
4415
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -2813,13 +2813,19 @@ function toAgentError(err) {
|
|
|
2813
2813
|
|
|
2814
2814
|
/**
|
|
2815
2815
|
* sc-7991 — hold a conversation with a workspace-authored AI agent.
|
|
2816
|
-
* Returns `{ send, reset, messages, sending, error, available }`.
|
|
2816
|
+
* Returns `{ send, cancel, reset, messages, sending, resuming, error, available }`.
|
|
2817
2817
|
*
|
|
2818
2818
|
* send(text) → Promise<{ role, content }> the agent's reply
|
|
2819
2819
|
*
|
|
2820
|
-
*
|
|
2821
|
-
* from an event handler
|
|
2822
|
-
* who places the widget and never types costs the workspace nothing.
|
|
2820
|
+
* Sending is IMPERATIVE — no PAID call is ever made on mount; the widget calls
|
|
2821
|
+
* `send` from an event handler, and the first `send` opens the conversation, so
|
|
2822
|
+
* an author who places the widget and never types costs the workspace nothing.
|
|
2823
|
+
*
|
|
2824
|
+
* sc-8867 — on mount the hook does reopen the caller's most recent thread with
|
|
2825
|
+
* this agent, so a refresh or a navigation keeps the conversation instead of
|
|
2826
|
+
* starting a blank one. That resume is two READ-ONLY GETs of the caller's own
|
|
2827
|
+
* rows and spends no AI credit. `resuming` is true only while it runs; `reset()`
|
|
2828
|
+
* starts a fresh thread and outdates any resume still in flight.
|
|
2823
2829
|
*
|
|
2824
2830
|
* `messages` is the running transcript this widget has seen, oldest first,
|
|
2825
2831
|
* each `{ id, role, content, created_at }` with `role` of "user" | "assistant".
|
|
@@ -2860,12 +2866,25 @@ export function useAgent(agentRef) {
|
|
|
2860
2866
|
const [messages, setMessages] = useState([]);
|
|
2861
2867
|
const [sending, setSending] = useState(false);
|
|
2862
2868
|
const [error, setError] = useState(null);
|
|
2869
|
+
const [resuming, setResuming] = useState(available);
|
|
2863
2870
|
|
|
2864
2871
|
// Set during render when the binding changes; consumed below. A setState in
|
|
2865
2872
|
// the render body would be a cascading re-render, so the flag is applied to
|
|
2866
2873
|
// the value this render returns instead.
|
|
2867
2874
|
const pendingResetRef = useRef(false);
|
|
2868
2875
|
|
|
2876
|
+
// sc-8867 — a `reset()` must SURVIVE the resume effect. Without it, "New
|
|
2877
|
+
// conversation" on a mount whose list call is still in flight is overwritten
|
|
2878
|
+
// by the transcript the user just asked to leave.
|
|
2879
|
+
const resumeGenerationRef = useRef(0);
|
|
2880
|
+
|
|
2881
|
+
// "New conversation" is a decision about the THREAD, not about this mount.
|
|
2882
|
+
// `reset()` drops only the client's handle — the server still lists the
|
|
2883
|
+
// abandoned thread as the most recent one — so without this a remount
|
|
2884
|
+
// resumes exactly what the user just dismissed. Cleared on a rebind, which
|
|
2885
|
+
// is a different agent and therefore a different decision.
|
|
2886
|
+
const suppressResumeRef = useRef(false);
|
|
2887
|
+
|
|
2869
2888
|
// A changed binding is a different agent, so neither the open thread nor the
|
|
2870
2889
|
// transcript carries — leaving the messages would render agent A's
|
|
2871
2890
|
// conversation underneath agent B.
|
|
@@ -2874,14 +2893,25 @@ export function useAgent(agentRef) {
|
|
|
2874
2893
|
boundRef.current = agentRef;
|
|
2875
2894
|
conversationRef.current = null;
|
|
2876
2895
|
pendingResetRef.current = true;
|
|
2896
|
+
// A resume for the PREVIOUS agent is now outdated, and the new binding's
|
|
2897
|
+
// own resume has not run yet — without this the widget renders one frame
|
|
2898
|
+
// of "no conversation" before the effect reports it is loading.
|
|
2899
|
+
resumeGenerationRef.current += 1;
|
|
2900
|
+
// A different agent is a different decision: leaving the old agent's
|
|
2901
|
+
// "new conversation" set would stop this one from ever resuming.
|
|
2902
|
+
suppressResumeRef.current = false;
|
|
2877
2903
|
}
|
|
2878
2904
|
|
|
2879
2905
|
const abortRef = useRef(null);
|
|
2880
2906
|
|
|
2881
2907
|
const reset = useCallback(() => {
|
|
2908
|
+
// Outdates any resume in flight, so its transcript is discarded on arrival.
|
|
2909
|
+
resumeGenerationRef.current += 1;
|
|
2910
|
+
suppressResumeRef.current = true;
|
|
2882
2911
|
conversationRef.current = null;
|
|
2883
2912
|
setMessages([]);
|
|
2884
2913
|
setError(null);
|
|
2914
|
+
setResuming(false);
|
|
2885
2915
|
}, []);
|
|
2886
2916
|
|
|
2887
2917
|
// Ends the WAIT, not the turn: the abort never reaches the server, which
|
|
@@ -2891,6 +2921,104 @@ export function useAgent(agentRef) {
|
|
|
2891
2921
|
if (controller) controller.abort();
|
|
2892
2922
|
}, []);
|
|
2893
2923
|
|
|
2924
|
+
// sc-8867 — reopen the caller's most recent thread with this agent.
|
|
2925
|
+
//
|
|
2926
|
+
// The transcript lives on the SERVER; holding it only in component state
|
|
2927
|
+
// meant a refresh or a navigation started a blank thread, losing the
|
|
2928
|
+
// context and paying to rebuild it. Both calls here are READ-ONLY GETs of
|
|
2929
|
+
// the caller's own rows: this spends no AI credit, so the hook's rule that
|
|
2930
|
+
// it never makes a PAID call on mount is intact — `send` and
|
|
2931
|
+
// `conversations.create` remain imperative and reachable only from an
|
|
2932
|
+
// event handler.
|
|
2933
|
+
//
|
|
2934
|
+
// Nothing is resumed when the transcript is non-empty (a send already
|
|
2935
|
+
// opened this thread) or after a `reset()`, whose generation bump outdates
|
|
2936
|
+
// an in-flight resume.
|
|
2937
|
+
useEffect(() => {
|
|
2938
|
+
if (!available) {
|
|
2939
|
+
setResuming(false);
|
|
2940
|
+
return undefined;
|
|
2941
|
+
}
|
|
2942
|
+
const client = clientRef.current;
|
|
2943
|
+
const agentId = agentIdRef.current;
|
|
2944
|
+
if (!client || !agentId) {
|
|
2945
|
+
setResuming(false);
|
|
2946
|
+
return undefined;
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
let cancelled = false;
|
|
2950
|
+
const generation = resumeGenerationRef.current;
|
|
2951
|
+
// A remount with the thread already open would re-read what it holds, and
|
|
2952
|
+
// a thread the user deliberately left must not come back on a remount.
|
|
2953
|
+
if (conversationRef.current || suppressResumeRef.current) {
|
|
2954
|
+
setResuming(false);
|
|
2955
|
+
return undefined;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// An older host may broker a client with no `list`. Resuming is an
|
|
2959
|
+
// ENHANCEMENT, so a client that cannot do it degrades to the previous
|
|
2960
|
+
// behaviour — never to a widget wedged on a loading state it cannot leave.
|
|
2961
|
+
let thread;
|
|
2962
|
+
try {
|
|
2963
|
+
thread = client.conversations(agentId);
|
|
2964
|
+
} catch {
|
|
2965
|
+
thread = null;
|
|
2966
|
+
}
|
|
2967
|
+
if (!thread || typeof thread.list !== "function") {
|
|
2968
|
+
setResuming(false);
|
|
2969
|
+
return undefined;
|
|
2970
|
+
}
|
|
2971
|
+
setResuming(true);
|
|
2972
|
+
|
|
2973
|
+
(async () => {
|
|
2974
|
+
try {
|
|
2975
|
+
const listed = await thread.list({ limit: 1 });
|
|
2976
|
+
const latest =
|
|
2977
|
+
listed && Array.isArray(listed.data) && listed.data.length > 0
|
|
2978
|
+
? listed.data[0]
|
|
2979
|
+
: null;
|
|
2980
|
+
const conversationId = latest && latest.id ? latest.id : null;
|
|
2981
|
+
if (!conversationId) return;
|
|
2982
|
+
const transcript = thread.messages(conversationId);
|
|
2983
|
+
if (!transcript || typeof transcript.list !== "function") return;
|
|
2984
|
+
const history = await transcript.list();
|
|
2985
|
+
// Re-checked AFTER the awaits: a rebind, an unmount or a reset during
|
|
2986
|
+
// the round trip must not adopt this agent's thread under another.
|
|
2987
|
+
if (
|
|
2988
|
+
cancelled ||
|
|
2989
|
+
agentIdRef.current !== agentId ||
|
|
2990
|
+
resumeGenerationRef.current !== generation ||
|
|
2991
|
+
suppressResumeRef.current ||
|
|
2992
|
+
// A `send` that started during the resume already latched a thread.
|
|
2993
|
+
// Overwriting it would orphan that PAID turn in a conversation
|
|
2994
|
+
// nothing reads again, and point the next send somewhere else.
|
|
2995
|
+
conversationRef.current
|
|
2996
|
+
) {
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
conversationRef.current = Promise.resolve(conversationId);
|
|
3000
|
+
const rows =
|
|
3001
|
+
history && Array.isArray(history.data) ? history.data : [];
|
|
3002
|
+
// Never clobber a turn a concurrent send already appended.
|
|
3003
|
+
if (rows.length > 0) setMessages((prior) => (prior.length > 0 ? prior : rows));
|
|
3004
|
+
} catch {
|
|
3005
|
+
// A transcript that will not load is not a failed turn, and the widget
|
|
3006
|
+
// is fully usable without it: a visible error here would report a
|
|
3007
|
+
// healthy agent as broken. The next send opens a fresh thread.
|
|
3008
|
+
} finally {
|
|
3009
|
+
if (!cancelled) setResuming(false);
|
|
3010
|
+
}
|
|
3011
|
+
})();
|
|
3012
|
+
|
|
3013
|
+
return () => {
|
|
3014
|
+
cancelled = true;
|
|
3015
|
+
// A hung `list()` never settles, so the `finally` above may never run.
|
|
3016
|
+
// Without this the next render stays latched on a spinner with the
|
|
3017
|
+
// composer disabled — the wedge this whole path exists to avoid.
|
|
3018
|
+
setResuming(false);
|
|
3019
|
+
};
|
|
3020
|
+
}, [available, agentRef]);
|
|
3021
|
+
|
|
2894
3022
|
const send = useCallback(async (text) => {
|
|
2895
3023
|
const client = clientRef.current;
|
|
2896
3024
|
const agentId = agentIdRef.current;
|
|
@@ -2962,6 +3090,7 @@ export function useAgent(agentRef) {
|
|
|
2962
3090
|
// than one frame of the previous agent's conversation.
|
|
2963
3091
|
let visibleMessages = messages;
|
|
2964
3092
|
let visibleError = error;
|
|
3093
|
+
let visibleResuming = resuming;
|
|
2965
3094
|
if (pendingResetRef.current) {
|
|
2966
3095
|
pendingResetRef.current = false;
|
|
2967
3096
|
visibleMessages = [];
|
|
@@ -2970,6 +3099,10 @@ export function useAgent(agentRef) {
|
|
|
2970
3099
|
// report a healthy agent as broken, and widgets gate on error.code.
|
|
2971
3100
|
visibleError = null;
|
|
2972
3101
|
if (error) setError(null);
|
|
3102
|
+
// The new binding's resume starts in the effect below, so report it as
|
|
3103
|
+
// loading now rather than showing an empty thread for one frame.
|
|
3104
|
+
visibleResuming = available;
|
|
3105
|
+
if (resuming !== available) setResuming(available);
|
|
2973
3106
|
}
|
|
2974
3107
|
|
|
2975
3108
|
return {
|
|
@@ -2978,6 +3111,10 @@ export function useAgent(agentRef) {
|
|
|
2978
3111
|
reset,
|
|
2979
3112
|
messages: visibleMessages,
|
|
2980
3113
|
sending,
|
|
3114
|
+
// sc-8867 — true only while the stored transcript is being reopened, so a
|
|
3115
|
+
// widget shows a loading state instead of an empty thread that is about to
|
|
3116
|
+
// fill. Never true for a turn in flight; that is `sending`.
|
|
3117
|
+
resuming: visibleResuming,
|
|
2981
3118
|
error: visibleError,
|
|
2982
3119
|
available,
|
|
2983
3120
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1255,11 +1255,21 @@ export interface AgentApi {
|
|
|
1255
1255
|
* "cancel", and show no error when the user presses it.
|
|
1256
1256
|
*/
|
|
1257
1257
|
cancel(): void;
|
|
1258
|
-
/**
|
|
1258
|
+
/**
|
|
1259
|
+
* Drop the open thread and clear the transcript this widget has seen. The
|
|
1260
|
+
* NEW thread is the one a later mount resumes.
|
|
1261
|
+
*/
|
|
1259
1262
|
reset(): void;
|
|
1260
1263
|
/** Oldest first. The SERVER owns the real transcript. */
|
|
1261
1264
|
messages: AgentMessage[];
|
|
1262
1265
|
sending: boolean;
|
|
1266
|
+
/**
|
|
1267
|
+
* sc-8867 — true only while the caller's most recent thread is being
|
|
1268
|
+
* reopened on mount. Render a loading state: an empty thread that is about
|
|
1269
|
+
* to fill reads as a lost conversation. Never true for a turn in flight —
|
|
1270
|
+
* that is `sending`.
|
|
1271
|
+
*/
|
|
1272
|
+
resuming: boolean;
|
|
1263
1273
|
error: AgentError | null;
|
|
1264
1274
|
/** False when no agent is bound, or the host brokers no agents client. */
|
|
1265
1275
|
available: boolean;
|
|
@@ -1267,9 +1277,13 @@ export interface AgentApi {
|
|
|
1267
1277
|
|
|
1268
1278
|
/**
|
|
1269
1279
|
* sc-7991 — hold a conversation with a workspace-authored AI agent.
|
|
1270
|
-
* IMPERATIVE:
|
|
1271
|
-
* widget sends ONE message; the system prompt and the real
|
|
1272
|
-
* server-side and are never readable or settable from here.
|
|
1280
|
+
* Sending is IMPERATIVE: no PAID call is made on mount, so an unused widget
|
|
1281
|
+
* costs nothing. The widget sends ONE message; the system prompt and the real
|
|
1282
|
+
* transcript stay server-side and are never readable or settable from here.
|
|
1283
|
+
*
|
|
1284
|
+
* sc-8867 — on mount it DOES reopen the caller's most recent thread with this
|
|
1285
|
+
* agent (two read-only GETs, no credit spent), so a refresh or a navigation
|
|
1286
|
+
* keeps the conversation rather than starting a blank one.
|
|
1273
1287
|
*/
|
|
1274
1288
|
export function useAgent(agentRef: string | null | undefined): AgentApi;
|
|
1275
1289
|
|
|
@@ -2539,6 +2553,88 @@ export const Overlay: (props: {
|
|
|
2539
2553
|
children?: ReactNode;
|
|
2540
2554
|
}) => any;
|
|
2541
2555
|
|
|
2556
|
+
/** Per-list row overrides; unset values follow Theme Settings > Lists. */
|
|
2557
|
+
export interface ListRowTokens {
|
|
2558
|
+
rowPadding?: number;
|
|
2559
|
+
rowMinHeight?: number;
|
|
2560
|
+
/** Space between rows. Above 0, rows are separate tiles on the muted surface. */
|
|
2561
|
+
gap?: number;
|
|
2562
|
+
radius?: number;
|
|
2563
|
+
background?: string;
|
|
2564
|
+
dividerColor?: string;
|
|
2565
|
+
dividerWidth?: number;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
/**
|
|
2569
|
+
* Themed list container. Spaces and insets its `<ListRow>` children the way the
|
|
2570
|
+
* app's look says, and hands its overrides down to every row.
|
|
2571
|
+
*/
|
|
2572
|
+
export const List: (props: ListRowTokens & {
|
|
2573
|
+
style?: any;
|
|
2574
|
+
testID?: string;
|
|
2575
|
+
children?: ReactNode;
|
|
2576
|
+
}) => any;
|
|
2577
|
+
|
|
2578
|
+
/**
|
|
2579
|
+
* Themed list row. Padding, height, divider, radius and background come from
|
|
2580
|
+
* the theme's `list` scope, so every list in the app matches.
|
|
2581
|
+
*
|
|
2582
|
+
* @example
|
|
2583
|
+
* <List>
|
|
2584
|
+
* {items.map((item) => (
|
|
2585
|
+
* <ListRow key={item.id} title={item.name} subtitle={item.email} onPress={() => open(item)} />
|
|
2586
|
+
* ))}
|
|
2587
|
+
* </List>
|
|
2588
|
+
*/
|
|
2589
|
+
export const ListRow: (props: ListRowTokens & {
|
|
2590
|
+
title?: ReactNode;
|
|
2591
|
+
subtitle?: ReactNode;
|
|
2592
|
+
leading?: ReactNode;
|
|
2593
|
+
trailing?: ReactNode;
|
|
2594
|
+
/** Makes the row a button with a press state. */
|
|
2595
|
+
onPress?: () => void;
|
|
2596
|
+
selected?: boolean;
|
|
2597
|
+
disabled?: boolean;
|
|
2598
|
+
/** Defaults to true; pass false on the last row. */
|
|
2599
|
+
divider?: boolean;
|
|
2600
|
+
accessibilityLabel?: string;
|
|
2601
|
+
style?: any;
|
|
2602
|
+
testID?: string;
|
|
2603
|
+
/** Replaces the title block, e.g. for a multi-column row. */
|
|
2604
|
+
children?: ReactNode;
|
|
2605
|
+
}) => any;
|
|
2606
|
+
|
|
2607
|
+
/** Themed empty / zero-data state: icon, title and caption on the muted surface. */
|
|
2608
|
+
export const EmptyState: (props: {
|
|
2609
|
+
/** A lucide icon name; defaults to "inbox". Pass null for none. */
|
|
2610
|
+
icon?: string | null;
|
|
2611
|
+
title?: ReactNode;
|
|
2612
|
+
message?: ReactNode;
|
|
2613
|
+
style?: any;
|
|
2614
|
+
testID?: string;
|
|
2615
|
+
children?: ReactNode;
|
|
2616
|
+
}) => any;
|
|
2617
|
+
|
|
2618
|
+
export type TextRole = "display" | "title" | "subtitle" | "body" | "caption" | "overline";
|
|
2619
|
+
export const TEXT_ROLES: readonly TextRole[];
|
|
2620
|
+
export const TEXT_TRANSFORMS: readonly string[];
|
|
2621
|
+
export const LINE_HEIGHT_RATIO: number;
|
|
2622
|
+
export const LEGACY_FONT_ROLE_ALIASES: Readonly<Record<string, TextRole>>;
|
|
2623
|
+
export const DEFAULT_TEXT_ROLE: TextRole;
|
|
2624
|
+
export const ROLE_FACES: Readonly<Record<TextRole, "heading" | "body">>;
|
|
2625
|
+
export function normaliseTextRole(value: unknown): TextRole;
|
|
2626
|
+
export function roleTokens(role: string, typography: any): Record<string, any> | null;
|
|
2627
|
+
export function roleColor(tokens: any, colors: any): string | undefined;
|
|
2628
|
+
/**
|
|
2629
|
+
* The full text style for a semantic tier — size, absolute line height, weight,
|
|
2630
|
+
* tracking, case, the tier's face and its colour — from the theme.
|
|
2631
|
+
*
|
|
2632
|
+
* @example
|
|
2633
|
+
* const theme = useTheme();
|
|
2634
|
+
* <Text style={textRoleStyle("caption", theme.typography, theme.colors)}>Updated 2m ago</Text>
|
|
2635
|
+
*/
|
|
2636
|
+
export function textRoleStyle(role: TextRole | string, typography: any, colors?: any): Record<string, any>;
|
|
2637
|
+
|
|
2542
2638
|
// ----------------------------------------------------- formatted content
|
|
2543
2639
|
// sc-6970 — widgets have no HTML path: they render through React Native
|
|
2544
2640
|
// primitives, which have no `dangerouslySetInnerHTML` on either host. Content
|
package/dist/index.js
CHANGED
|
@@ -189,6 +189,21 @@ export {
|
|
|
189
189
|
applyPendingReactions,
|
|
190
190
|
} from "./emoji-reactions.js";
|
|
191
191
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
192
|
+
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
193
|
+
export { List, ListRow, EmptyState } from "./list.js";
|
|
194
|
+
// The semantic text tiers, shared with the built-in widgets.
|
|
195
|
+
export {
|
|
196
|
+
textRoleStyle,
|
|
197
|
+
TEXT_ROLES,
|
|
198
|
+
TEXT_TRANSFORMS,
|
|
199
|
+
LINE_HEIGHT_RATIO,
|
|
200
|
+
LEGACY_FONT_ROLE_ALIASES,
|
|
201
|
+
DEFAULT_TEXT_ROLE,
|
|
202
|
+
ROLE_FACES,
|
|
203
|
+
normaliseTextRole,
|
|
204
|
+
roleTokens,
|
|
205
|
+
roleColor,
|
|
206
|
+
} from "./text-roles.js";
|
|
192
207
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
|
193
208
|
export { pressableLift } from "./interaction.js";
|
|
194
209
|
export { styleGroup } from "./style-group.js";
|
package/dist/index.native.js
CHANGED
|
@@ -182,6 +182,21 @@ export {
|
|
|
182
182
|
applyPendingReactions,
|
|
183
183
|
} from "./emoji-reactions.js";
|
|
184
184
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
185
|
+
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
186
|
+
export { List, ListRow, EmptyState } from "./list.native.js";
|
|
187
|
+
// The semantic text tiers, shared with the built-in widgets.
|
|
188
|
+
export {
|
|
189
|
+
textRoleStyle,
|
|
190
|
+
TEXT_ROLES,
|
|
191
|
+
TEXT_TRANSFORMS,
|
|
192
|
+
LINE_HEIGHT_RATIO,
|
|
193
|
+
LEGACY_FONT_ROLE_ALIASES,
|
|
194
|
+
DEFAULT_TEXT_ROLE,
|
|
195
|
+
ROLE_FACES,
|
|
196
|
+
normaliseTextRole,
|
|
197
|
+
roleTokens,
|
|
198
|
+
roleColor,
|
|
199
|
+
} from "./text-roles.js";
|
|
185
200
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
|
186
201
|
export { pressableLift } from "./interaction.native.js";
|
|
187
202
|
export { styleGroup } from "./style-group.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The values `<List>` / `<ListRow>` paint with, resolved from the theme's `list`
|
|
2
|
+
// component scope. Platform-free, like overlay-tokens.js, so both hosts render
|
|
3
|
+
// a row from the same numbers and tests can pin them without a renderer.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
6
|
+
import { mixHex } from "./contract.js";
|
|
7
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
8
|
+
|
|
9
|
+
// A row with no themed height still meets the 44px touch-target floor.
|
|
10
|
+
export const LIST_ROW_MIN_HEIGHT = 44;
|
|
11
|
+
|
|
12
|
+
// The per-row overrides a widget may pass, named as the theme tokens are.
|
|
13
|
+
export const LIST_TOKEN_NAMES = Object.freeze([
|
|
14
|
+
"rowPadding",
|
|
15
|
+
"rowMinHeight",
|
|
16
|
+
"gap",
|
|
17
|
+
"radius",
|
|
18
|
+
"background",
|
|
19
|
+
"dividerColor",
|
|
20
|
+
"dividerWidth",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function isSet(value) {
|
|
24
|
+
return value !== undefined && value !== null && value !== "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pick(overrides, scope, name) {
|
|
28
|
+
if (overrides && isSet(overrides[name])) return overrides[name];
|
|
29
|
+
if (scope && isSet(scope[name])) return scope[name];
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve one list's row values: explicit override, then the theme's `list`
|
|
35
|
+
* scope, then the theme tokens. Both layers pass the contract's own bounds, so
|
|
36
|
+
* a hand-edited or planned value cannot reach a native style unvalidated.
|
|
37
|
+
* Unthemed, a row is the flush band with a 1px hairline the Data List drew;
|
|
38
|
+
* a `gap` makes separate tiles on the surface, outlined by the divider.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
41
|
+
* @param {object} [overrides] — per-list values keyed by LIST_TOKEN_NAMES.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveListTokens(theme, overrides) {
|
|
44
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
45
|
+
const fallback = DEFAULT_THEME_TOKENS;
|
|
46
|
+
const colors = base.colors || fallback.colors;
|
|
47
|
+
const spacing = base.spacing || fallback.spacing;
|
|
48
|
+
const scope = base.components && base.components.list;
|
|
49
|
+
const own = normaliseThemeComponents({ list: overrides }).list;
|
|
50
|
+
|
|
51
|
+
const gap = pick(own, scope, "gap") ?? 0;
|
|
52
|
+
const separated = gap > 0;
|
|
53
|
+
const background = pick(own, scope, "background") ?? (separated ? colors.surface : undefined);
|
|
54
|
+
return {
|
|
55
|
+
rowPadding: pick(own, scope, "rowPadding") ?? spacing.md,
|
|
56
|
+
rowMinHeight: pick(own, scope, "rowMinHeight") ?? LIST_ROW_MIN_HEIGHT,
|
|
57
|
+
gap,
|
|
58
|
+
separated,
|
|
59
|
+
// A flush band has square ends; a divider would curve into round ones.
|
|
60
|
+
radius: separated ? pick(own, scope, "radius") ?? 0 : 0,
|
|
61
|
+
background,
|
|
62
|
+
dividerColor: pick(own, scope, "dividerColor") ?? colors.border,
|
|
63
|
+
dividerWidth: pick(own, scope, "dividerWidth") ?? 1,
|
|
64
|
+
selectedBackground: colors.primarySoft || colors.surfaceMuted,
|
|
65
|
+
selectedAccent: colors.primary,
|
|
66
|
+
pressedBackground: mixHex(colors.onSurface, background || colors.surface, 0.94),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The ONE implementation behind the `<List>`, `<ListRow>` and `<EmptyState>`
|
|
2
|
+
// SDK primitives. The platform bindings (`list.js` / `list.native.js`) differ
|
|
3
|
+
// only in where the primitives come from, so the hosts cannot drift.
|
|
4
|
+
//
|
|
5
|
+
// Every widget drawing a list of records used to hand-roll its rows, so two
|
|
6
|
+
// widgets in one app disagreed on padding, dividers and radius. These read the
|
|
7
|
+
// theme's `list` scope, which a look sets once for the whole app.
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { useHostTheme } from "./hooks.js";
|
|
11
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
12
|
+
import { Icon } from "./icon.js";
|
|
13
|
+
import { resolveListTokens, LIST_TOKEN_NAMES } from "./list-tokens.js";
|
|
14
|
+
import { textRoleStyle } from "./text-roles.js";
|
|
15
|
+
|
|
16
|
+
// Outside a widget host (a test, a preview) the defaults still paint a row.
|
|
17
|
+
function useListTheme() {
|
|
18
|
+
return useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function listOverrides(props) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const name of LIST_TOKEN_NAMES) {
|
|
24
|
+
if (props[name] !== undefined && props[name] !== null) out[name] = props[name];
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Bind the shared list components to one platform's primitives.
|
|
31
|
+
*
|
|
32
|
+
* @param {{View: any, Text: any, Pressable: any}} rn
|
|
33
|
+
*/
|
|
34
|
+
export function makeList(rn) {
|
|
35
|
+
const { View, Text, Pressable } = rn;
|
|
36
|
+
const ListContext = React.createContext(null);
|
|
37
|
+
|
|
38
|
+
function List(props) {
|
|
39
|
+
const { children, style, testID } = props;
|
|
40
|
+
const theme = useListTheme();
|
|
41
|
+
const inherited = React.useContext(ListContext);
|
|
42
|
+
const overrides = { ...inherited, ...listOverrides(props) };
|
|
43
|
+
const tokens = resolveListTokens(theme, overrides);
|
|
44
|
+
// No divider under the last row: the list's own edge ends it.
|
|
45
|
+
const rows = React.Children.toArray(children);
|
|
46
|
+
const last = rows.length - 1;
|
|
47
|
+
const content = rows.map((row, i) =>
|
|
48
|
+
i === last && React.isValidElement(row) && row.props.divider === undefined
|
|
49
|
+
? React.cloneElement(row, { divider: false })
|
|
50
|
+
: row,
|
|
51
|
+
);
|
|
52
|
+
return React.createElement(
|
|
53
|
+
ListContext.Provider,
|
|
54
|
+
{ value: overrides },
|
|
55
|
+
React.createElement(View, { style: [{ gap: tokens.gap }, style], testID }, content),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
List.displayName = "List";
|
|
59
|
+
|
|
60
|
+
function ListRow(props) {
|
|
61
|
+
const {
|
|
62
|
+
title,
|
|
63
|
+
subtitle,
|
|
64
|
+
leading,
|
|
65
|
+
trailing,
|
|
66
|
+
onPress,
|
|
67
|
+
selected = false,
|
|
68
|
+
disabled = false,
|
|
69
|
+
divider = true,
|
|
70
|
+
accessibilityLabel,
|
|
71
|
+
style,
|
|
72
|
+
testID,
|
|
73
|
+
children,
|
|
74
|
+
} = props;
|
|
75
|
+
const theme = useListTheme();
|
|
76
|
+
const inherited = React.useContext(ListContext);
|
|
77
|
+
const tokens = resolveListTokens(theme, {
|
|
78
|
+
...inherited,
|
|
79
|
+
...listOverrides(props),
|
|
80
|
+
});
|
|
81
|
+
const { colors, spacing, typography } = theme;
|
|
82
|
+
|
|
83
|
+
const pressable = typeof onPress === "function";
|
|
84
|
+
// A tile is outlined; a flush band is separated by its bottom divider.
|
|
85
|
+
const edge = tokens.separated
|
|
86
|
+
? tokens.dividerWidth > 0
|
|
87
|
+
? { borderWidth: tokens.dividerWidth, borderColor: tokens.dividerColor }
|
|
88
|
+
: null
|
|
89
|
+
: divider && tokens.dividerWidth > 0
|
|
90
|
+
? { borderBottomWidth: tokens.dividerWidth, borderColor: tokens.dividerColor }
|
|
91
|
+
: null;
|
|
92
|
+
const rowStyle = {
|
|
93
|
+
flexDirection: "row",
|
|
94
|
+
alignItems: "center",
|
|
95
|
+
gap: spacing.md,
|
|
96
|
+
paddingHorizontal: tokens.rowPadding,
|
|
97
|
+
paddingVertical: tokens.rowPadding,
|
|
98
|
+
// A dense look may shrink a static row, never a tap target.
|
|
99
|
+
minHeight: pressable ? Math.max(tokens.rowMinHeight, 44) : tokens.rowMinHeight,
|
|
100
|
+
borderRadius: tokens.radius,
|
|
101
|
+
...(tokens.background ? { backgroundColor: tokens.background } : null),
|
|
102
|
+
...edge,
|
|
103
|
+
// A tint alone is too faint to read as the current row.
|
|
104
|
+
...(selected
|
|
105
|
+
? {
|
|
106
|
+
backgroundColor: tokens.selectedBackground,
|
|
107
|
+
borderLeftWidth: 3,
|
|
108
|
+
borderLeftColor: tokens.selectedAccent,
|
|
109
|
+
}
|
|
110
|
+
: null),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const body =
|
|
114
|
+
children !== undefined
|
|
115
|
+
? children
|
|
116
|
+
: React.createElement(
|
|
117
|
+
View,
|
|
118
|
+
{ style: { flex: 1, gap: 2 } },
|
|
119
|
+
title !== undefined && title !== null
|
|
120
|
+
? React.createElement(
|
|
121
|
+
Text,
|
|
122
|
+
{
|
|
123
|
+
style: textRoleStyle("subtitle", typography, colors),
|
|
124
|
+
numberOfLines: 1,
|
|
125
|
+
},
|
|
126
|
+
title,
|
|
127
|
+
)
|
|
128
|
+
: null,
|
|
129
|
+
subtitle !== undefined && subtitle !== null
|
|
130
|
+
? React.createElement(
|
|
131
|
+
Text,
|
|
132
|
+
{
|
|
133
|
+
style: textRoleStyle("caption", typography, colors),
|
|
134
|
+
numberOfLines: 2,
|
|
135
|
+
},
|
|
136
|
+
subtitle,
|
|
137
|
+
)
|
|
138
|
+
: null,
|
|
139
|
+
);
|
|
140
|
+
const content = [leading || null, body, trailing || null];
|
|
141
|
+
|
|
142
|
+
if (!pressable) {
|
|
143
|
+
return React.createElement(
|
|
144
|
+
View,
|
|
145
|
+
{
|
|
146
|
+
style: [rowStyle, style],
|
|
147
|
+
testID,
|
|
148
|
+
accessibilityLabel,
|
|
149
|
+
accessible: accessibilityLabel ? true : undefined,
|
|
150
|
+
},
|
|
151
|
+
...content,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return React.createElement(
|
|
155
|
+
Pressable,
|
|
156
|
+
{
|
|
157
|
+
onPress,
|
|
158
|
+
disabled,
|
|
159
|
+
// accessibilityRole + aria-*: the pair that announces on both hosts.
|
|
160
|
+
accessibilityRole: "button",
|
|
161
|
+
accessibilityLabel,
|
|
162
|
+
"aria-selected": selected,
|
|
163
|
+
"aria-disabled": disabled,
|
|
164
|
+
testID,
|
|
165
|
+
style: ({ pressed }) => [
|
|
166
|
+
rowStyle,
|
|
167
|
+
pressed ? { backgroundColor: tokens.pressedBackground } : null,
|
|
168
|
+
disabled ? { opacity: 0.5 } : null,
|
|
169
|
+
style,
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
...content,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
ListRow.displayName = "ListRow";
|
|
176
|
+
|
|
177
|
+
function EmptyState({ icon = "inbox", title, message, children, style, testID }) {
|
|
178
|
+
const { colors, spacing, radii, typography } = useListTheme();
|
|
179
|
+
return React.createElement(
|
|
180
|
+
View,
|
|
181
|
+
{
|
|
182
|
+
style: [
|
|
183
|
+
{
|
|
184
|
+
padding: spacing.lg,
|
|
185
|
+
borderRadius: radii.md,
|
|
186
|
+
backgroundColor: colors.surfaceMuted,
|
|
187
|
+
alignItems: "center",
|
|
188
|
+
gap: spacing.sm,
|
|
189
|
+
},
|
|
190
|
+
style,
|
|
191
|
+
],
|
|
192
|
+
testID,
|
|
193
|
+
},
|
|
194
|
+
icon ? React.createElement(Icon, { name: icon, size: 20, color: colors.onSurfaceMuted }) : null,
|
|
195
|
+
title
|
|
196
|
+
? React.createElement(
|
|
197
|
+
Text,
|
|
198
|
+
{ style: { ...textRoleStyle("subtitle", typography, colors), textAlign: "center" } },
|
|
199
|
+
title,
|
|
200
|
+
)
|
|
201
|
+
: null,
|
|
202
|
+
message
|
|
203
|
+
? React.createElement(
|
|
204
|
+
Text,
|
|
205
|
+
{ style: { ...textRoleStyle("caption", typography, colors), textAlign: "center" } },
|
|
206
|
+
message,
|
|
207
|
+
)
|
|
208
|
+
: null,
|
|
209
|
+
children || null,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
EmptyState.displayName = "EmptyState";
|
|
213
|
+
|
|
214
|
+
return { List, ListRow, EmptyState };
|
|
215
|
+
}
|
package/dist/list.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<List>`, `<ListRow>`, `<EmptyState>` (web). Binds ./list-view.js to
|
|
2
|
+
// react-native-web; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { makeList } from "./list-view.js";
|
|
6
|
+
|
|
7
|
+
export const { List, ListRow, EmptyState } = makeList(ReactNative);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<List>`, `<ListRow>`, `<EmptyState>` (native). Binds ./list-view.js to
|
|
2
|
+
// React Native's own primitives; only the import differs from ./list.js.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Pressable } from "react-native";
|
|
5
|
+
import { makeList } from "./list-view.js";
|
|
6
|
+
|
|
7
|
+
export const { List, ListRow, EmptyState } = makeList({ View, Text, Pressable });
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// REQ-THEME-ROLE — the semantic text tiers, resolved against a theme.
|
|
2
|
+
//
|
|
3
|
+
// The ONE implementation behind every role-styled text: the built-in widgets
|
|
4
|
+
// (frontend `_shared/textStyle.js` re-exports it), the SDK's own themed
|
|
5
|
+
// components, and any custom widget that calls `textRoleStyle`. Platform-free,
|
|
6
|
+
// so both hosts and the exported app resolve a tier identically.
|
|
7
|
+
|
|
8
|
+
// Leading is a MULTIPLE of the font size; 1.4 is the platform body ratio.
|
|
9
|
+
export const LINE_HEIGHT_RATIO = 1.4;
|
|
10
|
+
|
|
11
|
+
export const TEXT_TRANSFORMS = ["none", "uppercase", "capitalize"];
|
|
12
|
+
|
|
13
|
+
// The role names the TIER; `theme.typography.roles` owns what it looks like.
|
|
14
|
+
export const TEXT_ROLES = [
|
|
15
|
+
"display",
|
|
16
|
+
"title",
|
|
17
|
+
"subtitle",
|
|
18
|
+
"body",
|
|
19
|
+
"caption",
|
|
20
|
+
"overline",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// sc-3862's `heading` value predates the tiers and must keep resolving.
|
|
24
|
+
export const LEGACY_FONT_ROLE_ALIASES = Object.freeze({ heading: "title" });
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_TEXT_ROLE = "body";
|
|
27
|
+
|
|
28
|
+
// Structural, not a token: the tier decides which of the two faces it takes.
|
|
29
|
+
export const ROLE_FACES = Object.freeze({
|
|
30
|
+
display: "heading",
|
|
31
|
+
title: "heading",
|
|
32
|
+
subtitle: "body",
|
|
33
|
+
body: "body",
|
|
34
|
+
caption: "body",
|
|
35
|
+
overline: "body",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export function normaliseTextRole(value) {
|
|
39
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
40
|
+
const aliased = LEGACY_FONT_ROLE_ALIASES[raw] || raw;
|
|
41
|
+
return TEXT_ROLES.includes(aliased) ? aliased : DEFAULT_TEXT_ROLE;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The role's token set as the host resolved it, or null. */
|
|
45
|
+
export function roleTokens(role, typography) {
|
|
46
|
+
const roles = typography && typography.roles;
|
|
47
|
+
const tokens = roles && roles[normaliseTextRole(role)];
|
|
48
|
+
return tokens || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The colour a role paints in: the theme's explicit override, else the surface
|
|
53
|
+
* token the role names — resolved per render so a dark surface recolours it.
|
|
54
|
+
*/
|
|
55
|
+
export function roleColor(tokens, colors) {
|
|
56
|
+
if (!tokens) return undefined;
|
|
57
|
+
const explicit = typeof tokens.color === "string" ? tokens.color.trim() : "";
|
|
58
|
+
if (explicit) return explicit;
|
|
59
|
+
if (!colors || typeof tokens.colorToken !== "string") return undefined;
|
|
60
|
+
return colors[tokens.colorToken];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The complete text style for a semantic tier: size, absolute line height,
|
|
65
|
+
* weight, tracking, case, the tier's face and its colour.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} role — one of TEXT_ROLES; a legacy `heading` is aliased.
|
|
68
|
+
* @param {object} typography — the resolved theme's typography tokens.
|
|
69
|
+
* @param {object} [colors] — the resolved theme's colours, for `colorToken`.
|
|
70
|
+
*/
|
|
71
|
+
export function textRoleStyle(role, typography, colors) {
|
|
72
|
+
const resolved = normaliseTextRole(role);
|
|
73
|
+
const tokens = roleTokens(resolved, typography) || {};
|
|
74
|
+
const size =
|
|
75
|
+
typeof tokens.fontSize === "number" && Number.isFinite(tokens.fontSize)
|
|
76
|
+
? tokens.fontSize
|
|
77
|
+
: undefined;
|
|
78
|
+
const ratio =
|
|
79
|
+
typeof tokens.lineHeight === "number" && Number.isFinite(tokens.lineHeight)
|
|
80
|
+
? tokens.lineHeight
|
|
81
|
+
: LINE_HEIGHT_RATIO;
|
|
82
|
+
const family = !typography
|
|
83
|
+
? undefined
|
|
84
|
+
: ROLE_FACES[resolved] === "heading"
|
|
85
|
+
? typography.headingFontFamily || typography.fontFamily
|
|
86
|
+
: typography.fontFamily;
|
|
87
|
+
const color = roleColor(tokens, colors);
|
|
88
|
+
return {
|
|
89
|
+
...(size ? { fontSize: size, lineHeight: Math.round(size * ratio) } : null),
|
|
90
|
+
...(tokens.fontWeight ? { fontWeight: tokens.fontWeight } : null),
|
|
91
|
+
...(tokens.fontStyle ? { fontStyle: tokens.fontStyle } : null),
|
|
92
|
+
...(typeof tokens.letterSpacing === "number" &&
|
|
93
|
+
Number.isFinite(tokens.letterSpacing)
|
|
94
|
+
? { letterSpacing: tokens.letterSpacing }
|
|
95
|
+
: null),
|
|
96
|
+
...(TEXT_TRANSFORMS.includes(tokens.textTransform)
|
|
97
|
+
? { textTransform: tokens.textTransform }
|
|
98
|
+
: null),
|
|
99
|
+
...(family ? { fontFamily: family } : null),
|
|
100
|
+
...(color ? { color } : null),
|
|
101
|
+
};
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.162.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|