foldkit-primitives 0.3.0 → 0.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 (51) hide show
  1. package/README.md +144 -723
  2. package/dist/device/index.d.mts.map +1 -1
  3. package/dist/device/index.mjs +95 -87
  4. package/dist/device/index.mjs.map +1 -1
  5. package/dist/dom/index.d.mts +71 -3
  6. package/dist/dom/index.d.mts.map +1 -1
  7. package/dist/dom/index.mjs +303 -6
  8. package/dist/dom/index.mjs.map +1 -1
  9. package/dist/editable-text-CKkS_mWt.d.mts +235 -0
  10. package/dist/editable-text-CKkS_mWt.d.mts.map +1 -0
  11. package/dist/events/index.d.mts +8 -1
  12. package/dist/events/index.d.mts.map +1 -1
  13. package/dist/events/index.mjs +32 -17
  14. package/dist/events/index.mjs.map +1 -1
  15. package/dist/follow-tab-stop-BSZsUrrk.mjs +805 -0
  16. package/dist/follow-tab-stop-BSZsUrrk.mjs.map +1 -0
  17. package/dist/index.d.mts +6 -6
  18. package/dist/index.mjs +7 -7
  19. package/dist/interaction/index.d.mts +247 -92
  20. package/dist/interaction/index.d.mts.map +1 -1
  21. package/dist/interaction/index.mjs +507 -224
  22. package/dist/interaction/index.mjs.map +1 -1
  23. package/dist/internal-DP-xLKbT.mjs +37 -0
  24. package/dist/internal-DP-xLKbT.mjs.map +1 -0
  25. package/dist/media/index.d.mts.map +1 -1
  26. package/dist/media/index.mjs +3 -3
  27. package/dist/media/index.mjs.map +1 -1
  28. package/dist/motion/index.mjs +1 -1
  29. package/dist/{motion-LfJYAMnP.mjs → motion-DGGBZ0wt.mjs} +14 -13
  30. package/dist/motion-DGGBZ0wt.mjs.map +1 -0
  31. package/dist/net/index.d.mts +69 -5
  32. package/dist/net/index.d.mts.map +1 -1
  33. package/dist/net/index.mjs +139 -56
  34. package/dist/net/index.mjs.map +1 -1
  35. package/dist/observers/index.d.mts +1 -1
  36. package/dist/observers/index.mjs +3 -0
  37. package/dist/observers/index.mjs.map +1 -1
  38. package/dist/state/index.d.mts +54 -12
  39. package/dist/state/index.d.mts.map +1 -1
  40. package/dist/state/index.mjs +198 -90
  41. package/dist/state/index.mjs.map +1 -1
  42. package/dist/time/index.d.mts +21 -1
  43. package/dist/time/index.d.mts.map +1 -1
  44. package/dist/time/index.mjs +48 -19
  45. package/dist/time/index.mjs.map +1 -1
  46. package/package.json +20 -9
  47. package/dist/motion-LfJYAMnP.mjs.map +0 -1
  48. package/dist/move-CZwHyTGk.mjs +0 -215
  49. package/dist/move-CZwHyTGk.mjs.map +0 -1
  50. package/dist/move-DNiMPcuf.d.mts +0 -58
  51. package/dist/move-DNiMPcuf.d.mts.map +0 -1
package/README.md CHANGED
@@ -1,74 +1,40 @@
1
1
  # `foldkit-primitives`
2
2
 
3
- Ready-made [`foldkit-bundle`](../bundle) primitives: media queries, breakpoints,
4
- presence, timers, intervals, debounce, tweens, springs, pagination, history,
5
- locales, selections, ranges, geolocation, cameras, permissions, sockets,
6
- broadcasts, observers, and clipboard. Most are ordinary bundles — Model,
7
- Message, init, update, and Subscriptions collected in one value — published
8
- under a tree-shakeable subpath, so an application pays only for the primitives
9
- it places. The exceptions keep their own form: entries are Subscription
10
- streams mapped to the parent's Message (keyboard, pointer, scroll); Mounts
11
- are element-scoped observation attached with `h.OnMount` (`Resize`,
12
- `Autofocus`); Commands are one-shot (`copyText`, `share`); pure helpers are
13
- functions (`range`, `formatRelativeTime`).
3
+ Ready-made pieces of browser, clock, device, and interaction state for a
4
+ Foldkit application: a media query, a timer, a socket, an undo stack, a roving
5
+ tab stop, a resize observer, a clipboard write. Each comes in the shape Foldkit
6
+ already has, so adding one adds no store, no hook, and no second reducer. The
7
+ fact lives in your Model, arrives as a Message, and replays like everything
8
+ else.
14
9
 
15
10
  ```ts
16
11
  import { MediaQuery } from 'foldkit-primitives/media'
17
12
  ```
18
13
 
19
- ## Which state belongs here?
14
+ > **The browser reports; the Model owns.** A primitive observes something and
15
+ > hands your `update` a Message. What your application keeps, and what it does
16
+ > about it, stays yours.
20
17
 
21
- A primitive belongs here when it is **reused across applications** and
22
- **stateful**: something observes it, something reacts to it, and replay sees
23
- the same transitions. That is exactly the bundle shape, which is why this
24
- package depends on `foldkit-bundle` and nothing else (besides peers). The one
25
- exception is `interaction`, whose Bundles ship with a `foldkit-mixins` Behavior
26
- that wires them to a view's slots; `foldkit-mixins` is an optional peer that
27
- only that subpath needs.
18
+ ## Which form a primitive takes
28
19
 
29
- | Kind | Form | Example |
30
- | --- | --- | --- |
31
- | Stateful + effectful | bundle | MediaQuery, Timer, WebSocket, Pagination |
32
- | Interaction state a view's slots must reflect | bundle + Behavior | RovingTabindex, Typeahead, ListNavigation, GridNavigation, Press |
33
- | Keyed collections of stateful items | bundle per key | uploads, sockets, timers (later) |
34
- | Stream source with a stored fact | bundle with one boolean/scalar slice | Online, Visibility, WindowSize |
35
- | Stream source only | Subscription entry, not a bundle | keyboard, pointer, scroll, broadcast |
36
- | Element-scoped observation | Mount, not a bundle | Resize, Intersection, Mutation, Autofocus |
37
- | One-shot actions | Command, not a bundle | clipboard copy, share, script load |
38
- | Derived data | pure function, not a bundle | range, relative time, platform |
39
-
40
- Choose by what you need to own. State that outlives the moment — a match,
41
- a count, a page, a position — wants a bundle: the Model keeps it, replay
42
- sees it. A stream you only react to wants an entry: map it into the
43
- parent's Message and keep nothing. Work bound to one element wants a
44
- Mount. Work that runs once and reports back wants a Command. A value
45
- computed from data you already have wants a pure function. When in doubt,
46
- start with the lighter form; promote to a bundle the day the state needs
47
- a name in the Model.
48
-
49
- A bundle holds no state and performs no I/O by itself. The parent Model owns
50
- the placed slice; the browser (or server, or clock) only reports facts as
51
- Messages. The same rule as everywhere else: observation is not ownership.
52
-
53
- Solid developers will notice missing plumbing: there is no event bus
54
- because Messages are the bus, no memo because derivations are pure reads
55
- over the Model, and no reactive map or store because the Model holds plain
56
- data (Effect collections where mutation matters). None of it is missing by
57
- accident — one state machine leaves nowhere for a second one to live.
58
-
59
- ## The mental model
60
-
61
- ```text
62
- browser / clock / server ──facts as Messages──▶ update ──▶ Model slice
63
- ▲
64
- The parent Model owns the recorded fact; subscriptions report environment changes.
65
- ```
20
+ Every primitive is one of five things, chosen by what it has to own:
21
+
22
+ | It needs to own | Form | Joins the application by | Examples |
23
+ | --- | --- | --- | --- |
24
+ | A fact that outlives the moment: a match, a count, a page, a position | **bundle** | placing it in the parent, as a field and a wrapper Message | `MediaQuery`, `Timer`, `Pagination`, `websocket` |
25
+ | Interaction state a view's slots must reflect | **bundle + Behavior** | placing it, then attaching its Behavior to the view's slots | `RovingTabindex`, `Press`, `DismissLayer` |
26
+ | Nothing: a stream you react to | **entry** | lifting it into your Subscriptions, mapped to your Message | `keyboardEvents`, `ticks`, `broadcastMessages` |
27
+ | Nothing: observation bound to one element | **Mount** | attaching it in the view with `h.OnMount` | `Resize`, `Autofocus`, `KeepInView` |
28
+ | Nothing: work done once that reports back | **Command** | returning it from `update` | `copyText`, `share`, `enterFullscreen` |
29
+ | Nothing: a value computed from what you have | **function** | calling it | `range`, `matchHotkey`, `formatRelativeTime` |
66
30
 
