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,195 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { announce } from "@poetry/controllers/helpers/announce"
3
+ import { isImeKeydown } from "@poetry/controllers/helpers/escape"
4
+
5
+ // The SensitiveInput machine, adapted from an MIT-licensed source
6
+ // (source and license in THIRD_PARTY_NOTICES.md): a secret field in
7
+ // three states - masked | revealed | empty - where data-state on the root
8
+ // carries the truth and CSS renders it. Masked-with-value turns the MASK
9
+ // OVERLAY into the reveal affordance (role=button + label + sr-hint; only
10
+ // text spans inside - a role on the surrounding group would trip axe
11
+ // nested-interactive around the inert input)
12
+ // while the real input stays rendered for layout but goes inert
13
+ // (aria-hidden, tabindex -1, readonly, transparent). Reveal: click
14
+ // anywhere on the group (mask clicks bubble) or Enter/Space on the mask,
15
+ // focus moves into the input. Re-mask: Escape (focus returns to the mask
16
+ // - the input just lost its tab stop), leaving the component, or the eye.
17
+ // Typing into an empty field auto-reveals so composition happens in
18
+ // type=text. The no-JS story is a plain password input.
19
+ const EVENT_PREFIX = "poetry:sensitive-input"
20
+
21
+ export default class SensitiveInputController extends Controller {
22
+ // The events this controller dispatches (manifest surface;
23
+ // events_declaration.test.js enforces the list stays honest).
24
+ static events = ["poetry:sensitive-input:reveal", "poetry:sensitive-input:mask"]
25
+
26
+ static targets = ["mask", "input", "toggle", "hint"]
27
+
28
+ static values = {
29
+ // "{label}, masked." - built server-side so i18n stays in Rails.
30
+ maskedLabel: String,
31
+ // The re-mask announcement ("Value hidden").
32
+ hiddenMessage: String,
33
+ // The host-declared readonly (the controller borrows the input's
34
+ // readOnly while masked, so the option needs its own home).
35
+ readOnly: Boolean
36
+ }
37
+
38
+ /** Adopts the server-rendered data-state and re-derives the reflection. */
39
+ connect() {
40
+ this.#visible = this.element.getAttribute("data-state") === "revealed"
41
+ this.#reflect()
42
+ }
43
+
44
+ /**
45
+ * The group's click action: click anywhere on the bordered group
46
+ * reveals (the mask button's own clicks bubble here too). Addon-cell
47
+ * clicks and synthetic label clicks are filtered (the body comments).
48
+ *
49
+ * @param {MouseEvent} event
50
+ */
51
+ reveal(event) {
52
+ if (this.#disabled || this.#state !== "masked") return
53
+ // The addon cell holds its own actions (copy/eye) - their clicks
54
+ // bubble here and must never reveal.
55
+ if (event.target instanceof Element && event.target.closest("[data-slot=input-group-addon]")) return
56
+ // Synthetic label-click guard (the coordinate check): a <label for=>
57
+ // click re-dispatches on the input with no gesture behind it.
58
+ if (event.detail === 0 && event.clientX === 0 && event.clientY === 0) return
59
+
60
+ this.#reveal()
61
+ }
62
+
63
+ /**
64
+ * The mask overlay's keydown action: Enter/Space reveals (the overlay
65
+ * is the reveal affordance while masked).
66
+ *
67
+ * @param {KeyboardEvent} event
68
+ */
69
+ maskKeydown(event) {
70
+ if (this.#disabled || this.#state !== "masked") return
71
+ if (event.target !== this.maskTarget) return
72
+ if (event.key !== "Enter" && event.key !== " ") return
73
+
74
+ event.preventDefault()
75
+ this.#reveal()
76
+ }
77
+
78
+ /**
79
+ * The input's keydown action: Escape re-masks and is consumed - the
80
+ * NEXT press reaches the dismissal layer.
81
+ *
82
+ * @param {KeyboardEvent} event
83
+ */
84
+ inputKeydown(event) {
85
+ if (event.key !== "Escape" || isImeKeydown(event)) return
86
+ if (this.#state !== "revealed") return
87
+
88
+ // Consumed: this press masks; the NEXT one reaches the dismissal layer.
89
+ event.preventDefault()
90
+ event.stopPropagation()
91
+ this.#mask({ focusMask: true })
92
+ }
93
+
94
+ /**
95
+ * The root's focusout action: leaving the component with a value
96
+ * re-masks.
97
+ *
98
+ * @param {FocusEvent} event
99
+ */
100
+ blurred(event) {
101
+ if (event.relatedTarget instanceof Node && this.element.contains(event.relatedTarget)) return
102
+ if (this.#state !== "revealed") return
103
+
104
+ this.#mask()
105
+ }
106
+
107
+ /**
108
+ * The input action: emptiness drives the state; the first character
109
+ * typed into an empty field reveals (composition belongs in type=text).
110
+ */
111
+ changed() {
112
+ if (this.#state === "empty" && this.inputTarget.value !== "") this.#visible = true
113
+
114
+ this.#reflect()
115
+ }
116
+
117
+ /**
118
+ * The eye's click action: re-masks and hands focus to the mask button -
119
+ * the eye is about to hide (it only exists while revealed).
120
+ *
121
+ * @param {MouseEvent} event
122
+ */
123
+ toggle(event) {
124
+ event.stopPropagation()
125
+ if (this.#state !== "revealed") return
126
+
127
+ this.#mask({ focusMask: true })
128
+ }
129
+
130
+ #visible = false
131
+
132
+ get #state() {
133
+ return this.element.getAttribute("data-state")
134
+ }
135
+
136
+ get #disabled() {
137
+ return this.element.hasAttribute("data-disabled")
138
+ }
139
+
140
+ #reveal() {
141
+ this.#visible = true
142
+ // Move focus off the mask BEFORE #reflect hides it: hiding the
143
+ // focused element drops focus to body, blurred() reads that as
144
+ // leaving the field, and the re-mask beats the reveal (the
145
+ // takes-two-clicks bug). The input accepts .focus() even while
146
+ // masked (tabindex=-1 only skips Tab); read-only hands focus on
147
+ // to the eye once #reflect has shown it.
148
+ this.inputTarget.focus()
149
+ this.#reflect()
150
+ if (this.readOnlyValue && this.hasToggleTarget) this.toggleTarget.focus()
151
+ this.dispatch("reveal", { prefix: EVENT_PREFIX })
152
+ }
153
+
154
+ #mask({ focusMask = false } = {}) {
155
+ this.#visible = false
156
+ this.#reflect()
157
+ if (this.hiddenMessageValue !== "") announce(this.hiddenMessageValue)
158
+ if (focusMask) this.maskTarget.focus()
159
+ this.dispatch("mask", { prefix: EVENT_PREFIX })
160
+ }
161
+
162
+ #reflect() {
163
+ const empty = this.inputTarget.value === ""
164
+ const state = empty ? "empty" : this.#visible ? "revealed" : "masked"
165
+ this.element.setAttribute("data-state", state)
166
+
167
+ const masked = state === "masked"
168
+
169
+ if (masked) {
170
+ this.maskTarget.setAttribute("role", "button")
171
+ this.maskTarget.setAttribute("tabindex", this.#disabled ? "-1" : "0")
172
+ this.maskTarget.setAttribute("aria-label", this.maskedLabelValue)
173
+ if (this.hasHintTarget) this.maskTarget.setAttribute("aria-describedby", this.hintTarget.id)
174
+ } else {
175
+ this.maskTarget.removeAttribute("role")
176
+ this.maskTarget.removeAttribute("tabindex")
177
+ this.maskTarget.removeAttribute("aria-label")
178
+ this.maskTarget.removeAttribute("aria-describedby")
179
+ }
180
+
181
+ // The real input: rendered for layout, inert while masked; native
182
+ // password whenever the value is not shown.
183
+ this.inputTarget.type = state === "revealed" ? "text" : "password"
184
+ this.inputTarget.readOnly = masked || this.readOnlyValue
185
+ if (masked) {
186
+ this.inputTarget.setAttribute("aria-hidden", "true")
187
+ this.inputTarget.setAttribute("tabindex", "-1")
188
+ } else {
189
+ this.inputTarget.removeAttribute("aria-hidden")
190
+ this.inputTarget.removeAttribute("tabindex")
191
+ }
192
+
193
+ if (this.hasToggleTarget) this.toggleTarget.hidden = state !== "revealed"
194
+ }
195
+ }
@@ -0,0 +1,24 @@
1
+ import DialogController from "@poetry/controllers/dialog_controller"
2
+ import { enterPresence } from "@poetry/controllers/helpers/presence"
3
+
4
+ // The Sheet: the dialog machinery, animated end to end. Everything hard
5
+ // about the overlay is inherited (native <dialog> platform trap,
6
+ // backdrop-click discrimination, scroll lock, hotkey) - including the
7
+ // presence-hold close, which the base controller owns (exit flips the
8
+ // pair to data-closed and holds the dialog through the slide-out before
9
+ // the native close()). This subclass only upgrades the ENTER: open rides
10
+ // enterPresence so the data-starting-style hook fires like every other
11
+ // presence consumer.
12
+ export default class SheetController extends DialogController {
13
+ /**
14
+ * Opens with the animated entry: showModal(), then the presence enter
15
+ * so the data-starting-style hook fires like every other presence
16
+ * consumer (the exit half is inherited - the base close() already holds
17
+ * through the slide-out).
18
+ */
19
+ open() {
20
+ this.dialogTarget.showModal()
21
+ enterPresence(this.dialogTarget)
22
+ this.lockScroll()
23
+ }
24
+ }
@@ -0,0 +1,272 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { watchMobile } from "@poetry/controllers/helpers/breakpoint"
3
+ import { matchesHotkey } from "@poetry/controllers/helpers/hotkey"
4
+ import { enterPresence, exitPresence } from "@poetry/controllers/helpers/presence"
5
+ import { lockScroll, unlockScroll } from "@poetry/controllers/helpers/scroll_lock"
6
+ import { setState } from "@poetry/controllers/helpers/state"
7
+ import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
8
+
9
+ // The Sidebar state machine (desktop plus the mobile mode): expand/collapse
10
+ // coordination for the app shell. The COLLAPSE itself is pure CSS - the
11
+ // peer sidebar carries data-state=expanded|collapsed and the dictionary's
12
+ // group-data-[state=collapsed] classes do all the width/transform work;
13
+ // this controller only flips that attribute (plus data-collapsible, which
14
+ // the source sets to the mode WHILE collapsed and "" while expanded),
15
+ // persists the choice to a cookie (so the SERVER can read it and render
16
+ // the right initial state - poetry's server-first angle), and binds the
17
+ // Cmd/Ctrl+B shortcut.
18
+ //
19
+ // MOBILE (DOM-move): below md the trigger routes to a separate
20
+ // never-persisted openMobile state (only desktop
21
+ // toggles write the cookie). Opening ADOPTS the server-rendered nav
22
+ // children from the desktop inner into the mobile <dialog> (one render,
23
+ // no duplicate ids - the render-twice rejection) and shows it through the
24
+ // sheet presence path; closing holds through the slide-out, then moves
25
+ // the children back. Crossing to desktop while open restores INSTANTLY.
26
+ // The component-facing event namespace (the poetry:<component> rule).
27
+ const EVENT_PREFIX = "poetry:sidebar"
28
+
29
+ export default class SidebarController extends Controller {
30
+ // The events this controller dispatches (manifest surface;
31
+ // events_declaration.test.js enforces the list stays honest).
32
+ static events = ["poetry:sidebar:mobile-toggle", "poetry:sidebar:toggle"]
33
+
34
+ static targets = ["sidebar", "inner", "mobileDialog", "mobileInner"]
35
+ static values = {
36
+ open: { type: Boolean, default: true },
37
+ // The collapse mode written to data-collapsible while collapsed
38
+ // (offcanvas | icon); "none" means the shortcut/trigger are inert.
39
+ collapsible: { type: String, default: "offcanvas" },
40
+ cookieName: { type: String, default: "sidebar_state" },
41
+ cookieMaxAge: { type: Number, default: 604800 }, // 7 days
42
+ shortcut: { type: String, default: "b" }
43
+ }
44
+
45
+ #onKeydown = null
46
+ #unwatchMobile = null
47
+ #unsubscribeBeforeCache = null
48
+ #isMobile = false
49
+ #mobileOpen = false
50
+ #closingMobile = false
51
+ #locked = false
52
+
53
+ /**
54
+ * Heals a restored zombie snapshot, reflects the server value once (the
55
+ * body comment explains why not openValueChanged), starts the
56
+ * breakpoint watcher, subscribes the before-cache close, and binds the
57
+ * shortcut.
58
+ */
59
+ connect() {
60
+ this.#healRestoredSnapshot()
61
+ // Reflect the server value to the DOM once. We do NOT drive reflection
62
+ // off openValueChanged - Stimulus fires value callbacks asynchronously
63
+ // (MutationObserver), so a click's DOM update would lag a frame; the
64
+ // mutators below reflect synchronously instead.
65
+ this.#reflect()
66
+
67
+ this.#unwatchMobile = watchMobile((mobile) => this.#mobileChanged(mobile))
68
+
69
+ // Close before Turbo snapshots (instantly - the page is being torn
70
+ // down anyway): an open mobile sheet serialized into the cache
71
+ // restores as a de-modalized zombie holding the nav children hostage
72
+ // over a frozen scroll lock.
73
+ this.#unsubscribeBeforeCache = onBeforeCache(() => {
74
+ if (!this.#mobileOpen) return
75
+ this.#closingMobile = false
76
+ this.mobileDialogTarget.removeAttribute("data-ending-style")
77
+ setState(this.mobileDialogTarget, "closed")
78
+ this.#restoreMobile()
79
+ })
80
+
81
+ this.#onKeydown = (event) => {
82
+ // The full descriptor grammar (the dialog idiom): a bare metaKey||
83
+ // ctrlKey check also fires on stray-modifier chords (Cmd+Shift+B).
84
+ if (event.defaultPrevented || !matchesHotkey(event, `meta+${this.shortcutValue}`)) return
85
+
86
+ event.preventDefault()
87
+ this.toggle()
88
+ }
89
+ window.addEventListener("keydown", this.#onKeydown)
90
+ }
91
+
92
+ /**
93
+ * Unwires the shortcut / watcher / before-cache subscriptions and
94
+ * balances the scroll lock.
95
+ */
96
+ disconnect() {
97
+ if (this.#onKeydown) window.removeEventListener("keydown", this.#onKeydown)
98
+ this.#onKeydown = null
99
+ this.#unwatchMobile?.()
100
+ this.#unwatchMobile = null
101
+ this.#unsubscribeBeforeCache?.()
102
+ this.#unsubscribeBeforeCache = null
103
+ this.#unlock()
104
+ }
105
+
106
+ /**
107
+ * The trigger's (and rail's) click action - also the shortcut's
108
+ * landing. On mobile the SAME trigger routes to the sheet; on desktop
109
+ * it flips the collapse (inert when collapsible is "none").
110
+ */
111
+ toggle() {
112
+ if (this.#isMobile && this.hasMobileDialogTarget) {
113
+ if (this.#mobileOpen) this.closeMobile()
114
+ else this.#openMobile()
115
+ return
116
+ }
117
+ if (this.collapsibleValue === "none") return
118
+
119
+ this.#set(!this.openValue)
120
+ }
121
+
122
+ // -- the mobile sheet -------------------------------------------------
123
+
124
+ /**
125
+ * The mobile dialog's cancel action (and the close affordances): closes
126
+ * through the sheet exit, then moves the nav children home.
127
+ *
128
+ * @param {Event} [event] - the native cancel event, when Esc drove it
129
+ */
130
+ closeMobile(event) {
131
+ if (event?.type === "cancel") event.preventDefault() // route Esc through the animated path
132
+ if (!this.#mobileOpen || this.#closingMobile) return
133
+
134
+ this.#closingMobile = true
135
+ exitPresence(this.mobileDialogTarget, {
136
+ onRemove: () => {
137
+ this.#closingMobile = false
138
+ this.#restoreMobile()
139
+ }
140
+ })
141
+ }
142
+
143
+ /**
144
+ * The mobile dialog's click action - the dialog's coordinate
145
+ * discrimination (a backdrop click targets the <dialog> itself AND
146
+ * lands outside its bounding rect).
147
+ *
148
+ * @param {MouseEvent} event
149
+ */
150
+ mobileBackdropClose(event) {
151
+ if (event.target !== this.mobileDialogTarget) return
152
+
153
+ const rect = this.mobileDialogTarget.getBoundingClientRect()
154
+ const inside = rect.top <= event.clientY && event.clientY <= rect.bottom &&
155
+ rect.left <= event.clientX && event.clientX <= rect.right
156
+ if (!inside) this.closeMobile()
157
+ }
158
+
159
+ // DOM-move: adopt the server-rendered nav into the mobile
160
+ // dialog - one render, no duplicate ids.
161
+ #openMobile() {
162
+ if (this.#mobileOpen || !this.hasMobileInnerTarget) return
163
+
164
+ while (this.innerTarget.firstChild) this.mobileInnerTarget.appendChild(this.innerTarget.firstChild)
165
+ this.mobileDialogTarget.showModal()
166
+ enterPresence(this.mobileDialogTarget)
167
+ this.#lock()
168
+ this.#mobileOpen = true
169
+ this.dispatch("mobile-toggle", { prefix: EVENT_PREFIX, detail: { open: true } })
170
+ }
171
+
172
+ // Close the native dialog and move the nav children HOME.
173
+ #restoreMobile() {
174
+ this.mobileDialogTarget.close()
175
+ while (this.mobileInnerTarget.firstChild) this.innerTarget.appendChild(this.mobileInnerTarget.firstChild)
176
+ this.#unlock()
177
+ this.#mobileOpen = false
178
+ this.dispatch("mobile-toggle", { prefix: EVENT_PREFIX, detail: { open: false } })
179
+ }
180
+
181
+ // Crossing to desktop while the sheet is open restores INSTANTLY (no
182
+ // exit animation - the layout is changing wholesale anyway).
183
+ #mobileChanged(mobile) {
184
+ this.#isMobile = mobile
185
+ if (!mobile && this.#mobileOpen && !this.#closingMobile) {
186
+ this.mobileDialogTarget.removeAttribute("data-ending-style")
187
+ setState(this.mobileDialogTarget, "closed")
188
+ this.#restoreMobile()
189
+ }
190
+ }
191
+
192
+ /** Programmatically expands the desktop sidebar (persisted). */
193
+ open() {
194
+ this.#set(true)
195
+ }
196
+
197
+ /** Programmatically collapses the desktop sidebar (persisted). */
198
+ close() {
199
+ this.#set(false)
200
+ }
201
+
202
+ // The single mutator: update the value, reflect synchronously, persist.
203
+ // The cookie is written for genuine toggles only - the server already
204
+ // knows the initial value (it read the cookie to render it), so connect
205
+ // reflects without persisting.
206
+ #set(open) {
207
+ this.openValue = open
208
+ this.#reflect()
209
+ this.#persist()
210
+ }
211
+
212
+ #reflect() {
213
+ const collapsed = !this.openValue
214
+
215
+ for (const sidebar of this.sidebarTargets) {
216
+ sidebar.setAttribute("data-state", collapsed ? "collapsed" : "expanded")
217
+ sidebar.setAttribute("data-collapsible", collapsed ? this.collapsibleValue : "")
218
+ }
219
+ this.dispatch("toggle", { prefix: EVENT_PREFIX, detail: { open: this.openValue } })
220
+ }
221
+
222
+ #persist() {
223
+ if (typeof document === "undefined") return
224
+
225
+ document.cookie =
226
+ `${this.cookieNameValue}=${this.openValue}; path=/; max-age=${this.cookieMaxAgeValue}; samesite=lax`
227
+ }
228
+
229
+ // A mobile sheet restored from a PRE-FIX cached snapshot: the open
230
+ // attribute survived serialization (the nav children with it), and the
231
+ // body's inline lock styles came back with no refcount behind them.
232
+ // Normalize to closed, move the children home, and clear the orphaned
233
+ // lock styles directly (the refcounted helper is at zero on a fresh
234
+ // page and must not be decremented).
235
+ #healRestoredSnapshot() {
236
+ if (!this.hasMobileDialogTarget || !this.mobileDialogTarget.open) return
237
+ // A genuinely modal dialog only reconnects mid-flight when its subtree
238
+ // is MOVED while open - leave those alone (the dialog heal's rule).
239
+ let modal = false
240
+ try {
241
+ modal = this.mobileDialogTarget.matches(":modal")
242
+ } catch {
243
+ modal = false
244
+ }
245
+ if (modal) return
246
+
247
+ this.mobileDialogTarget.close()
248
+ setState(this.mobileDialogTarget, "closed")
249
+ if (this.hasMobileInnerTarget && this.hasInnerTarget) {
250
+ while (this.mobileInnerTarget.firstChild) this.innerTarget.appendChild(this.mobileInnerTarget.firstChild)
251
+ }
252
+ document.body.style.overflow = ""
253
+ document.body.style.paddingRight = ""
254
+ }
255
+
256
+ // Shared refcounted lock with scrollbar-gutter compensation (the dialog
257
+ // idiom) - the instance flag keeps double-unlocks (before-cache close,
258
+ // then disconnect) balanced.
259
+ #lock() {
260
+ if (this.#locked) return
261
+
262
+ this.#locked = true
263
+ lockScroll()
264
+ }
265
+
266
+ #unlock() {
267
+ if (!this.#locked) return
268
+
269
+ this.#locked = false
270
+ unlockScroll()
271
+ }
272
+ }