@sableui/react 0.6.2 → 0.7.1

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 (642) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +315 -0
  3. package/CONTRIBUTING.md +16 -10
  4. package/DESIGN.md +3 -3
  5. package/README.md +3 -3
  6. package/bin/sable-lint.mjs +18 -0
  7. package/bin/sable.mjs +470 -62
  8. package/dist/blocks/api-keys-block.js +44 -0
  9. package/dist/blocks/api-keys-block.meta.js +21 -0
  10. package/dist/blocks/chat-thread-block.js +17 -0
  11. package/dist/blocks/chat-thread-block.meta.js +20 -0
  12. package/dist/blocks/command-palette-block.js +24 -0
  13. package/dist/blocks/command-palette-block.meta.js +21 -0
  14. package/dist/blocks/cta-band-block.js +27 -0
  15. package/dist/blocks/cta-band-block.meta.js +20 -0
  16. package/dist/blocks/danger-zone-block.js +39 -0
  17. package/dist/blocks/danger-zone-block.meta.js +21 -0
  18. package/dist/blocks/device-comparison-block.js +69 -0
  19. package/dist/blocks/device-comparison-block.meta.js +21 -0
  20. package/dist/blocks/event-card-block.js +40 -0
  21. package/dist/blocks/event-card-block.meta.js +21 -0
  22. package/dist/blocks/faq-block.js +38 -0
  23. package/dist/blocks/faq-block.meta.js +20 -0
  24. package/dist/blocks/feature-grid-block.js +38 -0
  25. package/dist/blocks/feature-grid-block.meta.js +21 -0
  26. package/dist/blocks/feedback-form-block.js +30 -0
  27. package/dist/blocks/feedback-form-block.meta.js +21 -0
  28. package/dist/blocks/goal-progress-block.js +24 -0
  29. package/dist/blocks/goal-progress-block.meta.js +21 -0
  30. package/dist/blocks/hero-block.js +33 -0
  31. package/dist/blocks/hero-block.meta.js +21 -0
  32. package/dist/blocks/index.js +162 -1964
  33. package/dist/blocks/invite-team-block.js +36 -0
  34. package/dist/blocks/invite-team-block.meta.js +20 -0
  35. package/dist/blocks/invoice-history-block.js +41 -0
  36. package/dist/blocks/invoice-history-block.meta.js +21 -0
  37. package/dist/blocks/kpi-sparkline-block.js +29 -0
  38. package/dist/blocks/kpi-sparkline-block.meta.js +20 -0
  39. package/dist/blocks/media-player-block.js +68 -0
  40. package/dist/blocks/media-player-block.meta.js +21 -0
  41. package/dist/blocks/newsletter-block.js +35 -0
  42. package/dist/blocks/newsletter-block.meta.js +21 -0
  43. package/dist/blocks/not-found-block.js +27 -0
  44. package/dist/blocks/not-found-block.meta.js +20 -0
  45. package/dist/blocks/notification-list-block.js +32 -0
  46. package/dist/blocks/notification-list-block.meta.js +21 -0
  47. package/dist/blocks/notification-settings-block.js +25 -0
  48. package/dist/blocks/notification-settings-block.meta.js +20 -0
  49. package/dist/blocks/order-tracking-block.js +26 -0
  50. package/dist/blocks/order-tracking-block.meta.js +20 -0
  51. package/dist/blocks/pricing-card-block.js +46 -0
  52. package/dist/blocks/pricing-card-block.meta.js +21 -0
  53. package/dist/blocks/pricing-table-block.js +88 -0
  54. package/dist/blocks/pricing-table-block.meta.js +20 -0
  55. package/dist/blocks/product-card-block.js +26 -0
  56. package/dist/blocks/product-card-block.meta.js +21 -0
  57. package/dist/blocks/profile-card-block.js +58 -0
  58. package/dist/blocks/profile-card-block.meta.js +21 -0
  59. package/dist/blocks/profile-form-block.js +70 -0
  60. package/dist/blocks/profile-form-block.meta.js +21 -0
  61. package/dist/blocks/revenue-breakdown-block.js +38 -0
  62. package/dist/blocks/revenue-breakdown-block.meta.js +21 -0
  63. package/dist/blocks/sign-in-block.js +38 -0
  64. package/dist/blocks/sign-in-block.meta.js +21 -0
  65. package/dist/blocks/sign-up-block.js +48 -0
  66. package/dist/blocks/sign-up-block.meta.js +21 -0
  67. package/dist/blocks/site-footer-block.js +54 -0
  68. package/dist/blocks/site-footer-block.meta.js +20 -0
  69. package/dist/blocks/stat-row-block.js +28 -0
  70. package/dist/blocks/stat-row-block.meta.js +21 -0
  71. package/dist/blocks/steps-block.js +37 -0
  72. package/dist/blocks/steps-block.meta.js +20 -0
  73. package/dist/blocks/task-list-block.js +52 -0
  74. package/dist/blocks/task-list-block.meta.js +21 -0
  75. package/dist/blocks/team-members-block.js +49 -0
  76. package/dist/blocks/team-members-block.meta.js +21 -0
  77. package/dist/blocks/testimonial-block.js +49 -0
  78. package/dist/blocks/testimonial-block.meta.js +20 -0
  79. package/dist/blocks/traffic-sources-block.js +39 -0
  80. package/dist/blocks/traffic-sources-block.meta.js +21 -0
  81. package/dist/blocks/traffic-trend-block.js +61 -0
  82. package/dist/blocks/traffic-trend-block.meta.js +21 -0
  83. package/dist/blocks/upload-list-block.js +39 -0
  84. package/dist/blocks/upload-list-block.meta.js +21 -0
  85. package/dist/blocks/verify-code-block.js +28 -0
  86. package/dist/blocks/verify-code-block.meta.js +21 -0
  87. package/dist/blocks/wallet-block.js +48 -0
  88. package/dist/blocks/wallet-block.meta.js +21 -0
  89. package/dist/components/accordion/accordion.js +63 -0
  90. package/dist/components/accordion/accordion.meta.js +155 -0
  91. package/dist/components/accordion/index.js +15 -0
  92. package/dist/components/app-header/app-header.js +30 -0
  93. package/dist/components/app-header/app-header.meta.js +88 -0
  94. package/dist/components/app-header/index.js +7 -0
  95. package/dist/components/app-shell/app-shell-dock.js +37 -0
  96. package/dist/components/app-shell/app-shell.js +67 -0
  97. package/dist/components/app-shell/app-shell.meta.js +176 -0
  98. package/dist/components/app-shell/index.js +7 -0
  99. package/dist/components/area-chart/area-chart.js +10 -0
  100. package/dist/components/area-chart/area-chart.meta.js +218 -0
  101. package/dist/components/area-chart/index.js +7 -0
  102. package/dist/components/attachment/attachment.js +176 -0
  103. package/dist/components/attachment/attachment.meta.js +179 -0
  104. package/dist/components/attachment/attachment.variants.js +47 -0
  105. package/dist/components/attachment/index.js +7 -0
  106. package/dist/components/avatar/avatar.js +192 -0
  107. package/dist/components/avatar/avatar.meta.js +233 -0
  108. package/dist/components/avatar/avatar.variants.js +44 -0
  109. package/dist/components/avatar/index.js +22 -0
  110. package/dist/components/badge/badge.js +26 -0
  111. package/dist/components/badge/badge.meta.js +171 -0
  112. package/dist/components/badge/badge.variants.js +103 -0
  113. package/dist/components/badge/index.js +11 -0
  114. package/dist/components/banner/banner.js +76 -0
  115. package/dist/components/banner/banner.meta.js +172 -0
  116. package/dist/components/banner/banner.variants.js +44 -0
  117. package/dist/components/banner/index.js +9 -0
  118. package/dist/components/bar-chart/bar-chart.js +10 -0
  119. package/dist/components/bar-chart/bar-chart.meta.js +245 -0
  120. package/dist/components/bar-chart/index.js +7 -0
  121. package/dist/components/bottom-nav/bottom-nav.js +63 -0
  122. package/dist/components/bottom-nav/bottom-nav.meta.js +95 -0
  123. package/dist/components/bottom-nav/index.js +11 -0
  124. package/dist/components/breadcrumbs/breadcrumbs.js +90 -0
  125. package/dist/components/breadcrumbs/breadcrumbs.meta.js +111 -0
  126. package/dist/components/breadcrumbs/index.js +17 -0
  127. package/dist/components/browser/browser.js +39 -0
  128. package/dist/components/browser/browser.meta.js +80 -0
  129. package/dist/components/browser/index.js +7 -0
  130. package/dist/components/bubble/bubble.js +110 -0
  131. package/dist/components/bubble/bubble.meta.js +158 -0
  132. package/dist/components/bubble/bubble.variants.js +90 -0
  133. package/dist/components/bubble/index.js +13 -0
  134. package/dist/components/button/button.js +87 -0
  135. package/dist/components/button/button.meta.js +302 -0
  136. package/dist/components/button/button.variants.js +137 -0
  137. package/dist/components/button/index.js +9 -0
  138. package/dist/components/calendar/calendar.js +89 -0
  139. package/dist/components/calendar/calendar.meta.js +136 -0
  140. package/dist/components/calendar/index.js +7 -0
  141. package/dist/components/card/card.js +203 -0
  142. package/dist/components/card/card.meta.js +212 -0
  143. package/dist/components/card/index.js +23 -0
  144. package/dist/components/carousel/carousel.js +428 -0
  145. package/dist/components/carousel/carousel.meta.js +100 -0
  146. package/dist/components/carousel/index.js +7 -0
  147. package/dist/components/chat-composer/chat-composer.js +174 -0
  148. package/dist/components/chat-composer/chat-composer.meta.js +241 -0
  149. package/dist/components/chat-composer/chat-composer.variants.js +17 -0
  150. package/dist/components/chat-composer/index.js +7 -0
  151. package/dist/components/checkbox/checkbox-mark.js +28 -0
  152. package/dist/components/checkbox/checkbox.js +30 -0
  153. package/dist/components/checkbox/checkbox.meta.js +127 -0
  154. package/dist/components/checkbox/index.js +7 -0
  155. package/dist/components/checkbox-card/checkbox-card.js +94 -0
  156. package/dist/components/checkbox-card/checkbox-card.meta.js +154 -0
  157. package/dist/components/checkbox-card/checkbox-card.variants.js +43 -0
  158. package/dist/components/checkbox-card/index.js +11 -0
  159. package/dist/components/code/code.js +19 -0
  160. package/dist/components/code/code.meta.js +71 -0
  161. package/dist/components/code/index.js +7 -0
  162. package/dist/components/code-block/code-block.js +84 -0
  163. package/dist/components/code-block/code-block.meta.js +103 -0
  164. package/dist/components/code-block/index.js +8 -0
  165. package/dist/components/collapsible/collapsible.js +38 -0
  166. package/dist/components/collapsible/collapsible.meta.js +117 -0
  167. package/dist/components/collapsible/index.js +15 -0
  168. package/dist/components/color-field/color-field.js +293 -0
  169. package/dist/components/color-field/color-field.meta.js +142 -0
  170. package/dist/components/color-field/color.js +69 -0
  171. package/dist/components/color-field/index.js +15 -0
  172. package/dist/components/combobox/combobox.js +486 -0
  173. package/dist/components/combobox/combobox.meta.js +217 -0
  174. package/dist/components/combobox/index.js +37 -0
  175. package/dist/components/command-menu/command-menu-trigger.js +122 -0
  176. package/dist/components/command-menu/command-menu.js +433 -0
  177. package/dist/components/command-menu/command-menu.meta.js +279 -0
  178. package/dist/components/command-menu/index.js +21 -0
  179. package/dist/components/context-card/context-card.js +62 -0
  180. package/dist/components/context-card/context-card.meta.js +106 -0
  181. package/dist/components/context-card/index.js +17 -0
  182. package/dist/components/context-menu/context-menu.js +92 -0
  183. package/dist/components/context-menu/context-menu.meta.js +138 -0
  184. package/dist/components/context-menu/index.js +23 -0
  185. package/dist/components/data-table/data-table.js +142 -0
  186. package/dist/components/data-table/data-table.meta.js +146 -0
  187. package/dist/components/data-table/index.js +9 -0
  188. package/dist/components/date-picker/date-picker.js +196 -0
  189. package/dist/components/date-picker/date-picker.meta.js +164 -0
  190. package/dist/components/date-picker/index.js +7 -0
  191. package/dist/components/date-tile/date-tile.js +29 -0
  192. package/dist/components/date-tile/date-tile.meta.js +72 -0
  193. package/dist/components/date-tile/index.js +7 -0
  194. package/dist/components/destructive-action-modal/destructive-action-modal.js +130 -0
  195. package/dist/components/destructive-action-modal/destructive-action-modal.meta.js +136 -0
  196. package/dist/components/destructive-action-modal/index.js +9 -0
  197. package/dist/components/dialog/dialog.js +207 -0
  198. package/dist/components/dialog/dialog.meta.js +239 -0
  199. package/dist/components/dialog/index.js +45 -0
  200. package/dist/components/disclosure-picker/disclosure-picker.js +417 -0
  201. package/dist/components/disclosure-picker/disclosure-picker.meta.js +213 -0
  202. package/dist/components/disclosure-picker/index.js +21 -0
  203. package/dist/components/empty-state/empty-state.js +52 -0
  204. package/dist/components/empty-state/empty-state.meta.js +158 -0
  205. package/dist/components/empty-state/index.js +7 -0
  206. package/dist/components/entity/entity.js +70 -0
  207. package/dist/components/entity/entity.meta.js +108 -0
  208. package/dist/components/entity/entity.variants.js +16 -0
  209. package/dist/components/entity/index.js +7 -0
  210. package/dist/components/feedback/feedback.js +93 -0
  211. package/dist/components/feedback/feedback.meta.js +109 -0
  212. package/dist/components/feedback/index.js +7 -0
  213. package/dist/components/fieldset/fieldset.js +80 -0
  214. package/dist/components/fieldset/fieldset.meta.js +160 -0
  215. package/dist/components/fieldset/index.js +17 -0
  216. package/dist/components/file-dropzone/file-dropzone.js +193 -0
  217. package/dist/components/file-dropzone/file-dropzone.meta.js +210 -0
  218. package/dist/components/file-dropzone/file-dropzone.variants.js +26 -0
  219. package/dist/components/file-dropzone/index.js +13 -0
  220. package/dist/components/font-picker/font-picker.js +77 -0
  221. package/dist/components/font-picker/font-picker.meta.js +119 -0
  222. package/dist/components/font-picker/index.js +7 -0
  223. package/dist/components/form/form.js +145 -0
  224. package/dist/components/form/form.meta.js +142 -0
  225. package/dist/components/form/index.js +11 -0
  226. package/dist/components/gliding-highlight/gliding-highlight.js +50 -0
  227. package/dist/components/gliding-highlight/gliding-highlight.meta.js +135 -0
  228. package/dist/components/gliding-highlight/index.js +9 -0
  229. package/dist/components/gradient-art/gradient-art.js +168 -0
  230. package/dist/components/gradient-art/gradient-art.meta.js +81 -0
  231. package/dist/components/gradient-art/index.js +7 -0
  232. package/dist/components/gradient-art/renderer.js +336 -0
  233. package/dist/components/inline-status/index.js +7 -0
  234. package/dist/components/inline-status/inline-status.js +33 -0
  235. package/dist/components/inline-status/inline-status.meta.js +83 -0
  236. package/dist/components/inline-status/inline-status.variants.js +18 -0
  237. package/dist/components/input/index.js +7 -0
  238. package/dist/components/input/input.js +33 -0
  239. package/dist/components/input/input.meta.js +155 -0
  240. package/dist/components/kbd/index.js +8 -0
  241. package/dist/components/kbd/kbd.js +54 -0
  242. package/dist/components/kbd/kbd.meta.js +95 -0
  243. package/dist/components/label/index.js +7 -0
  244. package/dist/components/label/label.js +51 -0
  245. package/dist/components/label/label.meta.js +98 -0
  246. package/dist/components/line-chart/index.js +7 -0
  247. package/dist/components/line-chart/line-chart.js +10 -0
  248. package/dist/components/line-chart/line-chart.meta.js +222 -0
  249. package/dist/components/marker/index.js +7 -0
  250. package/dist/components/marker/marker.js +39 -0
  251. package/dist/components/marker/marker.meta.js +132 -0
  252. package/dist/components/marker/marker.variants.js +25 -0
  253. package/dist/components/marquee/index.js +7 -0
  254. package/dist/components/marquee/marquee.js +146 -0
  255. package/dist/components/marquee/marquee.meta.js +106 -0
  256. package/dist/components/media-image/index.js +7 -0
  257. package/dist/components/media-image/media-image.js +56 -0
  258. package/dist/components/media-image/media-image.meta.js +92 -0
  259. package/dist/components/media-tile/index.js +7 -0
  260. package/dist/components/media-tile/media-tile.js +135 -0
  261. package/dist/components/media-tile/media-tile.meta.js +133 -0
  262. package/dist/components/media-tile/media-tile.variants.js +48 -0
  263. package/dist/components/menu/index.js +33 -0
  264. package/dist/components/menu/menu.js +168 -0
  265. package/dist/components/menu/menu.meta.js +176 -0
  266. package/dist/components/message/index.js +7 -0
  267. package/dist/components/message/message.js +83 -0
  268. package/dist/components/message/message.meta.js +150 -0
  269. package/dist/components/message/message.variants.js +41 -0
  270. package/dist/components/message-scroller/index.js +9 -0
  271. package/dist/components/message-scroller/message-scroller.js +300 -0
  272. package/dist/components/message-scroller/message-scroller.meta.js +172 -0
  273. package/dist/components/meter/index.js +20 -0
  274. package/dist/components/meter/meter.js +112 -0
  275. package/dist/components/meter/meter.meta.js +227 -0
  276. package/dist/components/meter/meter.variants.js +61 -0
  277. package/dist/components/middle-truncate/index.js +7 -0
  278. package/dist/components/middle-truncate/middle-truncate.js +28 -0
  279. package/dist/components/middle-truncate/middle-truncate.meta.js +77 -0
  280. package/dist/components/note/index.js +9 -0
  281. package/dist/components/note/note.js +73 -0
  282. package/dist/components/note/note.meta.js +112 -0
  283. package/dist/components/note/note.variants.js +22 -0
  284. package/dist/components/notification-badge/index.js +15 -0
  285. package/dist/components/notification-badge/notification-badge.js +48 -0
  286. package/dist/components/notification-badge/notification-badge.meta.js +107 -0
  287. package/dist/components/notification-badge/notification-badge.variants.js +57 -0
  288. package/dist/components/number-field/index.js +7 -0
  289. package/dist/components/number-field/number-field.js +70 -0
  290. package/dist/components/number-field/number-field.meta.js +109 -0
  291. package/dist/components/otp-field/index.js +7 -0
  292. package/dist/components/otp-field/otp-field.js +70 -0
  293. package/dist/components/otp-field/otp-field.meta.js +197 -0
  294. package/dist/components/overflow-menu/index.js +7 -0
  295. package/dist/components/overflow-menu/overflow-menu.js +26 -0
  296. package/dist/components/overflow-menu/overflow-menu.meta.js +100 -0
  297. package/dist/components/page-header/index.js +7 -0
  298. package/dist/components/page-header/page-header.js +34 -0
  299. package/dist/components/page-header/page-header.meta.js +104 -0
  300. package/dist/components/page-loader/index.js +7 -0
  301. package/dist/components/page-loader/page-loader.js +37 -0
  302. package/dist/components/page-loader/page-loader.meta.js +85 -0
  303. package/dist/components/pagination/index.js +7 -0
  304. package/dist/components/pagination/pagination.js +105 -0
  305. package/dist/components/pagination/pagination.meta.js +141 -0
  306. package/dist/components/panel-group/index.js +10 -0
  307. package/dist/components/panel-group/panel-group.js +168 -0
  308. package/dist/components/panel-group/panel-group.meta.js +153 -0
  309. package/dist/components/panel-section/index.js +9 -0
  310. package/dist/components/panel-section/panel-section.js +115 -0
  311. package/dist/components/panel-section/panel-section.meta.js +156 -0
  312. package/dist/components/phone/index.js +7 -0
  313. package/dist/components/phone/phone.js +50 -0
  314. package/dist/{chunk-KFZ43RKU.js → components/phone/phone.meta.js} +3 -50
  315. package/dist/{chunk-NEQNXI2G.js → components/phone/status-icons.js} +4 -4
  316. package/dist/components/pie-chart/index.js +7 -0
  317. package/dist/components/pie-chart/pie-chart.js +191 -0
  318. package/dist/components/pie-chart/pie-chart.meta.js +210 -0
  319. package/dist/components/pill/index.js +9 -0
  320. package/dist/components/pill/pill.js +24 -0
  321. package/dist/components/pill/pill.meta.js +108 -0
  322. package/dist/components/pill/pill.variants.js +65 -0
  323. package/dist/components/popover/index.js +23 -0
  324. package/dist/components/popover/popover.js +100 -0
  325. package/dist/components/popover/popover.meta.js +136 -0
  326. package/dist/components/progress/index.js +19 -0
  327. package/dist/components/progress/progress.js +70 -0
  328. package/dist/components/progress/progress.meta.js +171 -0
  329. package/dist/components/progress/progress.variants.js +17 -0
  330. package/dist/components/radar-chart/index.js +7 -0
  331. package/dist/components/radar-chart/radar-chart.js +161 -0
  332. package/dist/components/radar-chart/radar-chart.meta.js +204 -0
  333. package/dist/components/radial-chart/index.js +7 -0
  334. package/dist/components/radial-chart/radial-chart.js +175 -0
  335. package/dist/components/radial-chart/radial-chart.meta.js +216 -0
  336. package/dist/components/radio/index.js +8 -0
  337. package/dist/components/radio/radio.js +39 -0
  338. package/dist/components/radio/radio.meta.js +104 -0
  339. package/dist/components/radio-card/index.js +7 -0
  340. package/dist/components/radio-card/radio-card.js +81 -0
  341. package/dist/components/radio-card/radio-card.meta.js +144 -0
  342. package/dist/components/radio-card/radio-card.variants.js +43 -0
  343. package/dist/components/relative-time/index.js +7 -0
  344. package/dist/components/relative-time/relative-time.js +47 -0
  345. package/dist/components/relative-time/relative-time.meta.js +77 -0
  346. package/dist/components/scroller/index.js +7 -0
  347. package/dist/components/scroller/scroller.js +111 -0
  348. package/dist/components/scroller/scroller.meta.js +118 -0
  349. package/dist/components/scrub-field/index.js +7 -0
  350. package/dist/components/scrub-field/scrub-field.js +124 -0
  351. package/dist/components/scrub-field/scrub-field.meta.js +139 -0
  352. package/dist/components/search-input/index.js +7 -0
  353. package/dist/components/search-input/search-input.js +91 -0
  354. package/dist/components/search-input/search-input.meta.js +129 -0
  355. package/dist/components/section-header/index.js +7 -0
  356. package/dist/components/section-header/section-header.js +39 -0
  357. package/dist/components/section-header/section-header.meta.js +174 -0
  358. package/dist/components/segmented-bar/index.js +14 -0
  359. package/dist/components/segmented-bar/segmented-bar.js +26 -0
  360. package/dist/components/segmented-bar/segmented-bar.meta.js +108 -0
  361. package/dist/components/segmented-bar/segmented-bar.variants.js +39 -0
  362. package/dist/components/select/index.js +25 -0
  363. package/dist/components/select/select.js +191 -0
  364. package/dist/components/select/select.meta.js +189 -0
  365. package/dist/components/separator/index.js +7 -0
  366. package/dist/components/separator/separator.js +42 -0
  367. package/dist/components/separator/separator.meta.js +84 -0
  368. package/dist/components/settings-menu/index.js +11 -0
  369. package/dist/components/settings-menu/settings-menu.js +101 -0
  370. package/dist/components/settings-menu/settings-menu.meta.js +177 -0
  371. package/dist/components/settings-menu/settings-menu.variants.js +15 -0
  372. package/dist/components/sheet/index.js +29 -0
  373. package/dist/components/sheet/sheet.js +121 -0
  374. package/dist/components/sheet/sheet.meta.js +167 -0
  375. package/dist/components/sheet/sheet.variants.js +21 -0
  376. package/dist/components/show-more/index.js +7 -0
  377. package/dist/components/show-more/show-more.js +87 -0
  378. package/dist/components/show-more/show-more.meta.js +79 -0
  379. package/dist/components/side-nav/index.js +13 -0
  380. package/dist/components/side-nav/side-nav.js +75 -0
  381. package/dist/components/side-nav/side-nav.meta.js +139 -0
  382. package/dist/components/side-nav/side-nav.variants.js +34 -0
  383. package/dist/components/sidebar/index.js +21 -0
  384. package/dist/components/sidebar/sidebar.js +196 -0
  385. package/dist/components/sidebar/sidebar.meta.js +118 -0
  386. package/dist/components/skeleton/index.js +7 -0
  387. package/dist/components/skeleton/skeleton.js +16 -0
  388. package/dist/components/skeleton/skeleton.meta.js +90 -0
  389. package/dist/components/slider/index.js +21 -0
  390. package/dist/components/slider/slider.js +115 -0
  391. package/dist/components/slider/slider.meta.js +214 -0
  392. package/dist/components/sparkline/index.js +7 -0
  393. package/dist/components/sparkline/sparkline.js +84 -0
  394. package/dist/components/sparkline/sparkline.meta.js +147 -0
  395. package/dist/components/spinner/index.js +7 -0
  396. package/dist/components/spinner/spinner.js +59 -0
  397. package/dist/components/spinner/spinner.meta.js +118 -0
  398. package/dist/components/status-bar/index.js +9 -0
  399. package/dist/components/status-bar/status-bar.js +40 -0
  400. package/dist/components/status-bar/status-bar.meta.js +117 -0
  401. package/dist/components/status-dot/index.js +9 -0
  402. package/dist/components/status-dot/status-dot.js +40 -0
  403. package/dist/components/status-dot/status-dot.meta.js +113 -0
  404. package/dist/components/status-dot/status-dot.variants.js +28 -0
  405. package/dist/components/stepper/index.js +17 -0
  406. package/dist/components/stepper/stepper.js +161 -0
  407. package/dist/components/stepper/stepper.meta.js +190 -0
  408. package/dist/components/stepper/stepper.variants.js +72 -0
  409. package/dist/components/streaming-text/index.js +7 -0
  410. package/dist/components/streaming-text/streaming-text.js +55 -0
  411. package/dist/components/streaming-text/streaming-text.meta.js +147 -0
  412. package/dist/components/switch/index.js +7 -0
  413. package/dist/components/switch/switch.js +26 -0
  414. package/dist/components/switch/switch.meta.js +133 -0
  415. package/dist/components/table/index.js +23 -0
  416. package/dist/components/table/table.js +117 -0
  417. package/dist/components/table/table.meta.js +143 -0
  418. package/dist/components/tabs/index.js +17 -0
  419. package/dist/components/tabs/tabs.js +58 -0
  420. package/dist/components/tabs/tabs.meta.js +120 -0
  421. package/dist/components/textarea/index.js +7 -0
  422. package/dist/components/textarea/textarea.js +38 -0
  423. package/dist/components/textarea/textarea.meta.js +117 -0
  424. package/dist/components/theme-selector/index.js +11 -0
  425. package/dist/components/theme-selector/theme-selector.js +145 -0
  426. package/dist/components/theme-selector/theme-selector.meta.js +181 -0
  427. package/dist/components/theme-switcher/index.js +7 -0
  428. package/dist/components/theme-switcher/theme-switcher.js +106 -0
  429. package/dist/components/theme-switcher/theme-switcher.meta.js +99 -0
  430. package/dist/components/thinking-orb/index.js +9 -0
  431. package/dist/components/thinking-orb/thinking-orb.js +126 -0
  432. package/dist/components/thinking-orb/thinking-orb.meta.js +142 -0
  433. package/dist/components/thinking-orb/thinking-orb.render.js +252 -0
  434. package/dist/components/toast/index.js +29 -0
  435. package/dist/components/toast/toast.js +254 -0
  436. package/dist/components/toast/toast.meta.js +261 -0
  437. package/dist/components/toggle/index.js +13 -0
  438. package/dist/components/toggle/toggle.js +51 -0
  439. package/dist/components/toggle/toggle.meta.js +129 -0
  440. package/dist/components/toolbar/index.js +27 -0
  441. package/dist/components/toolbar/toolbar.js +80 -0
  442. package/dist/components/toolbar/toolbar.meta.js +272 -0
  443. package/dist/components/toolbar/toolbar.variants.js +16 -0
  444. package/dist/components/tooltip/index.js +21 -0
  445. package/dist/components/tooltip/tooltip.js +74 -0
  446. package/dist/components/tooltip/tooltip.meta.js +156 -0
  447. package/dist/components/tree/index.js +12 -0
  448. package/dist/components/tree/tree.js +364 -0
  449. package/dist/components/tree/tree.meta.js +334 -0
  450. package/dist/components/video/index.js +7 -0
  451. package/dist/components/video/video.js +59 -0
  452. package/dist/components/video/video.meta.js +81 -0
  453. package/dist/components/workspace/index.js +9 -0
  454. package/dist/components/workspace/workspace.js +79 -0
  455. package/dist/components/workspace/workspace.meta.js +183 -0
  456. package/dist/foundations/accessibility.js +107 -0
  457. package/dist/foundations/index.js +25 -792
  458. package/dist/foundations/motion.js +135 -0
  459. package/dist/foundations/scrolling.js +62 -0
  460. package/dist/foundations/shape.js +113 -0
  461. package/dist/foundations/spacing.js +121 -0
  462. package/dist/foundations/surfaces.js +107 -0
  463. package/dist/foundations/types.js +0 -0
  464. package/dist/foundations/typography.js +152 -0
  465. package/dist/icon/Icon.js +30 -0
  466. package/dist/icon/IconProvider.js +27 -0
  467. package/dist/icon/compose.js +13 -0
  468. package/dist/icon/index.d.ts +13 -5
  469. package/dist/icon/index.js +13 -2
  470. package/dist/icon/registry.js +0 -0
  471. package/dist/icon-sets/brands.generated.js +123 -0
  472. package/dist/icon-sets/brands.js +10 -123
  473. package/dist/icon-sets/reicon.d.ts +3 -1
  474. package/dist/icon-sets/reicon.js +209 -1
  475. package/dist/index.d.ts +618 -34
  476. package/dist/index.js +211 -11351
  477. package/dist/ios/chrome.js +36 -0
  478. package/dist/ios/index.js +19 -315
  479. package/dist/ios/liquid-glass.js +103 -0
  480. package/dist/ios/native-app.js +46 -0
  481. package/dist/ios/navigation.js +63 -0
  482. package/dist/ios/phone-frame.js +64 -0
  483. package/dist/ios/preview-theme.js +83 -0
  484. package/dist/ios/preview.js +10 -137
  485. package/dist/ios/sheet.js +85 -0
  486. package/dist/ios/theme.js +19 -0
  487. package/dist/layout/AspectBox.js +27 -0
  488. package/dist/layout/Bleed.js +15 -0
  489. package/dist/layout/Center.js +21 -0
  490. package/dist/layout/Columns.js +70 -0
  491. package/dist/layout/Grid.js +50 -0
  492. package/dist/layout/GridOverlay.js +41 -0
  493. package/dist/layout/Page.js +11 -0
  494. package/dist/layout/Region.js +39 -0
  495. package/dist/layout/Row.js +45 -0
  496. package/dist/layout/SafeArea.js +26 -0
  497. package/dist/layout/Spacer.js +9 -0
  498. package/dist/layout/SplitPane.js +55 -0
  499. package/dist/layout/Stack.js +42 -0
  500. package/dist/layout/_responsive.js +16 -0
  501. package/dist/layout/_safeArea.js +18 -0
  502. package/dist/layout/index.d.ts +30 -8
  503. package/dist/layout/index.js +14 -2
  504. package/dist/lib/anchored-scroll.js +7 -0
  505. package/dist/lib/bleed.js +7 -0
  506. package/dist/lib/chart/cartesian.js +365 -0
  507. package/dist/lib/chart/chart-frame.js +163 -0
  508. package/dist/lib/chart/chart-legend.js +13 -0
  509. package/dist/lib/chart/chart-tooltip.js +38 -0
  510. package/dist/lib/chart/format.js +24 -0
  511. package/dist/lib/chart/geometry.js +114 -0
  512. package/dist/lib/chart/motion.js +34 -0
  513. package/dist/lib/chart/scale.js +72 -0
  514. package/dist/lib/chart/types.js +45 -0
  515. package/dist/lib/chart/use-arrival.js +41 -0
  516. package/dist/{chunk-SRQWQUTW.js → lib/cn.js} +5 -5
  517. package/dist/lib/component-guidance.js +215 -0
  518. package/dist/lib/density.js +54 -0
  519. package/dist/lib/field-shell.js +11 -0
  520. package/dist/lib/glyphs.js +9 -0
  521. package/dist/lib/heading-row.js +36 -0
  522. package/dist/lib/highlight-glide.js +84 -0
  523. package/dist/lib/hit-target.js +5 -0
  524. package/dist/lib/meta.js +0 -0
  525. package/dist/lib/portal-theme-scope.js +74 -0
  526. package/dist/{chunk-FKZXAFIY.js → lib/presentation.js} +8 -3
  527. package/dist/lib/refs.js +14 -0
  528. package/dist/lib/scrub-gesture.js +50 -0
  529. package/dist/lib/segmented.js +13 -0
  530. package/dist/lib/selection-card.js +41 -0
  531. package/dist/lib/status-tone.js +13 -0
  532. package/dist/lib/taxonomy.js +214 -0
  533. package/dist/lib/trim-label.js +20 -0
  534. package/dist/lib/use-copy.js +27 -0
  535. package/dist/lib/use-edge-fade.js +32 -0
  536. package/dist/lib/use-isomorphic-layout-effect.js +8 -0
  537. package/dist/lib/use-resize-observer.js +18 -0
  538. package/dist/lib/use-scroll-activity.js +52 -0
  539. package/dist/lib/use-wheel-to-horizontal.js +65 -0
  540. package/dist/motion/MotionProvider.js +75 -0
  541. package/dist/motion/Presence.js +112 -0
  542. package/dist/motion/Reveal.js +42 -0
  543. package/dist/motion/TextReveal.js +61 -0
  544. package/dist/motion/index.js +24 -0
  545. package/dist/motion/useInView.js +36 -0
  546. package/dist/motion/useReducedMotion.js +25 -0
  547. package/dist/motion/viewTransition.js +47 -0
  548. package/dist/patterns/auth-page.js +90 -0
  549. package/dist/patterns/auth-page.meta.js +27 -0
  550. package/dist/patterns/brand-landing-page.js +341 -0
  551. package/dist/patterns/brand-landing-page.meta.js +28 -0
  552. package/dist/patterns/chat-panel.js +185 -0
  553. package/dist/patterns/chat-panel.meta.js +31 -0
  554. package/dist/patterns/dashboard-page.js +332 -0
  555. package/dist/patterns/dashboard-page.meta.js +29 -0
  556. package/dist/patterns/feed-page.js +205 -0
  557. package/dist/patterns/feed-page.meta.js +25 -0
  558. package/dist/patterns/index.js +34 -2140
  559. package/dist/patterns/landing-page.js +356 -0
  560. package/dist/patterns/landing-page.meta.js +26 -0
  561. package/dist/patterns/list-detail-page.js +373 -0
  562. package/dist/patterns/list-detail-page.meta.js +24 -0
  563. package/dist/patterns/settings-page.js +268 -0
  564. package/dist/patterns/settings-page.meta.js +27 -0
  565. package/dist/presets/index.d.ts +7 -1
  566. package/dist/presets/index.js +53 -185
  567. package/dist/styles.css +118 -3
  568. package/dist/{theme-provider-kQszHYji.d.ts → theme-provider-S0NVJs2Y.d.ts} +6 -1
  569. package/dist/{chunk-NETUFI34.js → themes/_contract.js} +14 -2
  570. package/dist/themes/atelier/index.js +17 -1
  571. package/dist/themes/contrast.js +288 -0
  572. package/dist/themes/ember/ember.css +6 -6
  573. package/dist/themes/ember/index.js +16 -1
  574. package/dist/themes/index.d.ts +1 -1
  575. package/dist/themes/index.js +3 -8
  576. package/dist/themes/meridian/index.js +16 -1
  577. package/dist/themes/poppy/index.js +17 -1
  578. package/dist/themes/prism/index.js +17 -1
  579. package/dist/themes/prism/prism.css +2 -2
  580. package/dist/themes/registry.js +59 -0
  581. package/dist/themes/sable/index.js +16 -1
  582. package/dist/{chunk-EFLC4NA3.js → themes/theme-provider.js} +38 -54
  583. package/dist/themes/verdant/index.js +17 -1
  584. package/llms/blocks/media-player-block.md +20 -7
  585. package/llms/blocks/product-card-block.md +6 -4
  586. package/llms/blocks/task-list-block.md +1 -1
  587. package/llms/blocks/traffic-sources-block.md +1 -1
  588. package/llms/blocks/traffic-trend-block.md +5 -3
  589. package/llms/blocks/verify-code-block.md +1 -1
  590. package/llms/components/app-shell.md +5 -2
  591. package/llms/components/avatar.md +25 -3
  592. package/llms/components/button.md +20 -1
  593. package/llms/components/card.md +1 -1
  594. package/llms/components/disclosure-picker.md +168 -0
  595. package/llms/components/font-picker.md +104 -0
  596. package/llms/components/media-image.md +98 -0
  597. package/llms/components/media-tile.md +143 -0
  598. package/llms/components/page-header.md +6 -5
  599. package/llms/components/pie-chart.md +2 -2
  600. package/llms/components/radial-chart.md +1 -1
  601. package/llms/components/section-header.md +144 -0
  602. package/llms/components/sheet.md +10 -0
  603. package/llms/components/sidebar.md +4 -3
  604. package/llms/components/slider.md +14 -2
  605. package/llms/components/theme-selector.md +44 -4
  606. package/llms/components/tree.md +3 -3
  607. package/llms/layout/grid.md +24 -3
  608. package/llms/layout/region.md +16 -3
  609. package/llms/patterns/dashboard-page.md +3 -6
  610. package/llms/patterns/feed-page.md +4 -4
  611. package/llms/patterns/list-detail-page.md +1 -0
  612. package/llms/patterns/settings-page.md +2 -5
  613. package/llms/reference/utilities.md +84 -4
  614. package/llms/tasks/building-a-page.md +34 -3
  615. package/llms/tasks/converting-an-app.md +14 -4
  616. package/llms/tasks/creating-a-preset.md +4 -1
  617. package/llms/tasks/reviewing-a-design.md +19 -0
  618. package/llms/tasks/using-presets.md +19 -0
  619. package/llms-full.txt +1066 -78
  620. package/llms.txt +26 -9
  621. package/package.json +1 -1
  622. package/registry.json +2342 -164
  623. package/scripts/agent-setup.mjs +90 -0
  624. package/scripts/design-presets.mjs +59 -6
  625. package/scripts/grade.mjs +195 -29
  626. package/scripts/lint-rules.mjs +486 -10
  627. package/scripts/review-evidence.mjs +28 -1
  628. package/skills/sable/SKILL.md +67 -24
  629. package/targets/ios.json +45 -0
  630. package/dist/chunk-3IWZYBGH.js +0 -398
  631. package/dist/chunk-7MG4SHYX.js +0 -15
  632. package/dist/chunk-7QSDYBXO.js +0 -7421
  633. package/dist/chunk-ADLLDWLW.js +0 -15
  634. package/dist/chunk-C4Z3CF54.js +0 -15
  635. package/dist/chunk-EDXLM7YL.js +0 -5078
  636. package/dist/chunk-EHH4MHO6.js +0 -14
  637. package/dist/chunk-K7RUHUGC.js +0 -14
  638. package/dist/chunk-KO3NAEBF.js +0 -15
  639. package/dist/chunk-L4IEIQQA.js +0 -14
  640. package/dist/chunk-QJ6FWTUG.js +0 -40
  641. package/dist/chunk-VVW4UCDI.js +0 -4908
  642. package/dist/chunk-XAZGMY73.js +0 -187
