@teamofsilicons/silicon-ui 0.0.0-stage → 0.2.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 (354) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +81 -2
  3. package/check.mjs +236 -0
  4. package/components/accordion/accordion.d.ts +13 -0
  5. package/components/accordion/accordion.js +35 -0
  6. package/components/accordion/accordion.module.css +15 -0
  7. package/components/action-button/action-button.d.ts +11 -0
  8. package/components/action-button/action-button.js +57 -0
  9. package/components/action-button/action-button.module.css +15 -0
  10. package/components/activity-heatmap/activity-heatmap.d.ts +37 -0
  11. package/components/activity-heatmap/activity-heatmap.js +303 -0
  12. package/components/activity-heatmap/activity-heatmap.module.css +83 -0
  13. package/components/alert/alert.d.ts +13 -0
  14. package/components/alert/alert.js +79 -0
  15. package/components/alert/alert.module.css +15 -0
  16. package/components/animated-counter/animated-counter.d.ts +13 -0
  17. package/components/animated-counter/animated-counter.js +73 -0
  18. package/components/animated-counter/animated-counter.module.css +12 -0
  19. package/components/announcement-bar/announcement-bar.d.ts +53 -0
  20. package/components/announcement-bar/announcement-bar.js +187 -0
  21. package/components/announcement-bar/announcement-bar.module.css +60 -0
  22. package/components/avatar/avatar.d.ts +8 -0
  23. package/components/avatar/avatar.js +18 -0
  24. package/components/avatar/avatar.module.css +60 -0
  25. package/components/avatar-group/avatar-group.d.ts +13 -0
  26. package/components/avatar-group/avatar-group.js +24 -0
  27. package/components/avatar-group/avatar-group.module.css +27 -0
  28. package/components/badge/badge.d.ts +10 -0
  29. package/components/badge/badge.js +66 -0
  30. package/components/badge/badge.module.css +52 -0
  31. package/components/bar-chart/bar-chart.d.ts +32 -0
  32. package/components/bar-chart/bar-chart.js +327 -0
  33. package/components/bar-chart/bar-chart.module.css +49 -0
  34. package/components/billing-toggle/billing-toggle.d.ts +35 -0
  35. package/components/billing-toggle/billing-toggle.js +83 -0
  36. package/components/billing-toggle/billing-toggle.module.css +139 -0
  37. package/components/bottom-sheet/bottom-sheet.d.ts +27 -0
  38. package/components/bottom-sheet/bottom-sheet.js +386 -0
  39. package/components/bottom-sheet/bottom-sheet.module.css +69 -0
  40. package/components/breadcrumb/breadcrumb.d.ts +13 -0
  41. package/components/breadcrumb/breadcrumb.js +19 -0
  42. package/components/breadcrumb/breadcrumb.module.css +12 -0
  43. package/components/brush-chart/brush-chart.d.ts +45 -0
  44. package/components/brush-chart/brush-chart.js +426 -0
  45. package/components/brush-chart/brush-chart.module.css +69 -0
  46. package/components/button/button.d.ts +10 -0
  47. package/components/button/button.js +97 -0
  48. package/components/button/button.module.css +39 -0
  49. package/components/button-group/button-group.d.ts +59 -0
  50. package/components/button-group/button-group.js +280 -0
  51. package/components/button-group/button-group.module.css +87 -0
  52. package/components/button-link/button-link.d.ts +24 -0
  53. package/components/button-link/button-link.js +11 -0
  54. package/components/button-link/button-link.module.css +7 -0
  55. package/components/calendar/calendar.d.ts +22 -0
  56. package/components/calendar/calendar.js +253 -0
  57. package/components/calendar/calendar.module.css +51 -0
  58. package/components/card/card.d.ts +19 -0
  59. package/components/card/card.js +108 -0
  60. package/components/card/card.module.css +51 -0
  61. package/components/card-stack/card-stack.d.ts +34 -0
  62. package/components/card-stack/card-stack.js +291 -0
  63. package/components/card-stack/card-stack.module.css +63 -0
  64. package/components/carousel/carousel.d.ts +28 -0
  65. package/components/carousel/carousel.js +332 -0
  66. package/components/carousel/carousel.module.css +42 -0
  67. package/components/chat-thread/chat-thread.d.ts +135 -0
  68. package/components/chat-thread/chat-thread.js +406 -0
  69. package/components/chat-thread/chat-thread.module.css +242 -0
  70. package/components/checkbox/checkbox.d.ts +7 -0
  71. package/components/checkbox/checkbox.js +23 -0
  72. package/components/checkbox/checkbox.module.css +15 -0
  73. package/components/chip-group/chip-group.d.ts +24 -0
  74. package/components/chip-group/chip-group.js +196 -0
  75. package/components/chip-group/chip-group.module.css +38 -0
  76. package/components/code-block/code-block.d.ts +11 -0
  77. package/components/code-block/code-block.js +133 -0
  78. package/components/code-block/code-block.module.css +43 -0
  79. package/components/color-picker/color-picker.d.ts +40 -0
  80. package/components/color-picker/color-picker.js +449 -0
  81. package/components/color-picker/color-picker.module.css +111 -0
  82. package/components/combobox/combobox.d.ts +19 -0
  83. package/components/combobox/combobox.js +167 -0
  84. package/components/combobox/combobox.module.css +28 -0
  85. package/components/command-palette/command-palette.d.ts +20 -0
  86. package/components/command-palette/command-palette.js +151 -0
  87. package/components/command-palette/command-palette.module.css +94 -0
  88. package/components/comment-thread/comment-thread.d.ts +120 -0
  89. package/components/comment-thread/comment-thread.js +343 -0
  90. package/components/comment-thread/comment-thread.module.css +195 -0
  91. package/components/confirm-morph/confirm-morph.d.ts +65 -0
  92. package/components/confirm-morph/confirm-morph.js +355 -0
  93. package/components/confirm-morph/confirm-morph.module.css +146 -0
  94. package/components/context-menu/context-menu.d.ts +18 -0
  95. package/components/context-menu/context-menu.js +148 -0
  96. package/components/context-menu/context-menu.module.css +17 -0
  97. package/components/copy-button/copy-button.d.ts +10 -0
  98. package/components/copy-button/copy-button.js +69 -0
  99. package/components/copy-button/copy-button.module.css +21 -0
  100. package/components/countdown/countdown.d.ts +31 -0
  101. package/components/countdown/countdown.js +177 -0
  102. package/components/countdown/countdown.module.css +49 -0
  103. package/components/date-picker/date-picker.d.ts +17 -0
  104. package/components/date-picker/date-picker.js +100 -0
  105. package/components/date-picker/date-picker.module.css +34 -0
  106. package/components/date-range-picker/date-range-picker.d.ts +44 -0
  107. package/components/date-range-picker/date-range-picker.js +534 -0
  108. package/components/date-range-picker/date-range-picker.module.css +214 -0
  109. package/components/dialog/dialog.d.ts +11 -0
  110. package/components/dialog/dialog.js +52 -0
  111. package/components/dialog/dialog.module.css +36 -0
  112. package/components/donut-chart/donut-chart.d.ts +46 -0
  113. package/components/donut-chart/donut-chart.js +382 -0
  114. package/components/donut-chart/donut-chart.module.css +48 -0
  115. package/components/drawer/drawer.d.ts +20 -0
  116. package/components/drawer/drawer.js +111 -0
  117. package/components/drawer/drawer.module.css +97 -0
  118. package/components/dropdown-menu/dropdown-menu.d.ts +16 -0
  119. package/components/dropdown-menu/dropdown-menu.js +39 -0
  120. package/components/dropdown-menu/dropdown-menu.module.css +38 -0
  121. package/components/elastic-slider/elastic-slider.d.ts +39 -0
  122. package/components/elastic-slider/elastic-slider.js +261 -0
  123. package/components/elastic-slider/elastic-slider.module.css +40 -0
  124. package/components/empty-state/empty-state.d.ts +12 -0
  125. package/components/empty-state/empty-state.js +69 -0
  126. package/components/empty-state/empty-state.module.css +13 -0
  127. package/components/expandable-card/expandable-card.d.ts +13 -0
  128. package/components/expandable-card/expandable-card.js +89 -0
  129. package/components/expandable-card/expandable-card.module.css +22 -0
  130. package/components/expanding-button-group/expanding-button-group.d.ts +41 -0
  131. package/components/expanding-button-group/expanding-button-group.js +258 -0
  132. package/components/expanding-button-group/expanding-button-group.module.css +44 -0
  133. package/components/expanding-search/expanding-search.d.ts +38 -0
  134. package/components/expanding-search/expanding-search.js +242 -0
  135. package/components/expanding-search/expanding-search.module.css +52 -0
  136. package/components/file-dropzone/file-dropzone.d.ts +44 -0
  137. package/components/file-dropzone/file-dropzone.js +379 -0
  138. package/components/file-dropzone/file-dropzone.module.css +115 -0
  139. package/components/file-upload/file-upload.d.ts +22 -0
  140. package/components/file-upload/file-upload.js +192 -0
  141. package/components/file-upload/file-upload.module.css +49 -0
  142. package/components/filter-toolbar/filter-toolbar.d.ts +48 -0
  143. package/components/filter-toolbar/filter-toolbar.js +446 -0
  144. package/components/filter-toolbar/filter-toolbar.module.css +70 -0
  145. package/components/floating-button-group/floating-button-group.d.ts +51 -0
  146. package/components/floating-button-group/floating-button-group.js +215 -0
  147. package/components/floating-button-group/floating-button-group.module.css +74 -0
  148. package/components/gauge/gauge.d.ts +18 -0
  149. package/components/gauge/gauge.js +101 -0
  150. package/components/gauge/gauge.module.css +22 -0
  151. package/components/hold-to-confirm/hold-to-confirm.d.ts +25 -0
  152. package/components/hold-to-confirm/hold-to-confirm.js +165 -0
  153. package/components/hold-to-confirm/hold-to-confirm.module.css +20 -0
  154. package/components/hover-card/hover-card.d.ts +38 -0
  155. package/components/hover-card/hover-card.js +145 -0
  156. package/components/hover-card/hover-card.module.css +20 -0
  157. package/components/image-compare/image-compare.d.ts +27 -0
  158. package/components/image-compare/image-compare.js +267 -0
  159. package/components/image-compare/image-compare.module.css +25 -0
  160. package/components/in-view-title/in-view-title.d.ts +17 -0
  161. package/components/in-view-title/in-view-title.js +70 -0
  162. package/components/in-view-title/in-view-title.module.css +13 -0
  163. package/components/inline-edit/inline-edit.d.ts +27 -0
  164. package/components/inline-edit/inline-edit.js +315 -0
  165. package/components/inline-edit/inline-edit.module.css +73 -0
  166. package/components/input/input.d.ts +7 -0
  167. package/components/input/input.js +60 -0
  168. package/components/input/input.module.css +22 -0
  169. package/components/json-viewer/json-viewer.d.ts +48 -0
  170. package/components/json-viewer/json-viewer.js +463 -0
  171. package/components/json-viewer/json-viewer.module.css +92 -0
  172. package/components/line-chart/line-chart.d.ts +58 -0
  173. package/components/line-chart/line-chart.js +383 -0
  174. package/components/line-chart/line-chart.module.css +60 -0
  175. package/components/mention-input/mention-input.d.ts +72 -0
  176. package/components/mention-input/mention-input.js +417 -0
  177. package/components/mention-input/mention-input.module.css +134 -0
  178. package/components/metric-card/metric-card.d.ts +8 -0
  179. package/components/metric-card/metric-card.js +44 -0
  180. package/components/metric-card/metric-card.module.css +18 -0
  181. package/components/money-input/money-input.d.ts +48 -0
  182. package/components/money-input/money-input.js +503 -0
  183. package/components/money-input/money-input.module.css +77 -0
  184. package/components/morph-select/morph-select.d.ts +50 -0
  185. package/components/morph-select/morph-select.js +395 -0
  186. package/components/morph-select/morph-select.module.css +88 -0
  187. package/components/multi-select/multi-select.d.ts +18 -0
  188. package/components/multi-select/multi-select.js +105 -0
  189. package/components/multi-select/multi-select.module.css +37 -0
  190. package/components/notification-center/notification-center.d.ts +24 -0
  191. package/components/notification-center/notification-center.js +146 -0
  192. package/components/notification-center/notification-center.module.css +79 -0
  193. package/components/number-field/number-field.d.ts +41 -0
  194. package/components/number-field/number-field.js +455 -0
  195. package/components/number-field/number-field.module.css +71 -0
  196. package/components/otp-input/otp-input.d.ts +13 -0
  197. package/components/otp-input/otp-input.js +129 -0
  198. package/components/otp-input/otp-input.module.css +28 -0
  199. package/components/pagination/pagination.d.ts +7 -0
  200. package/components/pagination/pagination.js +60 -0
  201. package/components/pagination/pagination.module.css +16 -0
  202. package/components/password-field/password-field.d.ts +14 -0
  203. package/components/password-field/password-field.js +51 -0
  204. package/components/password-field/password-field.module.css +27 -0
  205. package/components/password-strength/password-strength.d.ts +107 -0
  206. package/components/password-strength/password-strength.js +161 -0
  207. package/components/password-strength/password-strength.module.css +57 -0
  208. package/components/phone-input/phone-input.d.ts +73 -0
  209. package/components/phone-input/phone-input.js +612 -0
  210. package/components/phone-input/phone-input.module.css +98 -0
  211. package/components/popover/popover.d.ts +6 -0
  212. package/components/popover/popover.js +19 -0
  213. package/components/popover/popover.module.css +32 -0
  214. package/components/progress/progress.d.ts +8 -0
  215. package/components/progress/progress.js +42 -0
  216. package/components/progress/progress.module.css +1 -0
  217. package/components/radio-cards/radio-cards.d.ts +35 -0
  218. package/components/radio-cards/radio-cards.js +110 -0
  219. package/components/radio-cards/radio-cards.module.css +84 -0
  220. package/components/radio-group/radio-group.d.ts +13 -0
  221. package/components/radio-group/radio-group.js +55 -0
  222. package/components/radio-group/radio-group.module.css +30 -0
  223. package/components/resizable-panels/resizable-panels.d.ts +37 -0
  224. package/components/resizable-panels/resizable-panels.js +338 -0
  225. package/components/resizable-panels/resizable-panels.module.css +40 -0
  226. package/components/rich-text-editor/rich-text-editor.d.ts +55 -0
  227. package/components/rich-text-editor/rich-text-editor.js +1733 -0
  228. package/components/rich-text-editor/rich-text-editor.module.css +201 -0
  229. package/components/ridgeline/ridgeline.d.ts +37 -0
  230. package/components/ridgeline/ridgeline.js +346 -0
  231. package/components/ridgeline/ridgeline.module.css +58 -0
  232. package/components/scroll-area/scroll-area.d.ts +40 -0
  233. package/components/scroll-area/scroll-area.js +153 -0
  234. package/components/scroll-area/scroll-area.module.css +79 -0
  235. package/components/search-field/search-field.d.ts +7 -0
  236. package/components/search-field/search-field.js +20 -0
  237. package/components/search-field/search-field.module.css +17 -0
  238. package/components/segmented-control/segmented-control.d.ts +16 -0
  239. package/components/segmented-control/segmented-control.js +56 -0
  240. package/components/segmented-control/segmented-control.module.css +15 -0
  241. package/components/select/select.d.ts +15 -0
  242. package/components/select/select.js +36 -0
  243. package/components/select/select.module.css +35 -0
  244. package/components/shortcut-recorder/shortcut-recorder.d.ts +108 -0
  245. package/components/shortcut-recorder/shortcut-recorder.js +380 -0
  246. package/components/shortcut-recorder/shortcut-recorder.module.css +85 -0
  247. package/components/signature-pad/signature-pad.d.ts +72 -0
  248. package/components/signature-pad/signature-pad.js +450 -0
  249. package/components/signature-pad/signature-pad.module.css +84 -0
  250. package/components/skeleton/skeleton.d.ts +13 -0
  251. package/components/skeleton/skeleton.js +53 -0
  252. package/components/skeleton/skeleton.module.css +11 -0
  253. package/components/slider/slider.d.ts +45 -0
  254. package/components/slider/slider.js +347 -0
  255. package/components/slider/slider.module.css +45 -0
  256. package/components/slope-chart/slope-chart.d.ts +38 -0
  257. package/components/slope-chart/slope-chart.js +183 -0
  258. package/components/slope-chart/slope-chart.module.css +51 -0
  259. package/components/slot-text/slot-text.d.ts +26 -0
  260. package/components/slot-text/slot-text.js +119 -0
  261. package/components/slot-text/slot-text.module.css +19 -0
  262. package/components/sortable-data-table/sortable-data-table.d.ts +38 -0
  263. package/components/sortable-data-table/sortable-data-table.js +240 -0
  264. package/components/sortable-data-table/sortable-data-table.module.css +116 -0
  265. package/components/sparkline/sparkline.d.ts +19 -0
  266. package/components/sparkline/sparkline.js +239 -0
  267. package/components/sparkline/sparkline.module.css +26 -0
  268. package/components/split-button/split-button.d.ts +19 -0
  269. package/components/split-button/split-button.js +33 -0
  270. package/components/split-button/split-button.module.css +51 -0
  271. package/components/stepper/stepper.d.ts +36 -0
  272. package/components/stepper/stepper.js +108 -0
  273. package/components/stepper/stepper.module.css +74 -0
  274. package/components/streamgraph/streamgraph.d.ts +51 -0
  275. package/components/streamgraph/streamgraph.js +376 -0
  276. package/components/streamgraph/streamgraph.module.css +55 -0
  277. package/components/swipe-actions/swipe-actions.d.ts +35 -0
  278. package/components/swipe-actions/swipe-actions.js +330 -0
  279. package/components/swipe-actions/swipe-actions.module.css +64 -0
  280. package/components/switch/switch.d.ts +7 -0
  281. package/components/switch/switch.js +45 -0
  282. package/components/switch/switch.module.css +12 -0
  283. package/components/tabs/tabs.d.ts +8 -0
  284. package/components/tabs/tabs.js +138 -0
  285. package/components/tabs/tabs.module.css +27 -0
  286. package/components/tag-input/tag-input.d.ts +15 -0
  287. package/components/tag-input/tag-input.js +150 -0
  288. package/components/tag-input/tag-input.module.css +34 -0
  289. package/components/text-morph/text-morph.d.ts +13 -0
  290. package/components/text-morph/text-morph.js +69 -0
  291. package/components/text-morph/text-morph.module.css +8 -0
  292. package/components/text-reveal/text-reveal.d.ts +14 -0
  293. package/components/text-reveal/text-reveal.js +20 -0
  294. package/components/text-reveal/text-reveal.module.css +13 -0
  295. package/components/text-shimmer/text-shimmer.d.ts +18 -0
  296. package/components/text-shimmer/text-shimmer.js +90 -0
  297. package/components/text-shimmer/text-shimmer.module.css +9 -0
  298. package/components/textarea/textarea.d.ts +7 -0
  299. package/components/textarea/textarea.js +56 -0
  300. package/components/textarea/textarea.module.css +22 -0
  301. package/components/theme-switch/theme-switch.d.ts +11 -0
  302. package/components/theme-switch/theme-switch.js +37 -0
  303. package/components/theme-switch/theme-switch.module.css +31 -0
  304. package/components/time-picker/time-picker.d.ts +13 -0
  305. package/components/time-picker/time-picker.js +95 -0
  306. package/components/time-picker/time-picker.module.css +23 -0
  307. package/components/timeline/timeline.d.ts +47 -0
  308. package/components/timeline/timeline.js +171 -0
  309. package/components/timeline/timeline.module.css +51 -0
  310. package/components/toast/toast.d.ts +8 -0
  311. package/components/toast/toast.js +118 -0
  312. package/components/toast/toast.module.css +100 -0
  313. package/components/toast-stack/toast-stack.d.ts +57 -0
  314. package/components/toast-stack/toast-stack.js +399 -0
  315. package/components/toast-stack/toast-stack.module.css +61 -0
  316. package/components/tooltip/tooltip.d.ts +8 -0
  317. package/components/tooltip/tooltip.js +62 -0
  318. package/components/tooltip/tooltip.module.css +18 -0
  319. package/components/tree-view/tree-view.d.ts +17 -0
  320. package/components/tree-view/tree-view.js +147 -0
  321. package/components/tree-view/tree-view.module.css +22 -0
  322. package/components/treemap/treemap.d.ts +35 -0
  323. package/components/treemap/treemap.js +375 -0
  324. package/components/treemap/treemap.module.css +59 -0
  325. package/components/usage-meter/usage-meter.d.ts +24 -0
  326. package/components/usage-meter/usage-meter.js +296 -0
  327. package/components/usage-meter/usage-meter.module.css +61 -0
  328. package/components/user-menu/user-menu.d.ts +62 -0
  329. package/components/user-menu/user-menu.js +316 -0
  330. package/components/user-menu/user-menu.module.css +97 -0
  331. package/components/waffle-chart/waffle-chart.d.ts +39 -0
  332. package/components/waffle-chart/waffle-chart.js +244 -0
  333. package/components/waffle-chart/waffle-chart.module.css +52 -0
  334. package/components.json +1330 -0
  335. package/eslint.js +53 -0
  336. package/lib/escape.d.ts +19 -0
  337. package/lib/escape.js +50 -0
  338. package/lib/media.d.ts +353 -0
  339. package/lib/media.js +65 -0
  340. package/lib/menu-highlight.d.ts +28 -0
  341. package/lib/menu-highlight.js +43 -0
  342. package/lib/motion-tokens.d.ts +60 -0
  343. package/lib/motion-tokens.js +25 -0
  344. package/lib/return-focus.d.ts +9 -0
  345. package/lib/return-focus.js +53 -0
  346. package/lib/silicon-provider.d.ts +60 -0
  347. package/lib/silicon-provider.js +47 -0
  348. package/lib/use-copy-feedback.d.ts +8 -0
  349. package/lib/use-copy-feedback.js +36 -0
  350. package/lib/use-today.d.ts +2 -0
  351. package/lib/use-today.js +29 -0
  352. package/package.json +76 -4
  353. package/styles.css +218 -0
  354. package/styles.css.d.ts +1 -0