67
- Initialization creates the Model slice; subscriptions observe ongoing facts and
68
- report Messages; `update` stores them. Defaults differ by primitive:
69
- MediaQuery starts at `false`, while Online, Visibility, and Locale read an
70
- available platform value during initialization. Missing browser APIs have
71
- primitive-specific fallbacks; do not infer readiness from an SSR default.
31
+ The lighter form wins when in doubt. Promote to a bundle the day the state
32
+ needs a name in the Model.
33
+
34
+ The one dependency is [`foldkit-bundle`](../bundle), whose placement
35
+ mechanism every bundle here uses. `interaction` additionally needs
36
+ [`foldkit-mixins`](../mixins), an optional peer, because its Behaviors write to
37
+ a view's slots.
72
38
 
73
39
  ## Install
74
40
 
@@ -76,32 +42,14 @@ primitive-specific fallbacks; do not infer readiness from an SSR default.
76
42
  pnpm add foldkit-primitives foldkit-bundle effect foldkit
77
43
  ```
78
44
 
79
- `effect` and `foldkit` are peer dependencies. Import per subpath —
80
- `foldkit-primitives/media`, `foldkit-primitives/net`, `foldkit-primitives/time`,
81
- `foldkit-primitives/state`, `foldkit-primitives/motion`, `foldkit-primitives/device`,
82
- `foldkit-primitives/events`, `foldkit-primitives/observers`, `foldkit-primitives/dom` —
83
- so bundlers drop the primitives you never import.
84
-
85
- ## Map of the package
86
-
87
- Each subpath is one concern, one import:
88
-
89
- - `media` — environment facts: MediaQuery (+presets), Breakpoints, platform
90
- - `net` — remote facts: Online, WebSocket, SSE, BroadcastChannel
91
- - `time` — clock facts: Timer, Interval, Debounce, Throttle, relative time
92
- - `state` — owned UI state: Pagination, History, Locale, SelectionSet, Virtual, range
93
- - `motion` — animation state: Tween, Spring, Presence
94
- - `interaction` — a Bundle (or Mount) and its `foldkit-mixins` Behavior: RovingTabindex, Typeahead, ListNavigation, GridNavigation, FocusScope, Press, LongPress, Move, FocusVisible, DismissLayer, ScrollLock, HideOutside, Selection, LiveAnnounce
95
- - `device` — hardware: Geolocation, MediaDevices, MediaStream, Permissions, Fullscreen
96
- - `events` — raw browser events: Visibility, WindowSize, Idle, InputModality, keyboard, pointer, scroll, focus
97
- - `observers` — element Mounts: Resize, Intersection, Mutation, Bounds
98
- - `dom` — element Mounts and one-shot Commands: Autofocus, FocusScope, Move, ScrollLock, HideOutside, InputMask, clipboard, share, script loading
45
+ `effect` and `foldkit` are peer dependencies. Import by subpath (`/media`,
46
+ `/net`, `/time`, `/state`, `/motion`, `/interaction`, `/device`, `/events`,
47
+ `/observers`, `/dom`) so a bundler drops what you never import.
99
48
 
100
49
  ## Sixty seconds: follow the color scheme
101
50
 
102
- **Place it in a parent.** `Bundle.compose` states the parent's own field and
103
- Message, and places the primitive under `dark`, with the wrapper Message
104
- `GotDarkMessage` by Foldkit's `Got<Field>Message` convention:
51
+ A bundle is placed in a parent, which gains a field for its state and a
52
+ wrapper Message for its transitions:
105
53
 
106
54
  ```ts
107
55
  import { Schema } from 'effect'
@@ -115,11 +63,7 @@ const Page = Bundle.compose({ theme: Schema.String }).pipe(
115
63
  )
116
64
  type Model = typeof Page.Model.Type
117
65
  type Message = typeof Page.Message.Type
118
- ```
119
-
120
- **Run it.** The parent's assembly derives the update and the Subscriptions:
121
66
 
122
- ```ts
123
67
  const { placements } = Page
124
68
 
