sygnal 6.5.0 → 7.0.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 +156 -0
- package/README.md +2 -0
- package/dist/astro/index.cjs.js +423 -27
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +423 -27
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +141 -3
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js +141 -4
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +169 -14
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +168 -15
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +2 -0
- package/dist/guide/ai-chat.md +6 -6
- package/dist/guide/behaviors.md +1 -1
- package/dist/guide/element-commands.md +4 -2
- package/dist/guide/forms-reference.md +89 -3
- package/dist/guide/forms.md +8 -8
- package/dist/guide/http.md +77 -7
- package/dist/guide/migrating-to-7.md +314 -0
- package/dist/guide/recipes/ai-summarize.md +2 -1
- package/dist/guide/recipes/i18n.md +1 -1
- package/dist/guide/ui/tabs.md +5 -4
- package/dist/guide/ui/tooltip.md +24 -0
- package/dist/index.cjs.js +781 -320
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +319 -43
- package/dist/index.esm.js +781 -320
- 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/ui.cjs.js +96 -11
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +96 -12
- package/dist/ui.esm.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vike/onRenderClient.cjs.js +63 -43
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +63 -43
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +21 -25
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +21 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +410 -27
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +410 -27
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +17 -16
- package/package.json +1 -1
- package/src/astro/index.d.ts +8 -0
- package/src/astro/index.ts +18 -0
- package/src/core/actions.ts +28 -21
- package/src/core/define.ts +2 -0
- package/src/core/hooks.ts +8 -2
- package/src/core/hosts/collection.ts +4 -3
- package/src/core/instance.ts +60 -16
- package/src/core/runtime.ts +5 -1
- package/src/core/view.ts +2 -0
- package/src/cycle/dom/utils.ts +4 -2
- package/src/devtools.d.ts +13 -0
- package/src/devtools.ts +3 -0
- package/src/extra/ai/chat/behavior.ts +3 -3
- package/src/extra/ai/commandBar.ts +3 -3
- package/src/extra/ai/compose/behavior.ts +3 -3
- package/src/extra/ai/transports/shared.ts +8 -2
- package/src/extra/behaviors.ts +21 -0
- package/src/extra/devGraph.ts +111 -0
- package/src/extra/devMcp.ts +1 -1
- package/src/extra/devtools.ts +18 -0
- package/src/extra/diagnostics/checks/fetch.ts +21 -1
- package/src/extra/diagnostics/checks/forms.ts +4 -3
- package/src/extra/diagnostics/checks/index.ts +2 -1
- package/src/extra/diagnostics/checks/inspect.ts +68 -6
- package/src/extra/diagnostics/checks/next.ts +3 -0
- package/src/extra/diagnostics/checks/public.d.ts +133 -12
- package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
- package/src/extra/diagnostics/codes.ts +19 -0
- package/src/extra/fetchDriver.ts +112 -7
- package/src/extra/fetchEventSource.ts +113 -0
- package/src/extra/form.ts +68 -26
- package/src/extra/formHelpers.ts +3 -1
- package/src/extra/lens.ts +3 -2
- package/src/extra/queryCache.ts +4 -3
- package/src/extra/socketDriver.ts +10 -4
- package/src/extra/testing.ts +68 -11
- package/src/index.d.ts +199 -30
- package/src/index.ts +1 -0
- package/src/shared.ts +3 -0
- package/src/ui/dialog.ts +3 -2
- package/src/ui/tooltips.ts +92 -0
- package/src/ui.d.ts +35 -0
- package/src/ui.ts +1 -0
- package/src/vike/onRenderClient.ts +44 -46
- package/src/vike/onRenderHtml.ts +5 -26
- package/src/vike/shell.ts +33 -0
- package/src/vite/graph.ts +255 -0
- package/src/vite/mcp.ts +3 -3
- package/src/vite/plugin.d.ts +19 -0
- package/src/vite/plugin.ts +144 -25
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# Migrating to 7.0
|
|
3
|
+
|
|
4
|
+
Sygnal 7.0 has two breaking changes:
|
|
5
|
+
|
|
6
|
+
1. **Reducer timing.** The output sinks of an action (EFFECT, EVENTS, PARENT, HTTP, ...) get the
|
|
7
|
+
state after the action's STATE update. In 6.x every reducer of an action got the state from
|
|
8
|
+
before it.
|
|
9
|
+
2. **Binding lifetimes.** When a child's `state` binding points at a different record, Sygnal
|
|
10
|
+
replaces the child with a new instance. In 6.x the child kept the record it was mounted with
|
|
11
|
+
until its key changed.
|
|
12
|
+
|
|
13
|
+
Nothing else in the 6.x API changes. The [timing audit](#step-by-step) points at the
|
|
14
|
+
reducers to check. There is no switch for the old timing: an app moves to 7.0 as a whole. If you
|
|
15
|
+
are not ready, [stay on 6.x](#not-ready-yet).
|
|
16
|
+
|
|
17
|
+
## Reducer timing
|
|
18
|
+
|
|
19
|
+
### What changed
|
|
20
|
+
|
|
21
|
+
In every model entry, STATE runs first, whatever the order of the keys. Every other sink
|
|
22
|
+
(EFFECT, EVENTS, PARENT, ELEMENT, HTTP and any other driver) then gets the committed state,
|
|
23
|
+
calculated fields included, as its first argument and as `props.state`. The fourth argument's
|
|
24
|
+
`previousState` is the state from before the action.
|
|
25
|
+
|
|
26
|
+
| Reducer | `state` in 6.x | `state` in 7.0 | `props.previousState` (7.0) |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| STATE | before the action | before the action | before the action |
|
|
29
|
+
| every other sink | before the action | after STATE | before the action |
|
|
30
|
+
|
|
31
|
+
When STATE returns `ABORT`, returns the same object or throws, the other sinks still run, with
|
|
32
|
+
`state === previousState`. `ABORT` means "no state change", not "cancel the action". Effects
|
|
33
|
+
still run synchronously inside the action, and the DOM patch follows it.
|
|
34
|
+
|
|
35
|
+
The tradeoff: payloads get simpler, because an output reads the value STATE just stored instead
|
|
36
|
+
of computing it a second time. A guard that decides whether the action may start reads
|
|
37
|
+
`previousState` instead of `state`.
|
|
38
|
+
|
|
39
|
+
### A value computed twice
|
|
40
|
+
|
|
41
|
+
An output that computed the new value itself now applies the update twice. A counter that
|
|
42
|
+
tells its parent about the new count:
|
|
43
|
+
|
|
44
|
+
```jsx
|
|
45
|
+
// 6.x: PARENT got the old count and added 1 itself
|
|
46
|
+
Counter.model = {
|
|
47
|
+
INC: {
|
|
48
|
+
STATE: (state) => ({ ...state, count: state.count + 1 }),
|
|
49
|
+
PARENT: (state) => ({ count: state.count + 1 }),
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
On 7.0 that sends 2 after the first click. Read the stored value instead:
|
|
55
|
+
|
|
56
|
+
```jsx
|
|
57
|
+
// 7.0: STATE has already incremented count
|
|
58
|
+
Counter.model = {
|
|
59
|
+
INC: {
|
|
60
|
+
STATE: (state) => ({ ...state, count: state.count + 1 }),
|
|
61
|
+
PARENT: (state) => ({ count: state.count }),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Look for the same pattern in requests that append a message, increment a request id or build a
|
|
67
|
+
record alongside STATE.
|
|
68
|
+
|
|
69
|
+
### A value STATE clears
|
|
70
|
+
|
|
71
|
+
The opposite case: STATE clears a field and the output needs what was there. In 6.x the output
|
|
72
|
+
read `state.draft`; on 7.0 that is the empty string. Read it from `previousState`:
|
|
73
|
+
|
|
74
|
+
```jsx
|
|
75
|
+
Editor.model = {
|
|
76
|
+
SAVE: {
|
|
77
|
+
STATE: (state) => ({ ...state, draft: '' }),
|
|
78
|
+
HTTP: (state, data, next, { previousState }) => ({
|
|
79
|
+
url: '/api/drafts', method: 'POST', json: { text: previousState.draft }, ok: 'SAVED',
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Deleting
|
|
86
|
+
|
|
87
|
+
A component that removes its own state (STATE returns `undefined`: a Collection item removing
|
|
88
|
+
itself, or a child bound with `state="draft"` or `byId()` deleting its record) has no new state
|
|
89
|
+
to give its outputs. They get its last state, as both `state` and `previousState`, so the 6.x
|
|
90
|
+
form keeps working unchanged:
|
|
91
|
+
|
|
92
|
+
```jsx
|
|
93
|
+
import { event } from 'sygnal'
|
|
94
|
+
|
|
95
|
+
TodoItem.model = {
|
|
96
|
+
REMOVE: {
|
|
97
|
+
STATE: () => undefined,
|
|
98
|
+
EVENTS: event('TODO_REMOVED', (state) => state.id),
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A parent that deletes a record from its own list does get its new state, without the record.
|
|
104
|
+
An output that needs the deleted record finds it in `previousState`:
|
|
105
|
+
|
|
106
|
+
```jsx
|
|
107
|
+
import { event } from 'sygnal'
|
|
108
|
+
|
|
109
|
+
TodoList.model = {
|
|
110
|
+
DELETE: {
|
|
111
|
+
STATE: (state, id) => ({ ...state, todos: state.todos.filter(t => t.id !== id) }),
|
|
112
|
+
EVENTS: event('TODO_DELETED', (state, id, next, { previousState }) =>
|
|
113
|
+
previousState.todos.find(t => t.id === id)),
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Loading and admission guards
|
|
119
|
+
|
|
120
|
+
A guard that decides whether an action may start must read the state from before the action.
|
|
121
|
+
Repeating `state.status !== 'working'` in both STATE and HTTP now blocks the first request:
|
|
122
|
+
STATE has already set `status` to `'working'` when HTTP runs. Keep the output guard, and test
|
|
123
|
+
`previousState`:
|
|
124
|
+
|
|
125
|
+
```jsx
|
|
126
|
+
import { ABORT } from 'sygnal'
|
|
127
|
+
|
|
128
|
+
Worker.model = {
|
|
129
|
+
START: {
|
|
130
|
+
STATE: (state) => (state.status !== 'working' ? { ...state, status: 'working' } : ABORT),
|
|
131
|
+
HTTP: (state, data, next, { previousState }) =>
|
|
132
|
+
previousState.status !== 'working' ? { url: '/api/work', ok: 'DONE' } : ABORT,
|
|
133
|
+
},
|
|
134
|
+
DONE: (state) => ({ ...state, status: 'idle' }),
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The first START sends a request; START actions while it works send none. Don't remove the HTTP
|
|
139
|
+
guard: STATE returning `ABORT` does not cancel the other sinks, so without it every START would
|
|
140
|
+
send a request. The same applies to loading flags, toggles and any output that should run only
|
|
141
|
+
when STATE admitted the action. `sygnal-check` reports [SYG256](https://sygnal.js.org/reference/errors#syg256) when
|
|
142
|
+
it can prove that an output guard contradicts the same action's literal STATE write, as `HTTP`
|
|
143
|
+
testing `state.status` would here. It can't prove every case, so check these guards during the
|
|
144
|
+
migration even without a warning.
|
|
145
|
+
|
|
146
|
+
### Behaviors
|
|
147
|
+
|
|
148
|
+
A behavior's model handlers follow the same contract: the first argument is the slice after
|
|
149
|
+
STATE, `props.state` the host's state after STATE, and `props.previousState` the host's state
|
|
150
|
+
from before the action. The slice from before the action is `props.previousState[key]`, with
|
|
151
|
+
`key` the sixth argument:
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import { defineBehavior } from 'sygnal'
|
|
155
|
+
|
|
156
|
+
const drafts = defineBehavior({
|
|
157
|
+
initialState: { draft: '' },
|
|
158
|
+
model: {
|
|
159
|
+
SAVE: {
|
|
160
|
+
STATE: (slice) => ({ ...slice, draft: '' }),
|
|
161
|
+
HTTP: (slice, data, next, props, options, key) => ({
|
|
162
|
+
url: '/api/drafts',
|
|
163
|
+
method: 'POST',
|
|
164
|
+
json: { text: props.previousState[key].draft },
|
|
165
|
+
ok: key + '.SAVED',
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Every built-in behavior (form, chat, compose, commandBar, undo, pager, selection, sortable and
|
|
173
|
+
the `sygnal/ui` parts) is on this contract. A host entry next to a behavior's action, such as
|
|
174
|
+
`'chat.DONE'`, follows the same rules.
|
|
175
|
+
|
|
176
|
+
### Libraries for both 6 and 7
|
|
177
|
+
|
|
178
|
+
A component or behavior published for both versions reads old values as
|
|
179
|
+
`props.previousState ?? state`: 6.x has no `previousState`, and there `state` is the old value.
|
|
180
|
+
Compute new values from the old ones, as 6.x code did; that gives the same result on both:
|
|
181
|
+
|
|
182
|
+
```jsx
|
|
183
|
+
Counter.model = {
|
|
184
|
+
INC: {
|
|
185
|
+
STATE: (state) => ({ ...state, count: state.count + 1 }),
|
|
186
|
+
PARENT: (state, data, next, props) => {
|
|
187
|
+
const before = props.previousState ?? state
|
|
188
|
+
return { count: before.count + 1 }
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Step by step
|
|
195
|
+
|
|
196
|
+
1. **Upgrade** `sygnal` and `sygnal-check` together: `npm install sygnal@7 sygnal-check@latest`.
|
|
197
|
+
|
|
198
|
+
2. **Turn on the timing audit.** It reports [SYG257](https://sygnal.js.org/reference/errors#syg257) when an output
|
|
199
|
+
sink reads a top-level state field that the same action's STATE changed: the reads whose
|
|
200
|
+
value changed with 7.0. Turn it on in tests with the `renderComponent()` option, and in the
|
|
201
|
+
dev server with the Vite plugin's:
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
const t = renderComponent(App, { timingAudit: true })
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
// vite.config.js
|
|
209
|
+
import { defineConfig } from 'vite'
|
|
210
|
+
import sygnal from 'sygnal/vite'
|
|
211
|
+
|
|
212
|
+
export default defineConfig({
|
|
213
|
+
plugins: [sygnal({ diagnostics: { timingAudit: true } })],
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
To turn it on for a whole test file or setup file, call `configureTimingAudit(true)` from
|
|
218
|
+
`'sygnal/diagnostics'`; `globalThis.__SYGNAL_TIMING_AUDIT__ = true` does the same before the
|
|
219
|
+
app starts. The audit is part of the dev diagnostics and adds nothing to production builds.
|
|
220
|
+
|
|
221
|
+
3. **Run the test suite and use the app in dev.** Each report names the component, action, sink
|
|
222
|
+
and field, once each:
|
|
223
|
+
|
|
224
|
+
```text
|
|
225
|
+
[Sygnal SYG257] Counter's 'INC' PARENT read 'count', which the same action's STATE changed:
|
|
226
|
+
since 7.0 it gets the new value (6.x: the old one)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
4. **Decide each report.** If the output wants the new value (a payload built from what STATE
|
|
230
|
+
stored), keep reading `state`, and remove any update the output repeats, as in the counter
|
|
231
|
+
above. If it wants the old value (a cleared field, a deleted record, a guard that admits the
|
|
232
|
+
action), read `props.previousState.<field>`. A nested read reports its top-level field: a read
|
|
233
|
+
of `state.form.values` reports `form`.
|
|
234
|
+
|
|
235
|
+
5. **Run `sygnal-check`** and fix any [SYG256](https://sygnal.js.org/reference/errors#syg256).
|
|
236
|
+
|
|
237
|
+
6. **Turn the audit off** when the reports are resolved. It is opt-in because in code written
|
|
238
|
+
for 7.0, reading the new value is the intent, and the reports would be noise.
|
|
239
|
+
|
|
240
|
+
Components and behaviors from other packages count too: check that each supports Sygnal 7, or
|
|
241
|
+
run the audit over the screens that use them.
|
|
242
|
+
|
|
243
|
+
## Binding lifetimes
|
|
244
|
+
|
|
245
|
+
### What changed
|
|
246
|
+
|
|
247
|
+
In 6.x a child kept the record it was mounted with until its key changed, so a selection needed
|
|
248
|
+
`key={selected.id}`. In 7.0, when the record a child's `state` binding points at changes, Sygnal
|
|
249
|
+
disposes the old child and mounts a new one. This applies to:
|
|
250
|
+
|
|
251
|
+
- a child's `state="path"` when the path changes, and `state={byId(field, id, idKey)}` when the
|
|
252
|
+
field, id or idKey changes;
|
|
253
|
+
- a Collection's or VirtualCollection's `from`;
|
|
254
|
+
- a Switchable's `state`.
|
|
255
|
+
|
|
256
|
+
```jsx
|
|
257
|
+
// 6.x needed key={selected.id}; 7.0 follows the selection by itself
|
|
258
|
+
<Workspace state={byId('projects', selected.id)} />
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The old child gets its `DISPOSE` action with its last state. Its pending requests, delayed
|
|
262
|
+
`next()` actions and subscriptions are cancelled, and every component inside it is disposed. The
|
|
263
|
+
new child starts from scratch: `INITIALIZE` and `BOOTSTRAP` run again and its local state
|
|
264
|
+
starts from its defaults. Collection items and Switchable pages of the old target are disposed
|
|
265
|
+
too, even when the new target has items with the same ids.
|
|
266
|
+
|
|
267
|
+
**The child's DOM is replaced.** Focus, caret position, scroll position and any text being typed
|
|
268
|
+
inside it are lost. Keep an input the user may be typing in while the selection changes outside
|
|
269
|
+
the replaced child, or keep its value in the parent's state.
|
|
270
|
+
|
|
271
|
+
### Deleting the bound record
|
|
272
|
+
|
|
273
|
+
When the record is deleted (the `byId()` arguments stay the same but the record is gone), the
|
|
274
|
+
old child is disposed as well, after the deleting action finishes, so that action's outputs
|
|
275
|
+
still run. Its `DISPOSE` gets its last state; a `STATE` change from `DISPOSE` can't bring the
|
|
276
|
+
deleted record back. A record added again later with the same id gets a new child, not the old
|
|
277
|
+
child's pending work. Children inside a hidden Switchable page are disposed the same way; their
|
|
278
|
+
replacements mount when the page is shown again.
|
|
279
|
+
|
|
280
|
+
### What doesn't replace a child
|
|
281
|
+
|
|
282
|
+
- A new `byId()` call with the same arguments on every render.
|
|
283
|
+
- Editing the record's fields, or reordering the array it's in.
|
|
284
|
+
- Switching a Switchable's active page with the same `state` target.
|
|
285
|
+
|
|
286
|
+
### Keys
|
|
287
|
+
|
|
288
|
+
You can drop a `key` that only worked around `byId()` selection. A key still resets a child whose
|
|
289
|
+
target hasn't changed: change the key and the child starts over.
|
|
290
|
+
|
|
291
|
+
**A stable key no longer keeps a child across a target change.** In 6.x
|
|
292
|
+
`<Editor key="editor" state={byId('docs', id)} />` kept one editor while `id` changed; in 7.0 the
|
|
293
|
+
editor is replaced. To keep something across the change (an open panel, a scroll position, a
|
|
294
|
+
half-written comment), lift it into the parent's state.
|
|
295
|
+
|
|
296
|
+
### Custom lenses
|
|
297
|
+
|
|
298
|
+
Sygnal can't tell whether a new `{ get, set }` lens object points at a different record, so a
|
|
299
|
+
child with a custom lens keeps the binding it was mounted with. Give it a key tied to the
|
|
300
|
+
selected record, and render it only while that record exists (or include availability in the
|
|
301
|
+
key):
|
|
302
|
+
|
|
303
|
+
```jsx
|
|
304
|
+
{doc && <Editor key={doc.id} state={docLens(doc.id)} />}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
See [Changing the selected record](https://sygnal.js.org/guide/state/#changing-the-selected-record).
|
|
308
|
+
|
|
309
|
+
## Not ready yet?
|
|
310
|
+
|
|
311
|
+
Stay on 6.x (`npm install sygnal@^6`) until the test suite passes on 7.0 with the audit's reports
|
|
312
|
+
resolved. There is no per-component switch for the old timing or the old lifetimes. The current
|
|
313
|
+
`sygnal-check` supports both: on 6.x it still reports
|
|
314
|
+
[SYG454](https://sygnal.js.org/reference/errors/#syg454) for a `byId()` selection without a key.
|
|
@@ -112,7 +112,7 @@ Article.model = {
|
|
|
112
112
|
},
|
|
113
113
|
SUMMARIZE: {
|
|
114
114
|
STATE: (state) => (state.status === 'busy' ? ABORT : { ...state, summary: '', status: 'busy' }),
|
|
115
|
-
LLM: (state) => (
|
|
115
|
+
LLM: (state, data, next, { previousState }) => (previousState.status === 'busy' ? ABORT : summarize(state.text)),
|
|
116
116
|
},
|
|
117
117
|
SUMMARY: (state, { text }) => ({ ...state, summary: text }),
|
|
118
118
|
SUMMARIZED: (state, { text }) => ({ ...state, summary: text, status: 'ready' }),
|
|
@@ -121,6 +121,7 @@ Article.model = {
|
|
|
121
121
|
}
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
+
- **Guard against the previous state.** `STATE` marks the article busy before the `LLM` reducer runs. Read `previousState.status` to decide whether a summary was already running; use the updated `state` to build the request.
|
|
124
125
|
- **The summary streams in** through `SUMMARY`, and `SUMMARIZED` ends the busy state.
|
|
125
126
|
- **Degrade, don't hide.** The excerpt is computed in the view from the text, so it needs no model. Where a summary isn't essential, an excerpt is an honest fallback.
|
|
126
127
|
- **Keep prompts short and in `instructions`.** The on-device model is small and has a small context window. Long articles need splitting, or a hosted model.
|
|
@@ -217,6 +217,6 @@ Measured with Vite, minified and gzipped, Sygnal not included: **15.5 KB** for i
|
|
|
217
217
|
|
|
218
218
|
- **Don't call `i18n.changeLanguage()`.** It changes a global inside i18next that Sygnal doesn't see, so nothing re-renders. Keep the locale in state and use `getFixedT`.
|
|
219
219
|
- **Keep `t` stable per locale.** `getFixedT` returns a new function on each call. Called directly in `.context`, it gives the context a new value whenever the root's state changes, not only when the locale does; `translator` hands out the same function until the locale changes.
|
|
220
|
-
- **Load translations before switching.** With translations in separate files, fetch the new language first (a `makeFetchDriver` request on the `LOCALE` action), add it with `i18n.addResourceBundle(locale, 'translation', data)` in an `EFFECT` of the reply's action (a reducer only returns state), and
|
|
220
|
+
- **Load translations before switching.** With translations in separate files, fetch the new language first (a `makeFetchDriver` request on the `LOCALE` action), add it with `i18n.addResourceBundle(locale, 'translation', data)` in an `EFFECT` of the reply's action (a reducer only returns state), and put the new locale in the reply action's `STATE` entry. In Sygnal 7, `STATE` commits first, then `EFFECT` adds the loaded strings synchronously before the action finishes and the view renders. A locale whose strings aren't loaded shows the keys.
|
|
221
221
|
- **Server rendering.** Take the locale from the request (the URL or `Accept-Language`) and pass it in the server's state. A locale saved only in `localStorage` reaches a server-rendered page after hydration, as a `RESTORE` action: the page shows the server's language first.
|
|
222
222
|
- **Missing keys show the key.** `t('cart.titel')` renders "cart.titel". Set `saveMissing` with a `missingKeyHandler` in development to log them.
|
package/dist/guide/ui/tabs.md
CHANGED
|
@@ -120,15 +120,16 @@ Editor.model = {
|
|
|
120
120
|
files: state.files.filter((f) => f.id !== id),
|
|
121
121
|
tabs: { ...state.tabs, selected: state.tabs.selected === id ? (neighbour(state.files, id)?.id ?? null) : state.tabs.selected },
|
|
122
122
|
}),
|
|
123
|
-
ELEMENT: (state, id) => {
|
|
124
|
-
const
|
|
125
|
-
return
|
|
123
|
+
ELEMENT: (state, id, next, { previousState }) => {
|
|
124
|
+
const nextFile = neighbour(previousState.files, id)
|
|
125
|
+
return nextFile ? { focus: `.tab[data-value="${nextFile.id}"]` } : ABORT
|
|
126
126
|
},
|
|
127
127
|
},
|
|
128
128
|
}
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
`ELEMENT` gets the state after `STATE` removed the file, so it finds the closed file's neighbor
|
|
132
|
+
in `previousState.files`. Closing the last tab returns `ABORT`: there is no neighbor to focus.
|
|
132
133
|
|
|
133
134
|
## Styling
|
|
134
135
|
|
package/dist/guide/ui/tooltip.md
CHANGED
|
@@ -49,6 +49,30 @@ The delays need the timer driver in `run()`. Tests don't: [fake timers](../timer
|
|
|
49
49
|
|
|
50
50
|
`state.saveTip` is `{ open, pending, hover, focus }`: `pending` is `'show'` or `'hide'` while a delay runs; `hover` and `focus` say where the pointer and the focus are. The actions are `saveTip.ENTER` and `saveTip.LEAVE` (data: `'hover'` or `'focus'`), `saveTip.SHOW`, `saveTip.HIDE` (the timers), `saveTip.ESCAPE` and `saveTip.TOGGLED`. The timers are declared under the names `saveTip.show` and `saveTip.hide`, next to the component's own [`timers`](../timers.md).
|
|
51
51
|
|
|
52
|
+
## Many tooltips: `tooltips()`
|
|
53
|
+
|
|
54
|
+
`tooltip()` keeps a state slice and two timers for each trigger, which is a lot for a paragraph of glossary terms or a toolbar of icon buttons. `tooltips()` serves every trigger of the component with one slice and one pair of timers. Each trigger names its tip with `aria-describedby`, and one tip shows at a time:
|
|
55
|
+
|
|
56
|
+
```jsx live
|
|
57
|
+
import { tooltips } from 'sygnal/ui'
|
|
58
|
+
|
|
59
|
+
const TERMS = { MVI: 'Model-View-Intent: the pattern every component follows', driver: 'The code that talks to the outside world', sink: 'What a component sends to a driver' }
|
|
60
|
+
|
|
61
|
+
function Glossary({ uid }) {
|
|
62
|
+
const term = (name, text) => (
|
|
63
|
+
<>
|
|
64
|
+
<button className="term" aria-describedby={uid('tip-' + name)} style={{ anchorName: '--' + uid(name) }}>{text}</button>
|
|
65
|
+
<span className="tip" id={uid('tip-' + name)} role="tooltip" popover="manual" style={{ positionAnchor: '--' + uid(name) }}>{TERMS[name]}</span>
|
|
66
|
+
</>
|
|
67
|
+
)
|
|
68
|
+
return <p>Every component is {term('MVI', 'MVI')}: its intent reads events, a {term('driver', 'driver')} performs the effects, and each {term('sink', 'sink')} carries a request.</p>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Glossary.uses = { tips: tooltips({ trigger: '.term', tip: '.tip' }) }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The options are those of `tooltip()`, with `trigger` and `tip` matching every trigger and every tip. Moving from one term to another while a tip is shown switches at once, without a second delay. `state.tips` is `{ id, open, pending, hover, focus }`: `id` is the tip of the trigger last entered and `open` the id of the tip shown. The actions carry the tip's id: `tips.ENTER` and `tips.LEAVE` (`{ via: 'hover' | 'focus', id }`), `tips.TOGGLED` (`{ id, open }`), plus `tips.SHOW`, `tips.HIDE` and `tips.ESCAPE`.
|
|
75
|
+
|
|
52
76
|
## Positioning
|
|
53
77
|
|
|
54
78
|
The trigger gets an anchor name and the tip refers to it. The anchor name must be unique on the page, so build it from `uid()` in the view, as above. The rest is CSS:
|