@wardic/dom 0.0.0-stage → 1.0.0-rc.2

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 (285) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/THIRD_PARTY_NOTICES.md +145 -0
  4. package/dist/Appear.d.ts +24 -0
  5. package/dist/Appear.d.ts.map +1 -0
  6. package/dist/Appear.js +25 -0
  7. package/dist/Appear.js.map +1 -0
  8. package/dist/Color.d.ts +14 -0
  9. package/dist/Color.d.ts.map +1 -0
  10. package/dist/Color.js +16 -0
  11. package/dist/Color.js.map +1 -0
  12. package/dist/DomAnnouncer.d.ts +13 -0
  13. package/dist/DomAnnouncer.d.ts.map +1 -0
  14. package/dist/DomAnnouncer.js +14 -0
  15. package/dist/DomAnnouncer.js.map +1 -0
  16. package/dist/DomClock.d.ts +11 -0
  17. package/dist/DomClock.d.ts.map +1 -0
  18. package/dist/DomClock.js +12 -0
  19. package/dist/DomClock.js.map +1 -0
  20. package/dist/DomDefaultTransition.d.ts +12 -0
  21. package/dist/DomDefaultTransition.d.ts.map +1 -0
  22. package/dist/DomDefaultTransition.js +13 -0
  23. package/dist/DomDefaultTransition.js.map +1 -0
  24. package/dist/DomInput.d.ts +13 -0
  25. package/dist/DomInput.d.ts.map +1 -0
  26. package/dist/DomInput.js +15 -0
  27. package/dist/DomInput.js.map +1 -0
  28. package/dist/DomInterpolators.d.ts +13 -0
  29. package/dist/DomInterpolators.d.ts.map +1 -0
  30. package/dist/DomInterpolators.js +14 -0
  31. package/dist/DomInterpolators.js.map +1 -0
  32. package/dist/DomKeyRules.d.ts +13 -0
  33. package/dist/DomKeyRules.d.ts.map +1 -0
  34. package/dist/DomKeyRules.js +14 -0
  35. package/dist/DomKeyRules.js.map +1 -0
  36. package/dist/DomPositionalKeys.d.ts +13 -0
  37. package/dist/DomPositionalKeys.d.ts.map +1 -0
  38. package/dist/DomPositionalKeys.js +14 -0
  39. package/dist/DomPositionalKeys.js.map +1 -0
  40. package/dist/DomProbe.d.ts +13 -0
  41. package/dist/DomProbe.d.ts.map +1 -0
  42. package/dist/DomProbe.js +14 -0
  43. package/dist/DomProbe.js.map +1 -0
  44. package/dist/DomProjectionSystem.d.ts +28 -0
  45. package/dist/DomProjectionSystem.d.ts.map +1 -0
  46. package/dist/DomProjectionSystem.js +41 -0
  47. package/dist/DomProjectionSystem.js.map +1 -0
  48. package/dist/DomRenderer.d.ts +13 -0
  49. package/dist/DomRenderer.d.ts.map +1 -0
  50. package/dist/DomRenderer.js +15 -0
  51. package/dist/DomRenderer.js.map +1 -0
  52. package/dist/FlushGate.d.ts +48 -0
  53. package/dist/FlushGate.d.ts.map +1 -0
  54. package/dist/FlushGate.js +62 -0
  55. package/dist/FlushGate.js.map +1 -0
  56. package/dist/MeasuredPair.d.ts +14 -0
  57. package/dist/MeasuredPair.d.ts.map +1 -0
  58. package/dist/MeasuredPair.js +16 -0
  59. package/dist/MeasuredPair.js.map +1 -0
  60. package/dist/OwnValues.d.ts +14 -0
  61. package/dist/OwnValues.d.ts.map +1 -0
  62. package/dist/OwnValues.js +15 -0
  63. package/dist/OwnValues.js.map +1 -0
  64. package/dist/ProjectionSystem.d.ts +34 -0
  65. package/dist/ProjectionSystem.d.ts.map +1 -0
  66. package/dist/ProjectionSystem.js +29 -0
  67. package/dist/ProjectionSystem.js.map +1 -0
  68. package/dist/ReorderController.d.ts +41 -0
  69. package/dist/ReorderController.d.ts.map +1 -0
  70. package/dist/ReorderController.js +44 -0
  71. package/dist/ReorderController.js.map +1 -0
  72. package/dist/Report.d.ts +12 -0
  73. package/dist/Report.d.ts.map +1 -0
  74. package/dist/Report.js +13 -0
  75. package/dist/Report.js.map +1 -0
  76. package/dist/ScrollOffset.d.ts +34 -0
  77. package/dist/ScrollOffset.d.ts.map +1 -0
  78. package/dist/ScrollOffset.js +42 -0
  79. package/dist/ScrollOffset.js.map +1 -0
  80. package/dist/Style.d.ts +15 -0
  81. package/dist/Style.d.ts.map +1 -0
  82. package/dist/Style.js +17 -0
  83. package/dist/Style.js.map +1 -0
  84. package/dist/Svg.d.ts +13 -0
  85. package/dist/Svg.d.ts.map +1 -0
  86. package/dist/Svg.js +14 -0
  87. package/dist/Svg.js.map +1 -0
  88. package/dist/Variables.d.ts +13 -0
  89. package/dist/Variables.d.ts.map +1 -0
  90. package/dist/Variables.js +14 -0
  91. package/dist/Variables.js.map +1 -0
  92. package/dist/index.d.ts +72 -0
  93. package/dist/index.d.ts.map +1 -0
  94. package/dist/index.js +60 -0
  95. package/dist/index.js.map +1 -0
  96. package/dist/internal/announcer.d.ts +50 -0
  97. package/dist/internal/announcer.d.ts.map +1 -0
  98. package/dist/internal/announcer.js +115 -0
  99. package/dist/internal/announcer.js.map +1 -0
  100. package/dist/internal/appear.d.ts +169 -0
  101. package/dist/internal/appear.d.ts.map +1 -0
  102. package/dist/internal/appear.js +499 -0
  103. package/dist/internal/appear.js.map +1 -0
  104. package/dist/internal/auto-scroll.d.ts +24 -0
  105. package/dist/internal/auto-scroll.d.ts.map +1 -0
  106. package/dist/internal/auto-scroll.js +124 -0
  107. package/dist/internal/auto-scroll.js.map +1 -0
  108. package/dist/internal/clock.d.ts +19 -0
  109. package/dist/internal/clock.d.ts.map +1 -0
  110. package/dist/internal/clock.js +39 -0
  111. package/dist/internal/clock.js.map +1 -0
  112. package/dist/internal/colors.d.ts +45 -0
  113. package/dist/internal/colors.d.ts.map +1 -0
  114. package/dist/internal/colors.js +193 -0
  115. package/dist/internal/colors.js.map +1 -0
  116. package/dist/internal/complex.d.ts +24 -0
  117. package/dist/internal/complex.d.ts.map +1 -0
  118. package/dist/internal/complex.js +28 -0
  119. package/dist/internal/complex.js.map +1 -0
  120. package/dist/internal/content-watch.d.ts +20 -0
  121. package/dist/internal/content-watch.d.ts.map +1 -0
  122. package/dist/internal/content-watch.js +76 -0
  123. package/dist/internal/content-watch.js.map +1 -0
  124. package/dist/internal/default-transition.d.ts +25 -0
  125. package/dist/internal/default-transition.d.ts.map +1 -0
  126. package/dist/internal/default-transition.js +41 -0
  127. package/dist/internal/default-transition.js.map +1 -0
  128. package/dist/internal/drag-defaults.d.ts +18 -0
  129. package/dist/internal/drag-defaults.d.ts.map +1 -0
  130. package/dist/internal/drag-defaults.js +38 -0
  131. package/dist/internal/drag-defaults.js.map +1 -0
  132. package/dist/internal/gate.d.ts +186 -0
  133. package/dist/internal/gate.d.ts.map +1 -0
  134. package/dist/internal/gate.js +453 -0
  135. package/dist/internal/gate.js.map +1 -0
  136. package/dist/internal/input.d.ts +41 -0
  137. package/dist/internal/input.d.ts.map +1 -0
  138. package/dist/internal/input.js +476 -0
  139. package/dist/internal/input.js.map +1 -0
  140. package/dist/internal/interpolators.d.ts +19 -0
  141. package/dist/internal/interpolators.d.ts.map +1 -0
  142. package/dist/internal/interpolators.js +30 -0
  143. package/dist/internal/interpolators.js.map +1 -0
  144. package/dist/internal/key-rules.d.ts +11 -0
  145. package/dist/internal/key-rules.d.ts.map +1 -0
  146. package/dist/internal/key-rules.js +244 -0
  147. package/dist/internal/key-rules.js.map +1 -0
  148. package/dist/internal/measure.d.ts +150 -0
  149. package/dist/internal/measure.d.ts.map +1 -0
  150. package/dist/internal/measure.js +278 -0
  151. package/dist/internal/measure.js.map +1 -0
  152. package/dist/internal/measured-pairs.d.ts +38 -0
  153. package/dist/internal/measured-pairs.d.ts.map +1 -0
  154. package/dist/internal/measured-pairs.js +251 -0
  155. package/dist/internal/measured-pairs.js.map +1 -0
  156. package/dist/internal/own-values.d.ts +35 -0
  157. package/dist/internal/own-values.d.ts.map +1 -0
  158. package/dist/internal/own-values.js +117 -0
  159. package/dist/internal/own-values.js.map +1 -0
  160. package/dist/internal/pass-ends.d.ts +21 -0
  161. package/dist/internal/pass-ends.d.ts.map +1 -0
  162. package/dist/internal/pass-ends.js +14 -0
  163. package/dist/internal/pass-ends.js.map +1 -0
  164. package/dist/internal/positional-keys.d.ts +16 -0
  165. package/dist/internal/positional-keys.d.ts.map +1 -0
  166. package/dist/internal/positional-keys.js +21 -0
  167. package/dist/internal/positional-keys.js.map +1 -0
  168. package/dist/internal/probe.d.ts +10 -0
  169. package/dist/internal/probe.d.ts.map +1 -0
  170. package/dist/internal/probe.js +117 -0
  171. package/dist/internal/probe.js.map +1 -0
  172. package/dist/internal/projection-defaults.d.ts +34 -0
  173. package/dist/internal/projection-defaults.d.ts.map +1 -0
  174. package/dist/internal/projection-defaults.js +21 -0
  175. package/dist/internal/projection-defaults.js.map +1 -0
  176. package/dist/internal/projection.d.ts +529 -0
  177. package/dist/internal/projection.d.ts.map +1 -0
  178. package/dist/internal/projection.js +4990 -0
  179. package/dist/internal/projection.js.map +1 -0
  180. package/dist/internal/renderer.d.ts +86 -0
  181. package/dist/internal/renderer.d.ts.map +1 -0
  182. package/dist/internal/renderer.js +616 -0
  183. package/dist/internal/renderer.js.map +1 -0
  184. package/dist/internal/reorder.d.ts +159 -0
  185. package/dist/internal/reorder.d.ts.map +1 -0
  186. package/dist/internal/reorder.js +437 -0
  187. package/dist/internal/reorder.js.map +1 -0
  188. package/dist/internal/report.d.ts +30 -0
  189. package/dist/internal/report.d.ts.map +1 -0
  190. package/dist/internal/report.js +60 -0
  191. package/dist/internal/report.js.map +1 -0
  192. package/dist/internal/require-global.d.ts +11 -0
  193. package/dist/internal/require-global.d.ts.map +1 -0
  194. package/dist/internal/require-global.js +31 -0
  195. package/dist/internal/require-global.js.map +1 -0
  196. package/dist/internal/reveal.d.ts +16 -0
  197. package/dist/internal/reveal.d.ts.map +1 -0
  198. package/dist/internal/reveal.js +66 -0
  199. package/dist/internal/reveal.js.map +1 -0
  200. package/dist/internal/runner.d.ts +21 -0
  201. package/dist/internal/runner.d.ts.map +1 -0
  202. package/dist/internal/runner.js +48 -0
  203. package/dist/internal/runner.js.map +1 -0
  204. package/dist/internal/scale-correction.d.ts +80 -0
  205. package/dist/internal/scale-correction.d.ts.map +1 -0
  206. package/dist/internal/scale-correction.js +227 -0
  207. package/dist/internal/scale-correction.js.map +1 -0
  208. package/dist/internal/scroll.d.ts +35 -0
  209. package/dist/internal/scroll.d.ts.map +1 -0
  210. package/dist/internal/scroll.js +244 -0
  211. package/dist/internal/scroll.js.map +1 -0
  212. package/dist/internal/scrollers.d.ts +35 -0
  213. package/dist/internal/scrollers.d.ts.map +1 -0
  214. package/dist/internal/scrollers.js +38 -0
  215. package/dist/internal/scrollers.js.map +1 -0
  216. package/dist/internal/starting-style.d.ts +141 -0
  217. package/dist/internal/starting-style.d.ts.map +1 -0
  218. package/dist/internal/starting-style.js +300 -0
  219. package/dist/internal/starting-style.js.map +1 -0
  220. package/dist/internal/svg.d.ts +60 -0
  221. package/dist/internal/svg.d.ts.map +1 -0
  222. package/dist/internal/svg.js +257 -0
  223. package/dist/internal/svg.js.map +1 -0
  224. package/dist/internal/variables.d.ts +45 -0
  225. package/dist/internal/variables.d.ts.map +1 -0
  226. package/dist/internal/variables.js +92 -0
  227. package/dist/internal/variables.js.map +1 -0
  228. package/package.json +65 -4
  229. package/src/Appear.ts +45 -0
  230. package/src/Color.ts +24 -0
  231. package/src/DomAnnouncer.ts +22 -0
  232. package/src/DomClock.ts +13 -0
  233. package/src/DomDefaultTransition.ts +16 -0
  234. package/src/DomInput.ts +22 -0
  235. package/src/DomInterpolators.ts +16 -0
  236. package/src/DomKeyRules.ts +17 -0
  237. package/src/DomPositionalKeys.ts +16 -0
  238. package/src/DomProbe.ts +15 -0
  239. package/src/DomProjectionSystem.ts +44 -0
  240. package/src/DomRenderer.ts +26 -0
  241. package/src/FlushGate.ts +81 -0
  242. package/src/MeasuredPair.ts +15 -0
  243. package/src/OwnValues.ts +19 -0
  244. package/src/ProjectionSystem.ts +51 -0
  245. package/src/ReorderController.ts +72 -0
  246. package/src/Report.ts +17 -0
  247. package/src/ScrollOffset.ts +54 -0
  248. package/src/Style.ts +37 -0
  249. package/src/Svg.ts +21 -0
  250. package/src/Variables.ts +17 -0
  251. package/src/index.ts +106 -0
  252. package/src/internal/announcer.ts +132 -0
  253. package/src/internal/appear.ts +739 -0
  254. package/src/internal/auto-scroll.ts +176 -0
  255. package/src/internal/clock.ts +45 -0
  256. package/src/internal/colors.ts +258 -0
  257. package/src/internal/complex.ts +29 -0
  258. package/src/internal/content-watch.ts +84 -0
  259. package/src/internal/culori.d.ts +58 -0
  260. package/src/internal/default-transition.ts +50 -0
  261. package/src/internal/drag-defaults.ts +54 -0
  262. package/src/internal/gate.ts +593 -0
  263. package/src/internal/input.ts +579 -0
  264. package/src/internal/interpolators.ts +33 -0
  265. package/src/internal/key-rules.ts +357 -0
  266. package/src/internal/measure.ts +357 -0
  267. package/src/internal/measured-pairs.ts +297 -0
  268. package/src/internal/own-values.ts +166 -0
  269. package/src/internal/pass-ends.ts +23 -0
  270. package/src/internal/positional-keys.ts +24 -0
  271. package/src/internal/probe.ts +176 -0
  272. package/src/internal/projection-defaults.ts +45 -0
  273. package/src/internal/projection.ts +5999 -0
  274. package/src/internal/renderer.ts +646 -0
  275. package/src/internal/reorder.ts +604 -0
  276. package/src/internal/report.ts +69 -0
  277. package/src/internal/require-global.ts +46 -0
  278. package/src/internal/reveal.ts +69 -0
  279. package/src/internal/runner.ts +64 -0
  280. package/src/internal/scale-correction.ts +310 -0
  281. package/src/internal/scroll.ts +356 -0
  282. package/src/internal/scrollers.ts +51 -0
  283. package/src/internal/starting-style.ts +396 -0
  284. package/src/internal/svg.ts +300 -0
  285. package/src/internal/variables.ts +145 -0