125
69
  const config = placements.complete({
@@ -131,665 +75,142 @@ const config = placements.complete({
131
75
  })
132
76
  ```
133
77
 
134
- `update` routes `GotDarkMessage` to the bundle; `subscriptions` runs the
135
- `matchMedia` stream; `initial` starts `matches` at `false` and the stream
136
- corrects it on subscribe. None of these calls perform I/O: `compose` states
137
- the parent, `withChild` places the primitive with its config, and the
138
- assembly derives the folding and the streams. The browser is touched only
139
- when the runtime subscribes. The Solid equivalent this replaces:
140
-
141
- ```ts
142
- // solid-primitives: const dark = createMediaQuery('(prefers-color-scheme: dark)')
143
- // Here the fact lives in the Model: replay, DevTools, and time travel see it.
144
- ```
145
-
146
- ## Composing placements
78
+ What each line does, and does not do:
147
79
 
148
- One assembly holds every placement: spread its update, init,
149
- subscriptions, and resources once per application, as above. Two
150
- placements of one bundle observe twice — share the field instead, so one
151
- stream feeds every reader. One assembly holds one socket, stream, or
152
- watch: resource tags are per module, and `assemble` refuses the second.
153
- Bundles whose settled value is the whole point (`Debounce`, `Throttle`)
154
- surface it as an OutMessage the placement handles with `onOut`, so the
155
- signal can never be dropped by omission. Entries lift with
156
- `Subscription.persistent`, mapping into the parent's Message; Mounts
157
- attach in views with `h.OnMount`.
80
+ - **`Bundle.compose(...).pipe(withChild('dark', MediaQuery, …))`** states the
81
+ parent: its own field `theme`, its own Message `ThemeSet`, and `MediaQuery`
82
+ under `dark`. The Model is `{ theme, dark: { matches } }`; the Message union
83
+ gains `GotDarkMessage`. Nothing runs.
84
+ - **`placements.initial({ theme: 'light' })`** gives the fields no placement
85
+ owns; `dark` starts at `{ matches: false }`.
86
+ - **`placements.update(own)`** routes `GotDarkMessage` to the bundle and
87
+ everything else to your own update.
88
+ - **`placements.subscriptions()`** is the `matchMedia` stream, lifted. The
89
+ browser is touched only when the runtime subscribes, which then corrects
90
+ `matches` at once.
91
+ - **`config`** is an ordinary Foldkit application config. Hand it to the
92
+ runtime.
158
93
 
159
- ## Media: `foldkit-primitives/media`
94
+ The Solid line this replaces is `createMediaQuery('(prefers-color-scheme: dark)')`.
95
+ The difference is where the answer lives: in the Model, where replay,
96
+ DevTools, an agent, and a server render all see it.
160
97
 
161
- > Reference: [`./media/README.md`](./media/README.md)
98
+ ## The other three ways in
162
99
 
163
- `MediaQuery` follows one CSS media query. Model `{ matches: boolean }`,
164
- one Message `Changed { matches }`, args `{ query: string }`. The stream emits
165
- the current value on subscribe, then every change; without `matchMedia` it is
166
- empty and the slice keeps its initial `false`.
167
-
168
- Bound presets place with no args:
100
+ **An entry**, lifted into your Subscriptions and mapped to your Message. The
101
+ parent keeps whatever it wants of the stream:
169
102
 
170
103
  ```ts
171
- import { PrefersDark, PrefersReducedMotion } from 'foldkit-primitives/media'
172
-
173
- const Page = Bundle.compose({ theme: Schema.String }).pipe(Bundle.withChild('dark', PrefersDark))
104
+ import { Stream } from 'effect'
105
+ import * as Subscription from 'foldkit/subscription'
106
+ import { keyboardEvents } from 'foldkit-primitives/events'
107
+
108
+ const subscriptions = Subscription.make<GameModel, GameMessage>()(() => ({
109
+ keys: Subscription.persistent(
110
+ keyboardEvents({ preventDefault: press => press.key.startsWith('Arrow') }).pipe(
111
+ Stream.filter(event => event._tag === 'Pressed'),
112
+ Stream.map(({ key }) => GameMessage.PressedKey({ key })),
113
+ ),
114
+ ),
115
+ }))
174
116
  ```
175
117
 
176
- `Breakpoints` derives names from one `resize` listener: args
177
- `{ breakpoints }` map names to mobile-first min-widths (finite — anything
178
- else is rejected at placement), Model
179
- `{ width, breakpoint }`, one Message `Changed { width }`. The breakpoint is
180
- the largest name at or below the width (ties break alphabetically); SSR
181
- starts at width 0 with `null`. Placing both `Breakpoints` and `WindowSize`
182
- doubles resize listeners — pick the one the view reads.
183
-
184
- `platformFromUA(ua, hints?)` reads `mac | windows | linux | android | ios | unknown`
185
- from a passed user-agent string (mobile checks first: Android contains
186
- "Linux", iPhones mention "Mac"); Client Hints `platform` wins when
187
- recognized, and a multi-touch Mac UA reads as iOS. `isBrowser()`/`isServer()` split SSR from
188
- client for init defaults.
189
-
190
- ## Net: `foldkit-primitives/net`
191
-
192
- > Reference: [`./net/README.md`](./net/README.md)
193
-
194
- `Online` keeps `online: boolean` in the Model, read from `navigator.onLine`
195
- at startup and kept current by the window's `online`/`offline` events. One
196
- Message `Changed { online }`, no args, no OutMessage. Without a window the
197
- stream is empty and the slice stays at its default, so SSR renders online.
198
-
199
- `sse({ name, createSource? })` is the one-directional sibling: the Model
200
- holds `{ url, status, lastError }`, the resource owns the EventSource, and a
201
- subscription streams its messages. No `send` — the server speaks, the Model
202
- listens. A `Failed` records the error but stays `connecting`: the browser
203
- reconnects dropped streams itself. Event payloads are always text per the
204
- SSE spec.
205
-
206
- `websocket({ name, createSocket? })` makes a duplex socket bundle: the Model
207
- holds `{ url, status, lastError }` with `status` moving
208
- closed → connecting → open. The resource owns the socket (one assembly holds
209
- one); `send` is a placed helper whose command writes through the resource tag
210
- and yields `Sent` on dispatch, `SendFailed` when no connection is open — a
211
- closed socket's `send` is a silent no-op per spec, so the bundle checks first.
212
- Incoming `Received` notifies without storing: project the payload into your
213
- own field to keep it. `createSocket` defaults to the platform WebSocket, read
214
- lazily so tests substitute a double; the socket service rides the assembly
215
- into the application's resources, as `RemoteClient` does for Remote.
216
-
217
- `broadcastMessages(name)` is the entry form for cross-tab traffic: posts from
218
- other instances arrive as `Received { data }` (notify without storing, like
219
- SSE). No bundle — the channel owns no state, so the parent maps `Received`
220
- into its own Message and keeps what it stores. `postBroadcast(name, data)` is
221
- the one-shot Command, yielding `Posted` or `BroadcastFailed`. A post never
222
- echoes to its own channel, per spec; without the API the entry is empty and
223
- the Command fails, instead of throwing.
224
-
225
- ## Time: `foldkit-primitives/time`
226
-
227
- > Reference: [`./time/README.md`](./time/README.md)
228
-
229
- `Timer` counts ticks while running. Model `{ count, running }`, Messages
230
- `Started`/`Stopped`/`Ticked`, args `{ intervalMs }` (positive and finite —
231
- anything else is rejected at placement). The tick stream runs on
232
- Effect's clock, so tests advance it with TestClock instead of waiting; while
233
- stopped the stream is empty. Restarting keeps the count; only `Ticked`
234
- advances it.
235
-
236
- `Interval` is the wall-clock sibling: Model `{ running, lastAt }`,
237
- `Ticked { at }` stamped from Effect's clock. `Timer` counts ticks,
238
- `Interval` records when — views render clocks and elapsed times from
239
- `lastAt`. Same args, same TestClock story, same silence while stopped.
240
-
241
- `debounce({ name, value })` is a factory over any value Schema (like
242
- `history`): `Changed` restarts a `{ delayMs }` timer, and only the latest
243
- value settles — as an OutMessage the placement must handle with `onOut`, so
244
- a settled query can never be dropped by omission. Each `Changed` bumps a
245
- generation the scheduled `Settled` carries; a superseded timer emits nothing.
246
-
247
- `Throttle` is the leading edge to Debounce's trailing one: the first
248
- `Attempted` in an `{ intervalMs }` window surfaces a `Throttled` OutMessage
249
- (`onOut`, likewise required), the rest are dropped. `Attempted` reads the
250
- clock through a Command, so the check runs on Effect time and tests drive
251
- it; the boundary counts as past (`>=`). Pair the two rather than adding a
252
- second timer.
253
-
254
- `formatRelativeTime(from, to, locale?)` picks the unit (seconds through
255
- years) and lets `Intl.RelativeTimeFormat` word it — locales come from the
256
- platform, not a phrase table. There is deliberately no `now` helper:
257
- `Clock.currentTimeMillis` already is it.
258
-
259
- ## Events: `foldkit-primitives/events`
260
-
261
- > Reference: [`./events/README.md`](./events/README.md)
262
-
263
- `Visibility` keeps `visible: boolean` in the Model, read from the document
264
- at startup (SSR assumes visible) and kept current by `visibilitychange`:
265
- one Message `Changed { visible }`, no args. A hidden page is the
266
- application's cue to pause polling and streams; the decision stays in
267
- application `update`, not here.
268
-
269
- `WindowSize` keeps raw `{ width, height }` in the Model, read from the
270
- window on subscribe and kept current by one `resize` listener. SSR starts
271
- at zero; teardown removes the listener.
272
-
273
- `Idle` keeps `idle: boolean` in the Model, args `{ timeoutMs }` (positive
274
- and finite). While active, activity (mouse, keys, pointer, scroll) debounced past the
275
- timeout settles to `BecameIdle`; while idle, the first activity wakes to
276
- `BecameActive` and the dependency flip restarts the watch. Starts active —
277
- a lurker idles when the silence elapses, because subscribe time seeds the
278
- debounce as last-known-alive.
279
-
280
- `keyboardEvents()`, `pointerEvents()`, `scrollEvents()`, and
281
- `activeElementEvents()` are entries, not bundles: the parent owns whatever
282
- key, cursor, scroll, or focus state it keeps. They report presses (with
283
- repeat and modifiers) and releases, moves `{ x, y }`, scroll positions, and
284
- focus `{ tag, id }` — elements cross as tag and id, never as live nodes.
285
- `matchHotkey("ctrl+shift+k", press)` answers whether a press is a shortcut,
286
- so `update` stays a table of chords; matching is exact and auto-repeat never
287
- matches. When the observed target itself depends on state, scope the entry
288
- through subscription dependencies and it restreams on change. When the
289
- handler must cancel the browser default, core
290
- `Subscription.fromEventFilterMap` maps synchronously inside dispatch. Lift
291
- with `Subscription.persistent`, mapping into the parent's Message; without
292
- a window each stream is empty instead of throwing.
293
-
294
- ## Observers: `foldkit-primitives/observers`
295
-
296
- > Reference: [`./observers/README.md`](./observers/README.md)
297
-
298
- `Resize` and `Intersection` are Mounts, not bundles: element-scoped
299
- observation attaches in views, not Model slots. Attach `Resize()` (or
300
- `Intersection()`) with `h.OnMount` on the element. There are no ref objects
301
- to thread: a Mount receives its element directly, and views take the rest
302
- as plain arguments.
303
-
304
- `Resize()` reports `Resized { width, height }` from the element's content
305
- box; `Intersection()` reports `IntersectionChanged { isIntersecting, ratio }`
306
- on viewport crossings; `Mutation()` reports `Mutated { type, added, removed,
307
- attribute }` for child, attribute, and text changes across the whole subtree
308
- — nodes cross as names, never as live objects, and unknown record types are
309
- skipped. `Bounds()` re-measures `Measured { x, y, width, height }` on
310
- observer, scroll, and resize, starting with the current rect; without a
311
- ResizeObserver the window events still measure. Without the observer API
312
- (SSR, old browser) they emit nothing instead of throwing; teardown
313
- disconnects. Treat repeated measurements as observations, not proof that a user action occurred.
314
-
315
- ## Device: `foldkit-primitives/device`
316
-
317
- > Reference: [`./device/README.md`](./device/README.md)
318
-
319
- `Geolocation` watches the device position while placed. Model `{ status,
320
- coords, lastError }` with `status` unknown → ready; denial is its own status
321
- (actionable UI), transient failures keep the last fix and note the error.
322
- Permission code 1 maps to `Denied`, anything else to `Failed`. Without a
323
- geolocation API the stream is empty instead of throwing.
324
-
325
- `mediaDevices({ name, create? })` scans the device list on placement and
326
- re-scans on `Scan`, `DevicesChanged` (wired to `devicechange`), and every
327
- placement: `Refreshed { devices }` with `{ deviceId, groupId, kind, label }`.
328
- Denial lands as `denied` while keeping the last list; other failures keep the last list and
329
- note the error; an unknown `kind` fails the scan at the boundary instead of
330
- entering the Model.
331
-
332
- `mediaStream({ name, request? })` holds one live camera/mic stream in a
333
- Managed Resource while the Model asks for it (`requesting` or `live`).
334
- `Started` requests with the placed `{ audio, video }` constraints, `Stopped`
335
- and `Ended` release; denial parks at `denied`, any other failure parks at
336
- `idle` with the error — both clear requirements, so a failing device never
337
- spins an acquire loop. Release stops every track. The `LiveStream` tag and
338
- service are exported for commands that attach the stream to an element; one
339
- assembly holds one stream.
340
-
341
- `permissions({ name, create? })` queries `{ names }` on acquire and watches
342
- each status object's `onchange` through an ordered queue into a persistent
343
- subscription. `Snapshot` replaces the states map, `Changed` merges one,
344
- `Cleared` empties on release (which also detaches every handler). Unknown
345
- names and unknown state strings fail the acquire — never Model facts.
346
-
347
- `enterFullscreen(element)` / `exitFullscreen()` are Commands yielding
348
- `Entered`/`Exited` or `Failed` (rejected request, missing capability, with a
349
- legacy `webkit` fallback); `fullscreenChanges()` starts with the current
350
- answer then follows flips as `Changed { active }`. No Model: the document
351
- owns fullscreen state.
352
-
353
- ## DOM: `foldkit-primitives/dom`
354
-
355
- > Reference: [`./dom/README.md`](./dom/README.md)
356
-
357
- `copyText` copies text as a Command: use it in `update` beside any bundle.
358
- It yields `Copied` on success and `CopyFailed` otherwise — denial, insecure
359
- context, or no clipboard API (SSR) all become the failure Message instead of
360
- throwing. No Model involved: the clipboard is not application state.
361
-
362
- `share(data)` posts `{ title?, text?, url? }` to the platform sheet: `Shared`
363
- on success, `Dismissed` on sheet cancel (its own outcome, not a failure),
364
- `ShareFailed` otherwise. `loadScript(src)` appends a head script unless one
365
- carries the URL already — idempotent by URL, so concurrent placements
366
- collapse onto the first tag and share its fate — yielding `Loaded` or
367
- `LoadFailed`. A dead tag is removed, so a retry fetches afresh.
368
-
369
- `Autofocus()` focuses the element on insert, then emits `Focused` (requested,
370
- not landed: a non-focusable element may decline). `InputMask({ pattern })`
371
- masks a field against `#`/`A`/`*` placeholders with literal separators,
372
- rewrites the field with approximate caret restore, and emits
373
- `Input { value, raw }` with masked and unmasked text. The parent owns the
374
- state, like any controlled input.
375
-
376
- ## State: `foldkit-primitives/state`
377
-
378
- > Reference: [`./state/README.md`](./state/README.md)
379
-
380
- `Pagination` keeps `{ page, perPage, total }` in the Model, with `total: null`
381
- while unknown. Every transition clamps into range: past the last page lands on
382
- it, below one lands on one, and a smaller total pulls the page back. New sizes
383
- and totals arrive as Messages (`SetPerPage` ignores a non-positive size, and
384
- `SetTotal` ignores a negative total).
385
- `pageCount` returns null while the total is unknown; `offset` gives the first
386
- item's index for a slice or a query. Loading data stays the application's job:
387
- this bundle owns the page, not the items.
388
-
389
- `history({ name, value, capacity })` makes an undo/redo bundle over any value
390
- Schema. The Model holds `{ past, present, future }`; `Push` records and drops
391
- the redo future, `Undo`/`Redo` move one step, `Clear` empties both sides while
392
- keeping the present. The past holds at most `capacity` entries (default 100);
393
- a negative or fractional capacity throws at the factory, naming it. The
394
- factory attaches the Message union, so placements dispatch
395
- `EditHistory.Message.Push(...)`. `canUndo`/`canRedo` read the edges:
118
+ **A Mount**, attached to the element it observes. No ref to thread: the Mount
119
+ receives its element.
396
120
 
397
121
  ```ts
398
- import { history } from 'foldkit-primitives/state'
122
+ import { Resize } from 'foldkit-primitives/observers'
399
123
 
400
- const EditHistory = history({ name: 'EditHistory', value: Schema.String, capacity: 50 })
401
- const Doc = Bundle.declare(EditHistory, 'doc')
124
+ const panel = (model: PanelModel, h: HtmlBuilder<PanelMessage>) =>
125
+ h.div([h.OnMount(Resize())], [`${model.width} × ${model.height}`])
402
126
  ```
403
127
 
404
- `Locale` keeps one string in the Model, read from `navigator.language` at
405
- startup with the configured `default` as fallback; `SetLocale` switches it.
406
- `SelectionSet` keeps string ids in first-selection order: `Select` (keeps
407
- position), `Deselect`, `Toggle` (re-appends), `ReplaceAll` (deduped), and
408
- `Clear`. `isSelected` reads membership. Neither subscribes to changes; Locale reads the initial browser language when available.
409
- Keyed children — lists with stable identity — place through the bundle
410
- mechanism's `each`.
411
- `range(start, end, step?)` counts half-open numbers — the pagination page
412
- list is `range(1, (pageCount(model) ?? 0) + 1)`; a zero or non-finite step
413
- throws, naming it.
414
-
415
- `Virtual` owns a virtualized list's scroll position, measured heights, and
416
- layout: Model `{ scrollTop, heights, scrolling, generation, estimatedHeight,
417
- overscan, gap, paddingStart, paddingEnd }`, Messages
418
- `Scrolled`/`Measured`/`Prune`/`Settled`, args for the layout plus optional
419
- `initialScrollTop`/`initialHeights` restores (measurements sanitized like
420
- live ones) and a `settleMs` silence (default 150). Every scroll marks
421
- `scrolling` until the silence settles — suspend loaders and parallax on it.
422
- `Viewport` reports the container's own scrolls and `MeasureRow({ key })` reports row heights, both as Mounts;
423
- `windowFor(model, keys, viewportHeight)` answers which rows to render plus
424
- the spacer height, `isAtEnd(model, keys, viewportHeight, threshold)` is the
425
- infinite-scroll check (an empty list counts as ended), `distanceToEnd`
426
- answers the pixels remaining for prefetch thresholds, and `offsetFor`
427
- computes programmatic scroll targets the application actuates itself.
428
- `Prune` drops heights for departed keys — the bundle never sees key order.
429
- Poisoned positions and heights are ignored, never stored. For window-
430
- scrolled lists, map the scroll entry into `Scrolled`; for follow-bottom,
431
- hold the end while `isAtEnd` and scroll on extend; to anchor a prepend,
432
- re-`Scrolled` by the totals' delta. Render each row keyed (with
433
- `aria-rowcount`/`posinset` from the window) so per-row placements keep
434
- identity. `stickyHeader(sections, start)` answers which section header
435
- sticks — CSS `position: sticky` does the sticking. `masonry(keys, heights,
436
- options)` packs fixed-width columns shortest-first into `{ placements,
437
- totalHeight }`: layout only, every placed item renders, so it fits hundreds
438
- of images rather than hundred-thousands. The sums never name an
439
- axis: pass column widths as heights and a horizontal offset as scroll
440
- position to window a carousel the same way — no parallel horizontal
441
- bundle. Windowed grids and per-index estimates stay out by design.
442
-
443
- Persisted state lives one package over: `Mirror.kv(App, { key, fields })`
444
- keeps a Model slice in Effect's `KeyValueStore` (localStorage in the
445
- browser), restored through a `MirrorRestored` Message the application
446
- reduces. Nothing here duplicates it — reach for the mirror when a slice
447
- should survive reload, and keep this package's bundles for live facts.
448
-
449
- ## Motion: `foldkit-primitives/motion`
450
-
451
- > Reference: [`./motion/README.md`](./motion/README.md)
452
-
453
- Whether motion should be reduced is a service, `Motion`, read when a
454
- transition starts rather than sniffed once. `Presence` then exits at once,
455
- and `Tween` and `Spring` jump to `to`, in the same Messages, so the Model sees
456
- the same transitions. Provide `Motion.live` (the user's
457
- `prefers-reduced-motion`) through the assembly's resources, or `Motion.reduced`
458
- and `Motion.full` in a test or for a setting the application owns. With no
459
- service provided, motion is full, so a placement that provides nothing behaves
460
- as before. `Motion.reducedMotion` is the Effect the bundles read, for a
461
- transition of your own.
462
-
463
- `Tween` animates one number from `from` to `to` over `ms` milliseconds.
464
- Model `{ value, running }`, Messages `Started`/`Ticked`/`Finished`, args
465
- `{ from, to, ms }` (a non-positive or non-finite duration is rejected at
466
- placement). Progress comes from Effect's clock, so tests advance it with TestClock; the
467
- stream ends with `Finished` carrying the exact end value, and the value rests
468
- at `to` either way. Linear interpolation only: easing curves stay the
469
- application's job.
470
-
471
- `Presence` holds mount-transition state for exit animations. Model
472
- `{ phase, generation }` with `phase` moving shown → hiding → hidden:
473
- `Hide` starts the timed `hiding` phase, and the `Hidden` fact it yields
474
- carries its generation, so a `Show` in between wins and the late fact is
475
- ignored. Args `{ durationMs }` (positive and finite). `isVisible` reads
476
- whether content renders (shown or mid-exit). The timeout Command is the default owner; a
477
- `transitionend` Mount stays a future opt-in, not a second timer.
478
-
479
- `Spring` pulls one number toward `to` with `{ stiffness, damping }` physics
480
- (positive and finite): Model `{ value, velocity, running }`, Messages
481
- `Started`/`Stopped`/`Ticked`/`Finished`, args `{ from, to, stiffness,
482
- damping }`. Fixed 16ms semi-implicit Euler makes the trajectory identical on
483
- the live clock and TestClock; the stream ends with `Finished` carrying the
484
- exact end value even when an underdamped spring overshoots on the way.
485
- `Tween` (fixed duration, linear) versus `Spring` (physics, settles) — pick
486
- the motion, not both.
487
-
488
- ## Interaction: `foldkit-primitives/interaction`
489
-
490
- Interaction state that a view's slots must reflect: the Bundle holds it in the
491
- Model, and a matching [`foldkit-mixins`](../mixins) Behavior writes the
492
- attributes and handlers on the slots. Importing this subpath needs
493
- `foldkit-mixins`; the other subpaths do not.
494
-
495
- `RovingTabindex` is one tab stop for a set of items: arrows move focus between
496
- them, the rest stay out of the tab order. The Model slice is `{ current }`, the
497
- current item's **id**, so a reorder keeps the same item current and a resumed
498
- page knows where focus was. Args: `orientation` (`'vertical' | 'horizontal' |
499
- 'both'`), `loop`, and `virtual` (focus stays on the container and
500
- `aria-activedescendant` points at the current item).
128
+ **A Command**, returned from `update`, whose result comes back as a Message
129
+ your union wraps:
501
130
 
