foldkit-primitives 0.3.0 → 0.4.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 +202 -13
  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-DpxwLXvU.mjs +800 -0
  16. package/dist/follow-tab-stop-DpxwLXvU.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 +57 -15
  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 +16 -5
  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
@@ -50,6 +50,12 @@ A bundle holds no state and performs no I/O by itself. The parent Model owns
50
50
  the placed slice; the browser (or server, or clock) only reports facts as
51
51
  Messages. The same rule as everywhere else: observation is not ownership.
52
52
 
53
+ A Message that changes nothing (a repeated resize, `Started` while running,
54
+ a total that is already held) returns the Model it was given, and a
55
+ placement then returns the parent unchanged. Foldkit renders only when the
56
+ root Model changes identity, so such a Message renders nothing, and a
57
+ `createLazy` view over a placed slice is skipped while the slice is the same.
58
+
53
59
  Solid developers will notice missing plumbing: there is no event bus
54
60
  because Messages are the bus, no memo because derivations are pure reads
55
61
  over the Model, and no reactive map or store because the Model holds plain
@@ -91,11 +97,11 @@ Each subpath is one concern, one import:
91
97
  - `time` — clock facts: Timer, Interval, Debounce, Throttle, relative time
92
98
  - `state` — owned UI state: Pagination, History, Locale, SelectionSet, Virtual, range
93
99
  - `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
100
+ - `interaction` — a Bundle (or Mount) and its `foldkit-mixins` Behavior: RovingTabindex, Typeahead, ListNavigation, GridNavigation, TreeNavigation, FocusScope, Press, LongPress, Move, Targets, PointerDrag, EditableText, FocusVisible, DismissLayer, ScrollLock, HideOutside, Selection, LiveAnnounce
95
101
  - `device` — hardware: Geolocation, MediaDevices, MediaStream, Permissions, Fullscreen
96
102
  - `events` — raw browser events: Visibility, WindowSize, Idle, InputModality, keyboard, pointer, scroll, focus
97
103
  - `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
104
+ - `dom` — element Mounts and one-shot Commands: Autofocus, FocusScope, KeepInView, Measure, Move, Targets, PointerDrag, EditableText, ScrollLock, HideOutside, InputMask, clipboard, share, script loading
99
105
 
100
106
  ## Sixty seconds: follow the color scheme
101
107
 
@@ -207,8 +213,12 @@ SSE spec.
207
213
  holds `{ url, status, lastError }` with `status` moving
