@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,70 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Fragment, useEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { motion, useInView, useReducedMotion } from "motion/react";
5
+ import { motionTokens } from "../../lib/motion-tokens.js";
6
+ import styles from "./in-view-title.module.css";
7
+ const subscribeHydration = () => () => { };
8
+ const clientSnapshot = () => true;
9
+ const serverSnapshot = () => false;
10
+ /** Matches the delay of the stylesheet fallback: when scripts arrive later than this, the fallback has already shown the title, so it stays put instead of animating again. */
11
+ const FALLBACK_MS = 2400;
12
+ const lateBoot = typeof window !== "undefined" && performance.now() > FALLBACK_MS;
13
+ const enter = [...motionTokens.ease.enter];
14
+ const standard = [...motionTokens.ease.standard];
15
+ /** Horizontal distance each letter travels per step from its word's center in the tracking variant. */
16
+ const TRACK_EM = .06;
17
+ /** Transform settles longest, blur clears sooner, and opacity leads, so text is readable before it has fully arrived. Hiding is a quick fade with no stagger. */
18
+ function reveal(instant, visible, delay, settle = .8) {
19
+ if (instant)
20
+ return { duration: 0 };
21
+ if (!visible)
22
+ return { duration: motionTokens.duration.exit, ease: standard };
23
+ const track = (duration, ease = enter) => ({ duration, delay, ease });
24
+ return { y: track(settle), x: track(settle), "--wipe": track(settle, standard), filter: track(settle * .78), opacity: track(settle * .56, standard) };
25
+ }
26
+ export function InViewTitle({ text, variant = "blur", as = "h2", lines, className, id, once = true }) {
27
+ const ref = useRef(null);
28
+ const inView = useInView(ref, { once, amount: .45 });
29
+ const [passed, setPassed] = useState(false);
30
+ useEffect(() => {
31
+ if (inView || passed)
32
+ return;
33
+ const revealPassedTitle = () => {
34
+ if (ref.current && ref.current.getBoundingClientRect().top < 0)
35
+ setPassed(true);
36
+ };
37
+ revealPassedTitle();
38
+ window.addEventListener("scroll", revealPassedTitle, { passive: true });
39
+ return () => window.removeEventListener("scroll", revealPassedTitle);
40
+ }, [inView, passed]);
41
+ const prefersReduced = useReducedMotion();
42
+ const hydrated = useSyncExternalStore(subscribeHydration, clientSnapshot, serverSnapshot);
43
+ const [serverRendered] = useState(!hydrated);
44
+ const reduced = hydrated && prefersReduced;
45
+ const late = hydrated && serverRendered && lateBoot;
46
+ const visible = reduced || late || inView || passed;
47
+ const instant = Boolean(reduced || late);
48
+ const Tag = as;
49
+ const wrapperClass = [styles.wrap, hydrated ? "" : styles.pending, className].filter(Boolean).join(" ");
50
+ const words = text.split(" ").filter(Boolean);
51
+ const step = Math.min(motionTokens.stagger.word * 1.5, .4 / Math.max(words.length, 1));
52
+ if (variant === "word")
53
+ return _jsx("div", { ref: ref, className: wrapperClass, children: _jsx(Tag, { id: id, "aria-label": text, children: words.map((word, index) => _jsxs(Fragment, { children: [_jsx("span", { className: styles.clip, "aria-hidden": "true", children: _jsx(motion.span, { className: styles.part, initial: false, animate: visible ? { y: "0em", opacity: 1 } : { y: "0.9em", opacity: 0 }, transition: reveal(instant, visible, index * step), children: word }) }), index < words.length - 1 ? " " : null] }, `${word}-${index}`)) }) });
54
+ if (variant === "line") {
55
+ const titleLines = lines?.length ? lines : [text];
56
+ return _jsx("div", { ref: ref, className: wrapperClass, children: _jsx(Tag, { id: id, "aria-label": text, children: titleLines.map((line, index) => _jsx("span", { className: styles.lineClip, "aria-hidden": "true", children: _jsx(motion.span, { className: styles.part, initial: false, animate: visible ? { y: "0%", opacity: 1 } : { y: "112%", opacity: 0 }, transition: reveal(instant, visible, index * motionTokens.stagger.line, .86), children: line }) }, `${line}-${index}`)) }) });
57
+ }
58
+ if (variant === "blur")
59
+ return _jsx("div", { ref: ref, className: wrapperClass, children: _jsx(Tag, { id: id, "aria-label": text, children: words.map((word, index) => _jsxs(Fragment, { children: [_jsx(motion.span, { className: `${styles.blurWord} ${styles.part}`, "aria-hidden": "true", initial: false, animate: visible ? { opacity: 1, y: "0em", filter: "blur(0px)" } : { opacity: 0, y: "0.2em", filter: `blur(${motionTokens.blur.text}px)` }, transition: reveal(instant, visible, index * step), children: word }), index < words.length - 1 ? " " : null] }, `${word}-${index}`)) }) });
60
+ if (variant === "tracking")
61
+ return _jsx("div", { ref: ref, className: wrapperClass, children: _jsx(Tag, { id: id, "aria-label": text, children: words.map((word, index) => {
62
+ const letters = Array.from(word);
63
+ const center = (letters.length - 1) / 2;
64
+ const delay = index * motionTokens.stagger.char * 2;
65
+ return _jsxs(Fragment, { children: [_jsx(motion.span, { className: `${styles.trackWord} ${styles.part}`, "aria-hidden": "true", initial: false, animate: visible ? { opacity: 1, filter: "blur(0px)" } : { opacity: 0, filter: `blur(${motionTokens.blur.soft}px)` }, transition: reveal(instant, visible, delay, .9), children: letters.map((letter, letterIndex) => _jsx(motion.span, { className: styles.part, initial: false, animate: { x: visible ? "0em" : `${(center - letterIndex) * TRACK_EM}em` }, transition: reveal(instant, visible, delay, 1), children: letter }, letterIndex)) }), index < words.length - 1 ? " " : null] }, `${word}-${index}`);
66
+ }) }) });
67
+ // A soft edged mask sweeps across the title instead of a hard clip, so no letter is ever cut mid stroke.
68
+ return _jsx("div", { ref: ref, className: wrapperClass, children: _jsx(motion.div, { className: `${styles.wipe} ${styles.part}`, initial: false, animate: visible ? { "--wipe": "0%", x: "0em" } : { "--wipe": "100%", x: "-0.12em" }, transition: reveal(instant, visible, 0, .9), children: _jsx(Tag, { id: id, children: text }) }) });
69
+ }
70
+ export default InViewTitle;
@@ -0,0 +1,13 @@
1
+ .wrap { min-width: 0; color: var(--foreground); }
2
+ /* Clips hide the rise; padding with matching negative margins leaves room for descenders and blur without changing layout. */
3
+ .clip { display: inline-block; overflow: hidden; vertical-align: top; padding: .08em .14em .2em; margin: -.08em -.14em -.2em; }
4
+ .lineClip { display: block; overflow: hidden; padding: .06em .12em .18em; margin: -.06em -.12em -.18em; }
5
+ .clip > span, .lineClip > span, .blurWord, .trackWord, .trackWord > span { display: inline-block; }
6
+ .trackWord { white-space: nowrap; }
7
+ /* The wipe mask is 2.3 times the title's width with a feathered middle, so the soft edge sits fully outside the title at both ends of the sweep.
8
+ Padding with matching negative margins keeps descenders and overhangs inside the masked box without changing layout. */
9
+ .wipe { padding: .1em .12em .22em; margin: -.1em -.12em -.22em; mask-image: linear-gradient(90deg, #000 44%, transparent 56%); mask-size: 230% 100%; mask-repeat: no-repeat; mask-position: var(--wipe, 0%) 0; }
10
+ /* Server rendered titles start hidden. If scripts have not taken over after 2.4s, show them anyway; the class is removed on hydration. */
11
+ .pending .part { animation: settle .4s var(--ease-enter) 2.4s forwards; }
12
+ @keyframes settle { to { opacity: 1; transform: none; filter: none; --wipe: 0%; mask-image: none; } }
13
+ @media (prefers-reduced-motion: reduce) { .part { opacity: 1 !important; transform: none !important; filter: none !important; mask-image: none !important; } .pending .part { animation: none; } }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Click-to-edit text for names and short fields that are read far more often than they change, such as a project name or its description.
3
+ * The text turns into a field in place with the same metrics, so nothing around it moves, and the box grows with what you type. Enter saves
4
+ * optimistically and a check draws once the save lands; Escape rolls the text back. A failed save restores the last saved value and says why.
5
+ * Use a regular form when several fields must be saved together.
6
+ */
7
+ export interface InlineEditProps {
8
+ /** The saved value. A new value from outside replaces the text while it is not being edited. */
9
+ value: string;
10
+ /** Persists the new value. Return a promise to show the saving state, and reject it to roll back. */
11
+ onSave: (next: string) => void | Promise<unknown>;
12
+ /** Accessible name, for example “Project name”. */
13
+ label: string;
14
+ /** Returns a message when the draft cannot be saved. */
15
+ validate?: (next: string) => string | null | undefined;
16
+ /** Shown when the value is empty. */
17
+ placeholder?: string;
18
+ /** Wraps onto several lines and grows in height. Enter still saves; Shift+Enter adds a line break. */
19
+ multiline?: boolean;
20
+ /** `title` for names and headings, `body` for descriptions. */
21
+ variant?: "title" | "body";
22
+ /** The element that holds the text, so a title can stay a heading. */
23
+ as?: "span" | "p" | "h1" | "h2" | "h3";
24
+ className?: string;
25
+ }
26
+ export declare function InlineEdit({ value, onSave, label, validate, placeholder, multiline, variant, as: Tag, className }: InlineEditProps): import("react").JSX.Element;
27
+ export default InlineEdit;
@@ -0,0 +1,315 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
5
+ import { Check, CircleAlert, Pencil, X } from "lucide-react";
6
+ import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import styles from "./inline-edit.module.css";
8
+ const enter = [...motionTokens.ease.enter];
9
+ const standard = [...motionTokens.ease.standard];
10
+ const blur = (px) => `blur(${px}px)`;
11
+ const noop = () => () => { };
12
+ /** Typing retargets many times a second, so the frame follows on a quicker spring without overshoot and keeps up with a fast typist. */
13
+ const typingSpring = { ...motionTokens.spring.snappy, visualDuration: motionTokens.duration.fast, bounce: 0 };
14
+ /** Reduced motion only counts after hydration, so the server and the first client render always agree. */
15
+ function useReduced() {
16
+ const hydrated = useSyncExternalStore(noop, () => true, () => false);
17
+ const reduced = useReducedMotion() ?? false;
18
+ return hydrated && reduced;
19
+ }
20
+ /** Text rises in from a soft blur. A rollback runs the other way, dropping the old words down and the saved ones in from above. */
21
+ const textMotion = {
22
+ enter: (direction) => ({ opacity: 0, y: `${direction * .3}em`, filter: blur(motionTokens.blur.soft) }),
23
+ rest: { opacity: 1, y: "0em", filter: blur(0), transition: { duration: motionTokens.duration.standard, ease: enter } },
24
+ exit: (direction) => ({ opacity: 0, y: `${direction * -.3}em`, filter: blur(motionTokens.blur.subtle), transition: { duration: motionTokens.duration.fast, ease: standard } }),
25
+ };
26
+ const textFade = { enter: { opacity: 0 }, rest: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
27
+ const rest = { opacity: 1, scale: 1, filter: blur(0) };
28
+ const iconIn = { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle) };
29
+ const iconOut = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: standard } };
30
+ const fadeIn = { opacity: 0 };
31
+ const fadeOut = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
32
+ /** Scale rides the spring; opacity and blur tween so the blur never overshoots below zero. */
33
+ const iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };
34
+ /** The tick draws itself from its short stroke, the way a hand would write it. */
35
+ function DrawnCheck({ reduced }) {
36
+ return _jsx("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.25, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx(motion.path, { d: "M4 12.5l5 5L20 6.5", initial: reduced ? false : { pathLength: 0, opacity: 0 }, animate: { pathLength: 1, opacity: 1 }, transition: { pathLength: { duration: .32, ease: enter, delay: .06 }, opacity: { duration: .05, delay: .06 } } }) });
37
+ }
38
+ /** A message row that opens and closes its height on a spring while the words rise in, so an error never pushes the page in one frame. */
39
+ function Reveal({ id, message, reduced }) {
40
+ const inner = useRef(null);
41
+ const height = useMotionValue(0);
42
+ useEffect(() => {
43
+ const node = inner.current;
44
+ if (!node || typeof ResizeObserver === "undefined")
45
+ return;
46
+ let measured = false;
47
+ const observer = new ResizeObserver(([entry]) => {
48
+ if (!entry)
49
+ return;
50
+ const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;
51
+ if (!measured || reduced) {
52
+ measured = true;
53
+ height.jump(next);
54
+ return;
55
+ }
56
+ animate(height, next, motionTokens.spring.smooth);
57
+ });
58
+ observer.observe(node, { box: "border-box" });
59
+ return () => { observer.disconnect(); height.stop(); };
60
+ }, [height, reduced]);
61
+ return _jsx(motion.span, { className: styles.reveal, style: { height }, children: _jsx("span", { ref: inner, id: id, className: styles.revealInner, "aria-live": "polite", children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: 1, children: message && _jsx(motion.span, { className: styles.message, "data-tone": message.tone, custom: 1, variants: reduced ? textFade : textMotion, initial: "enter", animate: "rest", exit: "exit", children: message.node }, message.key) }) }) });
62
+ }
63
+ export function InlineEdit({ value, onSave, label, validate, placeholder = "", multiline = false, variant = "title", as: Tag = "span", className }) {
64
+ const reduced = useReduced();
65
+ const ids = useId();
66
+ const displayHintId = `${ids}-display`, editHintId = `${ids}-edit`, messageId = `${ids}-message`;
67
+ const [committed, setCommitted] = useState(value);
68
+ const [shown, setShown] = useState(value);
69
+ const [seen, setSeen] = useState(value);
70
+ const [layer, setLayer] = useState({ key: 0, direction: 1 });
71
+ const [editing, setEditing] = useState(false);
72
+ const [draft, setDraft] = useState(value);
73
+ const [phase, setPhase] = useState("idle");
74
+ const [error, setError] = useState(null);
75
+ const [failed, setFailed] = useState(null);
76
+ const [flash, setFlash] = useState(null);
77
+ const [announcement, setAnnouncement] = useState("");
78
+ const root = useRef(null);
79
+ const display = useRef(null);
80
+ const control = useRef(null);
81
+ const selection = useRef(null);
82
+ const focusDisplay = useRef(false);
83
+ const saveRun = useRef(0);
84
+ const timers = useRef([]);
85
+ const frame = useRef(null);
86
+ const box = useRef(null);
87
+ // Single line: the layout box and the input take a new width at once, so the input never scrolls, while the visible frame springs after it.
88
+ // Multiline: the box's height springs, so content below glides, while the textarea already has the room its text needs.
89
+ // Both start at their natural size ("100%", "auto"), so the server markup is already right before any script runs.
90
+ const frameWidth = useMotionValue("100%");
91
+ const boxHeight = useMotionValue("auto");
92
+ const size = useRef(0);
93
+ const wasEditing = useRef(false);
94
+ // A new value from outside replaces the text, unless the person is typing or a save is still in flight.
95
+ if (value !== seen) {
96
+ setSeen(value);
97
+ if (value !== committed) {
98
+ setCommitted(value);
99
+ if (!editing && phase !== "saving" && value !== shown) {
100
+ setShown(value);
101
+ setLayer(current => ({ key: current.key + 1, direction: 1 }));
102
+ }
103
+ }
104
+ }
105
+ const layerText = editing ? draft : shown;
106
+ const saving = phase === "saving";
107
+ const later = (fn, ms) => { timers.current.push(setTimeout(fn, ms)); };
108
+ useEffect(() => () => timers.current.forEach(clearTimeout), []);
109
+ const naturalSize = () => { const node = display.current; return node ? parseFloat(getComputedStyle(node)[multiline ? "height" : "width"]) : 0; };
110
+ /** Written straight to the DOM before paint, because a motion value only reaches the style on its next frame, and one frame at the wrong size shows. */
111
+ const snap = (next) => {
112
+ const field = control.current;
113
+ if (multiline && field) {
114
+ field.style.height = `${next}px`;
115
+ field.scrollTop = 0;
116
+ }
117
+ const surface = multiline ? box.current : frame.current;
118
+ if (surface)
119
+ surface.style[multiline ? "height" : "width"] = `${next}px`;
120
+ (multiline ? boxHeight : frameWidth).jump(next);
121
+ };
122
+ // Changes the person caused (typing, switching modes, a rollback) spring from the size on screen. Measured in a layout effect, so no frame shows the new size early.
123
+ const onContentChange = useEffectEvent(() => {
124
+ const next = naturalSize();
125
+ const previous = size.current;
126
+ const typing = editing && wasEditing.current;
127
+ size.current = next;
128
+ wasEditing.current = editing;
129
+ if (!next)
130
+ return;
131
+ const field = control.current;
132
+ if (multiline && field) {
133
+ field.style.height = `${next}px`;
134
+ field.scrollTop = 0;
135
+ }
136
+ const target = multiline ? boxHeight : frameWidth;
137
+ if (!previous || reduced || typeof target.get() !== "number") {
138
+ snap(next);
139
+ return;
140
+ }
141
+ if (Math.abs(next - previous) < .1)
142
+ return;
143
+ animate(target, next, multiline ? motionTokens.spring.smooth : typing ? typingSpring : motionTokens.spring.morph);
144
+ });
145
+ useLayoutEffect(() => { onContentChange(); }, [layerText, editing, multiline, reduced]);
146
+ // A late web font or a resized container follows at once instead of animating.
147
+ const onResize = useEffectEvent(() => {
148
+ const next = naturalSize();
149
+ if (!next || Math.abs(next - size.current) < .1)
150
+ return;
151
+ size.current = next;
152
+ snap(next);
153
+ });
154
+ useEffect(() => {
155
+ const node = display.current;
156
+ if (!node || typeof ResizeObserver === "undefined")
157
+ return;
158
+ // Border box, so the room added while editing (padding, not content) is noticed too.
159
+ const observer = new ResizeObserver(() => onResize());
160
+ observer.observe(node, { box: "border-box" });
161
+ return () => observer.disconnect();
162
+ }, []);
163
+ // Focus follows the mode: into the field with the caret where the text was clicked, and back to the text after Enter or Escape.
164
+ useLayoutEffect(() => {
165
+ const field = control.current;
166
+ if (editing && field && selection.current !== null) {
167
+ const at = selection.current;
168
+ selection.current = null;
169
+ field.focus({ preventScroll: true });
170
+ if (at === "all")
171
+ field.select();
172
+ else
173
+ field.setSelectionRange(at, at);
174
+ }
175
+ if (!editing && focusDisplay.current) {
176
+ focusDisplay.current = false;
177
+ display.current?.focus({ preventScroll: true });
178
+ }
179
+ });
180
+ function startEdit(at, text = shown) {
181
+ if (editing || saving)
182
+ return;
183
+ setDraft(text);
184
+ setEditing(true);
185
+ setError(null);
186
+ setFailed(null);
187
+ setFlash(null);
188
+ if (phase !== "idle")
189
+ setPhase("idle");
190
+ selection.current = at;
191
+ }
192
+ /** The caret lands on the character that was clicked, the way it would in a text field. */
193
+ function caretAt(x, y) {
194
+ const node = display.current?.querySelector(`[data-layer="${layer.key}"]`)?.firstChild;
195
+ if (!node || !shown)
196
+ return shown.length;
197
+ const doc = document;
198
+ const position = doc.caretPositionFromPoint?.(x, y);
199
+ if (position)
200
+ return position.offsetNode === node ? Math.min(position.offset, shown.length) : shown.length;
201
+ const range = doc.caretRangeFromPoint?.(x, y);
202
+ return range && range.startContainer === node ? Math.min(range.startOffset, shown.length) : shown.length;
203
+ }
204
+ function onDisplayClick(event) {
205
+ if (saving)
206
+ return;
207
+ // A keyboard activation selects everything, ready to retype; a click puts the caret where it landed.
208
+ startEdit(event.detail === 0 ? "all" : caretAt(event.clientX, event.clientY));
209
+ }
210
+ const clean = (text) => multiline ? text.trim() : text.replace(/\s+/g, " ").trim();
211
+ function submit(source) {
212
+ const next = clean(draft);
213
+ const problem = validate?.(next) || null;
214
+ if (problem) {
215
+ setError(problem);
216
+ if (source !== "blur")
217
+ control.current?.focus();
218
+ return;
219
+ }
220
+ setEditing(false);
221
+ setError(null);
222
+ if (source !== "blur")
223
+ focusDisplay.current = true;
224
+ // Trimmed spaces would shift the text in one frame, so a cleaned value arrives with the text motion instead.
225
+ if (next !== draft)
226
+ setLayer(current => ({ key: current.key + 1, direction: 1 }));
227
+ if (next === shown)
228
+ return;
229
+ const previous = committed;
230
+ const run = ++saveRun.current;
231
+ setShown(next);
232
+ setPhase("saving");
233
+ setAnnouncement(`Saving ${label.toLowerCase()}`);
234
+ Promise.resolve().then(() => onSave(next)).then(() => {
235
+ if (run !== saveRun.current)
236
+ return;
237
+ setCommitted(next);
238
+ setPhase("saved");
239
+ setAnnouncement(`${label} saved`);
240
+ later(() => setPhase(current => current === "saved" ? "idle" : current), 1800);
241
+ }, () => {
242
+ if (run !== saveRun.current)
243
+ return;
244
+ setShown(previous);
245
+ setLayer(current => ({ key: current.key + 1, direction: -1 }));
246
+ setPhase("failed");
247
+ setFailed(next);
248
+ setFlash("on");
249
+ setAnnouncement("");
250
+ later(() => setFlash(current => current === "on" ? "off" : current), 1400);
251
+ later(() => setFlash(current => current === "off" ? null : current), 2000);
252
+ });
253
+ }
254
+ function cancel() {
255
+ setEditing(false);
256
+ setError(null);
257
+ focusDisplay.current = true;
258
+ if (draft !== shown)
259
+ setLayer(current => ({ key: current.key + 1, direction: -1 }));
260
+ }
261
+ function retry() {
262
+ if (!failed)
263
+ return;
264
+ startEdit(failed.length, failed);
265
+ }
266
+ function onChange(event) {
267
+ const next = event.target.value;
268
+ setDraft(next);
269
+ if (error)
270
+ setError(validate?.(clean(next)) || null);
271
+ }
272
+ function onKeyDown(event) {
273
+ if (event.key === "Escape") {
274
+ event.preventDefault();
275
+ event.stopPropagation();
276
+ cancel();
277
+ return;
278
+ }
279
+ if (event.key === "Enter" && !event.nativeEvent.isComposing && !(multiline && event.shiftKey)) {
280
+ event.preventDefault();
281
+ submit("key");
282
+ }
283
+ }
284
+ // Leaving the component saves, the way a rename does; switching windows does not.
285
+ function onBlur(event) {
286
+ if (!editing)
287
+ return;
288
+ const next = event.relatedTarget;
289
+ if (next && root.current?.contains(next))
290
+ return;
291
+ if (!next && !document.hasFocus())
292
+ return;
293
+ submit("blur");
294
+ }
295
+ const noun = label.toLowerCase();
296
+ const message = error
297
+ ? { key: `error:${error}`, tone: "error", node: _jsxs(_Fragment, { children: [_jsx(CircleAlert, { className: styles.messageIcon, size: 14, strokeWidth: 2, "aria-hidden": "true" }), _jsx("span", { children: error })] }) }
298
+ : failed && !editing
299
+ ? { key: `failed:${failed}`, tone: "failed", node: _jsxs(_Fragment, { children: [_jsx(CircleAlert, { className: styles.messageIcon, size: 14, strokeWidth: 2, "aria-hidden": "true" }), _jsxs("span", { children: [`Couldn’t save “${failed}”, so the last saved ${noun} is back.`, " ", _jsx("button", { type: "button", className: styles.retry, onClick: retry, children: "Try again" })] })] }) }
300
+ : null;
301
+ const slot = editing ? "edit" : phase;
302
+ const describedBy = (hint) => [hint, message ? messageId : null].filter(Boolean).join(" ");
303
+ const fieldProps = {
304
+ className: styles.control, value: draft, onChange, onKeyDown, placeholder, "aria-label": label, "aria-invalid": error ? true : undefined,
305
+ "aria-describedby": describedBy(editHintId), autoComplete: "off", spellCheck: variant === "body",
306
+ };
307
+ return _jsxs("div", { ref: root, className: [styles.root, className].filter(Boolean).join(" "), "data-variant": variant, "data-multiline": multiline || undefined, "data-editing": editing || undefined, "data-phase": phase, "data-invalid": error ? "" : undefined, "data-flash": flash ?? undefined, onBlur: onBlur, children: [_jsx(Tag, { className: styles.line, children: _jsxs(motion.span, { ref: box, className: styles.box, style: multiline ? { height: boxHeight } : undefined, children: [_jsx("button", { ref: display, type: "button", className: styles.display, onClick: onDisplayClick, tabIndex: editing ? -1 : undefined, "aria-label": `${label}: ${shown || placeholder}`, "aria-describedby": describedBy(displayHintId), "aria-disabled": saving || undefined, children: _jsx("span", { className: styles.layer, "data-empty": layerText ? undefined : "", children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: layer.direction, children: _jsx(motion.span, { "data-layer": layer.key, className: styles.text, custom: layer.direction, variants: reduced ? textFade : textMotion, initial: "enter", animate: "rest", exit: "exit", children: (layerText || placeholder || "\u200b") + (multiline && editing ? "\u200b" : "") }, layer.key) }) }) }), editing && (multiline
308
+ ? _jsx("textarea", { ref: node => { control.current = node; }, ...fieldProps, rows: 1, enterKeyHint: "done" })
309
+ : _jsx("input", { ref: node => { control.current = node; }, ...fieldProps, type: "text", enterKeyHint: "done" })), _jsx(motion.span, { ref: frame, className: styles.frame, style: multiline ? undefined : { width: frameWidth }, children: _jsx("span", { className: styles.slot, children: _jsx(AnimatePresence, { initial: false, children: _jsx(motion.span, { className: styles.slotItem, initial: reduced ? fadeIn : iconIn, animate: rest, exit: reduced ? fadeOut : iconOut, transition: reduced ? { duration: motionTokens.duration.fast } : iconEnter, children: slot === "edit" ? _jsxs("span", { className: styles.actions, children: [_jsx("button", { type: "button", className: styles.save, "aria-label": `Save ${noun}`, onPointerDown: event => event.preventDefault(), onClick: () => submit("button"), children: _jsx(Check, { size: 15, strokeWidth: 2, "aria-hidden": "true" }) }), _jsx("button", { type: "button", className: styles.cancel, "aria-label": "Cancel editing", onPointerDown: event => event.preventDefault(), onClick: cancel, children: _jsx(X, { size: 15, strokeWidth: 2, "aria-hidden": "true" }) })] })
310
+ : slot === "saving" ? _jsx("span", { className: styles.spinner, "aria-hidden": "true" })
311
+ : slot === "saved" ? _jsx("span", { className: styles.saved, "aria-hidden": "true", children: _jsx(DrawnCheck, { reduced: reduced }) })
312
+ : slot === "failed" ? _jsx(CircleAlert, { className: styles.failedIcon, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })
313
+ : _jsx(Pencil, { className: styles.pencil, size: 14, strokeWidth: 1.75, "aria-hidden": "true", onClick: () => startEdit(shown.length) }) }, slot) }) }) })] }) }), _jsx(Reveal, { id: messageId, message: message, reduced: reduced }), _jsx("span", { id: displayHintId, className: styles.srOnly, children: "Activate to edit." }), _jsx("span", { id: editHintId, className: styles.srOnly, children: multiline ? "Enter saves, Shift+Enter adds a line break, Escape cancels." : "Enter saves, Escape cancels." }), _jsx("span", { className: styles.srOnly, role: "status", children: announcement })] });
314
+ }
315
+ export default InlineEdit;
@@ -0,0 +1,73 @@
1
+ /* The box sits a border and padding outside the text, so the text lines up with the content around it and the hover surface reaches past it. */
2
+ .root { --pad-x: 8px; --pad-y: 3px; position: relative; display: grid; min-width: 0; margin-inline-start: calc(-1 * (var(--pad-x) + 1px)); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
3
+ /* Whole-pixel line heights: an input centres its text by rounding, so a fractional line would sit the field a pixel below the display. */
4
+ .root[data-variant="title"] { --size: var(--text-xl); --leading: round(var(--size) * 1.3, 2px); --weight: 500; --ink: var(--foreground); }
5
+ .root[data-variant="body"] { --pad-y: 5px; --size: var(--text-sm); --leading: round(var(--size) * 1.5, 1px); --weight: 400; --ink: var(--text-secondary); }
6
+ /* Room for the actions is reserved in every state, so the text never rewraps when editing starts. */
7
+ /* Inter at every size here: a heading host keeps its semantics but not the display face. */
8
+ .line { display: block; min-width: 0; margin: 0; padding-inline-end: 66px; color: var(--ink); font-family: var(--font-body); font-size: var(--size); font-weight: var(--weight); line-height: var(--leading); letter-spacing: var(--tracking-body); }
9
+ .root[data-multiline] .line { padding-inline-end: 38px; }
10
+ .box { position: relative; display: inline-block; max-width: 100%; vertical-align: top; }
11
+ .root[data-multiline] .box { display: block; }
12
+
13
+ /* Display and field share every metric: border, padding, font, and line height. Only the frame behind them changes. */
14
+ .display, .control { box-sizing: border-box; margin: 0; padding: var(--pad-y) var(--pad-x); border: 1px solid transparent; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: transparent; color: inherit; font: inherit; font-feature-settings: inherit; letter-spacing: inherit; word-spacing: inherit; line-height: var(--leading); text-align: left; text-indent: 0; text-transform: none; -webkit-tap-highlight-color: transparent; }
15
+ .display { position: relative; z-index: 1; display: block; max-width: 100%; cursor: text; }
16
+ .root[data-multiline] .display { width: 100%; }
17
+ .root[data-editing] .display { visibility: hidden; }
18
+ /* While editing, a single line keeps a little extra room, so the next character never has to wait for the box. */
19
+ .root[data-editing]:not([data-multiline]) .display { min-width: 5em; padding-inline-end: calc(var(--pad-x) + 16px); }
20
+ .display[aria-disabled="true"] { cursor: progress; }
21
+ .layer { position: relative; display: block; transition: opacity var(--duration-standard) var(--ease-standard); }
22
+ .layer[data-empty] { color: var(--text-muted); }
23
+ /* An optimistic save shows the new text at once, a little quieter until the save lands; then it settles to full strength. */
24
+ .root[data-phase="saving"] .layer { opacity: .5; transition-duration: var(--duration-fast); }
25
+ .text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: pre; }
26
+ .root[data-multiline] .text { overflow: visible; white-space: pre-wrap; overflow-wrap: break-word; }
27
+ .control { position: absolute; top: 0; left: 0; z-index: 2; width: 100%; height: 100%; outline: none; resize: none; overflow: hidden; caret-color: var(--foreground); }
28
+ /* The frame carries the focus ring for the field, so the field itself never draws a second one. */
29
+ .control:focus-visible { outline: none; }
30
+ .control::placeholder { color: var(--text-muted); opacity: 1; }
31
+ textarea.control { white-space: pre-wrap; overflow-wrap: break-word; }
32
+
33
+ /* The frame is the only surface: a hover tint at rest, a bordered field while editing, a soft danger wash when a save fails. */
34
+ .frame { position: absolute; top: 0; bottom: 0; left: 0; z-index: 0; box-sizing: border-box; border: 1px solid transparent; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: transparent; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard); }
35
+ .root[data-multiline] .frame { right: 0; }
36
+ @media (hover: hover) and (pointer: fine) {
37
+ .display:not([aria-disabled="true"]):hover ~ .frame { background: var(--surface-muted); }
38
+ .pencil { opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }
39
+ .display:hover ~ .frame .pencil, .display:focus-visible ~ .frame .pencil, .pencil:hover { opacity: 1; }
40
+ }
41
+ .root[data-editing] .frame { border-color: var(--foreground); background: var(--surface); box-shadow: 0 0 0 3px var(--focus-ring); }
42
+ .root[data-invalid] .frame { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in oklab, var(--danger) 24%, transparent); }
43
+ .root[data-flash="on"] .frame { border-color: color-mix(in oklab, var(--danger) 30%, transparent); background: color-mix(in oklab, var(--danger) 9%, transparent); }
44
+ /* The wash leaves slowly, without any movement, once the message has been read. */
45
+ .root[data-flash="off"] .frame { transition-duration: var(--duration-considered); }
46
+
47
+ /* The slot rides on the frame's edge, so it follows the width morph instead of jumping to the new width. */
48
+ .slot { position: absolute; top: 50%; left: calc(100% + 6px); display: grid; min-height: 28px; margin-top: -14px; pointer-events: auto; }
49
+ .root[data-multiline] .slot { top: 0; left: calc(100% + 8px); margin-top: calc(var(--pad-y) + var(--leading) / 2 - 13px); }
50
+ .slotItem { display: grid; grid-area: 1 / 1; min-height: 28px; place-items: center start; }
51
+ .actions { display: flex; gap: 4px; }
52
+ .root[data-multiline] .actions { flex-direction: column; }
53
+ .save, .cancel { display: grid; width: 28px; height: 28px; flex: none; place-items: center; padding: 0; border: 1px solid transparent; border-radius: var(--radius-full); corner-shape: round; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
54
+ .save { background: var(--foreground); color: var(--background); }
55
+ .cancel { border-color: var(--border); background: var(--surface); color: var(--text-secondary); }
56
+ .save:active, .cancel:active { transform: scale(.96); }
57
+ @media (hover: hover) and (pointer: fine) { .save:hover { background: color-mix(in oklab, var(--foreground) 86%, var(--background)); } .cancel:hover { border-color: var(--border-strong); background: var(--surface-muted); color: var(--foreground); } }
58
+ .pencil { margin-left: 2px; color: var(--text-muted); cursor: pointer; }
59
+ .spinner { display: block; width: 14px; height: 14px; margin-left: 2px; border: 1.5px solid var(--text-muted); border-right-color: transparent; border-radius: var(--radius-full); corner-shape: round; animation: spin .7s linear infinite; }
60
+ .saved { display: grid; margin-left: 1px; place-items: center; color: var(--success); }
61
+ .failedIcon { margin-left: 1px; color: var(--danger); }
62
+
63
+ /* The message row opens from a true zero; its spacing lives inside the clipped slot. */
64
+ .reveal { display: block; overflow: hidden; }
65
+ .revealInner { position: relative; display: block; }
66
+ .message { display: flex; align-items: flex-start; gap: 6px; padding: 6px var(--pad-x) 4px calc(var(--pad-x) + 1px); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 400; line-height: var(--leading-body); }
67
+ .message[data-tone="error"] { color: var(--danger); }
68
+ .messageIcon { flex: none; margin-top: 2px; color: var(--danger); }
69
+ .retry { margin: 0 0 0 2px; padding: 0; border: 0; border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: none; color: var(--foreground); font: inherit; font-weight: 500; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; cursor: pointer; }
70
+ @media (hover: hover) and (pointer: fine) { .retry:hover { text-decoration-color: currentColor; } }
71
+ .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
72
+ @keyframes spin { to { transform: rotate(360deg); } }
73
+ @media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-right-color: var(--text-muted); border-top-color: transparent; } .layer, .save, .cancel, .pencil { transition: none; } }
@@ -0,0 +1,7 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
3
+ label: string;
4
+ description?: string;
5
+ error?: string;
6
+ }
7
+ export declare const Input: import("react").ForwardRefExoticComponent<InputProps & import("react").RefAttributes<HTMLInputElement>>;
@@ -0,0 +1,60 @@
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 "./input.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.exit, 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
+ // Reduced motion mounts the row at full height: a zero-duration open would still paint one collapsed frame. The presence starts
49
+ // with initial={false}, so rows present at hydration render the same on server and client either way.
50
+ 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 }) }) });
51
+ }
52
+ export const Input = forwardRef(function Input({ label, description, error, id, className, ...props }, ref) {
53
+ const generatedId = useId();
54
+ const controlId = id ?? generatedId;
55
+ const hintId = description ? `${controlId}-description` : undefined;
56
+ const errorId = error ? `${controlId}-error` : undefined;
57
+ const describedBy = [props["aria-describedby"], hintId, errorId].filter(Boolean).join(" ") || undefined;
58
+ return _jsxs("div", { className: styles.field, children: [_jsx("label", { className: styles.label, htmlFor: controlId, children: label }), _jsx("input", { ...props, id: controlId, ref: ref, className: [styles.input, className].filter(Boolean).join(" "), "aria-invalid": error ? true : props["aria-invalid"], "aria-describedby": describedBy }), _jsx(FieldMessage, { id: hintId, text: description, className: styles.description }), _jsx(FieldMessage, { id: errorId, text: error, className: styles.error, alert: true })] });
59
+ });
60
+ Input.displayName = "Input";
@@ -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
+ .label { margin-bottom: var(--space-related); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
4
+ /* Focus answers with the border colour alone (no rings or halos); the field itself never changes size. */
5
+ .input { width: 100%; min-height: var(--control-height-md); padding: 0 var(--space-group); border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); outline: none; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
6
+ .input::placeholder { color: var(--text-muted); }
7
+ @media (hover: hover) and (pointer: fine) { .input:hover:not(:disabled, [aria-invalid="true"]) { border-color: var(--foreground); } }
8
+ .input:focus-visible { border-color: var(--foreground); }
9
+ .input[aria-invalid="true"] { border-color: var(--danger); }
10
+ .input:disabled { cursor: not-allowed; opacity: .5; background: var(--surface-muted); }
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
+ .description, .error { display: block; padding-top: var(--space-related); font-size: var(--text-xs); line-height: var(--leading-body); }
14
+ .description { 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) { .input { transition-duration: .01ms; } }