@cssints/apg-specs 0.0.0-stage → 0.5.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 (260) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -2
  3. package/dist/apg/accordion.bridge.d.mts +35 -0
  4. package/dist/apg/accordion.bridge.mjs +326 -0
  5. package/dist/apg/accordion.model.json +8397 -0
  6. package/dist/apg/alert.bridge.d.mts +22 -0
  7. package/dist/apg/alert.bridge.mjs +86 -0
  8. package/dist/apg/alert.model.json +871 -0
  9. package/dist/apg/alertdialog.bridge.d.mts +21 -0
  10. package/dist/apg/alertdialog.bridge.mjs +221 -0
  11. package/dist/apg/alertdialog.model.json +5232 -0
  12. package/dist/apg/breadcrumb.bridge.d.mts +26 -0
  13. package/dist/apg/breadcrumb.bridge.mjs +120 -0
  14. package/dist/apg/breadcrumb.model.json +574 -0
  15. package/dist/apg/button.bridge.d.mts +35 -0
  16. package/dist/apg/button.bridge.mjs +208 -0
  17. package/dist/apg/button.model.json +5814 -0
  18. package/dist/apg/carousel.bridge.d.mts +44 -0
  19. package/dist/apg/carousel.bridge.mjs +262 -0
  20. package/dist/apg/carousel.model.json +8963 -0
  21. package/dist/apg/checkbox-group.bridge.d.mts +25 -0
  22. package/dist/apg/checkbox-group.bridge.mjs +276 -0
  23. package/dist/apg/checkbox-group.model.json +3863 -0
  24. package/dist/apg/checkbox.bridge.d.mts +34 -0
  25. package/dist/apg/checkbox.bridge.mjs +253 -0
  26. package/dist/apg/checkbox.model.json +3158 -0
  27. package/dist/apg/combobox-autocomplete.bridge.d.mts +14 -0
  28. package/dist/apg/combobox-autocomplete.bridge.mjs +326 -0
  29. package/dist/apg/combobox-autocomplete.model.json +11586 -0
  30. package/dist/apg/combobox-dialog.bridge.d.mts +17 -0
  31. package/dist/apg/combobox-dialog.bridge.mjs +250 -0
  32. package/dist/apg/combobox-dialog.model.json +8981 -0
  33. package/dist/apg/combobox-grid.bridge.d.mts +17 -0
  34. package/dist/apg/combobox-grid.bridge.mjs +227 -0
  35. package/dist/apg/combobox-grid.model.json +8554 -0
  36. package/dist/apg/combobox-select-only.bridge.d.mts +24 -0
  37. package/dist/apg/combobox-select-only.bridge.mjs +304 -0
  38. package/dist/apg/combobox-select-only.model.json +7846 -0
  39. package/dist/apg/combobox-tree.bridge.d.mts +17 -0
  40. package/dist/apg/combobox-tree.bridge.mjs +211 -0
  41. package/dist/apg/combobox-tree.model.json +9345 -0
  42. package/dist/apg/dialog-modal.bridge.d.mts +35 -0
  43. package/dist/apg/dialog-modal.bridge.mjs +186 -0
  44. package/dist/apg/dialog-modal.model.json +7580 -0
  45. package/dist/apg/disclosure.bridge.d.mts +25 -0
  46. package/dist/apg/disclosure.bridge.mjs +202 -0
  47. package/dist/apg/disclosure.model.json +4703 -0
  48. package/dist/apg/feed.bridge.d.mts +31 -0
  49. package/dist/apg/feed.bridge.mjs +246 -0
  50. package/dist/apg/feed.model.json +4523 -0
  51. package/dist/apg/grid-selection.bridge.d.mts +13 -0
  52. package/dist/apg/grid-selection.bridge.mjs +160 -0
  53. package/dist/apg/grid-selection.model.json +6974 -0
  54. package/dist/apg/grid-sort.bridge.d.mts +12 -0
  55. package/dist/apg/grid-sort.bridge.mjs +138 -0
  56. package/dist/apg/grid-sort.model.json +3799 -0
  57. package/dist/apg/grid.bridge.d.mts +54 -0
  58. package/dist/apg/grid.bridge.mjs +441 -0
  59. package/dist/apg/grid.model.json +3802 -0
  60. package/dist/apg/link.bridge.d.mts +24 -0
  61. package/dist/apg/link.bridge.mjs +126 -0
  62. package/dist/apg/link.model.json +694 -0
  63. package/dist/apg/listbox-multi.bridge.d.mts +10 -0
  64. package/dist/apg/listbox-multi.bridge.mjs +261 -0
  65. package/dist/apg/listbox-multi.model.json +10893 -0
  66. package/dist/apg/listbox-rearrangeable.bridge.d.mts +20 -0
  67. package/dist/apg/listbox-rearrangeable.bridge.mjs +237 -0
  68. package/dist/apg/listbox-rearrangeable.model.json +17592 -0
  69. package/dist/apg/listbox-shared.d.mts +58 -0
  70. package/dist/apg/listbox-shared.mjs +171 -0
  71. package/dist/apg/listbox-single.bridge.d.mts +10 -0
  72. package/dist/apg/listbox-single.bridge.mjs +196 -0
  73. package/dist/apg/listbox-single.model.json +8024 -0
  74. package/dist/apg/listbox-typeahead.bridge.d.mts +10 -0
  75. package/dist/apg/listbox-typeahead.bridge.mjs +153 -0
  76. package/dist/apg/listbox-typeahead.model.json +5615 -0
  77. package/dist/apg/menu-button.bridge.d.mts +29 -0
  78. package/dist/apg/menu-button.bridge.mjs +259 -0
  79. package/dist/apg/menu-button.model.json +5706 -0
  80. package/dist/apg/menu-checkable.bridge.d.mts +25 -0
  81. package/dist/apg/menu-checkable.bridge.mjs +226 -0
  82. package/dist/apg/menu-checkable.model.json +4277 -0
  83. package/dist/apg/menu-menubar.bridge.d.mts +27 -0
  84. package/dist/apg/menu-menubar.bridge.mjs +273 -0
  85. package/dist/apg/menu-menubar.model.json +9540 -0
  86. package/dist/apg/meter.bridge.d.mts +28 -0
  87. package/dist/apg/meter.bridge.mjs +159 -0
  88. package/dist/apg/meter.model.json +1185 -0
  89. package/dist/apg/player.bridge.d.mts +24 -0
  90. package/dist/apg/player.bridge.mjs +342 -0
  91. package/dist/apg/player.model.json +11013 -0
  92. package/dist/apg/radio.bridge.d.mts +35 -0
  93. package/dist/apg/radio.bridge.mjs +371 -0
  94. package/dist/apg/radio.model.json +5011 -0
  95. package/dist/apg/slider-multithumb.bridge.d.mts +34 -0
  96. package/dist/apg/slider-multithumb.bridge.mjs +365 -0
  97. package/dist/apg/slider-multithumb.model.json +5774 -0
  98. package/dist/apg/slider.bridge.d.mts +54 -0
  99. package/dist/apg/slider.bridge.mjs +359 -0
  100. package/dist/apg/slider.model.json +4881 -0
  101. package/dist/apg/spinbutton.bridge.d.mts +41 -0
  102. package/dist/apg/spinbutton.bridge.mjs +327 -0
  103. package/dist/apg/spinbutton.model.json +7571 -0
  104. package/dist/apg/switch.bridge.d.mts +30 -0
  105. package/dist/apg/switch.bridge.mjs +243 -0
  106. package/dist/apg/switch.model.json +3159 -0
  107. package/dist/apg/tabs.bridge.d.mts +41 -0
  108. package/dist/apg/tabs.bridge.mjs +294 -0
  109. package/dist/apg/tabs.model.json +7659 -0
  110. package/dist/apg/timeline.bridge.d.mts +36 -0
  111. package/dist/apg/timeline.bridge.mjs +265 -0
  112. package/dist/apg/timeline.model.json +6189 -0
  113. package/dist/apg/toolbar.bridge.d.mts +25 -0
  114. package/dist/apg/toolbar.bridge.mjs +307 -0
  115. package/dist/apg/toolbar.model.json +6616 -0
  116. package/dist/apg/tooltip.bridge.d.mts +36 -0
  117. package/dist/apg/tooltip.bridge.mjs +208 -0
  118. package/dist/apg/tooltip.model.json +8295 -0
  119. package/dist/apg/treegrid.bridge.d.mts +28 -0
  120. package/dist/apg/treegrid.bridge.mjs +311 -0
  121. package/dist/apg/treegrid.model.json +17358 -0
  122. package/dist/apg/treeview.bridge.d.mts +26 -0
  123. package/dist/apg/treeview.bridge.mjs +396 -0
  124. package/dist/apg/treeview.model.json +13745 -0
  125. package/dist/apg/windowsplitter.bridge.d.mts +36 -0
  126. package/dist/apg/windowsplitter.bridge.mjs +242 -0
  127. package/dist/apg/windowsplitter.model.json +6253 -0
  128. package/dist/canvas/camera.model.json +6568 -0
  129. package/dist/canvas/canvas.bridge.d.mts +190 -0
  130. package/dist/canvas/canvas.bridge.mjs +729 -0
  131. package/dist/canvas/pointer.model.json +17095 -0
  132. package/dist/scenario.d.mts +233 -0
  133. package/dist/scenario.mjs +313 -0
  134. package/package.json +57 -4
  135. package/scenarios/apg/README.md +221 -0
  136. package/scenarios/apg/accordion.base-ui.json +1005 -0
  137. package/scenarios/apg/accordion.react-aria.json +929 -0
  138. package/scenarios/apg/alert.base-ui.json +81 -0
  139. package/scenarios/apg/alert.react-aria.json +98 -0
  140. package/scenarios/apg/alertdialog.base-ui.json +331 -0
  141. package/scenarios/apg/alertdialog.react-aria.json +225 -0
  142. package/scenarios/apg/breadcrumb.react-aria.json +312 -0
  143. package/scenarios/apg/button.ariakit.json +136 -0
  144. package/scenarios/apg/button.base-ui.json +747 -0
  145. package/scenarios/apg/button.react-aria.json +443 -0
  146. package/scenarios/apg/carousel.apg.json +909 -0
  147. package/scenarios/apg/checkbox.ariakit.json +299 -0
  148. package/scenarios/apg/checkbox.base-ui.json +1126 -0
  149. package/scenarios/apg/checkbox.react-aria.json +518 -0
  150. package/scenarios/apg/combobox.ariakit.json +945 -0
  151. package/scenarios/apg/combobox.base-ui.json +2444 -0
  152. package/scenarios/apg/combobox.react-aria.json +845 -0
  153. package/scenarios/apg/dialog-modal.ariakit.json +1102 -0
  154. package/scenarios/apg/dialog-modal.base-ui.json +775 -0
  155. package/scenarios/apg/dialog-modal.react-aria.json +638 -0
  156. package/scenarios/apg/disclosure.apg.json +349 -0
  157. package/scenarios/apg/disclosure.ariakit.json +163 -0
  158. package/scenarios/apg/disclosure.base-ui.json +451 -0
  159. package/scenarios/apg/disclosure.react-aria.json +643 -0
  160. package/scenarios/apg/feed.apg.json +442 -0
  161. package/scenarios/apg/grid.ariakit.json +197 -0
  162. package/scenarios/apg/grid.base-ui.json +720 -0
  163. package/scenarios/apg/grid.react-aria.json +969 -0
  164. package/scenarios/apg/landmarks.ariakit.json +93 -0
  165. package/scenarios/apg/landmarks.react-aria.json +330 -0
  166. package/scenarios/apg/link.ariakit.json +83 -0
  167. package/scenarios/apg/link.base-ui.json +88 -0
  168. package/scenarios/apg/link.react-aria.json +186 -0
  169. package/scenarios/apg/listbox.ariakit.json +78 -0
  170. package/scenarios/apg/listbox.base-ui.json +163 -0
  171. package/scenarios/apg/listbox.react-aria.json +1118 -0
  172. package/scenarios/apg/menu-button.ariakit.json +924 -0
  173. package/scenarios/apg/menu-button.base-ui.json +1155 -0
  174. package/scenarios/apg/menu-button.react-aria.json +1080 -0
  175. package/scenarios/apg/menubar.ariakit.json +758 -0
  176. package/scenarios/apg/menubar.base-ui.json +673 -0
  177. package/scenarios/apg/meter.base-ui.json +243 -0
  178. package/scenarios/apg/meter.react-aria.json +201 -0
  179. package/scenarios/apg/radio.ariakit.json +459 -0
  180. package/scenarios/apg/radio.base-ui.json +581 -0
  181. package/scenarios/apg/radio.react-aria.json +576 -0
  182. package/scenarios/apg/slider-multithumb.base-ui.json +316 -0
  183. package/scenarios/apg/slider-multithumb.react-aria.json +363 -0
  184. package/scenarios/apg/slider.base-ui.json +544 -0
  185. package/scenarios/apg/slider.react-aria.json +456 -0
  186. package/scenarios/apg/spinbutton.base-ui.json +1688 -0
  187. package/scenarios/apg/spinbutton.react-aria.json +426 -0
  188. package/scenarios/apg/switch.base-ui.json +456 -0
  189. package/scenarios/apg/switch.react-aria.json +303 -0
  190. package/scenarios/apg/table.ariakit.json +65 -0
  191. package/scenarios/apg/table.react-aria.json +221 -0
  192. package/scenarios/apg/tabs.ariakit.json +329 -0
  193. package/scenarios/apg/tabs.base-ui.json +875 -0
  194. package/scenarios/apg/tabs.react-aria.json +644 -0
  195. package/scenarios/apg/toolbar.ariakit.json +595 -0
  196. package/scenarios/apg/toolbar.base-ui.json +1189 -0
  197. package/scenarios/apg/toolbar.react-aria.json +383 -0
  198. package/scenarios/apg/tooltip.ariakit.json +441 -0
  199. package/scenarios/apg/tooltip.base-ui.json +411 -0
  200. package/scenarios/apg/tooltip.react-aria.json +454 -0
  201. package/scenarios/apg/treegrid.apg.json +2558 -0
  202. package/scenarios/apg/treegrid.react-aria.json +5094 -0
  203. package/scenarios/apg/treeview.apg.json +928 -0
  204. package/scenarios/apg/windowsplitter.apg.json +557 -0
  205. package/scenarios/canvas/README.md +69 -0
  206. package/scenarios/canvas/camera.contract.json +237 -0
  207. package/scenarios/canvas/camera.excalidraw.json +57 -0
  208. package/scenarios/canvas/marquee.excalidraw.json +122 -0
  209. package/scenarios/canvas/pan.excalidraw.json +46 -0
  210. package/scenarios/canvas/pointer.contract.json +528 -0
  211. package/scenarios/parts.json +291 -0
  212. package/shared/aria.qnt +186 -0
  213. package/shared/details-group.qnt +50 -0
  214. package/shared/focusgroup.qnt +152 -0
  215. package/shared/invokers.qnt +81 -0
  216. package/shared/popover.qnt +71 -0
  217. package/specs/apg/STATIC.md +103 -0
  218. package/specs/apg/accordion.qnt +578 -0
  219. package/specs/apg/alert.qnt +131 -0
  220. package/specs/apg/alertdialog.qnt +455 -0
  221. package/specs/apg/breadcrumb.qnt +88 -0
  222. package/specs/apg/button.qnt +445 -0
  223. package/specs/apg/carousel.qnt +732 -0
  224. package/specs/apg/checkbox-group.qnt +388 -0
  225. package/specs/apg/checkbox.qnt +394 -0
  226. package/specs/apg/combobox-autocomplete.qnt +764 -0
  227. package/specs/apg/combobox-dialog.qnt +627 -0
  228. package/specs/apg/combobox-grid.qnt +632 -0
  229. package/specs/apg/combobox-select-only.qnt +626 -0
  230. package/specs/apg/combobox-tree.qnt +633 -0
  231. package/specs/apg/dialog-modal.qnt +499 -0
  232. package/specs/apg/disclosure.qnt +309 -0
  233. package/specs/apg/feed.qnt +485 -0
  234. package/specs/apg/grid-selection.qnt +512 -0
  235. package/specs/apg/grid-sort.qnt +330 -0
  236. package/specs/apg/grid.qnt +360 -0
  237. package/specs/apg/link.qnt +119 -0
  238. package/specs/apg/listbox-multi.qnt +799 -0
  239. package/specs/apg/listbox-rearrangeable.qnt +801 -0
  240. package/specs/apg/listbox-single.qnt +511 -0
  241. package/specs/apg/listbox-typeahead.qnt +396 -0
  242. package/specs/apg/menu-button.qnt +363 -0
  243. package/specs/apg/menu-checkable.qnt +304 -0
  244. package/specs/apg/menu-menubar.qnt +531 -0
  245. package/specs/apg/meter.qnt +129 -0
  246. package/specs/apg/player.qnt +1081 -0
  247. package/specs/apg/radio.qnt +394 -0
  248. package/specs/apg/slider-multithumb.qnt +441 -0
  249. package/specs/apg/slider.qnt +396 -0
  250. package/specs/apg/spinbutton.qnt +494 -0
  251. package/specs/apg/switch.qnt +389 -0
  252. package/specs/apg/tabs.qnt +412 -0
  253. package/specs/apg/timeline.qnt +612 -0
  254. package/specs/apg/toolbar.qnt +364 -0
  255. package/specs/apg/tooltip.qnt +553 -0
  256. package/specs/apg/treegrid.qnt +983 -0
  257. package/specs/apg/treeview.qnt +1004 -0
  258. package/specs/apg/windowsplitter.qnt +487 -0
  259. package/specs/canvas/camera.qnt +407 -0
  260. package/specs/canvas/pointer.qnt +841 -0