package/llms-full.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sable (@sableui/react)
2
2
 
3
- > An owned, themeable React component system. Default theme: **Sable** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter. 106 components, 13 layout primitives, and a **patterns** tier of canonical page shells. The full, per-component contract is the generated `registry.json`.
3
+ > An owned, themeable React component system. Default theme: **Sable** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter. 111 components, 13 layout primitives, and a **patterns** tier of canonical page shells. The full, per-component contract is the generated `registry.json`.
4
4
 
5
5
  ## Start here — brief, composition, evidence
6
6
 
@@ -627,9 +627,9 @@ designs. Record the reason for the chosen composition.
627
627
  </AppShell>
628
628
  ```
629
629
 
630
- - **Never a global `max-width`.** Width is capped per region by role:
631
- `prose` / `form` / `feed` / `content` / `wide` / `full`. A form region is narrow
632
- because a 700px text input is harder to use, not more generous.
630
+ - **Never a global `max-width`.** Width is chosen per route by what the person
631
+ does there (below). A form region is narrow because a 700px text input is
632
+ harder to use, not more generous.
633
633
  - **Measure ≠ width.** Cap prose line length in `ch` (`--measure-prose`)
634
634
  independently of the region's layout width.
635
635
  - **The 12-column grid lives *inside* a region**, for genuinely multi-column
@@ -637,6 +637,37 @@ designs. Record the reason for the chosen composition.
637
637
  - **Chrome is off the grid.** `Sidebar` and rails are fixed-width, outside the
638
638
  content grid.
639
639
 
640
+ ## Choosing a width
641
+
642
+ Pick each route's `max` by what the person mainly does on it:
643
+
644
+ | They mainly… | `max` | Why |
645
+ |---|---|---|
646
+ | read long text | `prose` on the text, inside `content` | line length sets readability: cap the text, not the page |
647
+ | fill in a form | `form` | narrow fields are faster to scan and fill |
648
+ | scroll one stream | `feed` | one column, one eye path |
649
+ | review one record, or settings | `content` | labels stay close to their values |
650
+ | scan and compare rows, cards or charts | `wide` | room for columns without stretching the gaps between them |
651
+ | work across panes, a board, an editor or a map | omit it (fluid), or `full` | the extra width goes to the parts that use it |
652
+
653
+ - **Go fluid only when a flexible part uses the space**: a table's main column,
654
+ a canvas, a board, a reading pane. If extra width only widens the gaps between
655
+ fixed columns (a date drifting away from its subject), cap it.
656
+ - **In a fluid page, cap text by line length** (`--measure-prose`, or
657
+ `Center max="prose"`), not by page width.
658
+ - **Sibling routes in one nav share a width**, so the header doesn't jump as
659
+ people move between them. A detail view may differ.
660
+ - **`full` is the largest cap, not edge to edge.** It keeps the page inset;
661
+ `bleed` is edge to edge.
662
+ - **A cap is a ceiling.** It applies only once the viewport minus app chrome is
663
+ wider than it: beside a 256px sidebar, `wide` (1280px) starts at about 1536px,
664
+ so at 1440px `wide`, `full` and fluid look the same. Judge widths at 1920px and
665
+ 2560px, and record each route's width and reason in the brief.
666
+ - **A product's widths belong in its theme.** A preset may set `page-inset` and
667
+ `region-*` (`form`, `feed`, `content`, `wide`, `full`) in `tokens.shared`
668
+ (a copy in `light` or `dark` wins over it); keep the role names and change
669
+ the values.
670
+
640
671
  ## Typography: pick the tier by ROLE, not by size
641
672
 
642
673
  | tier | for | not |
@@ -1207,12 +1238,22 @@ probably keeping a layout that Sable would have given you.
1207
1238
 
1208
1239
  ## 4. Icons
1209
1240
 
1210
- **Reicon works by default.** No icon provider is needed. To use another icon
1211
- package, map Sable's semantic names in an app-owned IconSet and mount it through
1212
- IconProvider. To add domain-specific names while retaining Reicon:
1241
+ **Mount an icon set once at the root.** `<Icon>` has no built-in fallback:
1242
+ without an `IconProvider`, icons render nothing (a development build warns once).
1243
+
1244
+ ```tsx
1245
+ import { IconProvider } from "@sableui/react"
1246
+ import { reiconIconSet } from "@sableui/react/icons/reicon"
1247
+ <IconProvider set={reiconIconSet}>{app}</IconProvider>
1248
+ ```
1249
+
1250
+ To use another icon package, map Sable's semantic names in an app-owned IconSet
1251
+ and pass it instead; the app then ships none of Reicon. To add domain-specific
1252
+ names while retaining Reicon:
1213
1253
 
1214
1254
  ```tsx
1215
- import { IconProvider, composeIconSets, reiconIconSet } from "@sableui/react"
1255
+ import { IconProvider, composeIconSets } from "@sableui/react"
1256
+ import { reiconIconSet } from "@sableui/react/icons/reicon"
1216
1257
  <IconProvider set={composeIconSets(appExtras, reiconIconSet)} />