502
131
  ```ts
503
- import { Bundle } from 'foldkit-bundle'
504
- import { Behavior, Behaviors, Capability, Slot, Slots, SlotView } from 'foldkit-mixins'
505
- import { RovingTabindex } from 'foldkit-primitives/interaction'
506
-
507
- const Roving = Bundle.declare(RovingTabindex.bundle, 'toolbarFocus')
508
- const Model = Schema.Struct({ ...Roving.fields, tools: Schema.Array(Tool) })
509
- const Message = defineMessageUnion({ ...Roving.cases })
510
- const Page = Bundle.parent({ Model, Message })
511
- const args = { orientation: 'horizontal', loop: true, virtual: false } as const
512
- const placements = Page.assemble(Page.at(Roving, { args }))
513
-
514
- const ToolbarSlots = Slots.define({
515
- root: Slot.make({ capability: Capability.Container }),
516
- tool: Slot.make({ capability: Capability.Focusable }),
517
- })
518
- const describeTools = (tools: ReadonlyArray<Tool>) =>
519
- Behaviors.Collection.of(tools, { id: tool => tool.id, disabled: tool => tool.disabled })
132
+ import { mapMessage } from 'foldkit/command'
133
+ import { copyText } from 'foldkit-primitives/dom'
520
134
 
521
- // Ids on each item come from Collection; RovingTabindex reads them.
522
- const Ids = Behaviors.Collection.behavior(ToolbarSlots)<Model, Message>({
523
- item: 'tool',
524
- items: model => describeTools(model.tools),
525
- })
526
- const Focus = RovingTabindex.behavior(Roving, args)(ToolbarSlots)<Model, Message>({
527
- container: 'root',
528
- item: 'tool',
529
- items: model => describeTools(model.tools),
135
+ const copy = (model: NoteModel): Update.Return<NoteModel, NoteMessage> => ({
136
+ model,
137
+ commands: [mapMessage(copyText(model.text), message => NoteMessage.Clipboard({ message }))],
530
138
  })
531
-
532
- const Toolbar = SlotView.forMessages<Message>()
533
- .define(ToolbarSlots, (model, slots, h) => {
534
- const items = describeTools(model.tools)
535
- return h.div(
536
- slots.root.attrs([h.Role('toolbar')]),
537
- model.tools.map((tool, index) =>
538
- h.button(slots.tool.attrs([h.Key(tool.id)], items.slotItem(index)), [tool.label]),
539
- ),
540
- )
541
- })
542
- .pipe(Behavior.attach(Ids), Behavior.attach(Focus))
543
139
  ```