@@ -0,0 +1,612 @@
1
+ // Span timeline / waterfall — NOT an APG pattern.
2
+ //
3
+ // The o11y dashboard lays spans out on a shared time axis: nesting/indentation,
4
+ // a duration ruler, selection, per-parent collapse and a zoom window over the
5
+ // visible tick domain. WAI-ARIA APG has no waterfall pattern, so this is a new
6
+ // behaviour-machine modelled on the treeview spec's encoding:
7
+ //
8
+ // * the span tree is a fixed constant, ids are pre-order = document order;
9
+ // * `expanded` is a bitmask over node ids, `selected` is a node id (0 = none),
10
+ // `focus` is the roving-tabindex node and `hasFocus` says whether DOM focus
11
+ // is inside the timeline. Key events are no-ops while `hasFocus` is false.
12
+ // * the zoom window is `viewStart`/`viewEnd`, a sub-interval of the fixed
13
+ // tick domain 0..100. zoomIn shrinks it around its centre (no-op at the
14
+ // minimum width), zoomOut grows it around its centre clamped to the domain
15
+ // (no-op at full width), pan shifts it by a quarter of its width clamped
16
+ // in-domain, zoomReset restores the full domain.
17
+ //
18
+ // Request vs commit (map ticket rt1p). Selection is the value. Every event a
19
+ // user sees as a selection — `click`, `pressEnter` and the programmatic
20
+ // `select` — is a *request* carrying an intent (`requested`); it fires
21
+ // onValueChange (`requests` counts the calls) when the intent differs from the
22
+ // current `selected`, and then:
23
+ // uncontrolled the request is accepted: selection commits the intent
24
+ // controlled, holds the host ignores it ("rejected"): selection is untouched
25
+ // controlled, accepts the host answers with the same value (`accepting`): the
26
+ // selection commits through the controlled value, not the event
27
+ // `valueProp` is the only thing that changes selection while `controlled`;
28
+ // `setValue` is the host writing it. `control` / `acceptRequests` are
29
+ // configuration events (init is literal-only). Focus/expansion and the zoom
30
+ // window are not the controlled value: they keep moving on a rejected request,
31
+ // like treeview's roving focus. Invariants: ControlledFollowsProp,
32
+ // RejectedRequestUntouched, AcceptedRequestCommits.
33
+ //
34
+ // Span tree (fixed; start/end are fractions of the parent span):
35
+ //
36
+ // 1 root level 1, parent 0..1
37
+ // 2 child level 2, parent 0..0.6
38
+ // 3 leaf level 3, parent 0..0.3
39
+ // 4 leaf level 3, parent 0.3..0.6
40
+ // 5 leaf level 2, parent 0.6..1
41
+ //
42
+ // Encoding (the generator only supports str/int/bool vars): `expanded` is a
43
+ // bitmask over nodes 1..5 (bit n-1, weight 2^(n-1)); `selected` is the selected
44
+ // node id (0 = none); `focus` is the roving node (always 1..5, even while the
45
+ // timeline is not focused). This mirrors treeview.qnt but uses a selected id
46
+ // instead of a selection bitmask — a waterfall selects a single span.
47
+ //
48
+ // Deliberate omissions (ponytail): fractional/overlapping spans and lane
49
+ // packing (layout is the consumer's), drag/range selection, virtualisation,
50
+ // type-ahead, multi-select, and aria-activedescendant (the rows use roving
51
+ // tabindex). The visibility/navigation defs are shared with treeview's shape.
52
+ module apg_timeline {
53
+ var expanded: int // bitmask: parent nodes whose children are shown
54
+ var selected: int // selected node id, 0 = none
55
+ var focus: int // roving-tabindex node, 1..5
56
+ var hasFocus: bool // DOM focus is inside the timeline
57
+ var viewStart: int // zoom window start tick, 0..100
58
+ var viewEnd: int // zoom window end tick, 0..100
59
+ var controlled: bool // the host owns the selection (`value` prop present)
60
+ var accepting: bool // controlled host that answers every request with its intent
61
+ var valueProp: int // the controlled value prop (meaningful while controlled)
62
+ var requested: int // intent of the last request
63
+ var requests: int // number of requests that fired onValueChange
64
+ var outcome: str // "none" | "accepted" | "rejected"
65
+ var base: int // selection when the last request was received
66
+
67
+ // ---- the constant span tree ---------------------------------------------
68
+
69
+ val lastNode: int = 5
70
+ val allMask: int = 31
71
+ val domainMin: int = 0
72
+ val domainMax: int = 100
73
+ val minWidth: int = 10
74
+
75
+ def parentOf(n: int): int =
76
+ if (n == 2) 1
77
+ else if (n == 3) 2
78
+ else if (n == 4) 2
79
+ else if (n == 5) 1
80
+ else 0
81
+
82
+ def firstChild(n: int): int =
83
+ if (n == 1) 2
84
+ else if (n == 2) 3
85
+ else 0
86
+
87
+ def isParent(n: int): bool = firstChild(n) != 0
88
+
89
+ // ---- bitmask helpers ----------------------------------------------------
90
+
91
+ def weight(n: int): int =
92
+ if (n == 1) 1
93
+ else if (n == 2) 2
94
+ else if (n == 3) 4
95
+ else if (n == 4) 8
96
+ else if (n == 5) 16
97
+ else 0
98
+
99
+ def bit(mask: int, n: int): bool =
100
+ if (and(n >= 1, n <= lastNode)) ((mask / weight(n)) % 2) == 1 else false
101
+
102
+ def setBit(mask: int, n: int, on: bool): int =
103
+ if (bit(mask, n) == on) mask
104
+ else if (on) mask + weight(n)
105
+ else mask - weight(n)
106
+
107
+ def toggleBit(mask: int, n: int): int = setBit(mask, n, not(bit(mask, n)))
108
+
109
+ // ---- visibility and navigation ------------------------------------------
110
+
111
+ // the node's parent is shown-open (roots, and the pseudo-parent 0, always are)
112
+ def parentOpen(n: int, e: int): bool = or(parentOf(n) == 0, bit(e, parentOf(n)))
113
+
114
+ // focusable: every ancestor is expanded (depth <= 3 => two ancestor checks)
115
+ def visible(n: int, e: int): bool =
116
+ and(n >= 1, n <= lastNode, parentOpen(n, e), parentOpen(parentOf(n), e))
117
+
118
+ def visibleAfter(n: int, k: int, e: int): bool =
119
+ and(n + k <= lastNode, visible(n + k, e))
120
+ def visibleBefore(n: int, k: int, e: int): bool =
121
+ and(n - k >= 1, visible(n - k, e))
122
+
123
+ // Down: the next visible node in document order; the last node stays put
124
+ def nextVisible(n: int, e: int): int =
125
+ if (visibleAfter(n, 1, e)) n + 1
126
+ else if (visibleAfter(n, 2, e)) n + 2
127
+ else if (visibleAfter(n, 3, e)) n + 3
128
+ else if (visibleAfter(n, 4, e)) n + 4
129
+ else n
130
+
131
+ // Up: the previous visible node; the first node stays put
132
+ def prevVisible(n: int, e: int): int =
133
+ if (visibleBefore(n, 1, e)) n - 1
134
+ else if (visibleBefore(n, 2, e)) n - 2
135
+ else if (visibleBefore(n, 3, e)) n - 3
136
+ else if (visibleBefore(n, 4, e)) n - 4
137
+ else n
138
+
139
+ // End: the last node focusable without opening anything (node 1 is a root)
140
+ def lastVisible(e: int): int =
141
+ if (visible(5, e)) 5
142
+ else if (visible(4, e)) 4
143
+ else if (visible(3, e)) 3
144
+ else if (visible(2, e)) 2
145
+ else 1
146
+
147
+ // Right on a closed parent opens it (focus stays); on an open parent focus
148
+ // moves to its first child; on a leaf nothing happens
149
+ def rightExpands(f: int, e: int): bool = and(isParent(f), not(bit(e, f)))
150
+ def rightTarget(f: int, e: int): int =
151
+ if (and(isParent(f), bit(e, f))) firstChild(f) else f
152
+
153
+ // Left on an open parent closes it (focus stays); otherwise focus moves to
154
+ // the parent; on a root nothing happens
155
+ def leftCollapses(f: int, e: int): bool = and(isParent(f), bit(e, f))
156
+ def leftTarget(f: int, e: int): int =
157
+ if (leftCollapses(f, e)) f
158
+ else if (parentOf(f) == 0) f
159
+ else parentOf(f)
160
+
161
+ // ---- zoom window --------------------------------------------------------
162
+
163
+ def w(s: int, e: int): int = e - s
164
+
165
+ // shrink by a quarter, never below the minimum width
166
+ def shrink(x: int): int =
167
+ if (x <= minWidth) x
168
+ else if (x - (x / 4) < minWidth) minWidth
169
+ else x - (x / 4)
170
+
171
+ // grow by a quarter, never past the domain
172
+ def grow(x: int): int =
173
+ if (x >= domainMax) x
174
+ else if (x + (x / 4) > domainMax) domainMax
175
+ else x + (x / 4)
176
+
177
+ // clamp the window start so [start, start+width] stays inside the domain
178
+ def clampStart(s: int, wi: int): int =
179
+ if (s < domainMin) domainMin
180
+ else if (s + wi > domainMax) domainMax - wi
181
+ else s
182
+
183
+ def zoomInStart(s: int, e: int): int =
184
+ clampStart(s + ((w(s, e) - shrink(w(s, e))) / 2), shrink(w(s, e)))
185
+ def zoomInEnd(s: int, e: int): int = zoomInStart(s, e) + shrink(w(s, e))
186
+
187
+ def zoomOutStart(s: int, e: int): int =
188
+ clampStart(s - ((grow(w(s, e)) - w(s, e)) / 2), grow(w(s, e)))
189
+ def zoomOutEnd(s: int, e: int): int = zoomOutStart(s, e) + grow(w(s, e))
190
+
191
+ def panRightStart(s: int, e: int): int =
192
+ clampStart(s + (w(s, e) / 4), w(s, e))
193
+ def panRightEnd(s: int, e: int): int = panRightStart(s, e) + w(s, e)
194
+
195
+ def panLeftStart(s: int, e: int): int =
196
+ clampStart(s - (w(s, e) / 4), w(s, e))
197
+ def panLeftEnd(s: int, e: int): int = panLeftStart(s, e) + w(s, e)
198
+
199
+ // ---- request / commit (map ticket rt1p) ---------------------------------
200
+
201
+ // a change of intent fires onValueChange; an inert event (same intent) does not
202
+ def moved(before: int, after: int): int = if (before == after) 0 else 1
203
+ // the node a pointer click would select (an invisible target is inert)
204
+ def clickWant(node: int, e: int, sel: int): int =
205
+ if (visible(node, e)) node else sel
206
+ // the node Enter would select (a key outside the timeline is inert)
207
+ def enterWant(hf: bool, f: int, sel: int): int = if (hf) f else sel
208
+ // controlled host that keeps the old value: the request is rejected
209
+ def holds: bool = and(controlled, not(accepting))
210
+ // controlled host that writes the intent back through the value prop
211
+ def follows: bool = and(controlled, accepting)
212
+ def verdict: str = if (holds) "rejected" else "accepted"
213
+ // the selection after a request for `want`
214
+ def landed(h: bool, current: int, want: int): int = if (h) current else want
215
+ // the value prop after a request for `want`
216
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
217
+
218
+ // ---- actions ------------------------------------------------------------
219
+
220
+ action init = all {
221
+ expanded' = 0,
222
+ selected' = 0,
223
+ focus' = 1,
224
+ hasFocus' = false,
225
+ viewStart' = domainMin,
226
+ viewEnd' = domainMax,
227
+ controlled' = false,
228
+ accepting' = false,
229
+ valueProp' = 0,
230
+ requested' = 0,
231
+ requests' = 0,
232
+ outcome' = "none",
233
+ base' = 0
234
+ }
235
+
236
+ // Tab into the timeline: the roving node receives focus
237
+ action focusTimeline = all {
238
+ expanded' = expanded,
239
+ selected' = selected,
240
+ focus' = focus,
241
+ hasFocus' = true,
242
+ viewStart' = viewStart,
243
+ viewEnd' = viewEnd,
244
+ controlled' = controlled,
245
+ accepting' = accepting,
246
+ valueProp' = valueProp,
247
+ requested' = requested,
248
+ requests' = requests,
249
+ outcome' = outcome,
250
+ base' = base
251
+ }
252
+
253
+ action blurTimeline = all {
254
+ expanded' = expanded,
255
+ selected' = selected,
256
+ focus' = focus,
257
+ hasFocus' = false,
258
+ viewStart' = viewStart,
259
+ viewEnd' = viewEnd,
260
+ controlled' = controlled,
261
+ accepting' = accepting,
262
+ valueProp' = valueProp,
263
+ requested' = requested,
264
+ requests' = requests,
265
+ outcome' = outcome,
266
+ base' = base
267
+ }
268
+
269
+ // pointer/programmatic focus of one row: hidden rows are unreachable
270
+ action focusNode = {
271
+ nondet node = Set(1, 2, 3, 4, 5).oneOf()
272
+ all {
273
+ expanded' = expanded,
274
+ selected' = selected,
275
+ focus' = if (visible(node, expanded)) node else focus,
276
+ hasFocus' = if (visible(node, expanded)) true else hasFocus,
277
+ viewStart' = viewStart,
278
+ viewEnd' = viewEnd,
279
+ controlled' = controlled,
280
+ accepting' = accepting,
281
+ valueProp' = valueProp,
282
+ requested' = requested,
283
+ requests' = requests,
284
+ outcome' = outcome,
285
+ base' = base
286
+ }
287
+ }
288
+
289
+ action pressDown = all {
290
+ expanded' = expanded,
291
+ selected' = selected,
292
+ focus' = if (hasFocus) nextVisible(focus, expanded) else focus,
293
+ hasFocus' = hasFocus,
294
+ viewStart' = viewStart,
295
+ viewEnd' = viewEnd,
296
+ controlled' = controlled,
297
+ accepting' = accepting,
298
+ valueProp' = valueProp,
299
+ requested' = requested,
300
+ requests' = requests,
301
+ outcome' = outcome,
302
+ base' = base
303
+ }
304
+
305
+ action pressUp = all {
306
+ expanded' = expanded,
307
+ selected' = selected,
308
+ focus' = if (hasFocus) prevVisible(focus, expanded) else focus,
309
+ hasFocus' = hasFocus,
310
+ viewStart' = viewStart,
311
+ viewEnd' = viewEnd,
312
+ controlled' = controlled,
313
+ accepting' = accepting,
314
+ valueProp' = valueProp,
315
+ requested' = requested,
316
+ requests' = requests,
317
+ outcome' = outcome,
318
+ base' = base
319
+ }
320
+
321
+ action pressRight = all {
322
+ expanded' = if (and(hasFocus, rightExpands(focus, expanded))) setBit(expanded, focus, true) else expanded,
323
+ selected' = selected,
324
+ focus' = if (hasFocus) rightTarget(focus, expanded) else focus,
325
+ hasFocus' = hasFocus,
326
+ viewStart' = viewStart,
327
+ viewEnd' = viewEnd,
328
+ controlled' = controlled,
329
+ accepting' = accepting,
330
+ valueProp' = valueProp,
331
+ requested' = requested,
332
+ requests' = requests,
333
+ outcome' = outcome,
334
+ base' = base
335
+ }
336
+
337
+ action pressLeft = all {
338
+ expanded' = if (and(hasFocus, leftCollapses(focus, expanded))) setBit(expanded, focus, false) else expanded,
339
+ selected' = selected,
340
+ focus' = if (hasFocus) leftTarget(focus, expanded) else focus,
341
+ hasFocus' = hasFocus,
342
+ viewStart' = viewStart,
343
+ viewEnd' = viewEnd,
344
+ controlled' = controlled,
345
+ accepting' = accepting,
346
+ valueProp' = valueProp,
347
+ requested' = requested,
348
+ requests' = requests,
349
+ outcome' = outcome,
350
+ base' = base
351
+ }
352
+
353
+ action pressHome = all {
354
+ expanded' = expanded,
355
+ selected' = selected,
356
+ focus' = if (hasFocus) 1 else focus,
357
+ hasFocus' = hasFocus,
358
+ viewStart' = viewStart,
359
+ viewEnd' = viewEnd,
360
+ controlled' = controlled,
361
+ accepting' = accepting,
362
+ valueProp' = valueProp,
363
+ requested' = requested,
364
+ requests' = requests,
365
+ outcome' = outcome,
366
+ base' = base
367
+ }
368
+
369
+ action pressEnd = all {
370
+ expanded' = expanded,
371
+ selected' = selected,
372
+ focus' = if (hasFocus) lastVisible(expanded) else focus,
373
+ hasFocus' = hasFocus,
374
+ viewStart' = viewStart,
375
+ viewEnd' = viewEnd,
376
+ controlled' = controlled,
377
+ accepting' = accepting,
378
+ valueProp' = valueProp,
379
+ requested' = requested,
380
+ requests' = requests,
381
+ outcome' = outcome,
382
+ base' = base
383
+ }
384
+
385
+ // request: Enter selects the focused span (a key outside the timeline is inert)
386
+ action pressEnter = all {
387
+ expanded' = expanded,
388
+ selected' = landed(holds, selected, enterWant(hasFocus, focus, selected)),
389
+ focus' = focus,
390
+ hasFocus' = hasFocus,
391
+ viewStart' = viewStart,
392
+ viewEnd' = viewEnd,
393
+ controlled' = controlled,
394
+ accepting' = accepting,
395
+ valueProp' = propAfter(follows, valueProp, enterWant(hasFocus, focus, selected)),
396
+ requested' = if (moved(selected, enterWant(hasFocus, focus, selected)) == 1) enterWant(hasFocus, focus, selected) else requested,
397
+ requests' = requests + moved(selected, enterWant(hasFocus, focus, selected)),
398
+ outcome' = if (moved(selected, enterWant(hasFocus, focus, selected)) == 1) verdict else outcome,
399
+ base' = if (moved(selected, enterWant(hasFocus, focus, selected)) == 1) selected else base
400
+ }
401
+
402
+ action zoomIn = all {
403
+ expanded' = expanded,
404
+ selected' = selected,
405
+ focus' = focus,
406
+ hasFocus' = hasFocus,
407
+ viewStart' = zoomInStart(viewStart, viewEnd),
408
+ viewEnd' = zoomInEnd(viewStart, viewEnd),
409
+ controlled' = controlled,
410
+ accepting' = accepting,
411
+ valueProp' = valueProp,
412
+ requested' = requested,
413
+ requests' = requests,
414
+ outcome' = outcome,
415
+ base' = base
416
+ }
417
+
418
+ action zoomOut = all {
419
+ expanded' = expanded,
420
+ selected' = selected,
421
+ focus' = focus,
422
+ hasFocus' = hasFocus,
423
+ viewStart' = zoomOutStart(viewStart, viewEnd),
424
+ viewEnd' = zoomOutEnd(viewStart, viewEnd),
425
+ controlled' = controlled,
426
+ accepting' = accepting,
427
+ valueProp' = valueProp,
428
+ requested' = requested,
429
+ requests' = requests,
430
+ outcome' = outcome,
431
+ base' = base
432
+ }
433
+
434
+ action zoomReset = all {
435
+ expanded' = expanded,
436
+ selected' = selected,
437
+ focus' = focus,
438
+ hasFocus' = hasFocus,
439
+ viewStart' = domainMin,
440
+ viewEnd' = domainMax,
441
+ controlled' = controlled,
442
+ accepting' = accepting,
443
+ valueProp' = valueProp,
444
+ requested' = requested,
445
+ requests' = requests,
446
+ outcome' = outcome,
447
+ base' = base
448
+ }
449
+
450
+ action panLeft = all {
451
+ expanded' = expanded,
452
+ selected' = selected,
453
+ focus' = focus,
454
+ hasFocus' = hasFocus,
455
+ viewStart' = panLeftStart(viewStart, viewEnd),
456
+ viewEnd' = panLeftEnd(viewStart, viewEnd),
457
+ controlled' = controlled,
458
+ accepting' = accepting,
459
+ valueProp' = valueProp,
460
+ requested' = requested,
461
+ requests' = requests,
462
+ outcome' = outcome,
463
+ base' = base
464
+ }
465
+
466
+ action panRight = all {
467
+ expanded' = expanded,
468
+ selected' = selected,
469
+ focus' = focus,
470
+ hasFocus' = hasFocus,
471
+ viewStart' = panRightStart(viewStart, viewEnd),
472
+ viewEnd' = panRightEnd(viewStart, viewEnd),
473
+ controlled' = controlled,
474
+ accepting' = accepting,
475
+ valueProp' = valueProp,
476
+ requested' = requested,
477
+ requests' = requests,
478
+ outcome' = outcome,
479
+ base' = base
480
+ }
481
+
482
+ // request: pointer click on a row. Only a visible row can be hit. It takes
483
+ // focus, requests becoming the sole selection, and a parent opens/closes.
484
+ action click = {
485
+ nondet node = Set(1, 2, 3, 4, 5).oneOf()
486
+ all {
487
+ expanded' = if (and(visible(node, expanded), isParent(node))) toggleBit(expanded, node) else expanded,
488
+ selected' = landed(holds, selected, clickWant(node, expanded, selected)),
489
+ focus' = if (visible(node, expanded)) node else focus,
490
+ hasFocus' = if (visible(node, expanded)) true else hasFocus,
491
+ viewStart' = viewStart,
492
+ viewEnd' = viewEnd,
493
+ controlled' = controlled,
494
+ accepting' = accepting,
495
+ valueProp' = propAfter(follows, valueProp, clickWant(node, expanded, selected)),
496
+ requested' = if (moved(selected, clickWant(node, expanded, selected)) == 1) clickWant(node, expanded, selected) else requested,
497
+ requests' = requests + moved(selected, clickWant(node, expanded, selected)),
498
+ outcome' = if (moved(selected, clickWant(node, expanded, selected)) == 1) verdict else outcome,
499
+ base' = if (moved(selected, clickWant(node, expanded, selected)) == 1) selected else base
500
+ }
501
+ }
502
+
503
+ // request: the programmatic selection API (any node, no focus change)
504
+ // (named selectNode: `select` is a Quint built-in, QNT101)
505
+ action selectNode = {
506
+ nondet node = Set(1, 2, 3, 4, 5).oneOf()
507
+ all {
508
+ expanded' = expanded,
509
+ selected' = landed(holds, selected, node),
510
+ focus' = focus,
511
+ hasFocus' = hasFocus,
512
+ viewStart' = viewStart,
513
+ viewEnd' = viewEnd,
514
+ controlled' = controlled,
515
+ accepting' = accepting,
516
+ valueProp' = propAfter(follows, valueProp, node),
517
+ requested' = if (moved(selected, node) == 1) node else requested,
518
+ requests' = requests + moved(selected, node),
519
+ outcome' = if (moved(selected, node) == 1) verdict else outcome,
520
+ base' = if (moved(selected, node) == 1) selected else base
521
+ }
522
+ }
523
+
524
+ // commit: the host writes the controlled selection (a no-op while uncontrolled)
525
+ action setValue = {
526
+ nondet v = Set(0, 1, 2, 3, 4, 5).oneOf()
527
+ all {
528
+ expanded' = expanded,
529
+ selected' = if (controlled) v else selected,
530
+ focus' = focus,
531
+ hasFocus' = hasFocus,
532
+ viewStart' = viewStart,
533
+ viewEnd' = viewEnd,
534
+ controlled' = controlled,
535
+ accepting' = accepting,
536
+ valueProp' = if (controlled) v else valueProp,
537
+ requested' = requested,
538
+ requests' = requests,
539
+ outcome' = if (controlled) "none" else outcome,
540
+ base' = base
541
+ }
542
+ }
543
+
544
+ // configuration: the host starts owning the selection, adopting the current one
545
+ action control = all {
546
+ expanded' = expanded,
547
+ selected' = selected,
548
+ focus' = focus,
549
+ hasFocus' = hasFocus,
550
+ viewStart' = viewStart,
551
+ viewEnd' = viewEnd,
552
+ controlled' = true,
553
+ accepting' = accepting,
554
+ valueProp' = selected,
555
+ requested' = requested,
556
+ requests' = requests,
557
+ outcome' = outcome,
558
+ base' = base
559
+ }
560
+
561
+ // configuration: the controlled host answers every request with its intent
562
+ action acceptRequests = all {
563
+ expanded' = expanded,
564
+ selected' = selected,
565
+ focus' = focus,
566
+ hasFocus' = hasFocus,
567
+ viewStart' = viewStart,
568
+ viewEnd' = viewEnd,
569
+ controlled' = controlled,
570
+ accepting' = true,
571
+ valueProp' = valueProp,
572
+ requested' = requested,
573
+ requests' = requests,
574
+ outcome' = outcome,
575
+ base' = base
576
+ }
577
+
578
+ action step = any {
579
+ init, focusTimeline, blurTimeline, focusNode,
580
+ pressDown, pressUp, pressRight, pressLeft, pressHome, pressEnd, pressEnter,
581
+ zoomIn, zoomOut, zoomReset, panLeft, panRight, click, selectNode,
582
+ setValue, control, acceptRequests
583
+ }
584
+
585
+ // ---- invariants ---------------------------------------------------------
586
+
587
+ // the roving node exists
588
+ val FocusKnown: bool = and(focus >= 1, focus <= lastNode)
589
+ // APG: focus only ever rests on a focusable node — every ancestor is open
590
+ val FocusVisible: bool = visible(focus, expanded)
591
+ // selection is either none or a real node
592
+ val SelectedKnown: bool = or(selected == 0, and(selected >= 1, selected <= lastNode))
593
+ // the zoom window is a non-empty interval
594
+ val ZoomOrdered: bool = viewStart < viewEnd
595
+ // the zoom window stays inside the tick domain
596
+ val ZoomBounded: bool = and(viewStart >= domainMin, viewEnd <= domainMax)
597
+
598
+ def leafNeverOpen(n: int, e: int): bool = or(isParent(n), not(bit(e, n)))
599
+ // leaves are never recorded as expanded
600
+ val LeafNeverExpanded: bool = and(
601
+ leafNeverOpen(1, expanded), leafNeverOpen(2, expanded), leafNeverOpen(3, expanded),
602
+ leafNeverOpen(4, expanded), leafNeverOpen(5, expanded)
603
+ )
604
+ // callbacks only ever count up from zero
605
+ val RequestsNonNegative: bool = requests >= 0
606
+ // controlled: only the value prop sets the selection, so no event commits
607
+ val ControlledFollowsProp: bool = or(not(controlled), selected == valueProp)
608
+ // a rejected request leaves the selection as it found it
609
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), selected == base)
610
+ // an accepted request committed exactly its intent
611
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), selected == requested)
612
+ }