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.
Files changed (42) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/astro/index.cjs.js +69 -3
  3. package/dist/astro/index.cjs.js.map +1 -1
  4. package/dist/astro/index.mjs +69 -3
  5. package/dist/astro/index.mjs.map +1 -1
  6. package/dist/astro/server.cjs.js +3 -2
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -2
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/diagnostics.cjs.js +105 -19
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +105 -19
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/mcp-apps.md +59 -4
  15. package/dist/index.cjs.js +54 -25
  16. package/dist/index.cjs.js.map +1 -1
  17. package/dist/index.d.ts +27 -6
  18. package/dist/index.esm.js +54 -25
  19. package/dist/index.esm.js.map +1 -1
  20. package/dist/sygnal.min.js +1 -1
  21. package/dist/sygnal.min.js.map +1 -1
  22. package/dist/vike/config/package.json +1 -1
  23. package/dist/vite/plugin.cjs.js +69 -3
  24. package/dist/vite/plugin.cjs.js.map +1 -1
  25. package/dist/vite/plugin.mjs +69 -3
  26. package/dist/vite/plugin.mjs.map +1 -1
  27. package/llms.txt +4 -4
  28. package/package.json +1 -1
  29. package/src/core/cell.ts +25 -7
  30. package/src/core/hosts/collection.ts +6 -11
  31. package/src/core/hosts/switchable.ts +2 -2
  32. package/src/core/instance.ts +2 -2
  33. package/src/extra/diagnostics/checks/collections.ts +31 -25
  34. package/src/extra/diagnostics/checks/dom.ts +10 -2
  35. package/src/extra/diagnostics/checks/next.ts +39 -1
  36. package/src/extra/diagnostics/checks/persist.ts +4 -2
  37. package/src/extra/diagnostics/checks/wiring.ts +14 -6
  38. package/src/extra/diagnostics/codes.ts +9 -0
  39. package/src/extra/pwa.ts +3 -1
  40. package/src/extra/reducers.ts +10 -4
  41. package/src/index.d.ts +26 -5
  42. package/src/vite/plugin.ts +63 -2
@@ -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 on its next turn
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) => ({ updateModelContext: { city: state.city, selectedDay: state.days.find((d) => d.date === 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, or an object (`structuredContent`) |
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({ updateModelContext: { city: 'Oslo', selectedDay: days[1] } })
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="key". `owner`: the parent's name when `k` is one of its calculated fields (SYG409:
1415
- * writes ignored; `what` names the writer in the message). `dflt`: an isolated child's
1416
- * initialState, read while the slice is undefined. `has()`: the slice itself is defined (D174).
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 == null ? undefined : p[k];
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 '${k}'; ignored`, 'Bind it to a non-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?.[k] !== v)
1436
- parent.set({ ...p, [k]: v });
1466
+ if (getIn$1(p, ks) !== v)
1467
+ parent.set(setIn$1(p, ks, 0, v));
1437
1468
  },
1438
- has: () => parent.get()?.[k] !== undefined,
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 (SYG409 on a calculated
2867
- * one), a `{ get, set }` lens (SYG412 without get(); SYG401 for a non-array) or none (the
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
- if (typeof from == 'string') {
2950
- const cs = oc.get(), calc = owner.def.calcNames?.has(from);
2951
- if (isObj$2(cs) && !(from in cs) && !calc) {
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) => ({ type, data: payload(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) => {