544
140
 
545
- What each half does: the container gets `OnKeyDownFocus`, which on an arrow,
546
- Home or End focuses the next **enabled** item synchronously by its id, prevents
547
- the default, and dispatches `Focused { id }`. Each item gets `tabindex` `0` when
548
- it is the tab stop and `-1` otherwise, and `OnFocus` reporting `Focused`, so a
549
- click makes an item current too. Before anything is current, or when the
550
- current item is gone or disabled, the first enabled item is the tab stop. Keys
551
- with ctrl, alt or meta held are left alone; `direction: model => 'rtl'` swaps
552
- left and right. The pure `move(enabled, current, key, modifiers, options)` and
553
- `tabStop(items, current)` are exported for a view that wires its own.
554
-
555
- Under `virtual` the items get no `tabindex`, the container gets
556
- `aria-activedescendant`, and a key keeps DOM focus where it is and only moves
557
- the pointer. Nothing is written on dispose: the attributes are data, so a view
558
- that no longer attaches the Behavior leaves no `tabindex` behind. PageUp and
559
- PageDown are handled when `move` is given a `page`; `ListNavigation` below
560
- does that.
561
-
562
- `Typeahead` is type-to-find for a host that has no roving tab stop, or whose
563
- focus is managed elsewhere. The Model slice is `{ query, generation }`:
564
- printable keys extend the query, a timer of `timeoutMs` on Effect's clock
565
- clears it (`generation` lets a superseded timer change nothing), and `Cleared`
566
- drops it on purpose. Which item a query picks is the pure
567
- `Typeahead.match(texts, enabled, query, current)`: one character, or one
568
- character repeated, starts *after* the current item so repeated presses cycle;
569
- a longer query starts *at* it, since the user is refining. Case and leading
570
- whitespace are ignored and disabled items are skipped. The Behavior
571
- (`Typeahead.behavior(Declared)(Slots)<Model, Message>({ host, items, text,
572
- current })`) gives the host `OnKeyDownFocus`: a printable key with no ctrl, alt
573
- or meta extends the query, focuses the match by id, and dispatches `Typed`;
574
- with no match the key is still recorded and focus stays put; a space with an
575
- empty query is left to the host.
576
-
577
- `ListNavigation` is what a list host takes when it wants both: arrows, Home,
578
- End, PageUp and PageDown by `page`, and typeahead, in **one placement**. It
579
- exists for two reasons. The resolver allows one owner per event on a slot, so
580
- `RovingTabindex` and `Typeahead` cannot both own the host's `OnKeyDownFocus`;
581
- and under `virtual` a typed key must move the pointer and extend the query in
582
- one transition, which two placements cannot do. Its Model slice is `{ current,
583
- query, generation }`, its args are `RovingTabindex`'s plus `timeoutMs` and
584
- `page`, and its Behavior takes `{ container, item, items, text, direction? }`.
585
- `Typed { char, match }` carries the item the query now picks, so `update` sets
586
- `current` and `query` together.
141
+ ## The map
587
142
 
