@colixsystems/widget-sdk 0.146.0 → 0.148.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 CHANGED
@@ -47,6 +47,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
47
47
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
48
48
  | **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 }`. |
49
49
  | **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. |
50
+ | **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. |
50
51
  | **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. |
51
52
  | **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. |
52
53
  | **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) |
@@ -71,7 +72,41 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
71
72
 
72
73
  ## Status
73
74
 
74
- `v0.146.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**.
75
+ `v0.148.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
+
77
+ ### What's new in 0.148.0 (contract 1.116.0)
78
+
79
+ **New `useAgent(agentRef)` hook — a widget can talk to the WORKSPACE's own AI agent (sc-7991).** A new `agents` host client (`@colixsystems/agents-client`), a new `agentRef` propertySchema type and a new `ai.invoke:*` scope. Everything is additive; nothing existing changes shape.
80
+
81
+ 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.
82
+
83
+ ```js
84
+ const { send, reset, messages, sending, error, available } = useAgent(props.agentId);
85
+ ```
86
+
87
+ **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.
88
+
89
+ **Imperative, like `useInterpretDraft`.** It never fires on mount, and the FIRST `send` opens the conversation — so a widget an author places and nobody types into costs the workspace nothing.
90
+
91
+ **Bind it with an `agentRef` property.** Declare `{ type: "agentRef" }` in your `propertySchema` and the Studio renders an agent picker; the value is the agent id you pass to `useAgent`. It is a bare UUID like `pageRef` / `groupRef`, so Copy Workspace carries a bound widget over to the copied workspace's own agent.
92
+
93
+ **Every send spends the workspace's AI credits**, and is rate-limited per caller *and* per workspace. Branch on `AgentError.code`: `AI_QUOTA_EXCEEDED` (429) is the credit ceiling and will **not** clear on a retry — offering "try again" there is worse than useless — where `RATE_LIMITED` (429) will. `AGENT_DISABLED` (409) means the author switched the agent off, so hide the affordance rather than retrying. Never show an app user a raw error or the workspace's billing state: they have not heard of an AI credit and cannot buy one.
94
+
95
+ **`available: false` is a render state, not an error.** It means no agent is bound, or this host brokers no agents client (an unbound canvas preview). Render a visible unbound state — never a blank box.
96
+
97
+ The linter rejects `useAgent()` unless your manifest declares `ai.invoke:*` in `requestedScopes`.
98
+
99
+ ### What's new in 0.147.0 (contract 1.115.0)
100
+
101
+ **BREAKING: the bottom navigation bar is themed by `footer` alone — `resolveFooterTokens` reads no `sidebar` (sc-8033).** Every colour fell back to the rail's, so restyling the sidebar restyled a page's bottom bar and the two could not be dressed apart. That is the defect sc-7951 set out to fix and only half did: it gave the strip a Design panel, but left the panel's "Active tab style" offering a **Follow the sidebar** option and every colour still inheriting. The resolver now reads `theme.footer` and nothing else, and the Studio panel offers the strip's **eight** settings with no inherit anywhere.
102
+
103
+ - **The overloaded `activeColor` is retired** for **`footer.tabActiveBackgroundColor`** and **`footer.tabActiveTextColor`**. One key meant the FILL under `"filled"` and the LABEL under `"accent"`, which is exactly why no author could set both. They are independent now, and each style's *defaults* are what it already rendered — `"filled"`: the brand primary behind a white label; `"accent"`: no surface, a brand-primary label and top edge. So all four colours stay meaningful under both styles.
104
+ - **`activeTint` is gone from `FooterTokens`.** The returned **`activeColor`** is the active tab's label, glyph and accent edge — one colour for all three, because a mark and its label disagreeing about which colour means "you are here" would be two marks.
105
+ - **`backgroundColor` and `textColor` are resolved strings**, not raw-or-null. Both hosts kept their own `#ffffff` / `#475569` defaults for them; a default that lives in each host is the drift §8 exists to prevent, and these had already diverged once. `borderColor` stays nullable — no line, no colour.
106
+ - **`borderWidth` admits `0` here alone**, where `resolveSidebarTokens` keeps sc-7965's 1–8 clamp. The strip's thickness is a Studio control offered down to zero, so a stored zero is an author saying "no line" rather than the junk that clamp refuses; it resolves into the existing no-divider state, so neither host needed new drawing code.
107
+
108
+ **No shipped app moved.** A backfill (`20270123000000_sc8033_footer_tokens_uncouple`) materialised into each workspace's `footer` block exactly what it had been inheriting, and split every stored `activeColor` onto the key that says which half it meant. Nothing is written where the old chain ended in a default the new one also ends in.
109
+
75
110
 
76
111
  ### What's new in 0.146.0 (contract 1.114.0)
77
112
 
package/dist/contract.cjs CHANGED
@@ -1172,6 +1172,47 @@ const HOOKS = [
1172
1172
  requiredContextSlice: ["datastore.interpret"],
1173
1173
  scopes: ["datastore.read:<table>"],
1174
1174
  },