@@ -0,0 +1,27 @@
1
+ /* Positioned so an outgoing panel pops out exactly where it was while the next one takes its place. */
2
+ .root { position: relative; }
3
+ .listShell { position: relative; display: inline-flex; max-width: 100%; min-width: 0; align-items: center; isolation: isolate; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-muted); }
4
+ .viewport { min-width: 0; overflow-x: auto; border-radius: inherit; corner-shape: inherit; scrollbar-width: none; }
5
+ .viewport::-webkit-scrollbar { display: none; }
6
+ .listShell[data-left="true"] .viewport { mask-image: linear-gradient(to right, transparent, black 35px, black); }
7
+ .listShell[data-right="true"] .viewport { mask-image: linear-gradient(to right, black, black calc(100% - 35px), transparent); }
8
+ .listShell[data-left="true"][data-right="true"] .viewport { mask-image: linear-gradient(to right, transparent, black 35px, black calc(100% - 35px), transparent); }
9
+ /* The list owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */
10
+ .list { isolation: isolate; display: inline-flex; width: max-content; align-items: center; gap: var(--space-tight); padding: var(--space-tight); }
11
+ .scrollButton { position: absolute; z-index: 2; top: 0; bottom: 0; display: grid; width: 33px; place-items: center; border: 0; background: var(--surface-muted); color: var(--foreground); }
12
+ .scrollLeft { left: 0; border-radius: var(--radius-control) 0 0 var(--radius-control); }
13
+ .scrollRight { right: 0; border-radius: 0 var(--radius-control) var(--radius-control) 0; }
14
+ .scrollButton:disabled { opacity: 0; pointer-events: none; }
15
+ .scrollButton:focus-visible { --focus-outline-offset: -3px; }
16
+ .trigger { position: relative; -webkit-tap-highlight-color: transparent; display: inline-grid; min-width: 5.5rem; place-items: center; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - 3px); padding: 0 var(--space-group); background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
17
+ @media (hover: hover) and (pointer: fine) { .trigger:hover { color: var(--foreground); } }
18
+ .trigger:active { color: var(--foreground); }.trigger[data-state="active"] { color: var(--accent-strong); }
19
+ .selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; corner-shape: inherit; background: var(--surface); box-shadow: var(--shadow-resting); will-change: transform; }
20
+ .triggerLabel { position: relative; z-index: 1; }
21
+ .trigger:disabled { cursor: not-allowed; opacity: .5; }
22
+ .content { margin-top: var(--space-inset); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
23
+ /* The outgoing panel is lifted out of flow by the presence animation; drop its margin so it stays in place and ignore stray clicks. */
24
+ .content[data-motion-pop-id] { margin-top: 0; }
25
+ .content[data-state="inactive"] { pointer-events: none; }
26
+ .content:focus-visible { --focus-outline-offset: 3px; border-radius: var(--radius-control); }
27
+ @media (prefers-reduced-motion: reduce) { .trigger { transition: none; } }
@@ -0,0 +1,15 @@
1
+ export interface TagInputProps {
2
+ label: string;
3
+ value?: string[];
4
+ defaultValue?: string[];
5
+ onValueChange?: (value: string[]) => void;
6
+ placeholder?: string;
7
+ description?: string;
8
+ id?: string;
9
+ }
10
+ /**
11
+ * Tags keep the field still: a new tag blurs in where its text was typed while the caret glides aside, a removed tag
12
+ * leaves its slot and the rest glide in, and the shell follows wrapped rows on a spring. Backspace or the arrow keys
13
+ * pick a tag first, a ring glides to it, and the next Backspace removes it.
14
+ */
15
+ export declare function TagInput({ label, value, defaultValue, onValueChange, placeholder, description, id }: TagInputProps): import("react").JSX.Element;
@@ -0,0 +1,150 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
4
+ import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
5
+ import { X as Xmark } from "lucide-react";
6
+ import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import styles from "./tag-input.module.css";
8
+ /** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */
9
+ function MotionText({ text }) {
10
+ const reduced = useReducedMotion();
11
+ const words = text.split(" ");
12
+ return _jsxs(_Fragment, { children: [_jsx("span", { className: styles.srOnly, children: text }), _jsx("span", { className: styles.words, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: words.map((word, index) => _jsx(motion.span, { className: styles.word, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.35em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, transition: reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: index < words.length - 1 ? `${word} ` : word }, `${index}:${word}`)) }) })] });
13
+ }
14
+ /** Helper copy: the row opens its height on a spring, then the words settle in. */
15
+ function FieldMessage({ id, text, className }) {
16
+ return _jsx(AnimatePresence, { initial: false, children: text ? _jsx(MessageRow, { id: id, text: text, className: className }, "message") : null });
17
+ }
18
+ /** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */
19
+ function MessageRow({ id, text, className }) {
20
+ const reduced = useReducedMotion();
21
+ const copyRef = useRef(null);
22
+ const [height, setHeight] = useState("auto");
23
+ useEffect(() => {
24
+ const node = copyRef.current;
25
+ if (!node || typeof ResizeObserver === "undefined")
26
+ return;
27
+ const observer = new ResizeObserver(([entry]) => setHeight(entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight));
28
+ observer.observe(node);
29
+ return () => observer.disconnect();
30
+ }, []);
31
+ // Reduced motion mounts the row at full height: a zero-duration open would still paint one collapsed frame.
32
+ return _jsx(motion.span, { className: styles.messageSlot, initial: reduced ? false : { height: 0, opacity: 0 }, animate: { height, opacity: 1 }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }, children: _jsx(motion.span, { ref: copyRef, id: id, className: className, initial: reduced ? false : { y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { y: 0, filter: "blur(0px)" }, transition: { duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: _jsx(MotionText, { text: text }) }) });
33
+ }
34
+ /**
35
+ * Tags keep the field still: a new tag blurs in where its text was typed while the caret glides aside, a removed tag
36
+ * leaves its slot and the rest glide in, and the shell follows wrapped rows on a spring. Backspace or the arrow keys
37
+ * pick a tag first, a ring glides to it, and the next Backspace removes it.
38
+ */
39
+ export function TagInput({ label, value, defaultValue = [], onValueChange, placeholder = "Add a tag", description, id }) {
40
+ const generatedId = useId();
41
+ const inputId = id ?? generatedId;
42
+ const hintId = description ? `${inputId}-description` : undefined;
43
+ const [internal, setInternal] = useState(defaultValue);
44
+ const [draft, setDraft] = useState("");
45
+ const [picked, setPicked] = useState(null);
46
+ const [notice, setNotice] = useState("");
47
+ const reduced = useReducedMotion();
48
+ const [scope, animate] = useAnimate();
49
+ const contentRef = useRef(null);
50
+ const inputRef = useRef(null);
51
+ const ringRef = useRef(null);
52
+ const ringAt = useRef("");
53
+ // The shell follows its wrapped rows on a spring instead of jumping when a tag starts or leaves a line.
54
+ const [height, setHeight] = useState("auto");
55
+ const tags = value ?? internal;
56
+ const active = picked !== null && tags.includes(picked) ? picked : null;
57
+ useEffect(() => {
58
+ const node = contentRef.current;
59
+ if (!node || typeof ResizeObserver === "undefined")
60
+ return;
61
+ const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
62
+ observer.observe(node);
63
+ return () => observer.disconnect();
64
+ }, []);
65
+ // The ring measures the picked tag's resting box, glides between picks, and fades in place when the pick clears.
66
+ useLayoutEffect(() => {
67
+ const ring = ringRef.current;
68
+ const node = active === null ? null : contentRef.current?.querySelector(`[data-tag="${CSS.escape(active)}"]`);
69
+ if (!ring)
70
+ return;
71
+ // It leaves on the same curve as a removed tag, so a picked tag and its ring fade out as one piece.
72
+ if (!node) {
73
+ if (ringAt.current)
74
+ animate(ring, reduced ? { opacity: 0 } : { opacity: 0, scale: .9 }, { duration: reduced ? 0 : motionTokens.duration.instant, ease: [...motionTokens.ease.standard] });
75
+ ringAt.current = "";
76
+ return;
77
+ }
78
+ const box = { x: node.offsetLeft, y: node.offsetTop, width: node.offsetWidth, height: node.offsetHeight };
79
+ const at = Object.values(box).join(" ");
80
+ if (at === ringAt.current)
81
+ return;
82
+ // A ring that is still fading from the last pick glides on to the next one, so quick Backspaces read as one motion.
83
+ if (!reduced && (ringAt.current || Number(getComputedStyle(ring).opacity) > .02))
84
+ animate(ring, { ...box, opacity: 1, scale: 1 }, { ...motionTokens.spring.morph, opacity: { duration: motionTokens.duration.fast } });
85
+ else
86
+ animate(ring, { ...box, opacity: [0, 1], scale: [reduced ? 1 : .9, 1] }, reduced ? { duration: 0, opacity: { duration: motionTokens.duration.instant } } : { duration: 0, opacity: { duration: motionTokens.duration.fast }, scale: motionTokens.spring.snappy });
87
+ ringAt.current = at;
88
+ });
89
+ function say(message) { setNotice(previous => previous === message ? `${message}\u00a0` : message); }
90
+ function update(next) { if (value === undefined)
91
+ setInternal(next); onValueChange?.(next); }
92
+ function pick(tag) { setPicked(tag); if (tag !== null)
93
+ say(`${tag} selected. Press Backspace to remove it.`); }
94
+ function add() {
95
+ const tag = draft.trim();
96
+ if (!tag)
97
+ return;
98
+ const existing = tags.find(item => item.toLowerCase() === tag.toLowerCase());
99
+ // A duplicate pulses the tag that already exists, so the ignored Enter still gets an answer.
100
+ if (existing) {
101
+ const node = scope.current?.querySelector(`[data-tag="${CSS.escape(existing)}"]`);
102
+ if (node && !reduced)
103
+ animate(node, { scale: [1, 1.06, 1] }, { duration: motionTokens.duration.standard + motionTokens.duration.instant, ease: [...motionTokens.ease.inOut] });
104
+ say(`${existing} is already added`);
105
+ return;
106
+ }
107
+ update([...tags, tag]);
108
+ setDraft("");
109
+ setPicked(null);
110
+ say(`Added ${tag}`);
111
+ }
112
+ function remove(tag) { update(tags.filter(item => item !== tag)); setPicked(null); say(`Removed ${tag}`); inputRef.current?.focus(); }
113
+ function onKeyDown(event) {
114
+ const { key, currentTarget: input } = event;
115
+ const atStart = input.selectionStart === 0 && input.selectionEnd === 0;
116
+ const index = active === null ? tags.length : tags.indexOf(active);
117
+ let handled = true;
118
+ if (key === "Enter" || key === ",")
119
+ add();
120
+ else if ((key === "Backspace" || key === "Delete") && active !== null)
121
+ remove(active);
122
+ else if (key === "Backspace" && atStart && tags.length)
123
+ pick(tags[tags.length - 1]);
124
+ else if (key === "ArrowLeft" && (atStart || active !== null) && index > 0)
125
+ pick(tags[index - 1]);
126
+ else if (key === "ArrowRight" && active !== null)
127
+ pick(tags[index + 1] ?? null);
128
+ else if (key === "Escape" && active !== null)
129
+ pick(null);
130
+ else
131
+ handled = false;
132
+ if (handled)
133
+ event.preventDefault();
134
+ }
135
+ // A click on a tag picks it without taking focus from the field. The remove button keeps its own press.
136
+ function onTagPointer(event, tag) {
137
+ if (event.target.closest("button"))
138
+ return;
139
+ if (!tag) {
140
+ event.preventDefault();
141
+ return;
142
+ }
143
+ pick(active === tag ? null : tag);
144
+ inputRef.current?.focus();
145
+ }
146
+ const move = reduced ? { duration: 0 } : motionTokens.spring.morph;
147
+ const glide = reduced ? { duration: 0 } : motionTokens.spring.smooth;
148
+ return _jsxs("div", { className: styles.field, children: [_jsx("label", { htmlFor: inputId, children: label }), _jsx(motion.div, { ref: scope, className: styles.control, initial: false, animate: { height }, transition: glide, children: _jsxs("div", { ref: contentRef, className: styles.content, onClick: event => { if (event.target === event.currentTarget)
149
+ inputRef.current?.focus(); }, children: [_jsx("span", { ref: ringRef, className: styles.ring, "aria-hidden": "true" }), _jsx(AnimatePresence, { initial: false, children: !draft && !tags.length && _jsx(motion.span, { className: styles.placeholder, "aria-hidden": "true", initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: 0 } }, transition: reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: placeholder }, "placeholder") }), _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: tags.map(tag => _jsxs(motion.span, { layout: reduced ? false : "position", className: styles.tag, "data-tag": tag, "data-picked": tag === active || undefined, onMouseDown: event => onTagPointer(event), onClick: event => onTagPointer(event, tag), initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .9, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .9, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, transition: reduced ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.morph, layout: move, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, children: [_jsx("span", { className: styles.tagLabel, children: tag }), _jsx("button", { type: "button", onClick: () => remove(tag), "aria-label": `Remove ${tag}`, children: _jsx(Xmark, { width: 14, height: 14, "aria-hidden": "true" }) })] }, tag)) }), _jsx(motion.input, { layout: reduced ? false : "position", transition: { layout: move }, ref: inputRef, id: inputId, value: draft, onChange: event => { setDraft(event.currentTarget.value); setPicked(null); }, onKeyDown: onKeyDown, onBlur: () => { add(); setPicked(null); }, placeholder: tags.length ? "" : placeholder, "aria-describedby": hintId })] }) }), _jsx("span", { className: styles.srOnly, "aria-live": "polite", children: notice }), _jsx(FieldMessage, { id: hintId, text: description, className: styles.hint })] });
150
+ }
@@ -0,0 +1,34 @@
1
+ /* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */
2
+ /* The field fills its column, so a new tag never widens it or re-centers it inside a centered layout. */
3
+ .field { display: grid; width: 100%; min-width: 0; }
4
+ .field label { margin-bottom: var(--space-related); font-size: var(--text-sm); font-weight: 500; }
5
+ /* The shell animates its height to .content, which carries the wrapping rows. The ring contracts onto the border on focus. */
6
+ .control { box-sizing: content-box; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface); box-shadow: 0 0 0 6px transparent; transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard); }
7
+ @media (hover: hover) and (pointer: fine) { .control:hover:not(:focus-within) { border-color: var(--border-strong); } }
8
+ .control:focus-within { border-color: var(--foreground); box-shadow: 0 0 0 3px var(--focus-ring); outline: 2px solid transparent; outline-offset: 2px; }
9
+ /* Equal 7px padding makes one row fill the minimum height exactly, so existing tags hold their line when a second row opens. */
10
+ .content { position: relative; display: flex; flex-wrap: wrap; align-items: center; align-content: flex-start; gap: 6px; min-height: calc(var(--control-height-md) - 2px); box-sizing: border-box; padding: 7px; cursor: text; }
11
+ .tag { position: relative; display: inline-flex; align-items: center; gap: 3px; min-height: 28px; box-sizing: border-box; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; padding: 0 4px 0 9px; background: var(--surface-muted); color: var(--foreground); font-size: var(--text-sm); white-space: nowrap; cursor: default; }
12
+ .tag button { display: grid; width: 22px; height: 22px; place-items: center; border: 0; border-radius: 50%; corner-shape: round; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
13
+ @media (hover: hover) and (pointer: fine) { .tag button:hover { background: var(--surface); color: var(--foreground); } }
14
+ .tag button:focus-visible { background: var(--surface); color: var(--foreground); outline: none; box-shadow: inset 0 0 0 1.5px var(--focus-ring); }
15
+ /* Quick press, spring release. */
16
+ .tag button:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
17
+ .content input { flex: 1 1 100px; min-width: 80px; min-height: 28px; box-sizing: border-box; border: 0; padding: 0 5px; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); }
18
+ .content input:focus, .content input:focus-visible { outline: none; }
19
+ .content input::placeholder { color: transparent; }
20
+ /* The visible placeholder is drawn here so it can rise in when the last tag leaves. The native one stays for assistive tech. Inset matches the content and input padding. */
21
+ .placeholder { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; padding: 0 12px; color: var(--text-muted); font-size: var(--text-sm); white-space: nowrap; pointer-events: none; }
22
+ /* The pick ring rides above the pills and sizes itself to the picked tag, so it can glide between tags without scaling its border. */
23
+ .ring { position: absolute; top: 0; left: 0; z-index: 1; box-sizing: border-box; border: 1px solid var(--accent); border-radius: var(--radius-full); corner-shape: round; background: var(--accent-subtle); opacity: 0; pointer-events: none; }
24
+ /* A resting tag is not a stacking context (its filter ends at none), so its label and button sit above the ring while its fill stays below. */
25
+ .tagLabel, .tag button { position: relative; z-index: 2; }
26
+ .tag[data-picked] button { color: var(--foreground); }
27
+ /* The row's space is padding inside the clipped slot, so the height animates from a true zero. */
28
+ .messageSlot { display: block; overflow: hidden; }
29
+ .hint { display: block; padding-top: var(--space-related); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
30
+ /* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */
31
+ .words { position: relative; display: block; }
32
+ .word { display: inline-block; white-space: pre; }
33
+ .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
34
+ @media (prefers-reduced-motion: reduce) { .control, .tag button { transition: none; } .tag button:active { transform: none; } }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Morphs one short label into the next in place. Letters both strings share glide to their new positions, new letters sharpen in, removed letters blur away,
3
+ * and the width follows on a spring so the surrounding layout glides instead of jumping. Use it for status words that change a few letters at a time,
4
+ * such as Publish, Publishing, and Published, or Follow and Following. It stays on one line, and assistive technology reads the plain text.
5
+ */
6
+ export interface TextMorphProps {
7
+ children: string;
8
+ as?: "span" | "div" | "p" | "strong" | "h1" | "h2" | "h3";
9
+ className?: string;
10
+ id?: string;
11
+ }
12
+ export declare function TextMorph({ children, as, className, id }: TextMorphProps): import("react").JSX.Element;
13
+ export default TextMorph;
@@ -0,0 +1,69 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
5
+ import { motionTokens } from "../../lib/motion-tokens.js";
6
+ import styles from "./text-morph.module.css";
7
+ const enter = [...motionTokens.ease.enter];
8
+ const standard = [...motionTokens.ease.standard];
9
+ const blurred = `blur(${motionTokens.blur.soft}px)`;
10
+ /** Lets leaving letters start to clear before the first new letter sharpens in their place. */
11
+ const HANDOFF = .05;
12
+ /** Letters match by character and occurrence, so the second "i" in one string pairs with the second "i" in the next. */
13
+ function toGlyphs(text) {
14
+ const seen = new Map();
15
+ return Array.from(text).map(char => { const count = seen.get(char) ?? 0; seen.set(char, count + 1); return { char, key: `${char}-${count}` }; });
16
+ }
17
+ const measure = (element) => parseFloat(getComputedStyle(element).width);
18
+ export function TextMorph({ children, as = "span", className, id }) {
19
+ const Tag = as;
20
+ const reduced = useReducedMotion();
21
+ const frame = useRef(null);
22
+ const track = useRef(null);
23
+ const width = useRef(0);
24
+ const sizing = useRef(null);
25
+ const glyphs = useMemo(() => toGlyphs(children), [children]);
26
+ // The previous label tells which letters are new, so only they stagger, in reading order, however far along the word they sit.
27
+ const [labels, setLabels] = useState({ current: children, previous: children });
28
+ if (labels.current !== children)
29
+ setLabels({ current: children, previous: labels.current });
30
+ const kept = useMemo(() => new Set(toGlyphs(labels.previous).map(glyph => glyph.key)), [labels.previous]);
31
+ let entering = 0;
32
+ // The frame holds an explicit width so a new label never snaps it; the spring then follows the track, which already has the new letters in place.
33
+ useLayoutEffect(() => {
34
+ const frameElement = frame.current, trackElement = track.current;
35
+ if (!frameElement || !trackElement)
36
+ return;
37
+ const next = measure(trackElement);
38
+ if (!Number.isFinite(next))
39
+ return;
40
+ if (width.current && Math.abs(next - width.current) > .5 && !reduced)
41
+ sizing.current = animate(frameElement, { width: next }, motionTokens.spring.morph);
42
+ else {
43
+ sizing.current?.stop();
44
+ frameElement.style.width = `${next}px`;
45
+ }
46
+ width.current = next;
47
+ }, [children, reduced]);
48
+ // Font loading or a responsive font size changes the width without a new label: follow it immediately.
49
+ useEffect(() => {
50
+ const frameElement = frame.current, trackElement = track.current;
51
+ if (!frameElement || !trackElement || typeof ResizeObserver === "undefined")
52
+ return;
53
+ const observer = new ResizeObserver(() => {
54
+ const next = measure(trackElement);
55
+ if (!Number.isFinite(next) || Math.abs(next - width.current) < .5)
56
+ return;
57
+ sizing.current?.stop();
58
+ frameElement.style.width = `${next}px`;
59
+ width.current = next;
60
+ });
61
+ observer.observe(trackElement);
62
+ return () => observer.disconnect();
63
+ }, []);
64
+ return _jsxs(Tag, { id: id, className: className, children: [_jsx("span", { className: styles.srOnly, children: children }), _jsx("span", { ref: frame, className: styles.frame, "aria-hidden": "true", children: _jsx("span", { ref: track, className: styles.track, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: glyphs.map(({ char, key }) => {
65
+ const order = kept.has(key) ? 0 : entering++;
66
+ return _jsx(motion.span, { layout: "position", className: styles.glyph, initial: reduced ? false : { opacity: 0, scale: .8, y: "0.14em", filter: blurred }, animate: { opacity: 1, scale: 1, y: "0em", filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .86, y: "-0.1em", filter: blurred, transition: { duration: motionTokens.duration.exit, ease: standard } }, transition: reduced ? { duration: 0 } : { layout: motionTokens.spring.morph, default: { duration: motionTokens.duration.standard + .04, ease: enter, delay: HANDOFF + Math.min(order, 8) * motionTokens.stagger.char * 2 } }, children: char === " " ? "\u00a0" : char }, key);
67
+ }) }) }) })] });
68
+ }
69
+ export default TextMorph;
@@ -0,0 +1,8 @@
1
+ /* The frame carries the animated width. Its mask reveals new letters through a soft trailing edge as it grows and fades leaving ones as it shrinks.
2
+ Padding with matching negative margins gives the mask room for blur and descenders without changing layout; the edge sits entirely in that room, so resting text is never dimmed.
3
+ The track always starts at the frame's leading edge, even in centered text: otherwise it would recenter inside the old width and leaving letters would jump. */
4
+ .frame.frame, .frame { position: relative; display: inline-block; box-sizing: content-box; padding: .5em .5em .5em .2em; margin: -.5em -.5em -.5em -.2em; text-align: start; white-space: nowrap; mask-image: linear-gradient(90deg, #000 calc(100% - .5em), transparent); }
5
+ /* The track sizes to the letters in flow. Leaving letters are taken out of flow and positioned against it. */
6
+ .track { position: relative; display: inline-block; white-space: nowrap; }
7
+ .glyph { display: inline-block; white-space: pre; transform-origin: 50% 60%; }
8
+ .srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; user-select: none; }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Reveals a short headline or sentence once, as it mounts: each word rises out of its own clip while it sharpens from a soft blur.
3
+ * The entrance runs in CSS, so it starts on first paint, never waits for hydration, and never leaves text hidden when scripts are slow.
4
+ * Use `\n` in `text` for a deliberate line break and change the element's `key` to replay it.
5
+ */
6
+ export interface TextRevealProps {
7
+ text: string;
8
+ as?: "h1" | "h2" | "h3" | "p";
9
+ className?: string;
10
+ id?: string; /** Seconds to wait before the first word rises. Defaults to 0. */
11
+ delay?: number;
12
+ }
13
+ export declare function TextReveal({ text, as, className, id, delay }: TextRevealProps): import("react").JSX.Element;
14
+ export default TextReveal;
@@ -0,0 +1,20 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Fragment } from "react";
4
+ import { motionTokens } from "../../lib/motion-tokens.js";
5
+ import styles from "./text-reveal.module.css";
6
+ /** Total stagger stays under this many seconds, however long the text is. */
7
+ const MAX_STAGGER = motionTokens.duration.considered;
8
+ export function TextReveal({ text, as = "h2", className, id, delay = 0 }) {
9
+ const Tag = as;
10
+ const lines = text.split("\n").map(line => line.split(" ").filter(Boolean));
11
+ const count = lines.reduce((total, words) => total + words.length, 0);
12
+ const step = Math.min(motionTokens.stagger.word, MAX_STAGGER / Math.max(count, 1));
13
+ const blur = as === "p" ? motionTokens.blur.soft : motionTokens.blur.text;
14
+ let index = 0;
15
+ return _jsxs(Tag, { id: id, className: [styles.reveal, className].filter(Boolean).join(" "), style: { "--reveal-blur": `${blur}px` }, children: [_jsx("span", { className: styles.srOnly, children: text.replace(/\n/g, " ") }), lines.map((words, lineIndex) => _jsxs(Fragment, { children: [_jsx("span", { className: styles.line, "aria-hidden": "true", children: words.map((word, wordIndex) => {
16
+ const position = index++;
17
+ return _jsxs(Fragment, { children: [_jsx("span", { className: styles.clip, children: _jsx("span", { className: styles.word, style: { "--reveal-delay": `${delay + position * step + lineIndex * step}s` }, children: word }) }), wordIndex < words.length - 1 ? " " : null] }, `${word}-${position}`);
18
+ }) }), lineIndex < lines.length - 1 ? " " : null] }, lineIndex))] });
19
+ }
20
+ export default TextReveal;
@@ -0,0 +1,13 @@
1
+ .reveal { max-width: 24ch; margin: 0; overflow: visible; color: var(--foreground); text-wrap: balance; }
2
+ .line { display: block; }
3
+ /* The clip hides the rise below the line through a feathered bottom edge, so a blurred word surfaces softly instead of being cut by a hard line.
4
+ Padding with matching negative margins leaves room for descenders and the blur without changing layout; the feather sits below the descenders, so resting words are never dimmed. */
5
+ .clip { display: inline-block; vertical-align: top; padding: .1em .16em .24em; margin: -.1em -.16em -.24em; mask-image: linear-gradient(#000 calc(100% - .18em), transparent); }
6
+ /* Three tracks: the rise settles longest, the fade finishes first, and the blur clears between them.
7
+ Backwards fill holds the hidden start through each word's delay, then lets go at the end, so resting words keep no transform or filter layer and render as crisp as plain text. */
8
+ .word { display: inline-block; animation: rise .76s var(--ease-enter) var(--reveal-delay, 0s) backwards, fade .44s var(--ease-standard) var(--reveal-delay, 0s) backwards, focus .58s var(--ease-enter) var(--reveal-delay, 0s) backwards; }
9
+ .srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; user-select: none; }
10
+ @keyframes rise { from { transform: translateY(.62em); } }
11
+ @keyframes fade { from { opacity: 0; } }
12
+ @keyframes focus { from { filter: blur(var(--reveal-blur, 4px)); } }
13
+ @media (prefers-reduced-motion: reduce) { .clip { mask-image: none; } .word { animation: fade var(--duration-fast) var(--ease-standard) backwards; } }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A calm light sweep across a short status line, such as "Thinking" or "Generating summary", to show that work is ongoing.
3
+ * The band uses the current text color over a muted base. When `active` turns false the band glides off and the text settles to solid.
4
+ * A changed label rises in place while the line springs to its new width. The component sets `aria-busy`; announce completion from your own live region.
5
+ */
6
+ export interface TextShimmerProps {
7
+ /** The status text. Keep it to one short line. */
8
+ children: string;
9
+ /** Sweep while work is ongoing. Defaults to true. */
10
+ active?: boolean;
11
+ /** Seconds for one sweep across the text. Defaults to 1.8. */
12
+ duration?: number;
13
+ as?: "span" | "p" | "div" | "h2" | "h3" | "h4";
14
+ className?: string;
15
+ id?: string;
16
+ }
17
+ export declare function TextShimmer({ children, active, duration, as, className, id }: TextShimmerProps): import("react").JSX.Element;
18
+ export default TextShimmer;
@@ -0,0 +1,90 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useLayoutEffect, useRef, useSyncExternalStore } from "react";
4
+ import { AnimatePresence, animate, motion, useInView, useMotionValue, usePageInView, useReducedMotion, useTransform } from "motion/react";
5
+ import { motionTokens } from "../../lib/motion-tokens.js";
6
+ import styles from "./text-shimmer.module.css";
7
+ const subscribeHydration = () => () => { };
8
+ const clientSnapshot = () => true;
9
+ const serverSnapshot = () => false;
10
+ /** Half width of the highlight band in em, and a bell shaped falloff as [offset, strength] stops. */
11
+ const band = 2.2;
12
+ const falloff = [[-1, 0], [-.5, .5], [-.2, .9], [0, 1], [.2, .9], [.5, .5], [1, 0]];
13
+ const percent = (value) => `${(value * 100).toFixed(1)}%`;
14
+ /** Paints the band at sweep position `t` (0 sits left of the text, 1 right of it) over a base that is `settle` of the way to solid. */
15
+ function shimmerImage(t, settle) {
16
+ const base = `color-mix(in oklab, var(--ts-highlight) ${percent(settle)}, var(--ts-base))`;
17
+ const stops = falloff.map(([offset, strength]) => `color-mix(in oklab, var(--ts-highlight) ${percent(strength)}, ${base}) calc(${(t * 100).toFixed(2)}% + ${((2 * t - 1 + offset) * band).toFixed(3)}em)`);
18
+ return `linear-gradient(90deg, ${base}, ${stops.join(", ")}, ${base})`;
19
+ }
20
+ export function TextShimmer({ children, active = true, duration = 1.8, as = "span", className, id }) {
21
+ const ref = useRef(null);
22
+ const prefersReduced = useReducedMotion();
23
+ const hydrated = useSyncExternalStore(subscribeHydration, clientSnapshot, serverSnapshot);
24
+ const reduced = Boolean(hydrated && prefersReduced);
25
+ const inView = useInView(ref, { margin: "64px" });
26
+ const pageVisible = usePageInView();
27
+ const sweep = useMotionValue(0);
28
+ const settle = useMotionValue(active ? 0 : 1);
29
+ const backgroundImage = useTransform(() => shimmerImage(sweep.get(), settle.get()));
30
+ const running = active && !reduced && inView && pageVisible;
31
+ const restWidth = useRef(0);
32
+ const Tag = as;
33
+ // Loop the sweep only while work is ongoing, visible, and motion is allowed. A paused band resumes where it stopped.
34
+ useEffect(() => {
35
+ if (reduced)
36
+ sweep.jump(0);
37
+ if (!running)
38
+ return;
39
+ let controls;
40
+ const pass = (delay) => {
41
+ if (sweep.get() >= 1)
42
+ sweep.jump(0);
43
+ controls = animate(sweep, 1, { duration: duration * (1 - sweep.get()), delay, ease: "linear", onComplete: () => pass(duration * .32) });
44
+ };
45
+ pass(sweep.get() > 0 ? 0 : .12);
46
+ return () => controls?.stop();
47
+ }, [running, reduced, duration, sweep]);
48
+ // Finishing lets a band in flight glide off while the base resolves to solid, so the text never hard cuts.
49
+ useEffect(() => {
50
+ if (active) {
51
+ const dim = animate(settle, 0, { duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.standard] });
52
+ return () => dim.stop();
53
+ }
54
+ const t = sweep.get();
55
+ const glide = !reduced && t > 0 && t < 1 ? animate(sweep, 1, { duration: Math.min(.42, duration * (1 - t) * .5), ease: [...motionTokens.ease.enter] }) : undefined;
56
+ const solid = animate(settle, 1, { duration: reduced ? motionTokens.duration.instant : .36, ease: [...motionTokens.ease.standard] });
57
+ return () => { glide?.stop(); solid.stop(); };
58
+ }, [active, reduced, duration, sweep, settle]);
59
+ // A new label springs the line to its width instead of resizing in one frame, so text after it never jumps.
60
+ useLayoutEffect(() => {
61
+ const stage = ref.current;
62
+ if (!stage)
63
+ return;
64
+ const sizing = Boolean(stage.style.width);
65
+ const current = stage.getBoundingClientRect().width;
66
+ stage.style.width = "";
67
+ const next = stage.getBoundingClientRect().width;
68
+ const from = sizing ? current : restWidth.current;
69
+ restWidth.current = next;
70
+ if (reduced || !from || Math.abs(from - next) < .5) {
71
+ delete stage.dataset.sizing;
72
+ return;
73
+ }
74
+ stage.dataset.sizing = "";
75
+ const controls = animate(stage, { width: [from, next] }, { ...motionTokens.spring.morph, onComplete: () => { stage.style.width = ""; delete stage.dataset.sizing; } });
76
+ return () => controls.stop();
77
+ }, [children, reduced]);
78
+ // Keep the resting width current when fonts load or the container resizes.
79
+ useEffect(() => {
80
+ const stage = ref.current;
81
+ if (!stage || typeof ResizeObserver === "undefined")
82
+ return;
83
+ const observer = new ResizeObserver(() => { if (!stage.style.width)
84
+ restWidth.current = stage.getBoundingClientRect().width; });
85
+ observer.observe(stage);
86
+ return () => observer.disconnect();
87
+ }, []);
88
+ return _jsx(Tag, { id: id, className: [styles.shimmer, className].filter(Boolean).join(" "), "data-state": active ? "active" : "idle", "aria-busy": active || undefined, children: _jsx("span", { ref: ref, className: styles.stage, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(motion.span, { className: styles.label, style: { backgroundImage }, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: reduced ? { opacity: 1 } : { opacity: 1, y: "0em", filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, y: "-0.24em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }, transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: children }, children) }) }) });
89
+ }
90
+ export default TextShimmer;
@@ -0,0 +1,9 @@
1
+ /* The band is the current text color; the resting base leans toward muted text. Override with --text-shimmer-highlight and --text-shimmer-base. */
2
+ .shimmer { --ts-highlight: var(--text-shimmer-highlight, currentColor); --ts-base: var(--text-shimmer-base, color-mix(in oklab, currentColor 12%, var(--text-muted))); }
3
+ .stage { position: relative; display: inline-block; max-width: 100%; }
4
+ .label { display: inline-block; max-width: 100%; background-repeat: no-repeat; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
5
+ /* While the line springs to a new width the label keeps its own line, so it never rewraps mid morph. */
6
+ .stage[data-sizing] .label { max-width: none; white-space: nowrap; }
7
+ .label::selection { -webkit-text-fill-color: currentColor; }
8
+ @supports not (color: color-mix(in oklab, red, blue)) { .label { background: none !important; -webkit-text-fill-color: currentColor; } }
9
+ @media (forced-colors: active) { .label { background: none !important; -webkit-text-fill-color: currentColor; } }
@@ -0,0 +1,7 @@
1
+ import type { TextareaHTMLAttributes } from "react";
2
+ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
3
+ label: string;
4
+ description?: string;
5
+ error?: string;
6
+ }
7
+ export declare const Textarea: import("react").ForwardRefExoticComponent<TextareaProps & import("react").RefAttributes<HTMLTextAreaElement>>;
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { forwardRef, useEffect, useId, useRef, useState } from "react";
4
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
5
+ import { motionTokens } from "../../lib/motion-tokens.js";
6
+ import styles from "./textarea.module.css";
7
+ /* Digits roll up when a number grows and down when it shrinks; `custom` hands the latest direction to digits already leaving. */
8
+ const digit = {
9
+ enter: (direction) => ({ opacity: 0, y: `${direction * 0.6}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),
10
+ center: { opacity: 1, y: 0, filter: "blur(0px)" },
11
+ exit: (direction) => ({ opacity: 0, y: `${direction * -0.6}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),
12
+ };
13
+ const isNumber = (word) => /^\d[\d.,/:]*%?$/.test(word);
14
+ /** A count in the copy, such as "12 characters", rolls only the digits that changed; a gained or lost digit opens or closes its width. */
15
+ function RollingNumber({ word }) {
16
+ const reduced = useReducedMotion();
17
+ const [shown, setShown] = useState({ word, direction: 1 });
18
+ if (shown.word !== word)
19
+ setShown({ word, direction: parseFloat(word.replace(/,/g, "")) < parseFloat(shown.word.replace(/,/g, "")) ? -1 : 1 });
20
+ const characters = word.split("");
21
+ const transition = reduced ? { duration: 0 } : { y: motionTokens.spring.snappy, width: motionTokens.spring.morph, opacity: { duration: motionTokens.duration.fast }, filter: { duration: motionTokens.duration.fast } };
22
+ return _jsx(AnimatePresence, { initial: false, children: characters.map((character, index) => _jsx(motion.span, { className: styles.column, initial: { width: 0, opacity: 0 }, animate: { width: "auto", opacity: 1 }, exit: { width: 0, opacity: 0 }, transition: transition, children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", custom: shown.direction, children: _jsx(motion.span, { custom: shown.direction, variants: digit, initial: "enter", animate: "center", exit: "exit", transition: transition, children: character }, character) }) }, characters.length - index)) });
23
+ }
24
+ /** Changed words rise in and unblur while unchanged words hold still, and numbers roll. Assistive tech reads the plain copy. */
25
+ function MotionText({ text }) {
26
+ const reduced = useReducedMotion();
27
+ const words = text.split(" ");
28
+ return _jsxs(_Fragment, { children: [_jsx("span", { className: styles.srOnly, children: text }), _jsx("span", { className: styles.words, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: words.map((word, index) => _jsxs(motion.span, { className: styles.word, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.35em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }, transition: reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: [isNumber(word) ? _jsx(RollingNumber, { word: word }) : word, index < words.length - 1 ? " " : null] }, `${index}:${isNumber(word) ? "#" : word}`)) }) })] });
29
+ }
30
+ /** Helper and error copy: the row opens its height on a spring, then the words settle in. */
31
+ function FieldMessage({ id, text, className, alert }) {
32
+ return _jsx(AnimatePresence, { initial: false, children: text ? _jsx(MessageRow, { id: id, text: text, className: className, alert: alert }, "message") : null });
33
+ }
34
+ /** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */
35
+ function MessageRow({ id, text, className, alert }) {
36
+ const reduced = useReducedMotion();
37
+ const copyRef = useRef(null);
38
+ const [height, setHeight] = useState("auto");
39
+ useEffect(() => {
40
+ const node = copyRef.current;
41
+ if (!node || typeof ResizeObserver === "undefined")
42
+ return;
43
+ const observer = new ResizeObserver(([entry]) => { if (entry)
44
+ setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight); });
45
+ observer.observe(node);
46
+ return () => observer.disconnect();
47
+ }, []);
48
+ return _jsx(motion.span, { className: styles.messageSlot, initial: reduced ? false : { height: 0, opacity: 0 }, animate: { height, opacity: 1 }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }, children: _jsx(motion.span, { ref: copyRef, id: id, className: className, role: alert ? "alert" : undefined, initial: reduced ? false : { y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { y: 0, filter: "blur(0px)" }, transition: { duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: _jsx(MotionText, { text: text }) }) });
49
+ }
50
+ export const Textarea = forwardRef(function Textarea({ label, description, error, id, className, ...props }, ref) {
51
+ const generatedId = useId();
52
+ const controlId = id ?? generatedId;
53
+ const hintId = description ? `${controlId}-description` : undefined;
54
+ const errorId = error ? `${controlId}-error` : undefined;
55
+ return _jsxs("div", { className: styles.field, children: [_jsx("label", { htmlFor: controlId, children: label }), _jsx("textarea", { ...props, id: controlId, ref: ref, className: [styles.control, className].filter(Boolean).join(" "), "aria-invalid": error ? true : props["aria-invalid"], "aria-describedby": [props["aria-describedby"], hintId, errorId].filter(Boolean).join(" ") || undefined }), _jsx(FieldMessage, { id: hintId, text: description, className: styles.hint }), _jsx(FieldMessage, { id: errorId, text: error, className: styles.error, alert: true })] });
56
+ });
@@ -0,0 +1,22 @@
1
+ /* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */
2
+ .field { display: grid; min-width: 0; }
3
+ .field label { margin-bottom: var(--space-related); font-size: var(--text-sm); font-weight: 500; }
4
+ /* Same focus language as Input: the ring contracts onto the border while it fades in, and the box never changes size. */
5
+ .control { width: 100%; min-height: 110px; resize: vertical; border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: var(--space-group); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); outline: none; box-shadow: 0 0 0 6px transparent; transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
6
+ .control::placeholder { color: var(--text-muted); }
7
+ @media (hover: hover) and (pointer: fine) { .control:hover:not(:disabled, [aria-invalid="true"]) { border-color: var(--foreground); } }
8
+ .control:focus-visible { border-color: var(--foreground); box-shadow: 0 0 0 3px var(--focus-ring); outline: 2px solid transparent; outline-offset: 2px; }
9
+ .control[aria-invalid="true"] { border-color: var(--danger); }
10
+ .control:disabled { cursor: not-allowed; background: var(--surface-muted); opacity: .6; }
11
+ /* The row's space is padding inside the clipped slot, so the height animates from a true zero. */
12
+ .messageSlot { display: block; overflow: hidden; }
13
+ .hint, .error { display: block; padding-top: var(--space-related); font-size: var(--text-xs); line-height: var(--leading-body); }
14
+ .hint { color: var(--text-muted); } .error { color: var(--danger); }
15
+ /* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */
16
+ .words { position: relative; display: block; }
17
+ .word { display: inline-block; white-space: pre; }
18
+ /* One column per character of a count. Tabular digits keep columns equal, so only a gained or lost digit changes the width. */
19
+ .column { position: relative; display: inline-flex; justify-content: center; font-variant-numeric: tabular-nums; }
20
+ .column > span { display: inline-block; }
21
+ .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
22
+ @media (prefers-reduced-motion: reduce) { .control { transition: none; } }
@@ -0,0 +1,11 @@
1
+ export type ThemeSwitchVariant = "reveal" | "eclipse" | "split" | "rise";
2
+ export type Theme = "light" | "dark";
3
+ export interface ThemeSwitchProps {
4
+ theme: Theme;
5
+ variant?: ThemeSwitchVariant;
6
+ onThemeChange: (next: Theme, variant: ThemeSwitchVariant, trigger: HTMLElement) => void;
7
+ label?: string;
8
+ iconOnly?: boolean;
9
+ }
10
+ export declare function ThemeSwitch({ theme, variant, onThemeChange, label, iconOnly }: ThemeSwitchProps): import("react").JSX.Element;
11
+ export default ThemeSwitch;