588
- ```ts
589
- const Nav = Bundle.declare(ListNavigation.bundle, 'nav')
590
- // place with { args: { orientation: 'vertical', loop: false, virtual: false, timeoutMs: 500, page: 10 } }
591
- const Keys = ListNavigation.behavior(Nav, args)(ListSlots)<Model, Message>({
592
- container: 'list',
593
- item: 'option',
594
- items: model => describeFruits(model.fruits),
595
- text: (model, index) => model.fruits[index]?.label ?? '',
596
- })
597
- ```
598
-
599
- `GridNavigation` is the two-dimensional counterpart for cells laid out in rows
600
- of `columns` (a calendar grid, a swatch picker, an emoji palette). Its Model
601
- slice and item attributes are `RovingTabindex`'s, so the two are
602
- interchangeable on a view; only the pure `move` differs. Left and right step
603
- within the row and up and down within the column, skipping disabled cells;
604
- under `wrap` a horizontal key continues into the next row and a vertical key
605
- into the next column, otherwise the key is consumed at the edge. Home and End
606
- are the row's first and last enabled cell, Ctrl+Home and Ctrl+End the grid's.
607
- RTL swaps left and right, and `virtual` works as it does for `RovingTabindex`.
608
-
609
- ```ts
610
- const Cells = Bundle.declare(GridNavigation.bundle, 'cells')
611
- // place with { args: { columns: 7, wrap: false, virtual: false } }
612
- const Keys = GridNavigation.behavior(Cells, args)(CalendarSlots)<Model, Message>({
613
- container: 'grid',
614
- item: 'day',
615
- items: model => describeDays(model.days),
616
- })
617
- ```
143
+ Each subpath is one concern and one import. Its README has the full
144
+ reference: every Model shape, Message, argument, and failure rule.
618
145
 
