foldkit-primitives 0.1.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +454 -0
  3. package/dist/device/index.d.mts +312 -0
  4. package/dist/device/index.d.mts.map +1 -0
  5. package/dist/device/index.mjs +490 -0
  6. package/dist/device/index.mjs.map +1 -0
  7. package/dist/dom/index.d.mts +73 -0
  8. package/dist/dom/index.d.mts.map +1 -0
  9. package/dist/dom/index.mjs +218 -0
  10. package/dist/dom/index.mjs.map +1 -0
  11. package/dist/events/index.d.mts +154 -0
  12. package/dist/events/index.d.mts.map +1 -0
  13. package/dist/events/index.mjs +287 -0
  14. package/dist/events/index.mjs.map +1 -0
  15. package/dist/index.d.mts +10 -0
  16. package/dist/index.mjs +10 -0
  17. package/dist/media/index.d.mts +79 -0
  18. package/dist/media/index.d.mts.map +1 -0
  19. package/dist/media/index.mjs +127 -0
  20. package/dist/media/index.mjs.map +1 -0
  21. package/dist/motion/index.d.mts +112 -0
  22. package/dist/motion/index.d.mts.map +1 -0
  23. package/dist/motion/index.mjs +208 -0
  24. package/dist/motion/index.mjs.map +1 -0
  25. package/dist/net/index.d.mts +199 -0
  26. package/dist/net/index.d.mts.map +1 -0
  27. package/dist/net/index.mjs +329 -0
  28. package/dist/net/index.mjs.map +1 -0
  29. package/dist/observers/index.d.mts +59 -0
  30. package/dist/observers/index.d.mts.map +1 -0
  31. package/dist/observers/index.mjs +156 -0
  32. package/dist/observers/index.mjs.map +1 -0
  33. package/dist/state/index.d.mts +342 -0
  34. package/dist/state/index.d.mts.map +1 -0
  35. package/dist/state/index.mjs +504 -0
  36. package/dist/state/index.mjs.map +1 -0
  37. package/dist/time/index.d.mts +129 -0
  38. package/dist/time/index.d.mts.map +1 -0
  39. package/dist/time/index.mjs +213 -0
  40. package/dist/time/index.mjs.map +1 -0
  41. package/package.json +96 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Glenn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,454 @@
