poetry-core 0.0.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 (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. metadata +267 -0
@@ -0,0 +1,162 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { tabbableWithin } from "@poetry/controllers/helpers/tabbable"
3
+ import { ensureFocusGuards, removeFocusGuards } from "@poetry/controllers/helpers/focus_guards"
4
+ import { logicallyContains } from "@poetry/controllers/helpers/portal"
5
+
6
+ // The overlay focus scope: traps Tab/Shift+Tab within the
7
+ // subtree, loops at the edges, and - the part to get exact - snapshots
8
+ // document.activeElement on connect and RESTORES it on disconnect (focus
9
+ // return). Backs Dialog-family overlays, Popover, Menus, Select, Command.
10
+ //
11
+ // Listeners are wired here, not as data-actions: pause/resume must attach
12
+ // and detach them dynamically as scopes stack, which data-action cannot do.
13
+ export default class FocusScopeController extends Controller {
14
+ // The events this controller dispatches (manifest surface;
15
+ // events_declaration.test.js enforces the list stays honest).
16
+ static events = [
17
+ "poetry--core--focus-scope:mount-auto-focus", "poetry--core--focus-scope:unmount-auto-focus"
18
+ ]
19
+
20
+ // Nested scopes: only the TOP scope has live listeners. Opening a child
21
+ // pauses the parent; closing it resumes the parent (one shared stack -
22
+ // class-level so it is inspectable).
23
+ static stack = []
24
+
25
+ static values = {
26
+ trapped: { type: Boolean, default: true },
27
+ loop: { type: Boolean, default: true }
28
+ }
29
+
30
+ #active = false
31
+ #guarded = false
32
+ #previouslyFocused = null
33
+ #lastFocusedWithin = null
34
+ #onKeydown = (event) => this.#handleKeydown(event)
35
+ #onFocusin = (event) => this.#handleFocusin(event)
36
+
37
+ /**
38
+ * Snapshots the focus-return element BEFORE any focus moves, pauses the
39
+ * previous scope and takes the top of the stack, takes the guard
40
+ * refcount when trapped, then runs mount auto-focus (cancelable - a
41
+ * consumer owns initial focus by vetoing it).
42
+ */
43
+ connect() {
44
+ // The container is the focus fallback when the scope has no tabbables.
45
+ if (!this.element.hasAttribute("tabindex")) this.element.setAttribute("tabindex", "-1")
46
+
47
+ // Snapshot BEFORE any focus moves: this is where focus RETURNS to.
48
+ this.#previouslyFocused = document.activeElement
49
+
50
+ FocusScopeController.stack.at(-1)?.#pause()
51
+ FocusScopeController.stack.push(this)
52
+ this.#resume()
53
+
54
+ if (this.trappedValue) {
55
+ ensureFocusGuards()
56
+ this.#guarded = true
57
+ }
58
+
59
+ // Cancelable: a consumer preventDefault()s to own initial focus itself
60
+ // (e.g. Select focusing the active option, not the first tabbable).
61
+ const mount = this.dispatch("mount-auto-focus", { cancelable: true })
62
+
63
+ if (!mount.defaultPrevented) this.#focus(tabbableWithin(this.element)[0] ?? this.element)
64
+ }
65
+
66
+ /**
67
+ * Leaves the stack (resuming the scope below when this was the top),
68
+ * releases the guards, and restores focus to the snapshot (cancelable -
69
+ * a consumer sends focus elsewhere by vetoing it).
70
+ */
71
+ disconnect() {
72
+ const wasTop = FocusScopeController.stack.at(-1) === this
73
+ const index = FocusScopeController.stack.indexOf(this)
74
+
75
+ if (index !== -1) FocusScopeController.stack.splice(index, 1)
76
+
77
+ this.#pause()
78
+
79
+ if (wasTop) FocusScopeController.stack.at(-1)?.#resume()
80
+
81
+ if (this.#guarded) {
82
+ removeFocusGuards()
83
+ this.#guarded = false
84
+ }
85
+
86
+ // Focus return, cancelable so a consumer can send focus elsewhere
87
+ // (e.g. a submenu handing focus back to its subtrigger).
88
+ const unmount = this.dispatch("unmount-auto-focus", { cancelable: true })
89
+
90
+ if (!unmount.defaultPrevented && this.#previouslyFocused?.isConnected) {
91
+ this.#previouslyFocused.focus()
92
+ }
93
+
94
+ this.#previouslyFocused = null
95
+ this.#lastFocusedWithin = null
96
+ }
97
+
98
+ // Paused = no listeners at all: a paused parent neither traps nor loops
99
+ // while a child scope is on top of it.
100
+ #pause() {
101
+ if (!this.#active) return
102
+
103
+ this.#active = false
104
+ this.element.removeEventListener("keydown", this.#onKeydown)
105
+ document.removeEventListener("focusin", this.#onFocusin)
106
+ }
107
+
108
+ #resume() {
109
+ if (this.#active) return
110
+
111
+ this.#active = true
112
+ this.element.addEventListener("keydown", this.#onKeydown)
113
+
114
+ if (this.trappedValue) document.addEventListener("focusin", this.#onFocusin)
115
+ }
116
+
117
+ // Tab at the EDGES only: wrap under loop, hard-stop under trapped. A
118
+ // mid-list Tab is the browser's job - only the edges need help.
119
+ #handleKeydown(event) {
120
+ if (event.key !== "Tab") return
121
+ // A Tab mid-IME-composition commits the candidate text, it does not
122
+ // navigate - intercepting it eats the commit, so let it pass.
123
+ if (event.isComposing) return
124
+ if (!this.trappedValue && !this.loopValue) return
125
+
126
+ const candidates = tabbableWithin(this.element)
127
+
128
+ if (candidates.length === 0) {
129
+ event.preventDefault() // nothing to land on; focus stays on the container
130
+ return
131
+ }
132
+
133
+ const first = candidates[0]
134
+ const last = candidates[candidates.length - 1]
135
+
136
+ if (!event.shiftKey && document.activeElement === last) {
137
+ event.preventDefault()
138
+ if (this.loopValue) this.#focus(first)
139
+ } else if (event.shiftKey && document.activeElement === first) {
140
+ event.preventDefault()
141
+ if (this.loopValue) this.#focus(last)
142
+ }
143
+ }
144
+
145
+ // trapped: focus escaping the scope (a body-edge guard sentinel, a
146
+ // programmatic move, a click outside) is yanked back to the last focused
147
+ // element within the scope.
148
+ #handleFocusin(event) {
149
+ // Logical containment: a portaled sub level (menus portal each sub on
150
+ // its own open) sits outside the scope's SUBTREE but inside its tree.
151
+ if (logicallyContains(this.element, event.target)) {
152
+ this.#lastFocusedWithin = event.target
153
+ return
154
+ }
155
+
156
+ this.#focus(this.#lastFocusedWithin ?? tabbableWithin(this.element)[0] ?? this.element)
157
+ }
158
+
159
+ #focus(element) {
160
+ element?.focus?.()
161
+ }
162
+ }
@@ -0,0 +1,228 @@
1
+ // The announce SINGLETON: shared screen-reader live regions - a plain
2
+ // module, not a controller. Injecting a node that IS a live region is
3
+ // unreliably announced across SR/browser pairs (the region must exist
4
+ // BEFORE its text changes), so consumers keep their own nodes
5
+ // aria-live=off and route announcements
6
+ // through here: TWO lazily-created sr-only regions on body (polite +
7
+ // assertive), refcounted via acquire()/release() (regions removed when the
8
+ // last consumer releases; consumers: Toast, async form status, Combobox
9
+ // result counts - API changes after Toast ships are breaking, so the
10
+ // surface stays exactly this).
11
+ //
12
+ // Announcement mechanics: clear-then-set on a microtask (identical
13
+ // consecutive messages re-announce), a per-region message queue with a
14
+ // small gap between messages, textContent ONLY (live-region injection is a
15
+ // real sink - never innerHTML).
16
+ //
17
+ // Tab-visibility muting: while the tab is hidden both regions flip
18
+ // aria-live=off (muted); announcements made while hidden keep at most the
19
+ // LAST message, flushed on return (no backlog flood).
20
+
21
+ // The gap (ms) between queued messages per region - long enough for SRs to
22
+ // treat consecutive messages as separate announcements.
23
+ const QUEUE_GAP = 150
24
+
25
+ // Safari drops messages announced right after a live region is INSERTED
26
+ // (~100ms of post-creation warmup is required in practice; a WebKit
27
+ // behavior, not a spec timing) - a fresh region holds its queue until
28
+ // this warmup elapses. The clear-then-set rAF alone (~one frame) is
29
+ // shorter than Safari needs.
30
+ const REGION_WARMUP = 100
31
+
32
+ const POLITENESS = {
33
+ polite: { role: "status", live: "polite" },
34
+ assertive: { role: "alert", live: "assertive" }
35
+ }
36
+
37
+ let refCount = 0
38
+ let regions = null // { polite: region, assertive: region } | null
39
+ let hiddenBacklog = null // { message, politeness } - at most the LAST while hidden
40
+
41
+ const onVisibilityChange = () => {
42
+ if (document.hidden) mute()
43
+ else unmute()
44
+ }
45
+
46
+ /**
47
+ * Takes a refcount on the shared live regions (created on the first
48
+ * acquire): a consumer acquires while it is connected and releases on
49
+ * teardown.
50
+ */
51
+ export function acquire() {
52
+ refCount += 1
53
+ ensureRegions()
54
+ }
55
+
56
+ /**
57
+ * Releases one refcount; the last release removes the regions and their
58
+ * visibility listener.
59
+ */
60
+ export function release() {
61
+ if (refCount === 0) return
62
+
63
+ refCount -= 1
64
+
65
+ if (refCount === 0) teardown()
66
+ }
67
+
68
+ /**
69
+ * The announcement surface: queues `message` on the shared region for
70
+ * `politeness`. While the tab is hidden only the LAST message is kept,
71
+ * flushed on return.
72
+ *
73
+ * @param {string} message - stringified; injected as textContent only
74
+ * @param {"polite" | "assertive"} [politeness="polite"] - unknown values
75
+ * fall back to polite
76
+ */
77
+ export function announce(message, politeness = "polite") {
78
+ ensureRegions()
79
+
80
+ const resolved = Object.hasOwn(POLITENESS, politeness) ? politeness : "polite"
81
+
82
+ if (document.hidden) {
83
+ hiddenBacklog = { message, politeness: resolved }
84
+ return
85
+ }
86
+
87
+ enqueue(regions[resolved], message)
88
+ }
89
+
90
+ function ensureRegions() {
91
+ // A body swap (Turbo render, test reset) can detach live regions without
92
+ // a release - rebuild rather than announce into detached nodes.
93
+ if (regions && !regions.polite.element.isConnected) teardown()
94
+ if (regions) return
95
+
96
+ regions = {
97
+ polite: createRegion("polite"),
98
+ assertive: createRegion("assertive")
99
+ }
100
+
101
+ document.addEventListener("visibilitychange", onVisibilityChange)
102
+
103
+ if (document.hidden) mute()
104
+ }
105
+
106
+ function createRegion(politeness) {
107
+ const element = document.createElement("div")
108
+ const { role, live } = POLITENESS[politeness]
109
+
110
+ element.setAttribute("data-poetry-announce-region", politeness)
111
+ element.setAttribute("role", role)
112
+ element.setAttribute("aria-live", live)
113
+ element.setAttribute("aria-atomic", "true")
114
+
115
+ // sr-only, inline (the helper cannot assume a utility class exists).
116
+ Object.assign(element.style, {
117
+ position: "absolute",
118
+ width: "1px",
119
+ height: "1px",
120
+ padding: "0",
121
+ margin: "-1px",
122
+ overflow: "hidden",
123
+ clip: "rect(0, 0, 0, 0)",
124
+ whiteSpace: "nowrap",
125
+ border: "0"
126
+ })
127
+
128
+ document.body.appendChild(element)
129
+
130
+ const region = {
131
+ politeness, element, queue: [], draining: false, timer: null,
132
+ warm: false, warmupTimer: null
133
+ }
134
+
135
+ region.warmupTimer = window.setTimeout(() => {
136
+ region.warmupTimer = null
137
+ region.warm = true
138
+ drain(region)
139
+ }, REGION_WARMUP)
140
+
141
+ return region
142
+ }
143
+
144
+ // Per-region queue: clear-then-set across an animation FRAME, not a
145
+ // microtask (so a message identical to the region's current text still
146
+ // re-announces), then a small gap before the next queued message. The
147
+ // frame matters twice over: a microtask lands in the SAME accessibility-
148
+ // tree flush, so (a) a freshly created region would be seen "born with
149
+ // content" - a mutation pattern ATs reliably skip - and (b) clear+set
150
+ // would coalesce into one text change, letting AT dedup swallow repeats.
151
+ // rAF pushes the set into the next frame's flush; a hidden document has
152
+ // no frames, but the visibility contract already routes those to the
153
+ // backlog before enqueue.
154
+ function enqueue(region, message) {
155
+ region.queue.push(String(message))
156
+ drain(region)
157
+ }
158
+
159
+ function drain(region) {
160
+ if (!region.warm || region.draining) return
161
+
162
+ const message = region.queue.shift()
163
+
164
+ if (message === undefined) return
165
+
166
+ region.draining = true
167
+ region.element.textContent = ""
168
+
169
+ requestAnimationFrame(() => {
170
+ // The regions may have been torn down or muted between ticks.
171
+ if (!regions || regions[region.politeness] !== region) return
172
+
173
+ region.element.textContent = message
174
+ region.timer = window.setTimeout(() => {
175
+ region.timer = null
176
+ region.draining = false
177
+ drain(region)
178
+ }, QUEUE_GAP)
179
+ })
180
+ }
181
+
182
+ function mute() {
183
+ if (!regions) return
184
+
185
+ for (const region of Object.values(regions)) {
186
+ region.element.setAttribute("aria-live", "off")
187
+ // Muted messages are dropped - only announce() calls made while hidden
188
+ // keep their LAST message (the backlog contract).
189
+ region.queue.length = 0
190
+
191
+ if (region.timer !== null) {
192
+ window.clearTimeout(region.timer)
193
+ region.timer = null
194
+ }
195
+
196
+ region.draining = false
197
+ }
198
+ }
199
+
200
+ function unmute() {
201
+ if (!regions) return
202
+
203
+ for (const region of Object.values(regions)) {
204
+ region.element.setAttribute("aria-live", POLITENESS[region.politeness].live)
205
+ }
206
+
207
+ if (hiddenBacklog) {
208
+ const { message, politeness } = hiddenBacklog
209
+
210
+ hiddenBacklog = null
211
+ enqueue(regions[politeness], message)
212
+ }
213
+ }
214
+
215
+ function teardown() {
216
+ if (!regions) return
217
+
218
+ for (const region of Object.values(regions)) {
219
+ if (region.timer !== null) window.clearTimeout(region.timer)
220
+ if (region.warmupTimer !== null) window.clearTimeout(region.warmupTimer)
221
+
222
+ region.element.remove()
223
+ }
224
+
225
+ regions = null
226
+ hiddenBacklog = null
227
+ document.removeEventListener("visibilitychange", onVisibilityChange)
228
+ }
@@ -0,0 +1,28 @@
1
+ // The mobile breakpoint: matchMedia below Tailwind's md (768px), with a
2
+ // change listener. The
3
+ // first poetry consumer is the Sidebar's mobile-Sheet mode. Environments
4
+ // without matchMedia (the dommy QuickJS engine, bare jsdom) report
5
+ // DESKTOP - the server-rendered desktop shell is the safe default.
6
+ /** The mobile cutoff (px): viewports strictly narrower are mobile. */
7
+ export const MOBILE_BREAKPOINT = 768
8
+
9
+ /**
10
+ * Watches the mobile breakpoint: calls `onChange(isMobile)` immediately
11
+ * with the current state and again on every crossing. Environments
12
+ * without matchMedia report desktop once and never call again.
13
+ *
14
+ * @param {(isMobile: boolean) => void} onChange
15
+ * @returns {() => void} unwatch
16
+ */
17
+ export function watchMobile(onChange) {
18
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
19
+ onChange(false)
20
+ return () => {}
21
+ }
22
+
23
+ const query = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
24
+ const listener = () => onChange(query.matches)
25
+ query.addEventListener("change", listener)
26
+ onChange(query.matches)
27
+ return () => query.removeEventListener("change", listener)
28
+ }
@@ -0,0 +1,18 @@
1
+ // The DOM is the registry: collection items are read from the document
2
+ // in DOM order - no client-side bookkeeping, no registration step; a
3
+ // membership question is always a fresh query.
4
+
5
+ /** The attribute marking an element as a collection item. */
6
+ export const COLLECTION_ITEM_SELECTOR = "[data-poetry-collection-item]"
7
+
8
+ /**
9
+ * The collection items under `root`, in DOM order.
10
+ *
11
+ * @param {ParentNode} root - the element (or document) to query
12
+ * @param {string} [selector] - override for consumers with their own item
13
+ * marker (defaults to {@link COLLECTION_ITEM_SELECTOR})
14
+ * @returns {Element[]}
15
+ */
16
+ export function collectionItems(root, selector = COLLECTION_ITEM_SELECTOR) {
17
+ return Array.from(root.querySelectorAll(selector))
18
+ }
@@ -0,0 +1,15 @@
1
+ // Reading direction: the platform mechanism - the closest [dir]
2
+ // ancestor - consumed by roving-focus (Left/Right flip) and popper (side flip).
3
+
4
+ /**
5
+ * The reading direction in effect at `element`: the closest `[dir]`
6
+ * ancestor's value. Only an explicit rtl flips - dir="auto", dir="ltr" and
7
+ * no [dir] ancestor at all resolve "ltr".
8
+ *
9
+ * @param {Element} element
10
+ * @returns {"ltr" | "rtl"}
11
+ */
12
+ export function directionOf(element) {
13
+ const dir = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
14
+ return dir === "rtl" ? "rtl" : "ltr"
15
+ }
@@ -0,0 +1,38 @@
1
+ // Capture-phase Escape handling: the primitive under dismissable's
2
+ // topmost-only Esc behavior.
3
+
4
+ /**
5
+ * True when a keydown belongs to an IME composition. An Escape that
6
+ * cancels IME composition must never reach dismissal: CJK users press
7
+ * Escape to drop an in-progress composition, and closing the overlay
8
+ * under them destroys the field they were typing into. Chromium reports
9
+ * isComposing on the cancel keydown; some engines only mark it with the
10
+ * legacy 229 keyCode - check both. Every Escape consumer (dismissable via
11
+ * onEscapeKeydown, plus controllers with their own Escape branches) gates
12
+ * through this predicate.
13
+ *
14
+ * @param {KeyboardEvent} event
15
+ * @returns {boolean}
16
+ */
17
+ export function isImeKeydown(event) {
18
+ return event.isComposing || event.keyCode === 229
19
+ }
20
+
21
+ /**
22
+ * Subscribes `callback` to Escape keydowns, with IME-cancel presses
23
+ * filtered out via {@link isImeKeydown}.
24
+ *
25
+ * @param {(event: KeyboardEvent) => void} callback
26
+ * @param {Object} [options]
27
+ * @param {boolean} [options.capture=true] - capture phase, so dismissal
28
+ * sees the key before bubble-phase consumers can swallow it
29
+ * @param {EventTarget} [options.target=window]
30
+ * @returns {() => void} unsubscribe
31
+ */
32
+ export function onEscapeKeydown(callback, { capture = true, target = window } = {}) {
33
+ const listener = (event) => {
34
+ if (event.key === "Escape" && !isImeKeydown(event)) callback(event)
35
+ }
36
+ target.addEventListener("keydown", listener, { capture })
37
+ return () => target.removeEventListener("keydown", listener, { capture })
38
+ }
@@ -0,0 +1,104 @@
1
+ // The Command filter spec: deterministic
2
+ // substring + a 5-band rank - deliberately NOT a fuzzy command-score. Pure string
3
+ // functions so the CI spec table pins the contract: any scoring change is
4
+ // a reviewed table change, never a silent reorder of every palette. The
5
+ // score's ONLY job is picking the auto-highlighted first match - the
6
+ // controller hides score-0 items and NEVER reorders the DOM (DOM order is
7
+ // the ranking authority within a band).
8
+ //
9
+ // Bands: prefix 4 > word-boundary 3 > substring 2 > keyword 1 > hidden 0;
10
+ // an empty query scores 1 (everything visible). Matching is
11
+ // diacritic-folded ("creme" matches "Crème") and case-insensitive.
12
+
13
+ /** An empty query: everything stays visible. */
14
+ export const SCORE_EMPTY_QUERY = 1
15
+ /** The label starts with the query. */
16
+ export const SCORE_PREFIX = 4
17
+ /** A word inside the label starts with the query. */
18
+ export const SCORE_WORD = 3
19
+ /** The query appears anywhere in the label. */
20
+ export const SCORE_SUBSTRING = 2
21
+ /** Only a keyword starts with the query. */
22
+ export const SCORE_KEYWORD = 1
23
+ /** No match - the controller hides the item. */
24
+ export const SCORE_HIDDEN = 0
25
+
26
+ // Words split on whitespace / dash / underscore / slash (the contract's
27
+ // word-boundary definition).
28
+ const WORD_SPLIT = /[\s\-_/]+/
29
+
30
+ const ITEM_TEXT_SELECTOR = '[data-slot="command-item-text"], [data-slot="combobox-item-text"]'
31
+
32
+ /**
33
+ * trim + lowercase + NFKD-fold combining marks: diacritic-insensitive
34
+ * matching for free in every locale that marks are decorative in (folding
35
+ * is always-on - the contract's documented call).
36
+ *
37
+ * @param {*} value - stringified; null/undefined normalize to ""
38
+ * @returns {string}
39
+ */
40
+ export function normalize(value) {
41
+ return String(value ?? "").trim().toLowerCase().normalize("NFKD").replace(/\p{M}/gu, "")
42
+ }
43
+
44
+ /**
45
+ * The pure scorer. label/query arrive RAW (normalization is this
46
+ * function's business); keywords is an array of raw strings.
47
+ *
48
+ * @param {string} label
49
+ * @param {string} query
50
+ * @param {string[]} [keywords=[]]
51
+ * @returns {number} a SCORE_* band
52
+ */
53
+ export function scoreText(label, query, keywords = []) {
54
+ const q = normalize(query)
55
+
56
+ if (q === "") return SCORE_EMPTY_QUERY
57
+
58
+ const folded = normalize(label)
59
+
60
+ if (folded.startsWith(q)) return SCORE_PREFIX
61
+ if (folded.split(WORD_SPLIT).some((word) => word !== "" && word.startsWith(q))) return SCORE_WORD
62
+ if (folded.includes(q)) return SCORE_SUBSTRING
63
+ if (keywords.some((keyword) => normalize(keyword) !== "" && normalize(keyword).startsWith(q))) {
64
+ return SCORE_KEYWORD
65
+ }
66
+
67
+ return SCORE_HIDDEN
68
+ }
69
+
70
+ /**
71
+ * The label an item filters against: data-filter-value overrides
72
+ * (icon-rich content), else the item-text part, else the item's own text.
73
+ *
74
+ * @param {Element} item
75
+ * @returns {string}
76
+ */
77
+ export function filterLabel(item) {
78
+ return item.dataset.filterValue ??
79
+ item.querySelector(ITEM_TEXT_SELECTOR)?.textContent ??
80
+ item.textContent ?? ""
81
+ }
82
+
83
+ /**
84
+ * data-keywords: whitespace-separated extra filter terms an item may
85
+ * carry beyond its visible label.
86
+ *
87
+ * @param {Element} item
88
+ * @returns {string[]}
89
+ */
90
+ export function filterKeywords(item) {
91
+ return (item.dataset.keywords ?? "").split(/\s+/).filter(Boolean)
92
+ }
93
+
94
+ /**
95
+ * Scores one collection item against the query via its filter label and
96
+ * keywords.
97
+ *
98
+ * @param {Element} item
99
+ * @param {string} query
100
+ * @returns {number} a SCORE_* band
101
+ */
102
+ export function scoreItem(item, query) {
103
+ return scoreText(filterLabel(item), query, filterKeywords(item))
104
+ }
@@ -0,0 +1,46 @@
1
+ // Focus guards: two visually-hidden tabindex=0 sentinels at the
2
+ // body edges so focusin/focusout fire predictably at the document boundary
3
+ // while any trapped overlay is open - a Tab out of the last real element
4
+ // lands on a guard (which focus-scope yanks back), never on nothing.
5
+ // Refcounted module state: one pair per page no matter how many overlays.
6
+
7
+ /** Selector matching the two page-edge focus-guard sentinels. */
8
+ export const FOCUS_GUARD_SELECTOR = "[data-poetry-focus-guard]"
9
+
10
+ let guardCount = 0
11
+
12
+ /**
13
+ * Takes a refcount on the page-edge guard pair, creating the two
14
+ * sentinels on the first acquire. Pair every call with
15
+ * {@link removeFocusGuards} on teardown.
16
+ */
17
+ export function ensureFocusGuards() {
18
+ if (guardCount === 0) {
19
+ document.body.insertAdjacentElement("afterbegin", createGuard())
20
+ document.body.insertAdjacentElement("beforeend", createGuard())
21
+ }
22
+ guardCount += 1
23
+ }
24
+
25
+ /**
26
+ * Releases one refcount on the guard pair; the last release removes both
27
+ * sentinels from the document.
28
+ */
29
+ export function removeFocusGuards() {
30
+ if (guardCount === 0) return
31
+
32
+ guardCount -= 1
33
+
34
+ if (guardCount === 0) {
35
+ for (const guard of document.querySelectorAll(FOCUS_GUARD_SELECTOR)) guard.remove()
36
+ }
37
+ }
38
+
39
+ function createGuard() {
40
+ const guard = document.createElement("span")
41
+ guard.setAttribute("data-poetry-focus-guard", "")
42
+ guard.setAttribute("tabindex", "0")
43
+ guard.setAttribute("aria-hidden", "true")
44
+ guard.style.cssText = "position: fixed; opacity: 0; pointer-events: none; outline: none;"
45
+ return guard
46
+ }
@@ -0,0 +1,43 @@
1
+ // The hotkey descriptor grammar, extracted from the dialog controller so
2
+ // any surface can speak it (the generic hotkey controller, future
3
+ // data-hotkey affordances). "meta+k" / "ctrl+shift+p": '+'-separated
4
+ // modifiers plus one final key token, matched exactly - unlisted modifiers
5
+ // must be UP, so plain typing never triggers. "meta" matches metaKey OR
6
+ // ctrlKey (⌘K on mac, ^K elsewhere - the command-palette convention).
7
+ /**
8
+ * Whether a keydown satisfies a hotkey descriptor (the grammar above).
9
+ *
10
+ * @param {KeyboardEvent} event
11
+ * @param {string} descriptor - e.g. "meta+k", "ctrl+shift+p", "?"
12
+ * @returns {boolean}
13
+ */
14
+ export function matchesHotkey(event, descriptor) {
15
+ const tokens = descriptor.toLowerCase().split("+").map((token) => token.trim())
16
+ const key = tokens.pop()
17
+
18
+ if ((event.key ?? "").toLowerCase() !== key) return false
19
+
20
+ const meta = tokens.includes("meta") ? (event.metaKey || event.ctrlKey) : true
21
+ const ctrl = tokens.includes("ctrl") ? event.ctrlKey : true
22
+ const shift = tokens.includes("shift") === event.shiftKey
23
+ const alt = tokens.includes("alt") === event.altKey
24
+ const noStray = tokens.includes("meta") || tokens.includes("ctrl") ||
25
+ (!event.metaKey && !event.ctrlKey)
26
+
27
+ return meta && ctrl && shift && alt && noStray
28
+ }
29
+
30
+ /**
31
+ * True when the event originates in a text-editing context - unmodified
32
+ * single-key shortcuts ("/", "?") must stay inert while the user types
33
+ * (the standard hotkey ignore list: form fields and contenteditable).
34
+ *
35
+ * @param {KeyboardEvent} event
36
+ * @returns {boolean}
37
+ */
38
+ export function isEditingTarget(event) {
39
+ const target = event.composedPath?.()[0] ?? event.target
40
+ if (!target || !(target instanceof Element)) return false
41
+
42
+ return Boolean(["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable)
43
+ }