619
- `FocusScope` is the one entry here that is a Mount, not a Bundle: which
620
- element has focus is a DOM fact, so nothing crosses to the Model. The Mount
621
- lives in `foldkit-primitives/dom`; `FocusScope.behavior(Slots)<Input,
622
- Message>({ container, contain?, restore?, initialFocus? })` attaches it to a
623
- container slot. On insert the container focuses `initialFocus`, else its first
624
- tabbable descendant, else itself. With `contain` (default), Tab from the last
625
- tabbable wraps to the first, Shift+Tab from the first wraps to the last, and
626
- focus that lands outside comes straight back; Tab in the middle is the
627
- browser's. On unmount, with `restore` (default), focus returns to the element
628
- that had it, if it is still in the document. A native `<dialog>` does all of
629
- this itself; this is for a custom overlay, a menu, or a command palette.
630
- `tabbableWithin(element)` is exported: focusable, visible descendants with a
631
- non-negative `tabindex`, in order.
632
-
633
- `Press` turns pointer and keyboard activation of one element into one fact.
634
- Foldkit's declarative pointer attributes carry no button, pointer id, or click
635
- detail, so `Press.events` is a Mount that reports what the element saw
636
- (`PointerDown`, `PointerUp`, `PointerCancelled`, `KeyDown`, `KeyUp`,
637
- `Clicked`), and the Bundle's `update` decides: primary button only, one
638
- pointer at a time, `pointerleave` and `pointercancel` cancel, Enter and Space
639
- with a repeat ignored, a click with `detail` 0 (keyboard on a native control,
640
- or assistive technology) counts, and the ghost click that follows a touch is
641
- ignored inside a window of `clickSuppressionMs` that a Command on Effect's
642
- clock closes. Enter and Space are default-prevented on the element, so a
643
- native control does not also click and Space does not scroll. Activation is
644
- the OutMessage `Pressed { pointerType, shiftKey }`, and the placement must handle it:
645
-
646
- ```ts
647
- const Button = Bundle.declare(Press.bundle, 'saveButton')
648
- const placements = Page.assemble(
649
- Page.at(Button, {
650
- args: { clickSuppressionMs: 50 },
651
- onOut: () => model => ({ model, commands: [save(model)] }),
652
- }),
653
- )
654
- const Activate = Press.behavior(Button)(CardSlots)<Model, Message>({
655
- target: 'save',
656
- disabled: model => model.saving,
657
- })
658
- ```
659
-
660
- The Behavior attaches the Mount to the target slot, writes `data-pressed`
661
- while the element is down for styling, and marks a disabled target
662
- `aria-disabled`, which the Mount reads at event time so nothing is reported
663
- and no remount is needed. The Model slice is `{ pressed, pointerId, key,
664
- suppressing, generation }`; only `pressed` is meant for a view.
665
-
666
- `LongPress` is holding for `thresholdMs`. It reads the same facts
667
- `Press.events` reports, so it needs no Mount of its own; the threshold is a
668
- Command on Effect's clock carrying a generation, and a release before it fires
669
- makes its `Elapsed` a no-op. `LongPressed { pointerType }` is the OutMessage,
670
- required at placement. The Behavior writes `data-holding` while down. `Press`
671
- and `LongPress` on one slot are refused by the resolver, since both would mount
672
- `PressEvents`; a slot takes one of them.
673
-
674
- `Move` is pointer movement as facts, a Mount in `foldkit-primitives/dom`: a
675
- primary-button pointer down captures the pointer and reports `MoveStarted`,
676
- each move reports `Moved { deltaX, deltaY }` from where it went down, and up,
677
- cancel, or lost capture reports `MoveEnded { completed }`. A second pointer
678
- and a secondary button are ignored; capture is released with the Mount.
679
- `Move.behavior(Slots)<Input, Message>({ handle, toMessage })` attaches it to a
680
- `Draggable` slot and maps each fact into the view's Messages; a drag's meaning
681
- (a threshold, a snap, a reorder) is the parent's `update`.
682
-
683
- `FocusVisible` is the one entry whose Bundle lives elsewhere: `InputModality`
684
- in `foldkit-primitives/events` keeps `{ modality }` (`'keyboard'`, `'pointer'`,
685
- or `'unknown'` before any input), fed by the window's `keydown` (a modifier
686
- alone says nothing) and `pointerdown`. `FocusVisible.behavior(Declared)(Slots)
687
- <Model, Message>({ target })` writes `data-focus-visible` on the target while
688
- the page is driven by keyboard, so a stylesheet shows a ring with
689
- `[data-focus-visible]:focus`. CSS `:focus-visible` does this with no Model at
690
- all; this is for a design system that must decide in the Model, or show the
691
- same answer somewhere other than the focused element.
692
-
693
- `DismissLayer` closes overlays that are not native `<dialog>` or `popover`
694
- elements: Escape closes the topmost open layer, and a pointer press closes
695
- the layers it is outside of. One Bundle, **placed once**, owns the document
696
- listeners; each layer's Behavior marks its container with
697
- `data-foldkit-plus-layer="<id>"` and its trigger with the matching trigger
698
- attribute. The stack is the DOM order of the marked elements at the moment of
699
- the event, so a layer takes part exactly while it is rendered and an `open`
700
- flag in the parent Model is its whole lifecycle; nothing registers. The rules,
701
- each a test: a press inside a parent layer is outside its children, so the
702
- parent stays and the children go; a press on a layer's trigger counts as
703
- inside it, so a click on the trigger never dismisses and reopens; a layer
704
- placed with `outsidePress: false` or `escape: false` opts out of that path.
705
- `Dismiss { ids }` is the OutMessage, and the placement's `onOut` closes them:
706
-
707
- ```ts
708
- const Layers = Bundle.declare(DismissLayer.bundle, 'layers')
709
- const placements = Page.assemble(
710
- Page.at(Layers, {
711
- onOut: ({ ids }) => model => ({ model: { ...model, menuOpen: ids.includes('menu') ? false : model.menuOpen } }),
712
- }),
713
- )
714
- const Dismissable = DismissLayer.behavior(Layers)(MenuSlots)<Model, Message>({
715
- layer: 'panel',
716
- trigger: 'button',
717
- id: () => 'menu',
718
- })
719
- ```
720
-
721
- The Model slice is `{ layers }`, the open layers as the last event saw them,
722
- for DevTools and agents. `toDismiss(layers, inside)` is the pure rule.
723
-
724
- `Layers.scrollLock(Slots)({ container })` and `Layers.hideOutside(Slots)({
725
- container })` are Mounts over Foldkit's own `Dom.lockScroll` and
726
- `Dom.inertOthers`: the first locks the document's scroll while the container is
727
- mounted, refcounted so nested overlays release together, with Foldkit's iOS
728
- handling; the second marks everything outside the container inert while it is
729
- mounted, keyed by an id the Mount mints so two overlays restore independently.
730
- Both live in `foldkit-primitives/dom` as `ScrollLock` and `HideOutside`. A
731
- native `<dialog>` shown modally needs neither.
732
-
733
- `Selection` is which items are selected, with `mode` `'single'` (a click
734
- replaces; `allowEmpty` says whether clicking the selected item deselects it),
735
- `'multiple'` (a click toggles), or `'none'`, and the `anchor` a range extends
736
- from. The Model slice is `{ selected, anchor }`. A range needs the items'
737
- order, which the view knows and the Bundle does not, so `Ranged { id, order }`
738
- carries it; `Selection.between(order, from, to)` is the pure span. The Behavior
739
- (`Selection.behavior(Declared, args)(Slots)<Model, Message>({ container?,
740
- item, items, click? })`) writes `aria-selected` on each item and
741
- `aria-multiselectable` on the container, and wires a plain click on each
742
- enabled item to `Activated`; pass `click: false` when `Press` or the view owns
743
- the click. For a Shift range, `Press`'s `Pressed { pointerType, shiftKey }`
744
- says whether Shift was held, and the placement's `onOut` dispatches `Ranged`.
745
-
746
- `LiveAnnounce` speaks to assistive technology: one Bundle, placed once, holds
747
- the text of a polite and an assertive live region. `say(Declared)(text,
748
- politeness?)` builds the Message to return from `update` or an `onOut`; an
749
- announcement waits `debounceMs` so a burst reads once, then clears after
750
- `clearAfterMs`, both on Effect's clock with a generation so a superseded timer
751
- changes nothing; the same text twice gets a trailing no-break space toggled,
752
- which is what makes a screen reader read it again. `LiveAnnounce.view(slice,
753
- h)` renders the two regions: put it once in the page and hide them visually
754
- with a rule on `[data-foldkit-plus-live]`, never `display: none`.
755
-
756
- ## Testing placements
757
-
758
- Every primitive is testable without the platform, following one pattern
759
- with three ingredients. First, substitute the environment: factories take
760
- `create` (streams, devices, permissions) or `request` (camera) doubles, so
761
- tests pass fakes instead of stubbing globals. Second, advance time instead
762
- of waiting it: anything on Effect's clock (debounce, throttle, presence,
763
- idle, timers, tweens) runs under `TestClock.adjust`. Third, never hang on a
764
- quiet stream: collect with a bounded `takeMessages`, which fails fast naming
765
- the stall. The settle-before-adjust rule applies throughout — yield after
766
- forking before the first `TestClock.adjust`, or dispatched events hit
767
- unregistered listeners. Each ingredient is demonstrated in this package's
768
- `test/` directory, named after its primitive.
146
+ | Subpath | What it covers | Primitives |
147
+ | --- | --- | --- |
148
+ | [`media`](./media/README.md) | the environment | `MediaQuery` (+ `PrefersDark`, `PrefersReducedMotion`), `Breakpoints`, `platformFromUA`, `isBrowser` |
149
+ | [`net`](./net/README.md) | the network | `Online`, `websocket`, `sse`, `broadcastMessages`, `postBroadcast` |
150
+ | [`time`](./time/README.md) | the clock | `Timer`, `Interval`, `ticks`, `debounce`, `Throttle`, `formatRelativeTime` |
151
+ | [`state`](./state/README.md) | UI state with nowhere else to live | `Pagination`, `history`, `Locale`, `SelectionSet`, `Virtual`, `range`, layout math |
152
+ | [`motion`](./motion/README.md) | animation | `Tween`, `Spring`, `Presence`, the `Motion` service |
153
+ | [`interaction`](./interaction/README.md) | keyboard, pointer, and focus patterns, with their Behaviors | `RovingTabindex`, `Typeahead`, `ListNavigation`, `GridNavigation`, `TreeNavigation`, `FocusScope`, `Press`, `LongPress`, `Move`, `Targets`, `PointerDrag`, `EditableText`, `FocusVisible`, `DismissLayer`, `Layers`, `Selection`, `LiveAnnounce` |
154
+ | [`device`](./device/README.md) | hardware | `Geolocation`, `mediaDevices`, `mediaStream`, `permissions`, fullscreen |
155
+ | [`events`](./events/README.md) | raw window events | `Visibility`, `WindowSize`, `Idle`, `InputModality`, `keyboardEvents`, `pointerEvents`, `scrollEvents`, `activeElementEvents`, `matchHotkey` |
156
+ | [`observers`](./observers/README.md) | element observers | `Resize`, `Intersection`, `Mutation`, `Bounds` |
157
+ | [`dom`](./dom/README.md) | element Mounts and one-shot Commands | `Autofocus`, `InputMask`, `KeepInView`, `Measure`, `FocusScope`, `FollowTabStop`, `ScrollLock`, `HideOutside`, `copyText`, `readText`, `share`, `loadScript`, `keepScroll` |
158
+
159
+ ## Rules every primitive keeps
160
+
161
+ These hold across the package, so the subpath pages do not repeat them.
162
+
163
+ - **A fact that changed nothing returns the Model it was given.** A repeated
164
+ resize, `Started` while running, a total already held: the placement then
165
+ returns the parent unchanged, Foldkit renders nothing, and a `createLazy` view
166
+ over the slice is skipped.
167
+ - **Init is a safe default, not a read.** `matches: false`, `online: true`, a
168
+ count of zero. A server render shows these; the subscription corrects them on
169
+ the client. Do not infer readiness from the default.
170
+ - **Without the platform API, nothing throws.** A stream is empty and the
171
+ slice keeps its default; a Command yields its failure Message (`CopyFailed`,
172
+ `ShareFailed`); a Mount emits nothing.
173
+ - **One placement observes once.** Two placements of one query open two
174
+ listeners; share the field. One assembly holds one socket, stream, camera,
175
+ or permission watch: their resource tags are per module, and `assemble`
176
+ refuses the second.
177
+ - **An OutMessage cannot be dropped by omission.** `debounce`, `Throttle`,
178
+ `Press`, `DismissLayer`, and the rest that surface a settled value require
179
+ `onOut` at placement. `Bundle.ignore` drops one on purpose.
180
+ - **Everything timed runs on Effect's clock.** Timers, tweens, debounces,
181
+ presence, idle, typeahead. Tests advance them with `TestClock.adjust`
182
+ instead of waiting.
183
+ - **Reduced motion is a service.** Provide `Motion.live` through the
184
+ assembly's resources, or `Motion.reduced` and `Motion.full` in a test;
185
+ absent, motion is full.
186
+ - **Elements never cross into the Model.** A focus report is `{ tag, id }`, a
187
+ mutation names nodes, a target is its marked id.
188
+
189
+ ## Testing a placement
190
+
191
+ Three ingredients, each shown in this package's `test/` directory under the
192
+ primitive's name:
193
+
194
+ 1. **Substitute the environment.** Factories take a `create` (streams,
195
+ devices, permissions, sockets) or `request` (camera) double, so a test
196
+ passes a fake instead of stubbing a global.
197
+ 2. **Advance time instead of waiting.** Anything on Effect's clock runs under
198
+ `TestClock.adjust`. Yield after forking before the first adjust, or
199
+ dispatched events reach listeners not yet registered.
200
+ 3. **Never hang on a quiet stream.** Collect with a bounded take that fails
201
+ fast naming the stall.
769
202
 
