sygnal 6.3.0 → 6.5.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/CHANGELOG.md +91 -0
- package/dist/astro/index.cjs.js +69 -3
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +69 -3
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +3 -2
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3 -2
- package/dist/astro/server.mjs.map +1 -1
- package/dist/diagnostics.cjs.js +105 -19
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +105 -19
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/mcp-apps.md +59 -4
- package/dist/index.cjs.js +54 -25
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +27 -6
- package/dist/index.esm.js +54 -25
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vite/plugin.cjs.js +69 -3
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +69 -3
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/src/core/cell.ts +25 -7
- package/src/core/hosts/collection.ts +6 -11
- package/src/core/hosts/switchable.ts +2 -2
- package/src/core/instance.ts +2 -2
- package/src/extra/diagnostics/checks/collections.ts +31 -25
- package/src/extra/diagnostics/checks/dom.ts +10 -2
- package/src/extra/diagnostics/checks/next.ts +39 -1
- package/src/extra/diagnostics/checks/persist.ts +4 -2
- package/src/extra/diagnostics/checks/wiring.ts +14 -6
- package/src/extra/diagnostics/codes.ts +9 -0
- package/src/extra/pwa.ts +3 -1
- package/src/extra/reducers.ts +10 -4
- package/src/index.d.ts +26 -5
- package/src/vite/plugin.ts +63 -2
package/dist/guide/mcp-apps.md
CHANGED
|
@@ -48,10 +48,19 @@ App.model = {
|
|
|
48
48
|
STATE: (state) => ({ ...state, status: 'loading' }),
|
|
49
49
|
MCP: (state) => ({ callTool: 'get_forecast', args: { city: state.city }, ok: 'RESULT' }),
|
|
50
50
|
},
|
|
51
|
-
// tell the model what the user picked: it reads it
|
|
51
|
+
// tell the model what the user picked: store it on the server, where the model reads it with a
|
|
52
|
+
// tool, then send it as model context too (see Model context below)
|
|
52
53
|
PICK: {
|
|
53
54
|
STATE: (state, date) => ({ ...state, selected: date }),
|
|
54
|
-
MCP: (state, date) => ({
|
|
55
|
+
MCP: (state, date) => ({ callTool: 'select_day', args: { city: state.city, day: state.days.find((d) => d.date === date) }, ok: 'PICKED' }),
|
|
56
|
+
},
|
|
57
|
+
PICKED: {
|
|
58
|
+
MCP: (state) => ({
|
|
59
|
+
updateModelContext: {
|
|
60
|
+
content: `The user selected ${state.selected} in the ${state.city} forecast card.`,
|
|
61
|
+
structuredContent: { city: state.city, selectedDay: state.days.find((d) => d.date === state.selected) },
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
55
64
|
},
|
|
56
65
|
// post a message to the conversation, as the user
|
|
57
66
|
ASK: { MCP: (state) => ({ message: `What should I pack for ${state.city} on ${state.selected}?` }) },
|
|
@@ -92,7 +101,7 @@ The latest tool input, tool result, host context and `host` are replayed to a co
|
|
|
92
101
|
| Value | Does |
|
|
93
102
|
|---|---|
|
|
94
103
|
| `{ callTool: 'name', args, ok, error }` | Calls a tool on the MCP server through the host. `ok` gets the `CallToolResult`; `error` gets `{ error, code?, result?, request }` (a JSON-RPC error, or a result with `isError: true`) |
|
|
95
|
-
| `{ updateModelContext: value }` | Tells the model something, for its next turn: a string (one text block), content blocks,
|
|
104
|
+
| `{ updateModelContext: value }` | Tells the model something, for its next turn: a string (one text block), content blocks, an object (`structuredContent`), or `{ content, structuredContent }`. The host decides whether the model sees it: see [Model context](#model-context) |
|
|
96
105
|
| `{ message: 'text' }` | Posts a message to the conversation as the user (a string or content blocks) |
|
|
97
106
|
| `{ openLink: 'https://…' }` | Asks the host to open a link |
|
|
98
107
|
| `{ displayMode: 'fullscreen' }` | Asks for `'inline'`, `'fullscreen'` or `'pip'`; `ok` gets `{ mode }`, the mode the host chose |
|
|
@@ -155,6 +164,45 @@ Export.model = {
|
|
|
155
164
|
|
|
156
165
|
**Theme and size.** Follow `host-context-changed` for the host's `theme` (and its `styles.variables`, CSS custom properties the host suggests), so the view matches the conversation around it. Keep the view compact for `inline` mode; offer full screen with `{ displayMode: 'fullscreen' }` when `availableDisplayModes` includes it.
|
|
157
166
|
|
|
167
|
+
### Model context
|
|
168
|
+
|
|
169
|
+
A view has three ways to tell the model something, and hosts handle them differently:
|
|
170
|
+
|
|
171
|
+
- `{ message }` posts a message to the conversation as the user. The model sees it and answers it, in every host that supports `message`. Use it when the user's click is a question (the starter's **Ask about this day**).
|
|
172
|
+
- `{ updateModelContext }` gives the host context for the model's **next** turn, and the host decides what to do with it. The spec says a host *should* pass it on, not how. Cursor adds it to the next user message; claude.ai keeps it and lets the model fetch it with a built-in tool ("Reading widget context"), and so does Claude Desktop's Code tab (`read_widget_context`, which the host points the model to); Claude Desktop's chat (tested October 2026) accepts it, but its model has no way to read it.
|
|
173
|
+
- **A tool on your server.** The view saves what the user did with a tool only the view may call, and the model reads it with an ordinary tool. This reaches the model in every host, because every host offers the server's tools.
|
|
174
|
+
|
|
175
|
+
The starter does the last two. When the user picks a day, the view calls `select_day`, then sends the same selection as model context:
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
// server/server.js
|
|
179
|
+
let selection = null // one per process: the stdio server runs one per host connection
|
|
180
|
+
|
|
181
|
+
// only the view can call it: visibility ['app'] keeps it out of the model's tool list
|
|
182
|
+
server.registerTool('select_day', {
|
|
183
|
+
description: 'Records the day the user selected in the forecast card.',
|
|
184
|
+
inputSchema: { city: z.string(), day: z.object({ date: z.string(), sky: z.string(), high: z.number(), low: z.number() }) },
|
|
185
|
+
_meta: { ui: { resourceUri: VIEW_URI, visibility: ['app'] } },
|
|
186
|
+
}, async ({ city, day }) => {
|
|
187
|
+
selection = { city, day }
|
|
188
|
+
return { content: [{ type: 'text', text: `Selected ${day.date} in ${city}` }], structuredContent: selection }
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// the model's way to read it; get_forecast's description says to call it
|
|
192
|
+
server.registerTool('get_selected_day', {
|
|
193
|
+
description: 'The day the user selected in the forecast card shown by get_forecast, if any.',
|
|
194
|
+
inputSchema: {},
|
|
195
|
+
}, async () => ({
|
|
196
|
+
content: [{ type: 'text', text: selection ? `The user selected ${selection.day.date} in ${selection.city}.` : 'The user has not selected a day.' }],
|
|
197
|
+
}))
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The stdio server runs once per host connection, so one variable holds one user's selection. A server many users share (Streamable HTTP behind a public URL) has to keep it per user or session.
|
|
201
|
+
|
|
202
|
+
Send `updateModelContext` with a text `content`: hosts list the content types they take (`MCP.select('host')` gives `hostCapabilities.updateModelContext`, Claude's is `{ text: {}, image: {} }`), and one that doesn't list `structuredContent` may drop an update that only has it.
|
|
203
|
+
|
|
204
|
+
A host may also keep a tool's view (the `ui://` resource) and the tool list for as long as the server stays connected: Claude Desktop fetches them once per connection. After rebuilding the view or changing a tool, reconnect the server (restart the host, or turn the server off and on) before trying it again.
|
|
205
|
+
|
|
158
206
|
## The view's own tools
|
|
159
207
|
|
|
160
208
|
The model sees the server's tools. With `tools: agentTools`, the view also offers its components' [`agent` declarations](./agent.md) to the host as tools of its own, so the model can operate the view while it is shown: select a day, change units, open a detail. Calls go through the same [call rules](./agent.md#the-call-rules) as any agent's.
|
|
@@ -263,7 +311,14 @@ it('shows the result, refreshes, and tells the model what the user picked', asyn
|
|
|
263
311
|
|
|
264
312
|
t.simulateEvent('.day', 'click', { data: { date: 'Tue' } })
|
|
265
313
|
await t.waitForState((s) => s.selected === 'Tue')
|
|
266
|
-
expect(t.requests('MCP').at(-1)).toEqual({
|
|
314
|
+
expect(t.requests('MCP').at(-1)).toEqual({ callTool: 'select_day', args: { city: 'Oslo', day: days[1] }, ok: 'PICKED' })
|
|
315
|
+
await t.respond('MCP', { content: [], structuredContent: { city: 'Oslo', day: days[1] } }, 'PICKED')
|
|
316
|
+
expect(t.requests('MCP').at(-1)).toEqual({
|
|
317
|
+
updateModelContext: {
|
|
318
|
+
content: 'The user selected Tue in the Oslo forecast card.',
|
|
319
|
+
structuredContent: { city: 'Oslo', selectedDay: days[1] },
|
|
320
|
+
},
|
|
321
|
+
})
|
|
267
322
|
})
|
|
268
323
|
```
|
|
269
324
|
|
package/dist/index.cjs.js
CHANGED
|
@@ -785,6 +785,10 @@ const DEV_CODE_SEVERITY = {
|
|
|
785
785
|
SYG425: 'warn',
|
|
786
786
|
// PLAN-9 (checks/next.ts): D471 a lowercase <collection> / <switchable> tag (deprecated in 6.3.0)
|
|
787
787
|
SYG426: 'warn',
|
|
788
|
+
// checks/next.ts: CHILD.select(undefined), usually a missing export
|
|
789
|
+
SYG427: 'error',
|
|
790
|
+
// checks/next.ts: a path designator that 6.4 read as one key with a dot
|
|
791
|
+
SYG428: 'warn',
|
|
788
792
|
// PLAN-9 (checks/next.ts): D470 key identity, two children with one identity
|
|
789
793
|
SYG453: 'warn',
|
|
790
794
|
SYG612: 'error',
|
|
@@ -1411,18 +1415,45 @@ function localCell(app, parent) {
|
|
|
1411
1415
|
return { local: true, get: () => (own ? v : parent.get()), set: (n) => { v = n; own = true; app.commit(); } };
|
|
1412
1416
|
}
|
|
1413
1417
|
/**
|
|
1414
|
-
* state
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1418
|
+
* SPIKE (state paths): a state designator as segments. A plain key is one segment; 'a.b.0' and
|
|
1419
|
+
* 'a[0].b' are paths into nested objects and arrays. No array form: an array `from` / `state` is
|
|
1420
|
+
* the data passed by mistake (SYG410 / SYG412), and ['x', 'y'] would be ambiguous.
|
|
1421
|
+
*/
|
|
1422
|
+
const pathOf$1 = (p) => p.replace(/\[(\d+)\]/g, '.$1').split('.');
|
|
1423
|
+
const getIn$1 = (o, ks) => { for (const k of ks) {
|
|
1424
|
+
if (o == null)
|
|
1425
|
+
return undefined;
|
|
1426
|
+
o = o[k];
|
|
1427
|
+
} return o; };
|
|
1428
|
+
/** `o` with the value at `ks` replaced; only the levels on the path are copied (a missing level becomes {}) */
|
|
1429
|
+
function setIn$1(o, ks, i, v) {
|
|
1430
|
+
if (i == ks.length)
|
|
1431
|
+
return v;
|
|
1432
|
+
const k = ks[i], cur = o?.[k], n = setIn$1(cur, ks, i + 1, v);
|
|
1433
|
+
if (n === cur && o != null)
|
|
1434
|
+
return o;
|
|
1435
|
+
if (Array.isArray(o)) {
|
|
1436
|
+
const c = o.slice();
|
|
1437
|
+
c[k] = n;
|
|
1438
|
+
return c;
|
|
1439
|
+
}
|
|
1440
|
+
return { ...o, [k]: n };
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* state="key" (SPIKE: or a path, 'a.b' / 'a[0].b'). `owner`: the parent's name when the first
|
|
1444
|
+
* segment is one of its calculated fields (SYG409: writes ignored; `what` names the writer in
|
|
1445
|
+
* the message). `dflt`: an isolated child's initialState, read while the slice is undefined.
|
|
1446
|
+
* `has()`: the slice itself is defined (D174).
|
|
1417
1447
|
*/
|
|
1418
1448
|
function keyCell(parent, k, owner, dflt, what = 'Sub-component') {
|
|
1449
|
+
const ks = pathOf$1(k);
|
|
1419
1450
|
let lp = {}, lv;
|
|
1420
1451
|
return {
|
|
1421
1452
|
get() {
|
|
1422
1453
|
const p = parent.get();
|
|
1423
1454
|
if (p !== lp) {
|
|
1424
1455
|
lp = p;
|
|
1425
|
-
lv = p
|
|
1456
|
+
lv = getIn$1(p, ks);
|
|
1426
1457
|
if (lv === undefined)
|
|
1427
1458
|
lv = dflt;
|
|
1428
1459
|
}
|
|
@@ -1430,12 +1461,12 @@ function keyCell(parent, k, owner, dflt, what = 'Sub-component') {
|
|
|
1430
1461
|
},
|
|
1431
1462
|
set(v) {
|
|
1432
1463
|
if (owner)
|
|
1433
|
-
return warn('SYG409', owner, `${what} tried to update calculated field '${
|
|
1464
|
+
return warn('SYG409', owner, `${what} tried to update calculated field '${ks[0]}'; ignored`, 'Bind it to a non-calculated field');
|
|
1434
1465
|
const p = parent.get();
|
|
1435
|
-
if (p
|
|
1436
|
-
parent.set(
|
|
1466
|
+
if (getIn$1(p, ks) !== v)
|
|
1467
|
+
parent.set(setIn$1(p, ks, 0, v));
|
|
1437
1468
|
},
|
|
1438
|
-
has: () => parent.get()
|
|
1469
|
+
has: () => getIn$1(parent.get(), ks) !== undefined,
|
|
1439
1470
|
};
|
|
1440
1471
|
}
|
|
1441
1472
|
/**
|
|
@@ -2781,7 +2812,7 @@ class Inst {
|
|
|
2781
2812
|
fail$2('SYG405', def.name, 'Sub-component initialState replaces the state its parent passes in', 'Remove initialState, or set isolatedState = true');
|
|
2782
2813
|
}
|
|
2783
2814
|
const dflt = def.isolated ? def.initialState : undefined;
|
|
2784
|
-
const cell = typeof st == 'string' ? keyCell(this.cell, st, calcOf?.has(st) && this.def.name, dflt)
|
|
2815
|
+
const cell = typeof st == 'string' ? keyCell(this.cell, st, calcOf?.has(pathOf$1(st)[0]) && this.def.name, dflt)
|
|
2785
2816
|
: st !== undefined ? lensCell(this.cell, st, this.def.name, (e) => app.appError(this, e, 'view'), dflt)
|
|
2786
2817
|
: def.isolated ? localCell(app, this.cell) : this.cell;
|
|
2787
2818
|
const scope = app.scope();
|
|
@@ -2863,8 +2894,9 @@ class Inst {
|
|
|
2863
2894
|
*
|
|
2864
2895
|
* It stands where the `<Collection>` marker was in its owner's template and renders the items
|
|
2865
2896
|
* inside the owner's render: no stream of its own, no pickCombine.
|
|
2866
|
-
* - `of`: a component function (D163: no names). `from`: a state key
|
|
2867
|
-
*
|
|
2897
|
+
* - `of`: a component function (D163: no names). `from`: a state key or path ('board.cards';
|
|
2898
|
+
* SYG409 on a calculated first segment; SYG401 when it isn't an array: the dev entry's), a
|
|
2899
|
+
* `{ get, set }` lens (SYG412 without get(); SYG401 for a non-array) or none (the
|
|
2868
2900
|
* owner's state is the array).
|
|
2869
2901
|
* - Items are keyed by `id`, an id-less item by its raw index (D169); one key -> index map per
|
|
2870
2902
|
* array identity serves every item (cell.ts indexer). A duplicate key renders its first element
|
|
@@ -2946,16 +2978,9 @@ function arrayCell(owner, from) {
|
|
|
2946
2978
|
set: (v) => oc.set(v),
|
|
2947
2979
|
};
|
|
2948
2980
|
}
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
const arrays = Object.keys(cs).filter(k => Array.isArray(cs[k]));
|
|
2953
|
-
warn('SYG401', name, `Collection from="${from}" is not in state${arrays.length ? ` (array fields: '${arrays.join("', '")}')` : ''}; it renders nothing until it exists`, 'Set it to an array in initialState');
|
|
2954
|
-
}
|
|
2955
|
-
else if (isObj$2(cs) && !Array.isArray(cs[from]))
|
|
2956
|
-
warn('SYG401', name, `Collection 'from' field '${from}' is not an array; it renders nothing`, 'Set it to an array in initialState');
|
|
2957
|
-
return keyCell(oc, from, calc && name, undefined, 'Collection');
|
|
2958
|
-
}
|
|
2981
|
+
// a key or a path ('board.cards'); SYG401 for one that isn't an array is the dev entry's (checks/collections.ts)
|
|
2982
|
+
if (typeof from == 'string')
|
|
2983
|
+
return keyCell(oc, from, owner.def.calcNames?.has(pathOf$1(from)[0]) && name, undefined, 'Collection');
|
|
2959
2984
|
if (isObj$2(from) && typeof from.get == 'function') {
|
|
2960
2985
|
let lp = {}, lv;
|
|
2961
2986
|
return {
|
|
@@ -4164,7 +4189,7 @@ class SwitchableHost {
|
|
|
4164
4189
|
this.lr = true;
|
|
4165
4190
|
this.kids = [];
|
|
4166
4191
|
check$3(owner, props);
|
|
4167
|
-
const st = props.state, calc = typeof st == 'string' && owner.def.calcNames?.has(st);
|
|
4192
|
+
const st = props.state, calc = typeof st == 'string' && owner.def.calcNames?.has(pathOf$1(st)[0]);
|
|
4168
4193
|
this.cell = typeof st == 'string' ? keyCell(owner.cell, st, calc && owner.def.name, undefined, 'Switchable sub-component')
|
|
4169
4194
|
: st !== undefined ? lensCell(owner.cell, st, owner.def.name, (e) => owner.app.appError(owner, e, 'view'))
|
|
4170
4195
|
: owner.cell;
|
|
@@ -17460,10 +17485,14 @@ function toggle(field) {
|
|
|
17460
17485
|
return (state) => ({ ...state, [field]: !state[field] });
|
|
17461
17486
|
}
|
|
17462
17487
|
// event(type, payload?) → EVENTS sink fn for the object form:
|
|
17463
|
-
// `ACTION: { EVENTS: event('TYPE', (state, data) => payload) }` (static payloads allowed)
|
|
17488
|
+
// `ACTION: { EVENTS: event('TYPE', (state, data) => payload) }` (static payloads allowed).
|
|
17489
|
+
// A payload function that returns ABORT sends nothing, as ABORT does from any other sink.
|
|
17464
17490
|
function event(type, payload) {
|
|
17465
17491
|
return typeof payload === 'function'
|
|
17466
|
-
? (state, data, next, props) =>
|
|
17492
|
+
? (state, data, next, props) => {
|
|
17493
|
+
const d = payload(state, data, next, props);
|
|
17494
|
+
return isAbort(d) ? ABORT : { type, data: d };
|
|
17495
|
+
}
|
|
17467
17496
|
: () => ({ type, data: payload });
|
|
17468
17497
|
}
|
|
17469
17498
|
// ── emit() ─────────────────────────────────────────────────────────
|
|
@@ -17501,7 +17530,7 @@ function trackWorker(worker, events) {
|
|
|
17501
17530
|
function makeServiceWorkerDriver(scriptUrl, options = {}) {
|
|
17502
17531
|
return function serviceWorkerDriver(sink$) {
|
|
17503
17532
|
const events = new EventTarget();
|
|
17504
|
-
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
|
17533
|
+
if (options.enabled !== false && typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
|
17505
17534
|
navigator.serviceWorker
|
|
17506
17535
|
.register(scriptUrl, { scope: options.scope })
|
|
17507
17536
|
.then((reg) => {
|