1175
+ {
1176
+ name: "useAgent",
1177
+ signature: "useAgent(agentRef)",
1178
+ description:
1179
+ "sc-7991 — hold a conversation with one of the WORKSPACE's own AI " +
1180
+ "agents (an author-written name + system prompt). IMPERATIVE: call " +
1181
+ "send(text) from an event handler, never on mount — the first send " +
1182
+ "opens the conversation, so a widget nobody types into costs nothing. " +
1183
+ "`messages` is the running transcript, oldest first, each " +
1184
+ "{ id, role, content, created_at } with role 'user' | 'assistant'. " +
1185
+ "You CANNOT set the system prompt, replay an edited transcript, " +
1186
+ "or pick a model. No API returns the prompt — but that is a guarantee " +
1187
+ "about the WIRE, not secrecy: a model can be talked into repeating its " +
1188
+ "own prompt, so never put credentials or hidden data in one. " +
1189
+ "or pick a model: the server assembles every turn from the stored " +
1190
+ "prompt plus the stored history, and `content` is the only thing that " +
1191
+ "goes on the wire. `agentRef` is an agent id an author bound through an " +
1192
+ "`agentRef` property — when it is missing, or the host brokers no " +
1193
+ "agents client, `available` is false and send() rejects UNAVAILABLE; " +
1194
+ "render a visible unbound state, NEVER a blank box. reset() drops the " +
1195
+ "thread and clears the transcript (a 'new chat' affordance). " +
1196
+ "Every send spends the workspace's AI CREDITS and is rate-limited per " +
1197
+ "caller AND per workspace, so call it once per user action. " +
1198
+ "AgentError.code distinguishes the refusals that share a status: " +
1199
+ "AI_QUOTA_EXCEEDED (429) is the workspace's credit ceiling and will " +
1200
+ "NOT clear on a retry — never offer 'try again' for it — where " +
1201
+ "RATE_LIMITED (429) will; AGENT_DISABLED (409) means the author turned " +
1202
+ "it off, so hide the affordance rather than retrying. NEVER surface a " +
1203
+ "raw error or the workspace's billing state to an app user. " +
1204
+ "Reads ctx.agents.",
1205
+ returnShape: {
1206
+ send: "(text) => Promise<{ id, role, content, created_at } | null>",
1207
+ reset: "() => void",
1208
+ messages: "Array<{ id, conversation_id, role, content, created_at }>",
1209
+ sending: "boolean",
1210
+ error: "AgentError | null",
1211
+ available: "boolean // false when no agent is bound, or the host brokers none",
1212
+ },
1213
+ requiredContextSlice: ["agents.conversations"],
1214
+ scopes: ["ai.invoke:*"],
1215
+ },
1175
1216
  {
1176
1217
  name: "useDatastoreMutation",
1177
1218
  signature: "useDatastoreMutation(tableId)",
@@ -2271,6 +2312,21 @@ const WIDGET_CONTEXT_SHAPE = {
2271
2312
  required: false,
2272
2313
  fields: { params: "object", set: "function" },
2273
2314
  },
2315
+ agents: {
2316
+ description:
2317
+ "sc-7991 — injected @colixsystems/agents-client instance. " +
2318
+ "{ conversations(agentId) -> { create(), list(query), remove(conversationId), " +
2319
+ "messages(conversationId) -> { list(query), send({ content }) } } }. " +
2320
+ "Backs useAgent(). The workspace's agent owns the system prompt and the " +
2321
+ "server owns the transcript, so `send` puts ONLY `content` on the wire — " +
2322
+ "a prompt, a history or a model id cannot be smuggled through. " +
2323
+ "A conversation belongs to the CALLER (the app user, or the studio user " +
2324
+ "previewing on the canvas); another user's id is a 404, never a 403. " +
2325
+ "Absent on a host that brokers no agents (an unbound preview), where " +
2326
+ "useAgent() degrades to available: false rather than throwing.",
2327
+ required: false,
2328
+ fields: { conversations: "function" },
2329
+ },
2274
2330
  datastore: {
2275
2331
  description:
2276
2332
  "Injected @colixsystems/datastore-client instance. " +
@@ -4054,6 +4110,10 @@ const CONTRACT = deepFreeze({
4054
4110
  // and the inherited colours are hex-guarded on the way out as well as in.
4055
4111
  // Every colour still falls back to the sidebar's: an untouched workspace is
4056
4112
  // unchanged.
4113
+ // 1.116.0: additive (sc-7991) — `useAgent(agentRef)` + the `agents` host
4114
+ // client + the `agentRef` propertySchema type + the `ai.invoke:*` scope. A
4115
+ // widget can hold a conversation with one of the WORKSPACE's own AI agents.
4116
+ // Nothing existing changes shape.
4057
4117
  // 1.114.0: behaviour (sc-7965) — `resolveFooterTokens` / `resolveSidebarTokens`
4058
4118
  // now return a `borderWidth` CLAMPED to 1-8 instead of any finite number.
4059
4119
  // The bound lived only in the export (`coerceBorderWidth(...) ?? 1`), so a
@@ -4064,7 +4124,23 @@ const CONTRACT = deepFreeze({
4064
4124
  // Zero clamps up for the same reason the export already rendered 1 there —
4065
4125
  // the divider's COLOUR is its switch. Every legal 1-8 value is unchanged,
4066
4126
  // and only a hand-edited or pre-coercer blob can hold anything else.
4067
- version: "1.114.0",
4127
+ // 1.115.0: BREAKING on the pre-1.0 channel (sc-8033) — `resolveFooterTokens`
4128
+ // reads `theme.footer` and NOTHING else. Every colour used to fall back to
4129
+ // `theme.sidebar`, so restyling the rail restyled a page's bottom
4130
+ // navigation bar and the two could not be dressed apart — the defect
4131
+ // sc-7951 set out to fix and only half did. The wire block's overloaded
4132
+ // `activeColor` (the FILL under "filled", the LABEL under "accent") is
4133
+ // retired for `tabActiveBackgroundColor` + `tabActiveTextColor`, two
4134
+ // independent halves; `activeTint` folds into the returned `activeColor`,
4135
+ // which is now the active tab's label, glyph and accent edge. The returned
4136
+ // `backgroundColor` and `textColor` are resolved strings rather than
4137
+ // raw-or-null, so neither host keeps a default of its own. `borderWidth`
4138
+ // admits 0 HERE alone — the strip's thickness is a Studio control offered
4139
+ // down to zero, and zero resolves into the existing no-divider state —
4140
+ // where the rail's and the header's keep sc-7965's 1-8 clamp. A backfill
4141
+ // (`20270123000000_sc8033_footer_tokens_uncouple`) materialised what each
4142
+ // workspace was inheriting, so no shipped app moved.
4143
+ version: "1.116.0",
4068
4144
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4069
4145
  hooks: HOOKS,
4070
4146
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1172,6 +1172,47 @@ const HOOKS = [
1172
1172
  requiredContextSlice: ["datastore.interpret"],
1173
1173
  scopes: ["datastore.read:<table>"],
1174
1174
  },
1175
+ {
1176
+ name: "useAgent",
1177
+ signature: "useAgent(agentRef)",
1178
+ description:
1179
+ "sc-7991 — hold a conversation with one of the WORKSPACE's own AI " +
1180
+ "agents (an author-written name + system prompt). IMPERATIVE: call " +
1181
+ "send(text) from an event handler, never on mount — the first send " +
1182
+ "opens the conversation, so a widget nobody types into costs nothing. " +
1183
+ "`messages` is the running transcript, oldest first, each " +
1184
+ "{ id, role, content, created_at } with role 'user' | 'assistant'. " +
1185
+ "You CANNOT set the system prompt, replay an edited transcript, " +
1186
+ "or pick a model. No API returns the prompt — but that is a guarantee " +
1187
+ "about the WIRE, not secrecy: a model can be talked into repeating its " +
1188
+ "own prompt, so never put credentials or hidden data in one. " +
1189
+ "or pick a model: the server assembles every turn from the stored " +
1190
+ "prompt plus the stored history, and `content` is the only thing that " +
1191
+ "goes on the wire. `agentRef` is an agent id an author bound through an " +
1192
+ "`agentRef` property — when it is missing, or the host brokers no " +
1193
+ "agents client, `available` is false and send() rejects UNAVAILABLE; " +
1194
+ "render a visible unbound state, NEVER a blank box. reset() drops the " +
1195
+ "thread and clears the transcript (a 'new chat' affordance). " +
1196
+ "Every send spends the workspace's AI CREDITS and is rate-limited per " +
1197
+ "caller AND per workspace, so call it once per user action. " +
1198
+ "AgentError.code distinguishes the refusals that share a status: " +
1199
+ "AI_QUOTA_EXCEEDED (429) is the workspace's credit ceiling and will " +
1200
+ "NOT clear on a retry — never offer 'try again' for it — where " +
1201
+ "RATE_LIMITED (429) will; AGENT_DISABLED (409) means the author turned " +
1202
+ "it off, so hide the affordance rather than retrying. NEVER surface a " +
1203
+ "raw error or the workspace's billing state to an app user. " +
1204
+ "Reads ctx.agents.",
1205
+ returnShape: {
1206
+ send: "(text) => Promise<{ id, role, content, created_at } | null>",
1207
+ reset: "() => void",
1208
+ messages: "Array<{ id, conversation_id, role, content, created_at }>",
1209
+ sending: "boolean",
1210
+ error: "AgentError | null",
1211
+ available: "boolean // false when no agent is bound, or the host brokers none",
1212
+ },
1213
+ requiredContextSlice: ["agents.conversations"],
1214
+ scopes: ["ai.invoke:*"],
1215
+ },
1175
1216
  {
1176
1217
  name: "useDatastoreMutation",
1177
1218
  signature: "useDatastoreMutation(tableId)",
@@ -2267,6 +2308,21 @@ const WIDGET_CONTEXT_SHAPE = {
2267
2308
  required: false,
2268
2309
  fields: { params: "object", set: "function" },
2269
2310
  },
2311
+ agents: {
2312
+ description:
2313
+ "sc-7991 — injected @colixsystems/agents-client instance. " +
2314
+ "{ conversations(agentId) -> { create(), list(query), remove(conversationId), " +
2315
+ "messages(conversationId) -> { list(query), send({ content }) } } }. " +
2316
+ "Backs useAgent(). The workspace's agent owns the system prompt and the " +
2317
+ "server owns the transcript, so `send` puts ONLY `content` on the wire — " +
2318
+ "a prompt, a history or a model id cannot be smuggled through. " +
2319
+ "A conversation belongs to the CALLER (the app user, or the studio user " +
2320
+ "previewing on the canvas); another user's id is a 404, never a 403. " +
2321
+ "Absent on a host that brokers no agents (an unbound preview), where " +
2322
+ "useAgent() degrades to available: false rather than throwing.",
2323
+ required: false,
2324
+ fields: { conversations: "function" },
2325
+ },
2270
2326
  datastore: {
2271
2327
  description:
2272
2328
  "Injected @colixsystems/datastore-client instance. " +
@@ -4050,6 +4106,10 @@ const CONTRACT = deepFreeze({
4050
4106
  // and the inherited colours are hex-guarded on the way out as well as in.
4051
4107
  // Every colour still falls back to the sidebar's: an untouched workspace is
4052
4108
  // unchanged.
4109
+ // 1.116.0: additive (sc-7991) — `useAgent(agentRef)` + the `agents` host
4110
+ // client + the `agentRef` propertySchema type + the `ai.invoke:*` scope. A
4111
+ // widget can hold a conversation with one of the WORKSPACE's own AI agents.
4112
+ // Nothing existing changes shape.
4053
4113
  // 1.114.0: behaviour (sc-7965) — `resolveFooterTokens` / `resolveSidebarTokens`
4054
4114
  // now return a `borderWidth` CLAMPED to 1-8 instead of any finite number.
4055
4115
  // The bound lived only in the export (`coerceBorderWidth(...) ?? 1`), so a
@@ -4060,7 +4120,23 @@ const CONTRACT = deepFreeze({
4060
4120
  // Zero clamps up for the same reason the export already rendered 1 there —
4061
4121
  // the divider's COLOUR is its switch. Every legal 1-8 value is unchanged,
4062
4122
  // and only a hand-edited or pre-coercer blob can hold anything else.
4063
- version: "1.114.0",
4123
+ // 1.115.0: BREAKING on the pre-1.0 channel (sc-8033) — `resolveFooterTokens`
4124
+ // reads `theme.footer` and NOTHING else. Every colour used to fall back to
4125
+ // `theme.sidebar`, so restyling the rail restyled a page's bottom
4126
+ // navigation bar and the two could not be dressed apart — the defect
4127
+ // sc-7951 set out to fix and only half did. The wire block's overloaded
4128
+ // `activeColor` (the FILL under "filled", the LABEL under "accent") is
4129
+ // retired for `tabActiveBackgroundColor` + `tabActiveTextColor`, two
4130
+ // independent halves; `activeTint` folds into the returned `activeColor`,
4131
+ // which is now the active tab's label, glyph and accent edge. The returned
4132
+ // `backgroundColor` and `textColor` are resolved strings rather than
4133
+ // raw-or-null, so neither host keeps a default of its own. `borderWidth`
4134
+ // admits 0 HERE alone — the strip's thickness is a Studio control offered
4135
+ // down to zero, and zero resolves into the existing no-divider state —
4136
+ // where the rail's and the header's keep sc-7965's 1-8 clamp. A backfill
4137
+ // (`20270123000000_sc8033_footer_tokens_uncouple`) materialised what each
4138
+ // workspace was inheriting, so no shipped app moved.
4139
+ version: "1.116.0",
4064
4140
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4065
4141
  hooks: HOOKS,
4066
4142
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -2694,6 +2694,196 @@ export function useInterpretDraft(tableId) {
2694
2694
  return { interpret, interpreting, error, result, available };
2695
2695
  }
2696
2696
 
2697
+ /**
2698
+ * sc-7991 — structured error thrown by `useAgent().send`. Carries a stable
2699
+ * `code` so widgets branch without parsing message strings; mirrors the shape
2700
+ * of `TranslateError` / `NotificationError`.
2701
+ *
2702
+ * `code` is one of:
2703
+ * - "UNAVAILABLE" — the host brokers no agents client, or no agent
2704
+ * is bound (the widget should render its unbound
2705
+ * state, not an error)
2706
+ * - "AGENT_NOT_FOUND" — 404 (the bound agent was deleted)
2707
+ * - "AGENT_DISABLED" — 409 (the author turned it off; it still exists)
2708
+ * - "CONVERSATION_NOT_FOUND"— 404 (a stale thread; `reset()` starts a new one)
2709
+ * - "INVALID_MESSAGE" — 400 (empty, or past the length cap)
2710
+ * - "AI_QUOTA_EXCEEDED" — 429 (the WORKSPACE is out of AI credit; this
2711
+ * does NOT clear by retrying — say so)
2712
+ * - "RATE_LIMITED" — 429 (too many turns — retry shortly)
2713
+ * - "AI_UNAVAILABLE" — 503 (no AI provider configured)
2714
+ * - "AI_GENERATION_FAILED" — 502 (the provider did not answer)
2715
+ * - "AUTH_REQUIRED" — 401 (no signed-in app user)
2716
+ * - "INTERNAL" — anything else (network, 5xx)
2717
+ */
2718
+ export class AgentError extends Error {
2719
+ constructor(code, message, opts) {
2720
+ super(message);
2721
+ this.name = "AgentError";
2722
+ this.code = code;
2723
+ if (opts && opts.cause) this.cause = opts.cause;
2724
+ }
2725
+ }
2726
+
2727
+ function toAgentError(err) {
2728
+ if (err instanceof AgentError) return err;
2729
+ const status =
2730
+ err && err.response && typeof err.response.status === "number"
2731
+ ? err.response.status
2732
+ : err && typeof err.status === "number"
2733
+ ? err.status
2734
+ : null;
2735
+ const bodyCode =
2736
+ (err && err.response && err.response.data && err.response.data.code) ||
2737
+ (err && typeof err.code === "string" ? err.code : null);
2738
+ const bodyMessage =
2739
+ (err && err.response && err.response.data && err.response.data.message) ||
2740
+ (err && err.message) ||
2741
+ "The agent could not answer";
2742
+ let code = "INTERNAL";
2743
+ if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
2744
+ else if (status === 401) code = "AUTH_REQUIRED";
2745
+ else if (status === 429) code = "RATE_LIMITED";
2746
+ else if (status === 400) code = "INVALID_MESSAGE";
2747
+ else if (status === 409) code = "AGENT_DISABLED";
2748
+ else if (status === 404) code = "AGENT_NOT_FOUND";
2749
+ else if (status === 502) code = "AI_GENERATION_FAILED";
2750
+ else if (status === 503) code = "AI_UNAVAILABLE";
2751
+ return new AgentError(code, bodyMessage, { cause: err });
2752
+ }
2753
+
2754
+ /**
2755
+ * sc-7991 — hold a conversation with a workspace-authored AI agent.
2756
+ * Returns `{ send, reset, messages, sending, error, available }`.
2757
+ *
2758
+ * send(text) → Promise<{ role, content }> the agent's reply
2759
+ *
2760
+ * The hook is IMPERATIVE — it NEVER fires on mount; the widget calls `send`
2761
+ * from an event handler. The first `send` opens the conversation, so an author
2762
+ * who places the widget and never types costs the workspace nothing.
2763
+ *
2764
+ * `messages` is the running transcript this widget has seen, oldest first,
2765
+ * each `{ id, role, content, created_at }` with `role` of "user" | "assistant".
2766
+ * The SERVER owns the real transcript and the system prompt: the widget sends
2767
+ * one message and can neither read nor set the prompt.
2768
+ *
2769
+ * `agentRef` is the agent id an author bound through an `agentRef` property.
2770
+ * When it is absent, or the host brokers no agents client (an unbound canvas
2771
+ * preview), `available` is false and `send` rejects UNAVAILABLE rather than
2772
+ * throwing at render — the widget must render a visible unbound state, never a
2773
+ * blank box (CLAUDE.md §8).
2774
+ *
2775
+ * Routes through the injected `@colixsystems/agents-client` at `ctx.agents`,
2776
+ * so the Player and the Expo export resolve against the identical client.
2777
+ */
2778
+ export function useAgent(agentRef) {
2779
+ const ctx = useWidgetContextOrThrow("useAgent");
2780
+ const available =
2781
+ Boolean(agentRef) &&
2782
+ Boolean(ctx.agents) &&
2783
+ typeof ctx.agents.conversations === "function";
2784
+
2785
+ const clientRef = useRef(available ? ctx.agents : null);
2786
+ clientRef.current = available ? ctx.agents : null;
2787
+ const agentIdRef = useRef(agentRef);
2788
+ agentIdRef.current = agentRef;
2789
+ // The open thread, held as the in-flight PROMISE rather than the id.
2790
+ //
2791
+ // A ref alone only fixes the SEQUENTIAL case. `if (!id) { id = await create() }`
2792
+ // reads the guard before the await and writes it after, so two overlapping
2793
+ // sends — a double-tap, or a widget sending on each keystroke — both see null,
2794
+ // both open a conversation, and both spend a paid turn. Latching the promise
2795
+ // makes the second caller await the FIRST create instead of starting another.
2796
+ const conversationRef = useRef(null);
2797
+
2798
+ const [messages, setMessages] = useState([]);
2799
+ const [sending, setSending] = useState(false);
2800
+ const [error, setError] = useState(null);
2801
+
2802
+ // Set during render when the binding changes; consumed below. A setState in
2803
+ // the render body would be a cascading re-render, so the flag is applied to
2804
+ // the value this render returns instead.
2805
+ const pendingResetRef = useRef(false);
2806
+
2807
+ // A changed binding is a different agent, so neither the open thread nor the
2808
+ // transcript carries — leaving the messages would render agent A's
2809
+ // conversation underneath agent B.
2810
+ const boundRef = useRef(agentRef);
2811
+ if (boundRef.current !== agentRef) {
2812
+ boundRef.current = agentRef;
2813
+ conversationRef.current = null;
2814
+ pendingResetRef.current = true;
2815
+ }
2816
+
2817
+ const reset = useCallback(() => {
2818
+ conversationRef.current = null;
2819
+ setMessages([]);
2820
+ setError(null);
2821
+ }, []);
2822
+
2823
+ const send = useCallback(async (text) => {
2824
+ const client = clientRef.current;
2825
+ const agentId = agentIdRef.current;
2826
+ if (!client || !agentId) {
2827
+ throw new AgentError("UNAVAILABLE", "No agent is available for this host");
2828
+ }
2829
+ const content = typeof text === "string" ? text.trim() : "";
2830
+ if (!content) {
2831
+ throw new AgentError("INVALID_MESSAGE", "A message is required");
2832
+ }
2833
+
2834
+ setSending(true);
2835
+ setError(null);
2836
+ try {
2837
+ const thread = client.conversations(agentId);
2838
+ if (!conversationRef.current) {
2839
+ // Assigned BEFORE the first await, so a concurrent send joins this one.
2840
+ conversationRef.current = thread.create().then((created) => {
2841
+ const id = created && created.id ? created.id : null;
2842
+ if (!id) {
2843
+ throw new AgentError("INTERNAL", "The conversation could not be opened");
2844
+ }
2845
+ return id;
2846
+ });
2847
+ }
2848
+ let conversationId;
2849
+ try {
2850
+ conversationId = await conversationRef.current;
2851
+ } catch (err) {
2852
+ // A failed open must not wedge the widget on a rejected promise.
2853
+ conversationRef.current = null;
2854
+ throw err;
2855
+ }
2856
+ const turn = await thread.messages(conversationId).send({ content });
2857
+ const userMessage = turn && turn.user_message ? turn.user_message : null;
2858
+ const reply = turn && turn.reply ? turn.reply : null;
2859
+ setMessages((prior) =>
2860
+ prior.concat(userMessage ? [userMessage] : [], reply ? [reply] : []),
2861
+ );
2862
+ setSending(false);
2863
+ return reply;
2864
+ } catch (err) {
2865
+ const e = toAgentError(err);
2866
+ // A stale thread is recoverable on the next send rather than permanently
2867
+ // broken: drop it so the widget opens a fresh one.
2868
+ if (e.code === "CONVERSATION_NOT_FOUND") conversationRef.current = null;
2869
+ setError(e);
2870
+ setSending(false);
2871
+ throw e;
2872
+ }
2873
+ }, []);
2874
+
2875
+ // The binding changed this render: hand back the empty transcript now rather
2876
+ // than one frame of the previous agent's conversation.
2877
+ let visibleMessages = messages;
2878
+ if (pendingResetRef.current) {
2879
+ pendingResetRef.current = false;
2880
+ visibleMessages = [];
2881
+ if (messages.length > 0) setMessages([]);
2882
+ }
2883
+
2884
+ return { send, reset, messages: visibleMessages, sending, error, available };
2885
+ }
2886
+
2697
2887
  /**
2698
2888
  * Datastore mutation hook. Returns { create, update, delete }, each method
2699
2889
  * returning a Promise. Routes through the injected
package/dist/host.d.ts CHANGED
@@ -173,35 +173,32 @@ export function normaliseNavigation(navigation: unknown): ResolvedNavigation;
173
173
  */
174
174
  export function menuItemCap(menuType: string): number | null;
175
175
 
176
- /** The app's bottom edge — a page's own bottom navigation bar. A `null` colour
177
- * means the host keeps its own default; a `null` `borderColor` means no
178
- * divider is drawn at all. */
176
+ /** The app's bottom edge — a page's own bottom navigation bar. Every colour is
177
+ * resolved, so no host keeps a default of its own; a `null` `borderColor`
178
+ * means no divider is drawn at all. */
179
179
  export interface FooterTokens {
180
- backgroundColor: string | null;
181
- textColor: string | null;
182
- /** Never `null`: an unset value resolves to the brand primary here rather
183
- * than in each host, which is what lets the two active-tab fields below be
184
- * resolved here at all. */
180
+ backgroundColor: string;
181
+ textColor: string;
182
+ /** The active tab's label, its glyph, and — under "accent" — the edge that
183
+ * marks it. One colour for all three: a mark and its label disagreeing about
184
+ * which colour means "you are here" would be two marks, not one. */
185
185
  activeColor: string;
186
+ /** `null` when no divider is drawn, which a zero `borderWidth` also means. */
186
187
  borderColor: string | null;
187
188
  borderWidth: number | null;
188
189
  activeStyle: "filled" | "accent";
189
190
  /** Whether a tab's glyph sits beside its label or above it (sc-7951). */
190
191
  tabLayout: "inline" | "stacked";
191
- /** The active tab's surface, or `null` for none. Under "filled" with nothing
192
- * named this is the active colour — what both hosts shipped. */
192
+ /** The active tab's surface, or `null` for none — which is what "accent"
193
+ * resolves to unless the author names one. */
193
194
  activeSurface: string | null;
194
- /** The active tab's label and glyph. White only under an unnamed "filled"
195
- * surface, which is the pairing both hosts hard-coded. */
196
- activeTint: string;
197
195
  }
198
196
 
199
197
  /**
200
- * Resolves the footer strip's tokens from a whole `theme_config`. Every colour
201
- * falls back to the sidebar's, so a workspace that never opens the Bottom
202
- * navigation bar panel renders exactly as it did before the block existed.
203
- * `tabActiveBackgroundColor` and `tabLayout` are the strip's own and inherit
204
- * from nothing — a vertical rail item has neither.
198
+ * Resolves the footer strip's tokens from a whole `theme_config`. It reads
199
+ * `theme.footer` and NOTHING else (sc-8033): the colours used to fall back to
200
+ * the sidebar's, so restyling the rail restyled the strip and the two could not
201
+ * be dressed apart.
205
202
  */
206
203
  export function resolveFooterTokens(theme: unknown): FooterTokens;
207
204
 
package/dist/index.d.ts CHANGED
@@ -663,6 +663,8 @@ export interface WidgetContext<TProps = unknown> {
663
663
  notifications: NotificationsClient;
664
664
  /** Injected @colixsystems/identification-client; backs useIdentification. */
665
665
  identification: IdentificationClient;
666
+ /** sc-7991 — injected @colixsystems/agents-client backing useAgent(). */
667
+ agents?: { conversations(agentId: string): unknown };
666
668
  /** Host child-node renderer; backs WidgetTree / useChildRenderer. */
667
669
  renderer: { renderNode(node: unknown): unknown };
668
670
  events: { emit(eventName: string, payload?: unknown): void };
@@ -1204,6 +1206,36 @@ export function useInterpretDraft(
1204
1206
  tableId: string | null | undefined,
1205
1207
  ): InterpretDraftApi;
1206
1208
 
1209
+ export interface AgentMessage {
1210
+ id: string;
1211
+ conversation_id: string;
1212
+ /** The system prompt is NOT part of the transcript — it lives on the agent. */
1213
+ role: "user" | "assistant";
1214
+ content: string;
1215
+ created_at: string;
1216
+ }
1217
+
1218
+ export interface AgentApi {
1219
+ /** Imperative — call from an event handler. The first send opens the thread. */
1220
+ send(text: string): Promise<AgentMessage | null>;
1221
+ /** Drop the open thread and clear the transcript this widget has seen. */
1222
+ reset(): void;
1223
+ /** Oldest first. The SERVER owns the real transcript. */
1224
+ messages: AgentMessage[];
1225
+ sending: boolean;
1226
+ error: AgentError | null;
1227
+ /** False when no agent is bound, or the host brokers no agents client. */
1228
+ available: boolean;
1229
+ }
1230
+
1231
+ /**
1232
+ * sc-7991 — hold a conversation with a workspace-authored AI agent.
1233
+ * IMPERATIVE: never fires on mount, so an unused widget costs nothing. The
1234
+ * widget sends ONE message; the system prompt and the real transcript stay
1235
+ * server-side and are never readable or settable from here.
1236
+ */
1237
+ export function useAgent(agentRef: string | null | undefined): AgentApi;
1238
+
1207
1239
  // REQ-RT-07 realtime subscription transport state.
1208
1240
  export type DatastoreSubscriptionStatus =
1209
1241
  | "connecting"
@@ -1926,10 +1958,29 @@ export class DirectoryError extends Error {
1926
1958
  }
1927
1959
 
1928
1960
  /**
1929
- * sc-3783 — error class thrown by `useTranslate().translate`. The `code` is a
1930
- * stable categorisation widgets can branch on. TRANSLATION_QUOTA_EXCEEDED and
1931
- * TRANSLATE_NOT_CONFIGURED will not clear on a retry — show the original text.
1961
+ * sc-7991 — structured error thrown by `useAgent().send`. Branch on `code`:
1962
+ * `AI_QUOTA_EXCEEDED` is the workspace's credit ceiling and will NOT clear on
1963
+ * a retry, unlike `RATE_LIMITED`; `AGENT_DISABLED` means the author switched
1964
+ * the agent off; `AI_AGENTS_DISABLED` means the feature is not released for
1965
+ * this workspace. Never show an app user a raw code or the billing state.
1932
1966
  */
1967
+ export class AgentError extends Error {
1968
+ code:
1969
+ | "UNAVAILABLE"
1970
+ | "AGENT_NOT_FOUND"
1971
+ | "AGENT_DISABLED"
1972
+ | "CONVERSATION_NOT_FOUND"
1973
+ | "INVALID_MESSAGE"
1974
+ | "AI_QUOTA_EXCEEDED"
1975
+ | "RATE_LIMITED"
1976
+ | "AI_UNAVAILABLE"
1977
+ | "AI_GENERATION_FAILED"
1978
+ | "AUTH_REQUIRED"
1979
+ | "INTERNAL"
1980
+ | (string & {});
1981
+ constructor(code: string, message: string, opts?: { cause?: unknown });
1982
+ }
1983
+
1933
1984
  export class TranslateError extends Error {
1934
1985
  code:
1935
1986
  | "UNSUPPORTED"
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  NotificationError,
29
29
  DirectoryError,
30
30
  PermissionError,
31
+ AgentError,
31
32
  useDatastoreQuery,
32
33
  useDatastoreRecord,
33
34
  useDatastoreSchema,
@@ -35,6 +36,7 @@ export {
35
36
  useStableQuery,
36
37
  useCanWrite,
37
38
  useInterpretDraft,
39
+ useAgent,
38
40
  useAsset,
39
41
  useAssetsByTag,
40
42
  useFilestoreFiles,
@@ -28,6 +28,7 @@ export {
28
28
  NotificationError,
29
29
  DirectoryError,
30
30
  PermissionError,
31
+ AgentError,
31
32
  useDatastoreQuery,
32
33
  useDatastoreRecord,
33
34
  useDatastoreSchema,
@@ -35,6 +36,7 @@ export {
35
36
  useStableQuery,
36
37
  useCanWrite,
37
38
  useInterpretDraft,
39
+ useAgent,
38
40
  useAsset,
39
41
  useAssetsByTag,
40
42
  useFilestoreFiles,
package/dist/linter.cjs CHANGED
@@ -565,6 +565,26 @@ function _scopeRules(source, manifest) {
565
565
  });
566
566
  }
567
567
  }
568
+
569
+ // sc-7991 — every send spends the workspace's AI credits, so the manifest has
570
+ // to declare the capability. This gate is ADVISORY, not an authorization
571
+ // boundary: unlike users/groups (minted into X-Widget-Scopes and checked
572
+ // server-side), nothing re-checks ai.invoke at call time, so a widget that
573
+ // evades this regex still reaches the endpoint. What actually bounds abuse is
574
+ // the app-user JWT, the per-actor and per-tenant limiters and the AI quota
575
+ // gate. The scope's real job is DISCLOSURE — it is what the marketplace
576
+ // install consent screen shows.
577
+ if (/\buseAgent\s*\(/.test(source)) {
578
+ const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
579
+ if (!invokes) {
580
+ findings.push({
581
+ rule: "scope-required-for-useAgent",
582
+ label: "useAgent() requires `ai.invoke:*` in manifest.requestedScopes",
583
+ line: 0,
584
+ snippet: "",
585
+ });
586
+ }
587
+ }
568
588
  const lines = source.split(/\r?\n/);
569
589
  let firedUsersWrite = false;
570
590
  let firedUsersDelete = false;
package/dist/linter.js CHANGED
@@ -528,6 +528,26 @@ function _scopeRules(source, manifest) {
528
528
  });
529
529
  }
530
530
  }
531
+
532
+ // sc-7991 — every send spends the workspace's AI credits, so the manifest has
533
+ // to declare the capability. This gate is ADVISORY, not an authorization
534
+ // boundary: unlike users/groups (minted into X-Widget-Scopes and checked
535
+ // server-side), nothing re-checks ai.invoke at call time, so a widget that
536
+ // evades this regex still reaches the endpoint. What actually bounds abuse is
537
+ // the app-user JWT, the per-actor and per-tenant limiters and the AI quota
538
+ // gate. The scope's real job is DISCLOSURE — it is what the marketplace
539
+ // install consent screen shows.
540
+ if (/\buseAgent\s*\(/.test(source)) {
541
+ const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
542
+ if (!invokes) {
543
+ findings.push({
544
+ rule: "scope-required-for-useAgent",
545
+ label: "useAgent() requires `ai.invoke:*` in manifest.requestedScopes",
546
+ line: 0,
547
+ snippet: "",
548
+ });
549
+ }
550
+ }
531
551
  const lines = source.split(/\r?\n/);
532
552
  let firedUsersWrite = false;
533
553
  let firedUsersDelete = false;
@@ -71,78 +71,76 @@ function menuItemCap(menuType) {
71
71
  }
72
72
 
73
73
  // REQ-NAV-LOCAL: the app's BOTTOM EDGE — the tokens a page's own bottom
74
- // navigation bar is drawn from. Every colour falls back to the sidebar's, so a
75
- // workspace that never opens the panel keeps the appearance it has.
74
+ // navigation bar is drawn from.
76
75
  //
77
76
  // sc-7753: the global quick bar these tokens were introduced for is retired;
78
77
  // the bar that reads them now is the page's own (`local_nav = BOTTOM_BAR`).
79
78
  //
80
- // sc-7951: three of the keys ARE that bar's own — the active-item style it
81
- // used to borrow from the rail for want of a control, the active tab's
82
- // surface, and whether the glyph sits beside the label or above it.
79
+ // sc-8033: the block is SELF-CONTAINED — it reads `theme.footer` and nothing
80
+ // else. Every colour used to fall back to the rail's, so restyling the sidebar
81
+ // restyled the strip and the two could not be dressed apart; that is the defect
82
+ // sc-7951 set out to fix and only half did. A one-off backfill materialised
83
+ // what each workspace was inheriting, so no shipped app lost its look when the
84
+ // chain went (migration `20260918000000_footer_tokens_uncouple`).
85
+ //
86
+ // The eight authorable keys are the eight controls the Design panel offers, one
87
+ // for one: `backgroundColor`, `textColor`, `tabActiveBackgroundColor`,
88
+ // `tabActiveTextColor`, `borderColor`, `borderWidth`, `activeStyle`,
89
+ // `tabLayout`.
83
90
  function resolveFooterTokens(theme) {
84
91
  const config = isPlainObject(theme) ? theme : {};
85
92
  const footer = isPlainObject(config.footer) ? config.footer : {};
86
- const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
87
- // Hex-guarded on the way out, not merely on the way in: `theme_config` is an
88
- // opaque blob an operator can hand-edit, and the EXPORT already refused a
89
- // malformed colour here while the Player passed it to a style object — so the
90
- // guard is what stops the two hosts painting a stored junk value differently.
91
- const pick = (key) => hexOrNull(footer[key]) || hexOrNull(sidebar[key]) || null;
92
- const borderColor = pick("borderColor");
93
- // sc-7951: the rail's style is the FALLBACK now, not the only source. Unset
94
- // still follows the rail, which is what keeps every shipped app where it is.
95
- const activeStyle = footer.activeStyle || sidebar.activeStyle;
96
- const accent = activeStyle === "accent";
97
- const activeColor = hexOr(pick("activeColor"), brandPrimary(config));
98
- // Footer-only: a rail item is a full-width row with no tab surface to paint,
99
- // so this inherits from nothing and unset means "no surface of its own".
100
- const namedSurface = hexOrNull(footer.tabActiveBackgroundColor);
93
+ // Hex-guarded on read: `theme_config` is an opaque blob an operator can
94
+ // hand-edit, and the EXPORT already refused a malformed colour here while the
95
+ // Player passed it to a style object — so the guard is what stops the two
96
+ // hosts painting a stored junk value differently.
97
+ const accent = footer.activeStyle === "accent";
98
+ const borderColor = hexOrNull(footer.borderColor);
99
+ // sc-8033: the strip's thickness is authorable down to 0, where the rail's
100
+ // and the header's clamp up to 1 (sc-7965). The panel OFFERS zero, so zero is
101
+ // a choice rather than the junk value that clamp refuses — and it resolves
102
+ // into the existing no-divider state, so neither host needs a second way to
103
+ // draw nothing.
104
+ const borderWidth = borderColor ? footerBorderWidthOr(footer.borderWidth) : 0;
105
+ const divider = borderWidth > 0;
101
106
  return {
102
- backgroundColor: pick("backgroundColor"),
103
- textColor: pick("textColor"),
104
- // Resolved here rather than by each caller, which is what lets the two
105
- // active-tab fields below be resolved here too.
106
- activeColor,
107
- // REQ-THEME-LOOK: the divider's COLOUR is its switch. Unset draws no line at
108
- // all — which the native strip did not honour before this, ruling a
109
- // hairline across a tinted bar that had asked for none.
110
- borderColor,
111
- borderWidth: borderColor ? borderWidthOr(footer.borderWidth, sidebar.borderWidth) : null,
107
+ backgroundColor: hexOr(footer.backgroundColor, DEFAULT_FOOTER_SURFACE),
108
+ textColor: hexOr(footer.textColor, DEFAULT_CHROME_TEXT),
109
+ borderColor: divider ? borderColor : null,
110
+ borderWidth: divider ? borderWidth : null,
112
111
  activeStyle: accent ? "accent" : "filled",
113
112
  // sc-7951: bottom tabs are the only strip tall enough to stack the two, so
114
113
  // the choice is the footer's alone.
115
114
  tabLayout: footer.tabLayout === "stacked" ? "stacked" : "inline",
116
- // sc-7951: the active tab's two halves, resolved HERE so neither host
117
- // re-derives the back-compat rule. "filled" with nothing named is what both
118
- // hosts shipped — the active colour as the fill, the label forced white —
119
- // and that pairing is the only reason a marked tab's label is not simply
120
- // the active colour. Naming a surface unbundles them: the surface is the
121
- // author's and the label goes back to the active colour.
122
- activeSurface: namedSurface || (accent ? null : activeColor),
123
- activeTint: accent || namedSurface ? activeColor : FILLED_TAB_LABEL,
115
+ // sc-8033: the active tab's two halves are two KEYS, resolved here so
116
+ // neither host re-derives them. They used to be one overloaded
117
+ // `activeColor` — the fill under "filled", the label under "accent" — which
118
+ // is why no author could set both. Only the DEFAULTS still consult the
119
+ // style, and they are exactly what each style shipped: a brand-primary fill
120
+ // under a white label, or no fill under a brand-primary label and edge.
121
+ activeSurface: accent
122
+ ? hexOrNull(footer.tabActiveBackgroundColor)
123
+ : hexOr(footer.tabActiveBackgroundColor, brandPrimary(config)),
124
+ // The active tab's label, its glyph, and — under "accent" — the edge that
125
+ // marks it. One colour for all three: a mark and its label disagreeing
126
+ // about which colour means "you are here" would be two marks, not one.
127
+ activeColor: hexOr(
128
+ footer.tabActiveTextColor,
129
+ accent ? brandPrimary(config) : FILLED_TAB_LABEL,
130
+ ),
124
131
  };
125
132
  }
126
133
 
127
134
  // REQ-THEME-14 — the rail's and the app bar's tokens, resolved once for both
128
135
  // hosts.
129
136
  //
130
- // These return FULLY resolved values where `resolveFooterTokens` above still
131
- // returns raw-or-null for its SURFACE, LABEL and DIVIDER colours, and the
132
- // difference is deliberate. The footer's two hosts happen to agree on their
133
- // separately-written defaults for those three (`#ffffff` / `#475569` / no
134
- // line); the rail's and the bar's did not. sc-7951 resolved the footer's active
135
- // tab — `activeColor`, `activeSurface`, `activeTint` — because a rule that
136
- // pairs a surface with a label cannot live in two hosts; the three above are
137
- // what remains of the old shape. An unset `sidebar.backgroundColor` painted #ffffff on web and the app
138
- // background in the export, and an unset `topBar.textColor` painted the brand
139
- // primary on web and slate-700 in the export. A default that lives in the
140
- // resolver cannot drift; one that lives in each host already had. The footer's
141
- // three remaining raw colours want this same shape next.
142
- //
143
- // Every default is what the WEB Player renders today, so the Player does not
144
- // move and the export converges onto it. That is the direction the footer was
145
- // converged in too (compiler.service's footerBg defaults to the web's #ffffff).
137
+ // Every default here is what the WEB Player renders, so the Player does not
138
+ // move and the export converges onto it. An unset `sidebar.backgroundColor`
139
+ // painted #ffffff on web and the app background in the export, and an unset
140
+ // `topBar.textColor` painted the brand primary on web and slate-700 in the
141
+ // export. A default that lives in the resolver cannot drift; one that lives in
142
+ // each host already had — which is why `resolveFooterTokens` above stopped
143
+ // returning raw-or-null too (sc-8033).
146
144
  //
147
145
  // sc-6596 — the ONE exception, and it moves BOTH hosts: an unset rail or bar
148
146
  // takes the page's own colour. Converging on the Player's white left a themed
@@ -151,13 +149,16 @@ function resolveFooterTokens(theme) {
151
149
  const DEFAULT_PRIMARY_COLOR = "#3b82f6";
152
150
  // The web nav item's `text-slate-600`.
153
151
  const DEFAULT_CHROME_TEXT = "#475569";
152
+ // The strip's own surface. Both hosts already defaulted to this separately (the
153
+ // web `footerBg`, the compiler's `#ffffff`); sc-8033 resolved it here instead.
154
+ const DEFAULT_FOOTER_SURFACE = "#ffffff";
154
155
  // The web mobile header's icon colour, `text-slate-700`.
155
156
  const DEFAULT_TOP_BAR_TINT = "#334155";
156
157
  // The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
157
158
  // (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
158
159
  const DEFAULT_APP_SURFACE = "#f8fafc";
159
- // The literal both hosts hard-coded behind an unnamed "filled" surface, before
160
- // resolveFooterTokens decided the pairing once (sc-7951).
160
+ // A filled tab's label where the author names none — the literal both hosts
161
+ // hard-coded behind an unnamed "filled" surface before the resolver decided it.
161
162
  const FILLED_TAB_LABEL = "#ffffff";
162
163
 
163
164
  // A gradient shows only when BOTH stops are a flat hex — the rule both hosts'
@@ -377,6 +378,18 @@ function resolveTopBarTokens(theme) {
377
378
  // COLOUR is its switch, so a coloured 0px edge is a contradiction, not "off".
378
379
  const BORDER_WIDTH_MIN = 1;
379
380
  const BORDER_WIDTH_MAX = 8;
381
+
382
+ // The BOTTOM strip's own range, which starts at zero. Its thickness is a
383
+ // control the Design panel offers down to 0, so a stored zero is an author
384
+ // saying "no line" rather than the junk value the clamp above refuses — and
385
+ // `resolveFooterTokens` turns it back into the no-divider state both hosts
386
+ // already draw, so the extra bound costs neither host a branch (sc-8033).
387
+ function footerBorderWidthOr(value) {
388
+ if (typeof value !== "number" || !Number.isFinite(value)) {
389
+ return BORDER_WIDTH_MIN;
390
+ }
391
+ return Math.min(Math.max(Math.round(value), 0), BORDER_WIDTH_MAX);
392
+ }
380
393
  function borderWidthOr(...values) {
381
394
  for (const value of values) {
382
395
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -62,78 +62,76 @@ export function menuItemCap(menuType) {
62
62
  }
63
63
 
64
64
  // REQ-NAV-LOCAL: the app's BOTTOM EDGE — the tokens a page's own bottom
65
- // navigation bar is drawn from. Every colour falls back to the sidebar's, so a
66
- // workspace that never opens the panel keeps the appearance it has.
65
+ // navigation bar is drawn from.
67
66
  //
68
67
  // sc-7753: the global quick bar these tokens were introduced for is retired;
69
68
  // the bar that reads them now is the page's own (`local_nav = BOTTOM_BAR`).
70
69
  //
71
- // sc-7951: three of the keys ARE that bar's own — the active-item style it
72
- // used to borrow from the rail for want of a control, the active tab's
73
- // surface, and whether the glyph sits beside the label or above it.
70
+ // sc-8033: the block is SELF-CONTAINED — it reads `theme.footer` and nothing
71
+ // else. Every colour used to fall back to the rail's, so restyling the sidebar
72
+ // restyled the strip and the two could not be dressed apart; that is the defect
73
+ // sc-7951 set out to fix and only half did. A one-off backfill materialised
74
+ // what each workspace was inheriting, so no shipped app lost its look when the
75
+ // chain went (migration `20260918000000_footer_tokens_uncouple`).
76
+ //
77
+ // The eight authorable keys are the eight controls the Design panel offers, one
78
+ // for one: `backgroundColor`, `textColor`, `tabActiveBackgroundColor`,
79
+ // `tabActiveTextColor`, `borderColor`, `borderWidth`, `activeStyle`,
80
+ // `tabLayout`.
74
81
  export function resolveFooterTokens(theme) {
75
82
  const config = isPlainObject(theme) ? theme : {};
76
83
  const footer = isPlainObject(config.footer) ? config.footer : {};
77
- const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
78
- // Hex-guarded on the way out, not merely on the way in: `theme_config` is an
79
- // opaque blob an operator can hand-edit, and the EXPORT already refused a
80
- // malformed colour here while the Player passed it to a style object — so the
81
- // guard is what stops the two hosts painting a stored junk value differently.
82
- const pick = (key) => hexOrNull(footer[key]) || hexOrNull(sidebar[key]) || null;
83
- const borderColor = pick("borderColor");
84
- // sc-7951: the rail's style is the FALLBACK now, not the only source. Unset
85
- // still follows the rail, which is what keeps every shipped app where it is.
86
- const activeStyle = footer.activeStyle || sidebar.activeStyle;
87
- const accent = activeStyle === "accent";
88
- const activeColor = hexOr(pick("activeColor"), brandPrimary(config));
89
- // Footer-only: a rail item is a full-width row with no tab surface to paint,
90
- // so this inherits from nothing and unset means "no surface of its own".
91
- const namedSurface = hexOrNull(footer.tabActiveBackgroundColor);
84
+ // Hex-guarded on read: `theme_config` is an opaque blob an operator can
85
+ // hand-edit, and the EXPORT already refused a malformed colour here while the
86
+ // Player passed it to a style object — so the guard is what stops the two
87
+ // hosts painting a stored junk value differently.
88
+ const accent = footer.activeStyle === "accent";
89
+ const borderColor = hexOrNull(footer.borderColor);
90
+ // sc-8033: the strip's thickness is authorable down to 0, where the rail's
91
+ // and the header's clamp up to 1 (sc-7965). The panel OFFERS zero, so zero is
92
+ // a choice rather than the junk value that clamp refuses — and it resolves
93
+ // into the existing no-divider state, so neither host needs a second way to
94
+ // draw nothing.
95
+ const borderWidth = borderColor ? footerBorderWidthOr(footer.borderWidth) : 0;
96
+ const divider = borderWidth > 0;
92
97
  return {
93
- backgroundColor: pick("backgroundColor"),
94
- textColor: pick("textColor"),
95
- // Resolved here rather than by each caller, which is what lets the two
96
- // active-tab fields below be resolved here too.
97
- activeColor,
98
- // REQ-THEME-LOOK: the divider's COLOUR is its switch. Unset draws no line at
99
- // all — which the native strip did not honour before this, ruling a
100
- // hairline across a tinted bar that had asked for none.
101
- borderColor,
102
- borderWidth: borderColor ? borderWidthOr(footer.borderWidth, sidebar.borderWidth) : null,
98
+ backgroundColor: hexOr(footer.backgroundColor, DEFAULT_FOOTER_SURFACE),
99
+ textColor: hexOr(footer.textColor, DEFAULT_CHROME_TEXT),
100
+ borderColor: divider ? borderColor : null,
101
+ borderWidth: divider ? borderWidth : null,
103
102
  activeStyle: accent ? "accent" : "filled",
104
103
  // sc-7951: bottom tabs are the only strip tall enough to stack the two, so
105
104
  // the choice is the footer's alone.
106
105
  tabLayout: footer.tabLayout === "stacked" ? "stacked" : "inline",
107
- // sc-7951: the active tab's two halves, resolved HERE so neither host
108
- // re-derives the back-compat rule. "filled" with nothing named is what both
109
- // hosts shipped — the active colour as the fill, the label forced white —
110
- // and that pairing is the only reason a marked tab's label is not simply
111
- // the active colour. Naming a surface unbundles them: the surface is the
112
- // author's and the label goes back to the active colour.
113
- activeSurface: namedSurface || (accent ? null : activeColor),
114
- activeTint: accent || namedSurface ? activeColor : FILLED_TAB_LABEL,
106
+ // sc-8033: the active tab's two halves are two KEYS, resolved here so
107
+ // neither host re-derives them. They used to be one overloaded
108
+ // `activeColor` — the fill under "filled", the label under "accent" — which
109
+ // is why no author could set both. Only the DEFAULTS still consult the
110
+ // style, and they are exactly what each style shipped: a brand-primary fill
111
+ // under a white label, or no fill under a brand-primary label and edge.
112
+ activeSurface: accent
113
+ ? hexOrNull(footer.tabActiveBackgroundColor)
114
+ : hexOr(footer.tabActiveBackgroundColor, brandPrimary(config)),
115
+ // The active tab's label, its glyph, and — under "accent" — the edge that
116
+ // marks it. One colour for all three: a mark and its label disagreeing
117
+ // about which colour means "you are here" would be two marks, not one.
118
+ activeColor: hexOr(
119
+ footer.tabActiveTextColor,
120
+ accent ? brandPrimary(config) : FILLED_TAB_LABEL,
121
+ ),
115
122
  };
116
123
  }
117
124
 
118
125
  // REQ-THEME-14 — the rail's and the app bar's tokens, resolved once for both
119
126
  // hosts.
120
127
  //
121
- // These return FULLY resolved values where `resolveFooterTokens` above still
122
- // returns raw-or-null for its SURFACE, LABEL and DIVIDER colours, and the
123
- // difference is deliberate. The footer's two hosts happen to agree on their
124
- // separately-written defaults for those three (`#ffffff` / `#475569` / no
125
- // line); the rail's and the bar's did not. sc-7951 resolved the footer's active
126
- // tab — `activeColor`, `activeSurface`, `activeTint` — because a rule that
127
- // pairs a surface with a label cannot live in two hosts; the three above are
128
- // what remains of the old shape. An unset `sidebar.backgroundColor` painted #ffffff on web and the app
129
- // background in the export, and an unset `topBar.textColor` painted the brand
130
- // primary on web and slate-700 in the export. A default that lives in the
131
- // resolver cannot drift; one that lives in each host already had. The footer's
132
- // three remaining raw colours want this same shape next.
133
- //
134
- // Every default is what the WEB Player renders today, so the Player does not
135
- // move and the export converges onto it. That is the direction the footer was
136
- // converged in too (compiler.service's footerBg defaults to the web's #ffffff).
128
+ // Every default here is what the WEB Player renders, so the Player does not
129
+ // move and the export converges onto it. An unset `sidebar.backgroundColor`
130
+ // painted #ffffff on web and the app background in the export, and an unset
131
+ // `topBar.textColor` painted the brand primary on web and slate-700 in the
132
+ // export. A default that lives in the resolver cannot drift; one that lives in
133
+ // each host already had — which is why `resolveFooterTokens` above stopped
134
+ // returning raw-or-null too (sc-8033).
137
135
  //
138
136
  // sc-6596 — the ONE exception, and it moves BOTH hosts: an unset rail or bar
139
137
  // takes the page's own colour. Converging on the Player's white left a themed
@@ -142,13 +140,16 @@ export function resolveFooterTokens(theme) {
142
140
  const DEFAULT_PRIMARY_COLOR = "#3b82f6";
143
141
  // The web nav item's `text-slate-600`.
144
142
  const DEFAULT_CHROME_TEXT = "#475569";
143
+ // The strip's own surface. Both hosts already defaulted to this separately (the
144
+ // web `footerBg`, the compiler's `#ffffff`); sc-8033 resolved it here instead.
145
+ const DEFAULT_FOOTER_SURFACE = "#ffffff";
145
146
  // The web mobile header's icon colour, `text-slate-700`.
146
147
  const DEFAULT_TOP_BAR_TINT = "#334155";
147
148
  // The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
148
149
  // (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
149
150
  const DEFAULT_APP_SURFACE = "#f8fafc";
150
- // The literal both hosts hard-coded behind an unnamed "filled" surface, before
151
- // resolveFooterTokens decided the pairing once (sc-7951).
151
+ // A filled tab's label where the author names none — the literal both hosts
152
+ // hard-coded behind an unnamed "filled" surface before the resolver decided it.
152
153
  const FILLED_TAB_LABEL = "#ffffff";
153
154
 
154
155
  // A gradient shows only when BOTH stops are a flat hex — the rule both hosts'
@@ -368,6 +369,18 @@ export function resolveTopBarTokens(theme) {
368
369
  // COLOUR is its switch, so a coloured 0px edge is a contradiction, not "off".
369
370
  const BORDER_WIDTH_MIN = 1;
370
371
  const BORDER_WIDTH_MAX = 8;
372
+
373
+ // The BOTTOM strip's own range, which starts at zero. Its thickness is a
374
+ // control the Design panel offers down to 0, so a stored zero is an author
375
+ // saying "no line" rather than the junk value the clamp above refuses — and
376
+ // `resolveFooterTokens` turns it back into the no-divider state both hosts
377
+ // already draw, so the extra bound costs neither host a branch (sc-8033).
378
+ function footerBorderWidthOr(value) {
379
+ if (typeof value !== "number" || !Number.isFinite(value)) {
380
+ return BORDER_WIDTH_MIN;
381
+ }
382
+ return Math.min(Math.max(Math.round(value), 0), BORDER_WIDTH_MAX);
383
+ }
371
384
  function borderWidthOr(...values) {
372
385
  for (const value of values) {
373
386
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -23,6 +23,11 @@ const VALID_TYPES = new Set([
23
23
  // value is the raw uuid string so the tenant-copy walker can remap it
24
24
  // through `_remapJson` without per-prop hooks.
25
25
  "groupRef",
26
+ // sc-7991: `agentRef` is a picker for one of the workspace's AI agents,
27
+ // emitting a bare TenantAiAgent UUID into the page JSON. A widget reads it
28
+ // and calls `useAgent(agentRef)`. Bare uuid string like `groupRef`/`pageRef`,
29
+ // so tenant-copy remaps it through the layout walk's merged id map.
30
+ "agentRef",
26
31
  // REQ-WDG-PAGEREF: `pageRef` is a page picker that emits a bare AppPage
27
32
  // UUID into the page JSON. Renders a page `<select>` in the Studio
28
33
  // Properties Panel; a widget reads it and navigates via
@@ -148,6 +153,7 @@ function coerceLeaf(def, value, path, errors) {
148
153
  case "recordBinding":
149
154
  case "groupRef":
150
155
  case "pageRef":
156
+ case "agentRef":
151
157
  case "richText":
152
158
  case "asset":
153
159
  case "folderRef":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.146.0",
3
+ "version": "0.148.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",