@@ -0,0 +1,604 @@
1
+ /**
2
+ * The DOM's **Reorder group** controller (ADR-0020): the framework-free half of
3
+ * `<Reorder>`, shared by every DOM binding. A binding adds only its reactivity, its
4
+ * lifecycle and its elements.
5
+ *
6
+ * It owns core's `Reorder.Group` (the **Crossover** and the new order), reads the
7
+ * group element's `direction` at each drag start, builds the group's **Auto-scroll**,
8
+ * and routes each item's drag samples: the start opens the group's gesture, each move
9
+ * runs the Crossover and Auto-scroll, the end (or the item's disposal mid-gesture)
10
+ * closes both. The items' `touch-action` needs the axis before a touch starts, so after
11
+ * every Measure pass the controller detects the axis from the items' boxes and reports
12
+ * a change through `onAxis`; while the app sets the axis it detects nothing. Motion
13
+ * detects on every item's every measurement (Group.tsx:121-133 at 16c6122). When the app
14
+ * clears its axis, no Measure pass comes with that: a binding reads `detect()`, the axis
15
+ * from the boxes the items have now, in the computation that derives its axis, so whatever
16
+ * reads the axis follows in the same update (#257's audit: an effect that detected and
17
+ * reported it updated them a step late).
18
+ *
19
+ * It also runs each item's **Keyboard reorder** (ADR-0037), on by default: a key from the item
20
+ * itself that the page didn't take (Space or Enter lifts and drops, Escape cancels, the arrows,
21
+ * Home and End move the lifted item) goes to core's `Reorder` model, its default prevented; each
22
+ * step is an **Announcement** (a held arrow's thinned to the last), scrolls the item's new place
23
+ * into view after the Measure pass that lays it out, and keeps focus on the item; the lifted node is
24
+ * raised as a dragged one is, and the binding turns its `whileDrag` on. Focus leaving the item, a
25
+ * press on it, or the app changing `values` drops it where it is; its unmount ends the lift unsaid.
26
+ *
27
+ * Each **Reorder hold** (#348, ADR-0020 *Amendment (#348)*), an item in a user's hand from its drag's start or
28
+ * its lift, is told at its start and, once, at its end, whatever ends it: after `onReorderEnd`, when
29
+ * it moved. One at a time: a hold's start ends the open one first, and that item's later release
30
+ * tells nothing more.
31
+ * `move` announces too. Nothing is announced without an `announce`.
32
+ *
33
+ * Observability: `wardic.reorder:` debug logs through the runtime, annotated: the
34
+ * group's and Auto-scroll's events, and `axis` when the detected axis changes (cold
35
+ * path: never per sample), plus one warning per controller for a remount (below).
36
+ *
37
+ * Tier 2 (ADR-0008): for DOM binding authors.
38
+ */
39
+ import * as AutoScroll from "@wardic/core/AutoScroll"
40
+ import type * as Drag from "@wardic/core/Drag"
41
+ import type * as InputSource from "@wardic/core/InputSource"
42
+ import * as Reorder from "@wardic/core/Reorder"
43
+ import * as Bool from "effect/Boolean"
44
+ import * as Duration from "effect/Duration"
45
+ import * as Effect from "effect/Effect"
46
+ import * as FiberHandle from "effect/FiberHandle"
47
+ import { constTrue, constVoid, identity } from "effect/Function"
48
+ import * as Match from "effect/Match"
49
+ import * as MutableRef from "effect/MutableRef"
50
+ import * as Option from "effect/Option"
51
+ import * as Predicate from "effect/Predicate"
52
+ import * as Scope from "effect/Scope"
53
+ import * as Str from "effect/String"
54
+ import * as UndefinedOr from "effect/UndefinedOr"
55
+ import { documentOf, prepareAnnouncer } from "./announcer.ts"
56
+ import { type AutoScroller, makeAutoScroller } from "./auto-scroll.ts"
57
+ import type { ProjectionNodeHandle, ProjectionSystem } from "./projection.ts"
58
+ import { debugOf } from "./report.ts"
59
+ import { revealLayoutOf } from "./reveal.ts"
60
+ import type { Runner } from "./runner.ts"
61
+
62
+ /**
63
+ * The group element's style: no scroll anchoring, so the browser doesn't shift the
64
+ * page as items move (Motion's Group.tsx:187-195 at 16c6122). The user's style goes
65
+ * over it.
66
+ */
67
+ export const REORDER_GROUP_STYLE = Object.freeze({ "overflow-anchor": "none" } as const)
68
+
69
+ /**
70
+ * What {@link makeReorderController} runs on: the runner it logs through, the projection system the
71
+ * items are nodes of, and the Environment's `Announcer`. `ReorderController.make` takes them from
72
+ * its context (#132's PR 3, ADR-0041).
73
+ */
74
+ export interface ReorderControllerWiring {
75
+ /** The runner the controller logs through and waits in. */
76
+ readonly runtime: Runner<never>
77
+ /** The projection system the items are nodes of. */
78
+ readonly projection: Omit<ProjectionSystem, "dispose" | "onPassEnd">
79
+ /** Speak an **Announcement**: the Environment's `Announcer`. Left out, nothing is announced. */
80
+ readonly announce?: ((message: string) => void) | undefined
81
+ /** The engine's Measure-pass ends: its `PassEnds` service (internal/pass-ends.ts). */
82
+ readonly onPassEnd: (listener: () => void) => () => void
83
+ }
84
+
85
+ /**
86
+ * A user's reorder that changed the order, at its end: a pointer drag released, or a keyboard lift
87
+ * dropped, with the item at another index than at its start. What an app saves: the whole order, or the
88
+ * one item that moved and where.
89
+ */
90
+ // #257 item 3.
91
+ export interface ReorderEnd<V> {
92
+ /** The order at the end. */
93
+ readonly values: Array<V>
94
+ /** The item the user moved: the one dragged, or lifted. */
95
+ readonly value: V
96
+ /** Its index at the gesture's start. */
97
+ readonly from: number
98
+ /** Its index at the end. */
99
+ readonly to: number
100
+ }
101
+
102
+ /** A **Reorder hold**'s start: the item taken in hand, by its drag's start or a keyboard lift, and its index then. */
103
+ // #348.
104
+ export interface ReorderHoldStart<V> {
105
+ /** The item taken in hand. */
106
+ readonly value: V
107
+ /** Its index at the start. */
108
+ readonly from: number
109
+ }
110
+
111
+ /**
112
+ * How a **Reorder hold** ended: `"drop"`, released or dropped where it is (a pointer's release or
113
+ * cancel, a drop key, focus leaving, a press, the app's `values`, another hold); `"cancel"`, Escape, the
114
+ * order put back; `"unmount"`, its item went away.
115
+ */
116
+ // #348.
117
+ export type ReorderHoldHow = "drop" | "cancel" | "unmount"
118
+
119
+ /**
120
+ * A **Reorder hold**'s end, whatever ended it, once: the order then, the item, its index at the start
121
+ * and now (`-1` once it's out of the values), and how. After `onReorderEnd`, in the same task, when the
122
+ * order changed.
123
+ */
124
+ // #348.
125
+ export interface ReorderHoldEnd<V> extends ReorderEnd<V> {
126
+ /** How it ended. */
127
+ readonly how: ReorderHoldHow
128
+ }
129
+
130
+ /** What a binding hands its group's controller. */
131
+ export interface ReorderControllerOptions<V> {
132
+ /** The app's values, in order. */
133
+ readonly values: () => ReadonlyArray<V>
134
+ /** Receives a new order; the app sets `values` from it. */
135
+ readonly onReorder: (values: Array<V>) => void
136
+ /**
137
+ * Hears a user's reorder that changed the order, at its end: once a gesture, not at each step, as
138
+ * `onReorder` is. Not a keyboard cancel, which puts the order back, nor `move`.
139
+ */
140
+ // #257 item 3.
141
+ readonly onReorderEnd?: ((end: ReorderEnd<V>) => void) | undefined
142
+ /** Hears each **Reorder hold**'s start: a drag of an item past its threshold, or a keyboard lift. */
143
+ // #348.
144
+ readonly onHoldStart?: ((start: ReorderHoldStart<V>) => void) | undefined
145
+ /** Hears each **Reorder hold**'s end, once, whatever ended it; after `onReorderEnd` when it moved. */
146
+ // #348.
147
+ readonly onHoldEnd?: ((end: ReorderHoldEnd<V>) => void) | undefined
148
+ /** The app's axis, overriding detection; `None` to detect. */
149
+ readonly axis?: (() => Option.Option<Reorder.Axis>) | undefined
150
+ /** Called when the detected axis changes (after a Measure pass, or {@link ReorderController.resolve}). */
151
+ readonly onAxis?: ((axis: Reorder.Axis) => void) | undefined
152
+ /**
153
+ * How the binding keys its items, for the warning logged once per controller when an
154
+ * item registers with a value an item of this group unregistered in the same task
155
+ * (the signature of a list keyed by index): "key items with `keyedWith`". Solid's is
156
+ * `"<For>"`; by default "their value".
157
+ */
158
+ readonly keyedWith?: string | undefined
159
+ /** Whether the items reorder by keyboard, read at each key; `false` turns it off. On when left out. */
160
+ // ADR-0037.
161
+ readonly keyboard?: (() => boolean) | undefined
162
+ /** The group's announcements over the defaults, read at each announcement. */
163
+ // ADR-0037.
164
+ readonly announcements?: (() => Option.Option<Partial<Reorder.Announcements<V>>>) | undefined
165
+ }
166
+
167
+ /** One item of a {@link ReorderController}: its binding feeds it the item's handle, drag samples, keys, focus and presses. */
168
+ export interface ReorderItemDriver {
169
+ /** Register the item's projection handle with the group; returns the unregister. */
170
+ readonly register: (handle: ProjectionNodeHandle) => () => void
171
+ /** The axis the item's drag takes, read at each gesture's start. */
172
+ readonly dragAxis: () => Reorder.Axis
173
+ /** The item's drag samples, as the drag delivers them. */
174
+ readonly onSample: (sample: Drag.Sample) => void
175
+ /** A key at the item or inside it, inside its dispatch: a keyboard reorder's. */
176
+ // ADR-0037.
177
+ readonly onKey: (event: InputSource.KeyboardLikeEvent) => void
178
+ /** Focus left the item: a lift drops where it is, unless focus is back on the item. */
179
+ // ADR-0037.
180
+ readonly onBlur: () => void
181
+ /** A press on the item: a lift drops where it is, before any drag. */
182
+ // ADR-0037.
183
+ readonly onPress: () => void
184
+ /** The item is going away: end a gesture or a lift still in progress. */
185
+ readonly dispose: () => void
186
+ }
187
+
188
+ /** How long a held arrow's announcements wait for the next repeat: only the last is spoken (ADR-0037, Q4). */
189
+ export const REPEAT_ANNOUNCE: Duration.Duration = Duration.millis(150)
190
+
191
+ /**
192
+ * A Reorder item's default instructions on the web: core's keys, and focus mode. A
193
+ * screen reader in browse mode (NVDA's, JAWS's) keeps Space and the arrows for itself until its user
194
+ * switches to focus mode, and it doesn't switch by itself on a list item.
195
+ */
196
+ // ADR-0037: the NVDA pass.
197
+ export const DEFAULT_REORDER_INSTRUCTIONS = `${Reorder.DEFAULT_INSTRUCTIONS} Some screen readers need focus mode for these keys.`
198
+
199
+ /** How an item is spoken: its `aria-label`, else its text with its whitespace collapsed, else its value. */
200
+ const labelOf = (element: Option.Option<Element>, value: unknown): string =>
201
+ Option.getOrElse(
202
+ Option.orElse(
203
+ Option.flatMap(element, (el) => nonEmpty(Option.map(Option.fromNullOr(el.getAttribute("aria-label")), Str.trim))),
204
+ () => Option.flatMap(element, (el) => nonEmpty(Option.map(Option.fromNullOr(el.textContent), (text) => text.replace(/\s+/g, " ").trim()))),
205
+ ),
206
+ () => String(value),
207
+ )
208
+
209
+ /** A text, `None` when it's empty. */
210
+ const nonEmpty = (text: Option.Option<string>): Option.Option<string> => Option.filter(text, Str.isNonEmpty)
211
+
212
+ /** A **Reorder group** on the DOM. */
213
+ // ADR-0020.
214
+ export interface ReorderController<V> {
215
+ /** Core's Reorder group (`Reorder.move(group, value, toIndex)` reorders in code). */
216
+ readonly group: Reorder.Group<V>
217
+ /** The app's axis, else the last detected one (`"y"` before the first). */
218
+ readonly axis: () => Reorder.Axis
219
+ /** The group's element: its `direction`, and the scroller Auto-scroll looks for from it. */
220
+ readonly attach: (element: Element) => void
221
+ /**
222
+ * The driver for the item with `value`; `element` is its element (for Auto-scroll, its label, its
223
+ * focus and its reveal), and `onLift` hears its keyboard lift start and end (the binding's `whileDrag`).
224
+ */
225
+ readonly item: (value: V, element: () => Option.Option<Element>, onLift?: (lifted: boolean) => void) => ReorderItemDriver
226
+ /** Move `value` to `toIndex` in code (`Reorder.move`), and announce it. */
227
+ // ADR-0037.
228
+ readonly move: (value: V, toIndex: number) => void
229
+ /**
230
+ * The axis now: the app's, else detected from the items' boxes as they are, reporting nothing. A
231
+ * binding derives its axis with it when the app's is cleared.
232
+ */
233
+ // #257's audit.
234
+ readonly detect: () => Reorder.Axis
235
+ /** The app's values changed: re-measure, so the items animate to their slots. */
236
+ readonly valuesChanged: () => void
237
+ /** Stops listening to measures and ends the auto-scroll. The Scope calls it. */
238
+ readonly dispose: () => void
239
+ }
240
+
241
+ /** Build a {@link ReorderController}: a **Reorder group** over `wiring.projection`'s nodes. */
242
+ export const makeReorderController = <V>(wiring: ReorderControllerWiring, options: ReorderControllerOptions<V>): ReorderController<V> => {
243
+ const { runtime, projection, announce } = wiring
244
+ const debug = debugOf(runtime, "wardic.reorder")
245
+ // Speak an Announcement, or nothing without an `announce`.
246
+ const speak = UndefinedOr.match(announce, { onUndefined: () => constVoid, onDefined: identity })
247
+ const keyboardOn = (): boolean => UndefinedOr.match(options.keyboard, { onUndefined: constTrue, onDefined: (on) => on() })
248
+ // The group's element and its Auto-scroll, once it's attached.
249
+ const element = MutableRef.make(Option.none<Element>())
250
+ const autoScroller = MutableRef.make(Option.none<AutoScroller>())
251
+ const resetAutoScroll = (): void => {
252
+ const scroller = MutableRef.get(autoScroller)
253
+ if (Option.isSome(scroller)) scroller.value.reset()
254
+ }
255
+ // The order the group last asked for: a `values` change to anything else is the app's own (ADR-0037).
256
+ let asked: ReadonlyArray<V> | undefined
257
+ /** The app's axis, if it sets one. */
258
+ const appAxis = (): Option.Option<Reorder.Axis> => Option.flatMap(Option.fromUndefinedOr(options.axis), (axis) => axis())
259
+ const group = Reorder.make<V>({
260
+ values: options.values,
261
+ onReorder: (values) => {
262
+ asked = values
263
+ options.onReorder(values)
264
+ },
265
+ axis: () => appAxis(),
266
+ // Read once per drag start (Motion reads it on every sample, Group.tsx:142-147 at 16c6122).
267
+ direction: () =>
268
+ Bool.match(Option.exists(MutableRef.get(element), (el) => getComputedStyle(el).direction === "rtl"), {
269
+ onTrue: () => "rtl" as const,
270
+ onFalse: () => "ltr" as const,
271
+ }),
272
+ debug,
273
+ })
274
+
275
+ let detected: Reorder.Axis = "y"
276
+ let reported: Reorder.Axis | undefined
277
+ const resolve = (): void => {
278
+ if (Option.isSome(appAxis())) return
279
+ detected = Reorder.resolveAxis(group)
280
+ if (detected === reported) return
281
+ reported = detected
282
+ debug("axis", { axis: detected })
283
+ if (Predicate.isNotUndefined(options.onAxis)) options.onAxis(detected)
284
+ }
285
+ // --- The keyboard reorder (ADR-0037) ---------------------------------------
286
+ /** The lifted item's element, its handle and its `onLift`, by value, while it's registered. */
287
+ interface Keyed {
288
+ readonly element: () => Option.Option<Element>
289
+ handle: Option.Option<ProjectionNodeHandle>
290
+ readonly onLift: (lifted: boolean) => void
291
+ }
292
+ const keyed = new Map<V, Keyed>()
293
+ const elementOf = (value: V): Option.Option<Element> => Option.flatMap(Option.fromUndefinedOr(keyed.get(value)), (k) => k.element())
294
+ // The item whose new place the next Measure pass lays out, to scroll into view then.
295
+ const reveal = MutableRef.make(Option.none<V>())
296
+ // A held arrow's latest announcement, waiting for the next repeat (ADR-0025, *Amendment (#69)*): a
297
+ // fiber in the Scope the controller was built in, which closes it after `dispose`.
298
+ const pending = runtime.runSync(FiberHandle.make<void, never>().pipe(Scope.provide(runtime.scope)))
299
+
300
+ const announceNow = (message: string): void => {
301
+ runtime.runFork(FiberHandle.clear(pending))
302
+ speak(message)
303
+ }
304
+ /** A held arrow's step: spoken once the repeats stop for {@link REPEAT_ANNOUNCE}, only the last. */
305
+ const announceLater = (message: string): void => {
306
+ FiberHandle.setUnsafe(pending, runtime.runFork(Effect.sleep(REPEAT_ANNOUNCE).pipe(Effect.andThen(Effect.sync(() => speak(message))))))
307
+ }
308
+ const wording = (): Reorder.Announcements<V> => ({
309
+ ...(Reorder.defaultAnnouncements as Reorder.Announcements<V>),
310
+ ...Option.getOrUndefined(Option.flatMap(Option.fromUndefinedOr(options.announcements), (announcements) => announcements())),
311
+ })
312
+ const say = (kind: keyof Reorder.Announcements<V>, value: V, placement: Reorder.Placement): string =>
313
+ wording()[kind]({ value, label: labelOf(elementOf(value), value), position: placement.position, count: placement.count })
314
+
315
+ /** Raise the lifted item (or lower it) and tell its binding (its `whileDrag`). */
316
+ const setLifted = (value: V, lifted: boolean): void => {
317
+ const k = keyed.get(value)
318
+ if (Predicate.isUndefined(k)) return
319
+ if (Option.isSome(k.handle)) k.handle.value.setLifted(lifted)
320
+ k.onLift(lifted)
321
+ }
322
+ /** `value`'s index in the app's values now. */
323
+ const indexOf = (value: V): number => options.values().indexOf(value)
324
+ /** The end of a user's reorder of `value`, begun at `from`: told when it moved (#257 item 3). */
325
+ const ended = (value: V, from: number): void => {
326
+ const to = indexOf(value)
327
+ if (to === from) return
328
+ UndefinedOr.map(options.onReorderEnd, (tell) => tell({ values: [...options.values()], value, from, to }))
329
+ }
330
+ // --- Holds (#348) ------------------------------------------------------------
331
+ /** The open **Reorder hold**: the item in hand, how it was taken, and its index then. One at a time. */
332
+ let held: Option.Option<{ readonly value: V; readonly from: number; readonly by: "drag" | "lift" }> = Option.none()
333
+ /** Whether `value`'s drag is the open hold: a drag a later hold took over tells nothing more. */
334
+ const holdsDrag = (value: V): boolean => Option.exists(held, (h) => h.by === "drag" && h.value === value)
335
+ const holdStart = (value: V, from: number, by: "drag" | "lift"): void => {
336
+ held = Option.some({ value, from, by })
337
+ UndefinedOr.map(options.onHoldStart, (tell) => tell({ value, from }))
338
+ }
339
+ /** End `value`'s hold, `how`: told once, and only for the open one. */
340
+ const holdEnd = (value: V, how: ReorderHoldHow): void =>
341
+ Option.match(Option.filter(held, (h) => h.value === value), {
342
+ onNone: constVoid,
343
+ onSome: (h) => {
344
+ held = Option.none()
345
+ UndefinedOr.map(options.onHoldEnd, (tell) => tell({ values: [...options.values()], value, from: h.from, to: indexOf(value), how }))
346
+ },
347
+ })
348
+
349
+ /** The lifted item's index at its lift. */
350
+ let liftedFrom = -1
351
+ /** Drop the lifted item where it is and announce it: a drop key, or an interruption. */
352
+ const dropLifted = (): void =>
353
+ Option.match(Reorder.liftedValue(group), {
354
+ onNone: constVoid,
355
+ onSome: (value) => {
356
+ Option.match(Reorder.drop(group, value), { onNone: constVoid, onSome: (placement) => announceNow(say("drop", value, placement)) })
357
+ setLifted(value, false)
358
+ ended(value, liftedFrom)
359
+ holdEnd(value, "drop")
360
+ },
361
+ })
362
+ /**
363
+ * A drag's start ends the open hold first (#348, one at a time): a lift is dropped where it is,
364
+ * and another item's drag ends, told as if released, its own release telling nothing more.
365
+ */
366
+ const endHeld = (): void =>
367
+ Option.match(held, {
368
+ onNone: constVoid,
369
+ onSome: (h) => {
370
+ if (h.by === "lift") return dropLifted()
371
+ ended(h.value, h.from)
372
+ holdEnd(h.value, "drop")
373
+ },
374
+ })
375
+ /** Focus back on the lifted item, if a reorder took it (moving a focused node can blur it), without scrolling. */
376
+ const keepFocus = (value: V): void =>
377
+ Option.match(Option.filter(elementOf(value), (el) => el.ownerDocument.activeElement !== el), {
378
+ onNone: constVoid,
379
+ onSome: (el) => (el as HTMLElement).focus({ preventScroll: true }),
380
+ })
381
+ /** Whether focus is on `value`'s element. */
382
+ const focused = (value: V): boolean => Option.exists(elementOf(value), (el) => el.ownerDocument.activeElement === el)
383
+
384
+ const onKeyOf = (value: V) => (event: InputSource.KeyboardLikeEvent): void => {
385
+ if (event.type !== "down") return
386
+ if (Bool.not(keyboardOn())) return
387
+ // A key the page took, or one from inside the item (a text field), isn't the reorder's.
388
+ if (event.handled) return
389
+ if (Bool.not(event.fromTarget)) return
390
+ // Keys during a pointer drag are left alone.
391
+ if (Reorder.isDragging(group)) return
392
+ const lifted = Option.contains(Reorder.liftedValue(group), value)
393
+ Option.match(Reorder.keyIntent(event.key), {
394
+ onNone: constVoid,
395
+ onSome: (intent) =>
396
+ Match.value(intent).pipe(
397
+ Match.tag("Toggle", () => {
398
+ event.handle()
399
+ if (event.repeat) return
400
+ if (lifted) return dropLifted()
401
+ // Lifting another item drops the one lifted first.
402
+ dropLifted()
403
+ Option.match(Reorder.lift(group, value), {
404
+ onNone: constVoid,
405
+ onSome: (placement) => {
406
+ liftedFrom = indexOf(value)
407
+ // The values the lift began with are the group's own: a binding re-reading them,
408
+ // the same, mid-lift (a memo whose sources changed) isn't the app changing them (#348).
409
+ asked = options.values()
410
+ setLifted(value, true)
411
+ announceNow(say("lift", value, placement))
412
+ holdStart(value, liftedFrom, "lift")
413
+ },
414
+ })
415
+ }),
416
+ Match.tag("Cancel", () => {
417
+ if (Bool.not(lifted)) return
418
+ event.handle()
419
+ Option.match(Reorder.cancel(group, value), {
420
+ onNone: constVoid,
421
+ onSome: (placement) => {
422
+ setLifted(value, false)
423
+ keepFocus(value)
424
+ if (placement.moved) MutableRef.set(reveal, Option.some(value))
425
+ announceNow(say("cancel", value, placement))
426
+ holdEnd(value, "cancel")
427
+ },
428
+ })
429
+ }),
430
+ Match.tagsExhaustive({
431
+ Step: (step) => stepLifted(event, value, lifted, step),
432
+ End: (end) => stepLifted(event, value, lifted, end),
433
+ }),
434
+ ),
435
+ })
436
+ }
437
+ /** An arrow, Home or End: the lifted item's step, its announcement (a held arrow's thinned), its reveal. */
438
+ const stepLifted = (event: InputSource.KeyboardLikeEvent, value: V, lifted: boolean, intent: Extract<Reorder.KeyIntent, { readonly _tag: "Step" | "End" }>): void => {
439
+ // Not lifted, the arrows, Home and End keep their own work (scrolling the page).
440
+ if (Bool.not(lifted)) return
441
+ event.handle()
442
+ Option.match(Reorder.moveLifted(group, value, intent), {
443
+ onNone: constVoid,
444
+ onSome: (placement) => {
445
+ // A repeat before the last step's Measure pass moved nothing: nothing to say.
446
+ if (placement.waiting) return
447
+ if (placement.moved) {
448
+ MutableRef.set(reveal, Option.some(value))
449
+ keepFocus(value)
450
+ }
451
+ const message = say("step", value, placement)
452
+ Bool.match(event.repeat, { onTrue: () => announceLater(message), onFalse: () => announceNow(message) })
453
+ },
454
+ })
455
+ }
456
+
457
+ // Every Measure pass measures every node, so its end is when the boxes are fresh, and when a
458
+ // stepped item's new place is laid out.
459
+ const stopPassEnd = wiring.onPassEnd(() => {
460
+ resolve()
461
+ const stepped = MutableRef.get(reveal)
462
+ MutableRef.set(reveal, Option.none())
463
+ const el = Option.filter(Option.flatMap(stepped, elementOf), (e) => e.isConnected)
464
+ if (Option.isSome(el)) revealLayoutOf(el.value)
465
+ })
466
+
467
+ const keyedWith = UndefinedOr.match(options.keyedWith, { onUndefined: () => "their value", onDefined: identity })
468
+ // Values whose item unregistered in this task, and whether a remount was warned of.
469
+ const unregisteredThisTask = new Set<V>()
470
+ let remountReported = false
471
+
472
+ const item = (value: V, itemElement: () => Option.Option<Element>, onLift: (lifted: boolean) => void = constVoid): ReorderItemDriver => {
473
+ // The gesture's axis (core's, fixed at its start), for Auto-scroll's axis.
474
+ let gestureAxis: Reorder.Axis = "y"
475
+ let inGesture = false
476
+ // Its index at the drag's start, for its `onReorderEnd` (#257 item 3).
477
+ let draggedFrom = -1
478
+ const k: Keyed = { element: itemElement, handle: Option.none(), onLift }
479
+ keyed.set(value, k)
480
+ const end = (): void => {
481
+ inGesture = false
482
+ Reorder.dragEnd(group, value)
483
+ resetAutoScroll()
484
+ }
485
+ return {
486
+ register: (handle) => {
487
+ if (!remountReported && unregisteredThisTask.has(value)) {
488
+ remountReported = true
489
+ runtime.runFork(
490
+ Effect.logWarning(
491
+ `wardic.reorder: an item remounted with the same value in one task (key items with ${keyedWith})`,
492
+ ).pipe(Effect.annotateLogs({ value: String(value) })),
493
+ )
494
+ }
495
+ const unregister = Reorder.register(group, value, {
496
+ layoutBox: handle.layoutBox,
497
+ dragOffset: handle.dragOffset,
498
+ layoutVersion: handle.layoutVersion,
499
+ })
500
+ k.handle = Option.some(handle)
501
+ keyed.set(value, k)
502
+ return () => {
503
+ if (Option.contains(k.handle, handle)) k.handle = Option.none()
504
+ unregister()
505
+ if (unregisteredThisTask.size === 0) queueMicrotask(() => unregisteredThisTask.clear())
506
+ unregisteredThisTask.add(value)
507
+ }
508
+ },
509
+ dragAxis: () => Reorder.resolveAxis(group),
510
+ onSample: (sample) => {
511
+ if (sample.phase === "start") {
512
+ endHeld()
513
+ inGesture = true
514
+ draggedFrom = indexOf(value)
515
+ gestureAxis = Reorder.dragStart(group, value)
516
+ holdStart(value, draggedFrom, "drag")
517
+ return
518
+ }
519
+ if (sample.phase === "move") {
520
+ const velocity = Reorder.dragMove(group, value, sample.velocity)
521
+ const scroller = MutableRef.get(autoScroller)
522
+ if (Option.isSome(scroller)) {
523
+ const on = AutoScroll.axis(gestureAxis, velocity)
524
+ scroller.value.update(sample.point, on, velocity[on], Option.getOrUndefined(k.element()))
525
+ }
526
+ return
527
+ }
528
+ end()
529
+ // A drag a later hold took over ended then.
530
+ if (Bool.not(holdsDrag(value))) return
531
+ ended(value, draggedFrom)
532
+ holdEnd(value, "drop")
533
+ },
534
+ onKey: onKeyOf(value),
535
+ onBlur: () => {
536
+ if (Bool.not(Option.contains(Reorder.liftedValue(group), value))) return
537
+ // A reorder can blur the node it moves; focus was put back on it, so it's no interruption.
538
+ if (focused(value)) return
539
+ dropLifted()
540
+ },
541
+ onPress: () => {
542
+ if (Option.contains(Reorder.liftedValue(group), value)) dropLifted()
543
+ },
544
+ dispose: () => {
545
+ if (inGesture) end()
546
+ // Unmounted while lifted: the lift ends, unsaid.
547
+ if (Option.contains(Reorder.liftedValue(group), value)) Reorder.drop(group, value)
548
+ holdEnd(value, "unmount")
549
+ if (keyed.get(value) === k) keyed.delete(value)
550
+ },
551
+ }
552
+ }
553
+
554
+ return {
555
+ group,
556
+ axis: () => Option.getOrElse(appAxis(), () => detected),
557
+ attach: (el) => {
558
+ MutableRef.set(element, Option.some(el))
559
+ MutableRef.set(autoScroller, Option.some(makeAutoScroller(el, debug)))
560
+ // The live region has to be in the page before the first lift (ADR-0037, Q7).
561
+ if (keyboardOn() && Predicate.isNotUndefined(announce)) prepareAnnouncer(documentOf(el))
562
+ },
563
+ item,
564
+ move: (value, toIndex) =>
565
+ Option.match(Reorder.move(group, value, toIndex), {
566
+ onNone: constVoid,
567
+ onSome: (placement) => {
568
+ if (placement.moved) announceNow(say("move", value, placement))
569
+ },
570
+ }),
571
+ detect: () => Reorder.resolveAxis(group),
572
+ valuesChanged: () => {
573
+ // The app changed `values` itself while an item is lifted: it's dropped at its new place.
574
+ if (Option.isSome(Reorder.liftedValue(group)) && options.values() !== asked) dropLifted()
575
+ projection.notify()
576
+ },
577
+ dispose: () => {
578
+ stopPassEnd()
579
+ resetAutoScroll()
580
+ },
581
+ }
582
+ }
583
+
584
+ const noop = (): void => {}
585
+
586
+ /**
587
+ * A {@link ReorderController} for a server render (ADR-0021), where there's no projection
588
+ * system to drive and nothing to reorder: its group has no values, so every operation on it
589
+ * does nothing, the axis is `"y"`, and an item's driver is inert too. A binding's server path hands it to its group, so code
590
+ * that reads `group`, `move` or `controller` at setup works there. Tier 2, for binding
591
+ * authors.
592
+ */
593
+ export const INERT_REORDER_CONTROLLER: ReorderController<never> = Object.freeze({
594
+ // A group with no values: every operation on it finds nothing to do.
595
+ group: Reorder.make<never>({ values: () => [], onReorder: noop }),
596
+ axis: () => "y" as const,
597
+ attach: noop,
598
+ item: () => ({ register: () => noop, dragAxis: () => "y" as const, onSample: noop, onKey: noop, onBlur: noop, onPress: noop, dispose: noop }),
599
+ move: noop,
600
+ detect: () => "y" as const,
601
+ valuesChanged: noop,
602
+ dispose: noop,
603
+ })
604
+
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Where a binding's failure stops (ADR-0030, decision 4): `reportFailure` logs it at Error and
3
+ * reports it to the Environment's `ErrorReporter`s. Core doesn't report: the Frame loop hands each
4
+ * run's failure to its caller (ADR-0027, decision 7), and a Tier 2 caller may recover. A binding
5
+ * boundary that ends a fiber nobody observes calls this, so a failure there reaches the console
6
+ * and an app's reporters instead of stopping its feature with nothing said.
7
+ *
8
+ * An app installs its reporters on the Environment it hands the provider,
9
+ * `ErrorReporter.layer([…])` merged into `env`; Wardic ships none. With none installed, a report
10
+ * is one `getRef` and a size check, on the failure path only. A reporter built with
11
+ * `ErrorReporter.make` dedupes a cause and its original error, so a failure that crosses two
12
+ * boundaries reports once: pass the cause on as it came, never wrapped.
13
+ *
14
+ * Tier 2 (ADR-0008): for binding authors.
15
+ */
16
+ import * as Bool from "effect/Boolean"
17
+ import * as Cause from "effect/Cause"
18
+ import * as Effect from "effect/Effect"
19
+ import * as ErrorReporter from "effect/ErrorReporter"
20
+ import * as LogLevel from "effect/LogLevel"
21
+ import * as Predicate from "effect/Predicate"
22
+ import * as References from "effect/References"
23
+
24
+ /**
25
+ * `cause` logged at Error as `message`, with the cause, and handed to the Environment's
26
+ * `ErrorReporter`s. An interruption is neither: it's how a run is removed, by a retarget, an unmount or a
27
+ * dispose. Annotate the log by piping `Effect.annotateLogs` onto it.
28
+ */
29
+ export const reportFailure = (message: string, cause: Cause.Cause<unknown>): Effect.Effect<void> =>
30
+ Bool.match(Cause.hasInterruptsOnly(cause), {
31
+ onTrue: () => Effect.void,
32
+ onFalse: () => Effect.logError(message, cause).pipe(Effect.andThen(ErrorReporter.report(cause))),
33
+ })
34
+
35
+ /**
36
+ * Whether a Debug log would be kept, as Effect's `MinimumLogLevel` says. Run it once (a runtime, a
37
+ * build) and keep the answer, not once a log.
38
+ */
39
+ // #618: the spine reads it alone, so an app that logs nothing else doesn't ship debugOf's log.
40
+ export const debugKept: Effect.Effect<boolean> = /*#__PURE__*/ Effect.map(References.MinimumLogLevel, (level) => Bool.not(LogLevel.isGreaterThan(level, "Debug")))
41
+
42
+ /**
43
+ * A lifecycle debug log for `scope` (`wardic.projection`, say): `log(event, annotations?)` logs
44
+ * `scope: event` at Debug, forked on `runner`, with the annotations. Whether a Debug log would be kept
45
+ * is read once, here ({@link debugKept}): when it wouldn't, `on` is `false` and `log` returns at once,
46
+ * forking nothing. A site that builds its annotations per element checks `on` first.
47
+ */
48
+ // #618 (M4): each call forked a fiber whatever the level, 0.3-0.5 µs to log nothing (one per layout
49
+ // node's mount and unmount); core's Frame loop reads the level once as this does (frameLoop.ts).
50
+ export const debugOf = (runner: { readonly runSync: <A>(effect: Effect.Effect<A>) => A; readonly runFork: <A>(effect: Effect.Effect<A>) => unknown }, scope: string): DebugLog => {
51
+ const on = runner.runSync(debugKept)
52
+ const log = (event: string, annotations?: Record<string, unknown>): void => {
53
+ if (!on) return
54
+ const logged = Effect.logDebug(`${scope}: ${event}`)
55
+ if (Predicate.isUndefined(annotations)) {
56
+ runner.runFork(logged)
57
+ return
58
+ }
59
+ runner.runFork(Effect.annotateLogs(logged, annotations))
60
+ }
61
+ return Object.assign(log, { on })
62
+ }
63
+
64
+ /** {@link debugOf}'s log: called with an event and its annotations, and `on` when a Debug log is kept. */
65
+ export interface DebugLog {
66
+ (event: string, annotations?: Record<string, unknown>): void
67
+ /** Whether a Debug log would be kept: when it's `false`, a call logs nothing. */
68
+ readonly on: boolean
69
+ }