1217
1258
  ```
1218
1259
 
@@ -1309,7 +1350,10 @@ cannot establish animation timing, hidden behavior, or an unseen responsive stat
1309
1350
  Choose an original direction suitable for the user's future product. Map that
1310
1351
  direction onto the installed Sable token vocabulary. Preserve readable semantic
1311
1352
  roles and supported control behavior rather than recoloring every primitive
1312
- independently. Load fonts through the consuming project's font setup.
1353
+ independently. Load fonts through the consuming project's font setup. Page widths
1354
+ are optional: set `page-inset` and the `region-*` caps (`form`, `feed`, `content`,
1355
+ `wide`, `full`) in `tokens.shared` only when the direction calls for a different
1356
+ page proportion, and keep each cap's role.
1313
1357
 
1314
1358
  ```bash
1315
1359
  pnpm exec sable preset --template
@@ -1900,6 +1944,10 @@ Save the surface brief, then capture the rendered route at a desktop width of at
1900
1944
  least 1024px and a mobile width of at most 480px. Record the actual viewport,
1901
1945
  route/state, supported color mode, and motion setting with each artifact. Inspect
1902
1946
  the whole route as well as its first viewport. Ensure fonts and media have loaded.
1947
+ Also capture a route that sets a width cap, or goes fluid, at 1920px: a cap applies
1948
+ only once the viewport minus app chrome exceeds it, so at 1024–1440px `wide`,
1949
+ `full` and fluid usually look the same and the width decision never shows. Add
1950
+ 2560px for a fluid route.
1903
1951
  Use a recording or an observed interaction report for behavior that a still cannot
1904
1952
  show. Include reduced motion and every color mode the surface promises to support.
1905
1953
 
@@ -1937,6 +1985,21 @@ those values into artifact records only after inspecting the corresponding files
1937
1985
  replacing a capture at the same path then makes the review incomplete. Neither
1938
1986
  the source fingerprint nor an artifact hash proves someone inspected the screen.
1939
1987
 
1988
+ The template starts with one example screenshot and one example report artifact.
1989
+ Replace them with your own captures; as shipped they point at no file and fail
1990
+ validation. Each artifact record has these fields:
1991
+
1992
+ | Field | Meaning |
1993
+ | --- | --- |
1994
+ | `id` | A unique name that dimensions, checks and iterations cite in `evidence`. |
1995
+ | `kind` | `screenshot`, `recording`, or `report` (a written observation, such as a keyboard walkthrough). |
1996
+ | `path` | The capture file, relative to the review JSON. It must exist and be non-empty. |
1997
+ | `sha256` | The file's hash, copied from `review.artifactHashes` after you inspect the file. |
1998
+ | `state` | The route and state captured, such as "checkout, payment error". |
1999
+ | `viewport` | `{ width, height }` in pixels. Screenshots and recordings only. You need one at 1024 wide or more and one at 480 wide or less. |
2000
+ | `colorMode` | `light` or `dark`. Screenshots and recordings only. |
2001
+ | `motion` | `full` or `reduced`. Screenshots and recordings only. At least one artifact must be captured with reduced motion. |
2002
+
1940
2003
  ## Review these eight dimensions
1941
2004
 
1942
2005
  | Dimension id | Look for concrete evidence |
@@ -2274,6 +2337,25 @@ Use a unique preset ID so it cannot override a bundled theme or another record.
2274
2337
  Record any intentional adaptation and run the validation and rendered review in
2275
2338
  [building-with-sable.md](./building-with-sable.md).
2276
2339
 
2340
+ ## Apply to an existing app
2341
+
2342
+ A preset pasted into an agent for a running app (Canvas's “Copy for agent”) is a
2343
+ restyle: the job ends with the app built and rendered in the preset, not with a
2344
+ saved file. Replace, don't add beside:
2345
+
2346
+ 1. Save it to `.sable/presets/<id>.json`, overwriting an earlier revision of the
2347
+ same ID, and run `sable preset --check`.
2348
+ 2. Regenerate its CSS with `sable preset --css` and import it after Sable's
2349
+ stylesheet. Remove the stylesheet of a theme it replaces when nothing else uses it.
2350
+ 3. Load the families its `fonts` stacks name through the app's existing font
2351
+ setup, and drop the old theme's font loading when no other code uses it.
2352
+ An unloaded family falls back to a system face without any error.
2353
+ 4. Point SableThemeProvider's `defaultThemeId` at the preset ID and
2354
+ `defaultPreference` at its `defaultMode`, unless the user asked for another
2355
+ mode. A bundled theme left as the default keeps the app on the old look.
2356
+ 5. Run the app's build and type check, then `sable check`, and review the result
2357
+ in both modes.
2358
+
2277
2359
  ## Upgrade the package and skill
2278
2360
 
2279
2361
  ```bash
@@ -3943,7 +4025,7 @@ App-shell creator dashboard: a branded Sidebar with an account footer (AppHeader
3943
4025
  ## Source
3944
4026
 
3945
4027
  ```tsx
3946
- import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, MenuItem, MenuSeparator, Meter, NotificationBadge, OverflowMenu, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, Stack } from "@sableui/react"
4028
+ import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, MenuItem, MenuSeparator, Meter, NotificationBadge, OverflowMenu, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarBrand, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, Stack } from "@sableui/react"
3947
4029
 
3948
4030
  // Edition status → Badge tone. Sable has no status vocabulary of its own;
3949
4031
  // an app maps ITS states onto tones once, here, and every badge follows.
