@kitn.ai/ui 0.28.0 → 0.29.0

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 (376) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-tooling/construct/public.d.ts +15 -0
  3. package/dist/agent-tooling/construct/schema.d.ts +111 -0
  4. package/dist/construct-cli.es.js +3 -3
  5. package/dist/construct.d.ts +5 -0
  6. package/dist/construct.js +327 -0
  7. package/dist/core-C8fzo39E.js +1 -12
  8. package/dist/engine-javascript-C1x7zo1_.js +1 -141
  9. package/dist/mcp.es.js +1 -1
  10. package/package.json +16 -16
  11. package/bin/route.test.js +0 -58
  12. package/src/agent-tooling/README.md +0 -192
  13. package/src/agent-tooling/archetypes.ts +0 -205
  14. package/src/agent-tooling/catalog/README.md +0 -676
  15. package/src/agent-tooling/catalog/catalog-types.ts +0 -231
  16. package/src/agent-tooling/catalog/fabrications.ts +0 -96
  17. package/src/agent-tooling/catalog/invariants.ts +0 -284
  18. package/src/agent-tooling/catalog/labs-titles.ts +0 -114
  19. package/src/agent-tooling/catalog/scenarios.ts +0 -87
  20. package/src/agent-tooling/catalog/surfaces.ts +0 -347
  21. package/src/agent-tooling/construct/cli-entry.ts +0 -8
  22. package/src/agent-tooling/construct/cli.ts +0 -147
  23. package/src/agent-tooling/construct/codegen.ts +0 -1635
  24. package/src/agent-tooling/construct/construct.v1.schema.json +0 -319
  25. package/src/agent-tooling/construct/dev.ts +0 -148
  26. package/src/agent-tooling/construct/fixtures/demo-widget.construct.json +0 -7
  27. package/src/agent-tooling/construct/fixtures/ops-console.construct.json +0 -44
  28. package/src/agent-tooling/construct/fixtures/owner-widget.construct.json +0 -32
  29. package/src/agent-tooling/construct/schema.ts +0 -452
  30. package/src/agent-tooling/integrations/anthropic.ts +0 -442
  31. package/src/agent-tooling/integrations/cloudflare.ts +0 -218
  32. package/src/agent-tooling/integrations/langgraph.ts +0 -133
  33. package/src/agent-tooling/integrations/mastra.ts +0 -182
  34. package/src/agent-tooling/integrations/mock.ts +0 -148
  35. package/src/agent-tooling/integrations/ollama.ts +0 -97
  36. package/src/agent-tooling/integrations/openai.ts +0 -95
  37. package/src/agent-tooling/integrations/openrouter.ts +0 -82
  38. package/src/agent-tooling/integrations/pi.ts +0 -176
  39. package/src/agent-tooling/integrations/pydantic-ai.ts +0 -108
  40. package/src/agent-tooling/integrations/vercel-ai-sdk.ts +0 -398
  41. package/src/agent-tooling/mcp/css-raw.d.ts +0 -12
  42. package/src/agent-tooling/mcp/manifest.ts +0 -422
  43. package/src/agent-tooling/mcp/server.ts +0 -116
  44. package/src/agent-tooling/mcp/stdio.ts +0 -18
  45. package/src/agent-tooling/mcp/tools/construct.ts +0 -130
  46. package/src/agent-tooling/mcp/tools/debug.ts +0 -621
  47. package/src/agent-tooling/mcp/tools/reference.ts +0 -980
  48. package/src/agent-tooling/mcp/tools/scaffold.ts +0 -6708
  49. package/src/agent-tooling/mcp/tools/theme.ts +0 -456
  50. package/src/agent-tooling/mcp/tools/types.ts +0 -14
  51. package/src/agent-tooling/mcp/validate-args.ts +0 -141
  52. package/src/agent-tooling/recipes/composed-thread.ts +0 -714
  53. package/src/agent-tooling/recipes/index.ts +0 -22
  54. package/src/agent-tooling/recipes/types.ts +0 -32
  55. package/src/agent-tooling/registry.ts +0 -132
  56. package/src/agent-tooling/route-emit.ts +0 -305
  57. package/src/agent-tooling/types.ts +0 -390
  58. package/src/components/artifact-card.tsx +0 -120
  59. package/src/components/artifact.tsx +0 -914
  60. package/src/components/attachment-types.ts +0 -66
  61. package/src/components/attachments.tsx +0 -536
  62. package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +0 -257
  63. package/src/components/audio-visualizer/aurora.glsl.ts +0 -330
  64. package/src/components/audio-visualizer/fit-scale.ts +0 -112
  65. package/src/components/audio-visualizer/index.tsx +0 -528
  66. package/src/components/audio-visualizer/labs/lab-choreography.ts +0 -187
  67. package/src/components/audio-visualizer/labs/lab-shaders.ts +0 -363
  68. package/src/components/audio-visualizer/labs/lab-visualizer.tsx +0 -102
  69. package/src/components/audio-visualizer/shader-canvas.tsx +0 -876
  70. package/src/components/audio-visualizer/sizes.ts +0 -54
  71. package/src/components/audio-visualizer/variant-aurora.tsx +0 -305
  72. package/src/components/audio-visualizer/variant-bar.tsx +0 -175
  73. package/src/components/audio-visualizer/variant-custom.tsx +0 -344
  74. package/src/components/audio-visualizer/variant-grid.tsx +0 -175
  75. package/src/components/audio-visualizer/variant-radial.tsx +0 -163
  76. package/src/components/audio-visualizer/variant-wave.tsx +0 -130
  77. package/src/components/audio-visualizer/wave.glsl.ts +0 -130
  78. package/src/components/card-fallback.tsx +0 -60
  79. package/src/components/card-renderer.tsx +0 -220
  80. package/src/components/card.tsx +0 -212
  81. package/src/components/chain-of-thought.tsx +0 -348
  82. package/src/components/chat-container.tsx +0 -89
  83. package/src/components/chat-scope-picker.tsx +0 -57
  84. package/src/components/chat-thread.tsx +0 -1014
  85. package/src/components/checkpoint.tsx +0 -88
  86. package/src/components/choice-card.tsx +0 -743
  87. package/src/components/coachmark.tsx +0 -249
  88. package/src/components/code-block.tsx +0 -181
  89. package/src/components/composer-dom.ts +0 -159
  90. package/src/components/composer-highlight.ts +0 -242
  91. package/src/components/composer-history.ts +0 -82
  92. package/src/components/composer.tsx +0 -1075
  93. package/src/components/confirm-card.tsx +0 -402
  94. package/src/components/context.tsx +0 -377
  95. package/src/components/conversation-item.tsx +0 -241
  96. package/src/components/conversation-list.tsx +0 -429
  97. package/src/components/conversation-panel.tsx +0 -118
  98. package/src/components/dismissed-stub.tsx +0 -80
  99. package/src/components/embed.tsx +0 -200
  100. package/src/components/empty.tsx +0 -177
  101. package/src/components/feedback-bar.tsx +0 -166
  102. package/src/components/file-tree.tsx +0 -530
  103. package/src/components/file-upload.tsx +0 -167
  104. package/src/components/form-widgets.tsx +0 -525
  105. package/src/components/form.tsx +0 -1296
  106. package/src/components/home-panel.tsx +0 -159
  107. package/src/components/image.tsx +0 -65
  108. package/src/components/link-preview.tsx +0 -195
  109. package/src/components/loader.tsx +0 -367
  110. package/src/components/markdown.tsx +0 -162
  111. package/src/components/message-skills.tsx +0 -40
  112. package/src/components/message.tsx +0 -765
  113. package/src/components/model-switcher.tsx +0 -132
  114. package/src/components/prompt-input.tsx +0 -188
  115. package/src/components/prompt-suggestion.tsx +0 -150
  116. package/src/components/reasoning.tsx +0 -293
  117. package/src/components/response-compare-types.ts +0 -152
  118. package/src/components/response-compare.tsx +0 -455
  119. package/src/components/response-stream.tsx +0 -109
  120. package/src/components/screen.tsx +0 -181
  121. package/src/components/scroll-button.tsx +0 -93
  122. package/src/components/source.tsx +0 -192
  123. package/src/components/tasks-card.tsx +0 -801
  124. package/src/components/text-shimmer.tsx +0 -37
  125. package/src/components/thinking-bar.tsx +0 -50
  126. package/src/components/thread.tsx +0 -228
  127. package/src/components/toast.tsx +0 -503
  128. package/src/components/tool-classify.ts +0 -26
  129. package/src/components/tool-types.ts +0 -49
  130. package/src/components/tool.tsx +0 -190
  131. package/src/components/use-card-resolution.ts +0 -44
  132. package/src/components/voice-input.tsx +0 -222
  133. package/src/components/voice-output.tsx +0 -242
  134. package/src/components/widget-tab-bar.tsx +0 -73
  135. package/src/components/workspace-shell.tsx +0 -285
  136. package/src/diagnostics/hook.ts +0 -352
  137. package/src/diagnostics/index.ts +0 -82
  138. package/src/diagnostics/report-request.ts +0 -296
  139. package/src/elements/agent-card.tsx +0 -67
  140. package/src/elements/artifact.tsx +0 -194
  141. package/src/elements/attachments.tsx +0 -140
  142. package/src/elements/audio-visualizer.tsx +0 -227
  143. package/src/elements/autoloader.ts +0 -89
  144. package/src/elements/avatar.tsx +0 -33
  145. package/src/elements/badge.tsx +0 -32
  146. package/src/elements/button.tsx +0 -149
  147. package/src/elements/card.tsx +0 -133
  148. package/src/elements/cards.tsx +0 -266
  149. package/src/elements/chain-of-thought.tsx +0 -141
  150. package/src/elements/chat-scope-picker.tsx +0 -65
  151. package/src/elements/chat-types.ts +0 -102
  152. package/src/elements/chat-workspace.tsx +0 -188
  153. package/src/elements/chat.tsx +0 -304
  154. package/src/elements/checkbox-group.tsx +0 -164
  155. package/src/elements/checkbox.tsx +0 -134
  156. package/src/elements/checkpoint.tsx +0 -47
  157. package/src/elements/choice.tsx +0 -100
  158. package/src/elements/coachmark.tsx +0 -90
  159. package/src/elements/code-block.tsx +0 -60
  160. package/src/elements/command.tsx +0 -197
  161. package/src/elements/compare.tsx +0 -111
  162. package/src/elements/compiled.css +0 -2
  163. package/src/elements/composer.tsx +0 -162
  164. package/src/elements/confirm-card.tsx +0 -76
  165. package/src/elements/context-meter.tsx +0 -107
  166. package/src/elements/conversation-item.tsx +0 -120
  167. package/src/elements/conversation-list.tsx +0 -203
  168. package/src/elements/css.ts +0 -5
  169. package/src/elements/default-input.tsx +0 -392
  170. package/src/elements/define-entry.ts +0 -12
  171. package/src/elements/define.tsx +0 -563
  172. package/src/elements/diagnostic-events.ts +0 -114
  173. package/src/elements/dialog.tsx +0 -145
  174. package/src/elements/disclosure.ts +0 -95
  175. package/src/elements/dock.tsx +0 -215
  176. package/src/elements/dropdown.tsx +0 -123
  177. package/src/elements/editable-label.tsx +0 -140
  178. package/src/elements/element-data-types.ts +0 -152
  179. package/src/elements/element-diagnostics.ts +0 -392
  180. package/src/elements/element-manifest.json +0 -354
  181. package/src/elements/element-nonscalar.json +0 -187
  182. package/src/elements/element-types.d.ts +0 -4353
  183. package/src/elements/embed.tsx +0 -35
  184. package/src/elements/empty.tsx +0 -29
  185. package/src/elements/feedback-bar.tsx +0 -66
  186. package/src/elements/file-tree.tsx +0 -62
  187. package/src/elements/file-upload.tsx +0 -44
  188. package/src/elements/form.tsx +0 -94
  189. package/src/elements/hover-card.tsx +0 -80
  190. package/src/elements/icon.tsx +0 -41
  191. package/src/elements/image.tsx +0 -32
  192. package/src/elements/input.tsx +0 -492
  193. package/src/elements/kbd.tsx +0 -45
  194. package/src/elements/link-preview.tsx +0 -34
  195. package/src/elements/loader.tsx +0 -25
  196. package/src/elements/markdown.tsx +0 -38
  197. package/src/elements/menu.tsx +0 -241
  198. package/src/elements/message-skills.tsx +0 -83
  199. package/src/elements/message.tsx +0 -361
  200. package/src/elements/model-switcher.tsx +0 -121
  201. package/src/elements/nav.tsx +0 -87
  202. package/src/elements/notice.tsx +0 -53
  203. package/src/elements/pane-grid.tsx +0 -111
  204. package/src/elements/pane-group.tsx +0 -119
  205. package/src/elements/pane.tsx +0 -129
  206. package/src/elements/popover.tsx +0 -81
  207. package/src/elements/progress-bar.tsx +0 -39
  208. package/src/elements/prompt-dock.tsx +0 -82
  209. package/src/elements/prompt-input.tsx +0 -240
  210. package/src/elements/prompt-suggestions.tsx +0 -113
  211. package/src/elements/radio-group.tsx +0 -129
  212. package/src/elements/reasoning.tsx +0 -71
  213. package/src/elements/register-impl.ts +0 -148
  214. package/src/elements/register.ts +0 -76
  215. package/src/elements/remote.tsx +0 -225
  216. package/src/elements/resizable.globals.d.ts +0 -36
  217. package/src/elements/resizable.tsx +0 -704
  218. package/src/elements/response-stream.tsx +0 -40
  219. package/src/elements/screen.tsx +0 -90
  220. package/src/elements/scroll-area.tsx +0 -33
  221. package/src/elements/scroll-button.tsx +0 -179
  222. package/src/elements/search.tsx +0 -185
  223. package/src/elements/segmented.tsx +0 -129
  224. package/src/elements/select.tsx +0 -165
  225. package/src/elements/separator.tsx +0 -36
  226. package/src/elements/setting-item.tsx +0 -50
  227. package/src/elements/settings-group.tsx +0 -37
  228. package/src/elements/skeleton.tsx +0 -45
  229. package/src/elements/slider.tsx +0 -171
  230. package/src/elements/slot-text.ts +0 -73
  231. package/src/elements/slots.ts +0 -787
  232. package/src/elements/source.tsx +0 -126
  233. package/src/elements/status.tsx +0 -42
  234. package/src/elements/styles.css +0 -328
  235. package/src/elements/switch.tsx +0 -118
  236. package/src/elements/tabs.tsx +0 -95
  237. package/src/elements/tasks.tsx +0 -103
  238. package/src/elements/text-shimmer.tsx +0 -28
  239. package/src/elements/thinking-bar.tsx +0 -43
  240. package/src/elements/thread.tsx +0 -139
  241. package/src/elements/toast.tsx +0 -106
  242. package/src/elements/tool.tsx +0 -78
  243. package/src/elements/tooltip.tsx +0 -85
  244. package/src/elements/validate-messages.ts +0 -76
  245. package/src/elements/voice-input.tsx +0 -100
  246. package/src/elements/voice-output.tsx +0 -82
  247. package/src/index.ts +0 -430
  248. package/src/primitives/audio-bands.ts +0 -174
  249. package/src/primitives/card-component-types.ts +0 -63
  250. package/src/primitives/card-contract.ts +0 -80
  251. package/src/primitives/card-data-types.ts +0 -361
  252. package/src/primitives/card-host.tsx +0 -37
  253. package/src/primitives/card-recovery.ts +0 -160
  254. package/src/primitives/card-registry.tsx +0 -78
  255. package/src/primitives/card-resolution.ts +0 -36
  256. package/src/primitives/card-routing.ts +0 -85
  257. package/src/primitives/card-schemas/artifact.schema.json +0 -92
  258. package/src/primitives/card-schemas/card-envelope.schema.json +0 -14
  259. package/src/primitives/card-schemas/card-event.schema.json +0 -12
  260. package/src/primitives/card-schemas/choice.schema.json +0 -75
  261. package/src/primitives/card-schemas/confirm.schema.json +0 -65
  262. package/src/primitives/card-schemas/embed.schema.json +0 -63
  263. package/src/primitives/card-schemas/form.result.schema.json +0 -7
  264. package/src/primitives/card-schemas/form.schema.json +0 -53
  265. package/src/primitives/card-schemas/link.schema.json +0 -54
  266. package/src/primitives/card-schemas/tasks.result.schema.json +0 -16
  267. package/src/primitives/card-schemas/tasks.schema.json +0 -83
  268. package/src/primitives/card-tags.ts +0 -61
  269. package/src/primitives/card-validate-cards.ts +0 -255
  270. package/src/primitives/card-validate-schemas.ts +0 -58
  271. package/src/primitives/card-validate.ts +0 -131
  272. package/src/primitives/chat-config.tsx +0 -103
  273. package/src/primitives/composer-model.ts +0 -49
  274. package/src/primitives/composer-triggers.ts +0 -35
  275. package/src/primitives/controllable.ts +0 -21
  276. package/src/primitives/conversation-store.ts +0 -248
  277. package/src/primitives/create-kai-chat.ts +0 -66
  278. package/src/primitives/create-tween.ts +0 -266
  279. package/src/primitives/embed-providers.ts +0 -254
  280. package/src/primitives/field-mask.ts +0 -256
  281. package/src/primitives/field-semantics.ts +0 -115
  282. package/src/primitives/highlighter.ts +0 -157
  283. package/src/primitives/input-mask.ts +0 -853
  284. package/src/primitives/link-preview.ts +0 -87
  285. package/src/primitives/message-feedback.ts +0 -124
  286. package/src/primitives/pdf-preview.ts +0 -121
  287. package/src/primitives/toast-store.ts +0 -338
  288. package/src/primitives/url-scheme-policy.ts +0 -70
  289. package/src/primitives/use-audio-analysis.ts +0 -340
  290. package/src/primitives/use-auto-resize.ts +0 -31
  291. package/src/primitives/use-resize-observer.ts +0 -45
  292. package/src/primitives/use-sequencer.ts +0 -72
  293. package/src/primitives/use-speech-recognition.ts +0 -146
  294. package/src/primitives/use-stick-to-bottom.ts +0 -75
  295. package/src/primitives/use-text-stream.ts +0 -143
  296. package/src/primitives/use-voice-recorder.ts +0 -62
  297. package/src/primitives/visualizer-sequences.ts +0 -350
  298. package/src/remote/host-embed.ts +0 -345
  299. package/src/remote/index.ts +0 -2
  300. package/src/remote/origin.ts +0 -30
  301. package/src/remote/provider-runtime.ts +0 -262
  302. package/src/remote/provider.ts +0 -2
  303. package/src/remote/validate.ts +0 -22
  304. package/src/remote/version.ts +0 -12
  305. package/src/remote/wire.ts +0 -48
  306. package/src/schemas/from-tool-call.ts +0 -151
  307. package/src/schemas/index.ts +0 -341
  308. package/src/schemas/provider-subsets.ts +0 -538
  309. package/src/schemas/registry.ts +0 -355
  310. package/src/schemas/tool-defs.ts +0 -793
  311. package/src/solid.ts +0 -173
  312. package/src/state/index.ts +0 -44
  313. package/src/state/messages.ts +0 -53
  314. package/src/state/mock.ts +0 -297
  315. package/src/state/parts.ts +0 -295
  316. package/src/state/persistence.ts +0 -180
  317. package/src/state/stream.ts +0 -246
  318. package/src/state/suggestions.ts +0 -9
  319. package/src/state/threads.ts +0 -142
  320. package/src/test-utils/fake-clock.ts +0 -88
  321. package/src/types.ts +0 -95
  322. package/src/ui/action-icons.ts +0 -53
  323. package/src/ui/agent-card.tsx +0 -218
  324. package/src/ui/avatar.tsx +0 -23
  325. package/src/ui/badge.tsx +0 -21
  326. package/src/ui/button.tsx +0 -49
  327. package/src/ui/card.tsx +0 -245
  328. package/src/ui/checkbox-group.tsx +0 -153
  329. package/src/ui/checkbox.tsx +0 -63
  330. package/src/ui/collapsible.tsx +0 -163
  331. package/src/ui/command.tsx +0 -103
  332. package/src/ui/dialog.tsx +0 -223
  333. package/src/ui/dock.tsx +0 -630
  334. package/src/ui/dropdown.tsx +0 -636
  335. package/src/ui/editable-label.tsx +0 -112
  336. package/src/ui/hover-card.tsx +0 -345
  337. package/src/ui/icon.tsx +0 -167
  338. package/src/ui/input.tsx +0 -391
  339. package/src/ui/kbd.tsx +0 -118
  340. package/src/ui/nav.tsx +0 -282
  341. package/src/ui/notice.tsx +0 -84
  342. package/src/ui/overlay.tsx +0 -277
  343. package/src/ui/pane-grid.tsx +0 -117
  344. package/src/ui/pane-group.tsx +0 -291
  345. package/src/ui/pane.tsx +0 -193
  346. package/src/ui/popover.tsx +0 -106
  347. package/src/ui/progress-bar.tsx +0 -83
  348. package/src/ui/prompt-dock.tsx +0 -145
  349. package/src/ui/radio.tsx +0 -150
  350. package/src/ui/resizable.tsx +0 -693
  351. package/src/ui/scroll-area.tsx +0 -26
  352. package/src/ui/segmented.tsx +0 -109
  353. package/src/ui/select.tsx +0 -168
  354. package/src/ui/separator.tsx +0 -10
  355. package/src/ui/settings-group.tsx +0 -67
  356. package/src/ui/skeleton.tsx +0 -73
  357. package/src/ui/slider.tsx +0 -178
  358. package/src/ui/status.tsx +0 -48
  359. package/src/ui/switch.tsx +0 -119
  360. package/src/ui/tabs.tsx +0 -168
  361. package/src/ui/textarea.tsx +0 -21
  362. package/src/ui/tooltip.tsx +0 -118
  363. package/src/utils/cn.ts +0 -30
  364. package/src/wire/chunk.ts +0 -248
  365. package/src/wire/consume.ts +0 -599
  366. package/src/wire/diagnostics.ts +0 -727
  367. package/src/wire/encode-probe.ts +0 -214
  368. package/src/wire/encode.ts +0 -947
  369. package/src/wire/files.ts +0 -342
  370. package/src/wire/formats/anthropic.ts +0 -274
  371. package/src/wire/formats/openai.ts +0 -228
  372. package/src/wire/index.ts +0 -95
  373. package/src/wire/media-types.ts +0 -344
  374. package/src/wire/read.ts +0 -396
  375. package/src/wire/sink-helpers.ts +0 -50
  376. package/src/wire/sse.ts +0 -153