770
203
  ## With Surface and Mirror
771
204
 
772
- Placed state is ordinary Model, so the surrounding tools apply unchanged —
773
- no bundle-specific Surface or Mirror API exists, by design. Declare a Surface
774
- over the placed fields to render them or expose them to an agent; point
775
- `Mirror.url` at them to link them; spread the bundle's cases into the same
776
- unions. The field refs and wrapper Messages are the same ones the rest of the
777
- application uses.
778
-
779
- ## Failure and recovery
780
-
781
- | Failure | Behaviour |
782
- | --- | --- |
783
- | No `window` (SSR) or no API (old browser, minimal DOM) | stream is empty; the slice keeps its default |
784
- | A Command without its platform API | failure Message (`CopyFailed`, `ShareFailed`, `BroadcastFailed`), never a throw |
785
- | Listener removed (unmount, gate closed) | finalizer disconnects; resubscribing re-reads the current value |
786
- | A Message for an unplaced child | never routes: wrappers only match placed variants |
787
- | MediaStream acquisition fails | parks at `denied`/`idle` with requirements cleared; `Started` retries |
205
+ Placed state is ordinary Model, so nothing here has a Surface or Mirror API of
206
+ its own. Declare a Surface over the placed fields to render them or expose them
207
+ to an agent; point `Mirror.url` at them to link them; spread the bundle's cases
208
+ into the same unions. A slice that must survive a reload persists through
209
+ `Mirror.kv`; this package owns live facts only.
788
210
 
789
211
  ## Limits
790
212
 
791
- - One placement observes one query. Two placements of `MediaQuery` with the
792
- same query open two listeners; share the field instead.
793
- - Presets cover the common queries. Anything else passes `args` explicitly.
794
- - `/plus` Surfaces and Mirrors per primitive are future work; declare them in
795
- the application for now.
213
+ - Presets cover the common media queries; anything else passes `args`.
214
+ - `Virtual` windows one axis of a list; windowed grids stay out by design.
215
+ - `Sync.lww`-style merging, collaborative text, and other replicated state are
216
+ [`foldkit-sync`](../sync)'s, not a primitive.