1
+ # `foldkit-primitives`
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`).
14
+
15
+ ```ts
16
+ import { MediaQuery } from 'foldkit-primitives/media'
17
+ ```
18
+
19
+ ## Which state belongs here?
20
+
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).
25
+
26
+ | Kind | Form | Example |
27
+ | --- | --- | --- |
28
+ | Stateful + effectful | bundle | MediaQuery, Timer, WebSocket, Pagination |
29
+ | Keyed collections of stateful items | bundle per key | uploads, sockets, timers (later) |
30
+ | Stream source with a stored fact | bundle with one boolean/scalar slice | Online, Visibility, WindowSize |
31
+ | Stream source only | Subscription entry, not a bundle | keyboard, pointer, scroll, broadcast |
32
+ | Element-scoped observation | Mount, not a bundle | Resize, Intersection, Mutation, Autofocus |
33
+ | One-shot actions | Command, not a bundle | clipboard copy, share, script load |
34
+ | Derived data | pure function, not a bundle | range, relative time, platform |
35
+
36
+ A bundle holds no state and performs no I/O by itself. The parent Model owns
37
+ the placed slice; the browser (or server, or clock) only reports facts as
38
+ Messages. The same rule as everywhere else: observation is not ownership.
39
+
40
+ Solid developers will notice missing plumbing: there is no event bus
41
+ because Messages are the bus, no memo because derivations are pure reads
42
+ over the Model, and no reactive map or store because the Model holds plain
43
+ data (Effect collections where mutation matters). None of it is missing by
44
+ accident — one state machine leaves nowhere for a second one to live.
45
+
46
+ ## The mental model
47
+
48
+ ```text
49
+ browser / clock / server ──facts as Messages──▶ update ──▶ Model slice
50
+ ▲
51
+ Note the direction: init is pure, so SSR renders the default; the stream corrects it live.
52
+ ```
53
+
54
+ Every primitive follows one lifecycle: `init` returns a safe default without
55
+ touching the environment (SSR-safe by construction). Streams report live
56
+ facts — MediaQuery reads the current match first, then changes; Timer ticks
57
+ while running. A primitive that cannot observe (no `window`, no API) yields
58
+ an empty stream instead of throwing, so the slice keeps its default.
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ pnpm add foldkit-primitives foldkit-bundle effect foldkit
64
+ ```
65
+
66
+ `effect` and `foldkit` are peer dependencies. Import per subpath —
67
+ `foldkit-primitives/media`, `foldkit-primitives/net`, `foldkit-primitives/time`,
68
+ `foldkit-primitives/state`, `foldkit-primitives/motion`, `foldkit-primitives/device`,
69
+ `foldkit-primitives/events`, `foldkit-primitives/observers`, `foldkit-primitives/dom` —
70
+ so bundlers drop the primitives you never import.
71
+
72
+ ## Sixty seconds: follow the color scheme
73
+
74
+ **Declare where it lives.** A declaration names the Model field and the
75
+ Message variant, by Foldkit's `Got<Field>Message` convention:
76
+
77
+ ```ts
78
+ import { Schema } from 'effect'
79
+ import { defineMessageUnion } from 'foldkit/message'
80
+ import { Bundle } from 'foldkit-bundle'
81
+ import { MediaQuery } from 'foldkit-primitives/media'
82
+
83
+ const Dark = Bundle.declare(MediaQuery, 'dark')
84
+
85
+ const Model = Schema.Struct({ ...Dark.fields, theme: Schema.String })
86
+ type Model = typeof Model.Type
87
+ const Message = defineMessageUnion({ ...Dark.cases, ThemeSet: { theme: Schema.String } })
88
+ type Message = typeof Message.Type
89
+ ```
90
+
91
+ **Place it.** The scope types everything from the parent's Schemas; the
92
+ assembly derives the update and the Subscriptions:
93
+
94
+ ```ts
95
+ const Page = Bundle.parent({ Model, Message })
96
+ const placements = Page.assemble(Page.at(Dark, { args: { query: '(prefers-color-scheme: dark)' } }))
97
+
98
+ const config = placements.complete({
99
+ init: () => placements.initial({ theme: 'light' }),
100
+ update: placements.update(model => ({ model })),
101
+ view,
102
+ subscriptions: placements.subscriptions(),
103
+ })
104
+ ```
105
+
106
+ `update` routes `GotDarkMessage` to the bundle; `subscriptions` runs the
107
+ `matchMedia` stream; `initial` starts `matches` at `false` and the stream
108
+ corrects it on subscribe. The Solid equivalent this replaces:
109
+
110
+ ```ts
111
+ // solid-primitives: const dark = createMediaQuery('(prefers-color-scheme: dark)')
112
+ // Here the fact lives in the Model: replay, DevTools, and time travel see it.
113
+ ```
114
+
115
+ ## Media: `foldkit-primitives/media`
116
+
117
+ `MediaQuery` follows one CSS media query. Model `{ matches: boolean }`,
118
+ one Message `Changed { matches }`, args `{ query: string }`. The stream emits
119
+ the current value on subscribe, then every change; without `matchMedia` it is
120
+ empty and the slice keeps its initial `false`.
121
+
122
+ Bound presets place with no args:
123
+
124
+ ```ts
125
+ import { PrefersDark, PrefersReducedMotion } from 'foldkit-primitives/media'
126
+
127
+ const placements = Page.assemble(Page.place(PrefersDark, 'dark'))
128
+ ```
129
+
130
+ `Breakpoints` derives names from one `resize` listener: args
131
+ `{ breakpoints }` map names to mobile-first min-widths (finite — anything
132
+ else is rejected at placement), Model
133
+ `{ width, breakpoint }`, one Message `Changed { width }`. The breakpoint is
134
+ the largest name at or below the width (ties break alphabetically); SSR
135
+ starts at width 0 with `null`. Placing both `Breakpoints` and `WindowSize`
136
+ doubles resize listeners — pick the one the view reads.
137
+
138
+ `platformFromUA(ua)` reads `mac | windows | linux | android | ios | unknown`
139
+ from a passed user-agent string (mobile checks first: Android contains
140
+ "Linux", iPhones mention "Mac"); `isBrowser()`/`isServer()` split SSR from
141
+ client for init defaults.
142
+
143
+ ## Net: `foldkit-primitives/net`
144
+
145
+ `Online` keeps `online: boolean` in the Model, read from `navigator.onLine`
146
+ at startup and kept current by the window's `online`/`offline` events. One
147
+ Message `Changed { online }`, no args, no OutMessage. Without a window the
148
+ stream is empty and the slice stays at its default, so SSR renders online.
149
+
150
+ `sse({ name, createSource? })` is the one-directional sibling: the Model
151
+ holds `{ url, status, lastError }`, the resource owns the EventSource, and a
152
+ subscription streams its messages. No `send` — the server speaks, the Model
153
+ listens. A `Failed` records the error but stays `connecting`: the browser
154
+ reconnects dropped streams itself. Event payloads are always text per the
155
+ SSE spec.
156
+
157
+ `websocket({ name, createSocket? })` makes a duplex socket bundle: the Model
158
+ holds `{ url, status, lastError }` with `status` moving
159
+ closed → connecting → open. The resource owns the socket (one assembly holds
160
+ one); `send` is a placed helper whose command writes through the resource tag
161
+ and yields `Sent` on dispatch, `SendFailed` when no connection is open — a
162
+ closed socket's `send` is a silent no-op per spec, so the bundle checks first.
163
+ Incoming `Received` notifies without storing: project the payload into your
164
+ own field to keep it. `createSocket` defaults to the platform WebSocket, read
165
+ lazily so tests substitute a double; the socket service rides the assembly
166
+ into the application's resources, as `RemoteClient` does for Remote.
167
+
168
+ `broadcastMessages(name)` is the entry form for cross-tab traffic: posts from
169
+ other instances arrive as `Received { data }` (notify without storing, like
170
+ SSE). No bundle — the channel owns no state, so the parent maps `Received`
171
+ into its own Message and keeps what it stores. `postBroadcast(name, data)` is
172
+ the one-shot Command, yielding `Posted` or `BroadcastFailed`. A post never
173
+ echoes to its own channel, per spec; without the API the entry is empty and
174
+ the Command fails, instead of throwing.
175
+
176
+ ## Time: `foldkit-primitives/time`
177
+
178
+ `Timer` counts ticks while running. Model `{ count, running }`, Messages
179
+ `Started`/`Stopped`/`Ticked`, args `{ intervalMs }` (positive and finite —
180
+ anything else is rejected at placement). The tick stream runs on
181
+ Effect's clock, so tests advance it with TestClock instead of waiting; while
182
+ stopped the stream is empty. Restarting keeps the count; only `Ticked`
183
+ advances it.
184
+
185
+ `Interval` is the wall-clock sibling: Model `{ running, lastAt }`,
186
+ `Ticked { at }` stamped from Effect's clock. `Timer` counts ticks,
187
+ `Interval` records when — views render clocks and elapsed times from
188
+ `lastAt`. Same args, same TestClock story, same silence while stopped.
189
+
190
+ `debounce({ name, value })` is a factory over any value Schema (like
191
+ `history`): `Changed` restarts a `{ delayMs }` timer, and only the latest
192
+ value settles — as an OutMessage the placement must handle with `onOut`, so
193
+ a settled query can never be dropped by omission. Each `Changed` bumps a
194
+ generation the scheduled `Settled` carries; a superseded timer emits nothing.
195
+
196
+ `Throttle` is the leading edge to Debounce's trailing one: the first
197
+ `Attempted` in an `{ intervalMs }` window surfaces a `Throttled` OutMessage
198
+ (`onOut`, likewise required), the rest are dropped. `Attempted` reads the
199
+ clock through a Command, so the check runs on Effect time and tests drive
200
+ it; the boundary counts as past (`>=`). Pair the two rather than adding a
201
+ second timer.
202
+
203
+ `formatRelativeTime(from, to, locale?)` picks the unit (seconds through
204
+ years) and lets `Intl.RelativeTimeFormat` word it — locales come from the
205
+ platform, not a phrase table. There is deliberately no `now` helper:
206
+ `Clock.currentTimeMillis` already is it.
207
+
208
+ ## Events: `foldkit-primitives/events`
209
+
210
+ `Visibility` keeps `visible: boolean` in the Model, read from the document
211
+ at startup (SSR assumes visible) and kept current by `visibilitychange`:
212
+ one Message `Changed { visible }`, no args. A hidden page is the
213
+ application's cue to pause polling and streams; the decision stays in
214
+ application `update`, not here.
215
+
216
+ `WindowSize` keeps raw `{ width, height }` in the Model, read from the
217
+ window on subscribe and kept current by one `resize` listener. SSR starts
218
+ at zero; teardown removes the listener.
219
+
220
+ `Idle` keeps `idle: boolean` in the Model, args `{ timeoutMs }` (positive
221
+ and finite). While active, activity (mouse, keys, pointer, scroll) debounced past the
222
+ timeout settles to `BecameIdle`; while idle, the first activity wakes to
223
+ `BecameActive` and the dependency flip restarts the watch. Starts active —
224
+ a lurker idles when the silence elapses, because subscribe time seeds the
225
+ debounce as last-known-alive.
226
+
227
+ `keyboardEvents()`, `pointerEvents()`, `scrollEvents()`, and
228
+ `activeElementEvents()` are entries, not bundles: the parent owns whatever
229
+ key, cursor, scroll, or focus state it keeps. They report presses (with
230
+ repeat and modifiers) and releases, moves `{ x, y }`, scroll positions, and
231
+ focus `{ tag, id }` — elements cross as tag and id, never as live nodes.
232
+ `matchHotkey("ctrl+shift+k", press)` answers whether a press is a shortcut,
233
+ so `update` stays a table of chords; matching is exact and auto-repeat never
234
+ matches. When the observed target itself depends on state, scope the entry
235
+ through subscription dependencies and it restreams on change. When the
236
+ handler must cancel the browser default, core
237
+ `Subscription.fromEventFilterMap` maps synchronously inside dispatch. Lift
238
+ with `Subscription.persistent`, mapping into the parent's Message; without
239
+ a window each stream is empty instead of throwing.
240
+
241
+ ## Observers: `foldkit-primitives/observers`
242
+
243
+ `Resize` and `Intersection` are Mounts, not bundles: element-scoped
244
+ observation attaches in views, not Model slots. Attach `Resize()` (or
245
+ `Intersection()`) with `h.OnMount` on the element. There are no ref objects
246
+ to thread: a Mount receives its element directly, and views take the rest
247
+ as plain arguments.
248
+
249
+ `Resize()` reports `Resized { width, height }` from the element's content
250
+ box; `Intersection()` reports `IntersectionChanged { isIntersecting, ratio }`
251
+ on viewport crossings; `Mutation()` reports `Mutated { type, added, removed,
252
+ attribute }` for child, attribute, and text changes across the whole subtree
253
+ — nodes cross as names, never as live objects, and unknown record types are
254
+ skipped. `Bounds()` re-measures `Measured { x, y, width, height }` on
255
+ observer, scroll, and resize, starting with the current rect; without a
256
+ ResizeObserver the window events still measure. Without the observer API
257
+ (SSR, old browser) they emit nothing instead of throwing; teardown
258
+ disconnects. They keep observing across time-travel pause — replay traffic
259
+ is same-valued and harmless.
260
+
261
+ ## Device: `foldkit-primitives/device`
262
+
263
+ `Geolocation` watches the device position while placed. Model `{ status,
264
+ coords, lastError }` with `status` unknown → ready; denial is its own status
265
+ (actionable UI), transient failures keep the last fix and note the error.
266
+ Permission code 1 maps to `Denied`, anything else to `Failed`. Without a
267
+ geolocation API the stream is empty instead of throwing.
268
+
269
+ `mediaDevices({ name, create? })` scans the device list on placement and
270
+ re-scans on `Scan`, `DevicesChanged` (wired to `devicechange`), and every
271
+ placement: `Refreshed { devices }` with `{ deviceId, groupId, kind, label }`.
272
+ Denial empties with status `denied`; other failures keep the last list and
273
+ note the error; an unknown `kind` fails the scan at the boundary instead of
274
+ entering the Model.
275
+
276
+ `mediaStream({ name, request? })` holds one live camera/mic stream in a
277
+ Managed Resource while the Model asks for it (`requesting` or `live`).
278
+ `Started` requests with the placed `{ audio, video }` constraints, `Stopped`
279
+ and `Ended` release; denial parks at `denied`, any other failure parks at
280
+ `idle` with the error — both clear requirements, so a failing device never
281
+ spins an acquire loop. Release stops every track. The `LiveStream` tag and
282
+ service are exported for commands that attach the stream to an element; one
283
+ assembly holds one stream.
284
+
285
+ `permissions({ name, create? })` queries `{ names }` on acquire and watches
286
+ each status object's `onchange` through an ordered queue into a persistent
287
+ subscription. `Snapshot` replaces the states map, `Changed` merges one,
288
+ `Cleared` empties on release (which also detaches every handler). Unknown
289
+ names and unknown state strings fail the acquire — never Model facts.
290
+
291
+ `enterFullscreen(element)` / `exitFullscreen()` are Commands yielding
292
+ `Entered`/`Exited` or `Failed` (rejected request, missing capability, with a
293
+ legacy `webkit` fallback); `fullscreenChanges()` starts with the current
294
+ answer then follows flips as `Changed { active }`. No Model: the document
295
+ owns fullscreen state.
296
+
297
+ ## DOM: `foldkit-primitives/dom`
298
+
299
+ `copyText` copies text as a Command: use it in `update` beside any bundle.
300
+ It yields `Copied` on success and `CopyFailed` otherwise — denial, insecure
301
+ context, or no clipboard API (SSR) all become the failure Message instead of
302
+ throwing. No Model involved: the clipboard is not application state.
303
+
304
+ `share(data)` posts `{ title?, text?, url? }` to the platform sheet: `Shared`
305
+ on success, `Dismissed` on sheet cancel (its own outcome, not a failure),
306
+ `ShareFailed` otherwise. `loadScript(src)` appends a head script unless one
307
+ carries the URL already — idempotent by URL, so concurrent placements
308
+ collapse onto the first tag and share its fate — yielding `Loaded` or
309
+ `LoadFailed`. A dead tag is removed, so a retry fetches afresh.
310
+
311
+ `Autofocus()` focuses the element on insert, then emits `Focused` (requested,
312
+ not landed: a non-focusable element may decline). `InputMask({ pattern })`
313
+ masks a field against `#`/`A`/`*` placeholders with literal separators,
314
+ rewrites the field with approximate caret restore, and emits
315
+ `Input { value, raw }` with masked and unmasked text. The parent owns the
316
+ state, like any controlled input.
317
+
318
+ ## State: `foldkit-primitives/state`
319
+
320
+ `Pagination` keeps `{ page, perPage, total }` in the Model, with `total: null`
321
+ while unknown. Every transition clamps into range: past the last page lands on
322
+ it, below one lands on one, and a smaller total pulls the page back. New sizes
323
+ and totals arrive as Messages (`SetPerPage` ignores a non-positive size, and
324
+ `SetTotal` ignores a negative total).
325
+ `pageCount` returns null while the total is unknown; `offset` gives the first
326
+ item's index for a slice or a query. Loading data stays the application's job:
327
+ this bundle owns the page, not the items.
328
+
329
+ `history({ name, value, capacity })` makes an undo/redo bundle over any value
330
+ Schema. The Model holds `{ past, present, future }`; `Push` records and drops
331
+ the redo future, `Undo`/`Redo` move one step, `Clear` empties both sides while
332
+ keeping the present. The past holds at most `capacity` entries (default 100);
333
+ a negative or fractional capacity throws at the factory, naming it. The
334
+ factory attaches the Message union, so placements dispatch
335
+ `EditHistory.Message.Push(...)`. `canUndo`/`canRedo` read the edges:
336
+
337
+ ```ts
338
+ import { history } from 'foldkit-primitives/state'
339
+
340
+ const EditHistory = history({ name: 'EditHistory', value: Schema.String, capacity: 50 })
341
+ const Doc = Bundle.declare(EditHistory, 'doc')
342
+ ```
343
+
344
+ `Locale` keeps one string in the Model, read from `navigator.language` at
345
+ startup with the configured `default` as fallback; `SetLocale` switches it.
346
+ `SelectionSet` keeps string ids in first-selection order: `Select` (keeps
347
+ position), `Deselect`, `Toggle` (re-appends), `ReplaceAll` (deduped), and
348
+ `Clear`. `isSelected` reads membership. Both are pure logic, no streams.
349
+ Keyed children — lists with stable identity — place through the bundle
350
+ mechanism's `each`.
351
+ `range(start, end, step?)` counts half-open numbers — the pagination page
352
+ list is `range(1, (pageCount(model) ?? 0) + 1)`; a zero or non-finite step
353
+ throws, naming it.
354
+
355
+ `Virtual` owns a virtualized list's scroll position, measured heights, and
356
+ layout: Model `{ scrollTop, heights, scrolling, generation, estimatedHeight,
357
+ overscan, gap, paddingStart, paddingEnd }`, Messages
358
+ `Scrolled`/`Measured`/`Prune`/`Settled`, args for the layout plus optional
359
+ `initialScrollTop`/`initialHeights` restores (measurements sanitized like
360
+ live ones) and a `settleMs` silence (default 150). Every scroll marks
361
+ `scrolling` until the silence settles — suspend loaders and parallax on it.
362
+ `Viewport` reports the container's own scrolls and `MeasureRow({ key })` reports row heights, both as Mounts;
363
+ `windowFor(model, keys, viewportHeight)` answers which rows to render plus
364
+ the spacer height, `isAtEnd(model, keys, viewportHeight, threshold)` is the
365
+ infinite-scroll check (an empty list counts as ended), and `offsetFor`
366
+ computes programmatic scroll targets the application actuates itself.
367
+ `Prune` drops heights for departed keys — the bundle never sees key order.
368
+ Poisoned positions and heights are ignored, never stored. For window-
369
+ scrolled lists, map the scroll entry into `Scrolled`; for follow-bottom,
370
+ hold the end while `isAtEnd` and scroll on extend; to anchor a prepend,
371
+ re-`Scrolled` by the totals' delta. Render each row keyed (with
372
+ `aria-rowcount`/`posinset` from the window) so per-row placements keep
373
+ identity. `stickyHeader(sections, start)` answers which section header
374
+ sticks — CSS `position: sticky` does the sticking. `masonry(keys, heights,
375
+ options)` packs fixed-width columns shortest-first into `{ placements,
376
+ totalHeight }`: layout only, every placed item renders, so it fits hundreds
377
+ of images rather than hundred-thousands. The sums never name an
378
+ axis: pass column widths as heights and a horizontal offset as scroll
379
+ position to window a carousel the same way — no parallel horizontal
380
+ bundle. Windowed grids and per-index estimates stay out by design.
381
+
382
+ Persisted state lives one package over: `Mirror.kv(App, { key, fields })`
383
+ keeps a Model slice in Effect's `KeyValueStore` (localStorage in the
384
+ browser), restored through a `MirrorRestored` Message the application
385
+ reduces. Nothing here duplicates it — reach for the mirror when a slice
386
+ should survive reload, and keep this package's bundles for live facts.
387
+
388
+ ## With Surface and Mirror
389
+
390
+ Placed state is ordinary Model, so the surrounding tools apply unchanged —
391
+ no bundle-specific Surface or Mirror API exists, by design. Declare a Surface
392
+ over the placed fields to render them or expose them to an agent; point
393
+ `Mirror.url` at them to link them; spread the bundle's cases into the same
394
+ unions. The field refs and wrapper Messages are the same ones the rest of the
395
+ application uses.
396
+
397
+ ## Motion: `foldkit-primitives/motion`
398
+
399
+ `Tween` animates one number from `from` to `to` over `ms` milliseconds.
400
+ Model `{ value, running }`, Messages `Started`/`Ticked`/`Finished`, args
401
+ `{ from, to, ms }` (a non-positive or non-finite duration is rejected at
402
+ placement). Progress comes from Effect's clock, so tests advance it with TestClock; the
403
+ stream ends with `Finished` carrying the exact end value, and the value rests
404
+ at `to` either way. Linear interpolation only: easing curves stay the
405
+ application's job.
406
+
407
+ `Presence` holds mount-transition state for exit animations. Model
408
+ `{ phase, generation }` with `phase` moving shown → hiding → hidden:
409
+ `Hide` starts the timed `hiding` phase, and the `Hidden` fact it yields
410
+ carries its generation, so a `Show` in between wins and the late fact is
411
+ ignored. Args `{ durationMs }` (positive and finite). `isVisible` reads
412
+ whether content renders (shown or mid-exit). The timeout Command is the default owner; a
413
+ `transitionend` Mount stays a future opt-in, not a second timer.
414
+
415
+ `Spring` pulls one number toward `to` with `{ stiffness, damping }` physics
416
+ (positive and finite): Model `{ value, velocity, running }`, Messages
417
+ `Started`/`Stopped`/`Ticked`/`Finished`, args `{ from, to, stiffness,
418
+ damping }`. Fixed 16ms semi-implicit Euler makes the trajectory identical on
419
+ the live clock and TestClock; the stream ends with `Finished` carrying the
420
+ exact end value even when an underdamped spring overshoots on the way.
421
+ `Tween` (fixed duration, linear) versus `Spring` (physics, settles) — pick
422
+ the motion, not both.
423
+
424
+ ## Testing placements
425
+
426
+ Every primitive is testable without the platform, following one pattern
427
+ with three ingredients. First, substitute the environment: factories take
428
+ `create` (streams, devices, permissions) or `request` (camera) doubles, so
429
+ tests pass fakes instead of stubbing globals. Second, advance time instead
430
+ of waiting it: anything on Effect's clock (debounce, throttle, presence,
431
+ idle, timers, tweens) runs under `TestClock.adjust`. Third, never hang on a
432
+ quiet stream: collect with a bounded `takeMessages`, which fails fast naming
433
+ the stall. The settle-before-adjust rule applies throughout — yield after
434
+ forking before the first `TestClock.adjust`, or dispatched events hit
435
+ unregistered listeners. Each ingredient is demonstrated in this package's
436
+ `test/` directory, named after its primitive.
437
+
438
+ ## Failure and recovery
439
+
440
+ | Failure | Behaviour |
441
+ | --- | --- |
442
+ | No `window` (SSR) or no API (old browser, minimal DOM) | stream is empty; the slice keeps its default |
443
+ | A Command without its platform API | failure Message (`CopyFailed`, `ShareFailed`, `BroadcastFailed`), never a throw |
444
+ | Listener removed (unmount, gate closed) | finalizer disconnects; resubscribing re-reads the current value |
445
+ | A Message for an unplaced child | never routes: wrappers only match placed variants |
446
+ | A failing device (denied camera, unknown permission) | parks at `denied`/`idle` with requirements cleared: no acquire loop |
447
+
448
+ ## Limits
449
+
450
+ - One placement observes one query. Two placements of `MediaQuery` with the
451
+ same query open two listeners; share the field instead.
452
+ - Presets cover the common queries. Anything else passes `args` explicitly.
453
+ - `/plus` Surfaces and Mirrors per primitive are future work; declare them in
454
+ the application for now.