@@ -4040,10 +4122,7 @@ export function DashboardPage() {
4040
4122
  sidebar={
4041
4123
  <Sidebar>
4042
4124
  <SidebarHeader>
4043
- <Row gap={1} align="center" className="px-3">
4044
- <Icon name="gem" variant="solid" className="text-xl" />
4045
- <span className="text-title-sm">Atelier</span>
4046
- </Row>
4125
+ <SidebarBrand mark={<Icon name="gem" variant="solid" />} label="Atelier" render={<a href="/" />} />
4047
4126
  </SidebarHeader>
4048
4127
  <SidebarNav>
4049
4128
  <SidebarItem icon={<Icon name="grid" />} label="Overview" active render={<a href="/" />} />
@@ -4188,7 +4267,7 @@ export function DashboardPage() {
4188
4267
  <span className="text-caption text-muted-foreground">
4189
4268
  Showing {editions.length} of {editionCount} editions
4190
4269
  </span>
4191
- <Button variant="ghost" size="sm" render={<a href="/editions" />}>
4270
+ <Button variant="ghost" size="sm" bleed="end" render={<a href="/editions" />}>
4192
4271
  View all editions
4193
4272
  <Icon name="arrow-right" />
4194
4273
  </Button>
@@ -4445,10 +4524,10 @@ const renderPost = (post: Post) => (
4445
4524
  </Meter>
4446
4525
  </Stack>
4447
4526
  ) : null}
4448
- {/* Pulled out by exactly a sm Button's own padding (px-3), so the
4449
- first icon lands on the text edge above it. */}
4450
- <Row gap={0.5} className="-ms-3">
4451
- <Button variant="ghost" size="sm" aria-label={`Like, ${post.likes} likes`}>
4527
+ {/* bleed="start" pulls the first button out by exactly its own
4528
+ padding, so its icon lands on the text edge above it. */}
4529
+ <Row gap={0.5}>
4530
+ <Button variant="ghost" size="sm" bleed="start" aria-label={`Like, ${post.likes} likes`}>
4452
4531
  <Icon name="heart" />
4453
4532
  {post.likes}
4454
4533
  </Button>
@@ -5365,6 +5444,7 @@ export function ListDetailPage() {
5365
5444
  ref={backRef}
5366
5445
  variant="ghost"
5367
5446
  size="sm"
5447
+ bleed="start"
5368
5448
  iconStart={<Icon name="chevron-left" />}
5369
5449
  onClick={backToList}
5370
5450
  className="self-start lg:hidden"
@@ -5549,7 +5629,7 @@ Account settings inside the app shell: a sticky SideNav section rail beside a co
5549
5629
 
5550
5630
  ```tsx
5551
5631
  import { useState } from "react"
5552
- import { AppHeader, AppShell, Avatar, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, Entity, Field, Form, Grid, Icon, Input, MenuItem, MenuSeparator, NotificationBadge, OverflowMenu, Page, PageHeader, Row, Select, SelectContent, SelectItem, SelectTrigger, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, SideNav, SideNavItem, Stack, Switch, Textarea } from "@sableui/react"
5632
+ import { AppHeader, AppShell, Avatar, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, Entity, Field, Form, Grid, Icon, Input, MenuItem, MenuSeparator, NotificationBadge, OverflowMenu, Page, PageHeader, Row, Select, SelectContent, SelectItem, SelectTrigger, Sidebar, SidebarBrand, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, SideNav, SideNavItem, Stack, Switch, Textarea } from "@sableui/react"
5553
5633
 
5554
5634
  const sections = [
5555
5635
  { id: "profile", label: "Profile" },
@@ -5611,10 +5691,7 @@ export function SettingsPage() {
5611
5691
  sidebar={
5612
5692
  <Sidebar>
5613
5693
  <SidebarHeader>
5614
- <Row gap={1} align="center" className="px-3">
5615
- <Icon name="gem" variant="solid" className="text-xl" />
5616
- <span className="text-title-sm">Atelier</span>
5617
- </Row>
5694
+ <SidebarBrand mark={<Icon name="gem" variant="solid" />} label="Atelier" render={<a href="/" />} />
5618
5695
  </SidebarHeader>
5619
5696
  <SidebarNav>
5620
5697
  <SidebarItem icon={<Icon name="grid" />} label="Overview" render={<a href="/" />} />
@@ -6930,9 +7007,15 @@ A now-playing card: cover, title and artist as an Entity with a Like action, a p
6930
7007
 
6931
7008
  ```tsx
6932
7009
  import { useState } from "react"
6933
- import { Button, Card, CardContent, cn, Entity, GradientArt, Icon, Slider } from "@sableui/react"
7010
+ import { Button, Card, CardContent, cn, Entity, Icon, MediaImage, Slider } from "@sableui/react"
6934
7011
 
6935
- const track = { title: "Low tide, high noon", artist: "Juno Avery", position: 38, elapsed: "1:24", remaining: "-2:16" }
7012
+ // `cover` is the artwork URL; left empty, the cover paints art seeded by the title.
7013
+ const track = { title: "Low tide, high noon", artist: "Juno Avery", cover: "", duration: 220, position: 84 }
7014
+
7015
+ /** Seconds → m:ss, the way the readout and the screen reader both say it. */
7016
+ function formatTime(seconds: number) {
7017
+ return `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`
7018
+ }
6936
7019
 
6937
7020
 
6938
7021
  export interface MediaPlayerBlockProps {
@@ -6947,6 +7030,7 @@ export interface MediaPlayerBlockProps {
6947
7030
 
6948
7031
  export function MediaPlayerBlock({ className, framed = true, onPlayingChange, onPrevious, onNext }: MediaPlayerBlockProps) {
6949
7032
  const [playing, setPlaying] = useState(true)
7033
+ const [position, setPosition] = useState(track.position)
6950
7034
  return (
6951
7035
  <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
6952
7036
  <CardContent className="flex flex-col gap-4">
@@ -6954,8 +7038,7 @@ export function MediaPlayerBlock({ className, framed = true, onPlayingChange, on
6954
7038
  card already pads it. */}
6955
7039
  <Entity
6956
7040
  className="p-0"
6957
- // Placeholder cover painted from the theme. Replace with the cover image.
6958
- leading={<GradientArt aria-hidden seed={track.title} className="size-14 rounded-md" />}
7041
+ leading={<MediaImage src={track.cover} seed={track.title} className="size-14 shrink-0 rounded-md" />}
6959
7042
  title={track.title}
6960
7043
  subtitle={track.artist}
6961
7044
  action={
@@ -6965,10 +7048,17 @@ export function MediaPlayerBlock({ className, framed = true, onPlayingChange, on
6965
7048
  }
6966
7049
  />
6967
7050
  <div className="flex flex-col gap-1.5">
6968
- <Slider defaultValue={track.position} aria-label="Position" />
7051
+ <Slider
7052
+ value={position}
7053
+ max={track.duration}
7054
+ onValueChange={setPosition}
7055
+ aria-label="Position"
7056
+ // Spoken as a time, not "38": the number alone means nothing.
7057
+ getAriaValueText={(_, value) => `${formatTime(value)} of ${formatTime(track.duration)}`}
7058
+ />
6969
7059
  <div className="flex justify-between text-mono-caption text-muted-foreground">
6970
- <span>{track.elapsed}</span>
6971
- <span>{track.remaining}</span>
7060
+ <span>{formatTime(position)}</span>
7061
+ <span>-{formatTime(track.duration - position)}</span>
6972
7062
  </div>
6973
7063
  </div>
6974
7064
  <div className="flex items-center justify-center gap-3">
@@ -7550,9 +7640,10 @@ A product card: edge-to-edge artwork, title and maker, an edition Badge, and the
7550
7640
  ## Source
7551
7641
 
7552
7642
  ```tsx
7553
- import { Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn, GradientArt } from "@sableui/react"
7643
+ import { AspectBox, Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn, MediaImage } from "@sableui/react"
7554
7644
 
7555
- const product = { title: "Untitled (gold rush)", maker: "Mara Okafor", edition: "12 of 50", price: "0.42 SOL" }
7645
+ // `image` is the product shot URL; left empty, it paints art seeded by the title.
7646
+ const product = { title: "Untitled (gold rush)", maker: "Mara Okafor", image: "", edition: "12 of 50", price: "0.42 SOL" }
7556
7647
 
7557
7648
 
7558
7649
  export interface ProductCardBlockProps {
@@ -7566,8 +7657,9 @@ export interface ProductCardBlockProps {
7566
7657
  export function ProductCardBlock({ className, framed = true, onCollect }: ProductCardBlockProps) {
7567
7658
  return (
7568
7659
  <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
7569
- {/* Placeholder art painted from the theme, so it follows every look. Replace with the product image. */}
7570
- <GradientArt aria-hidden seed={product.title} className="aspect-[4/3]" />
7660
+ <AspectBox ratio={4 / 3}>
7661
+ <MediaImage src={product.image} seed={product.title} />
7662
+ </AspectBox>
7571
7663
  <CardHeader>
7572
7664
  <CardTitle>{product.title}</CardTitle>
7573
7665
  <CardDescription>{product.maker}</CardDescription>
@@ -8327,7 +8419,7 @@ export function TaskListBlock({ className, framed = true, onAddTask }: TaskListB
8327
8419
  </ul>
8328
8420
  </CardContent>
8329
8421
  <CardFooter>
8330
- <Button variant="ghost" size="sm" iconStart={<Icon name="plus" />} onClick={onAddTask}>
8422
+ <Button variant="ghost" size="sm" bleed="start" iconStart={<Icon name="plus" />} onClick={onAddTask}>
8331
8423
  Add task
8332
8424
  </Button>
8333
8425
  </CardFooter>
@@ -8566,7 +8658,7 @@ export function TrafficSourcesBlock({ className, framed = true }: TrafficSources
8566
8658
  index="source"
8567
8659
  valueKey="visitors"
8568
8660
  donut
8569
- centerLabel={{ value: total.toLocaleString("en-US"), label: "Visitors" }}
8661
+ centerLabel={{ value: total, label: "Visitors" }}
8570
8662
  />
8571
8663
  </CardContent>
8572
8664
  <CardFooter className="text-body-sm text-muted-foreground">
@@ -8637,11 +8729,13 @@ export function TrafficTrendBlock({ className, framed = true }: TrafficTrendBloc
8637
8729
  const [range, setRange] = useState("90d")
8638
8730
  const shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))
8639
8731
  return (
8640
- <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-3xl", className)}>
8641
- <CardHeader>
8732
+ <Card variant={framed ? "default" : "flat"} className={cn("@container/trend w-full max-w-3xl", className)}>
8733
+ <CardHeader className="@max-sm/trend:has-data-[slot=card-action]:grid-cols-1">
8642
8734
  <CardTitle render={<h2 />}>Visitors</CardTitle>
8643
8735
  <CardDescription>Desktop and mobile, by day.</CardDescription>
8644
- <CardAction>
8736
+ {/* Beside the title on a wide card; under it on a narrow one, where three
8737
+ ranges would squeeze the title and description into a sliver. */}
8738
+ <CardAction className="@max-sm/trend:col-start-1 @max-sm/trend:row-span-1 @max-sm/trend:row-start-auto @max-sm/trend:mt-2 @max-sm/trend:justify-self-start">
8645
8739
  <ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label="Time range">
8646
8740
  {ranges.map((r) => (
8647
8741
  <Toggle key={r.id} value={r.id}>
@@ -8797,7 +8891,7 @@ export function VerifyCodeBlock({ className, framed = true, onVerify, onResend }
8797
8891
  </CardContent>
8798
8892
  <CardFooter className="justify-between gap-3">
8799
8893
  <span className="text-caption text-muted-foreground">Didn’t get it?</span>
8800
- <Button variant="ghost" size="sm" onClick={onResend}>
8894
+ <Button variant="ghost" size="sm" bleed="end" onClick={onResend}>
8801
8895
  Resend code
8802
8896
  </Button>
8803
8897
  </CardFooter>
@@ -8909,9 +9003,14 @@ Use Region as the page-width primitive that applies fluid page inset and an opti
8909
9003
 
8910
9004
  ### Best practices
8911
9005
 
8912
- - Do: Choose the max value by content role, such as prose, form, feed, content, wide, or full.
9006
+ - Do: Choose the max value by what the person does on the page: read (prose), fill in a form (form), scroll one stream (feed), review one record or settings (content), scan and compare rows or cards (wide).
9007
+ - Do: Omit max (fluid) for work across panes, a board, an editor or a map, where a flexible part uses the extra width; cap text inside it with --measure-prose.
9008
+ - Do: Give sibling routes in one nav the same width so the header does not jump between them.
9009
+ - Do: Review a capped or fluid route at 1920px and 2560px: a cap is a ceiling, so below the cap plus app chrome every choice looks the same.
8913
9010
  - Do: Use Region near the route or section boundary so inner layout stays intentional.
8914
9011
  - Don't: Do not use Region as a global wrapper that forces every page into the same max width.
9012
+ - Don't: Do not go fluid when the extra width only widens the gaps between fixed columns; cap it.
9013
+ - Don't: Do not read full as edge to edge: it is the largest cap and keeps the page inset. Use bleed for edge to edge.
8915
9014
 
8916
9015
  ## Props
8917
9016
 
@@ -8919,7 +9018,7 @@ Use Region as the page-width primitive that applies fluid page inset and an opti
8919
9018
  | --- | --- | --- | --- |
8920
9019
  | as | ElementType | "div" | Semantic element to render (e.g. "main", "section", "nav"). |
8921
9020
  | inset | boolean | true | Apply the fluid horizontal page inset (--page-inset). |
8922
- | max | "prose" \| "form" \| "feed" \| "content" \| "wide" \| "full" \| CSS length | — | Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (full-bleed ceiling). Omit for fully fluid. |
9021
+ | max | "prose" \| "form" \| "feed" \| "content" \| "wide" \| "full" \| CSS length | — | Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (the largest cap; keeps the inset, so not edge to edge — that is bleed). Omit for fluid: work views whose panes, board or canvas use the extra width. A cap is a ceiling: it applies only once the viewport minus app chrome exceeds it, so judge it at 1920px and up. |
8923
9022
  | bleed | boolean | false | Full-bleed: edge to edge with no inset. Overrides inset. |
8924
9023
  | safeArea | boolean \| ("top"\|"bottom"\|"left"\|"right")[] | false | env(safe-area-inset-*) padding for fixed or native-shell surfaces. true pads top+bottom; pass an edge array to target edges. |
8925
9024
 
@@ -8937,7 +9036,7 @@ import { PageHeader, Region } from "@sableui/react"
8937
9036
  <Region as="main" max="content"><PageHeader title="Settings" /></Region>
8938
9037
  ```
8939
9038
 
8940
- ### Fluid dashboard region
9039
+ ### Dashboard region (capped at wide)
8941
9040
 
8942
9041
  ```tsx
8943
9042
  import { Card, CardHeader, CardTitle, Grid, Region } from "@sableui/react"
@@ -8945,6 +9044,14 @@ import { Card, CardHeader, CardTitle, Grid, Region } from "@sableui/react"
8945
9044
  <Region max="wide"><Grid minItemWidth="16rem"><Card><CardHeader><CardTitle>Revenue</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Collects</CardTitle></CardHeader></Card></Grid></Region>
8946
9045
  ```
8947
9046
 
9047
+ ### Fluid workspace: the queue's table takes the extra width
9048
+
9049
+ ```tsx
9050
+ import { Card, CardContent, Col, Columns, DataTable, Region, SideNav, SideNavItem } from "@sableui/react"
9051
+
9052
+ <Region as="main"><Columns count={{ base: 1, lg: 12 }}><Col span={{ base: 1, lg: 3 }}><SideNav aria-label="Views"><SideNavItem label="Open" active /><SideNavItem label="Pending" /></SideNav></Col><Col span={{ base: 1, lg: 9 }}><Card><CardContent flush><DataTable columns={columns} data={tickets} /></CardContent></Card></Col></Columns></Region>
9053
+ ```
9054
+
8948
9055
  ---
8949
9056
 
8950
9057
  # Page
@@ -8993,7 +9100,7 @@ import { Card, CardContent, Page, PageHeader } from "@sableui/react"
8993
9100
 
8994
9101
  **Status:** stable
8995
9102
 
8996
- CSS Grid within a region. Two modes: fixed/responsive cols count, or minItemWidth auto-fit so cells breathe with NO breakpoints (the default choice for card grids).
9103
+ CSS Grid within a region. Two modes: fixed/responsive cols count, or minItemWidth so cells breathe with NO breakpoints (the default choice for card grids). A minItemWidth grid keeps its empty columns (track="fill"), so one result stays one cell wide; track="fit" stretches a fixed set across the row.
8997
9104
 
8998
9105
  **Keywords:** grid, card grid, auto fit, layout
8999
9106
 
@@ -9004,16 +9111,20 @@ Use Grid for uniform grid layouts inside a Region, especially card grids that ca
9004
9111
  ### Best practices
9005
9112
 
9006
9113
  - Do: Prefer minItemWidth for responsive card grids that do not need breakpoint-specific spans.
9114
+ - Do: Keep the default track="fill" for collections whose count is data (search results, shelves, galleries): sparse rows keep their empty columns, so one result stays one cell wide.
9115
+ - Do: Use track="fit" only for a fixed set designed to span the row, such as three KPI cards.
9007
9116
  - Do: Use explicit columns only when the design truly needs a fixed track count.
9008
9117
  - Don't: Do not use Grid to set a page's reading width; use Region or Center.
9118
+ - Don't: Do not use track="fit" for results or other data-driven collections: with one or two items the empty columns collapse and each item stretches across the row (a single result rendered as a full-width cover).
9009
9119
 
9010
9120
  ## Props
9011
9121
 
9012
9122
  | Name | Type | Default | Description |
9013
9123
  | --- | --- | --- | --- |
9014
9124
  | cols | number \| { base?, sm?, md?, lg?, xl?, "2xl"? } | 12 | Fixed column count or a mobile-first breakpoint map ({ base: 2, md: 4 }). Ignored when minItemWidth is set. |
9015
- | minItemWidth | string | — | Auto-fit mode: repeat(auto-fit, minmax(minItemWidth, 1fr)) — the grid grows/wraps responsively without breakpoints. e.g. "16rem". |
9016
- | gap | number \| string | var(--grid-gutter) | Gap between cells. Number = rhythm units (×8px); string = raw CSS. |
9125
+ | minItemWidth | string | — | Breathing mode: repeat(auto-fill, minmax(minItemWidth, 1fr)) — as many columns of at least minItemWidth as fit, wrapping without breakpoints. e.g. "16rem". |
9126
+ | track | "fill" \| "fit" | "fill" | What a minItemWidth grid does with columns it has no items for (only visible when fewer items than fit one row). "fill" (auto-fill) keeps them, so items keep their size whatever their count — one search result is one cover wide, not a banner. Use it for collections whose count is data: results, shelves, galleries. "fit" (auto-fit) collapses them and stretches the items to share the row — only for a fixed set designed to span it (three KPI cards, plan tiers). |
9127
+ | gap | number \| [row, column] \| string | var(--grid-gutter) | Gap between cells. Number = rhythm units (×8px); [row, column] = two rhythm counts, for a gallery whose rows want more air than its columns; string = raw CSS. |
9017
9128
 
9018
9129
  ## Related
9019
9130
 
@@ -9029,6 +9140,23 @@ import { Card, CardHeader, CardTitle, Grid } from "@sableui/react"
9029
9140
  <Grid minItemWidth="16rem" gap={3}><Card><CardHeader><CardTitle>Alpha</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Beta</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Gamma</CardTitle></CardHeader></Card></Grid>
9030
9141
  ```
9031
9142
 
9143
+ ### Sparse results keep their size
9144
+
9145
+ ```tsx
9146
+ import { Card, CardDescription, CardHeader, CardTitle, Grid } from "@sableui/react"
9147
+
9148
+ // One match still renders one cover wide: the empty columns are kept (track="fill", the default).
9149
+ <Grid minItemWidth="10rem" gap={2}><Card><CardHeader><CardTitle>Night Swim</CardTitle><CardDescription>Album · Undertow</CardDescription></CardHeader></Card></Grid>
9150
+ ```
9151
+
9152
+ ### A fixed set that spans the row
9153
+
9154
+ ```tsx
9155
+ import { Card, CardDescription, CardHeader, Grid } from "@sableui/react"
9156
+
9157
+ <Grid minItemWidth="12rem" track="fit" gap={2}><Card><CardHeader><CardDescription>Listeners</CardDescription><span className="text-mono-xl">48.2k</span></CardHeader></Card><Card><CardHeader><CardDescription>Streams</CardDescription><span className="text-mono-xl">1.3M</span></CardHeader></Card><Card><CardHeader><CardDescription>Saves</CardDescription><span className="text-mono-xl">9,410</span></CardHeader></Card></Grid>
9158
+ ```
9159
+
9032
9160
  ### Responsive fixed columns
9033
9161
 
9034
9162
  ```tsx
@@ -9634,9 +9762,9 @@ import { AppHeader, AppShell, PageHeader, Region } from "@sableui/react"
9634
9762
 
9635
9763
  **Status:** stable
9636
9764
 
9637
- The application frame: slots for status banners, a mobile header, a desktop sidebar, main content, a mobile bottom nav, and floating overlays — plus a built-in skip link and #main-content target.
9765
+ The application frame: slots for status banners, a mobile header, a desktop sidebar, main content, a mobile bottom nav, a persistent dock (a now-playing or call bar), and floating overlays — plus a built-in skip link and #main-content target.
9638
9766
 
9639
- **Keywords:** app shell, layout, frame, sidebar, header, bottom nav, mobile, banners, main
9767
+ **Keywords:** app shell, layout, frame, sidebar, header, bottom nav, mobile, banners, main, dock, player bar, now playing, persistent
9640
9768
 
9641
9769
  ## Usage
9642
9770
 
@@ -9660,6 +9788,7 @@ Use AppShell as the outer product frame for authenticated or app-like experience
9660
9788
  | Sidebar | no | Desktop navigation chrome, usually Sidebar. |
9661
9789
  | Main | yes | The route content landmark with id main-content. |
9662
9790
  | Bottom nav | no | Mobile fixed navigation with reserved bottom space. |
9791
+ | Dock | no | Persistent bottom chrome that outlives routes — a now-playing bar, a call bar. Sticky above the bottom nav; the desktop Sidebar shortens to clear it. |
9663
9792
 
9664
9793
  ## Props
9665
9794
 
@@ -9669,6 +9798,7 @@ Use AppShell as the outer product frame for authenticated or app-like experience
9669
9798
  | header | ReactNode | — | Mobile top header (e.g. AppHeader); expected to hide itself at lg. |
9670
9799
  | sidebar | ReactNode | — | Desktop side navigation (e.g. Sidebar); expected to hide itself below lg. Leading column of the body row. |
9671
9800
  | bottomNav | ReactNode | — | Mobile bottom navigation (e.g. BottomNav); fixed to the bottom edge. main reserves space for it. |
9801
+ | dock | ReactNode | — | Persistent chrome kept on screen across routes (a now-playing bar, an active-call bar, an upload tray). Full width, sticky to the bottom edge and above bottomNav on mobile; its height is published as --dock-height and the app Sidebar subtracts it. In flow, so main needs no reserved space. The contents paint their own surface and top border, and name themselves (e.g. <section aria-label="Player">). Mount AppShell in a layout that survives navigation, or the playback restarts. |
9672
9802
  | overlays | ReactNode | — | Floating overlays — a message launcher, FAB, or modal mount points. |
9673
9803
 
9674
9804
  ## Tokens used
@@ -9680,6 +9810,7 @@ Use AppShell as the outer product frame for authenticated or app-like experience
9680
9810
  - Renders a skip link to #main-content as the first focusable element.
9681
9811
  - Wraps content in a <main id="main-content"> landmark.
9682
9812
  - Slot components own their own ARIA (nav landmarks, labels).
9813
+ - The dock follows <main> in DOM order, so keyboard and screen-reader users reach the player after the page content, not before it.
9683
9814
 
9684
9815
  ## Related
9685
9816
 
@@ -9990,11 +10121,12 @@ Use Avatar to represent a person, team, or account with a compact visual identit
9990
10121
  - Do: Use `variant="art"` where people without photos sit side by side — a member list, an activity feed, a chat. Seeded by the person, each gets their own colour, so faces stay tellable apart at a glance; the neutral fallback makes everyone the same grey circle.
9991
10122
  - Do: Provide meaningful alt text for profile images, or an empty alt when the adjacent text already names the person.
9992
10123
  - Do: Use initials or a simple glyph fallback so missing images still communicate identity.
9993
- - Do: Use consistent sizes within a group; reserve lg for profile headers or major identity moments.
10124
+ - Do: Pick the size by the job: sm in dense rows and tables, md (the default) beside one line of text, lg in a row with a title and subtitle, xl (96px) for a profile header beside the person's name, 2xl (160px) for an artist or profile hero and roster portraits. Keep one size within a list or group.
9994
10125
  - Do: Use AvatarGroup for a stack of people, and give it an aria-label with the real count ("12 collaborators"). Set `surface` (or `--avatar-group-ring`) to whatever the group sits on, so the separating ring matches a card or popover, not the page. When it shares a row with actions, set `fit` so it gives up avatars to the "+N" count before the row has to wrap.
9995
10126
  - Don't: Hand-build a stack with negative margins and per-avatar ring classes, or fake the overflow as an Avatar with "+4" initials — screen readers hear "plus 4" with no context. Use AvatarGroup's `max`/`total`, or AvatarGroupCount.
9996
10127
  - Don't: Use avatars as decoration without nearby identity context. They should help users recognize a person or account.
9997
10128
  - Don't: Overload a dense table with large avatars; use sm or omit images when scanning data is more important.
10129
+ - Don't: Build a portrait by stretching an Avatar with size classes (`className="size-40"`) — the initials, the status indicator and the image fade stay at row scale. Use size="xl" or "2xl", which scale all three. Past 160px, or for a non-circular cover, it is media: an AspectBox, not an avatar.
9998
10130
 
9999
10131
  ### Anatomy
10000
10132
 
@@ -10025,19 +10157,26 @@ Use Avatar to represent a person, team, or account with a compact visual identit
10025
10157
  | fallback | ReactNode | — | Initials or a glyph rendered when no image is displayed. |
10026
10158
  | fallbackDelay | number | — | Delay before showing the fallback, in ms — avoids a flash on fast loads. |
10027
10159
  | status | ReactNode | — | A presence indicator pinned to the bottom-end edge, usually <StatusDot tone="success" />. It sits outside the clipped circle and wears a ring in the surface colour (--avatar-group-ring, else the page background). Use this instead of absolutely positioning a dot over an Avatar. |
10028
- | size | "sm" \| "md" \| "lg" | "md" | Square footprint: 32px / 40px / 48px. |
10160
+ | size | "sm" \| "md" \| "lg" \| "xl" \| "2xl" | "md" | Square footprint: 32px / 40px / 48px for rows; 96px / 160px for portraits (a profile header, an artist hero, a roster tile). The initials, a status indicator and its cut-out ring scale with it, and at the portrait sizes the photo fades in over the resting surface. |
10029
10161
  | variant | "neutral" \| "art" | "neutral" | What the fallback looks like. neutral: initials on the muted surface. art: initials over GradientArt seeded by the person, so each person without a photo gets their own stable colour from the theme. |
10030
10162
  | seed | string \| number | — | The art variant's seed. Defaults to the fallback text, then `alt`, so a person keeps one colour wherever they appear; pass a user id to tell apart two people who share initials. |
10031
10163
 
10032
10164
  ## Variants
10033
10165
 
10034
- - **size**: sm, md, lg
10166
+ - **size**: sm, md, lg, xl, 2xl
10035
10167
  - **variant**: neutral, art
10036
10168
 
10037
10169
  ## Tokens used
10038
10170
 
10039
10171
  `muted`, `muted-foreground`, `background`, `card`, `popover`, `overlay`, `overlay-foreground`
10040
10172
 
10173
+ ## Motion
10174
+
10175
+ - **arrival** · Image · `motion-fade` — At the portrait sizes (xl, 2xl) the photo replacing the initials is a visible pop across one of the page's largest elements; fading it in reads as the picture arriving, not the layout glitching. Row sizes swap instantly — too small to register, and a list of fading faces would shimmer.
10176
+ - **response** · Root · _no motion_ — An avatar is not interactive itself; a link or button wrapping it owns the response.
10177
+ - **transition** · Root · _no motion_ — It has no states of its own to move between beyond the image arriving.
10178
+ - **activity** · Root · _no motion_ — Loading shows the fallback, which is already a finished resting state rather than a spinner.
10179
+
10041
10180
  ## Accessibility
10042
10181
 
10043
10182
  - Base UI tracks the image loading status and swaps to the fallback on missing/loading/error — no manual onError wiring.
@@ -10087,6 +10226,20 @@ import { Avatar } from "@sableui/react"
10087
10226
  <Avatar src="/pfp.png" alt="Alex Morgan" fallback="AM" size="lg" />
10088
10227
  ```
10089
10228
 
10229
+ ### Profile portrait
10230
+
10231
+ ```tsx
10232
+ import { Avatar, StatusDot } from "@sableui/react"
10233
+
10234
+ <div className="flex flex-col items-center gap-3 text-center">
10235
+ <Avatar variant="art" size="2xl" fallback="LV" alt="Lena Vale" status={<StatusDot tone="success" />} />
10236
+ <div className="flex flex-col gap-1">
10237
+ <h1 className="text-heading-1">Lena Vale</h1>
10238
+ <p className="text-body-sm text-muted-foreground">1.2M monthly listeners</p>
10239
+ </div>
10240
+ </div>
10241
+ ```
10242
+
10090
10243
  ### Low-level parts
10091
10244
 
10092
10245
  ```tsx
@@ -10864,7 +11017,7 @@ import { Bubble, Message } from "@sableui/react"
10864
11017
 
10865
11018
  Pill-shaped action primitive (theme-driven radius via --button-radius, defaults to full). Neutral tiers (primary/secondary/ghost/outline), plus destructive and link, and three on-color tiers (on-color/on-color-outline/on-color-ghost) that paint with the surrounding ink so a button reads on any tone or brand fill. Optional loading state shows a spinner and disables the button.
10866
11019
 
10867
- **Keywords:** button, action, cta, submit, save, cancel, destructive, loading, link, icon button, on color, inverse, band, brand fill, tone fill
11020
+ **Keywords:** button, action, cta, submit, save, cancel, destructive, loading, link, icon button, on color, inverse, band, brand fill, tone fill, bleed, optical alignment, align label to edge, negative margin, view all
10868
11021
 
10869
11022
  ## Usage
10870
11023
 
@@ -10876,7 +11029,9 @@ Use Button when the user commits an action, submits a form, opens UI, or confirm
10876
11029
  - Do: Write labels as actions, such as "Save changes" or "Delete draft", instead of vague labels like "OK".
10877
11030
  - Do: Use loading with loadingLabel for async work so the action cannot double-fire and the state is announced.
10878
11031
  - Do: Put icons in iconStart / iconEnd rather than as children, so the button applies its optical padding. Start icons say what the action is (plus, share); end icons say where it goes (chevron-right, external).
11032
+ - Do: Set bleed on a ghost or link button that sits at the start or end of a row, so its LABEL lines up with the content edge the row shares ("View all" over a grid, "Add task" under a list, "Resend code" at a card footer's end). The box moves out by exactly its own padding; the hit area and hover fill stay full size and run past the edge. SectionHeader, PageHeader (back and actions) and CardAction already do this for their outermost ghost/link child — don't add it there, and use bleed="none" to opt one out.
10879
11033
  - Do: On a coloured fill — a solid Banner, a bg-tone-* or bg-primary band, an inverse (bg-foreground) section, a brand panel — use variant="on-color" for the main action and on-color-outline / on-color-ghost for the rest. They paint with the band's inherited ink (currentColor), so the surface must set its paired ink (text-tone-info-foreground, text-primary-foreground, text-background…), which it already should. Name the fill once on the surface, e.g. [--on-color-fill:var(--tone-info)], and the solid button's label takes the fill colour; unnamed, it falls back to black or white against the ink. Both clear 4.5:1 in every shipped theme and mode.
11034
+ - Don't: Pull a ghost button onto an edge with -ms-3 / -me-3 classNames. That number is one size's padding: it is wrong at xs, md and cta, wrong on a side carrying an icon, and wrong once a preset retunes the spacing. bleed reads the button's own padding var. And don't bleed a filled or outlined button — its visible edge is what aligns, so bleed is a no-op there.
10880
11035
  - Don't: Use destructive for reversible or low-risk actions; reserve it for operations with real loss or danger.
10881
11036
  - Don't: Use a button for plain navigation. Compose with render={<a />} when the element should behave like a link.
10882
11037
  - Don't: Use secondary on a tinted surface — inside a Note, a soft Banner, or any tone fill. `secondary` is a near-background surface token (in some themes within 2% of the page), so on a tint it reads as a hole punched in the fill rather than as a control sitting on it. On a soft tint use outline or ghost; on a full-strength fill use the on-color tiers.
@@ -10903,6 +11058,7 @@ Use Button when the user commits an action, submits a form, opens UI, or confirm
10903
11058
  | loadingLabel | ReactNode | — | Optional text shown in place of children while loading. |
10904
11059
  | iconStart | ReactNode | — | Icon before the label. The button takes one 2px step off its start padding so icon + label read as centred. Replaced by the spinner while loading. |
10905
11060
  | iconEnd | ReactNode | — | Icon after the label (disclosure, external link, next). Same optical correction on the end side. |
11061
+ | bleed | "start" \| "end" \| "both" \| "none" | — | Optical alignment for a borderless button (ghost, link, on-color-ghost): a negative inline margin of exactly its own padding on that side — --button-px, or the icon side's optical padding when an icon sits there; an icon-only size bleeds by its inset to the glyph — so the label (or glyph) lands on the content edge. Hit area and hover/focus fill keep their full size. Logical sides, so it mirrors in RTL. A no-op on variants with a visible edge (primary, secondary, outline, destructive, floating, field, on-color, on-color-outline): a filled button aligns its edge, not its label. Unset, a borderless button renders data-bleed="auto" and SectionHeader, PageHeader and CardAction bleed it when it is their outermost child; none opts out. |
10906
11062
 
10907
11063
  ## Variants
10908
11064
 
@@ -10928,6 +11084,7 @@ Use Button when the user commits an action, submits a form, opens UI, or confirm
10928
11084
  - Use render={<a/>} for link semantics rather than onClick navigation.
10929
11085
  - On-color tiers replace the --ring focus ring with one drawn in the band's ink past a transparent 2px gap, so focus stays visible on any fill (ink-on-fill is the theme's own tested pair).
10930
11086
  - On-color contrast is measured in button.test.tsx for every shipped theme and mode on each tone, primary, destructive, success, highlight and inverse fill: solid label at rest and hover, and outline/ghost label on the hover wash, all at least 4.5:1.
11087
+ - bleed moves only the box, never the hit target: the full padded area stays clickable and the focus ring still draws around the whole button, past the content edge.
10931
11088
  - loading sets aria-busy and disables activation (disabled on a native button, aria-disabled on a render element) so the action can't double-fire.
10932
11089
 
10933
11090
  ## Related
@@ -11034,6 +11191,21 @@ import { Button, Icon } from "@sableui/react"
11034
11191
  <Button size="icon" variant="ghost" aria-label="Share"><Icon name="share" /></Button>
11035
11192
  ```
11036
11193
 
11194
+ ### Optically aligned to the row's edges
11195
+
11196
+ ```tsx
11197
+ import { Button, Icon, Row, Stack } from "@sableui/react"
11198
+
11199
+ <Stack gap={1.5}>
11200
+ <Row justify="between">
11201
+ <span className="text-title-md">Tasks</span>
11202
+ <Button variant="ghost" size="sm" bleed="end">View all</Button>
11203
+ </Row>
11204
+ <span className="text-body-sm text-muted-foreground">Ship the release notes</span>
11205
+ <Button variant="ghost" size="sm" bleed="start" iconStart={<Icon name="plus" />} className="self-start">Add task</Button>
11206
+ </Stack>
11207
+ ```
11208
+
11037
11209
  ### Icon button with an unread badge
11038
11210
 
11039
11211
  ```tsx
@@ -11188,7 +11360,7 @@ Use Card for a discrete thing with its own boundary, such as one product, one no
11188
11360
  | CardHeader | ComponentPropsWithRef<"div"> | Top section holding CardTitle/CardDescription (and optionally CardAction). Padding follows the density knob. |
11189
11361
  | CardTitle | ComponentPropsWithRef<"div"> & { render?: useRender.RenderProp } | The card's name (text-title-lg). A div by default; pass `render={<h2 />}` (or h3…) to make it a heading in the page outline, at the level the card's position calls for. |
11190
11362
  | CardDescription | ComponentPropsWithRef<"div"> | Muted supporting line under the title. |
11191
- | CardAction | ComponentPropsWithRef<"div"> | Header-aligned top-right action slot (e.g. an overflow menu, switch, or close button). Place inside CardHeader, which becomes a 2-column grid when present and collapses to a single column otherwise. |
11363
+ | CardAction | ComponentPropsWithRef<"div"> | Header-aligned top-right action slot (e.g. an overflow menu, switch, or close button). Place inside CardHeader, which becomes a 2-column grid when present and collapses to a single column otherwise. A ghost or link Button that ends the slot bleeds by its own padding at the end, so a "View all" label or a "⋯" glyph sits on the card's content edge; filled buttons keep their edge there. |
11192
11364
  | CardContent | ComponentPropsWithRef<"div"> & { flush?: boolean } | The body region. Padding follows the density knob on all sides — does not assume a preceding CardHeader, so it is safe to use standalone (e.g. no CardHeader in the card). When paired with CardHeader, the seam stays tight because CardHeader zeroes its own bottom padding. `flush` runs it edge to edge for a table: the Table insets its first and last columns by exactly the card padding, so it lines up with the header — never hand-set pl-6/pr-6 on columns. |
11193
11365
  | CardList | ComponentPropsWithRef<"div"> | Rows that run edge to edge in the card (Entity rows, most often), divided by hairlines. Insets each row to the card's own padding, so a list card lines up with every other card at any density — no hand-tuned px-* on the rows. Under a CardHeader it opens with a hairline and a gap; alone it fills the card. |
11194
11366
  | CardFooter | ComponentPropsWithRef<"div"> & { actions?: boolean } | Bottom row for actions/meta. Padding follows the density knob. `actions` marks it as the card's action row (buttons only): in a sheet (IosSheet, a bottom SheetContent) it becomes a full-width stack pinned to the bottom, primary on top; on a page nothing changes. |
@@ -12973,6 +13145,177 @@ import { AlertDialog, AlertDialogClose, AlertDialogContent, AlertDialogDescripti
12973
13145
 
12974
13146
  ---
12975
13147
 
13148
+ # DisclosurePicker
13149
+
13150
+ **Status:** stable
13151
+
13152
+ A property row that names the current value and opens its list in place, beneath the row, instead of in a popup — for a tool panel or settings column where the choice is made while looking at what it changes. Built on Collapsible; the list is a listbox — arrow keys move, Enter or a click picks, applies and closes — and an optional search field filters long lists.
13153
+
13154
+ **Keywords:** disclosure picker, inline picker, in-place select, expanding list, property row, font picker, preset picker, inspector, picker row
13155
+
13156
+ ## Usage
13157
+
13158
+ Use DisclosurePicker for one-of-many choices in a docked panel or inspector where a popup would cover the preview the choice is about: the row shows the current value (in its own face, with a tile, as the content warrants), and the list opens beneath it, pushing the panel down. Use Select for a form field or anywhere a popup is fine; use Combobox when people type to find an option among hundreds; use RadioGroup or RadioCard when every option should stay visible. Nothing applies while arrowing through the list — only Enter, Space or a click picks — so it suits choices with a cost (a confirm, a font load, a reset); where each option should apply as focus lands on it, that is a radio group (ThemeSelector, RadioGroup).
13159
+
13160
+ ### Best practices
13161
+
13162
+ - Do: Enable search (`search` on DisclosurePickerPanel) only for long lists — roughly a dozen options or more, i.e. once the list scrolls. A short list (a handful of presets) reads faster with no field to skip past; the panel then has no search row and no gap.
13163
+ - Do: Show the value as the thing it is: a font row renders the family in its own face, a theme row leads with a ThemePreview tile in `media`. The row is a preview of the choice, not a label for it.
13164
+ - Do: Use `size="xs"` in a property grid (a compact Density / PanelSection), where it sits on the inset panel-field fill beside ScrubField and an xs SelectTrigger. Keep `md` in a settings column or a simple panel.
13165
+ - Do: When several rows share a panel, control `open` from the parent so only one list is open at a time — two open lists push each other and the preview out of view.
13166
+ - Do: Use it for a choice with a cost — one that opens a confirm, loads something, or resets other settings. Arrow keys only move focus; onValueChange fires once, on Enter, Space or a click, and only when the pick differs from `value`.
13167
+ - Do: Give options a leading visual with `media` (a ThemePreview tile, a swatch) instead of nesting another component's list: ThemeSelector's radio list applies each theme as the arrow keys land on it, which defeats commit-on-Enter. A list the picker cannot express (a Tree) can still close the row with `useDisclosurePicker().close()`.
13168
+ - Do: Render DisclosurePickerEmpty in place of the list when a search matches nothing, not inside it — a listbox holds only options.
13169
+ - Don't: Use it in a page-register form. A form field that pushes the form down on open reads as a layout jump; that is Select's job.
13170
+ - Don't: Rebuild the row from a Collapsible with hand-copied classes. The disclosure, the well, the hover step, the xs field shell, focus return and scroll-into-view all live here; two hand copies of this row in one panel is how it came to exist.
13171
+ - Don't: Turn search on for a short list just because the other rows have it. A search field over six options is a control with nothing to do.
13172
+
13173
+ ### Anatomy
13174
+
13175
+ | Part | Required | Description |
13176
+ | --- | --- | --- |
13177
+ | DisclosurePicker | yes | Root. Owns open state, size, focus return and scroll-into-view. |
13178
+ | DisclosurePickerTrigger | yes | The row: optional `label` caption, optional `media`, the value, and a chevron that flips when open. |
13179
+ | DisclosurePickerPanel | yes | What opens beneath the row: the opt-in search field, then the list. |
13180
+ | DisclosurePickerList | no | A listbox of options that scrolls past 16rem. Arrow keys move; Enter, Space or a click picks. |
13181
+ | DisclosurePickerOption | no | One option row, with optional leading media and trailing note. Chosen, it lifts out of the well; focused, it takes the ring. |
13182
+ | DisclosurePickerEmpty | no | The no-match line, rendered in place of the list when a search filters everything out. |
13183
+
13184
+ ## Subcomponents
13185
+
13186
+ | Name | Type | Description |
13187
+ | --- | --- | --- |
13188
+ | DisclosurePickerTrigger | { label?: ReactNode; media?: ReactNode; children?: ReactNode } + Base UI Collapsible.Trigger props | The row. `label` is a muted leading caption on a fixed column so stacked rows align; `media` a leading visual (a ThemePreview tile); children the value, truncated on one line. ArrowDown opens it, and steps into the list once open. |
13189
+ | DisclosurePickerPanel | { search?: { value: string; onValueChange(value: string): void; placeholder?: string; label?: string } } + Collapsible.Panel props | Opens beneath the row. `search` opts in to a SearchInput above the list (off by default); it is controlled because the consumer filters, and it is cleared when the row closes. Escape clears a query first, then closes; ArrowDown in the field steps into the list. |
13190
+ | DisclosurePickerList | { value: string; onValueChange(value: string): void; aria-label?: string } + div props | A listbox. ArrowUp/ArrowDown, Home/End and type-ahead move focus without applying anything (ArrowUp from the first option returns to the search field); Enter, Space or a click picks the focused option — onValueChange fires once, only when it differs from `value` — and closes the row. Escape closes without a pick. |
13191
+ | DisclosurePickerOption | { value: string; children?: ReactNode; media?: ReactNode; note?: ReactNode; disabled?: boolean; textValue?: string } | One option row. `media` is a leading visual (a ThemePreview tile); `note` a muted trailing annotation — options without one show a check when chosen. `disabled` skips it for the keyboard and refuses the pick; `textValue` is what type-ahead matches when the name is not plain text. |
13192
+ | DisclosurePickerEmpty | { children?: ReactNode } | The no-match line, rendered in place of the list. |
13193
+ | useDisclosurePicker | () => { open: boolean; close(): void; size: "md" \| "xs" } | For a list the picker does not own (a Tree) that should close the row when something is picked. |
13194
+
13195
+ ## Props
13196
+
13197
+ | Name | Type | Default | Description |
13198
+ | --- | --- | --- | --- |
13199
+ | open | boolean | — | Controlled open state. Pair with onOpenChange. |
13200
+ | defaultOpen | boolean | false | Uncontrolled initial open state. |
13201
+ | onOpenChange | (open: boolean) => void | — | Called with the next open state — from the trigger, a pick, or Escape. |
13202
+ | size | "md" \| "xs" | "md" | md is a --control-md row (40px comfortable) in a settings column, its own well when closed. xs is the property-grid rung: a --control-xs row on the inset panel-field fill with ui-tier type. Height, padding, radius, type and surface move together. |
13203
+ | className | string | — | Merged onto the root (layout only — the look comes from size). |
13204
+
13205
+ ## Tokens used
13206
+
13207
+ `foreground`, `muted-foreground`, `popover`, `hairline`, `panel-field`, `panel-field-hover`, `ring`, `accent`
13208
+
13209
+ ## Motion
13210
+
13211
+ - **arrival** · root · _no motion_ — A property row is present from first paint; animating it in would stage chrome the user is already scanning.
13212
+ - **response** · DisclosurePickerTrigger · `motion-interactive` — The row reads as a value, not a button; the hover step is what says it opens before it is clicked.
13213
+ - **transition** · DisclosurePickerPanel · `motion-disclosure` — The list pushes the panel down; growing its height ties the movement below to this row rather than to a layout jump.
13214
+ - **activity** · root · _no motion_ — Nothing is indeterminate: the options are resident. A list that loads should show its own loader inside the panel.
13215
+
13216
+ ## Accessibility
13217
+
13218
+ - The trigger is a real <button>; Base UI wires aria-expanded and aria-controls to the panel. Its accessible name is its text (caption + value) unless an aria-label is given.
13219
+ - The list is role="listbox" with role="option" rows; aria-selected marks the committed value, not the focused row, so a screen reader hears both where focus is and what is chosen. Selection does not follow focus: ArrowUp/ArrowDown, Home/End and type-ahead move focus (roving tabindex — one tab stop, so Tab leaves the list in one step) and apply nothing; Enter, Space or a click picks, applies and closes. Disabled options carry aria-disabled and are skipped.
13220
+ - Why a listbox and not a radio group: a radio group's checked state follows focus, so every arrow press would be a pick — wrong for a choice that opens a confirm, loads a font or resets settings. Base UI ships no standalone listbox (Select is popup-only, Combobox's inline list is focus-in-the-input, Composite is internal), so the keyboard model lives here.
13221
+ - Opening from the keyboard (Enter, Space, ArrowDown) moves focus into the panel: the search field when there is one, otherwise the chosen option (or the first). A pointer open focuses the search field on a fine pointer only, so touch does not raise the on-screen keyboard.
13222
+ - Escape closes the row without changing the value (clearing a query first) and returns focus to the trigger; a pick does the same. Focus is not pulled back when it has already moved on, e.g. to a confirm dialog the pick opened.
13223
+ - The panel animates with motion-disclosure, which is static under prefers-reduced-motion or MotionProvider off; the scroll that keeps the row in view is instant, never smooth.
13224
+
13225
+ ## Related
13226
+
13227
+ - Components: Collapsible, Select, Combobox, SearchInput, RadioGroup, ThemeSelector, FontPicker, PropertyRow
13228
+
13229
+ ## Examples
13230
+
13231
+ ### A short list — no search
13232
+
13233
+ ```tsx
13234
+ import { DisclosurePicker, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger } from "@sableui/react"
13235
+
13236
+ const [preset, setPreset] = useState("comfortable")
13237
+ const [open, setOpen] = useState(false)
13238
+ <DisclosurePicker open={open} onOpenChange={setOpen} className="w-72">
13239
+ <DisclosurePickerTrigger label="Density">{preset === "compact" ? "Compact" : "Comfortable"}</DisclosurePickerTrigger>
13240
+ <DisclosurePickerPanel>
13241
+ <DisclosurePickerList aria-label="Density" value={preset} onValueChange={setPreset}>
13242
+ <DisclosurePickerOption value="comfortable">Comfortable</DisclosurePickerOption>
13243
+ <DisclosurePickerOption value="compact">Compact</DisclosurePickerOption>
13244
+ </DisclosurePickerList>
13245
+ </DisclosurePickerPanel>
13246
+ </DisclosurePicker>
13247
+ ```
13248
+
13249
+ ### A long list — search opted in
13250
+
13251
+ ```tsx
13252
+ import { DisclosurePicker, DisclosurePickerEmpty, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger } from "@sableui/react"
13253
+
13254
+ const COUNTRIES = ["Argentina", "Australia", "Austria", "Belgium", "Brazil", "Canada", "Chile", "Denmark", "Finland", "France", "Germany", "Ireland", "Italy", "Japan", "Mexico", "Norway", "Portugal", "Spain", "Sweden"]
13255
+ const [country, setCountry] = useState("Canada")
13256
+ const [query, setQuery] = useState("")
13257
+ const shown = COUNTRIES.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()))
13258
+ <DisclosurePicker className="w-72">
13259
+ <DisclosurePickerTrigger label="Country">{country}</DisclosurePickerTrigger>
13260
+ <DisclosurePickerPanel search={{ value: query, onValueChange: setQuery, placeholder: `Search ${COUNTRIES.length} countries` }}>
13261
+ {shown.length > 0 ? (
13262
+ <DisclosurePickerList aria-label="Country" value={country} onValueChange={setCountry}>
13263
+ {shown.map((name) => (
13264
+ <DisclosurePickerOption key={name} value={name}>{name}</DisclosurePickerOption>
13265
+ ))}
13266
+ </DisclosurePickerList>
13267
+ ) : (
13268
+ <DisclosurePickerEmpty>No countries match “{query}”.</DisclosurePickerEmpty>
13269
+ )}
13270
+ </DisclosurePickerPanel>
13271
+ </DisclosurePicker>
13272
+ ```
13273
+
13274
+ ### A theme picker — media tiles, commit on Enter
13275
+
13276
+ ```tsx
13277
+ import { DisclosurePicker, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger, ThemePreview, useSableTheme } from "@sableui/react"
13278
+
13279
+ const { themeId, setThemeId, themes, mode } = useSableTheme()
13280
+ const current = themes.find((theme) => theme.id === themeId)
13281
+ <DisclosurePicker>
13282
+ <DisclosurePickerTrigger aria-label={`Theme: ${current?.label}`} media={current ? <ThemePreview theme={current} mode={mode} size="sm" /> : null}>
13283
+ {current?.label}
13284
+ </DisclosurePickerTrigger>
13285
+ <DisclosurePickerPanel>
13286
+ <DisclosurePickerList aria-label="Theme" value={themeId} onValueChange={setThemeId}>
13287
+ {themes.map((theme) => (
13288
+ <DisclosurePickerOption key={theme.id} value={theme.id} media={<ThemePreview theme={theme} mode={mode} size="sm" />}>
13289
+ {theme.label}
13290
+ </DisclosurePickerOption>
13291
+ ))}
13292
+ </DisclosurePickerList>
13293
+ </DisclosurePickerPanel>
13294
+ </DisclosurePicker>
13295
+ ```
13296
+
13297
+ ### Property-grid rung
13298
+
13299
+ ```tsx
13300
+ import { Density, DisclosurePicker, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger } from "@sableui/react"
13301
+
13302
+ const [blend, setBlend] = useState("normal")
13303
+ <Density value="compact" className="w-64">
13304
+ <DisclosurePicker size="xs">
13305
+ <DisclosurePickerTrigger label="Blend">{blend}</DisclosurePickerTrigger>
13306
+ <DisclosurePickerPanel>
13307
+ <DisclosurePickerList aria-label="Blend mode" value={blend} onValueChange={setBlend}>
13308
+ <DisclosurePickerOption value="normal">normal</DisclosurePickerOption>
13309
+ <DisclosurePickerOption value="multiply">multiply</DisclosurePickerOption>
13310
+ <DisclosurePickerOption value="screen">screen</DisclosurePickerOption>
13311
+ </DisclosurePickerList>
13312
+ </DisclosurePickerPanel>
13313
+ </DisclosurePicker>
13314
+ </Density>
13315
+ ```
13316
+
13317
+ ---
13318
+
12976
13319
  # EmptyState
12977
13320
 
12978
13321
  **Status:** stable
@@ -13442,6 +13785,113 @@ import { Field, FileDropzone } from "@sableui/react"
13442
13785
 
13443
13786
  ---
13444
13787
 
13788
+ # FontPicker
13789
+
13790
+ **Status:** stable
13791
+
13792
+ A DisclosurePicker for a typeface: the row names the chosen family in its own face and opens in place into a searchable list of families, each drawn in itself. The app supplies the families and loads the faces; Sable bundles no catalog.
13793
+
13794
+ **Keywords:** font picker, typeface, font family, font selector, typography, google fonts, inspector, customizer
13795
+
13796
+ ## Usage
13797
+
13798
+ Use FontPicker wherever someone chooses a typeface for a role (body, headings, code) in a panel that shows the result: a theme editor, a document inspector, a brand customizer. It is a DisclosurePicker, so the list opens beneath the row rather than over the preview; arrow keys walk the families (each drawn in its own face) without applying or loading anything, and Enter, Space or a click picks one, loads it and closes the row.
13799
+
13800
+ ### Best practices
13801
+
13802
+ - Do: Pass `loadFont` and load faces lazily: `"preview"` fires for every family when the list opens (load just the glyphs of its name — Google Fonts' `text=` parameter makes a 20-font list cost a few KB), `"use"` for the chosen one (load its real weights). Make it idempotent and define it once, outside render.
13803
+ - Do: Put non-font choices — "Preset (Figtree)", "Same as body" — in `options`, with a short `valueLabel` for the row. They list first and render in the app's face, so they never read as a family.
13804
+ - Do: Give each row a role `label` ("Body", "Headings", "Mono") and stack them; the caption column aligns. Control `open` from the parent so only one list is open at a time.
13805
+ - Don't: Leave search on for a short curated list — under a dozen families, pass `searchable={false}`. Search is on by default because font lists are usually long.
13806
+ - Don't: Use a Select of family names for this. A popup covers the text it changes, and a family name set in the app's face says nothing about the face.
13807
+
13808
+ ## Props
13809
+
13810
+ | Name | Type | Default | Description |
13811
+ | --- | --- | --- | --- |
13812
+ | value | string | — | The chosen family, or the value of one of `options`. |
13813
+ | onValueChange | (value: string) => void | — | Called once a family (or option) is picked — Enter, Space or a click — and only when it differs from `value`; the row then closes. Arrow keys and type-ahead only move focus; Escape closes without a pick. |
13814
+ | fonts | readonly { family: string; note?: string; stack?: string }[] | — | The families to offer, in order. `note` is a muted annotation (searched too); `stack` the CSS font-family to draw it in (default `"<family>"`). |
13815
+ | options | readonly { value: string; label: string; valueLabel?: string }[] | [] | Non-font choices listed above the fonts, in the app's face. `valueLabel` is what the row shows while it is chosen. |
13816
+ | label | ReactNode | — | The row's leading caption — the role the font plays. |
13817
+ | listLabel | string | "Font" | Accessible name of the list. |
13818
+ | searchable | boolean | true | A search field above the list, filtering by family and note. Pass false for a short curated list (under a dozen). |
13819
+ | searchLabel | string | "Search fonts" | Accessible name of the search field. |
13820
+ | loadFont | (font, purpose: "preview" \| "use") => void | — | Loads a face on demand: "preview" for every family when the list opens, "use" for the chosen one once it is picked (never while the list is only being walked). Receives your own font objects. Idempotent. |
13821
+ | open | boolean | — | Controlled open state, as DisclosurePicker. Control it to keep one of several rows open at a time. |
13822
+ | defaultOpen | boolean | false | Uncontrolled initial open state. |
13823
+ | onOpenChange | (open: boolean) => void | — | Called with the next open state. |
13824
+ | size | "md" \| "xs" | "md" | md for a settings column; xs for the property-grid rung (see DisclosurePicker). |
13825
+
13826
+ ## Tokens used
13827
+
13828
+ `foreground`, `muted-foreground`, `popover`, `hairline`, `panel-field`, `ring`
13829
+
13830
+ ## Motion
13831
+
13832
+ - **arrival** · root · _no motion_ — A property row is present from first paint.
13833
+ - **response** · Trigger · `motion-interactive` — Inherited from DisclosurePicker: the hover step says the value row opens.
13834
+ - **transition** · Panel · `motion-disclosure` — Inherited from DisclosurePicker: the list grows in place, tying the panel's movement to this row.
13835
+ - **activity** · root · _no motion_ — Faces load in the background and swap in when ready; the names are legible in the fallback meanwhile, so there is nothing to wait on.
13836
+
13837
+ ## Accessibility
13838
+
13839
+ - Inherits DisclosurePicker: a button trigger with aria-expanded/controls whose name is the caption plus the family; a listbox named by `listLabel`, aria-selected on the chosen family.
13840
+ - Keyboard open focuses the search field; ArrowDown steps from it into the list (ArrowUp from the first option steps back); arrows, Home/End and type-ahead move without applying; Enter or Space picks and closes; Escape clears the query, then closes without a pick and returns focus to the row.
13841
+ - Each family is set in its own face, but its name is still plain text, so a screen reader reads the name, not the glyphs.
13842
+
13843
+ ## Related
13844
+
13845
+ - Components: DisclosurePicker, ThemeSelector, Select, Combobox
13846
+
13847
+ ## Examples
13848
+
13849
+ ### Body font with lazy loading
13850
+
13851
+ ```tsx
13852
+ import { FontPicker } from "@sableui/react"
13853
+
13854
+ const FONTS = [
13855
+ { family: "Inter", note: "Neutral, dense UI" },
13856
+ { family: "Figtree", note: "Friendly geometric" },
13857
+ { family: "Fraunces", note: "Soft, wonky serif", stack: '"Fraunces", serif' },
13858
+ ]
13859
+ // Your loader; Sable never fetches fonts itself.
13860
+ const loadFont = (font: { family: string }, purpose: "preview" | "use") => {
13861
+ const id = `font-${purpose}-${font.family}`
13862
+ if (document.getElementById(id)) return
13863
+ const link = Object.assign(document.createElement("link"), { id, rel: "stylesheet" })
13864
+ const family = font.family.replace(/ /g, "+")
13865
+ link.href = purpose === "preview"
13866
+ ? `https://fonts.googleapis.com/css2?family=${family}&text=${encodeURIComponent(font.family)}`
13867
+ : `https://fonts.googleapis.com/css2?family=${family}:wght@400..700&display=swap`
13868
+ document.head.appendChild(link)
13869
+ }
13870
+ const [family, setFamily] = useState("Inter")
13871
+ <FontPicker label="Body" listLabel="Body font" value={family} onValueChange={setFamily} fonts={FONTS} loadFont={loadFont} searchable={false} />
13872
+ ```
13873
+
13874
+ ### With a preset option, one row of several
13875
+
13876
+ ```tsx
13877
+ import { FontPicker } from "@sableui/react"
13878
+
13879
+ const [open, setOpen] = useState<string | null>(null)
13880
+ const [body, setBody] = useState("theme")
13881
+ <FontPicker
13882
+ label="Body"
13883
+ listLabel="Body font"
13884
+ value={body}
13885
+ onValueChange={setBody}
13886
+ options={[{ value: "theme", label: "Preset (Figtree)", valueLabel: "Figtree" }]}
13887
+ fonts={[{ family: "Inter" }, { family: "Figtree" }, { family: "Geist" }]}
13888
+ open={open === "body"}
13889
+ onOpenChange={(next) => setOpen(next ? "body" : null)}
13890
+ />
13891
+ ```
13892
+
13893
+ ---
13894
+
13445
13895
  # Form
13446
13896
 
13447
13897
  **Status:** stable
@@ -14344,6 +14794,253 @@ import { Marquee } from "@sableui/react"
14344
14794
 
14345
14795
  ---
14346
14796
 
14797
+ # MediaImage
14798
+
14799
+ **Status:** beta
14800
+
14801
+ An image that fills its box and falls back to GradientArt seeded by the item when it has no source or the source fails — Avatar's image-load tracking, for covers, thumbnails and product shots instead of faces.
14802
+
14803
+ **Keywords:** image, img, image fallback, broken image, cover art, album art, thumbnail, placeholder image, onError, missing image
14804
+
14805
+ ## Usage
14806
+
14807
+ Use MediaImage wherever content imagery may be missing or broken: a cover in a grid, a thumbnail in a row, a product shot. It fills the box it is given — an AspectBox for a cover, a sized and rounded box for a thumbnail — shows a quiet muted surface while the image loads, fades the image in once decoded, and paints GradientArt seeded by the item when there is no image to show, so a grid of missing covers still reads as distinct things. For a person use Avatar; for a whole tile with title and actions use MediaTile, which composes this.
14808
+
14809
+ ### Best practices
14810
+
14811
+ - Do: Give the box the shape: `<AspectBox ratio={1} className="rounded-md"><MediaImage … /></AspectBox>`, or `size-14 rounded-md` on MediaImage itself for a thumbnail. It clips to whatever radius the box has.
14812
+ - Do: Pass `seed` (the title or an id) when `alt` is empty, so the fallback art is the item's own and stays the same in every list it appears in.
14813
+ - Do: Leave `alt` empty when a visible title sits next to the image — a tile, a row — and describe it when the image stands alone. A set `alt` also names the fallback, so the description survives a broken URL.
14814
+ - Don't: Do not hand-write `<img onError={() => setBroken(true)}>`. An image that fails before hydration never fires the handler on a prerendered page; MediaImage preloads off the DOM and catches it.
14815
+ - Don't: Do not use it for profile pictures. A person is a circle with initials — Avatar, whose `variant="art"` is the same fallback with the person's initials over it.
14816
+
14817
+ ## Props
14818
+
14819
+ | Name | Type | Default | Description |
14820
+ | --- | --- | --- | --- |
14821
+ | src | string | — | Image URL. Omitted, empty or failing to load ⇒ the fallback shows. |
14822
+ | alt | string | "" | Alt text. Empty is decorative — right beside a visible title. When set and no image shows, it names the fallback instead. |
14823
+ | seed | string \| number | — | Seeds the fallback art. Defaults to `alt`; pass the title or an id when `alt` is empty. |
14824
+ | fallback | ReactNode | — | Replaces the GradientArt fallback — a glyph or monogram. Fills the frame under the same clipping. |
14825
+ | fit | "cover" \| "contain" | "cover" | `cover` crops to fill (artwork, photos); `contain` letterboxes on the muted surface (a logo, a product that must not be cropped). |
14826
+ | srcSet | string | — | Responsive sources, passed to the `<img>`. |
14827
+ | sizes | string | — | The `sizes` attribute for `srcSet`. |
14828
+ | loading | "lazy" \| "eager" | "lazy" | Native lazy loading. Use `eager` for the first row of a grid above the fold. |
14829
+ | className | string | — | Sizing and rounding when MediaImage is the box itself (`size-14 rounded-md`). Inside an AspectBox it already fills. |
14830
+
14831
+ ## Variants
14832
+
14833
+ - **fit**: cover, contain
14834
+
14835
+ ## Tokens used
14836
+
14837
+ `muted`, `art-1`, `art-2`, `art-3`, `art-4`, `art-5`, `art-6`, `art-ground`
14838
+
14839
+ ## Motion
14840
+
14841
+ - **arrival** · image · `motion-fade` — The image fades in over the muted surface once it has decoded, so a slow cover settles in instead of popping row by row as the grid loads.
14842
+ - **response** · root · _no motion_ — Imagery, not a control. The tile or row around it answers the pointer.
14843
+ - **transition** · root · _no motion_ — Its only state change is loading → loaded or failed, which the arrival covers.
14844
+ - **activity** · root · _no motion_ — A loading image rests on a static muted surface; a shimmer per cover across a long grid would be noise.
14845
+
14846
+ ## Accessibility
14847
+
14848
+ - `alt` defaults to empty (decorative), because the usual neighbour is a visible title that already names the item; describe the image when it stands alone.
14849
+ - When no image is on screen and `alt` is set, the frame takes `role="img"` with that label, so a broken URL does not erase the description. The fallback art itself is always aria-hidden.
14850
+ - The `<img>` mounts only once it has loaded, so assistive technology never meets a broken-image icon.
14851
+
14852
+ ## Related
14853
+
14854
+ - Components: MediaTile, Avatar, GradientArt, AspectBox
14855
+
14856
+ ## Examples
14857
+
14858
+ ### Cover with fallback
14859
+
14860
+ ```tsx
14861
+ import { AspectBox, MediaImage } from "@sableui/react"
14862
+
14863
+ <AspectBox ratio={1} className="rounded-md">
14864
+ <MediaImage src={album.cover} seed={album.title} />
14865
+ </AspectBox>
14866
+ ```
14867
+
14868
+ ### No image yet
14869
+
14870
+ ```tsx
14871
+ import { AspectBox, MediaImage } from "@sableui/react"
14872
+
14873
+ <AspectBox ratio={1} className="rounded-md">
14874
+ <MediaImage seed="Low tide, high noon" />
14875
+ </AspectBox>
14876
+ ```
14877
+
14878
+ ### Row thumbnail
14879
+
14880
+ ```tsx
14881
+ import { MediaImage } from "@sableui/react"
14882
+
14883
+ <MediaImage src={track.art} seed={track.title} className="size-12 shrink-0 rounded-sm" />
14884
+ ```
14885
+
14886
+ ### Logo, never cropped
14887
+
14888
+ ```tsx
14889
+ import { AspectBox, MediaImage } from "@sableui/react"
14890
+
14891
+ <AspectBox ratio={16 / 9} className="rounded-lg">
14892
+ <MediaImage src={brand.logo} alt={brand.name} fit="contain" />
14893
+ </AspectBox>
14894
+ ```
14895
+
14896
+ ---
14897
+
14898
+ # MediaTile
14899
+
14900
+ **Status:** beta
14901
+
14902
+ A cover-first tile — square, 16:9 or poster artwork with an optional play-style action over it, then a title and a subtitle that can each be links — that is one link as a whole without nesting its other controls inside one.
14903
+
14904
+ **Keywords:** album, album art, cover, tile, media card, video thumbnail, podcast, playlist, gallery, product tile, poster, grid item, play button, music
14905
+
14906
+ ## Usage
14907
+
14908
+ The item in a media grid or shelf: an album, a playlist, a video, a podcast, a film, a photo, a product. The cover leads and carries the weight; the title and subtitle sit underneath without a card around them. The whole tile follows the title's link, the subtitle can link somewhere else (the artist, the channel), and an action — usually play — floats over the cover's corner, revealed on hover and focus and always shown on touch. A missing or broken cover becomes GradientArt seeded by the title, so a grid of items without artwork still reads as distinct things.
14909
+
14910
+ ### Best practices
14911
+
14912
+ - Do: Lay tiles out with `Grid` (`<Grid minItemWidth="10rem">`) or a horizontal `Scroller` for a shelf. The tile fills its cell; the grid decides the width.
14913
+ - Do: Pass `render={<Link to="…" />}` (or `href`) for where the tile goes, and `subtitleRender` / `subtitleHref` for a second destination. Each stays a separate control; never wrap the tile in your own link.
14914
+ - Do: Name the action for its item — `aria-label="Play Low tide, high noon"`, not "Play". A grid of twenty identical "Play" buttons is unusable from a screen reader's list of controls.
14915
+ - Do: Use `titleAs="h3"` when the tiles sit under a section heading, so screen-reader users can move tile to tile by heading.
14916
+ - Don't: Do not build this from `Card render={<Link />}`. A card that is a link cannot hold a second link or a button — interactive content cannot nest — which is exactly what a media tile needs.
14917
+ - Don't: Do not put more than one action on the cover. A second control over the art is a toolbar on a thumbnail; move secondary actions (add to queue, share) into an OverflowMenu in the action slot or onto the item's page.
14918
+ - Don't: Do not use it for a framed commerce card with a price and a buy button — that is ProductCardBlock — or for a person, which is an Avatar.
14919
+
14920
+ ### Anatomy
14921
+
14922
+ | Part | Required | Description |
14923
+ | --- | --- | --- |
14924
+ | Cover | yes | An AspectBox holding MediaImage: the image, or GradientArt seeded by the title. Drawn first, read last. |
14925
+ | Title | yes | The item's name. When the tile links, this is the link, stretched over the whole tile. |
14926
+ | Subtitle | no | The second line. Optionally its own link, raised above the stretched one. |
14927
+ | Action | no | One control over the cover's bottom-end corner — usually play. Revealed on hover and focus; always visible on touch. |
14928
+ | Badge | no | A static label over the cover's top-start corner — a duration, "New". Click-through. |
14929
+
14930
+ ## Props
14931
+
14932
+ | Name | Type | Default | Description |
14933
+ | --- | --- | --- | --- |
14934
+ | title | ReactNode | — | The item's name. Seeds the fallback art when it is a string. |
14935
+ | subtitle | ReactNode | — | The second line — an artist, a channel, a year. |
14936
+ | src | string | — | Cover image URL. Without one, or when it fails, the cover is GradientArt seeded by the item. |
14937
+ | alt | string | "" | Cover alt text. Empty by default: the title below already names the item. |
14938
+ | seed | string \| number | — | Seeds the fallback art. Defaults to `title` when it is a string. |
14939
+ | aspect | "square" \| "video" \| "poster" \| number | "square" | The cover's shape: square (albums, podcasts), video (16:9), poster (2:3 — films, books), or any width / height ratio. |
14940
+ | size | "sm" \| "md" \| "lg" | "md" | Moves the type tier, the cover-to-text gap, the corner and the overlay inset together. |
14941
+ | href | string | — | Where the tile goes. The title becomes the link, stretched over the whole tile. |
14942
+ | render | useRender.RenderProp | — | The title link's element, for a router: `render={<Link to="/albums/1" />}`. Takes precedence over `href`. |
14943
+ | subtitleHref | string | — | Makes the subtitle its own link — a separate tab stop above the tile's. |
14944
+ | subtitleRender | useRender.RenderProp | — | The subtitle link's element, for a router. |
14945
+ | action | ReactNode | — | One control over the cover's bottom-end corner, usually a play Button. Hidden until hover or focus on hover-capable devices; always shown on touch. |
14946
+ | badge | ReactNode | — | A static label on the cover's top-start corner — `<Badge appearance="overlay">`. |
14947
+ | titleAs | "h2" \| "h3" \| "h4" \| "h5" \| "p" | "p" | The title's element. A heading one level below the section's lets screen-reader users move tile to tile. |
14948
+ | loading | "lazy" \| "eager" | "lazy" | Native image loading. `eager` for the first row above the fold. |
14949
+ | ...div props | Omit<ComponentPropsWithRef<"div">, "title"> | — | Spread onto the root. |
14950
+
14951
+ ## Variants
14952
+
14953
+ - **size**: sm, md, lg
14954
+ - **aspect**: square, video, poster
14955
+
14956
+ ## Tokens used
14957
+
14958
+ `foreground`, `muted-foreground`, `muted`, `overlay`, `ring`
14959
+
14960
+ ## Motion
14961
+
14962
+ - **arrival** · Cover · `motion-fade` — The cover image fades in over a quiet surface once it decodes, so a grid loading on a slow connection settles tile by tile instead of popping.
14963
+ - **response** · Cover · `motion-interactive` — Hovering the tile deepens the cover with a light scrim and lifts the action in (`reveal-on-hover`), so it is clear the whole tile is one target and where its action is.
14964
+ - **transition** · root · _no motion_ — A tile has no states of its own to move between; the action carries any (play / pause).
14965
+ - **activity** · root · _no motion_ — Nothing runs inside a tile; a now-playing indicator belongs to the player, not the grid.
14966
+
14967
+ ## Accessibility
14968
+
14969
+ - No nested interactive content: the title is the tile's link, and its `stretched-link` overlay covers the tile, while the subtitle link and the action sit above it as separate controls.
14970
+ - Tab order is title → subtitle → action. The text comes first in the DOM and the cover is drawn above it with CSS `order`, so focus follows how a reader names the item.
14971
+ - Focusing the title link rings the whole tile, rounded like the cover, because the whole tile is what the link is; the subtitle link and the action ring themselves.
14972
+ - The action is hidden with opacity only, and only where the device can hover; it stays in the tab order and the accessibility tree, appears when anything in the tile has focus, and is always visible on touch.
14973
+ - The cover is decorative by default (`alt=""`), since the title names the item; the fallback art is aria-hidden.
14974
+ - Truncated titles keep their full text in the link's accessible name and in a `title` tooltip.
14975
+
14976
+ ## Related
14977
+
14978
+ - Components: MediaImage, GradientArt, Grid, AspectBox, Card, Scroller
14979
+
14980
+ ## Examples
14981
+
14982
+ ### Album grid
14983
+
14984
+ ```tsx
14985
+ import { Button, Grid, Icon, MediaTile } from "@sableui/react"
14986
+ // <Link> = your router's link (react-router, next/link, @tanstack/router, …)
14987
+
14988
+ <Grid minItemWidth="10rem" gap={3}>
14989
+ {albums.map((album) => (
14990
+ <MediaTile
14991
+ key={album.id}
14992
+ title={album.title}
14993
+ subtitle={album.artist}
14994
+ src={album.cover}
14995
+ render={<Link to={`/albums/${album.id}`} />}
14996
+ subtitleRender={<Link to={`/artists/${album.artistId}`} />}
14997
+ action={
14998
+ <Button size="icon" aria-label={`Play ${album.title}`} onClick={() => play(album)}>
14999
+ <Icon name="play" variant="solid" />
15000
+ </Button>
15001
+ }
15002
+ />
15003
+ ))}
15004
+ </Grid>
15005
+ ```
15006
+
15007
+ ### Video tile with a duration
15008
+
15009
+ ```tsx
15010
+ import { Badge, MediaTile } from "@sableui/react"
15011
+
15012
+ <MediaTile
15013
+ aspect="video"
15014
+ title="Building a synth from scratch"
15015
+ subtitle="Undertow Sessions · 2 days ago"
15016
+ src={video.thumbnail}
15017
+ href={`/watch/${video.id}`}
15018
+ badge={<Badge appearance="overlay">12:04</Badge>}
15019
+ />
15020
+ ```
15021
+
15022
+ ### Gallery, no links
15023
+
15024
+ ```tsx
15025
+ import { Grid, MediaTile } from "@sableui/react"
15026
+
15027
+ <Grid minItemWidth="8rem" gap={2}>
15028
+ {photos.map((photo) => (
15029
+ <MediaTile key={photo.id} size="sm" title={photo.caption} src={photo.url} alt={photo.description} />
15030
+ ))}
15031
+ </Grid>
15032
+ ```
15033
+
15034
+ ### Poster shelf
15035
+
15036
+ ```tsx
15037
+ import { MediaTile } from "@sableui/react"
15038
+
15039
+ <MediaTile aspect="poster" size="lg" titleAs="h3" title="The Long Dark" subtitle="2026 · Drama" href="/films/the-long-dark" />
15040
+ ```
15041
+
15042
+ ---
15043
+
14347
15044
  # Menu
14348
15045
 
14349
15046
  **Status:** stable
@@ -15306,6 +16003,7 @@ Use PageHeader to establish a page's title, supporting description, and primary
15306
16003
  - Do: Use one PageHeader per major page or route.
15307
16004
  - Do: Keep the title literal and put explanation in the lede or nearby content.
15308
16005
  - Don't: Do not use PageHeader inside repeated cards, dialogs, or compact panels.
16006
+ - Don't: Do not use a second PageHeader for a section of the page. It is the route's one h1; each section below it takes a SectionHeader (an h2 with the same title row, description and actions).
15309
16007
  - Don't: Do not put it outside the Region that holds the page content. It then sits at a different measure from what it introduces, and the page reads as two documents.
15310
16008
 
15311
16009
  ## Props
@@ -15314,8 +16012,8 @@ Use PageHeader to establish a page's title, supporting description, and primary
15314
16012
  | --- | --- | --- | --- |
15315
16013
  | title | ReactNode | — | The page title (renders an <h1>). |
15316
16014
  | description | ReactNode | — | Supporting copy beneath the title. |
15317
- | back | ReactNode | — | Leading element before the title — typically a back control on drill-in routes. Keep it a ghost icon Button (size="icon", variant="ghost", aria-label="Back") so it stays quieter than the title. |
15318
- | actions | ReactNode | — | Trailing actions aligned to the title row's end. They drop under the title when the row runs out of room, and the title wraps rather than truncating. |
16015
+ | back | ReactNode | — | Leading element before the title — typically a back control on drill-in routes. Keep it a ghost icon Button (size="icon", variant="ghost", aria-label="Back") so it stays quieter than the title. A ghost back control bleeds by its inset at the start, so its glyph lines up with the content edge below (Button bleed="none" opts out). |
16016
+ | actions | ReactNode | — | Trailing actions aligned to the title row's end. They drop under the title when the row runs out of room, and the title wraps rather than truncating. A ghost or link Button at either end of the group bleeds by its own padding, so its label meets the content edge (the end on the title row, the start once wrapped); filled buttons keep their edge on the line. Direct children only. |
15319
16017
  | titleAs | "h1" \| "h2" | "h1" | The title's heading element. Keep h1 — the route's one h1. Use h2 only when something above already owns the h1: a shell that renders the route title, or a page showing a PageHeader as an example. |
15320
16018
 
15321
16019
  ## Tokens used
@@ -15329,7 +16027,7 @@ Use PageHeader to establish a page's title, supporting description, and primary
15329
16027
 
15330
16028
  ## Related
15331
16029
 
15332
- - Components: AppHeader, Breadcrumbs, Button
16030
+ - Components: AppHeader, SectionHeader, Breadcrumbs, Button
15333
16031
 
15334
16032
  ## Examples
15335
16033
 
@@ -15360,8 +16058,8 @@ import { Button, PageHeader, Region } from "@sableui/react"
15360
16058
 
15361
16059
  // ✗ Outside the Region. The header then sits at a different measure from the
15362
16060
  // content it introduces, and the page reads as two documents.
15363
- // ✗ Two on one page. A second one is a section heading — an h2 in a
15364
- // smaller step (text-heading-3 or below), not another PageHeader.
16061
+ // ✗ Two on one page. A second one is a section heading: a SectionHeader
16062
+ // (an h2 on text-heading-3, same title row and actions), not another PageHeader.
15365
16063
  // ✗ Global actions in it (account, search). Those persist across routes and
15366
16064
  // belong in AppHeader.
15367
16065
  ```
@@ -15884,7 +16582,7 @@ Use PieChart for a share of one total across two to six categories — traffic s
15884
16582
  | categories | Record<string, { label?: string; tone?: ChartTone; color?: string; icon?: string }> | — | Labels, tones and icons per slice, keyed by the slice's name. Unlisted slices take the tones in order. |
15885
16583
  | className | string | "h-64" | Sizes the chart. Set a height. |
15886
16584
  | donut | boolean \| number | false | Cut out the middle: `true` for a ring at 60% of the radius, or a ratio (0–0.9). |
15887
- | centerLabel | { value: string; label?: string } | — | A value and caption in the hole of a donut. |
16585
+ | centerLabel | { value: string \| number; label?: string } | — | A value and caption in the hole of a donut. The value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K, 102.1K, 1.2M); pass a string for units. |
15888
16586
  | separator | boolean | true | A hairline in the surface colour between slices. |
15889
16587
  | labels | "none" \| "outside" \| "inside" | "none" | Label each slice outside the pie or inside it. |
15890
16588
  | labelContent | "value" \| "percent" \| "name" | "value" | What a slice label says. |
@@ -15938,7 +16636,7 @@ const sources = [
15938
16636
  index="source"
15939
16637
  valueKey="visitors"
15940
16638
  donut
15941
- centerLabel={{ value: "752", label: "Visitors" }}
16639
+ centerLabel={{ value: 752, label: "Visitors" }}
15942
16640
  />
15943
16641
  ```
15944
16642
 
@@ -16362,7 +17060,7 @@ Use RadialChart for progress toward goals (several rings), a single score or usa
16362
17060
  | stacked | boolean | false | Split ONE ring between the rows instead of a ring each. |
16363
17061
  | track | boolean | true | Draw each ring's full track behind it. |
16364
17062
  | labels | boolean | false | Name each ring at its start. |
16365
- | centerLabel | { value: string; label?: string } | — | A value and caption in the middle. |
17063
+ | centerLabel | { value: string \| number; label?: string } | — | A value and caption in the middle. On a full ring the value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K); pass a string for units ("7.2 GB", "72%"). |
16366
17064
  | legend | boolean | true unless rings are labelled | Show the legend. |
16367
17065
  | tooltip | boolean \| { indicator?: "dot" \| "line" \| "dashed" \| "none"; hideLabel?: boolean; labelFormatter?: (label, datum) => ReactNode; showTotal?: boolean } | true | Show a tooltip on hover and keyboard focus, or configure it: the row marker, hiding the heading, rewriting it, adding a Total row. |
16368
17066
  | valueFormatter | (value: number) => string | — | Format values everywhere (axis, labels, tooltip, table) — units like "$12,480". Default: compact on the axis, full elsewhere. |
@@ -16956,6 +17654,153 @@ import { SearchInput } from "@sableui/react"
16956
17654
 
16957
17655
  ---
16958
17656
 
17657
+ # SectionHeader
17658
+
17659
+ **Status:** stable
17660
+
17661
+ The heading block for one section of a page: a title (h2 by default), an optional line of description, and actions on the title row — a "View all" link or a couple of buttons — that drop beneath the title only when the row runs out of room.
17662
+
17663
+ **Keywords:** section header, section heading, section title, shelf, view all, see all, show all, h2, subsection, optical alignment, bleed
17664
+
17665
+ ## Usage
17666
+
17667
+ Use SectionHeader to head each section below the page title — a home-page shelf with View all, a settings group, a list with its own actions. PageHeader is the route's single h1; every section under it takes a SectionHeader, with titleId wired to the section's aria-labelledby.
17668
+
17669
+ ### Best practices
17670
+
17671
+ - Do: Wrap the header and its content in a `<section aria-labelledby={titleId}>` (a flex column with a gap, or a Stack inside it) so the section is a named region and the header's title is its name.
17672
+ - Do: Pick titleAs by the document outline (h2 under the page h1, h3 inside an h2 section) and size by visual weight: md for a page's sections and shelves, sm for denser groups like settings.
17673
+ - Do: Keep actions to one or two `size="sm"` buttons — the title is centred on that height. A shelf's "View all" is a ghost Button rendered as a link (`render={<a href=… />}`), named for what it opens when the label alone is ambiguous (aria-label="View all new releases").
17674
+ - Do: Pass a ghost or link action as a plain Button and let the header align it: the last direct child bleeds by its own padding at the end, so "View all" sits on the edge of the grid below, and the first bleeds at the start, which is the edge once the actions wrap beneath the title. Filled buttons keep their edge on the line. Set bleed="none" on the Button to opt one out.
17675
+ - Don't: Nudge a View all onto the edge with -me-3, or wrap the actions in your own div: the header aligns direct children only, and only by each button's real padding. If you must wrap them, set bleed on the Button inside instead.
17676
+ - Don't: Use a second PageHeader for a section — that is a second h1, and the outline then reports two documents. And do not hand-build `<div className="flex justify-between"><h2 className="text-heading-3">…` per shelf: it misses the wrapping, the aria wiring and the shared rhythm.
17677
+ - Don't: Use SectionHeader to title a Card (use CardHeader + CardTitle) or a docked tool panel (use PanelSection). It heads a section of the page, not a surface.
17678
+ - Don't: Add margin to the header to space it from its content. It has no outer spacing on purpose; the section's gap owns that rhythm, so every section on the page keeps the same one.
17679
+
17680
+ ### Anatomy
17681
+
17682
+ | Part | Required | Description |
17683
+ | --- | --- | --- |
17684
+ | Title | yes | The section's heading (h2 by default), wrapping rather than truncating. Its id names the section. |
17685
+ | Description | no | One line of supporting copy under the title, capped at the prose measure. |
17686
+ | Actions | no | Buttons or links at the title row's end; they move beneath the title when the row is too narrow for both. A borderless (ghost/link) Button at either end of the group is optically aligned: its label, not its invisible padding, meets the content edge. |
17687
+
17688
+ ## Props
17689
+
17690
+ | Name | Type | Default | Description |
17691
+ | --- | --- | --- | --- |
17692
+ | title | ReactNode | — | The section's title. |
17693
+ | description | ReactNode | — | One line of supporting copy beneath the title, capped at the prose measure. |
17694
+ | actions | ReactNode | — | Trailing actions on the title row — a View all link or a couple of buttons, size="sm". They stay on the title's row and drop beneath it only when the row runs out of room; the title wraps rather than truncating. A ghost or link Button that ends the group bleeds by its padding at the end (and one that starts it, at the start — the edge once wrapped), so its label lines up with the content below; Button bleed="none" opts out. Direct children only. |
17695
+ | titleAs | "h2" \| "h3" \| "h4" | "h2" | The heading element, chosen by the document outline: h2 for a section under the page's h1, h3 for a subsection inside one. |
17696
+ | titleId | string | — | The title's id, so the section it heads can point at it: <section aria-labelledby={titleId}>. Generated when omitted. |
17697
+ | size | "md" \| "sm" | "md" | Visual weight, independent of the heading level: md (text-heading-3) for a page's sections and shelves; sm (text-heading-4) for denser sets — settings groups, subsections. |
17698
+
17699
+ ## Variants
17700
+
17701
+ - **size**: md, sm
17702
+
17703
+ ## Tokens used
17704
+
17705
+ `foreground`, `muted-foreground`
17706
+
17707
+ ## Motion
17708
+
17709
+ - **arrival** · root · _no motion_ — A heading arrives with the section it names; animating it separately would make the section's title lag its content.
17710
+ - **response** · root · _no motion_ — The header itself is not interactive; the Buttons passed as actions carry their own response.
17711
+ - **transition** · root · _no motion_ — It has no states of its own to move between.
17712
+ - **activity** · root · _no motion_ — It never does indeterminate work.
17713
+
17714
+ ## Accessibility
17715
+
17716
+ - Renders a real heading (h2 by default); choose titleAs by the outline, never to get a size — size sets the look.
17717
+ - The title always carries an id (titleId, or a generated one). Point the section at it with aria-labelledby so the section is announced as a named region and appears in landmark navigation.
17718
+ - A repeated "View all" is ambiguous in a links list; name each one for its section (aria-label="View all new releases") or keep the visible label specific.
17719
+
17720
+ ## Related
17721
+
17722
+ - Components: PageHeader, CardHeader, PanelSection, Grid, Stack, Button
17723
+ - Patterns: SettingsPage, DashboardPage
17724
+
17725
+ ## Examples
17726
+
17727
+ ### A shelf with View all
17728
+
17729
+ ```tsx
17730
+ import { Button, Card, CardDescription, CardHeader, CardTitle, Grid, SectionHeader } from "@sableui/react"
17731
+
17732
+ <section aria-labelledby="new-releases" className="flex flex-col gap-4">
17733
+ <SectionHeader
17734
+ titleId="new-releases"
17735
+ title="New releases"
17736
+ description="Fresh from artists you follow."
17737
+ actions={
17738
+ <Button variant="ghost" size="sm" render={<a href="/new" />} aria-label="View all new releases">
17739
+ View all
17740
+ </Button>
17741
+ }
17742
+ />
17743
+ <Grid minItemWidth="10rem" gap={2}>
17744
+ <Card><CardHeader><CardTitle>Night Swim</CardTitle><CardDescription>Lena Vale</CardDescription></CardHeader></Card>
17745
+ <Card><CardHeader><CardTitle>Low Tide</CardTitle><CardDescription>Harbour Lights</CardDescription></CardHeader></Card>
17746
+ </Grid>
17747
+ </section>
17748
+ ```
17749
+
17750
+ ### A settings section
17751
+
17752
+ ```tsx
17753
+ import { Field, SectionHeader, Switch } from "@sableui/react"
17754
+
17755
+ <section aria-labelledby="playback" className="flex flex-col gap-4">
17756
+ <SectionHeader
17757
+ titleId="playback"
17758
+ size="sm"
17759
+ title="Playback"
17760
+ description="How tracks start, end and blend into each other."
17761
+ />
17762
+ <Field label="Crossfade" orientation="horizontal">
17763
+ <Switch defaultChecked />
17764
+ </Field>
17765
+ <Field label="Normalize volume" orientation="horizontal">
17766
+ <Switch />
17767
+ </Field>
17768
+ </section>
17769
+ ```
17770
+
17771
+ ### A section with two actions
17772
+
17773
+ ```tsx
17774
+ import { Button, Entity, Icon, SectionHeader, Stack } from "@sableui/react"
17775
+
17776
+ <section aria-labelledby="playlists" className="flex flex-col gap-4">
17777
+ <SectionHeader
17778
+ titleId="playlists"
17779
+ title="Your playlists"
17780
+ actions={
17781
+ <>
17782
+ <Button variant="ghost" size="sm" iconStart={<Icon name="filter" />}>Filter</Button>
17783
+ <Button size="sm" iconStart={<Icon name="plus" />}>New playlist</Button>
17784
+ </>
17785
+ }
17786
+ />
17787
+ <Stack gap={1}>
17788
+ <Entity className="[--entity-px:0]" title="Late drives" subtitle="42 tracks" />
17789
+ <Entity className="[--entity-px:0]" title="Focus" subtitle="18 tracks" />
17790
+ </Stack>
17791
+ </section>
17792
+ ```
17793
+
17794
+ ### A subsection inside a section
17795
+
17796
+ ```tsx
17797
+ import { SectionHeader } from "@sableui/react"
17798
+
17799
+ <SectionHeader titleAs="h3" size="sm" title="Appears on" />
17800
+ ```
17801
+
17802
+ ---
17803
+
16959
17804
  # SegmentedBar
16960
17805
 
16961
17806
  **Status:** stable
@@ -17414,6 +18259,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
17414
18259
  | SheetHeader | no | The title and description region. |
17415
18260
  | SheetTitle | no | The sheet heading. |
17416
18261
  | SheetDescription | no | Supporting context for the sheet. |
18262
+ | SheetList | no | Edge-to-edge rows (Entity) aligned with the header — a queue, notifications, a picker. |
17417
18263
  | SheetFooter | no | Footer actions for the workflow. |
17418
18264
 
17419
18265
  ## Subcomponents
@@ -17426,6 +18272,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
17426
18272
  | SheetHeader | ComponentProps<"div"> | Top section holding SheetTitle/SheetDescription. |
17427
18273
  | SheetTitle | Base UI Drawer.Title props + className | The sheet's accessible name — wires aria-labelledby on the popup. |
17428
18274
  | SheetDescription | Base UI Drawer.Description props + className | Muted supporting line; wires aria-describedby. |
18275
+ | SheetList | ComponentProps<"div"> | Rows that run edge to edge in the sheet, Entity most often. It bleeds through the sheet's padding (--sheet-p) and hands it back to each row as --entity-px, so row content lines up with SheetHeader and a row's hover fill reaches the panel's edges. Without it a list of Entity rows sits 12px in from the header. The Sheet equivalent of CardList. |
17429
18276
  | SheetFooter | ComponentProps<"div"> | Bottom row for actions, pinned after the scrollable body. In a bottom sheet it stacks: full width, the primary on top, every button at the cta height. |
17430
18277
 
17431
18278
  ## Props
@@ -17459,6 +18306,14 @@ Use Sheet for secondary workflows that slide in while preserving context from th
17459
18306
 
17460
18307
  ## Examples
17461
18308
 
18309
+ ### List of rows (a queue)
18310
+
18311
+ ```tsx
18312
+ import { Button, Entity, GradientArt, Sheet, SheetContent, SheetHeader, SheetList, SheetTitle, SheetTrigger } from "@sableui/react"
18313
+
18314
+ <Sheet><SheetTrigger render={<Button variant="secondary">Queue</Button>} /><SheetContent><SheetHeader><SheetTitle>Up next</SheetTitle></SheetHeader><SheetList>{queue.map((track) => <Entity key={track.id} leading={<GradientArt aria-hidden seed={track.title} className="size-10 rounded-sm" />} title={track.title} subtitle={track.artist} />)}</SheetList></SheetContent></Sheet>
18315
+ ```
18316
+
17462
18317
  ### Basic (right)
17463
18318
 
17464
18319
  ```tsx
@@ -17551,7 +18406,7 @@ import { ShowMore } from "@sableui/react"
17551
18406
 
17552
18407
  **Status:** stable
17553
18408
 
17554
- Desktop side-navigation rail (in-flow sticky, hidden below lg) with SidebarHeader / SidebarNav / SidebarItem / SidebarFooter parts. Items are router-agnostic via the `render` prop; an optional collapsed icon-rail mode reveals labels in tooltips.
18409
+ Desktop side-navigation rail (in-flow sticky, hidden below lg) with SidebarHeader / SidebarBrand / SidebarNav / SidebarItem / SidebarFooter parts. Items are router-agnostic via the `render` prop; an optional collapsed icon-rail mode reveals labels in tooltips.
17555
18410
 
17556
18411
  **Keywords:** sidebar, navigation, app chrome, rail
17557
18412
 
@@ -17577,6 +18432,7 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
17577
18432
 
17578
18433
  | Name | Type | Description |
17579
18434
  | --- | --- | --- |
18435
+ | SidebarBrand | { mark: ReactNode; label: ReactNode; render?: useRender.RenderProp } + div props | The brand row, inside SidebarHeader, on the same grid as SidebarItem: mark in the icons' 24px slot, label on the labels' column, so a logo lines up with the nav below it without copying the rail's inset numbers. Collapsed, it shows the mark alone (named by a string label). render={<Link to="/" />} makes it the home link. |
17580
18436
  | SidebarItem | { label: ReactNode; icon?: ReactNode; active?: boolean; badge?: ReactNode; render?: useRender.RenderProp } + button props | A single nav row. label is the row text (inline when expanded, in a hover tooltip when collapsed); icon is the leading icon node (the consumer swaps fill weight for the active state); active marks the current route (accent fill + aria-current="page"); badge is a trailing node (e.g. a count) pushed to the row end and hidden when collapsed; render composes another element, e.g. render={<Link to="/x" />}. |
17581
18437
 
17582
18438
  ## Props
@@ -17611,10 +18467,10 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
17611
18467
  ### Rail with nav
17612
18468
 
17613
18469
  ```tsx
17614
- import { Icon, NotificationBadge, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav } from "@sableui/react"
18470
+ import { Icon, NotificationBadge, Sidebar, SidebarBrand, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav } from "@sableui/react"
17615
18471
  // <Link> = your router's link (react-router, next/link, @tanstack/router, …)
17616
18472
 
17617
- <Sidebar><SidebarHeader>{brand}</SidebarHeader><SidebarNav><SidebarItem icon={<Icon name="bell" />} label="Notifications" badge={<NotificationBadge count={3} />} render={<Link to="/notifications" />} /></SidebarNav><SidebarFooter>{wallet}</SidebarFooter></Sidebar>
18473
+ <Sidebar><SidebarHeader><SidebarBrand mark={<Icon name="music" variant="solid" />} label="Undertow" render={<Link to="/" />} /></SidebarHeader><SidebarNav><SidebarItem icon={<Icon name="bell" />} label="Notifications" badge={<NotificationBadge count={3} />} render={<Link to="/notifications" />} /></SidebarNav><SidebarFooter>{wallet}</SidebarFooter></Sidebar>
17618
18474
  ```
17619
18475
 
17620
18476
  ### Collapsible (icon rail)
@@ -17865,6 +18721,9 @@ Use Slider for bounded numeric choices where approximate adjustment is faster th
17865
18721
  | step | number | 1 | Granularity the slider steps through. Snaps to multiples. |
17866
18722
  | disabled | boolean | false | Ignore user interaction and dim the control. |
17867
18723
  | orientation | 'horizontal' \| 'vertical' | horizontal | Axis the slider tracks along. A vertical slider has no intrinsic length — give it a height (h-40, or flex-1 in a column). |
18724
+ | aria-label | string | — | Accessible name, applied to each thumb's input (the element with role=slider). A range also names its group with it. |
18725
+ | getAriaLabel | (index) => string | — | Per-thumb name for a range ("Minimum price" / "Maximum price"). Wins over aria-label on the thumbs. |
18726
+ | getAriaValueText | (formattedValue, value, index) => string | — | What a screen reader announces for the value when the number alone is wrong — a seek bar's "1:23 of 3:45". For units or percent, `format` (Intl.NumberFormatOptions) is enough. |
17868
18727
 
17869
18728
  ## Tokens used
17870
18729
 
@@ -17877,7 +18736,8 @@ Use Slider for bounded numeric choices where approximate adjustment is faster th
17877
18736
  - The thumb's hit area is a fixed 16px box at every state. Only the decorative inner dot resizes — rest 12px, full 16px on hover, keyboard focus, and drag — so the target a pointer or finger must land on never moves or changes size. Keyboard users, who never hover, get the enlarged dot from focus-visible.
17878
18737
  - The rest-to-full transition uses the motion duration/easing tokens, so it collapses to an instant state change under prefers-reduced-motion or MotionProvider off. The size change itself still happens: it reports interaction state, it is not decoration.
17879
18738
  - Range sliders get one thumb per value with minStepsBetweenValues honored.
17880
- - Pass an aria-label (or getAriaLabel on a thumb) so each handle has an accessible name.
18739
+ - Pass aria-label (or getAriaLabel for a range) so each handle has an accessible name. On Slider it reaches the thumb inputs; on a bare SliderRoot it only names the role=group — when composing, put aria-label / getAriaLabel on each SliderThumb, or label the Root with aria-labelledby or a Field, which Base UI does carry to the thumbs.
18740
+ - getAriaValueText sets aria-valuetext for values a number misreads (time, named steps).
17881
18741
 
17882
18742
  ## Related
17883
18743
 
@@ -17899,7 +18759,15 @@ import { Slider } from "@sableui/react"
17899
18759
  import { Slider } from "@sableui/react"
17900
18760
 
17901
18761
  const [range, setRange] = useState([20, 60])
17902
- <Slider value={range} onValueChange={setRange} aria-label="Price range" />
18762
+ <Slider value={range} onValueChange={setRange} aria-label="Price range" getAriaLabel={(i) => (i ? "Maximum price" : "Minimum price")} />
18763
+ ```
18764
+
18765
+ ### Seek bar with spoken time
18766
+
18767
+ ```tsx
18768
+ import { Slider } from "@sableui/react"
18769
+
18770
+ <Slider value={position} max={duration} onValueChange={seek} aria-label="Seek" getAriaValueText={(_, v) => `${formatTime(v)} of ${formatTime(duration)}`} />
17903
18771
  ```
17904
18772
 
17905
18773
  ### Stepped
@@ -18872,23 +19740,37 @@ import { Textarea } from "@sableui/react"
18872
19740
 
18873
19741
  **Status:** stable
18874
19742
 
18875
- A presentational radiogroup for choosing a Sable design theme from theme manifests — swatch-strip cards for a settings page, or compact rows for tool chrome. Pair it with useSableTheme().themeId and setThemeId when the app uses SableThemeProvider.
19743
+ A presentational radiogroup for choosing a Sable design theme from theme manifests — tiles that draw each theme as a miniature of its own interface (its colours, type and corner shape) for a settings page, or compact rows for tool chrome. Pair it with useSableTheme().themeId and setThemeId when the app uses SableThemeProvider.
18876
19744
 
18877
19745
  **Keywords:** theme selector, theme, appearance, skin, design theme, preset
18878
19746
 
18879
19747
  ## Usage
18880
19748
 
18881
- Use ThemeSelector wherever a person picks a theme. The default `cards` layout is page-register furniture: a swatch strip, a name, and a caption only where one distinguishes the card (the default theme, or a single-mode theme), at ~144px tall. Use `layout="list"` in tool chrome, a popover, or any docked panel — five cards is the wrong object for a 256px surface, however it is sized. For colour mode (light/dark/system) use ThemeSwitcher, which is a segmented control and belongs in chrome as-is.
19749
+ Use ThemeSelector wherever a person picks a theme. The default `cards` layout is page-register furniture: a miniature of each theme's interface (its background, "Aa" in its display face, a card, and a primary button in its own corner shape), with the name and a caption only where one distinguishes it (the default theme, or a single-mode theme). Use `layout="list"` in tool chrome, a popover, or any docked panel — five cards is the wrong object for a 256px surface, however it is sized. For colour mode (light/dark/system) use ThemeSwitcher, which is a segmented control and belongs in chrome as-is.
18882
19750
 
18883
19751
  ### Best practices
18884
19752
 
18885
- - Do: Use layout="list" in a popover, a menu, or a docked panel. Rows sit on the --control-sm ladder and still show each theme's swatch strip, so the choice stays visible rather than described. Rows take `sable-frame-inner`, so host the list in a sable-frame (`sable-frame sable-surface sable-surface-popover sable-frame-popover` — PopoverContent is not one; give it that class list, or its p-4 leaves the rows unnested) and their corners nest concentrically.
19753
+ - Do: Use layout="list" in a popover, a menu, or a docked panel. Rows sit on the --control-sm ladder and still show a small tile of each theme, so the choice stays visible rather than described. Rows take `sable-frame-inner`, so host the list in a sable-frame (`sable-frame sable-surface sable-surface-popover sable-frame-popover` — PopoverContent is not one; give it that class list, or its p-4 leaves the rows unnested) and their corners nest concentrically.
18886
19754
  - Do: Pass mode={useSableTheme().mode} so every preview renders in the colour mode actually in use, rather than in each theme's manifest default.
19755
+ - Do: Use ThemeSelector when switching is free: it is a radio group, so arrow keys check the next theme and onValueChange fires on every press — the page restyles as you go, which is the point of a live theme switcher. When a switch has a cost (it opens a "discard changes?" confirm, loads fonts, or resets other settings), use a DisclosurePicker list instead, with a `ThemePreview size="sm"` as each option's `media`: arrows only move there, and Enter, Space or a click commits.
18887
19756
  - Do: Load each theme's fonts in the host app. Every card carries a type specimen in that theme's own face so people can compare typography as well as color. A theme whose fonts are not loaded falls back to system-ui, and its specimen stops telling the truth.
18888
19757
  - Don't: Add a viewport breakpoint to force a column count — `className="sm:grid-cols-1"` or similar. `sm:` keys off the VIEWPORT, not this component's container, so on a wide screen it does nothing for a narrow parent. The cards grid already reflows on its own width via auto-fit.
18889
19758
  - Don't: Put the cards layout in workspace chrome. It was built for a settings page; in a 256px popover the cards clip their labels to a single character and grow to roughly twice their intended height.
18890
19759
  - Don't: Use ThemeSelector for light / dark / system colour mode. That is ThemeSwitcher — a segmented control. This picks the design theme, which is a different axis entirely, and the two are frequently confused.
18891
19760
 
19761
+ ### Anatomy
19762
+
19763
+ | Part | Required | Description |
19764
+ | --- | --- | --- |
19765
+ | ThemeSelector | yes | The radiogroup of themes, as cards or list rows. |
19766
+ | ThemePreview | no | The tile each option draws, exported for showing a theme outside the picker — the row naming the current theme, or the `media` of each option in a commit-on-Enter DisclosurePicker list. |
19767
+
19768
+ ## Subcomponents
19769
+
19770
+ | Name | Type | Description |
19771
+ | --- | --- | --- |
19772
+ | ThemePreview | { theme: ThemeManifest; mode?: "light" \| "dark"; size?: "md" \| "sm" } | The tile each option draws: the theme as a miniature of itself (background, "Aa" in its display face, a card, a primary button in its own corner shape), in its own scope. Exported for anywhere that shows a theme outside the picker, e.g. a row naming the current preset, or each option of a DisclosurePicker list where a pick has a cost. Sized in the caller's spacing unit, so it lines up with the row it sits in. |
19773
+
18892
19774
  ## Props
18893
19775
 
18894
19776
  | Name | Type | Default | Description |
@@ -18896,7 +19778,7 @@ Use ThemeSelector wherever a person picks a theme. The default `cards` layout is
18896
19778
  | value | ThemeId | — | The currently selected Sable design theme id. |
18897
19779
  | onValueChange | (themeId: ThemeId) => void | — | Called with the next design theme id when an option is chosen. |
18898
19780
  | themes | readonly ThemeManifest[] | themeManifests | Theme manifests to render. Defaults to Sable's built-in themes. |
18899
- | mode | "light" \| "dark" | — | Preview mode for each card's swatch strip. Pass useSableTheme().mode to match the current color mode; omitted cards use their manifest defaultMode. |
19781
+ | mode | "light" \| "dark" | — | Preview mode for each theme's tile. Pass useSableTheme().mode to match the current color mode; omitted cards use their manifest defaultMode. |
18900
19782
  | label | string | "Design theme" | Accessible label for the radiogroup. |
18901
19783
  | layout | "cards" \| "list" | "cards" | Presentation. "cards" is the page-register picker (~144px per card); "list" is one compact --control-sm row per theme, for chrome and popovers. A presentation variant, not a size — "list" is not "cards, smaller" — which is why it is a prop rather than a density scope. |
18902
19784
  | className | string | — | Merged onto the outer radiogroup grid. |
@@ -18908,6 +19790,7 @@ Use ThemeSelector wherever a person picks a theme. The default `cards` layout is
18908
19790
  ## Accessibility
18909
19791
 
18910
19792
  - The container is role="radiogroup" with an aria-label; each card is a role="radio" button with aria-checked reflecting the selected theme.
19793
+ - Standard radio keyboard: arrow keys move the check — calling onValueChange on each press — and Space checks the focused option. That instant switching is right for a free, reversible choice; where a switch has a cost, use a DisclosurePicker list (a listbox: arrows move, Enter commits) with ThemePreview tiles instead.
18911
19794
  - Focus-visible draws a 2px ring on the focused card.
18912
19795
  - The selected card wears the shared selection-card checked ring (the same as RadioCard and CheckboxCard), a muted fill and a check icon, so selection does not rely on colour alone.
18913
19796
  - Presentational only: it does not change the document theme; pass useSableTheme().setThemeId or apply the change in onValueChange.
@@ -18918,6 +19801,31 @@ Use ThemeSelector wherever a person picks a theme. The default `cards` layout is
18918
19801
 
18919
19802
  ## Examples
18920
19803
 
19804
+ ### When a switch has a cost: a DisclosurePicker list of ThemePreview tiles
19805
+
19806
+ ```tsx
19807
+ import { DisclosurePicker, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger, ThemePreview, useSableTheme } from "@sableui/react"
19808
+
19809
+ // Arrows move; Enter, Space or a click commits — so whatever the switch costs
19810
+ // (a confirm, a font load, a reset) happens once, on the pick.
19811
+ const { themeId, setThemeId, themes, mode } = useSableTheme()
19812
+ const current = themes.find((theme) => theme.id === themeId)
19813
+ <DisclosurePicker>
19814
+ <DisclosurePickerTrigger aria-label={`Theme: ${current?.label}`} media={current ? <ThemePreview theme={current} mode={mode} size="sm" /> : null}>
19815
+ {current?.label}
19816
+ </DisclosurePickerTrigger>
19817
+ <DisclosurePickerPanel>
19818
+ <DisclosurePickerList aria-label="Theme" value={themeId} onValueChange={setThemeId}>
19819
+ {themes.map((theme) => (
19820
+ <DisclosurePickerOption key={theme.id} value={theme.id} media={<ThemePreview theme={theme} mode={mode} size="sm" />}>
19821
+ {theme.label}
19822
+ </DisclosurePickerOption>
19823
+ ))}
19824
+ </DisclosurePickerList>
19825
+ </DisclosurePickerPanel>
19826
+ </DisclosurePicker>
19827
+ ```
19828
+
18921
19829
  ### With SableThemeProvider
18922
19830
 
18923
19831
  ```tsx
@@ -19660,7 +20568,7 @@ Use Tree for the hierarchy panel in a workspace: layers, objects, an inspector o
19660
20568
  - Do: Give Tree an aria-label naming the hierarchy ("Layers", "Files"). It is a role="tree", and an unnamed tree is announced as just "tree".
19661
20569
  - Do: For a file tree, nest with `subtree` and use the icon convention: `icon={<Icon name="folder" />}` on a row with a subtree, `icon={<Icon name="file" />}` on a leaf. There are no folder/file presets — they would only bind those two props.
19662
20570
  - Do: Give TreeAddRow the same depth as the rows it adds to, so its mark lands in their type column: one deeper than the parent for a child list, the parent's own depth for a sibling list.
19663
- - Do: Put mutable per-row state (visibility, solo, a row menu) in `actions`, on the right rail. Show it on hover, and at rest only when its value is the non-default one.
20571
+ - Do: Put mutable per-row state (visibility, solo, a row menu) in `actions`, on the right rail. Show it on hover with `reveal-on-hover` (the row is its `group`), and at rest only when its value is the non-default one.
19664
20572
  - Do: Let section headers sit on the rail at the same height as a depth-0 row. Space and a hairline separate sections; TreeSection handles both, including dropping the divider on the first.
19665
20573
  - Don't: Hardcode a left inset anywhere in the panel. Use treeIndent(depth) or var(--tree-rail); a hand-written px value is how a tree ends up with four different left edges.
19666
20574
  - Don't: Redefine --tree-indent to anything other than --tree-column. A step smaller than the column puts a child's content left of its parent's and the nesting reads inverted.
@@ -19703,7 +20611,7 @@ Use Tree for the hierarchy panel in a workspace: layers, objects, an inspector o
19703
20611
  | selected | boolean | false | Selection fill and accent bar on a TreeRow. Inside a Tree it is exposed as aria-selected on the treeitem; a row rendered outside a Tree is a plain div and exposes aria-current instead. |
19704
20612
  | toggleLabel | string | — | Names the thing being disclosed, for the toggle's accessible label — "Expand Background" rather than "Expand group". Every example passes it; a screen-reader user hearing twenty identical "Expand group" buttons has no way to tell them apart. |
19705
20613
  | icon | ReactNode | — | The kind marker for a TreeRow, rendered in the type column at 1em. Use a semantic Icon so it follows the app icon set. |
19706
- | disclosureIcon | ReactNode | — | Replaces TreeRow's semantic chevron-down icon. Omit to use the current IconProvider set or Reicon by default. |
20614
+ | disclosureIcon | ReactNode | — | Replaces TreeRow's semantic chevron-down icon. Omit to use the chevron-down from the app's IconProvider set. |
19707
20615
  | actions | ReactNode | — | Right-rail controls for a TreeRow: visibility, solo, a row menu. |
19708
20616
  | label | ReactNode | — | TreeAddRow's visible label. Name what gets added — 'Add effect', not 'Add'. |
19709
20617
 
@@ -19719,7 +20627,7 @@ Use Tree for the hierarchy panel in a workspace: layers, objects, an inspector o
19719
20627
  - Each treeitem is named by its own label (aria-labelledby the name), never by the text of its icon or action buttons.
19720
20628
  - The disclosure is a real button with aria-expanded and a label naming what it discloses, and it stops propagation so collapsing a group never changes the selection. Inside a Tree the treeitem carries aria-expanded and the arrow keys, so the button becomes a pointer target only: aria-hidden, out of the tab order, and it leaves focus where it was when clicked.
19721
20629
  - The disclosure's POINTER TARGET is bigger than the mark it draws. `--tree-column` is 20px comfortable and 18px compact, both under WCAG 2.5.8's 24x24 floor, so a centred pseudo-element takes the hit at `--control-xs` (28/24) instead. The drawn box stays on the column, because that column is also the indent step — growing the button would push every name in the tree one column right.
19722
- - Right-rail actions stay in the tab order, so a row with actions costs extra Tab stops. Keep actions few and show them on hover and focus-visible.
20630
+ - Right-rail actions stay in the tab order, so a row with actions costs extra Tab stops. Keep actions few and reveal them with `reveal-on-hover`, which also shows them on focus and always on touch.
19723
20631
  - `dimmed` is presentation only. A row switched off must also say so in its actions' aria-pressed or its accessible name; opacity alone is invisible to a screen reader and fails contrast.
19724
20632
  - Right-rail controls that appear on hover must also appear on focus-visible, or they are unreachable by keyboard.
19725
20633
 
@@ -20428,8 +21336,7 @@ Which token to reach for, and what it is most often confused with. Generated fro
20428
21336
 
20429
21337
  The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, when to reach for it, and what you would hand-write without it. Generated from `src/utilities/utility-docs.ts`; the full data is `registry.json → utilities`; one entry at a time with `sable utility <name>`.
20430
21338
 
20431
- - **Typography:** The paired `text-*` page tiers (`text-heading-1`, `text-body-md`, `text-label-sm`, `text-mono-md`, …) are documented as `registry.json → tokens.typography` (named without the `text-` prefix). `sable token typography`.
20432
- - **Motion:** The `motion-*` and `animate-*` recipes are listed in `registry.json → tokens.motion.recipes`, with the slot model in `registry.json → motionLayer`. `sable docs motion`.
21339
+ - **Typography:** The paired `text-*` page tiers (`text-heading-1`, `text-body-md`, `text-label-sm`, `text-mono-md`, …) are documented as `registry.json → tokens.typography` (named without the `text-` prefix). Retrieve one with `sable utility text-heading-1`, all with `sable token typography`.
20433
21340
 
20434
21341
  ### relative surface (6)
20435
21342
 
@@ -20557,12 +21464,93 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
20557
21464
  - **Instead of** An animated fade or a hand-written `mask-image` gradient. Use `StreamingText` first; this is for a stream rendered without it.
20558
21465
  - `<span className="stream-tail">{latestChunk}</span>`
20559
21466
 
20560
- ### helper (3)
21467
+ ### motion (24)
21468
+
21469
+ - **`motion-interactive`** — The transition for interactive chrome: colour, background, border, shadow, opacity, fill, stroke, scale and translate at the fast duration and standard easing. Also removes the mobile tap delay and the platform's grey tap flash. *Use for:* On anything clickable that changes on hover, focus, press or selection: a custom row, a tile, a link, an app-owned button. It is the `response` slot, and the only transition declarer such an element needs.
21470
+ - **Instead of** Tailwind's colour-only or all-property transition utilities with a hand-picked duration. Those ignore --motion-scale, skip the transform properties `motion-press` needs, and leave the 300ms tap delay on touch screens. sable-lint's `static-interactive` fires on a hover style with no recipe.
21471
+ - `<a href="/inbox" className="motion-interactive link-underline text-primary">Inbox</a>`
21472
+ - **`motion-press`** — A small tactile settle (scale 0.97) while the element is pressed. Skips disabled elements. Declares only the pressed state, not a transition. *Use for:* A pressable surface that is not a Sable Button (Button already has it): a tile, a card that acts as a button, an icon control.
21473
+ - **Instead of** An `active:` scale utility with its own transition, which fights `motion-interactive` for the transition property and keeps animating with motion off.
21474
+ - `<button type="button" className="motion-interactive motion-press sable-surface sable-frame sable-frame-r-lg sable-frame-p-4">Open</button>`
21475
+ - **`motion-fade`** — Opacity-only enter and exit, keyed to Base UI's `data-starting-style` / `data-ending-style`: fades in at the enter duration, out at the exit duration. *Use for:* Scrims, backdrops and overlays that should appear without moving. The element rests visible; the data attributes supply the from and to.
21476
+ - **Instead of** A keyframe fade or a conditional opacity class, which cannot animate the exit of an element Base UI is unmounting.
21477
+ - `<div className="motion-fade fixed inset-0 bg-overlay" />`
21478
+ - **`motion-pop`** — Enter with a slight scale from the anchor edge (spring easing) and exit faster without overshoot, keyed to `data-starting-style` / `data-ending-style`. Scales from Base UI's `--transform-origin`. *Use for:* Anchored or centred floating surfaces: popovers, menus, selects, tooltips, dialogs, command menus. Sable's own overlays already carry it.
21479
+ - **Instead of** A custom zoom keyframe, or a spring on exit, which draws the eye to what the user just dismissed.
21480
+ - `<div className="motion-pop sable-surface-popover bg-popover text-popover-foreground rounded-lg shadow-lg">…</div>`
21481
+ - **`motion-slide-up`** — Fade plus a 0.5rem rise on enter, reversed faster on exit, keyed to `data-starting-style` / `data-ending-style`. The suffix is the travel direction on enter. *Use for:* A surface arriving from below: a toast at the bottom of the screen, a bottom bar, a banner under its trigger.
21482
+ - **Instead of** A translate keyframe with a literal duration, which keeps moving under reduced motion.
21483
+ - `<div className="motion-slide-up sable-surface rounded-lg">Saved</div>`
21484
+ - **`motion-slide-down`** — Fade plus a 0.5rem drop on enter, reversed faster on exit, keyed to `data-starting-style` / `data-ending-style`. *Use for:* A surface arriving from above: a top toast, a dropdown panel that reads better sliding than scaling.
21485
+ - **Instead of** A translate keyframe with a literal duration.
21486
+ - `<div className="motion-slide-down sable-surface rounded-lg">Connected</div>`
21487
+ - **`motion-slide-left`** — Fade plus a 0.5rem travel leftward on enter (it starts offset to the right), reversed faster on exit. *Use for:* A surface arriving from the right edge: a side panel, a right-anchored toast.
21488
+ - **Instead of** A translate keyframe with a literal duration.
21489
+ - `<aside className="motion-slide-left sable-surface">…</aside>`
21490
+ - **`motion-slide-right`** — Fade plus a 0.5rem travel rightward on enter (it starts offset to the left), reversed faster on exit. *Use for:* A surface arriving from the left edge: a navigation drawer, a left-anchored toast.
21491
+ - **Instead of** A translate keyframe with a literal duration.
21492
+ - `<nav className="motion-slide-right sable-surface">…</nav>`
21493
+ - **`motion-disclosure`** — Height plus fade for a panel that opens and closes: carries the overflow, the transition and the content fade. The component sets the target height and zeroes it on the starting/ending style. *Use for:* A hand-built expanding region. Accordion and Collapsible already use it; reach for them first.
21494
+ - **Instead of** Animating max-height to a guessed number, or a show/hide with no transition. Height is the one sanctioned non-compositor property.
21495
+ - `<div className="motion-disclosure h-[var(--collapsible-panel-height)]">…</div>`
21496
+ - **`motion-icon-swap`** — Cross-fades two glyphs stacked in one cell when a control's icon changes with its state: the outgoing one shrinks and blurs out, the incoming one resolves in. The inactive glyph is the one marked `aria-hidden="true"` (or `data-active="false"`). *Use for:* Play/pause, mute/unmute, outline/fill, copy/check, menu/close: one control whose icon changes. Keep both icons mounted; the correct accessibility markup is also the animation hook.
21497
+ - **Instead of** Rendering one icon or the other (it snaps between two frames), or absolutely positioning the second icon, which collapses the wrapper. Under reduced motion the swap is instant and the glyph still changes.
21498
+ - `<span className="motion-icon-swap"><Icon name="play" aria-hidden={playing} /><Icon name="pause" aria-hidden={!playing} /></span>`
21499
+ - **`motion-spin`** — A continuous rotation for a spinner glyph, scaled by --motion-scale so reduced motion freezes it. *Use for:* A loading glyph that is not the Spinner component (use Spinner first), such as an icon inside a busy button.
21500
+ - **Instead of** Tailwind's built-in spin animation, which ignores --motion-scale. sable-lint's `builtin-animate` flags it.
21501
+ - `<Icon name="spinner" className="motion-spin" />`
21502
+ - **`motion-orbit`** — A slow continuous rotation (24s by default) for ambient line art. `--motion-orbit-duration` sets the speed and `--motion-orbit-direction` the direction; it rotates about the centre of any viewBox. *Use for:* The idle loop of an illustration or schematic after it draws on, usually with `motion-draw` on the same figure.
21503
+ - **Instead of** `motion-spin`, which is a loader's speed, or a hand-written keyframe that keeps turning under reduced motion.
21504
+ - `<svg className="motion-orbit [--motion-orbit-duration:40s]" viewBox="0 0 200 200">…</svg>`
21505
+ - **`motion-pulse`** — A soft looping opacity pulse, scaled by --motion-scale. *Use for:* A live or pending indicator: a recording dot, an unsaved marker, a pending status.
21506
+ - **Instead of** Tailwind's built-in pulse animation, which ignores --motion-scale. For a loading placeholder use Skeleton or `motion-shimmer`.
21507
+ - `<span className="motion-pulse size-2 rounded-full bg-destructive" />`
21508
+ - **`motion-shimmer`** — The skeleton sweep: one band of light that passes across every skeleton on screen in sequence, because the gradient is fixed to the viewport. Rests off-screen, so with motion off a skeleton is a calm block. *Use for:* A hand-built loading placeholder. The Skeleton component already uses it.
21509
+ - **Instead of** A pulse on each block, or a per-box gradient, where a short line and a tall card sweep at different speeds.
21510
+ - `<div className="motion-shimmer h-4 w-48 rounded-md bg-muted" />`
21511
+ - **`motion-text-shimmer`** — The sweep run through the glyphs instead of behind them, for a live status line. The gradient goes muted to foreground and back, so the text stays readable on every frame. *Use for:* Status text while work is happening: "Thinking…", "Searching", "Generating response".
21512
+ - **Instead of** `motion-shimmer` on text, which paints a band behind real words and reads as a skeleton. Named `motion-*`, not `text-*`, so tailwind-merge does not drop the colour class beside it.
21513
+ - `<span className="motion-text-shimmer text-body-sm">Thinking…</span>`
21514
+ - **`motion-dot-bounce`** — A looping vertical bounce for one dot of a typing or waiting indicator. Offset each dot with an animation delay. *Use for:* A three-dot typing indicator in a chat or assistant surface.
21515
+ - **Instead of** Tailwind's built-in bounce animation, which ignores --motion-scale.
21516
+ - `<span className="motion-dot-bounce size-1.5 rounded-full bg-muted-foreground" />`
21517
+ - **`motion-ping`** — A looping ring that expands and fades from a point, scaled by --motion-scale. *Use for:* Drawing attention to a live point: a new-activity dot, a location marker, a beacon behind a status dot.
21518
+ - **Instead of** Tailwind's built-in ping animation, which ignores --motion-scale.
21519
+ - `<span className="motion-ping absolute inset-0 rounded-full bg-success" />`
21520
+ - **`animate-fade-in`** — A one-shot fade in on mount at the base duration, decelerating, with `both` fill so there is no flash before it starts. *Use for:* Content that appears once when a route or panel mounts.
21521
+ - **Instead of** A keyframe with a literal duration. For content entering on scroll use Reveal (`motion-reveal`).
21522
+ - `<section className="animate-fade-in">…</section>`
21523
+ - **`animate-fade-up`** — A one-shot fade plus rise on mount at the slow duration, decelerating, with `both` fill. *Use for:* A page section or list arriving from below on mount.
21524
+ - **Instead of** A translate keyframe with a literal duration. For scroll-triggered entrances use Reveal.
21525
+ - `<div className="animate-fade-up">…</div>`
21526
+ - **`animate-fade-down`** — A one-shot fade plus drop on mount at the slow duration, decelerating, with `both` fill. *Use for:* Content arriving from above on mount, such as a notice under a header.
21527
+ - **Instead of** A translate keyframe with a literal duration.
21528
+ - `<div className="animate-fade-down">…</div>`
21529
+ - **`motion-stream-in`** — Each token of a live stream resolves in from a slight blur once, on mount. Knows nothing of the final length, so there is no stagger. *Use for:* Wrapping each arriving chunk of a streamed response. StreamingText already does it.
21530
+ - **Instead of** The reveal recipes, which stage text that is already there, or a typewriter effect.
21531
+ - `<span key={chunk.id} className="motion-stream-in">{chunk.text}</span>`
21532
+ - **`motion-caret`** — A caret that blinks only when idle and holds steady while `data-streaming="true"`, so the blink returning signals the stream has finished. *Use for:* The cursor at the end of streamed or typed text.
21533
+ - **Instead of** A caret that blinks while text is arriving, which sets two competing rhythms.
21534
+ - `<span className="motion-caret" data-streaming={streaming} aria-hidden="true">▍</span>`
21535
+ - **`motion-reveal`** — Fade plus a 0.75rem rise when `data-reveal` flips from "out" to "in". Out of view it rests hidden and nudged down; with motion off the content stays visible. *Use for:* Through the Reveal and TextReveal components, which set `data-reveal` when the element scrolls into view. Use the class directly only on an element whose `data-reveal` you set yourself.
21536
+ - **Instead of** An IntersectionObserver plus hand-written opacity classes.
21537
+ - `<Reveal><Card>…</Card></Reveal>`
21538
+ - **`motion-draw`** — Draws an SVG path on when an ancestor's `data-reveal` flips to "in". The path needs `pathLength="1"`. `--motion-draw-duration` and `--motion-draw-delay` (both scaled by --motion-scale) set its pace and stagger. *Use for:* Line art and schematics inside a Reveal. Stagger sibling paths with `--motion-draw-delay`.
21539
+ - **Instead of** A hand-written dash-offset animation with literal timing, which keeps animating, or keeps its delay, under reduced motion.
21540
+ - `<Reveal><svg viewBox="0 0 100 100"><path className="motion-draw" pathLength="1" d="M10 50 L90 50" /></svg></Reveal>`
21541
+
21542
+ ### helper (5)
20561
21543
 
20562
21544
  - **`hit-target`** — Grows an element's tap target to 44px with a transparent `::after`, without moving its layout by a pixel. `--hit-target-size` overrides the size. *Use for:* A deliberately small mark — a tag's remove ×, a tiny disclosure, a toast close — that must still meet the WCAG touch floor. Shrink it (`[--hit-target-size:32px]`) where targets sit side by side, or the later one steals the taps.
20563
21545
  - **Instead of** Padding the glyph up (moves it off the row's grid and pushes siblings) or a bigger icon. Never on an `<input>` or other replaced element — pseudo-elements silently do nothing there; wrap it in a label.
20564
21546
  - `<button className="hit-target text-muted-foreground" aria-label="Remove"><Icon name="xmark" /></button>`
20565
- - **`link-underline`** — An underline drawn from the font's own metrics (position and thickness `from-font`) with ink skipping descenders, so it retunes when a theme swaps typeface. *Use for:* Inline links in running text. Pair with `motion-interactive` for a colour change on hover.
21547
+ - **`reveal-on-hover`** — Hides a row or tile action until its item is hovered or holds focus — on hover-capable devices only. Touch and no-hover devices always show it, and it fades with opacity, so it never leaves the tab order or the accessibility tree. The item is the nearest ancestor with `group` (or `data-reveal-scope`); an open popup keeps its trigger visible. `--reveal-offset` adds a short rise. *Use for:* Secondary per-item actions in a dense list or grid — a tile's play button, a row's overflow menu or visibility toggle — where showing every action at rest would turn the list into a wall of controls. Put `group` on the item (the row, the tile), not on the list, and the class on the action or its wrapper.
21548
+ - **Instead of** `opacity-0 group-hover:opacity-100` (hidden forever on a phone), `hidden group-hover:flex` (drops it from the tab order and the accessibility tree), or a hand-written `[@media(hover:hover)]:opacity-0` pair (snaps, and misses focus and open menus). Not for a primary action — that stays visible.
21549
+ - `<li className="group flex items-center gap-2"><span className="flex-1">Track</span><Button variant="ghost" size="icon-sm" aria-label="Like Track" className="reveal-on-hover"><Icon name="heart" /></Button></li>`
21550
+ - **`stretched-link`** — Makes one link cover its whole item with a transparent `::after`, so a tile or card is clickable everywhere while its other links and buttons stay separate controls. Focus draws the ring on the overlay, rounded by `--stretched-link-radius`. *Use for:* A card, tile or row that IS a link but also holds a second control — a subtitle link to the artist, a play button, a menu. Put it on the primary link (usually the title), `relative` on the item, and `relative z-10` on every other control.
21551
+ - **Instead of** Wrapping the item in an `<a>` or `Card render={<a />}` — invalid as soon as it nests another link or button, which browsers re-parent and screen readers read as one long link. Not with `hit-target` on the same element (both own `::after`).
21552
+ - `<article className="relative [--stretched-link-radius:var(--radius-lg)]"><a href="/albums/1" className="stretched-link">Low tide</a><a href="/artists/2" className="relative z-10">Juno Avery</a></article>`
21553
+ - **`link-underline`** — An underline drawn from the font's own metrics (position and thickness `from-font`) with ink skipping descenders, so it retunes when a theme swaps typeface. Also carries the link's focus-visible outline in `--ring`, so a plain `<a>` meets the focus floor without app CSS. *Use for:* Every plain text link — inline in running text or standalone ("Forgot password?"). Pair with `motion-interactive` for a colour change on hover.
20566
21554
  - **Instead of** `underline underline-offset-4 decoration-2` — tuned to one face and wrong for the next theme's. Animating thickness or offset is not supported; a growing underline is a separate pseudo-element.
20567
21555
  - `<a href="/terms" className="link-underline motion-interactive text-primary">terms</a>`
20568
21556
  - **`checkerboard`** — The transparency checkerboard, painted from `--panel` and a derived darker step so it reads in both modes. `--checkerboard-size` (default 16px) and `--checkerboard-alt` tune it. *Use for:* Behind anything that can be translucent: a colour swatch with alpha, an image or artboard preview, an alpha slider track.