208
214
  closed → connecting → open. The resource owns the socket (one assembly holds
209
215
  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.
216
+ and yields `Sent { data }` on dispatch, carrying what was sent, and
217
+ `SendFailed` when no connection is open — a closed socket's `send` is a
218
+ silent no-op per spec, so the bundle checks first. Args `{ url,
219
+ connectTimeoutMs? }`: with a timeout, a socket still connecting when it runs
220
+ out is closed and reported as `TimedOut` (status closed, `lastError` set);
221
+ without one, an attempt waits as long as the browser does.
212
222
  Incoming `Received` notifies without storing: project the payload into your
213
223
  own field to keep it. `createSocket` defaults to the platform WebSocket, read
214
224
  lazily so tests substitute a double; the socket service rides the assembly
@@ -230,14 +240,41 @@ the Command fails, instead of throwing.
230
240
  `Started`/`Stopped`/`Ticked`, args `{ intervalMs }` (positive and finite —
231
241
  anything else is rejected at placement). The tick stream runs on
232
242
  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.
243
+ stopped the stream is empty. The first tick comes one interval after
244
+ `Started`. Restarting keeps the count; only `Ticked` advances it.
235
245
 
236
246
  `Interval` is the wall-clock sibling: Model `{ running, lastAt }`,
237
247
  `Ticked { at }` stamped from Effect's clock. `Timer` counts ticks,
238
248
  `Interval` records when — views render clocks and elapsed times from
239
249
  `lastAt`. Same args, same TestClock story, same silence while stopped.
240
250
 
251
+ Both own their `running` flag, at an interval fixed where they are placed.
252
+ When the parent's Model already decides whether the clock runs and how fast
253
+ (a game's phase and its score), a second flag would be a second owner of that
254
+ fact. `ticks` is the entry for that case: `intervalMs` reads the interval from
255
+ the Model, `None` while stopped, and `onTick` makes the parent's Message with
256
+ the tick's time:
257
+
258
+ ```ts
259
+ import { Option } from 'effect'
260
+ import * as Subscription from 'foldkit/subscription'
261
+ import { ticks } from 'foldkit-primitives/time'
262
+
263
+ const subscriptions = Subscription.make<Game, GameMessage>()(() => ({
264
+ clock: ticks({
265
+ intervalMs: (game: Game) =>
266
+ game.playing ? Option.some(Math.max(80, 150 - game.points)) : Option.none(),
267
+ onTick: () => GameMessage.TickedClock(),
268
+ }),
269
+ }))
270
+ ```
271
+
272
+ A Model change that leaves the interval alone does nothing. A new interval
273
+ applies from the next tick, without restarting the clock, so a game that
274
+ speeds up keeps its rhythm instead of ticking at once; `None` stops it, and
275
+ `Some` again starts afresh, one interval before the first tick. `Timer` and
276
+ `Interval` are built on it.
277
+
241
278
  `debounce({ name, value })` is a factory over any value Schema (like
242
279
  `history`): `Changed` restarts a `{ delayMs }` timer, and only the latest
243
280
  value settles — as an OutMessage the placement must handle with `onOut`, so
@@ -285,9 +322,11 @@ focus `{ tag, id }` — elements cross as tag and id, never as live nodes.
285
322
  `matchHotkey("ctrl+shift+k", press)` answers whether a press is a shortcut,
286
323
  so `update` stays a table of chords; matching is exact and auto-repeat never
287
324
  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
325
+ through subscription dependencies and it restreams on change.
326
+ `keyboardEvents({ preventDefault: press => ... })` cancels the default of the
327
+ presses the predicate picks (the arrows scrolling a game's page,
328
+ `matchHotkey('ctrl+s', press)`), decided in the listener, because a default
329
+ can only be cancelled while the event is dispatching. Lift
291
330
  with `Subscription.persistent`, mapping into the parent's Message; without
292
331
  a window each stream is empty instead of throwing.
293
332
 
@@ -357,7 +396,10 @@ owns fullscreen state.
357
396
  `copyText` copies text as a Command: use it in `update` beside any bundle.
358
397
  It yields `Copied` on success and `CopyFailed` otherwise — denial, insecure
359
398
  context, or no clipboard API (SSR) all become the failure Message instead of
360
- throwing. No Model involved: the clipboard is not application state.
399
+ throwing. `readText()` reads it the same way, yielding a
400
+ `ClipboardReadMessage`: `Read { text }`, or `ReadFailed` when the browser
401
+ refuses. No Model involved: the clipboard is not
402
+ application state.
361
403
 
362
404
  `share(data)` posts `{ title?, text?, url? }` to the platform sheet: `Shared`
363
405
  on success, `Dismissed` on sheet cancel (its own outcome, not a failure),
@@ -373,6 +415,26 @@ rewrites the field with approximate caret restore, and emits
373
415
  `Input { value, raw }` with masked and unmasked text. The parent owns the
374
416
  state, like any controlled input.
375
417
 
418
+ `KeepInView({ selector })` keeps what is marked in view: whenever an element in
419
+ its subtree newly matches `selector` (the row just selected, the node just
420
+ inserted), it is scrolled into view the least amount that shows it. It sends no
421
+ Message and redraws nothing, so a focused row stays focused. Where there is no
422
+ layout (jsdom), it scrolls nothing.
423
+
424
+ `Measure({ targets: { selected: '[aria-selected="true"]' } })` measures, for
425
+ each named target, the first element in its subtree that matches, relative to
426
+ its scroll box, and writes `--fk-selected-x`, `-y`, `-w`, `-h` (pixels) and
427
+ `--fk-selected-display` (`block`, or `none` while nothing matches) on the
428
+ element (`measured('selected')` names them). A child placed absolutely from
429
+ them, such as an editor's selection outline, sits over the target and scrolls
430
+ with it. Where an element is, is presentation: it sends no Message and keeps
431
+ nothing in the Model. It measures again when the subtree changes, as the
432
+ records arrive, so the box moves in the frame the change is drawn in; and, at
433
+ most once a frame, when the element scrolls, when it or a target changes size,
434
+ when the window resizes, when an image or a font inside loads, and on each
435
+ frame of a transition or an animation inside. Anything else that moves a
436
+ target without resizing it (a stylesheet added) is seen at the next of these.
437
+
376
438
  ## State: `foldkit-primitives/state`
377
439
 
378
440
  > Reference: [`./state/README.md`](./state/README.md)
@@ -387,9 +449,18 @@ item's index for a slice or a query. Loading data stays the application's job:
387
449
  this bundle owns the page, not the items.
388
450
 
389
451
  `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);
452
+ Schema. The Model holds `{ past, present, future, group }`; `Push` records and
453
+ drops the redo future (an empty future is kept as it was, so a view reading it
454
+ is not drawn again per push), `Undo`/`Redo` move one step, `GoTo { step }`
455
+ jumps to any kept value, counted from the oldest (0) so the present is at
456
+ `past.length`, and `Clear` empties both sides while keeping the present. A `Push` may name a `group`: consecutive pushes of
457
+ the same group are one step, so typing a word undoes as a whole, with no clock.
458
+ The steps are also pure functions, `History.start`, `push`, `undo`, `redo`,
459
+ `goTo` and `clear`, for a parent that records an edit in the same transition that makes it
460
+ (the page Builder keeps its page this way). Two more are only functions:
461
+ `History.close(model)` ends the group, so the same group pushed again is a step
462
+ of its own, and `History.revert(model, group)` takes back the step `group` is
463
+ making with nothing left to redo, which is what a cancelled edit leaves. The past holds at most `capacity` entries (default 100);
393
464
  a negative or fractional capacity throws at the factory, naming it. The
394
465
  factory attaches the Message union, so placements dispatch
395
466
  `EditHistory.Message.Push(...)`. `canUndo`/`canRedo` read the edges:
@@ -425,6 +496,11 @@ the spacer height, `isAtEnd(model, keys, viewportHeight, threshold)` is the
425
496
  infinite-scroll check (an empty list counts as ended), `distanceToEnd`
426
497
  answers the pixels remaining for prefetch thresholds, and `offsetFor`
427
498
  computes programmatic scroll targets the application actuates itself.
499
+ These share one table of row offsets, built once per `keys` array and
500
+ `heights` record and then searched, so keep the keys array between renders
501
+ (derive it where the list changes, not in the view) and a scroll costs
502
+ O(log n) instead of a pass over every row. Both are compared by identity:
503
+ never change a keys array in place.
428
504
  `Prune` drops heights for departed keys — the bundle never sees key order.
429
505
  Poisoned positions and heights are ignored, never stored. For window-
430
506
  scrolled lists, map the scroll entry into `Scrolled`; for follow-bottom,
@@ -616,6 +692,39 @@ const Keys = GridNavigation.behavior(Cells, args)(CalendarSlots)<Model, Message>
616
692
  })
617
693
  ```
618
694
 
695
+ `TreeNavigation` is the tree counterpart, after the WAI-ARIA tree pattern,
696
+ for a file explorer, a page's layers, or a nested menu: Up and Down through
697
+ the rows that are showing, Right opens a row or steps into it, Left closes it or
698
+ steps out to its parent, Home and End go to the first and last row. The view
699
+ gives every row, open or not, in tree order, each with its `parent` and whether
700
+ it is a `branch`; `TreeNavigation.shown(rows, model, args)` is the rows that are
701
+ showing, with their level and place among their siblings. The Model slice is
702
+ `{ current, toggled }`: openness is stored as the rows toggled away from
703
+ `openByDefault`, so a layers panel that starts open and a file tree that starts
704
+ closed are one bundle with a different arg. The Behavior writes each showing
705
+ row's `id` (through `domId`, by default the row's own), `role="treeitem"`,
706
+ `aria-level`, `aria-posinset`, `aria-setsize`, `aria-expanded` on a branch,
707
+ `aria-disabled`, a roving `tabindex` and `OnFocus`; the container's keys move
708
+ focus by id, or open and close the current row in place. Focus in the tree
709
+ follows its stop when a transition it did not see moves it or removes the
710
+ focused row (the `FollowTabStop` Mount, in `foldkit-primitives/dom`, which
711
+ never takes focus from outside the container). The level is also the
712
+ custom property `--fk-tree-level`, 1 at the top, so one rule indents any depth:
713
+ `padding-inline-start: calc(var(--fk-tree-level) * 1rem)`.
714
+
715
+ ```ts
716
+ const Layers = Bundle.declare(TreeNavigation.bundle, 'layers')
717
+ // place with { args: { openByDefault: true } }
718
+ const Keys = TreeNavigation.behavior(Layers, { openByDefault: true })(TreeSlots)<Model, Message>({
719
+ container: 'tree',
720
+ item: 'row',
721
+ rows: model => model.folders.map(folder => ({ id: folder.id, parent: folder.parent, branch: folder.hasChildren })),
722
+ })
723
+ ```
724
+
725
+ The pure `move(shown, current, key, modifiers, model, args, direction?)` answers
726
+ with `Focus`, `Open` or `Close`, for a view that wires its own.
727
+
619
728
  `FocusScope` is the one entry here that is a Mount, not a Bundle: which
620
729
  element has focus is a DOM fact, so nothing crosses to the Model. The Mount
621
730
  lives in `foldkit-primitives/dom`; `FocusScope.behavior(Slots)<Input,
@@ -680,6 +789,86 @@ and a secondary button are ignored; capture is released with the Mount.
680
789
  `Draggable` slot and maps each fact into the view's Messages; a drag's meaning
681
790
  (a threshold, a snap, a reorder) is the parent's `update`.
682
791
 
792
+ `Targets` is which marked descendant of a container the pointer is over, and
793
+ which one was pressed, as one Mount on the container rather than one per item.
794
+ A descendant is marked by an attribute holding its id (a canvas's
795
+ `data-composition-node`, a table's `data-row`), and the nearest marked ancestor
796
+ of the event's target, inside the container, is the one reported:
797
+ `TargetHovered { id }` once per change, `null` when there is none or the
798
+ pointer leaves, and `TargetPressed { id, shiftKey, altKey, ctrlKey, metaKey }`
799
+ on a click. `preventDefault: true` stops a press's default, such as a link
800
+ navigating inside an editor's canvas. The Mount is `Targets({ attribute,
801
+ preventDefault })` in `foldkit-primitives/dom`; `Targets.behavior(Slots)<Input,
802
+ Message>({ container, attribute, preventDefault?, toMessage })` attaches it.
803
+ `targetOf(container, from, attribute)` is the pure lookup.
804
+
805
+ `EditableText` is text typed into a marked descendant of a container while it
806
+ is `contenteditable`, as one Mount on the container. The view decides which
807
+ field is editable; the Mount focuses it as it becomes so (made editable, or
808
+ drawn editable), with the caret at its end, and at no other time: a field
809
+ editable all along, or another going away, moves no focus. It reads what is
810
+ typed:
811
+
812
+ - **Text, never markup.** It reads `innerText`. A field is one line unless it
813
+ carries `aria-multiline="true"`, and in one line a line break becomes a
814
+ space. Where the browser lacks `contenteditable="plaintext-only"`, a paste is
815
+ inserted as its text.
816
+ - **`TextEdited { field, text }`** on each change, `field` being the marking
817
+ attribute's value. Nothing is reported while an input method composes; the
818
+ composed text arrives once.
819
+ - **`TextCommitted { field, text }`** on Enter (Shift+Enter breaks a line in a
820
+ multiline field) or on leaving the field; **`TextCancelled { field, initial }`**
821
+ on Escape, which also puts the text the field had when the edit began (on
822
+ focus, or on the first keystroke after an edit ended) back in the DOM, the
823
+ caret at its end. A view that stopped redrawing the field while it was edited
824
+ would not. An edit ends once: the blur after Enter commits nothing more.
825
+ - **Focus comes back to the container** when Enter or Escape ended an edit and
826
+ the view then removes the field or makes it no longer editable, which leaves
827
+ focus on nothing; give the container a `tabindex`, and the next key (an
828
+ undo) reaches it. A field left by the author keeps no such claim.
829
+ - **`EditAsked { field }`** on a double-click over a marked field that is not
830
+ editable yet: the view's cue to make it so.
831
+
832
+ The Mount is `EditableText({ attribute })` in `foldkit-primitives/dom`;
833
+ `EditableText.behavior(Slots)<Input, Message>({ container, attribute, toMessage })`
834
+ attaches it. What a change means (a prop set, an undo group) is the parent's
835
+ `update`.
836
+
837
+ `PointerDrag` is dragging one marked descendant onto another, marked the way
838
+ `Targets` marks them. A primary press on one that moves more than
839
+ `DRAG_THRESHOLD` (4px) starts a drag (`DragStarted { id }`); then, once per
840
+ change, `DraggedOver { over }` says which other marked descendant the pointer
841
+ is over and in which third of its box, `{ id, zone: 'before' | 'inside' |
842
+ 'after' }`, or `null`. Releasing is `DragDropped { id, over }`, and Escape, a
843
+ cancelled pointer, or a button found released mid-drag is `DragCancelled { id }`.
844
+ An Escape that cancels is heard on the way down and goes no further, so the
845
+ focused element's own Escape (a canvas's deselect) does not also run.
846
+ It follows the pointer that pressed and ignores a second one; the element under
847
+ it is found by position, so a touch or a pen, which the browser captures to
848
+ where it went down, drags too, once the marked elements have `touch-action:
849
+ none` so a finger drags rather than scrolls. The click a drop ends with is
850
+ swallowed, so a `Targets` on the same container does not also press. A press
851
+ in `contenteditable` text selects text and starts no drag. It writes
852
+ no roles, `tabindex` or keys, so it sits beside a tree's or a listbox's own;
853
+ the keyboard's way to do what a drag does is yours to give. Boxes are measured
854
+ as the pointer moves and never kept; an element drawn as `display: contents`
855
+ is measured by its first child (`boxOf`). The Mount is
856
+ `PointerDrag({ attribute })` in `foldkit-primitives/dom`;
857
+ `PointerDrag.behavior(Slots)<Input, Message>({ container, attribute, toMessage })`
858
+ attaches it, and `zoneOf(box, y)` is the pure split.
859
+
860
+ A drag may also land somewhere other than among its own: with
861
+ `targets: { attribute: 'data-composition-node', within: '#page' }`, what is
862
+ dragged is still one of the container's marked descendants (a palette's
863
+ tiles), and `over` is an element marked by that attribute inside the one
864
+ `within` selects (the page's nodes). Its own tiles, and a matching element
865
+ outside that one, are then over nothing. `within` is looked for nearest
866
+ first, under the container's closest ancestor that holds a match, so two
867
+ editors on one page each drop onto their own page. Such a drag's
868
+ `DraggedOver` and `DragDropped` also carry `region`: whether the pointer is
869
+ inside that element, so `over: null` with `region: true` is its empty space
870
+ (an empty page, the space below its last node), and with `false`, elsewhere.
871
+
683
872
  `FocusVisible` is the one entry whose Bundle lives elsewhere: `InputModality`
684
873
  in `foldkit-primitives/events` keeps `{ modality }` (`'keyboard'`, `'pointer'`,
685
874
  or `'unknown'` before any input), fed by the window's `keydown` (a modifier
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/device/fullscreen.ts","../../src/device/geolocation.ts","../../src/device/media-devices.ts","../../src/device/media-stream.ts","../../src/device/permissions.ts"],"mappings":";;;;;qBAWa,6CAAiB;;;;;;;;;;YAMlB,2BAA2B,kBAAkB;;;;;;;qBAW5C,kBAAmB,SAAS,YAAU,QAAQ;;qBA0B9C,sBAAqB,QAAQ;;;;;qBAgC7B,yBAAwB,OAAO,OAAO;;;qBC1EtC,aAAW,OAAA;;;;;YAKZ,qBAAqB,YAAY;qBAEhC,kBAAgB,OAAA;;;;;;;;;YAKjB,0BAA0B,iBAAiB;qBAE1C,8CAAkB;;;;;;;;;;;YASnB,4BAA4B,mBAAmB;qBAmD9C,aAAW,OAAA;;;;;;;;;;;;;;;;;;;;;qBCxEX,aAAW,OAAA;;;;;;YAMZ,qBAAqB,YAAY;qBAEhC,mBAAiB,OAAA;;;;;;;;;;YAKlB,2BAA2B,kBAAkB;qBAE5C,+CAAmB;;;;;;;;;;;;;;;;YAOpB,6BAA6B,oBAAoB;;iBAG5C;EACf,oBAAoB,QAClB;aACW;aACA;aACA;aACA;;EAGb,iBAAiB,cAAc;EAC/B,oBAAoB,cAAc;;qBAUvB,qBAAsB,qBAAqB;WAC7C,MAAM;;WAEN,WAAW,WAAW,cAAc;MAC9C,OAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBChDY,kBAAgB,OAAA;;;;YAIjB,0BAA0B,iBAAiB;qBAE1C,8CAAkB;;;;;;;sBAAlB,OAAA;;;YAQD,4BAA4B,mBAAmB;;iBAG1C;EACf;;;iBAIe;EACf,aAAa,cAAc;;iBAGZ;WACN;WACA;;;qBAIE,YAAU,gBAAA,gBAAA,mBAAA,gBAAA;;YAGX,oBAAoB,gBAAgB,iBAAiB;qBAoBpD,oBAAqB,qBAAqB;WAC5C,MAAM;;WAEN,YACL,aAAa,2BAA2B,QAAQ;MACrD,OAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBC5DY,iBAAe,OAAA;YAChB,yBAAyB,gBAAgB;qBAExC,kBAAgB,OAAA;;;;YAIjB,0BAA0B,iBAAiB;qBAE1C,8CAAkB;;;;;;;;;;;;;YAMnB,4BAA4B,mBAAmB;;iBAG1C;WACN;EACT;;;iBAIe;EACf,MAAM;aAAuB;MAAiB,QAAQ;;UAG9C;WACC,UAAU,oBAAoB;WAC9B,UAAU,SAAS,eAAe;WAClC,QAAQ,MAAM,MAAM;;;qBAIlB,OAAK,gBAAA,gBAAA,UAAA,gBAAA;;YAGN,eAAe,gBAAgB,iBAAiB;qBAuB/C,oBAAqB,qBAAqB;WAC5C,MAAM;;WAEN,WAAW,WAAW,cAAc;MAC9C,OAAA,OAAA;WAYqB,OAAA;;;;;;;;;;;;;;;;;;;;;EApCb,OAAA,gBAAgB,MACrB,kBACA,oBACA,OAAO,OAAO,wBACd,UACA,eACC,UAAU,aAAa"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/device/fullscreen.ts","../../src/device/geolocation.ts","../../src/device/media-devices.ts","../../src/device/media-stream.ts","../../src/device/permissions.ts"],"mappings":";;;;;qBAWa,6CAAiB;;;;;;;;;;YAMlB,2BAA2B,kBAAkB;;;;;;;qBAW5C,kBAAmB,SAAS,YAAU,QAAQ;;qBA0B9C,sBAAqB,QAAQ;;;;;qBAgC7B,yBAAwB,OAAO,OAAO;;;qBCzEtC,aAAW,OAAA;;;;;YAKZ,qBAAqB,YAAY;qBAEhC,kBAAgB,OAAA;;;;;;;;;YAKjB,0BAA0B,iBAAiB;qBAE1C,8CAAkB;;;;;;;;;;;YASnB,4BAA4B,mBAAmB;qBAmD9C,aAAW,OAAA;;;;;;;;;;;;;;;;;;;;;qBCxEX,aAAW,OAAA;;;;;;YAMZ,qBAAqB,YAAY;qBAEhC,mBAAiB,OAAA;;;;;;;;;;YAKlB,2BAA2B,kBAAkB;qBAE5C,+CAAmB;;;;;;;;;;;;;;;;YAOpB,6BAA6B,oBAAoB;;iBAa5C;EACf,oBAAoB,QAClB;aACW;aACA;aACA;aACA;;EAGb,iBAAiB,cAAc;EAC/B,oBAAoB,cAAc;;qBAUvB,qBAAsB,qBAAqB;WAC7C,MAAM;;WAEN,WAAW,WAAW,cAAc;MAC9C,OAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBC1DY,kBAAgB,OAAA;;;;YAIjB,0BAA0B,iBAAiB;qBAE1C,8CAAkB;;;;;;;sBAAlB,OAAA;;;YAQD,4BAA4B,mBAAmB;;iBAG1C;EACf;;;iBAIe;EACf,aAAa,cAAc;;iBAGZ;WACN;WACA;;;qBAIE,YAAU,gBAAA,gBAAA,mBAAA,gBAAA;;YAGX,oBAAoB,gBAAgB,iBAAiB;qBAoBpD,oBAAqB,qBAAqB;WAC5C,MAAM;;WAEN,YACL,aAAa,2BAA2B,QAAQ;MACrD,OAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBC5DY,iBAAe,OAAA;YAChB,yBAAyB,gBAAgB;qBAExC,kBAAgB,OAAA;;;;YAIjB,0BAA0B,iBAAiB;qBAE1C,8CAAkB;;;;;;;;;;;;;YAMnB,4BAA4B,mBAAmB;;iBAG1C;WACN;EACT;;;iBAIe;EACf,MAAM;aAAuB;MAAiB,QAAQ;;UAG9C;WACC,UAAU,oBAAoB;WAC9B,UAAU,SAAS,eAAe;WAClC,QAAQ,MAAM,MAAM;;;qBAIlB,OAAK,gBAAA,gBAAA,UAAA,gBAAA;;YAGN,eAAe,gBAAgB,iBAAiB;qBAuB/C,oBAAqB,qBAAqB;WAC5C,MAAM;;WAEN,WAAW,WAAW,cAAc;MAC9C,OAAA,OAAA;WAYqB,OAAA;;;;;;;;;;;;;;;;;;;;;EApCb,OAAA,gBAAgB,MACrB,kBACA,oBACA,OAAO,OAAO,wBACd,UACA,eACC,UAAU,aAAa"}
@@ -1,3 +1,4 @@
1
+ import { i as unlessSame } from "../internal-DP-xLKbT.mjs";
1
2
  import { Effect, Option, Queue, Schema, Stream } from "effect";
2
3
  import { defineMessageUnion } from "foldkit/message";
3
4
  import * as Subscription from "foldkit/subscription";
@@ -127,7 +128,7 @@ const Geolocation = Bundle.make("Geolocation", {
127
128
  lastError: null
128
129
  } }),
129
130
  update: (model, message) => GeolocationMessage.match(message, {
130
- Located: ({ latitude, longitude, accuracy }) => ({ model: {
131
+ Located: ({ latitude, longitude, accuracy }) => model.status === "ready" && model.coords !== null && model.coords.latitude === latitude && model.coords.longitude === longitude && model.coords.accuracy === accuracy ? { model } : { model: {
131
132
  ...model,
132
133
  status: "ready",
133
134
  coords: {
@@ -135,15 +136,15 @@ const Geolocation = Bundle.make("Geolocation", {
135
136
  longitude,
136
137
  accuracy
137
138
  }
138
- } }),
139
- Denied: () => ({ model: {
139
+ } },
140
+ Denied: () => ({ model: unlessSame(model, {
140
141
  ...model,
141
142
  status: "denied"
142
- } }),
143
- Failed: ({ message }) => ({ model: {
143
+ }) }),
144
+ Failed: ({ message }) => ({ model: unlessSame(model, {
144
145
  ...model,
145
146
  lastError: message
146
- } })
147
+ }) })
147
148
  }),
148
149
  subscriptions: () => Subscription.make()(() => ({ watch: Subscription.persistent(watchStream()) }))
149
150
  });
@@ -183,6 +184,7 @@ const MediaDevicesMessage = defineMessageUnion({
183
184
  Denied: {},
184
185
  Failed: { message: Schema.String }
185
186
  });
187
+ const sameDevices = (a, b) => a.length === b.length && a.every((device, index) => device.deviceId === b[index].deviceId && device.groupId === b[index].groupId && device.kind === b[index].kind && device.label === b[index].label);
186
188
  const failMessage$2 = (error) => error instanceof Error ? error.message : String(error);
187
189
  const isDenied$1 = (error) => typeof error?.name === "string" && ["NotAllowedError", "SecurityError"].includes(error.name);
188
190
  const mediaDevices = (config) => {
@@ -239,19 +241,19 @@ const mediaDevices = (config) => {
239
241
  model,
240
242
  commands: [scan()]
241
243
  }),
242
- Refreshed: ({ devices }) => ({ model: {
244
+ Refreshed: ({ devices }) => model.status === "ready" && sameDevices(model.devices, devices) ? { model } : { model: {
243
245
  ...model,
244
246
  status: "ready",
245
- devices: [...devices]
246
- } }),
247
- Denied: () => ({ model: {
247
+ devices
248
+ } },
249
+ Denied: () => ({ model: unlessSame(model, {
248
250
  ...model,
249
251
  status: "denied"
250
- } }),
251
- Failed: ({ message }) => ({ model: {
252
+ }) }),
253
+ Failed: ({ message }) => ({ model: unlessSame(model, {
252
254
  ...model,
253
255
  lastError: message
254
- } })
256
+ }) })
255
257
  }),
256
258
  subscriptions: () => Subscription.make()(() => ({ changes: Subscription.persistent(changedStream()) }))
257
259
  });
@@ -306,32 +308,32 @@ const mediaStream = (config) => {
306
308
  lastError: null
307
309
  } }),
308
310
  update: (model, message) => MediaStreamMessage.match(message, {
309
- Started: () => ({ model: {
311
+ Started: () => model.status === "requesting" || model.status === "live" ? { model } : { model: {
310
312
  ...model,
311
313
  status: "requesting"
312
- } }),
313
- Stopped: () => ({ model: {
314
+ } },
315
+ Stopped: () => ({ model: unlessSame(model, {
314
316
  ...model,
315
317
  status: "idle"
316
- } }),
317
- Live: () => ({ model: {
318
+ }) }),
319
+ Live: () => ({ model: unlessSame(model, {
318
320
  ...model,
319
321
  status: "live",
320
322
  lastError: null
321
- } }),
322
- Ended: () => ({ model: {
323
+ }) }),
324
+ Ended: () => ({ model: unlessSame(model, {
323
325
  ...model,
324
326
  status: "idle"
325
- } }),
326
- Denied: () => ({ model: {
327
+ }) }),
328
+ Denied: () => ({ model: unlessSame(model, {
327
329
  ...model,
328
330
  status: "denied"
329
- } }),
330
- Failed: ({ message }) => ({ model: {
331
+ }) }),
332
+ Failed: ({ message }) => ({ model: unlessSame(model, {
331
333
  ...model,
332
334
  status: "idle",
333
335
  lastError: message
334
- } })
336
+ }) })
335
337
  }),
336
338
  resources: (args) => ManagedResource.make()((entry) => ({ stream: entry(Schema.Option(Schema.Struct({
337
339
  audio: Schema.Boolean,
@@ -405,79 +407,85 @@ const permissions = (config) => {
405
407
  lastError: null
406
408
  } }),
407
409
  update: (model, message) => PermissionsMessage.match(message, {
408
- Snapshot: ({ states }) => ({ model: {
409
- ...model,
410
- states: { ...states }
411
- } }),
412
- Changed: ({ name, state }) => ({ model: {
410
+ Snapshot: ({ states }) => {
411
+ const next = unlessSame(model.states, { ...states });
412
+ return next === model.states ? { model } : { model: {
413
+ ...model,
414
+ states: next
415
+ } };
416
+ },
417
+ Changed: ({ name, state }) => Object.hasOwn(model.states, name) && model.states[name] === state ? { model } : { model: {
413
418
  ...model,
414
419
  states: {
415
420
  ...model.states,
416
421
  [name]: state
417
422
  }
418
- } }),
419
- Cleared: () => ({ model: {
423
+ } },
424
+ Cleared: () => Object.keys(model.states).length === 0 ? { model } : { model: {
420
425
  ...model,
421
426
  states: {}
422
- } }),
423
- Failed: ({ message }) => ({ model: {
427
+ } },
428
+ Failed: ({ message }) => ({ model: unlessSame(model, {
424
429
  ...model,
425
430
  lastError: message
426
- } })
431
+ }) })
427
432
  }),
428
- resources: (args) => ManagedResource.make()((entry) => ({ watch: entry(Schema.Option(Schema.Array(Schema.String)), {
429
- resource: Watch,
430
- modelToMaybeRequirements: () => Option.some([...new Set(args.names)]),
431
- acquire: (names) => Effect.gen(function* () {
432
- const api = handle();
433
- if (api === null) throw new Error("permissions are unavailable");
434
- const events = yield* Queue.unbounded();
435
- const statuses = /* @__PURE__ */ new Map();
436
- let tail = Promise.resolve();
437
- const offer = (message) => {
438
- const run = () => Effect.runPromise(Queue.offer(events, message)).then(() => void 0, () => void 0);
439
- tail = tail.then(run, run);
440
- };
441
- const queried = [];
442
- for (const name of names) {
443
- const status = yield* Effect.tryPromise({
444
- try: () => api.query({ name }),
445
- catch: (error) => error
446
- });
447
- queried.push([
448
- name,
449
- status,
450
- readState(status.state, name)
451
- ]);
452
- }
453
- const snapshot = {};
454
- for (const [name, status, state] of queried) {
455
- status.onchange = () => {
456
- try {
457
- offer(PermissionsMessage.Changed({
458
- name,
459
- state: readState(status.state, name)
460
- }));
461
- } catch {
462
- offer(PermissionsMessage.Failed({ message: `unknown permission state for ${name}` }));
463
- }
433
+ resources: (args) => {
434
+ const names = Option.some([...new Set(args.names)]);
435
+ return ManagedResource.make()((entry) => ({ watch: entry(Schema.Option(Schema.Array(Schema.String)), {
436
+ resource: Watch,
437
+ modelToMaybeRequirements: () => names,
438
+ acquire: (names) => Effect.gen(function* () {
439
+ const api = handle();
440
+ if (api === null) throw new Error("permissions are unavailable");
441
+ const events = yield* Queue.unbounded();
442
+ const statuses = /* @__PURE__ */ new Map();
443
+ let tail = Promise.resolve();
444
+ const offer = (message) => {
445
+ const run = () => Effect.runPromise(Queue.offer(events, message)).then(() => void 0, () => void 0);
446
+ tail = tail.then(run, run);
464
447
  };
465
- statuses.set(name, status);
466
- snapshot[name] = state;
467
- }
468
- return {
469
- statuses,
470
- snapshot,
471
- events
472
- };
473
- }),
474
- onAcquired: ({ snapshot }) => PermissionsMessage.Snapshot({ states: { ...snapshot } }),
475
- onReleased: () => PermissionsMessage.Cleared(),
476
- onAcquireError: (error) => PermissionsMessage.Failed({ message: failMessage(error) }),
477
- release: ({ statuses, events }) => Effect.asVoid(Effect.andThen(Effect.sync(() => {
478
- for (const status of statuses.values()) status.onchange = null;
479
- }), Queue.shutdown(events)))
480
- }) })),
448
+ const queried = [];
449
+ for (const name of names) {
450
+ const status = yield* Effect.tryPromise({
451
+ try: () => api.query({ name }),
452
+ catch: (error) => error
453
+ });
454
+ queried.push([
455
+ name,
456
+ status,
457
+ readState(status.state, name)
458
+ ]);
459
+ }
460
+ const snapshot = {};
461
+ for (const [name, status, state] of queried) {
462
+ status.onchange = () => {
463
+ try {
464
+ offer(PermissionsMessage.Changed({
465
+ name,
466
+ state: readState(status.state, name)
467
+ }));
468
+ } catch {
469
+ offer(PermissionsMessage.Failed({ message: `unknown permission state for ${name}` }));
470
+ }
471
+ };
472
+ statuses.set(name, status);
473
+ snapshot[name] = state;
474
+ }
475
+ return {
476
+ statuses,
477
+ snapshot,
478
+ events
479
+ };
480
+ }),
481
+ onAcquired: ({ snapshot }) => PermissionsMessage.Snapshot({ states: { ...snapshot } }),
482
+ onReleased: () => PermissionsMessage.Cleared(),
483
+ onAcquireError: (error) => PermissionsMessage.Failed({ message: failMessage(error) }),
484
+ release: ({ statuses, events }) => Effect.asVoid(Effect.andThen(Effect.sync(() => {
485
+ for (const status of statuses.values()) status.onchange = null;
486
+ }), Queue.shutdown(events)))
487
+ }) }));
488
+ },
481
489
  subscriptions: () => Subscription.make()(() => ({ changes: Subscription.persistent(Stream.unwrap(Effect.matchEffect(Watch.get, {
482
490
  onFailure: () => Effect.succeed(Stream.empty),
483
491
  onSuccess: ({ events }) => Effect.succeed(Stream.fromQueue(events))