@@ -1,853 +0,0 @@
1
- // src/primitives/input-mask.ts
2
- // Tier 2, the stateful half: one `HTMLInputElement` driven through the pure format engine
3
- // in `field-mask.ts`. Framework-agnostic -- no Solid, no DOM beyond the one input and its
4
- // document. Spec: docs/superpowers/specs/2026-08-24-form-field-formats-design.md
5
- // (§2 tier 2, §3, and the §5 improvement list, which is binding here).
6
- //
7
- // THE FORMATTED TEXT IS `input.value` (spec §2). There is no overlay and no ghost layer,
8
- // so the caret, selection, find-in-page and every mobile affordance are the browser's own.
9
- // What this module adds on top is: interception, normalization, a caret that never rests
10
- // inside a literal run, its own undo stack (a programmatic `.value` write destroys the
11
- // native one), and a stated clipboard policy.
12
- //
13
- // Four rules hold this file together; each is one of the §5 improvements and each is the
14
- // reason some obvious-looking shortcut is not taken:
15
- // 1. `commit` is the ONLY writer of `el.value`, the selection, the undo stacks and the
16
- // callbacks (§5.5). The reference this was derived from repeated that sequence in five
17
- // places and they had already drifted apart.
18
- // 2. `beforeinput` is the interception point WHERE IT IS CANCELABLE; where it is not, the
19
- // longest-common-prefix/suffix diff in `input` reconciles whatever the browser did
20
- // (§5.1). Both paths end in `applyEdit` -> `commit`, so there is one edit semantics.
21
- // 3. Between `compositionstart` and `compositionend` this module does NOTHING: no cancel,
22
- // no `.value` write, no caret move, no clamp (§5.2). Cancelling mid-composition breaks
23
- // the composition outright, and Android word suggestion is far more common in these
24
- // fields than CJK input.
25
- // 4. `.value` is never shadowed with `Object.defineProperty` (§5.8). The canonical value
26
- // is read through `getCanonicalValue()`; the element facade will publish it with
27
- // `setFormValue()`.
28
- //
29
- // ONE KNOWN IMPRECISION, recorded rather than papered over. An undo entry's selection is
30
- // read from the element at commit time. On the `beforeinput` path that is exactly right --
31
- // the event was canceled, so the caret has not moved yet. On the `input` diff fallback the
32
- // browser has ALREADY moved it, so the entry stores the post-edit caret against the
33
- // pre-edit text: undoing a browser-driven edit restores the correct text with a caret that
34
- // is merely plausible. Recovering the true one means caching the selection from
35
- // `selectionchange` and trusting that it fires before `input`, which is browser-timing
36
- // dependent and unverifiable in jsdom -- a guess dressed as a fix. The caret is clamped, so
37
- // it is never out of range. Task 6 can measure the real ordering and decide.
38
- import {
39
- compileMask,
40
- formatForDisplay,
41
- formatRaw,
42
- formattedToRawIndex,
43
- normalizeToRaw,
44
- rawFromFormatted,
45
- rawToFormattedIndex,
46
- type CaseMode,
47
- type MaskPattern,
48
- type RejectReason,
49
- } from './field-mask';
50
- import { canonicalize, type FieldSemanticType } from './field-semantics';
51
-
52
- /** What a copy or cut puts on the clipboard. A stated policy the consumer selects, NOT a
53
- * consequence of `obscure`: copying bullets is not a security control -- the
54
- * value is in the page -- and whether a card number may be copied at all is an app-layer
55
- * decision (CLAUDE.md: the kit decides HOW, the app decides WHETHER). */
56
- export type CopyPolicy = 'formatted' | 'canonical' | 'obscured' | 'blocked';
57
-
58
- /** Why an edit or a re-configuration refused, or partly refused, the content it was given.
59
- *
60
- * A superset of `field-mask.ts`'s `RejectReason`, widened HERE rather than there because
61
- * the extra member is not an engine verdict: the engine's three describe one edit failing
62
- * against one pattern, while `format-change-clipped` describes the PATTERN moving out from
63
- * under a value that was already accepted. Widening keeps every consumer that narrows on
64
- * the engine's three compiling unchanged. */
65
- export type InputMaskRejectReason = RejectReason | 'format-change-clipped';
66
-
67
- export interface InputMaskOptions {
68
- format: string;
69
- guide?: string;
70
- semantic?: FieldSemanticType;
71
- caseMode?: CaseMode;
72
- copyPolicy?: CopyPolicy;
73
- /** Tier 3. Wired -- it selects the default copy policy -- but does NOT yet transform the
74
- * display; that lands with the obscured rendering in tier 3 (task 10). */
75
- obscure?: boolean;
76
- initialValue?: string;
77
- onInput?: (detail: { canonical: string; formatted: string }) => void;
78
- onReject?: (detail: { reason: InputMaskRejectReason; data: string }) => void;
79
- }
80
-
81
- export interface InputMask {
82
- /** Accepts the canonical form or the formatted form; both normalize to the same value. */
83
- setValue(value: string): void;
84
- getRawValue(): string;
85
- getCanonicalValue(): string;
86
- getFormattedValue(): string;
87
- setObscure(on: boolean): void;
88
- /** Re-compiles and preserves the value, re-fitted to the new pattern. */
89
- update(next: Partial<InputMaskOptions>): void;
90
- detach(): void;
91
- }
92
-
93
- /** Undo history is capped (spec §5.6). A long-lived field otherwise grows an unbounded
94
- * array; dropping from the bottom keeps the recent history, which is the useful end. */
95
- const UNDO_LIMIT = 200;
96
-
97
- /** The obscured glyph, U+2022. Tier 3's display uses the same one. */
98
- const BULLET = '•';
99
-
100
- /** One undo entry: the state to restore, INCLUDING the selection the user had when the
101
- * edit that superseded it began (spec §5.6). Restoring text without the caret is what
102
- * makes a custom undo stack feel broken.
103
- *
104
- * `pattern` is a fourth field beyond the three the spec names, and it is what lets the
105
- * history SURVIVE an `update()`. A formatted string can only be decoded by the pattern
106
- * that produced it -- `V-123` under `V-***` is raw `123`, and under `#####` it is raw `123`
107
- * read from different positions -- so an entry restored under a pattern that did not write
108
- * it is garbage. Carrying the pattern lets `restore` decode with the right one and then
109
- * re-fit; without it the only safe thing to do on a format change is throw the whole stack
110
- * away, which is itself a silent drop of the user's history. */
111
- interface UndoEntry {
112
- readonly formatted: string;
113
- readonly pattern: MaskPattern;
114
- readonly selStart: number;
115
- readonly selEnd: number;
116
- }
117
-
118
- /** How a commit affects the undo history.
119
- * - `run` an insertion that may coalesce into the run already open.
120
- * - `break` an edit that ends the run and always gets its own entry.
121
- * - `none` a restore or a re-render: no history, and the redo stack survives. */
122
- type UndoMode = 'break' | 'run' | 'none';
123
-
124
- /** `inputType`s that insert. `insertText` is the one that coalesces into a typing run;
125
- * the rest are bulk edits and each gets its own undo entry. */
126
- const INSERT_TYPES = new Set([
127
- 'insertText',
128
- 'insertFromPaste',
129
- 'insertFromPasteAsQuotation',
130
- 'insertFromDrop',
131
- 'insertReplacementText',
132
- 'insertFromYank',
133
- 'insertTranspose',
134
- ]);
135
-
136
- /** `inputType`s that delete. `deleteByCut` is DELIBERATELY absent: the `cut` listener
137
- * cancels the cut and performs the deletion itself, so this event never arrives for a
138
- * cut -- and if it did, the range would already be gone and a second delete would eat a
139
- * character the user never selected. */
140
- const DELETE_TYPES = new Set([
141
- 'deleteContent',
142
- 'deleteContentBackward',
143
- 'deleteContentForward',
144
- 'deleteWordBackward',
145
- 'deleteWordForward',
146
- 'deleteSoftLineBackward',
147
- 'deleteSoftLineForward',
148
- 'deleteHardLineBackward',
149
- 'deleteHardLineForward',
150
- 'deleteByDrag',
151
- ]);
152
-
153
- /** Caret-moving keys break an open typing run (spec §5.6). Pointer-driven moves break it
154
- * through the `mousedown` listener. */
155
- const NAV_KEYS = new Set([
156
- 'ArrowLeft',
157
- 'ArrowRight',
158
- 'ArrowUp',
159
- 'ArrowDown',
160
- 'Home',
161
- 'End',
162
- 'PageUp',
163
- 'PageDown',
164
- ]);
165
-
166
- export function createInputMask(el: HTMLInputElement, options: InputMaskOptions): InputMask {
167
- let opts: InputMaskOptions = { ...options };
168
- let pattern: MaskPattern = compileMask(opts.format, opts.guide);
169
- let hasGuide = opts.guide !== undefined;
170
- let caseMode: CaseMode = opts.caseMode ?? 'preserve';
171
- let semantic: FieldSemanticType = opts.semantic ?? 'custom';
172
- let obscure = opts.obscure ?? false;
173
-
174
- /** The formatted text, authoritative and equal to `el.value` outside a composition. */
175
- let formatted = '';
176
- /** The pattern that produced `formatted`. Diverges from `pattern` for exactly the span of
177
- * an `update()`, which is why an undo entry pushed during one must be stamped with THIS
178
- * and not with the pattern that has already been swapped in. */
179
- let formattedPattern: MaskPattern = pattern;
180
- /** The fill-position characters, authoritative. Kept beside `formatted` rather than
181
- * re-read from it on every access: a consumer guide whose characters happen to satisfy
182
- * their own position's class (`@@@` guided `abc`) would otherwise read back as content. */
183
- let raw = '';
184
-
185
- const undoStack: UndoEntry[] = [];
186
- const redoStack: UndoEntry[] = [];
187
- let runOpen = false;
188
-
189
- let composing = false;
190
- /** Set while THIS module writes the selection, so the `selectionchange` clamp does not
191
- * react to its own work. Not a timer and not a race: it is set and cleared around one
192
- * synchronous call. */
193
- let settingSelection = false;
194
- let detached = false;
195
- /** A consumer write (`setValue` / `update`) that arrived mid-composition, held until
196
- * `compositionend`. Only the latest survives: an earlier one has already been superseded
197
- * by its own author, and replaying both would flash an intermediate value. */
198
- let pendingWrite: (() => void) | null = null;
199
-
200
- /** With a guide the field is always `format.length` long and unfilled positions show the
201
- * guide; without one it shows only up to the last typed character (spec §2). */
202
- const display = (value: string): string =>
203
- hasGuide ? formatForDisplay(pattern, value) : formatRaw(pattern, value);
204
-
205
- /** A caret offset in `formatted` -> a raw index, clamped to what is actually typed. */
206
- const toRaw = (pos: number): number =>
207
- Math.min(formattedToRawIndex(pattern, formatted, pos), raw.length);
208
-
209
- function selectionRaw(): [number, number] {
210
- const start = el.selectionStart ?? formatted.length;
211
- const end = el.selectionEnd ?? formatted.length;
212
- return [toRaw(start), toRaw(Math.max(start, end))];
213
- }
214
-
215
- function setSelection(start: number, end: number): void {
216
- settingSelection = true;
217
- try {
218
- el.setSelectionRange(start, end);
219
- } catch {
220
- // `setSelectionRange` throws on input types that do not support selection
221
- // (`number`, `email`, ...). The value write already landed; a caret is presentation,
222
- // not data, so there is nothing here to report.
223
- } finally {
224
- settingSelection = false;
225
- }
226
- }
227
-
228
- function reject(reason: InputMaskRejectReason, data: string): void {
229
- // Loud, always (spec §5.3). The silent `preventDefault` this replaces is the exact
230
- // shape CLAUDE.md calls the default-wrong choice: a decision made while withholding
231
- // the information that it happened.
232
- opts.onReject?.({ reason, data });
233
- }
234
-
235
- // ---------------------------------------------------------------------------------
236
- // The single commit path (§5.5). Nothing else in this file assigns `el.value`, touches
237
- // the selection, touches the undo stacks or calls a consumer callback.
238
- // ---------------------------------------------------------------------------------
239
-
240
- /** `raw` travels in the options bag rather than being re-derived from `nextFormatted`
241
- * for the reason given at the `raw` declaration: `rawFromFormatted` over a display
242
- * string trusts the guide. Every caller already has the raw it just computed. */
243
- function commit(
244
- nextFormatted: string,
245
- caret: number,
246
- write: { undo: UndoMode; raw: string; notify?: boolean; selectionEnd?: number },
247
- ): void {
248
- if (write.undo !== 'none') {
249
- if (write.undo === 'break' || !runOpen) {
250
- undoStack.push({
251
- formatted,
252
- pattern: formattedPattern,
253
- selStart: el.selectionStart ?? formatted.length,
254
- selEnd: el.selectionEnd ?? formatted.length,
255
- });
256
- if (undoStack.length > UNDO_LIMIT) undoStack.shift();
257
- }
258
- runOpen = write.undo === 'run';
259
- redoStack.length = 0;
260
- }
261
-
262
- formatted = nextFormatted;
263
- formattedPattern = pattern;
264
- raw = write.raw;
265
- el.value = nextFormatted; // native setter; no descriptor shadowing (§5.8)
266
-
267
- const start = Math.max(0, Math.min(caret, nextFormatted.length));
268
- const end = Math.max(start, Math.min(write.selectionEnd ?? caret, nextFormatted.length));
269
- setSelection(start, end);
270
-
271
- if (write.notify !== false) opts.onInput?.({ canonical: getCanonicalValue(), formatted });
272
- }
273
-
274
- /** The raw-space adapter every edit goes through: it is what guarantees the formatted
275
- * text and the caret are always derived from the same raw. */
276
- function commitRaw(nextRaw: string, rawCaret: number, write: { undo: UndoMode; notify?: boolean }): void {
277
- const next = display(nextRaw);
278
- commit(next, rawToFormattedIndex(pattern, next, rawCaret), { ...write, raw: nextRaw });
279
- }
280
-
281
- // ---------------------------------------------------------------------------------
282
- // Edit application
283
- // ---------------------------------------------------------------------------------
284
-
285
- /** A copy of the pattern with extra permissive fill positions, used ONLY to answer "was
286
- * anything actually lost to capacity?".
287
- *
288
- * Without it, `absorbed.length < inserted.length` cannot tell an overflow from an
289
- * absorbed separator: `chg 4821` is eight characters landing in seven fill positions
290
- * with nothing lost, and reporting `over-capacity` for it would be a false alarm on the
291
- * single most common paste in the target field family. Re-normalizing against a wider
292
- * pattern answers it exactly. Returns `null` when the format is already at the engine's
293
- * length cap, in which case the check is skipped rather than guessed. */
294
- function widerPattern(extra: number): MaskPattern | null {
295
- try {
296
- return compileMask(pattern.format + '@'.repeat(extra));
297
- } catch {
298
- return null;
299
- }
300
- }
301
-
302
- /** Replace raw `[rawStart, rawEnd)` with whatever of `inserted` the mask accepts.
303
- *
304
- * The whole edit is expressed as ONE normalization over `prefix + inserted + tail`,
305
- * which is what keeps typing, pasting, autofill and the diff fallback on identical
306
- * semantics. `formatRaw` re-inserts the literals in front of the insertion point so
307
- * `normalizeToRaw` -- which walks the FORMAT, and is the literal-aware normalizer that
308
- * fixes spec §5.7 -- sees a string aligned to the pattern from index 0. That is why
309
- * pasting `V-123` under `V-***` yields `V-123` and not `V-V12`.
310
- *
311
- * Returns whether it committed. */
312
- function applyEdit(rawStart: number, rawEnd: number, inserted: string, kind: 'insert' | 'bulk'): boolean {
313
- const room = pattern.capacity - (raw.length - (rawEnd - rawStart));
314
- if (inserted.length > 0 && room <= 0) {
315
- reject('full', inserted);
316
- return false;
317
- }
318
-
319
- const prefix = raw.slice(0, rawStart);
320
- const prefixText = formatRaw(pattern, prefix);
321
- const withInsert = normalizeToRaw(pattern, prefixText + inserted, caseMode);
322
- const absorbed = withInsert.slice(prefix.length);
323
- const tail = raw.slice(rawEnd);
324
- const nextRaw = normalizeToRaw(pattern, formatRaw(pattern, withInsert) + tail, caseMode);
325
-
326
- if (inserted.length > 0 && absorbed.length === 0) {
327
- reject('wrong-class', inserted);
328
- return false;
329
- }
330
- if (nextRaw === raw) return false; // a delete with nothing to delete: no edit, no noise
331
-
332
- commitRaw(nextRaw, prefix.length + absorbed.length, {
333
- undo: kind === 'insert' ? 'run' : 'break',
334
- notify: true,
335
- });
336
-
337
- // Reported AFTER the commit, and deliberately alongside it rather than instead of it.
338
- // `full` and `wrong-class` refuse the whole edit and leave the text unchanged, which is
339
- // the §5.3 contract. `over-capacity` is the clip that `field-mask.ts` documents the
340
- // CALLER as responsible for reporting ("input past the last fill position is clipped --
341
- // the caller compares lengths"): refusing an entire paste for being one character long
342
- // is worse than accepting what fits and saying so. `data` is the input that was
343
- // refused or partly refused, not a computed remainder.
344
- const lostTail = nextRaw.length < withInsert.length + tail.length;
345
- const lostInsert =
346
- withInsert.length === pattern.capacity &&
347
- absorbed.length < inserted.length &&
348
- overflowed(prefixText, inserted, withInsert.length);
349
- if (lostTail || lostInsert) reject('over-capacity', inserted);
350
- return true;
351
- }
352
-
353
- function overflowed(prefixText: string, inserted: string, absorbedTotal: number): boolean {
354
- const wider = widerPattern(inserted.length);
355
- if (wider === null) return false;
356
- return normalizeToRaw(wider, prefixText + inserted, caseMode).length > absorbedTotal;
357
- }
358
-
359
- /** The §5.1 fallback: the browser already mutated the field, so work out what it did.
360
- * Longest common prefix/suffix, caret-independent, and robust against alphanumeric
361
- * literals -- the one idea carried over from the reference unchanged. */
362
- function reconcile(): void {
363
- const current = el.value;
364
- const max = Math.min(current.length, formatted.length);
365
- let pre = 0;
366
- while (pre < max && current[pre] === formatted[pre]) pre += 1;
367
- let suf = 0;
368
- while (
369
- suf < max - pre &&
370
- current[current.length - 1 - suf] === formatted[formatted.length - 1 - suf]
371
- ) {
372
- suf += 1;
373
- }
374
-
375
- const insertedText = current.slice(pre, current.length - suf);
376
- const start = toRaw(pre);
377
- const end = Math.max(start, toRaw(formatted.length - suf));
378
- applyEdit(start, end, insertedText, 'bulk');
379
-
380
- if (el.value !== formatted) {
381
- // The edit was refused (or was a no-op). The browser's text is still on screen, so
382
- // put the authoritative text back -- a refusal must leave the field unchanged, and
383
- // "unchanged" here means undoing the browser, not leaving its version in place.
384
- commit(formatted, rawToFormattedIndex(pattern, formatted, raw.length), {
385
- undo: 'none',
386
- raw,
387
- notify: false,
388
- });
389
- }
390
- }
391
-
392
- // ---------------------------------------------------------------------------------
393
- // Undo / redo (§5.6)
394
- // ---------------------------------------------------------------------------------
395
-
396
- function snapshot(): UndoEntry {
397
- return {
398
- formatted,
399
- pattern: formattedPattern,
400
- selStart: el.selectionStart ?? formatted.length,
401
- selEnd: el.selectionEnd ?? formatted.length,
402
- };
403
- }
404
-
405
- function restore(entry: UndoEntry): void {
406
- runOpen = false;
407
- if (entry.pattern === pattern) {
408
- commit(entry.formatted, entry.selStart, {
409
- undo: 'none',
410
- selectionEnd: entry.selEnd,
411
- raw: rawFromFormatted(pattern, entry.formatted),
412
- notify: true,
413
- });
414
- return;
415
- }
416
- // Recorded under an older pattern (an `update()` happened since). Decode with the
417
- // pattern that wrote it, then re-fit through the current one. The FORMATTED text is
418
- // what gets re-normalized, never the bare raw: `normalizeToRaw` is not idempotent on
419
- // raw when a fill character happens to equal a leading literal -- `V-***` holding
420
- // `V12` would re-normalize to `12` and lose a character on every undo. With the
421
- // literals present (`V-V12`) the positional walk consumes the leading `V` as the
422
- // literal it is.
423
- //
424
- // Note what this canNOT do: if the new pattern is narrower, the re-fit clips again, so
425
- // undo does not resurrect what `update()` already reported as clipped. Undo reverses an
426
- // EDIT; a format change is not one. That is why the loud report below is the half that
427
- // actually protects the value.
428
- const decoded = rawFromFormatted(entry.pattern, entry.formatted);
429
- const refit = normalizeToRaw(pattern, formatRaw(entry.pattern, decoded), caseMode);
430
- commitRaw(refit, refit.length, { undo: 'none', notify: true });
431
- }
432
-
433
- function undo(): void {
434
- const entry = undoStack.pop();
435
- if (entry === undefined) return;
436
- redoStack.push(snapshot());
437
- restore(entry);
438
- }
439
-
440
- function redo(): void {
441
- const entry = redoStack.pop();
442
- if (entry === undefined) return;
443
- undoStack.push(snapshot());
444
- restore(entry);
445
- }
446
-
447
- // ---------------------------------------------------------------------------------
448
- // Clipboard (§5.10)
449
- // ---------------------------------------------------------------------------------
450
-
451
- /** Bullets at the FILLED `*` positions only. `#` and `@` positions, literals and guide
452
- * characters stay revealed -- which is what makes `**** **** **** ####` mean "show the
453
- * last four" with no `showLast` prop (spec §2 tier 3). */
454
- function obscured(text: string): string {
455
- const chars = text.split('');
456
- for (let i = 0; i < pattern.fillIndexes.length && i < raw.length; i += 1) {
457
- const index = pattern.fillIndexes[i]!;
458
- if (pattern.format[index] === '*' && index < chars.length) chars[index] = BULLET;
459
- }
460
- return chars.join('');
461
- }
462
-
463
- function clipboardText(): string {
464
- const start = el.selectionStart ?? 0;
465
- const end = el.selectionEnd ?? 0;
466
- switch (copyPolicy()) {
467
- case 'blocked':
468
- return '';
469
- case 'formatted':
470
- return formatted.slice(start, end);
471
- case 'obscured':
472
- return obscured(formatted).slice(start, end);
473
- case 'canonical':
474
- default: {
475
- const [rawStart, rawEnd] = selectionRaw();
476
- // A whole-field selection copies the canonical value -- the thing a consumer means
477
- // by "copy this phone number". A partial selection has no canonical form of its
478
- // own (its literals depend on where it sat), so it copies the characters it covers.
479
- if (rawStart === 0 && rawEnd === raw.length) {
480
- return canonicalize(pattern, formatRaw(pattern, raw), semantic);
481
- }
482
- return raw.slice(rawStart, rawEnd);
483
- }
484
- }
485
- }
486
-
487
- /** Explicit option wins; otherwise `obscure` picks the default (spec §5.10). */
488
- function copyPolicy(): CopyPolicy {
489
- return opts.copyPolicy ?? (obscure ? 'obscured' : 'canonical');
490
- }
491
-
492
- // ---------------------------------------------------------------------------------
493
- // Listeners
494
- // ---------------------------------------------------------------------------------
495
-
496
- function onBeforeInput(event: Event): void {
497
- if (detached || composing) return;
498
- const e = event as InputEvent;
499
- // Not cancelable -- composition on Android, several IMEs. Do nothing here and let the
500
- // diff in `input` absorb whatever the browser does (§5.1).
501
- if (!e.cancelable) return;
502
- const inputType = e.inputType;
503
-
504
- if (INSERT_TYPES.has(inputType)) {
505
- e.preventDefault();
506
- const data = e.data ?? e.dataTransfer?.getData('text/plain') ?? '';
507
- const [start, end] = selectionRaw();
508
- applyEdit(start, end, data, inputType === 'insertText' ? 'insert' : 'bulk');
509
- return;
510
- }
511
-
512
- if (DELETE_TYPES.has(inputType)) {
513
- e.preventDefault();
514
- let [start, end] = selectionRaw();
515
- if (start === end) {
516
- // A DOM selection that maps to an EMPTY raw range is a selection over literals
517
- // only. It is not a collapsed caret, and running the directional branch on it
518
- // destroys a character the user did not select: selecting exactly the `-` of
519
- // `555-123-4567` and pressing Backspace would eat the `5` in front of it.
520
- // A literal is not content, so there is nothing to delete and nothing to report --
521
- // no decision is being withheld, the field visibly does not change.
522
- if ((el.selectionStart ?? 0) !== (el.selectionEnd ?? 0)) return;
523
- const backward = inputType.endsWith('Backward');
524
- // A masked field has no words and no lines, so the word/line deletes take the whole
525
- // side of the caret. Stated, not silent: the alternative is pretending `4821` is a
526
- // word, which is a guess about a format the consumer chose.
527
- const wide = /^delete(Word|SoftLine|HardLine)/.test(inputType);
528
- if (wide) {
529
- if (backward) start = 0;
530
- else end = raw.length;
531
- } else if (backward) {
532
- if (start === 0) return;
533
- start -= 1;
534
- } else {
535
- if (end >= raw.length) return;
536
- end += 1;
537
- }
538
- }
539
- applyEdit(start, end, '', 'bulk');
540
- return;
541
- }
542
-
543
- if (inputType === 'historyUndo') {
544
- e.preventDefault();
545
- undo();
546
- return;
547
- }
548
- if (inputType === 'historyRedo') {
549
- e.preventDefault();
550
- redo();
551
- }
552
- // Anything else is the browser's: it happens, and `input` reconciles it.
553
- }
554
-
555
- function onInputEvent(): void {
556
- if (detached || composing) return;
557
- if (el.value === formatted) return; // our own commit, or a genuine no-op
558
- reconcile();
559
- }
560
-
561
- function onCompositionStart(): void {
562
- if (detached) return;
563
- composing = true;
564
- }
565
-
566
- function onCompositionEnd(): void {
567
- if (detached) return;
568
- composing = false;
569
- // A consumer write that arrived mid-composition wins over the composed text: it is the
570
- // later statement of intent, and it is the value the consumer's own model now holds.
571
- const pending = pendingWrite;
572
- pendingWrite = null;
573
- if (pending !== null) {
574
- pending();
575
- return;
576
- }
577
- // Otherwise exactly one reconciliation, here (§5.2). Every `input` that arrived during
578
- // the composition was ignored on purpose.
579
- if (el.value !== formatted) reconcile();
580
- }
581
-
582
- /** Run a consumer write now, or hold it until the composition ends (§5.2 applied to the
583
- * masker's own writes: a framework re-render calling `setValue` mid-composition is the
584
- * classic controlled-input IME bug, and rewriting `.value` there kills the composition). */
585
- function writeOrDefer(apply: () => void): void {
586
- if (composing) {
587
- pendingWrite = apply;
588
- return;
589
- }
590
- apply();
591
- }
592
-
593
- function onKeyDown(event: Event): void {
594
- if (detached || composing) return;
595
- const e = event as KeyboardEvent;
596
- const mod = e.ctrlKey || e.metaKey;
597
- const key = e.key.toLowerCase();
598
- if (mod && key === 'z') {
599
- e.preventDefault();
600
- if (e.shiftKey) redo();
601
- else undo();
602
- return;
603
- }
604
- if (mod && key === 'y') {
605
- e.preventDefault();
606
- redo();
607
- return;
608
- }
609
- if (NAV_KEYS.has(e.key)) runOpen = false;
610
- }
611
-
612
- function onMouseDown(): void {
613
- runOpen = false;
614
- }
615
-
616
- function onBlur(): void {
617
- runOpen = false;
618
- }
619
-
620
- /** The caret window: `[floor, frontier]`.
621
- *
622
- * FLOOR is the first fill position, so the caret never rests inside a leading literal
623
- * run -- with `CHG-####` the field focuses at `CHG-|####` and Home, ArrowLeft and a click
624
- * into the prefix all land there. On an UNGUIDED empty field the floor collapses to 0,
625
- * because there is no rendered prefix to be trapped behind until a character exists;
626
- * `rawToFormattedIndex` clamps to the text that exists, which is what makes that fall out
627
- * rather than needing a special case.
628
- *
629
- * FRONTIER is one past the last filled character (`12/23/4|yyy`), so a click into the
630
- * unfilled guide tail snaps back and End stops at the content rather than at the end of
631
- * the template. Between the two, every position is legal and is left completely alone. */
632
- function caretWindow(): [floor: number, frontier: number] {
633
- return [
634
- rawToFormattedIndex(pattern, formatted, 0),
635
- rawToFormattedIndex(pattern, formatted, raw.length),
636
- ];
637
- }
638
-
639
- /** ONE clamp function, idempotent, NO TIMERS (§5.9) -- but four triggers, and the trigger
640
- * list is MEASURED, not assumed. §5.9's objection to the reference was the racing
641
- * `mousedown`+rAF / `mouseup`+`setTimeout` pair and the keyboard-driven selection it
642
- * missed; it is not an objection to listening for more than one event, and an earlier
643
- * version of this comment asserted that `selectionchange` alone covered everything. It
644
- * does not. Measured in real Chromium (tests/e2e/input-mask-ivp.spec.ts, scenarios 9-10),
645
- * logging every candidate event with the offset it saw:
646
- *
647
- * Home -> ["el:keyup@0"]
648
- * click -> ["el:selectionchange@4", "doc:selectionchange@4", "el:mouseup@0", "el:click@0"]
649
- *
650
- * Two facts fall out, and both were live defects the owner hit on a guided field:
651
- * - A KEYBOARD caret move fires NO `selectionchange` whatsoever. Home, End and the
652
- * arrows reached the clamp on no engine, which is exactly "I could only go back to
653
- * the start of ####" failing to hold. `keyup` is the event that does fire.
654
- * - A CLICK fires `selectionchange` early, and then the browser applies its own
655
- * pixel-derived offset AFTER it -- note the `@4` from our clamp being replaced by the
656
- * `@0` visible at `mouseup`. Clamping only on `selectionchange` gets overwritten by
657
- * the very gesture it was reacting to. `mouseup` is where the offset is final.
658
- *
659
- * So: `selectionchange` (the general case), `keyup` (keyboard), `mouseup` (pointer), and
660
- * `focus` -- the last because a selection that does not CHANGE fires nothing at all, so a
661
- * field entered while its caret is already at 0 would otherwise render with the caret
662
- * inside the literal prefix and never get a chance to be corrected. Every trigger is the
663
- * same idempotent call that early-returns when the selection is already legal, so firing
664
- * several times for one gesture costs a comparison and changes nothing. */
665
- /** "Is the caret in THIS field?" -- asked of the element's own root, never of the
666
- * document.
667
- *
668
- * `document.activeElement` RETARGETS: for a focused node inside a shadow tree it reports
669
- * the OUTERMOST host, so on any element this kit actually ships the answer is `<kai-chat>`
670
- * and never the input. `el.ownerDocument.activeElement !== el` was therefore true on every
671
- * trigger, and the whole clamp early-returned -- the caret discipline was dead in the
672
- * ops-console app (input at `kai-chat` shadow -> card -> `kai-form` shadow) while passing
673
- * in Storybook, whose story mounts the input into a FLAT document where the two agree.
674
- * Measured in that app on the dist build (m11-diagnose.mjs), focusing the `CHG-####`
675
- * field:
676
- *
677
- * el:focus docActiveIsEl=false docActive=kai-chat rootActiveIsEl=true
678
- * el:mouseup docActiveIsEl=false docActive=kai-chat rootActiveIsEl=true
679
- * doc:selectionchange docActiveIsEl=false docActive=kai-chat rootActiveIsEl=true
680
- *
681
- * All four triggers FIRE at the right offsets through two shadow roots -- the trigger
682
- * table above holds unchanged; it was only this guard that was wrong. `getRootNode()` is
683
- * read per call rather than captured, because the element can be moved between roots, and
684
- * it degrades correctly: in a flat document the root IS the document, and while detached
685
- * the root is a fragment with no `activeElement`, which reads as not focused. */
686
- function isFocused(): boolean {
687
- const root = el.getRootNode() as Document | ShadowRoot;
688
- return root.activeElement === el;
689
- }
690
-
691
- function clampSelection(): void {
692
- if (detached || composing || settingSelection) return;
693
- if (!isFocused()) return;
694
- const start = el.selectionStart;
695
- const end = el.selectionEnd;
696
- if (start === null || end === null) return;
697
-
698
- const [floor, frontier] = caretWindow();
699
-
700
- let nextStart = start;
701
- let nextEnd = end;
702
- if (start === end) {
703
- nextStart = nextEnd = Math.max(floor, Math.min(start, frontier));
704
- } else {
705
- // A range obeys the SAME window at both ends. Clamping only the end was the earlier
706
- // reading, and it let Ctrl-A leave the anchor at 0 -- visibly selecting a literal
707
- // prefix that is not content and cannot be edited. Collapsing to the window keeps
708
- // select-all meaning "all of the value": the raw range is unchanged, so a canonical
709
- // copy of a fully-selected field still yields the whole value, literals included.
710
- nextEnd = Math.min(end, frontier);
711
- nextStart = Math.min(Math.max(start, floor), nextEnd);
712
- }
713
- if (nextStart === start && nextEnd === end) return; // already valid: do nothing at all
714
- setSelection(nextStart, nextEnd);
715
- }
716
-
717
- function onCopy(event: Event): void {
718
- if (detached) return;
719
- const e = event as ClipboardEvent;
720
- e.preventDefault();
721
- e.clipboardData?.setData('text/plain', clipboardText());
722
- }
723
-
724
- function onCut(event: Event): void {
725
- if (detached) return;
726
- const e = event as ClipboardEvent;
727
- e.preventDefault();
728
- e.clipboardData?.setData('text/plain', clipboardText());
729
- const [start, end] = selectionRaw();
730
- if (start === end) return;
731
- runOpen = false;
732
- applyEdit(start, end, '', 'bulk');
733
- }
734
-
735
- function getCanonicalValue(): string {
736
- // `formatRaw`, not the display string: a guide character must never be read as content.
737
- return canonicalize(pattern, formatRaw(pattern, raw), semantic);
738
- }
739
-
740
- const elementListeners: Array<[string, EventListener]> = [
741
- ['beforeinput', onBeforeInput],
742
- ['input', onInputEvent],
743
- ['compositionstart', onCompositionStart],
744
- // `compositionupdate` is deliberately NOT bound, though §5.2 names it: `composing` is
745
- // already true for the whole window, so a handler would have nothing to do. Binding one
746
- // to "keep the set complete" would be an empty listener implying a hook that is not there.
747
- ['compositionend', onCompositionEnd],
748
- ['keydown', onKeyDown],
749
- ['mousedown', onMouseDown],
750
- // The three non-`selectionchange` clamp triggers. See `clampSelection` for the measured
751
- // Chromium event logs that make each of them necessary rather than defensive.
752
- ['keyup', clampSelection],
753
- ['mouseup', clampSelection],
754
- ['focus', clampSelection],
755
- ['blur', onBlur],
756
- ['copy', onCopy],
757
- ['cut', onCut],
758
- ];
759
- const doc = el.ownerDocument;
760
-
761
- for (const [kind, handler] of elementListeners) el.addEventListener(kind, handler);
762
- // `selectionchange` fires on the DOCUMENT for `<input>` in every browser this kit
763
- // targets; the element-targeted version is newer and not yet universal.
764
- doc.addEventListener('selectionchange', clampSelection);
765
-
766
- {
767
- const seed = normalizeToRaw(pattern, opts.initialValue ?? el.value, caseMode);
768
- // No undo entry and no callback: attaching is not an edit the consumer made.
769
- commitRaw(seed, seed.length, { undo: 'none', notify: false });
770
- }
771
-
772
- return {
773
- setValue(value: string): void {
774
- writeOrDefer(() => {
775
- const next = normalizeToRaw(pattern, value, caseMode);
776
- // A bulk replacement, so it breaks the run and gets its own undo entry -- but it
777
- // does NOT notify: a controlled widget that wrote this value would echo itself
778
- // forever. (`update()` below is the opposite case, and for a stated reason.)
779
- commitRaw(next, next.length, { undo: 'break', notify: false });
780
- });
781
- },
782
-
783
- getRawValue: () => raw,
784
- getCanonicalValue,
785
- getFormattedValue: () => formatted,
786
-
787
- setObscure(on: boolean): void {
788
- obscure = on;
789
- // Tier 3 (task 10) makes this change the rendered text. Today it selects the default
790
- // copy policy and nothing else, which is stated on the option rather than implied.
791
- },
792
-
793
- update(next: Partial<InputMaskOptions>): void {
794
- // Configuration lands immediately -- a new `onInput` must not be shadowed by the old
795
- // one for the length of a composition. Only the VALUE WRITE is deferrable.
796
- const previousText = formatRaw(pattern, raw);
797
- const previousRaw = raw;
798
- const previousFormatted = formatted;
799
-
800
- // COMPILE FIRST, ASSIGN AFTER. `compileMask` throws on a format over the length cap
801
- // or a guide that does not align, and the throw must leave this masker exactly as it
802
- // was. Merging into `opts` first would park the rejected config in state where the
803
- // NEXT update -- one that says nothing about `format` -- picks it up and applies a
804
- // change that was already refused. The element facade hits precisely this shape when
805
- // `format` and `guide` are separate reactive attributes that do not land in the same
806
- // tick.
807
- const merged: InputMaskOptions = { ...opts, ...next };
808
- const nextPattern = compileMask(merged.format, merged.guide);
809
-
810
- opts = merged;
811
- pattern = nextPattern;
812
- hasGuide = merged.guide !== undefined;
813
- caseMode = merged.caseMode ?? 'preserve';
814
- semantic = merged.semantic ?? 'custom';
815
- obscure = merged.obscure ?? false;
816
- runOpen = false;
817
- // The undo history is NOT cleared: entries carry the pattern that wrote them and
818
- // `restore` re-fits across a change. Redo is, because it holds futures that the new
819
- // pattern may not be able to reach.
820
- redoStack.length = 0;
821
-
822
- writeOrDefer(() => {
823
- const nextRaw = normalizeToRaw(pattern, previousText, caseMode);
824
- const nextFormatted = display(nextRaw);
825
- const changed = nextFormatted !== previousFormatted;
826
- // A format change that cannot hold what the field already held DESTROYS user
827
- // content, and the consumer's own model still has the old value -- so silence here
828
- // is worse than an ordinary silent drop: nothing would ever converge. The element
829
- // facade re-calls `update()` on any `format` prop change, so this fires on a
830
- // routine reactive render. Report the loss, and notify so a controlled consumer
831
- // catches up. `data` carries the pre-change text, which after this call is the only
832
- // copy of the discarded characters anywhere.
833
- if (nextRaw.length < previousRaw.length) reject('format-change-clipped', previousText);
834
- // `undo: 'none'`, always. An entry pushed here would be DEAD BY CONSTRUCTION: it
835
- // would hold `previousFormatted` under the old pattern, and `restore` re-fits a
836
- // stale entry with exactly the computation two lines up
837
- // (`normalizeToRaw(pattern, previousText)`), so restoring it lands on the text
838
- // already on screen -- the first Ctrl+Z after a format change would do nothing
839
- // visible. The history the user cares about is the entries UNDERNEATH, which
840
- // survive because each carries the pattern that wrote it.
841
- commitRaw(nextRaw, nextRaw.length, { undo: 'none', notify: changed });
842
- });
843
- },
844
-
845
- detach(): void {
846
- if (detached) return;
847
- detached = true;
848
- pendingWrite = null; // a deferred write must not fire into a field we no longer own
849
- for (const [kind, handler] of elementListeners) el.removeEventListener(kind, handler);
850
- doc.removeEventListener('selectionchange', clampSelection);
851
- },
852
- };
853
- }