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,500 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import {
3
+ applyMaskToRaw,
4
+ buildDisplayValue,
5
+ checkComplete,
6
+ extractRaw,
7
+ findNextEditablePosition,
8
+ generatePattern,
9
+ nextTokenPosition,
10
+ parseMask,
11
+ prevTokenPosition,
12
+ processInput
13
+ } from "@poetry/controllers/helpers/mask"
14
+
15
+ // The input-mask machine (the engine is adapted from an MIT-licensed
16
+ // source - source and license in THIRD_PARTY_NOTICES.md) on a bare native
17
+ // <input> - this.element IS the input. Three layers:
18
+ //
19
+ // 1. THE PRIMARY PATH is keydown with preventDefault: a printable char
20
+ // skips forward over literals from the caret and pattern-tests (a
21
+ // failing char is a NO-OP, it never reaches the field), a selection is
22
+ // replaced via a raw rebuild, and the caret lands past the next literal
23
+ // run. Backspace scans BACKWARD over literals (the caret lands at the
24
+ // deleted token), Delete scans FORWARD (the caret stays),
25
+ // Cmd/Ctrl+Backspace kills to start, arrows hop literal runs. Because
26
+ // preventDefault kills the native undo stack, a custom
27
+ // {raw, selectionStart} history (max 100, deduped) backs
28
+ // Cmd/Ctrl+Z / Shift+Cmd/Ctrl+Z / Ctrl+Y.
29
+ //
30
+ // 2. THE FALLBACK is the input-event diff (IME, mobile keyboards,
31
+ // autofill, password managers - paths that never emit clean keydowns):
32
+ // the longest common prefix+suffix against the previous display
33
+ // isolates the inserted text and the removed span, the value is rebuilt
34
+ // from raw pieces, re-masked, and the caret lands after the insertion.
35
+ //
36
+ // 3. THE DISPLAY: skeleton padding ("__/__") shows on focus
37
+ // (showMaskOnFocus, default) or permanently (alwaysShowMask); blur
38
+ // strips it back to the filled region - or clears the field entirely
39
+ // when autoClear is set and the mask is incomplete. A COLLAPSED caret
40
+ // is clamped into [first token, end of filled region] on focus (rAF),
41
+ // mousedown (rAF) and mouseup; selections are never touched.
42
+ //
43
+ // A deliberate divergence from the adapted source: every programmatic value write dispatches a
44
+ // native bubbling `input` event (#painting guards the controller against
45
+ // its own echo) so Rails/Turbo listeners stay live. The raw value mirrors
46
+ // to data-raw after every change; `pattern` is set on connect from
47
+ // generatePattern("full-inexact") unless the input already carries one.
48
+ const MAX_UNDO_HISTORY = 100
49
+
50
+ export default class MaskController extends Controller {
51
+ // The events this controller dispatches (manifest surface;
52
+ // events_declaration.test.js enforces the list stays honest).
53
+ static events = ["poetry:mask:change", "poetry:mask:complete"]
54
+
55
+ static values = {
56
+ mask: String,
57
+ slotChar: { type: String, default: "_" },
58
+ alwaysShowMask: Boolean,
59
+ showMaskOnFocus: { type: Boolean, default: true },
60
+ autoClear: Boolean,
61
+ upcase: Boolean
62
+ }
63
+
64
+ #slots = []
65
+ #processed = "" // the masked value, no skeleton padding
66
+ #display = "" // what the input currently shows (may include skeleton)
67
+ #raw = ""
68
+ #wasComplete = false
69
+ #focused = false
70
+ #painting = false // set while dispatching our own native input echo
71
+ #undoStack = []
72
+ #redoStack = []
73
+
74
+ /**
75
+ * Parses the mask, stamps the pattern attribute (unless the input
76
+ * carries one), wires the listener set, and processes a server-rendered
77
+ * value silently.
78
+ */
79
+ connect() {
80
+ if (this.element.tagName !== "INPUT") {
81
+ console.warn("poetry--core--mask: must be attached to an <input>")
82
+ return
83
+ }
84
+
85
+ this.#slots = parseMask(this.maskValue)
86
+
87
+ if (!this.element.hasAttribute("pattern")) {
88
+ this.element.setAttribute("pattern", generatePattern(this.#slots, "full-inexact"))
89
+ }
90
+
91
+ this.#listen("addEventListener")
92
+ this.#initialize()
93
+ }
94
+
95
+ /** Unwires the listener set. */
96
+ disconnect() {
97
+ this.#listen("removeEventListener")
98
+ }
99
+
100
+ #listen(method) {
101
+ for (const [type, handler] of [
102
+ ["input", this.#onInput],
103
+ ["focus", this.#onFocus],
104
+ ["blur", this.#onBlur],
105
+ ["mousedown", this.#onMousedown],
106
+ ["mouseup", this.#onMouseup],
107
+ ["keydown", this.#onKeydown],
108
+ ["paste", this.#onPaste]
109
+ ]) this.element[method](type, handler)
110
+ }
111
+
112
+ // Mount: a server-rendered value is processed through the mask SILENTLY -
113
+ // no poetry events, no native input echo (nothing changed semantically).
114
+ #initialize() {
115
+ const value = this.element.value
116
+
117
+ if (value) {
118
+ const processed = processInput(value, this.#slots)
119
+
120
+ this.#processed = processed
121
+ this.#raw = extractRaw(processed, this.#slots)
122
+ this.#display = buildDisplayValue(processed, this.#slots, this.slotCharValue, this.#showSlots(processed))
123
+ this.#wasComplete = checkComplete(processed, this.#slots)
124
+ this.element.value = this.#display
125
+ } else if (this.alwaysShowMaskValue) {
126
+ this.#display = buildDisplayValue("", this.#slots, this.slotCharValue, true)
127
+ this.element.value = this.#display
128
+ }
129
+
130
+ this.element.setAttribute("data-raw", this.#raw)
131
+ }
132
+
133
+ // --- the keydown path (primary: full editing control) ---
134
+
135
+ #onKeydown = (event) => {
136
+ const input = this.element
137
+ const start = input.selectionStart ?? 0
138
+ const end = input.selectionEnd ?? 0
139
+ const processed = this.#processed
140
+ const slots = this.#slots
141
+ const modifier = event.metaKey || (event.ctrlKey && !event.altKey)
142
+ const key = (event.key ?? "").toLowerCase()
143
+
144
+ if (modifier && key === "z" && !event.shiftKey) {
145
+ event.preventDefault()
146
+ this.#restore(this.#undoStack, this.#redoStack)
147
+ return
148
+ }
149
+
150
+ if (modifier && ((key === "z" && event.shiftKey) || (key === "y" && !event.shiftKey))) {
151
+ event.preventDefault()
152
+ this.#restore(this.#redoStack, this.#undoStack)
153
+ return
154
+ }
155
+
156
+ if (event.key === "Backspace") {
157
+ event.preventDefault()
158
+
159
+ if (modifier) {
160
+ // Cmd/Ctrl+Backspace: kill to start, keep the tail.
161
+ const from = Math.min(start, processed.length)
162
+ const afterRaw = extractRaw(processed.slice(from), slots.slice(from))
163
+
164
+ this.#pushUndo()
165
+ this.#updateValue(this.#mask(afterRaw), 0)
166
+ return
167
+ }
168
+
169
+ if (start !== end) {
170
+ this.#deleteSelection(start, end)
171
+ return
172
+ }
173
+
174
+ if (start === 0) return
175
+
176
+ // Scan BACKWARD over literals; the caret lands at the deleted token.
177
+ let pos = start - 1
178
+
179
+ while (pos >= 0 && slots[pos]?.type === "literal") pos--
180
+
181
+ if (pos < 0) return
182
+
183
+ const beforeRaw = extractRaw(processed.slice(0, pos), slots.slice(0, pos))
184
+ const afterRaw = extractRaw(processed.slice(pos + 1), slots.slice(pos + 1))
185
+
186
+ this.#pushUndo()
187
+ this.#updateValue(this.#mask(beforeRaw + afterRaw), pos)
188
+ return
189
+ }
190
+
191
+ if (event.key === "Delete") {
192
+ event.preventDefault()
193
+
194
+ if (start !== end) {
195
+ this.#deleteSelection(start, end)
196
+ return
197
+ }
198
+
199
+ // Scan FORWARD over literals; the caret stays put.
200
+ let pos = start
201
+
202
+ while (pos < slots.length && slots[pos]?.type === "literal") pos++
203
+
204
+ if (pos >= processed.length) return
205
+
206
+ const beforeRaw = extractRaw(processed.slice(0, start), slots.slice(0, start))
207
+ const afterRaw = extractRaw(processed.slice(pos + 1), slots.slice(pos + 1))
208
+
209
+ this.#pushUndo()
210
+ this.#updateValue(this.#mask(beforeRaw + afterRaw), start)
211
+ return
212
+ }
213
+
214
+ if (event.key === "ArrowRight" && !event.shiftKey) {
215
+ const next = findNextEditablePosition(start + 1, slots, input.value.length)
216
+
217
+ if (next !== start + 1) {
218
+ event.preventDefault()
219
+ input.setSelectionRange(next, next)
220
+ }
221
+ return
222
+ }
223
+
224
+ if (event.key === "ArrowLeft" && !event.shiftKey) {
225
+ if (start === 0) return
226
+
227
+ const previous = prevTokenPosition(slots, start - 1)
228
+
229
+ if (previous >= 0 && previous !== start - 1) {
230
+ event.preventDefault()
231
+ input.setSelectionRange(previous + 1, previous + 1)
232
+ }
233
+ return
234
+ }
235
+
236
+ if ((event.key ?? "").length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
237
+ event.preventDefault() // a failing char is a no-op, never the field's problem
238
+
239
+ let pos = Math.min(start, processed.length)
240
+
241
+ while (pos < slots.length && slots[pos]?.type === "literal") pos++
242
+
243
+ if (pos >= slots.length) return
244
+
245
+ const transform = this.#transform()
246
+ const char = transform ? transform(event.key) : event.key
247
+
248
+ if (!slots[pos].pattern.test(char)) return
249
+
250
+ const beforeRaw = extractRaw(processed.slice(0, pos), slots.slice(0, pos))
251
+ const afterFrom = start < end ? Math.min(end, processed.length) : pos
252
+ const afterRaw = extractRaw(processed.slice(afterFrom), slots.slice(afterFrom))
253
+ const masked = this.#mask(beforeRaw + char + afterRaw)
254
+
255
+ this.#pushUndo()
256
+ this.#updateValue(masked, findNextEditablePosition(pos + 1, slots, masked.length))
257
+ }
258
+ }
259
+
260
+ // Selection deletion (Backspace and Delete share it): keep the raw
261
+ // before the selection, keep the raw after it, re-mask, caret at start.
262
+ #deleteSelection(start, end) {
263
+ const processed = this.#processed
264
+ const to = Math.min(end, processed.length)
265
+ const beforeRaw = extractRaw(processed.slice(0, start), this.#slots.slice(0, start))
266
+ const afterRaw = extractRaw(processed.slice(to), this.#slots.slice(to))
267
+
268
+ this.#pushUndo()
269
+ this.#updateValue(this.#mask(beforeRaw + afterRaw), start)
270
+ }
271
+
272
+ // --- the input-event diff fallback (IME / mobile / autofill) ---
273
+
274
+ #onInput = () => {
275
+ if (this.#painting) return // our own native echo
276
+
277
+ const prev = this.#display
278
+ const curr = this.element.value
279
+
280
+ let prefix = 0
281
+ const maxPrefix = Math.min(prev.length, curr.length)
282
+
283
+ while (prefix < maxPrefix && prev[prefix] === curr[prefix]) prefix++
284
+
285
+ let suffix = 0
286
+ const maxSuffix = Math.min(prev.length - prefix, curr.length - prefix)
287
+
288
+ while (suffix < maxSuffix && prev[prev.length - 1 - suffix] === curr[curr.length - 1 - suffix]) suffix++
289
+
290
+ const inserted = curr.slice(prefix, curr.length - suffix)
291
+ const removedEnd = prev.length - suffix
292
+ const beforeRaw = extractRaw(prev.slice(0, prefix), this.#slots.slice(0, prefix))
293
+ const afterRaw = extractRaw(prev.slice(removedEnd), this.#slots.slice(removedEnd))
294
+ const masked = this.#mask(beforeRaw + inserted + afterRaw)
295
+
296
+ if (masked !== prev) this.#pushUndo()
297
+
298
+ this.#updateValue(masked, this.#mask(beforeRaw + inserted).length)
299
+ }
300
+
301
+ // --- paste: raw insertion at the clamped selection ---
302
+
303
+ #onPaste = (event) => {
304
+ event.preventDefault()
305
+
306
+ const text = event.clipboardData?.getData("text") ?? ""
307
+ const input = this.element
308
+ const processed = this.#processed
309
+ const start = Math.min(input.selectionStart ?? 0, processed.length)
310
+ const end = Math.min(input.selectionEnd ?? 0, processed.length)
311
+ const beforeRaw = extractRaw(processed.slice(0, start), this.#slots.slice(0, start))
312
+ const afterRaw = extractRaw(processed.slice(end), this.#slots.slice(end))
313
+
314
+ this.#pushUndo()
315
+ this.#updateValue(this.#mask(beforeRaw + text + afterRaw))
316
+
317
+ // The caret lands after the MASKED insertion (invalid chars dropped).
318
+ const caret = Math.min(this.#mask(beforeRaw + text).length, this.#slots.length)
319
+
320
+ if (document.activeElement === input) input.setSelectionRange(caret, caret)
321
+ }
322
+
323
+ // --- focus / blur: skeleton lifecycle ---
324
+
325
+ #onFocus = () => {
326
+ this.#focused = true
327
+
328
+ if (this.showMaskOnFocusValue || this.alwaysShowMaskValue) {
329
+ this.#display = buildDisplayValue(this.#processed, this.#slots, this.slotCharValue, true)
330
+ this.#paint(this.#display)
331
+ }
332
+
333
+ // rAF: the browser places the caret AFTER focus fires.
334
+ requestAnimationFrame(() => {
335
+ if (document.activeElement === this.element) this.#clampCaret({ floor: true })
336
+ })
337
+ }
338
+
339
+ #onBlur = () => {
340
+ this.#focused = false
341
+
342
+ const slots = this.#slots
343
+ const skeleton = buildDisplayValue(this.#processed, slots, this.slotCharValue, true)
344
+ // An externally mutated value (no input event fired) still re-parses.
345
+ const processed = this.element.value === skeleton
346
+ ? this.#processed
347
+ : processInput(this.element.value, slots)
348
+ const complete = checkComplete(processed, slots)
349
+
350
+ if (this.autoClearValue && !complete && processed.length > 0) {
351
+ this.#clear()
352
+
353
+ if (this.alwaysShowMaskValue) {
354
+ this.#display = buildDisplayValue("", slots, this.slotCharValue, true)
355
+ this.#paint(this.#display)
356
+ }
357
+ return
358
+ }
359
+
360
+ if (!this.alwaysShowMaskValue && !complete) {
361
+ if (extractRaw(processed, slots).length === 0) {
362
+ this.#clear()
363
+ return
364
+ }
365
+
366
+ // Strip the skeleton padding; the filled region stays.
367
+ this.#processed = processed
368
+ this.#raw = extractRaw(processed, slots)
369
+ this.#display = processed
370
+ this.element.setAttribute("data-raw", this.#raw)
371
+ this.#paint(processed)
372
+ }
373
+ }
374
+
375
+ // --- caret clamping (focus rAF / mousedown rAF / mouseup) ---
376
+
377
+ #onMouseup = () => {
378
+ if (document.activeElement === this.element) this.#clampCaret({ floor: true })
379
+ }
380
+
381
+ #onMousedown = () => {
382
+ // rAF: the click's caret placement hasn't happened yet at mousedown.
383
+ requestAnimationFrame(() => {
384
+ if (document.activeElement === this.element) this.#clampCaret({ floor: false })
385
+ })
386
+ }
387
+
388
+ // Clamp a COLLAPSED caret into [first token, end of the filled region];
389
+ // selections are never touched (floor: the mousedown variant only caps).
390
+ #clampCaret({ floor }) {
391
+ const input = this.element
392
+ const start = input.selectionStart ?? 0
393
+
394
+ if (start !== (input.selectionEnd ?? 0)) return
395
+
396
+ const first = nextTokenPosition(this.#slots)
397
+ const limit = this.#processed.length > 0
398
+ ? findNextEditablePosition(this.#processed.length, this.#slots, this.#processed.length)
399
+ : first
400
+
401
+ if (start > limit || (floor && start < first)) input.setSelectionRange(limit, limit)
402
+ }
403
+
404
+ // --- the undo/redo history (preventDefault kills the native stack) ---
405
+
406
+ #pushUndo() {
407
+ const state = { raw: this.#raw, selectionStart: this.element.selectionStart ?? this.#raw.length }
408
+ const top = this.#undoStack[this.#undoStack.length - 1]
409
+
410
+ if (top && top.raw === state.raw && top.selectionStart === state.selectionStart) return
411
+
412
+ this.#undoStack.push(state)
413
+
414
+ if (this.#undoStack.length > MAX_UNDO_HISTORY) this.#undoStack.shift()
415
+
416
+ this.#redoStack = []
417
+ }
418
+
419
+ #restore(from, to) {
420
+ const state = from.pop()
421
+
422
+ if (!state) return
423
+
424
+ to.push({ raw: this.#raw, selectionStart: this.element.selectionStart ?? 0 })
425
+ this.#updateValue(this.#mask(state.raw), state.selectionStart)
426
+ }
427
+
428
+ // --- the single value sink ---
429
+
430
+ // Canonicalize -> re-derive raw + display -> write the DOM -> fire the
431
+ // events. change fires per user-driven mutation; complete fires ONCE per
432
+ // rise into complete (editing below re-arms it).
433
+ #updateValue(masked, caret) {
434
+ const processed = processInput(masked, this.#slots)
435
+ const raw = extractRaw(processed, this.#slots)
436
+ const display = buildDisplayValue(processed, this.#slots, this.slotCharValue, this.#showSlots(processed))
437
+
438
+ this.#processed = processed
439
+ this.#raw = raw
440
+ this.#display = display
441
+ this.element.setAttribute("data-raw", raw)
442
+ this.#paint(display, caret === undefined ? undefined : Math.min(caret, processed.length))
443
+
444
+ const complete = checkComplete(processed, this.#slots)
445
+ const detail = { raw, masked: display, complete }
446
+
447
+ this.dispatch("change", { prefix: "poetry:mask", detail })
448
+
449
+ if (complete && !this.#wasComplete) {
450
+ this.dispatch("complete", { prefix: "poetry:mask", detail })
451
+ }
452
+
453
+ this.#wasComplete = complete
454
+ }
455
+
456
+ // autoClear / empty blur: everything resets and listeners hear about it.
457
+ #clear() {
458
+ this.#processed = ""
459
+ this.#display = ""
460
+ this.#raw = ""
461
+ this.#wasComplete = false
462
+ this.element.setAttribute("data-raw", "")
463
+ this.#paint("")
464
+ this.dispatch("change", { prefix: "poetry:mask", detail: { raw: "", masked: "", complete: false } })
465
+ }
466
+
467
+ // Every programmatic value write echoes a native bubbling input event
468
+ // (the recorded port caveat - Rails/Turbo listeners must stay
469
+ // live); #painting keeps #onInput from re-processing the echo.
470
+ #paint(display, caret) {
471
+ const changed = this.element.value !== display
472
+
473
+ if (changed) this.element.value = display
474
+
475
+ if (caret !== undefined && document.activeElement === this.element) {
476
+ this.element.setSelectionRange(caret, caret)
477
+ }
478
+
479
+ if (!changed) return
480
+
481
+ this.#painting = true
482
+ this.element.dispatchEvent(new Event("input", { bubbles: true }))
483
+ this.#painting = false
484
+ }
485
+
486
+ #showSlots(processed) {
487
+ return (this.alwaysShowMaskValue || this.#focused) &&
488
+ (this.showMaskOnFocusValue || processed.length > 0)
489
+ }
490
+
491
+ #mask(raw) {
492
+ return applyMaskToRaw(raw, this.#slots, this.#transform())
493
+ }
494
+
495
+ // A transform could generalize to any fn; poetry keeps ONE
496
+ // declarative knob - uppercase before validation.
497
+ #transform() {
498
+ return this.upcaseValue ? (char) => char.toUpperCase() : undefined
499
+ }
500
+ }