@sableui/react 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/AGENTS.md +157 -0
  2. package/CHANGELOG.md +50 -0
  3. package/CONTRIBUTING.md +221 -0
  4. package/DESIGN.md +548 -0
  5. package/LICENSE +21 -0
  6. package/README.md +194 -2
  7. package/THIRD_PARTY_NOTICES.md +44 -0
  8. package/bin/sable-lint.mjs +0 -0
  9. package/bin/sable.mjs +1223 -0
  10. package/dist/_contract-CYScOdf5.d.ts +150 -0
  11. package/dist/chunk-3IWZYBGH.js +398 -0
  12. package/dist/chunk-5Z5KLOAU.js +167 -0
  13. package/dist/chunk-7MG4SHYX.js +15 -0
  14. package/dist/chunk-ADLLDWLW.js +15 -0
  15. package/dist/chunk-EP55KSYA.js +14 -0
  16. package/dist/chunk-J23ELQQZ.js +9396 -0
  17. package/dist/chunk-JI7WJP2L.js +286 -0
  18. package/dist/chunk-K7RUHUGC.js +14 -0
  19. package/dist/chunk-KO3NAEBF.js +15 -0
  20. package/dist/chunk-L4IEIQQA.js +14 -0
  21. package/dist/chunk-L6Y46C62.js +270 -0
  22. package/dist/chunk-NEQNXI2G.js +25 -0
  23. package/dist/chunk-PAOZBZME.js +40 -0
  24. package/dist/chunk-RWP4XBSK.js +139 -0
  25. package/dist/chunk-SRQWQUTW.js +63 -0
  26. package/dist/chunk-YXBOYWBN.js +15 -0
  27. package/dist/foundations/index.d.ts +88 -0
  28. package/dist/foundations/index.js +792 -0
  29. package/dist/icon/index.d.ts +60 -0
  30. package/dist/icon/index.js +2 -0
  31. package/dist/icon-sets/brands.d.ts +44 -0
  32. package/dist/icon-sets/brands.js +147 -0
  33. package/dist/icon-sets/reicon.d.ts +8 -0
  34. package/dist/icon-sets/reicon.js +1 -0
  35. package/dist/index.d.ts +7013 -0
  36. package/dist/index.js +15462 -0
  37. package/dist/ios/chrome.css +83 -0
  38. package/dist/ios/frame.css +5 -0
  39. package/dist/ios/index.d.ts +70 -0
  40. package/dist/ios/index.js +313 -0
  41. package/dist/ios/preview.d.ts +64 -0
  42. package/dist/ios/preview.js +133 -0
  43. package/dist/ios/styles.css +77 -0
  44. package/dist/layout/index.d.ts +280 -0
  45. package/dist/layout/index.js +2 -0
  46. package/dist/meta-6dUzgC6M.d.ts +205 -0
  47. package/dist/patterns/index.d.ts +36 -0
  48. package/dist/patterns/index.js +934 -0
  49. package/dist/presets/index.d.ts +37 -0
  50. package/dist/presets/index.js +117 -0
  51. package/dist/registry-CyL28OzJ.d.ts +28 -0
  52. package/dist/styles.css +1893 -0
  53. package/dist/theme-provider-BWcizhAt.d.ts +105 -0
  54. package/dist/themes/atelier/atelier.css +213 -0
  55. package/dist/themes/atelier/index.d.ts +9 -0
  56. package/dist/themes/atelier/index.js +1 -0
  57. package/dist/themes/base/base.css +443 -0
  58. package/dist/themes/base/index.d.ts +9 -0
  59. package/dist/themes/base/index.js +1 -0
  60. package/dist/themes/ember/ember.css +270 -0
  61. package/dist/themes/ember/index.d.ts +9 -0
  62. package/dist/themes/ember/index.js +1 -0
  63. package/dist/themes/index.d.ts +10 -0
  64. package/dist/themes/index.js +8 -0
  65. package/dist/themes/meridian/index.d.ts +9 -0
  66. package/dist/themes/meridian/index.js +1 -0
  67. package/dist/themes/meridian/meridian.css +255 -0
  68. package/dist/themes/poppy/index.d.ts +6 -0
  69. package/dist/themes/poppy/index.js +1 -0
  70. package/dist/themes/poppy/poppy.css +174 -0
  71. package/dist/themes/prism/index.d.ts +6 -0
  72. package/dist/themes/prism/index.js +1 -0
  73. package/dist/themes/prism/prism.css +183 -0
  74. package/dist/themes/verdant/index.d.ts +9 -0
  75. package/dist/themes/verdant/index.js +1 -0
  76. package/dist/themes/verdant/verdant.css +210 -0
  77. package/dist/themes.css +30 -0
  78. package/llms/components/accordion.md +93 -0
  79. package/llms/components/app-header.md +70 -0
  80. package/llms/components/app-shell.md +117 -0
  81. package/llms/components/attachment.md +105 -0
  82. package/llms/components/avatar.md +141 -0
  83. package/llms/components/badge.md +117 -0
  84. package/llms/components/banner.md +119 -0
  85. package/llms/components/bottom-nav.md +80 -0
  86. package/llms/components/breadcrumbs.md +75 -0
  87. package/llms/components/browser.md +67 -0
  88. package/llms/components/bubble.md +119 -0
  89. package/llms/components/button.md +199 -0
  90. package/llms/components/calendar.md +75 -0
  91. package/llms/components/card.md +135 -0
  92. package/llms/components/carousel.md +87 -0
  93. package/llms/components/chat-composer.md +126 -0
  94. package/llms/components/checkbox-card.md +98 -0
  95. package/llms/components/checkbox.md +90 -0
  96. package/llms/components/code-block.md +81 -0
  97. package/llms/components/code.md +62 -0
  98. package/llms/components/collapsible.md +83 -0
  99. package/llms/components/color-field.md +100 -0
  100. package/llms/components/combobox.md +119 -0
  101. package/llms/components/command-menu.md +158 -0
  102. package/llms/components/context-card.md +73 -0
  103. package/llms/components/context-menu.md +79 -0
  104. package/llms/components/data-table.md +99 -0
  105. package/llms/components/date-picker.md +97 -0
  106. package/llms/components/description.md +96 -0
  107. package/llms/components/destructive-action-modal.md +76 -0
  108. package/llms/components/dialog.md +127 -0
  109. package/llms/components/dots-menu.md +64 -0
  110. package/llms/components/empty-state.md +113 -0
  111. package/llms/components/entity.md +67 -0
  112. package/llms/components/feedback.md +63 -0
  113. package/llms/components/fieldset.md +106 -0
  114. package/llms/components/file-dropzone.md +103 -0
  115. package/llms/components/form.md +111 -0
  116. package/llms/components/gliding-highlight.md +84 -0
  117. package/llms/components/inline-status.md +62 -0
  118. package/llms/components/input.md +127 -0
  119. package/llms/components/kbd.md +57 -0
  120. package/llms/components/label.md +73 -0
  121. package/llms/components/marker.md +99 -0
  122. package/llms/components/marquee.md +66 -0
  123. package/llms/components/menu.md +122 -0
  124. package/llms/components/message-scroller.md +110 -0
  125. package/llms/components/message.md +114 -0
  126. package/llms/components/meter.md +124 -0
  127. package/llms/components/middle-truncate.md +62 -0
  128. package/llms/components/note.md +81 -0
  129. package/llms/components/notification-badge.md +67 -0
  130. package/llms/components/number-field.md +70 -0
  131. package/llms/components/otp-field.md +101 -0
  132. package/llms/components/page-header.md +76 -0
  133. package/llms/components/page-loader.md +67 -0
  134. package/llms/components/pagination.md +92 -0
  135. package/llms/components/panel-group.md +120 -0
  136. package/llms/components/panel-section.md +122 -0
  137. package/llms/components/phone.md +70 -0
  138. package/llms/components/pill.md +73 -0
  139. package/llms/components/popover.md +79 -0
  140. package/llms/components/progress.md +98 -0
  141. package/llms/components/radio-card.md +90 -0
  142. package/llms/components/radio.md +86 -0
  143. package/llms/components/relative-time.md +60 -0
  144. package/llms/components/scroller.md +88 -0
  145. package/llms/components/scrub-field.md +97 -0
  146. package/llms/components/search-input.md +84 -0
  147. package/llms/components/select.md +106 -0
  148. package/llms/components/separator.md +70 -0
  149. package/llms/components/settings-menu.md +139 -0
  150. package/llms/components/sheet.md +97 -0
  151. package/llms/components/show-more.md +59 -0
  152. package/llms/components/side-nav.md +106 -0
  153. package/llms/components/sidebar.md +87 -0
  154. package/llms/components/skeleton.md +62 -0
  155. package/llms/components/slider.md +114 -0
  156. package/llms/components/spinner.md +87 -0
  157. package/llms/components/status-bar.md +91 -0
  158. package/llms/components/status-dot.md +70 -0
  159. package/llms/components/stepper.md +125 -0
  160. package/llms/components/streaming-text.md +86 -0
  161. package/llms/components/switch.md +87 -0
  162. package/llms/components/table.md +112 -0
  163. package/llms/components/tabs.md +84 -0
  164. package/llms/components/textarea.md +88 -0
  165. package/llms/components/theme-selector.md +95 -0
  166. package/llms/components/theme-switcher.md +71 -0
  167. package/llms/components/thinking-orb.md +91 -0
  168. package/llms/components/toast.md +130 -0
  169. package/llms/components/toggle.md +95 -0
  170. package/llms/components/toolbar.md +168 -0
  171. package/llms/components/tooltip.md +93 -0
  172. package/llms/components/tree.md +227 -0
  173. package/llms/components/video.md +66 -0
  174. package/llms/components/workspace.md +138 -0
  175. package/llms/foundations/accessibility.md +60 -0
  176. package/llms/foundations/motion.md +96 -0
  177. package/llms/foundations/scrolling.md +41 -0
  178. package/llms/foundations/shape.md +73 -0
  179. package/llms/foundations/spacing.md +82 -0
  180. package/llms/foundations/surfaces.md +69 -0
  181. package/llms/foundations/typography.md +120 -0
  182. package/llms/layout/aspect-box.md +37 -0
  183. package/llms/layout/bleed.md +31 -0
  184. package/llms/layout/center.md +37 -0
  185. package/llms/layout/columns.md +51 -0
  186. package/llms/layout/grid-overlay.md +40 -0
  187. package/llms/layout/grid.md +47 -0
  188. package/llms/layout/page.md +39 -0
  189. package/llms/layout/region.md +49 -0
  190. package/llms/layout/row.md +40 -0
  191. package/llms/layout/safe-area.md +37 -0
  192. package/llms/layout/spacer.md +39 -0
  193. package/llms/layout/split-pane.md +41 -0
  194. package/llms/layout/stack.md +39 -0
  195. package/llms/patterns/auth-page.md +68 -0
  196. package/llms/patterns/brand-landing-page.md +423 -0
  197. package/llms/patterns/chat-panel.md +210 -0
  198. package/llms/patterns/dashboard-page.md +170 -0
  199. package/llms/patterns/feed-page.md +103 -0
  200. package/llms/patterns/landing-page.md +122 -0
  201. package/llms/patterns/list-detail-page.md +100 -0
  202. package/llms/patterns/settings-page.md +121 -0
  203. package/llms/reference/token-intent.md +324 -0
  204. package/llms/reference/utilities.md +129 -0
  205. package/llms/tasks/adding-a-component.md +113 -0
  206. package/llms/tasks/auditing-an-app.md +216 -0
  207. package/llms/tasks/building-a-page.md +109 -0
  208. package/llms/tasks/building-a-tool.md +141 -0
  209. package/llms/tasks/building-an-ios-prototype.md +105 -0
  210. package/llms/tasks/building-with-sable.md +73 -0
  211. package/llms/tasks/choosing-a-component.md +109 -0
  212. package/llms/tasks/converting-an-app.md +139 -0
  213. package/llms/tasks/creating-a-preset.md +43 -0
  214. package/llms/tasks/creating-design-assets.md +96 -0
  215. package/llms/tasks/designing-a-surface.md +115 -0
  216. package/llms/tasks/developing-taste.md +157 -0
  217. package/llms/tasks/getting-started.md +63 -0
  218. package/llms/tasks/learning-from-builds.md +74 -0
  219. package/llms/tasks/reviewing-a-design.md +114 -0
  220. package/llms/tasks/reviewing-a-journey.md +84 -0
  221. package/llms/tasks/reviewing-motion.md +92 -0
  222. package/llms/tasks/using-presets.md +87 -0
  223. package/llms/taste/asset-plan.template.json +53 -0
  224. package/llms/taste/composition-follows-content.md +20 -0
  225. package/llms/taste/hierarchy-is-a-relationship.md +22 -0
  226. package/llms/taste/lessons.json +57 -0
  227. package/llms/taste/motion-needs-a-reason.md +21 -0
  228. package/llms/taste/surface-states-travel-together.md +20 -0
  229. package/llms-full.txt +14906 -0
  230. package/llms.txt +405 -0
  231. package/package.json +185 -3
  232. package/registry.json +18812 -0
  233. package/scripts/agent-setup.mjs +54 -0
  234. package/scripts/design-atlas.mjs +221 -0
  235. package/scripts/design-directions.mjs +123 -0
  236. package/scripts/design-memory.mjs +166 -0
  237. package/scripts/design-preferences.mjs +227 -0
  238. package/scripts/design-presets.mjs +48 -0
  239. package/scripts/grade.mjs +509 -0
  240. package/scripts/lint-rules.mjs +1065 -0
  241. package/scripts/review-evidence.mjs +120 -0
  242. package/scripts/skeleton.mjs +248 -0
  243. package/skills/entry/SKILL.md +34 -0
  244. package/skills/sable/SKILL.md +187 -0
  245. package/skills/sable/agents/openai.yaml +6 -0
  246. package/targets/ios.json +982 -0
  247. package/third-party/reicon-LICENSE.txt +20 -0
@@ -0,0 +1,1893 @@
1
+ /* ==========================================================================
2
+ @sableui/react — styles entry.
3
+
4
+ Import this AFTER `@import "tailwindcss"` in the CONSUMER's root CSS:
5
+
6
+ @import "tailwindcss";
7
+ @import "@sableui/react/styles.css";
8
+
9
+ Do NOT add `@import "tailwindcss"` here — the consumer owns Tailwind, and
10
+ importing it twice loads the whole framework twice. This file ships only:
11
+ the default theme tokens, the @theme color map, the paired typography
12
+ @utility set, and an @source directive so Tailwind scans the package.
13
+ ========================================================================== */
14
+
15
+ /* Base ONLY. It is the default theme, and it is also the layout floor:
16
+ `--page-inset`, the `--region-*` caps, `--rhythm-unit`, `--grid-*` and
17
+ `--z-*` are declared here on a bare `:root`, so every other theme — bundled
18
+ or app-owned — inherits them. Removing it does not save 18 KB, it silently
19
+ changes layout everywhere.
20
+
21
+ The bundled ALTERNATES are opt-in, because they are
22
+ `[data-sable-theme="…"]`-scoped and an app that never sets that attribute
23
+ can never match them. Shipping them by default put 5.5 KB gzip of
24
+ permanently-dead CSS in every consuming app — found by a real app's build
25
+ audit, not in theory. Want one? Import it:
26
+
27
+ @import "@sableui/react/theme/meridian.css";
28
+
29
+ Want all of them (a theme switcher over the bundled set, like the docs app)?
30
+ @import "@sableui/react/themes.css";
31
+
32
+ The failure mode if you forget: `data-sable-theme` is set, nothing matches,
33
+ and the page renders as Base — no error, just the wrong theme. If a
34
+ theme selector looks like it does nothing, this import is the first thing
35
+ to check. */
36
+ @import "./themes/base/base.css";
37
+
38
+ /* Scan the package for utility classes (path is relative to THIS file). Covers
39
+ both workspace source (.ts/.tsx next to this file in src/) and the compiled
40
+ dist (.js next to the copied styles.css), so the same file works in both. */
41
+ @source "./**/*.{ts,tsx,js,mjs,jsx}";
42
+
43
+ @theme inline {
44
+ /* Radii — theme-provided so alternate themes can tighten or soften the shell. */
45
+ --radius-none: var(--theme-radius-none, 0px);
46
+ --radius-xs: var(--theme-radius-xs, 4px);
47
+ --radius-sm: var(--theme-radius-sm, 8px);
48
+ --radius-md: var(--theme-radius-md, 12px);
49
+ --radius-lg: var(--theme-radius-lg, 16px);
50
+ --radius-xl: var(--theme-radius-xl, 20px);
51
+ --radius-full: var(--theme-radius-full, 9999px);
52
+
53
+ /* Elevation — theme-provided so docs/app shells can express a different surface
54
+ feel without touching component code. */
55
+ --shadow-sm: var(--theme-shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.04), 0 1px 3px 0 rgb(0 0 0 / 0.03));
56
+ --shadow: var(--theme-shadow, 0 1px 2px 0 rgb(0 0 0 / 0.04), 0 2px 6px -1px rgb(0 0 0 / 0.05));
57
+ --shadow-md: var(--theme-shadow-md, 0 1px 2px 0 rgb(0 0 0 / 0.04), 0 4px 8px -2px rgb(0 0 0 / 0.06), 0 12px 24px -6px rgb(0 0 0 / 0.07));
58
+ --shadow-lg: var(--theme-shadow-lg, 0 2px 4px 0 rgb(0 0 0 / 0.04), 0 8px 16px -4px rgb(0 0 0 / 0.07), 0 24px 48px -12px rgb(0 0 0 / 0.11));
59
+ --shadow-xl: var(--theme-shadow-xl, 0 2px 4px 0 rgb(0 0 0 / 0.04), 0 12px 24px -6px rgb(0 0 0 / 0.08), 0 32px 64px -16px rgb(0 0 0 / 0.13));
60
+
61
+ /* Semantic color tokens → Tailwind utilities (bg-card, text-muted-foreground…) */
62
+ --color-background: var(--background);
63
+ --color-foreground: var(--foreground);
64
+ --color-card: var(--card);
65
+ --color-card-foreground: var(--card-foreground);
66
+ --color-popover: var(--popover);
67
+ --color-popover-foreground: var(--popover-foreground);
68
+ --color-primary: var(--primary);
69
+ --color-primary-foreground: var(--primary-foreground);
70
+ --color-secondary: var(--secondary);
71
+ --color-secondary-foreground: var(--secondary-foreground);
72
+ --color-muted: var(--muted);
73
+ --color-muted-foreground: var(--muted-foreground);
74
+ --color-accent: var(--accent);
75
+ --color-accent-foreground: var(--accent-foreground);
76
+ --color-destructive: var(--destructive);
77
+ --color-destructive-foreground: var(--destructive-foreground);
78
+ --color-success: var(--success);
79
+ --color-success-foreground: var(--success-foreground);
80
+ --color-border: var(--border);
81
+ --color-border-strong: var(--border-strong);
82
+ --color-border-floating: var(--border-floating);
83
+ --color-input: var(--input);
84
+ --color-ring: var(--ring);
85
+
86
+ /* Sidebar set */
87
+ --color-sidebar: var(--sidebar);
88
+ --color-sidebar-foreground: var(--sidebar-foreground);
89
+ --color-sidebar-primary: var(--sidebar-primary);
90
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
91
+ --color-sidebar-accent: var(--sidebar-accent);
92
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
93
+ --color-sidebar-border: var(--sidebar-border);
94
+ --color-sidebar-ring: var(--sidebar-ring);
95
+
96
+ /* Post-type tones + highlight (semantic — carry meaning) */
97
+ --color-tone-standard: var(--tone-standard);
98
+ --color-tone-collectible: var(--tone-collectible);
99
+ --color-tone-edition: var(--tone-edition);
100
+ --color-tone-warning: var(--tone-warning);
101
+ --color-tone-info: var(--tone-info);
102
+ --color-highlight: var(--highlight);
103
+
104
+ /* Tone ink — theme-adaptive legible text/icon color ON a tone fill, so a
105
+ component writes `text-tone-edition-foreground` (no palette name, no dark:). */
106
+ --color-tone-standard-foreground: var(--tone-standard-foreground);
107
+ --color-tone-collectible-foreground: var(--tone-collectible-foreground);
108
+ --color-tone-edition-foreground: var(--tone-edition-foreground);
109
+ --color-tone-warning-foreground: var(--tone-warning-foreground);
110
+ --color-tone-info-foreground: var(--tone-info-foreground);
111
+ --color-highlight-foreground: var(--highlight-foreground);
112
+
113
+ /* Overlay — fixed dark scrim/media surface + its light ink (bg-overlay,
114
+ bg-overlay/50, text-overlay-foreground). Replaces ad-hoc bg-black/text-white. */
115
+ --color-overlay: var(--overlay);
116
+ --color-overlay-foreground: var(--overlay-foreground);
117
+
118
+ /* Motion easings → ease-* utilities (ease-standard/decelerate/emphasized;
119
+ `linear` is built-in). Durations stay raw CSS vars and are consumed via
120
+ `duration-(--motion-duration-*)` so the --motion-scale kill-switch applies.
121
+ See themes/base/base.css → Motion tokens, and DESIGN.md → Motion. */
122
+ --ease-standard: var(--motion-ease-standard);
123
+ --ease-decelerate: var(--motion-ease-decelerate);
124
+ --ease-emphasized: var(--motion-ease-emphasized);
125
+ --ease-spring: var(--motion-ease-spring);
126
+ --ease-bounce: var(--motion-ease-bounce);
127
+
128
+ /* Workspace surfaces → bg-canvas / bg-panel / border-hairline utilities.
129
+ The register's surface roles; see the derivation block below. */
130
+ --color-canvas: var(--canvas);
131
+ --color-canvas-foreground: var(--canvas-foreground);
132
+ --color-panel: var(--panel);
133
+ --color-panel-foreground: var(--panel-foreground);
134
+ --color-panel-raised: var(--panel-raised);
135
+ --color-hairline: var(--hairline);
136
+ --color-hairline-strong: var(--hairline-strong);
137
+ }
138
+
139
+ /* ==========================================================================
140
+ Layering (z-index) — one ordered scale, owned by the CONTRACT.
141
+
142
+ This lived in the original default theme's CSS until 0.44.0, which made a theme the
143
+ owner of a scale every component references: `z-(--z-nav)` in AppHeader,
144
+ BottomNav and AppShell, `z-(--z-sticky)` in Banner, `z-(--z-toast)` in the
145
+ toast viewport, `z-(--z-skip-link)` in AppShell and Workspace. It only
146
+ worked because that theme's block happened to carry a `:root` arm — a consumer
147
+ shipping one of the other four themes on its own got no scale at all, and
148
+ every one of those utilities silently resolved to `z-index: auto`.
149
+
150
+ A theme may still override an individual step; it may not be the only place
151
+ the step exists. Reference these by name and never write a raw `z-[9999]`.
152
+ See DESIGN.md → Layering.
153
+ ========================================================================== */
154
+
155
+ :root {
156
+ --z-sticky: 30; /* sticky in-content chrome: table headers, sticky banners */
157
+ --z-nav: 40; /* app chrome: AppHeader, Sidebar, BottomNav, status banners */
158
+ --z-overlay: 50; /* portaled transient surfaces: dialog, sheet, popover, menu, tooltip */
159
+ --z-toast: 60; /* toasts — above modals so they're never hidden */
160
+ --z-skip-link: 100; /* the skip-to-content link — above all chrome */
161
+ }
162
+
163
+ /* ==========================================================================
164
+ The page ground. Without it, `.dark` on <html> turned the ink light while the
165
+ page stayed the browser's white, so a fresh install in dark mode was
166
+ unreadable until the app thought to paint its body. `color-scheme` lets
167
+ native scrollbars and form controls follow the mode. An app that paints its
168
+ own body still wins: this sits on <html>, in the base layer.
169
+ ========================================================================== */
170
+
171
+ @layer base {
172
+ html {
173
+ background-color: var(--background);
174
+ color: var(--foreground);
175
+ color-scheme: light;
176
+ }
177
+ html.dark {
178
+ color-scheme: dark;
179
+ }
180
+ }
181
+
182
+ /* ==========================================================================
183
+ Density — the control ladder both REGISTERS render from.
184
+ See DESIGN.md → Registers and themes/_contract.ts → DENSITY_TOKENS.
185
+
186
+ Every sized component reads `--control-*` rather than hardcoding a height, so
187
+ a density scope only has to redefine the vars. That is the whole mechanism:
188
+ no selector reaches into rendered output to override a height after the fact,
189
+ which is what an app-local `[data-density] button { height: … }` hack has to
190
+ do (and which loses to any competing utility of equal specificity).
191
+ ========================================================================== */
192
+
193
+ :root,
194
+ [data-density="comfortable"] {
195
+ /* Page register — reading surfaces, touch-friendly targets. These values are
196
+ exactly what the components hardcoded before the ladder existed, so the
197
+ default register renders byte-identical. */
198
+ --control-xs: 28px;
199
+ --control-sm: 32px;
200
+ --control-md: 40px; /* the default control height */
201
+ --control-cta: 44px; /* prominent / touch-first CTA */
202
+ --control-lg: 48px;
203
+ --field-numeric-width: 72px;
204
+ --control-gap: 8px;
205
+ --item-px: 8px;
206
+ --item-gap: 8px;
207
+
208
+ --panel-width: 280px;
209
+ --panel-width-min: 220px;
210
+ --panel-width-max: 420px;
211
+ --chrome-bar-height: 48px;
212
+ --status-bar-height: 28px;
213
+
214
+ /* Tree geometry. --tree-indent MUST equal --tree-column (see _contract.ts). */
215
+ --tree-rail: 12px;
216
+ --tree-column: 20px;
217
+ --tree-indent: 20px;
218
+
219
+ /* Declared here as well as in compact, and the symmetry is the point: a density
220
+ scope must be able to RESTORE every value it can override. Compact flattened
221
+ this to 5px and comfortable said nothing, so a comfortable subtree nested in a
222
+ compact one — a settings form inside a tool, exactly the mixed-register case —
223
+ recovered the whole control ladder and kept flat buttons with no way back.
224
+ Found in a real conversion: md 40px ✓, xs 28px ✓, radius 5px ✗.
225
+
226
+ Routed through a --theme-* hook rather than hardcoded, so a theme can still
227
+ choose its own button shape without fighting this scope. */
228
+ --button-radius: var(--theme-button-radius, 9999px);
229
+ }
230
+
231
+ /* Workspace register — measured from the Figma and Paper editors (2026-07-26),
232
+ not estimated. Figma's dominant control is 32px, Paper's is 24px, and neither
233
+ uses anything near 40px in chrome. */
234
+ [data-density="compact"] {
235
+ --control-xs: 24px; /* icon buttons, numeric fields (Paper's dominant size) */
236
+ --control-sm: 28px;
237
+ --control-md: 32px; /* the workspace default (Figma's dominant size) */
238
+ --control-cta: 36px; /* full-width prominent action (Paper's 36px) */
239
+ --control-lg: 40px;
240
+ --field-numeric-width: 44px;
241
+ --control-gap: 6px;
242
+ --item-px: 6px;
243
+ --item-gap: 6px;
244
+
245
+ /* Chrome controls are not pills. DESIGN.md → Registers lists this as one of
246
+ the two rules the workspace register deliberately reverses: neither Figma
247
+ nor Paper has a single pill in its chrome — both run one ~5px radius on
248
+ essentially everything. The theme hook preserves an explicit shape choice
249
+ such as Meridian's square corners. */
250
+ --button-radius: var(--theme-button-radius, 5px);
251
+
252
+ --panel-width: 240px; /* Figma 241/241, Paper 240 */
253
+ --panel-width-min: 200px;
254
+ --panel-width-max: 400px;
255
+ --chrome-bar-height: 40px;
256
+ --status-bar-height: 24px;
257
+
258
+ /* Tree geometry. --tree-indent MUST equal --tree-column (see _contract.ts).
259
+ 20px measured in Figma's layers panel; 18px here because the marks are
260
+ authored on an 18 grid and render 1:1 at 18px. Reicon (and every general
261
+ set) authors on a 24 grid and only scales — its documented 12/18/24/32 are
262
+ render sizes, not per-size geometry — so a general glyph at 18px lands its
263
+ strokes off the device-pixel grid no matter which name you pick. That is
264
+ why Tree owns its disclosure and add marks. */
265
+ --tree-rail: 8px;
266
+ --tree-column: 18px;
267
+ --tree-indent: 18px;
268
+ }
269
+
270
+ /* ==========================================================================
271
+ Workspace surfaces — DERIVED from each theme's own semantic tokens, so every
272
+ theme inherits a coherent workspace band without hand-authoring ten values
273
+ per mode. A theme tunes by declaring the `--theme-*` hook.
274
+
275
+ The band is deliberately COMPRESSED and the hairline is deliberately quiet:
276
+ Paper's entire panel ladder spans 249 → 242 → 238 (3-7 RGB units) and lets a
277
+ 1px low-contrast edge do the structural work. In this register hairlines carry
278
+ structure INSTEAD of tonal steps — the reverse of the page register's rule.
279
+ ========================================================================== */
280
+
281
+ /* Declared on every element that can re-declare the base colors, not just
282
+ :root. A custom property resolves where it is declared and inherits as a
283
+ finished color, so on :root alone a nested `.dark` island (an inspector, a
284
+ dark sidebar) or a preset-scoped preview kept the PAGE's canvas and panel:
285
+ white fields inside a dark panel. */
286
+ :root,
287
+ [data-sable-theme],
288
+ .dark {
289
+ /* The work surface. NOT `background`: the artboard is the subject, not the
290
+ page behind the UI. Pulled a touch away from the page so docked panels can
291
+ sit above it without a border. */
292
+ --canvas: var(--theme-canvas, color-mix(in oklch, var(--background) 97%, var(--foreground)));
293
+ --canvas-foreground: var(--theme-canvas-foreground, var(--foreground));
294
+
295
+ /* Docked chrome, one compressed step off the canvas. */
296
+ --panel: var(--theme-panel, var(--background));
297
+ --panel-foreground: var(--theme-panel-foreground, var(--foreground));
298
+ --panel-raised: var(--theme-panel-raised, color-mix(in oklch, var(--background) 94%, var(--foreground)));
299
+
300
+ /* The structural edge. Alpha-based so it holds over any surface beneath it. */
301
+ --hairline: var(--theme-hairline, color-mix(in oklch, transparent 93%, var(--foreground)));
302
+ --hairline-strong: var(--theme-hairline-strong, color-mix(in oklch, transparent 86%, var(--foreground)));
303
+
304
+ /* The edge of a FLOATING surface (popover, menu, select, tooltip, a floating
305
+ panel). Lighter than `--border` on purpose: the shadow carries the
306
+ elevation, and a full hairline plus a shadow stacks into a dark outline.
307
+ Alpha-based, so it defines the shape on a same-colour page in light mode
308
+ and reads as a faint lit edge in dark mode, where shadows barely show. */
309
+ --border-floating: var(--theme-border-floating, color-mix(in oklch, transparent 92%, var(--foreground)));
310
+
311
+ /* The Switch knob when OFF: light in BOTH modes, like every physical toggle.
312
+ It used to be `--background`, which in a dark theme is a near-black knob on
313
+ a dark track (well under 3:1). When ON the knob takes `--primary-foreground`,
314
+ the ink every theme already tunes against its primary fill. */
315
+ --switch-thumb: var(--theme-switch-thumb, oklch(99% 0.002 90));
316
+ }
317
+
318
+ /* ==========================================================================
319
+ Typography — six tiers. Each utility bundles size + weight + line-height +
320
+ tracking. Compose with color/margin only; never override the four paired
321
+ properties individually. Mono uses the theme's --font-mono.
322
+ ========================================================================== */
323
+
324
+ @utility text-display-4xl {
325
+ font-family: var(--font-display, var(--font-sans));
326
+ font-size: var(--type-display-4xl-size, clamp(4.5rem, 12vw, 12rem));
327
+ font-weight: var(--type-display-4xl-weight, 800);
328
+ line-height: var(--type-display-4xl-leading, 0.9);
329
+ letter-spacing: var(--type-display-4xl-tracking, -0.05em);
330
+ }
331
+ @utility text-display-3xl {
332
+ font-family: var(--font-display, var(--font-sans));
333
+ font-size: var(--type-display-3xl-size, 6rem);
334
+ font-weight: var(--type-display-3xl-weight, 700);
335
+ line-height: var(--type-display-3xl-leading, 0.95);
336
+ letter-spacing: var(--type-display-3xl-tracking, -0.045em);
337
+ }
338
+ @utility text-display-2xl {
339
+ font-family: var(--font-display, var(--font-sans));
340
+ font-size: var(--type-display-2xl-size, 4.5rem);
341
+ font-weight: var(--type-display-2xl-weight, 700);
342
+ line-height: var(--type-display-2xl-leading, 1);
343
+ letter-spacing: var(--type-display-2xl-tracking, -0.04em);
344
+ }
345
+ @utility text-display-xl {
346
+ font-family: var(--font-display, var(--font-sans));
347
+ font-size: var(--type-display-xl-size, 3.75rem);
348
+ font-weight: var(--type-display-xl-weight, 700);
349
+ line-height: var(--type-display-xl-leading, 1.05);
350
+ letter-spacing: var(--type-display-xl-tracking, -0.035em);
351
+ }
352
+ @utility text-display-lg {
353
+ font-family: var(--font-display, var(--font-sans));
354
+ font-size: var(--type-display-lg-size, 3rem);
355
+ font-weight: var(--type-display-lg-weight, 700);
356
+ line-height: var(--type-display-lg-leading, 1.1);
357
+ letter-spacing: var(--type-display-lg-tracking, -0.03em);
358
+ }
359
+
360
+ @utility text-heading-1 {
361
+ font-family: var(--font-display, var(--font-sans));
362
+ font-size: var(--type-heading-1-size, 2.25rem);
363
+ font-weight: var(--type-heading-weight, 600);
364
+ line-height: var(--type-heading-1-leading, 1.15);
365
+ letter-spacing: var(--type-heading-1-tracking, -0.025em);
366
+ }
367
+ @utility text-heading-2 {
368
+ font-family: var(--font-display, var(--font-sans));
369
+ font-size: var(--type-heading-2-size, 1.75rem);
370
+ font-weight: var(--type-heading-weight, 600);
371
+ line-height: var(--type-heading-2-leading, 1.2);
372
+ letter-spacing: var(--type-heading-2-tracking, -0.02em);
373
+ }
374
+ @utility text-heading-3 {
375
+ font-family: var(--font-display, var(--font-sans));
376
+ font-size: var(--type-heading-3-size, 1.375rem);
377
+ font-weight: var(--type-heading-weight, 600);
378
+ line-height: var(--type-heading-3-leading, 1.25);
379
+ letter-spacing: var(--type-heading-3-tracking, -0.015em);
380
+ }
381
+ @utility text-heading-4 {
382
+ font-family: var(--font-display, var(--font-sans));
383
+ font-size: var(--type-heading-4-size, 1.125rem);
384
+ font-weight: var(--type-heading-weight, 600);
385
+ line-height: var(--type-heading-4-leading, 1.3);
386
+ letter-spacing: var(--type-heading-4-tracking, -0.01em);
387
+ }
388
+
389
+ /* The tiers below the heading ramp — title, body, label, caption, mono — read
390
+ OPTIONAL `--type-*` overrides with their current values as fallbacks. Unlike
391
+ the display/heading/title-weight vars (TYPE_SCALE_TOKENS) and the overline's
392
+ (TYPE_OVERLINE_TOKENS), these are optional TYPE_REFINEMENT_TOKENS in
393
+ `themes/_contract.ts`: a theme may retune a tier, and a theme that says
394
+ nothing renders exactly as it always has. They exist because a dense register
395
+ (a trading desk, an operator console) needs its own body/label/mono sizes,
396
+ and pinning them in a page's own <style> block is a patch, not a theme. */
397
+ @utility text-title-lg {
398
+ font-size: var(--type-title-lg-size, 1rem);
399
+ font-weight: var(--type-title-weight, 600);
400
+ line-height: var(--type-title-leading, 1.4);
401
+ letter-spacing: var(--type-title-tracking, -0.01em);
402
+ }
403
+ @utility text-title-sm {
404
+ font-size: var(--type-title-sm-size, 0.875rem);
405
+ font-weight: var(--type-title-weight, 600);
406
+ line-height: var(--type-title-leading, 1.4);
407
+ letter-spacing: var(--type-title-sm-tracking, -0.005em);
408
+ }
409
+
410
+ /* The LEDE tier — the standfirst under a headline, a card's description, a
411
+ section's one-paragraph intro. It carries `text-wrap: pretty` and the tiers
412
+ below it deliberately do not.
413
+
414
+ The pairing rule: `balance` for headings, `pretty` for ledes. `balance` evens
415
+ the line lengths of a short block and is capped by browsers at a handful of
416
+ lines, so it belongs on the 1-3 line headline. `pretty` is the cheaper rule
417
+ that only refuses a bad last line — no orphaned single word — which is
418
+ exactly the failure a two-to-four-line lede has. Not on `text-body-md` /
419
+ `text-body-sm`: those also serve long-form prose and table cells, where
420
+ `pretty` costs layout work over many lines to fix a break nobody reads as an
421
+ error. Ledes are short by definition, so the cost is bounded here. */
422
+ @utility text-body-lg {
423
+ font-size: var(--type-body-lg-size, 1.0625rem);
424
+ font-weight: var(--type-body-lg-weight, 400);
425
+ line-height: var(--type-body-lg-leading, 1.6);
426
+ letter-spacing: var(--type-body-lg-tracking, -0.005em);
427
+ text-wrap: pretty;
428
+ }
429
+ @utility text-body-md {
430
+ font-size: var(--type-body-md-size, 0.9375rem);
431
+ font-weight: var(--type-body-md-weight, 400);
432
+ line-height: var(--type-body-md-leading, 1.55);
433
+ letter-spacing: var(--type-body-md-tracking, -0.005em);
434
+ }
435
+ @utility text-body-sm {
436
+ font-size: var(--type-body-sm-size, 0.8125rem);
437
+ font-weight: var(--type-body-sm-weight, 400);
438
+ line-height: var(--type-body-sm-leading, 1.5);
439
+ letter-spacing: var(--type-body-sm-tracking, 0);
440
+ }
441
+ /* The dense floor of the body ramp — running text in operator consoles,
442
+ dense table cells, and metadata. Same size as text-caption but with body
443
+ leading: caption is tuned for one-line annotation, body-xs for wrapping. */
444
+ @utility text-body-xs {
445
+ font-size: var(--type-body-xs-size, 0.75rem);
446
+ font-weight: var(--type-body-xs-weight, 400);
447
+ line-height: var(--type-body-xs-leading, 1.5);
448
+ letter-spacing: var(--type-body-xs-tracking, 0);
449
+ }
450
+
451
+ @utility text-label-lg {
452
+ font-size: var(--type-label-lg-size, 0.875rem);
453
+ font-weight: var(--type-label-lg-weight, 600);
454
+ line-height: var(--type-label-lg-leading, 1.25);
455
+ letter-spacing: var(--type-label-lg-tracking, -0.005em);
456
+ }
457
+ @utility text-label-md {
458
+ font-size: var(--type-label-md-size, 0.75rem);
459
+ font-weight: var(--type-label-md-weight, 600);
460
+ line-height: var(--type-label-md-leading, 1.2);
461
+ letter-spacing: var(--type-label-md-tracking, 0);
462
+ }
463
+ @utility text-label-sm {
464
+ font-size: var(--type-label-sm-size, 0.6875rem);
465
+ font-weight: var(--type-label-sm-weight, 600);
466
+ line-height: var(--type-label-sm-leading, 1.2);
467
+ letter-spacing: var(--type-label-sm-tracking, 0);
468
+ }
469
+ /* Sentence case like every other label (UI text is sentence case). The
470
+ uppercase, letter-spaced eyebrow is its own tier, text-overline — opt in to
471
+ it (Label `eyebrow`) rather than getting caps from the smallest label. The
472
+ transform stays a themeable hook for a theme that wants caps here. */
473
+ @utility text-label-xs {
474
+ font-size: var(--type-label-xs-size, 0.6875rem);
475
+ font-weight: var(--type-label-xs-weight, 600);
476
+ line-height: var(--type-label-xs-leading, 1.2);
477
+ letter-spacing: var(--type-label-xs-tracking, 0.01em);
478
+ text-transform: var(--type-label-xs-transform, none);
479
+ }
480
+ @utility text-caption {
481
+ font-size: var(--type-caption-size, 0.75rem);
482
+ font-weight: var(--type-caption-weight, 400);
483
+ line-height: var(--type-caption-leading, 1.4);
484
+ letter-spacing: var(--type-caption-tracking, 0);
485
+ }
486
+
487
+ /* Overline / eyebrow / kicker — the small, uppercase, letter-spaced annotation
488
+ role used for badge labels, section eyebrows, and stat captions. Themeable
489
+ via TYPE_OVERLINE_TOKENS (see _contract.ts); `--type-overline-transform`
490
+ lets a theme opt out of all-caps rather than hardcoding `uppercase`. */
491
+ @utility text-overline {
492
+ font-size: var(--type-overline-size, 0.75rem);
493
+ font-weight: var(--type-overline-weight, 600);
494
+ line-height: var(--type-overline-leading, 1.2);
495
+ letter-spacing: var(--type-overline-tracking, 0.08em);
496
+ text-transform: var(--type-overline-transform, uppercase);
497
+ }
498
+
499
+ /* Metric mono — KPI values and prominent counts in product UI. The number IS
500
+ the content, so it earns display size, but in the mono family (tabular, on
501
+ the technical voice) rather than a heading tier — headings stay semantic. */
502
+ @utility text-mono-xl {
503
+ font-family: var(--font-mono);
504
+ font-size: var(--type-mono-xl-size, 1.75rem);
505
+ font-weight: var(--type-mono-xl-weight, 500);
506
+ line-height: var(--type-mono-xl-leading, 1.15);
507
+ letter-spacing: var(--type-mono-xl-tracking, -0.01em);
508
+ font-variant-numeric: tabular-nums;
509
+ }
510
+ @utility text-mono-lg {
511
+ font-family: var(--font-mono);
512
+ font-size: var(--type-mono-lg-size, 1.25rem);
513
+ font-weight: var(--type-mono-lg-weight, 500);
514
+ line-height: var(--type-mono-lg-leading, 1.25);
515
+ letter-spacing: var(--type-mono-lg-tracking, 0);
516
+ font-variant-numeric: tabular-nums;
517
+ }
518
+ @utility text-mono-md {
519
+ font-family: var(--font-mono);
520
+ font-size: var(--type-mono-md-size, 0.8125rem);
521
+ font-weight: var(--type-mono-md-weight, 400);
522
+ line-height: var(--type-mono-md-leading, 1.5);
523
+ letter-spacing: var(--type-mono-md-tracking, 0);
524
+ font-variant-numeric: tabular-nums;
525
+ }
526
+ /* Caption-sized mono — dense technical data (IDs, hashes, aligned numbers)
527
+ at the caption/body-xs size, so it never needs a decomposed
528
+ `font-mono text-caption` recomposition. */
529
+ @utility text-mono-caption {
530
+ font-family: var(--font-mono);
531
+ font-size: var(--type-mono-caption-size, 0.75rem);
532
+ font-weight: var(--type-mono-caption-weight, 400);
533
+ line-height: var(--type-mono-caption-leading, 1.4);
534
+ letter-spacing: var(--type-mono-caption-tracking, 0);
535
+ font-variant-numeric: tabular-nums;
536
+ }
537
+ @utility text-mono-sm {
538
+ font-family: var(--font-mono);
539
+ font-size: var(--type-mono-sm-size, 0.6875rem);
540
+ font-weight: var(--type-mono-sm-weight, 500);
541
+ line-height: var(--type-mono-sm-leading, 1.2);
542
+ letter-spacing: var(--type-mono-sm-tracking, 0.02em);
543
+ font-variant-numeric: tabular-nums;
544
+ }
545
+
546
+ /* ==========================================================================
547
+ Optical label centring.
548
+
549
+ A control centred with `align-items: center` centres its LINE BOX, which is
550
+ ascent-to-descent — not the band of glyphs the eye reads. Fonts are not
551
+ symmetric about that band, so the visible label lands off centre by however
552
+ lopsided the face is. Measured on a 40px control at 14px/600/1.25: Helvetica
553
+ sits a full 1px high, Segoe UI ~0.13px, Verdana ~0.06px.
554
+
555
+ That spread is exactly why this is not a `padding-top: 1px` fix — a nudge
556
+ tuned to one face is wrong for the next, and every Sable theme ships its own.
557
+ `text-box-trim` cuts the box to cap-height/baseline instead, so centring it
558
+ centres what reads, derived from each font's own metrics.
559
+
560
+ Applied via `lib/trim-label.tsx`, which wraps only TEXT children: the property
561
+ is inert on a flex container (no line boxes of its own), and a flex
562
+ container's text children are anonymous boxes CSS cannot target, so the label
563
+ needs a real element. Element children stay separate flex items so `gap`
564
+ still spaces icons from labels.
565
+
566
+ Guarded by @supports on purpose: where `text-box` is unimplemented this class
567
+ does nothing at all and rendering is identical to before it existed.
568
+ ========================================================================== */
569
+
570
+ @supports (text-box: trim-both cap alphabetic) {
571
+ .sable-label-trim {
572
+ text-box: trim-both cap alphabetic;
573
+ }
574
+ /* Retire the legacy hand-tuned nudge wherever the metric-derived fix applies.
575
+ Marked controls keep their em correction as a fallback for browsers with no
576
+ `text-box`; here it would double-correct, so it goes back to symmetric.
577
+
578
+ Symmetric, NOT zero. `padding-block: 0` was the first version of this rule
579
+ and it collapsed every Pill in the system to a 7.5px sliver the moment
580
+ Chrome shipped `text-box` — the trimmed label box is cap height, so with no
581
+ padding the pill is exactly as tall as its capitals and the fill stops
582
+ short of the descenders. The legacy `pt-[0.23em] pb-[0.1em]` was doing two
583
+ jobs at once: correcting the optical centre AND providing the pill's actual
584
+ vertical breathing room. `text-box` replaces the first job only.
585
+
586
+ 0.4em restores the pre-`text-box` height: the untrimmed line box (1.2em)
587
+ plus the legacy 0.33em of padding is 1.53em, and the trimmed cap box plus
588
+ 2 × 0.4em lands on the same place. It is a constant rather than a
589
+ font-derived value on purpose — now that the content box is cap height, a
590
+ fixed padding is what makes the space around the capitals identical across
591
+ every theme's typeface, which is the whole reason for trimming. */
592
+ .sable-optical-pad {
593
+ /* A component may ask for more room (Badge does: a status label reads
594
+ cramped at the 0.4em a Pill wants). */
595
+ padding-block: var(--sable-optical-pad, 0.4em);
596
+ }
597
+ }
598
+
599
+ /* ==========================================================================
600
+ UI tier — the WORKSPACE register's chrome typography. A separate ramp from the
601
+ six page tiers above, on purpose.
602
+
603
+ Tool chrome runs 10-12px at weight 450-550. Measured: Figma 11px/400 and
604
+ 11px/550 with 16px leading plus a 9px/450 micro tier; Paper 12px/500, 12px/400
605
+ and 12px/465, also on 16px leading. At that size the page register's 600 reads
606
+ as shouting and 400 goes muddy, which is why the weights sit in between.
607
+
608
+ Same rules as every other paired tier: bundles size + weight + leading +
609
+ tracking, composes with colour only, never decomposed — and registered in
610
+ lib/cn.ts's `ds-typography` group, without which tailwind-merge treats
611
+ `text-ui-md` as a COLOUR utility and silently drops the real colour class.
612
+ ========================================================================== */
613
+
614
+ @utility text-ui-md {
615
+ font-size: var(--type-ui-md-size, 12px);
616
+ font-weight: var(--type-ui-md-weight, 500);
617
+ line-height: var(--type-ui-leading, 16px);
618
+ letter-spacing: normal;
619
+ }
620
+
621
+ @utility text-ui-sm {
622
+ font-size: var(--type-ui-sm-size, 11px);
623
+ font-weight: var(--type-ui-sm-weight, 500);
624
+ line-height: var(--type-ui-leading, 16px);
625
+ letter-spacing: 0.005em;
626
+ }
627
+
628
+ /* The reading variant — descriptions and help text inside chrome. */
629
+ @utility text-ui-body {
630
+ font-size: var(--type-ui-md-size, 12px);
631
+ font-weight: 400;
632
+ line-height: var(--type-ui-leading, 16px);
633
+ letter-spacing: normal;
634
+ }
635
+
636
+ /* Panel section headers. Tracked rather than uppercase-shouted: at this density
637
+ an all-caps overline is louder than the content it labels. */
638
+ @utility text-ui-section {
639
+ font-size: var(--type-ui-sm-size, 11px);
640
+ font-weight: var(--type-ui-section-weight, 550);
641
+ line-height: var(--type-ui-leading, 16px);
642
+ letter-spacing: 0.01em;
643
+ }
644
+
645
+ /* Micro tier — chrome annotations (Figma's 9px/450). Use sparingly. */
646
+ @utility text-ui-micro {
647
+ font-size: var(--type-ui-micro-size, 10px);
648
+ font-weight: var(--type-ui-micro-weight, 450);
649
+ line-height: 14px;
650
+ letter-spacing: 0.02em;
651
+ }
652
+
653
+ /* Numeric values in a property grid — tabular so a column of numbers doesn't
654
+ dance while a slider drags. */
655
+ @utility text-ui-num {
656
+ font-family: var(--font-mono);
657
+ font-size: var(--type-ui-sm-size, 11px);
658
+ font-weight: 400;
659
+ line-height: var(--type-ui-leading, 16px);
660
+ font-variant-numeric: tabular-nums;
661
+ }
662
+
663
+ /* ==========================================================================
664
+ Relative CSS primitives - stable state selectors, semantic surfaces, and
665
+ concentric radius helpers owned by Sable's semantic token contract.
666
+ ========================================================================== */
667
+
668
+ @custom-variant sable-highlighted (&:where([data-highlighted], [data-active], [data-active-item]));
669
+ @custom-variant sable-selected (&:where([data-selected], [data-checked], [aria-selected="true"], [aria-checked="true"]));
670
+ @custom-variant sable-open (&:where([data-open], [data-popup-open], [data-panel-open], [aria-expanded="true"]));
671
+ @custom-variant sable-disabled (&:where(:disabled, [disabled], [data-disabled], [aria-disabled="true"]));
672
+ @custom-variant sable-invalid (&:where([aria-invalid="true"], [data-invalid]));
673
+
674
+ @utility sable-surface {
675
+ --sable-surface-bg: var(--card);
676
+ --sable-surface-fg: var(--card-foreground);
677
+ --sable-surface-edge: var(--border);
678
+ --sable-surface-hover-bg: color-mix(
679
+ in oklab,
680
+ var(--foreground) 8%,
681
+ var(--sable-surface-bg)
682
+ );
683
+ --sable-surface-active-bg: color-mix(
684
+ in oklab,
685
+ var(--foreground) 12%,
686
+ var(--sable-surface-bg)
687
+ );
688
+ background-color: var(--sable-surface-bg);
689
+ color: var(--sable-surface-fg);
690
+ border-color: var(--sable-surface-edge);
691
+ }
692
+ @utility sable-surface-muted {
693
+ --sable-surface-bg: var(--muted);
694
+ --sable-surface-fg: var(--foreground);
695
+ --sable-surface-edge: var(--border);
696
+ }
697
+ @utility sable-surface-popover {
698
+ --sable-surface-bg: var(--popover);
699
+ --sable-surface-fg: var(--popover-foreground);
700
+ --sable-surface-edge: var(--border-floating);
701
+ }
702
+ @utility sable-surface-accent {
703
+ --sable-surface-bg: var(--accent);
704
+ --sable-surface-fg: var(--accent-foreground);
705
+ --sable-surface-edge: var(--border);
706
+ }
707
+ /* A recessed area ON whatever surface it sits in: a segmented-control track, a
708
+ pad, a preview well. Derived from the surface underneath (the inherited
709
+ --sable-surface-bg, page background by default), so it is the same visible
710
+ step inside a card, a popover or the page, in every theme and mode. A fixed
711
+ `bg-muted` cannot do that: inside a popover it can sit within a step of the
712
+ panel in some dark modes, and the fix that tempts — an outline on every
713
+ well — turns a panel into a stack of boxes. A well needs no edge. */
714
+ @utility sable-well {
715
+ background-color: color-mix(
716
+ in oklab,
717
+ var(--foreground) 7%,
718
+ var(--sable-surface-bg, var(--background))
719
+ );
720
+ }
721
+ @utility sable-surface-interactive {
722
+ &:where(:hover, [data-highlighted], [data-active], [data-active-item]) {
723
+ background-color: var(
724
+ --sable-surface-hover-bg,
725
+ color-mix(in oklab, var(--foreground) 8%, transparent)
726
+ );
727
+ color: var(--sable-surface-hover-fg, var(--foreground));
728
+ }
729
+ &:where(:active, [data-selected], [data-checked], [aria-selected="true"], [aria-checked="true"]) {
730
+ background-color: var(--sable-surface-active-bg, var(--accent));
731
+ color: var(--sable-surface-active-fg, var(--accent-foreground));
732
+ }
733
+ }
734
+
735
+ /* Popup geometry, derived from the popup's own radius so rows nest
736
+ concentrically at every corner setting (Menu, Select, Combobox, ContextMenu). The inset grows with the radius: a rounder popup needs more
737
+ room, or its rows' corners approach half their height and every row turns
738
+ into a pill. The row radius is what is left. At the default ladder (md 12px)
739
+ this is exactly the old 4px inset and 8px rows. Utilities rather than :root
740
+ tokens so the math runs on the element, inside whatever preset it sits in. */
741
+ @utility popup-inset {
742
+ padding: max(--spacing(1), calc(var(--radius-md) / 3));
743
+ }
744
+ @utility rounded-popup-item {
745
+ border-radius: max(0px, calc(var(--radius-md) - max(--spacing(1), calc(var(--radius-md) / 3))));
746
+ }
747
+ /* A rule (separator) that runs edge to edge through the popup padding. It
748
+ bleeds by exactly popup-inset, so it reaches both edges at every radius; a
749
+ fixed -mx-1 stopped short of them once a rounder preset grew the inset. */
750
+ @utility popup-bleed {
751
+ margin-inline: calc(-1 * max(--spacing(1), calc(var(--radius-md) / 3)));
752
+ }
753
+
754
+ @utility sable-frame {
755
+ --sable-frame-radius: var(--radius-lg);
756
+ --sable-frame-padding: --spacing(4);
757
+ --sable-frame-border-width: 1px;
758
+ --sable-frame-edge: var(--sable-surface-edge, var(--border));
759
+ /* Concentric with the frame when the row sits near its edge (a popover's
760
+ 4px inset), floored at the theme's small radius: deep inside a padded
761
+ card the subtraction leaves ~1px, and a near-square row inside a
762
+ rounded card reads as a different system, not a parallel curve. */
763
+ --sable-frame-inner-radius: max(
764
+ var(--radius-sm),
765
+ calc(
766
+ var(--sable-frame-radius) - var(--sable-frame-padding) -
767
+ var(--sable-frame-border-width)
768
+ )
769
+ );
770
+ padding: var(--sable-frame-padding);
771
+ border: var(--sable-frame-border-width) solid var(--sable-frame-edge);
772
+ border-radius: var(--sable-frame-radius);
773
+ }
774
+ @utility sable-frame-popover {
775
+ --sable-frame-radius: var(--radius-md);
776
+ --sable-frame-padding: --spacing(1);
777
+ --sable-frame-edge: var(--border-floating);
778
+ box-shadow: var(--shadow-lg);
779
+ }
780
+ @utility sable-frame-borderless {
781
+ --sable-frame-border-width: 0px;
782
+ }
783
+ @utility sable-frame-r-* {
784
+ --sable-frame-radius: --value(--radius-*, [*]);
785
+ }
786
+ @utility sable-frame-p-* {
787
+ --sable-frame-padding: --value(--spacing-*, [*]);
788
+ --sable-frame-padding: --spacing(--value(number));
789
+ }
790
+ @utility sable-frame-inner {
791
+ border-radius: var(--sable-frame-inner-radius, var(--radius-sm));
792
+ }
793
+ @utility rounded-concentric-* {
794
+ border-radius: max(
795
+ 0px,
796
+ calc(--value(--radius-*, [*]) - --modifier(--spacing-*, [*]))
797
+ );
798
+ border-radius: max(
799
+ 0px,
800
+ calc(--value(--radius-*, [*]) - --spacing(--modifier(number)))
801
+ );
802
+ }
803
+
804
+ /* ==========================================================================
805
+ Hit target — the sanctioned implementation of the WCAG 2.5.5 touch floor for
806
+ a control that is genuinely smaller than it.
807
+
808
+ The control ladder guarantees box height, so most controls clear 44px on
809
+ their own and need nothing here. What it cannot cover is the marks that are
810
+ deliberately sub-24px because that IS the design: `Tree`'s ~18px disclosure
811
+ and add marks, `Toast`'s close glyph, a tag's remove affordance. DESIGN.md
812
+ has blessed "keeps the 44px hit area via padding or hit-slop, not a bigger
813
+ glyph" for a long time without shipping the hit-slop; every consumer invented
814
+ it, or skipped it.
815
+
816
+ A transparent `::after` rather than padding, because padding changes the box:
817
+ it moves the glyph off the row's optical grid, breaks a tight `gap`, and in a
818
+ flex row pushes every sibling. The pseudo-element overlays instead — the
819
+ target grows, the layout does not move a pixel.
820
+
821
+ NEVER put this on an `<input>` (or any replaced element: img, select,
822
+ textarea). Replaced elements do not render ::before/::after reliably, so the
823
+ rule silently does nothing and the target stays 18px with no error anywhere.
824
+ Wrap the input in a label and put the class on that.
825
+
826
+ And SHRINK it where targets sit side by side — two adjacent 44px slops on
827
+ 20px-apart marks overlap, and the later one in source order wins the taps for
828
+ both. Override per use rather than dropping the utility:
829
+
830
+ <button class="hit-target [--hit-target-size:32px]">
831
+
832
+ Not motion, so --motion-scale and prefers-reduced-motion do not touch it. */
833
+ @utility hit-target {
834
+ position: relative;
835
+
836
+ &::after {
837
+ content: "";
838
+ position: absolute;
839
+ top: 50%;
840
+ left: 50%;
841
+ transform: translate(-50%, -50%);
842
+ /* A literal 44px, and deliberately NOT a --control-* step. The WCAG floor
843
+ is not a control-ladder height: the compact register retunes every
844
+ control and must never retune the hit-area floor (DESIGN.md →
845
+ Registers). Reading `--control-cta` here would shrink it to 36px in a
846
+ workspace scope, which is the exact bug the ladder is not allowed to
847
+ cause. */
848
+ width: var(--hit-target-size, 44px);
849
+ height: var(--hit-target-size, 44px);
850
+ }
851
+ }
852
+
853
+ /* Link underline — driven by the FONT'S OWN metrics, so it retunes itself when
854
+ a theme swaps typeface (which Sable themes do). `from-font` takes the
855
+ underline position and thickness the type designer shipped in the face,
856
+ instead of the engine's guess or a hand-picked `underline-offset-4` that is
857
+ right for Figtree and wrong for the next theme's face. `skip-ink: auto` lifts
858
+ the rule around descenders so a "g" or "y" stays readable.
859
+
860
+ Only `text-decoration-color` animates reliably — thickness and offset are
861
+ not interpolated by every engine, and where they are they force a relayout of
862
+ the line box. If you want an underline that GROWS on hover, that is a
863
+ separate element (a positioned pseudo-element with a scaled transform), not
864
+ this utility. Pair with `motion-interactive`, whose transition-property list
865
+ already covers `color`. */
866
+ @utility link-underline {
867
+ text-decoration-line: underline;
868
+ text-underline-position: from-font;
869
+ text-decoration-thickness: from-font;
870
+ text-decoration-skip-ink: auto;
871
+ }
872
+
873
+ /* ==========================================================================
874
+ Highlight glide (lib/highlight-glide.tsx).
875
+
876
+ A popup that mounts <HighlightGlide> is marked [data-glide]. The single
877
+ travelling highlight then draws the active row, so each row's own
878
+ data-[highlighted] FILL steps aside — its text colour stays, and so does the
879
+ fill everywhere the glide is not mounted (the no-JS fallback). Rows become
880
+ positioned so they paint above the absolutely positioned highlight.
881
+ ========================================================================== */
882
+ /* Layered, so a row's own positioning utility wins. Unlayered, this beat
883
+ ComboboxVirtualList's `absolute` rows: they fell back into normal flow and
884
+ then shifted by their `top`, so the gap between rows grew row by row. */
885
+ @layer components {
886
+ [data-glide] {
887
+ position: relative;
888
+ }
889
+ [data-glide] :where([role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"]) {
890
+ position: relative;
891
+ }
892
+ }
893
+ [data-glide] [data-highlighted]:not([data-highlight-glide]) {
894
+ background-color: transparent;
895
+ }
896
+
897
+ /* Minimal scrollbars — thin, translucent themed thumb on a transparent track
898
+ (iOS/macOS feel). Opt-in: add `scrollbar-quiet` to any scroll container. */
899
+ /*
900
+ * A quiet scrollbar for bounded regions: no stepper arrows, a thumb that floats
901
+ * clear of the container's edges, and nothing visible until the reader is
902
+ * actually in the region.
903
+ *
904
+ * ── Why `scrollbar-width` is NOT set here ───────────────────────────────────
905
+ * It used to be, and that single declaration caused every symptom this utility
906
+ * now fixes. In Chromium, specifying the standard `scrollbar-width` makes the
907
+ * engine ignore `::-webkit-scrollbar` pseudo-elements completely and paint the
908
+ * PLATFORM scrollbar instead. On Windows that is the classic thin bar: stepper
909
+ * arrows at both ends, hard against the padding edge — so inside any rounded
910
+ * container the arrows collide with the corner radius and poke out of it. The
911
+ * old utility could not have removed them, because it had opted out of the only
912
+ * mechanism that can. It is set again below, but only where the pseudo-elements
913
+ * do not exist.
914
+ *
915
+ * ── The floating thumb ──────────────────────────────────────────────────────
916
+ * The thumb is a 10px-wide box with a 3px TRANSPARENT border and
917
+ * `background-clip: padding-box`, which paints a 4px bar centred in a 10px
918
+ * gutter. That is what keeps it off the container's border and out of its
919
+ * corners without reserving a wide channel. The track carries a small margin so
920
+ * the thumb stops short of both ends rather than running into the radius.
921
+ *
922
+ * ── Hidden at rest, quiet when it appears ───────────────────────────────────
923
+ * The thumb is transparent until the reader is in the region, then arrives
924
+ * quietly. It used to jump to 22% foreground (38% on hover), louder than the
925
+ * content around it. A brief faint-at-rest experiment (8%) was rolled back:
926
+ * on long chrome like a docs sidebar it read as a permanent stripe. The ramp:
927
+ *
928
+ * hidden at rest → 12% while the region is scrolling / hovered / focused
929
+ * → 16% under the pointer → 20% while dragging
930
+ *
931
+ * The thumb also sits 2px off the outer edge (an uneven transparent border:
932
+ * 1px toward the content, 5px toward the edge), and widens from 4px to 6px
933
+ * under the pointer so it is easier to grab. Border widths do not animate in
934
+ * WebKit, so the widening is an instant step; the colour is what fades.
935
+ *
936
+ * Fade timing is asymmetric: in at --motion-duration-fast with no delay, out
937
+ * after a short hold. Both carry --motion-scale, so reduced motion is instant.
938
+ * The gutter is reserved either way, so nothing reflows.
939
+ */
940
+ @utility scrollbar-quiet {
941
+ &::-webkit-scrollbar {
942
+ width: 10px;
943
+ height: 10px;
944
+ }
945
+ &::-webkit-scrollbar-track {
946
+ margin: 4px;
947
+ background: transparent;
948
+ }
949
+ /* The dead square where two scrollbars meet. */
950
+ &::-webkit-scrollbar-corner {
951
+ background: transparent;
952
+ }
953
+ /* The arrows. */
954
+ &::-webkit-scrollbar-button {
955
+ display: none;
956
+ width: 0;
957
+ height: 0;
958
+ }
959
+ &::-webkit-scrollbar-thumb {
960
+ background-color: transparent;
961
+ border-radius: var(--theme-radius-full, 9999px);
962
+ border: 3px solid transparent;
963
+ background-clip: padding-box;
964
+ /*
965
+ * Fades rather than blinks. The duration token already carries
966
+ * `--motion-scale`, so the reduced-motion policy collapses this to an
967
+ * instant swap along with everything else — a scrollbar that keeps
968
+ * animating after the user asked for stillness is the same bug as any
969
+ * other, just smaller.
970
+ */
971
+ transition: background-color var(--motion-duration-fast)
972
+ var(--motion-ease-standard) calc(160ms * var(--motion-scale, 1));
973
+ }
974
+ /* 1px toward the content, 5px toward the edge: a 4px bar, 2px off the edge. */
975
+ &::-webkit-scrollbar-thumb:vertical {
976
+ border-left-width: 1px;
977
+ border-right-width: 5px;
978
+ }
979
+ &::-webkit-scrollbar-thumb:horizontal {
980
+ border-top-width: 1px;
981
+ border-bottom-width: 5px;
982
+ }
983
+ /*
984
+ * Three ways to earn a visible thumb, in order of how well they model
985
+ * "someone is scrolling":
986
+ *
987
+ * `[data-scrolling]` is the real signal — set by `useScrollActivity` for a
988
+ * moment after each scroll event and then removed. It is the only one that
989
+ * catches a touch drag or a programmatic auto-scroll, neither of which
990
+ * hovers anything, and it is what makes a streamed chat flash its position
991
+ * as the transcript moves.
992
+ *
993
+ * Hover and focus-within are the no-JS baseline, so the bare utility still
994
+ * behaves for a consumer who never reaches for the hook. They also hold the
995
+ * thumb visible while the pointer is parked over the region, which is what
996
+ * you want when someone is about to grab it.
997
+ */
998
+ &[data-scrolling]::-webkit-scrollbar-thumb,
999
+ &:hover::-webkit-scrollbar-thumb,
1000
+ &:focus-within::-webkit-scrollbar-thumb {
1001
+ background-color: color-mix(in oklab, var(--foreground) 12%, transparent);
1002
+ transition-delay: 0s;
1003
+ }
1004
+ &::-webkit-scrollbar-thumb:hover {
1005
+ background-color: color-mix(in oklab, var(--foreground) 16%, transparent);
1006
+ }
1007
+ &::-webkit-scrollbar-thumb:active {
1008
+ background-color: color-mix(in oklab, var(--foreground) 20%, transparent);
1009
+ }
1010
+ /* Wider under the pointer: 6px, still 2px off the edge. */
1011
+ &::-webkit-scrollbar-thumb:vertical:hover,
1012
+ &::-webkit-scrollbar-thumb:vertical:active {
1013
+ border-right-width: 3px;
1014
+ }
1015
+ &::-webkit-scrollbar-thumb:horizontal:hover,
1016
+ &::-webkit-scrollbar-thumb:horizontal:active {
1017
+ border-bottom-width: 3px;
1018
+ }
1019
+
1020
+ }
1021
+
1022
+ /*
1023
+ * Firefox — which has no `::-webkit-scrollbar` and never drew arrows in its thin
1024
+ * scrollbar anyway. It cannot inset the thumb, so it gets the same policy at
1025
+ * lower fidelity: thin, and coloured only on interaction.
1026
+ *
1027
+ * ── Why the guard is `-moz-orient` and not `not selector(::-webkit-scrollbar)` ─
1028
+ * The obvious guard is the feature itself, and it does not survive the build.
1029
+ * Lightning CSS **downlevels `@supports not selector(…)`** — for engines that
1030
+ * cannot evaluate `selector()` it emits a duplicate of the rule OUTSIDE the
1031
+ * guard — so the compiled sheet contained:
1032
+ *
1033
+ * .scrollbar-quiet{scrollbar-width:thin;scrollbar-color:transparent transparent}
1034
+ *
1035
+ * unconditionally, in every browser. That re-created the original bug exactly:
1036
+ * `scrollbar-width` is the one property that makes Chromium ignore the
1037
+ * pseudo-elements, so everything above silently stopped applying and the
1038
+ * platform drew its own bar again. Moving the block to the top level did not
1039
+ * help; the duplication is the compiler's, not the nesting's.
1040
+ *
1041
+ * `-moz-orient` is a property Firefox alone implements, so this is a plain
1042
+ * property query — universally evaluable, nothing for Lightning to downlevel,
1043
+ * and verified false in Chromium. Do not "simplify" it back to a selector query.
1044
+ *
1045
+ * The invariant, stated plainly: **`scrollbar-width` must never reach a
1046
+ * Chromium browser.** Anything that puts it there disables everything above.
1047
+ */
1048
+ @supports (-moz-orient: inline) {
1049
+ .scrollbar-quiet {
1050
+ scrollbar-width: thin;
1051
+ scrollbar-color: transparent transparent;
1052
+ }
1053
+ .scrollbar-quiet[data-scrolling],
1054
+ .scrollbar-quiet:hover,
1055
+ .scrollbar-quiet:focus-within {
1056
+ scrollbar-color: color-mix(in oklab, var(--foreground) 16%, transparent)
1057
+ transparent;
1058
+ }
1059
+ }
1060
+
1061
+ /*
1062
+ * No scrollbar at all — the region still scrolls, it just never draws a bar.
1063
+ *
1064
+ * For surfaces where the affordance is carried by something better than a
1065
+ * scrollbar: a horizontal strip whose edge fades already say "there is more
1066
+ * this way", a carousel with paging controls, a canvas panned by drag. This is
1067
+ * the default for horizontal scrolling in Sable (see `Scroller`), because a
1068
+ * 4px bar under a row of media tiles is both uglier and less informative than
1069
+ * the fade it sits beneath.
1070
+ *
1071
+ * Use it deliberately, not to tidy something up. A VERTICAL region with no bar
1072
+ * and no fade tells a reader nothing about how far through a long list they
1073
+ * are, and there is no keyboard or pointer affordance left to discover. If you
1074
+ * cannot name the thing carrying the affordance instead, you want
1075
+ * `scrollbar-quiet`.
1076
+ */
1077
+ @utility scrollbar-hidden {
1078
+ scrollbar-width: none;
1079
+
1080
+ &::-webkit-scrollbar {
1081
+ display: none;
1082
+ width: 0;
1083
+ height: 0;
1084
+ }
1085
+ }
1086
+
1087
+ /* ==========================================================================
1088
+ The type half of the density ladder.
1089
+
1090
+ `--control-*` retunes every control's HEIGHT inside a `data-density` scope
1091
+ for free, because heights are custom properties. Typography is not: the page
1092
+ and workspace registers are separate paired utilities, so anything naming
1093
+ `text-body-md` kept page-register type while its box shrank around it. A
1094
+ compact `Input` was a 32px control carrying 15px text, and every form in a
1095
+ workspace read oversized — which is exactly how it was reported.
1096
+
1097
+ Sable's own components ask for the right tier through `useTypeTier()`, and a
1098
+ test enforces that (`lib/register-type.test.ts`). But a component sweep
1099
+ cannot reach the OTHER half of the problem: the markup a consuming app — or
1100
+ an agent working in one — writes itself. `<p class="text-body-md">` inside a
1101
+ tool panel is page-register type in a workspace, and no amount of correctness
1102
+ inside our components changes it.
1103
+
1104
+ So the utilities retune too, and for the same reason the control ladder does:
1105
+ the register is a scope, and everything in it should speak the register.
1106
+
1107
+ ── Sizes compress, weights move onto the workspace scale ───────────────────
1108
+ Both, and the weight half is not optional. DESIGN.md → Registers specifies
1109
+ the workspace voice as 10–12px at weights 450–550, and `useTypeTier()`
1110
+ already hands components exactly that. If these overrides kept the page
1111
+ weights, a compact panel would carry 600 labels from raw utilities beside
1112
+ 500 labels from wired components — the same nominal tier rendering two
1113
+ weights in one panel. Hierarchy still survives: label stays heavier than
1114
+ body at both registers, just on the register's own scale.
1115
+
1116
+ Specificity `0,2,0` against the utilities' `0,1,0`, and unlayered so it wins
1117
+ over `@layer utilities` regardless of order. A `Dialog` opened from a tool
1118
+ resets `data-density` to comfortable, so task surfaces are unaffected — see
1119
+ lib/density.tsx → Registers.
1120
+ ========================================================================== */
1121
+ [data-density="compact"] {
1122
+ /*
1123
+ * Every value is `min(page, workspace)`, so the register can only ever
1124
+ * COMPRESS. Without it these grow on some apps: the page tiers are `rem`
1125
+ * and the workspace tiers are absolute px, so at a 14px root
1126
+ * `text-label-md` is 10.5px on the page and would jump to 12px inside a
1127
+ * tool panel — a "denser" register rendering larger text than the page it
1128
+ * sits beside. `min()` makes that impossible at any root size.
1129
+ */
1130
+ & .text-body-lg {
1131
+ font-size: min(1.0625rem, var(--type-ui-md-size, 12px));
1132
+ line-height: var(--type-ui-leading, 16px);
1133
+ }
1134
+ & .text-body-md {
1135
+ font-size: min(0.9375rem, var(--type-ui-md-size, 12px));
1136
+ line-height: var(--type-ui-leading, 16px);
1137
+ }
1138
+ & .text-body-sm {
1139
+ font-size: min(0.8125rem, var(--type-ui-sm-size, 11px));
1140
+ line-height: var(--type-ui-leading, 16px);
1141
+ }
1142
+ & .text-body-xs,
1143
+ & .text-caption {
1144
+ font-size: min(0.75rem, var(--type-ui-sm-size, 11px));
1145
+ line-height: var(--type-ui-leading, 16px);
1146
+ }
1147
+ & .text-label-lg {
1148
+ font-size: min(0.875rem, var(--type-ui-md-size, 12px));
1149
+ font-weight: var(--type-ui-md-weight, 500);
1150
+ line-height: var(--type-ui-leading, 16px);
1151
+ }
1152
+ & .text-label-md {
1153
+ font-size: min(0.75rem, var(--type-ui-md-size, 12px));
1154
+ font-weight: var(--type-ui-md-weight, 500);
1155
+ line-height: var(--type-ui-leading, 16px);
1156
+ }
1157
+ & .text-label-sm,
1158
+ & .text-label-xs {
1159
+ font-size: min(0.6875rem, var(--type-ui-micro-size, 10px));
1160
+ font-weight: var(--type-ui-micro-weight, 450);
1161
+ line-height: 14px;
1162
+ }
1163
+ & .text-title-sm {
1164
+ font-size: min(0.875rem, var(--type-ui-md-size, 12px));
1165
+ font-weight: var(--type-ui-section-weight, 550);
1166
+ line-height: var(--type-ui-leading, 16px);
1167
+ }
1168
+ & .text-title-lg {
1169
+ font-size: min(1rem, var(--type-ui-md-size, 12px));
1170
+ font-weight: var(--type-ui-section-weight, 550);
1171
+ line-height: var(--type-ui-leading, 16px);
1172
+ }
1173
+ }
1174
+
1175
+ /* ==========================================================================
1176
+ Layout — responsive grid spans + split panes.
1177
+
1178
+ The fluid layout primitives (Region, Grid, Stack…) compute their styles inline
1179
+ so any dynamic value works without Tailwind scanning a class. Breakpoint-aware
1180
+ layout can't be inline (a style attribute has no media queries), so `Col` and
1181
+ `SplitPane` set per-breakpoint CSS custom properties inline and these rules
1182
+ resolve them mobile-first. Breakpoints match Tailwind's defaults
1183
+ (sm 40 / md 48 / lg 64 / xl 80 / 2xl 96 rem) so they line up with the `lg:*`
1184
+ visibility utilities the app-shell components already use.
1185
+ ========================================================================== */
1186
+
1187
+ /* `Col span={{ base, md, lg }}` / `start={…}` — each breakpoint resolves to the
1188
+ most specific value defined at or below it (mobile-first cascade). */
1189
+ [data-sable-col] {
1190
+ grid-column: var(--col-start, auto) / span var(--col-span, 1);
1191
+ --col-span: var(--col-span-base, 1);
1192
+ --col-start: var(--col-start-base, auto);
1193
+ }
1194
+ @media (width >= 40rem) {
1195
+ [data-sable-col] {
1196
+ --col-span: var(--col-span-sm, var(--col-span-base, 1));
1197
+ --col-start: var(--col-start-sm, var(--col-start-base, auto));
1198
+ }
1199
+ }
1200
+ @media (width >= 48rem) {
1201
+ [data-sable-col] {
1202
+ --col-span: var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1)));
1203
+ --col-start: var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto)));
1204
+ }
1205
+ }
1206
+ @media (width >= 64rem) {
1207
+ [data-sable-col] {
1208
+ --col-span: var(--col-span-lg, var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1))));
1209
+ --col-start: var(--col-start-lg, var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto))));
1210
+ }
1211
+ }
1212
+ @media (width >= 80rem) {
1213
+ [data-sable-col] {
1214
+ --col-span: var(--col-span-xl, var(--col-span-lg, var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1)))));
1215
+ --col-start: var(--col-start-xl, var(--col-start-lg, var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto)))));
1216
+ }
1217
+ }
1218
+ @media (width >= 96rem) {
1219
+ [data-sable-col] {
1220
+ --col-span: var(--col-span-2xl, var(--col-span-xl, var(--col-span-lg, var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1))))));
1221
+ --col-start: var(--col-start-2xl, var(--col-start-xl, var(--col-start-lg, var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto))))));
1222
+ }
1223
+ }
1224
+
1225
+ /* `Columns count` / `Grid cols` as a breakpoint map — set per-breakpoint --cols-*
1226
+ props inline; resolved mobile-first into the track's column count, so a grid
1227
+ thins to fewer columns on small screens (e.g. 2 → 6 → 12). */
1228
+ [data-sable-grid-cols] {
1229
+ display: grid;
1230
+ grid-template-columns: repeat(var(--cols, var(--grid-columns)), minmax(0, 1fr));
1231
+ --cols: var(--cols-base, var(--grid-columns));
1232
+ }
1233
+ @media (width >= 40rem) {
1234
+ [data-sable-grid-cols] {
1235
+ --cols: var(--cols-sm, var(--cols-base, var(--grid-columns)));
1236
+ }
1237
+ }
1238
+ @media (width >= 48rem) {
1239
+ [data-sable-grid-cols] {
1240
+ --cols: var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))));
1241
+ }
1242
+ }
1243
+ @media (width >= 64rem) {
1244
+ [data-sable-grid-cols] {
1245
+ --cols: var(
1246
+ --cols-lg,
1247
+ var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))))
1248
+ );
1249
+ }
1250
+ }
1251
+ @media (width >= 80rem) {
1252
+ [data-sable-grid-cols] {
1253
+ --cols: var(
1254
+ --cols-xl,
1255
+ var(
1256
+ --cols-lg,
1257
+ var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))))
1258
+ )
1259
+ );
1260
+ }
1261
+ }
1262
+ @media (width >= 96rem) {
1263
+ [data-sable-grid-cols] {
1264
+ --cols: var(
1265
+ --cols-2xl,
1266
+ var(
1267
+ --cols-xl,
1268
+ var(
1269
+ --cols-lg,
1270
+ var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))))
1271
+ )
1272
+ )
1273
+ );
1274
+ }
1275
+ }
1276
+
1277
+ /* `SplitPane collapseBelow="lg"` — a single stacked column below the breakpoint;
1278
+ a two-column track (primary:secondary fr ratio) at or above it. */
1279
+ [data-sable-split] {
1280
+ display: grid;
1281
+ gap: var(--split-gap, var(--grid-gutter));
1282
+ grid-template-columns: 1fr;
1283
+ }
1284
+ @media (width >= 40rem) {
1285
+ [data-sable-split][data-collapse="sm"] {
1286
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1287
+ }
1288
+ }
1289
+ @media (width >= 48rem) {
1290
+ [data-sable-split][data-collapse="md"] {
1291
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1292
+ }
1293
+ }
1294
+ @media (width >= 64rem) {
1295
+ [data-sable-split][data-collapse="lg"] {
1296
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1297
+ }
1298
+ }
1299
+ @media (width >= 80rem) {
1300
+ [data-sable-split][data-collapse="xl"] {
1301
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1302
+ }
1303
+ }
1304
+ @media (width >= 96rem) {
1305
+ [data-sable-split][data-collapse="2xl"] {
1306
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1307
+ }
1308
+ }
1309
+
1310
+ /* ==========================================================================
1311
+ Motion — keyframes, recipes, and policy.
1312
+
1313
+ Motion is a first-class layer of Sable. Three ideas:
1314
+
1315
+ 1. TOKENS (themes/base/base.css): a duration ladder + semantic
1316
+ easings, every duration scaled by --motion-scale.
1317
+ 2. RECIPES (below): named, reusable motion behaviours a component opts into
1318
+ with ONE class (e.g. `motion-pop`, `motion-press`) instead of hand-rolling
1319
+ a transition string per component. Enter/exit recipes drive off Base UI's
1320
+ data-[starting-style]/data-[ending-style] attributes, so the same class
1321
+ animates both directions and is fully interruptible.
1322
+ 3. POLICY (bottom): `prefers-reduced-motion` and the <MotionProvider> toggle
1323
+ collapse --motion-scale to 0 — one switch makes all token-driven motion
1324
+ static (transitions resolve instantly; loaders hold their first frame).
1325
+
1326
+ Keep motion subtle: opacity + transform only (compositor-friendly), short
1327
+ durations, no spring/overshoot. See DESIGN.md → Motion.
1328
+ ========================================================================== */
1329
+
1330
+ @keyframes sable-spin {
1331
+ to {
1332
+ rotate: 360deg;
1333
+ }
1334
+ }
1335
+ @keyframes sable-shimmer {
1336
+ from {
1337
+ background-position: 200% 0;
1338
+ }
1339
+ to {
1340
+ background-position: -200% 0;
1341
+ }
1342
+ }
1343
+ @keyframes sable-pulse-soft {
1344
+ 0%,
1345
+ 100% {
1346
+ opacity: 1;
1347
+ }
1348
+ 50% {
1349
+ opacity: 0.55;
1350
+ }
1351
+ }
1352
+ @keyframes sable-dot-bounce {
1353
+ 0%,
1354
+ 80%,
1355
+ 100% {
1356
+ translate: 0 0;
1357
+ opacity: 0.5;
1358
+ }
1359
+ 40% {
1360
+ translate: 0 -25%;
1361
+ opacity: 1;
1362
+ }
1363
+ }
1364
+ @keyframes sable-ping {
1365
+ 75%,
1366
+ 100% {
1367
+ scale: 2;
1368
+ opacity: 0;
1369
+ }
1370
+ }
1371
+ /* One arriving token. Blur is the point, not decoration: a pure fade makes each
1372
+ word a separate event, while resolving OUT OF BLUR reads as the word coming
1373
+ into focus — one continuous sentence rather than a queue. Kept to 4px, which
1374
+ is legible-adjacent; heavier looks like a rendering fault. */
1375
+ @keyframes sable-stream-in {
1376
+ from {
1377
+ opacity: 0;
1378
+ filter: blur(4px);
1379
+ }
1380
+ to {
1381
+ opacity: 1;
1382
+ filter: blur(0);
1383
+ }
1384
+ }
1385
+ /* The idle caret. `step-end` so it snaps rather than pulses — a caret that
1386
+ fades is a caret that looks like it is still doing something. */
1387
+ @keyframes sable-caret-blink {
1388
+ 0%,
1389
+ 100% {
1390
+ opacity: 1;
1391
+ }
1392
+ 50% {
1393
+ opacity: 0;
1394
+ }
1395
+ }
1396
+ @keyframes sable-fade-in {
1397
+ from {
1398
+ opacity: 0;
1399
+ }
1400
+ }
1401
+ @keyframes sable-fade-up {
1402
+ from {
1403
+ opacity: 0;
1404
+ translate: 0 0.5rem;
1405
+ }
1406
+ }
1407
+ @keyframes sable-fade-down {
1408
+ from {
1409
+ opacity: 0;
1410
+ translate: 0 -0.5rem;
1411
+ }
1412
+ }
1413
+
1414
+ /* --------------------------------------------------------------------------
1415
+ Interaction recipes — for always-mounted elements (hover/focus/press).
1416
+ -------------------------------------------------------------------------- */
1417
+
1418
+ /* The transparency checkerboard — what "no colour here" looks like. Two real
1419
+ consumers: the artboard behind a canvas, and a colour swatch whose value is
1420
+ transparent. Both were hand-rolling the same gradient.
1421
+
1422
+ The light square is `--panel`, so it is themeable and correct in both modes
1423
+ rather than two hardcoded greys. The dark square is DERIVED with a deliberate
1424
+ step rather than borrowed from `--panel-raised`, and that was a correction: the
1425
+ first version used `--panel-raised`, whose job is a selected row, and which
1426
+ therefore lives in the workspace register's deliberately COMPRESSED tonal band.
1427
+ Measured, that gave the two squares 19/255 apart in light and only 12/255 in
1428
+ dark — against roughly 31 for Figma's transparency checker and 51 for
1429
+ Photoshop's. A compressed band is right for chrome that should not shout and
1430
+ exactly wrong for a pattern whose whole job is to be seen. 88% doubles the step.
1431
+
1432
+ Override `--checkerboard-alt` for a specific pairing. Size is tunable per use —
1433
+ an artboard wants ~16px, a 24px swatch wants ~8px:
1434
+
1435
+ <div class="checkerboard [--checkerboard-size:8px]" />
1436
+
1437
+ NOT named `bg-checkerboard`, deliberately. A custom utility starting with
1438
+ `bg-` lands in tailwind-merge's background group and would silently evict (or
1439
+ be evicted by) a real `bg-*` colour class — the same failure that shipped
1440
+ white text on a white button via `text-ws-md`. See lib/cn.ts. An unprefixed
1441
+ name belongs to no merge group and is passed through untouched. */
1442
+ @utility checkerboard {
1443
+ background-image: repeating-conic-gradient(
1444
+ var(--panel) 0% 25%,
1445
+ var(--checkerboard-alt, color-mix(in oklch, var(--panel) 88%, var(--foreground)))
1446
+ 0% 50%
1447
+ );
1448
+ background-size: var(--checkerboard-size, 16px) var(--checkerboard-size, 16px);
1449
+ background-position: 0 0;
1450
+ }
1451
+
1452
+ /* The default transition for interactive chrome — hover/focus/selection colour
1453
+ shifts plus the press/slide transforms. The single transition declarer for
1454
+ interactive controls: it lists scale + translate too, so `motion-press` and
1455
+ thumb/indicator transforms compose without a conflicting transition-property.
1456
+ Replaces ad-hoc `transition-colors`.
1457
+
1458
+ It also carries the two TOUCH declarations, and this is the right host for
1459
+ them precisely because CONTRIBUTING.md → Motion already mandates this class on
1460
+ every interactive control (`response` slot, "replaces transition-colors").
1461
+ One hook reaches everything; a separate `touch-*` utility would have to be
1462
+ remembered per component, which is how the package got to zero uses of
1463
+ `touch-action` in the first place.
1464
+
1465
+ `touch-action: manipulation` drops the ~300ms click delay mobile engines hold
1466
+ in case a tap turns into a double-tap-to-zoom. Nothing in Sable wants
1467
+ double-tap zoom on a control, so the wait buys nothing and costs every tap on
1468
+ every button, tab, and menu item. It still permits pan and pinch-zoom on the
1469
+ page — only the double-tap gesture goes.
1470
+
1471
+ `-webkit-tap-highlight-color: transparent` removes the platform's grey flash,
1472
+ which paints an unthemeable rectangle over our own hover/press chrome (and
1473
+ over a rounded corner, squarely). We already answer "you touched this" with
1474
+ `motion-press` and the hover/active surface — the flash is a second, worse
1475
+ answer. Both are inert on pointer devices, and neither is motion, so
1476
+ --motion-scale: 0 and prefers-reduced-motion leave them untouched: the delay
1477
+ fix keeps working when animation is off, which is when it matters most. */
1478
+ @utility motion-interactive {
1479
+ transition-property: color, background-color, border-color, box-shadow,
1480
+ opacity, fill, stroke, scale, translate;
1481
+ transition-duration: var(--motion-duration-fast);
1482
+ transition-timing-function: var(--motion-ease-standard);
1483
+ touch-action: manipulation;
1484
+ -webkit-tap-highlight-color: transparent;
1485
+ }
1486
+
1487
+ /* Tactile press feedback — a small settle on pointer-down. Adds only the
1488
+ pressed-state target; pair it with `motion-interactive` (which declares the
1489
+ transition). Skips disabled elements. */
1490
+ @utility motion-press {
1491
+ &:active:not(:disabled):not([data-disabled]):not([aria-disabled="true"]) {
1492
+ scale: 0.97;
1493
+ }
1494
+ }
1495
+
1496
+ /* --------------------------------------------------------------------------
1497
+ Enter/exit recipes — for Base UI overlays/portals. The element rests in its
1498
+ open state; data-[starting-style] (just-mounted) and data-[ending-style]
1499
+ (closing) supply the from/to. transform-origin follows Base UI's anchor.
1500
+ -------------------------------------------------------------------------- */
1501
+
1502
+ /* Backdrops / scrims — opacity only. */
1503
+ @utility motion-fade {
1504
+ transition-property: opacity;
1505
+ transition-duration: var(--motion-duration-enter);
1506
+ transition-timing-function: var(--motion-ease-standard);
1507
+ &[data-starting-style],
1508
+ &[data-ending-style] {
1509
+ opacity: 0;
1510
+ }
1511
+ }
1512
+
1513
+ /* Anchored & centered surfaces — popover, menu, select, tooltip, dialog,
1514
+ combobox, command menu, date picker. Opacity + a slight scale from the
1515
+ anchor edge. */
1516
+ @utility motion-pop {
1517
+ transform-origin: var(--transform-origin, center);
1518
+ transition-property: opacity, scale, translate;
1519
+ transition-duration: var(--motion-duration-enter);
1520
+ transition-timing-function: var(--motion-ease-spring);
1521
+ &[data-ending-style] {
1522
+ transition-duration: var(--motion-duration-exit);
1523
+ transition-timing-function: var(--motion-ease-standard);
1524
+ }
1525
+ &[data-starting-style],
1526
+ &[data-ending-style] {
1527
+ opacity: 0;
1528
+ scale: 0.96;
1529
+ }
1530
+ }
1531
+
1532
+ /* Directional fade-slide (small 0.5rem offset) — toasts, banners, dropdowns
1533
+ that read better sliding than scaling. Suffix = travel direction on enter. */
1534
+ @utility motion-slide-up {
1535
+ transition-property: opacity, translate;
1536
+ transition-duration: var(--motion-duration-enter);
1537
+ transition-timing-function: var(--motion-ease-spring);
1538
+ &[data-ending-style] {
1539
+ transition-duration: var(--motion-duration-exit);
1540
+ transition-timing-function: var(--motion-ease-standard);
1541
+ }
1542
+ &[data-starting-style],
1543
+ &[data-ending-style] {
1544
+ opacity: 0;
1545
+ translate: 0 0.5rem;
1546
+ }
1547
+ }
1548
+ @utility motion-slide-down {
1549
+ transition-property: opacity, translate;
1550
+ transition-duration: var(--motion-duration-enter);
1551
+ transition-timing-function: var(--motion-ease-spring);
1552
+ &[data-ending-style] {
1553
+ transition-duration: var(--motion-duration-exit);
1554
+ transition-timing-function: var(--motion-ease-standard);
1555
+ }
1556
+ &[data-starting-style],
1557
+ &[data-ending-style] {
1558
+ opacity: 0;
1559
+ translate: 0 -0.5rem;
1560
+ }
1561
+ }
1562
+ @utility motion-slide-left {
1563
+ transition-property: opacity, translate;
1564
+ transition-duration: var(--motion-duration-enter);
1565
+ transition-timing-function: var(--motion-ease-spring);
1566
+ &[data-ending-style] {
1567
+ transition-duration: var(--motion-duration-exit);
1568
+ transition-timing-function: var(--motion-ease-standard);
1569
+ }
1570
+ &[data-starting-style],
1571
+ &[data-ending-style] {
1572
+ opacity: 0;
1573
+ translate: 0.5rem 0;
1574
+ }
1575
+ }
1576
+ @utility motion-slide-right {
1577
+ transition-property: opacity, translate;
1578
+ transition-duration: var(--motion-duration-enter);
1579
+ transition-timing-function: var(--motion-ease-spring);
1580
+ &[data-ending-style] {
1581
+ transition-duration: var(--motion-duration-exit);
1582
+ transition-timing-function: var(--motion-ease-standard);
1583
+ }
1584
+ &[data-starting-style],
1585
+ &[data-ending-style] {
1586
+ opacity: 0;
1587
+ translate: -0.5rem 0;
1588
+ }
1589
+ }
1590
+
1591
+ /* Disclosure — height + fade transition for accordion/collapsible panels. The
1592
+ component sets the target height (height: var(--…-panel-height)) and zeroes
1593
+ it on data-[starting-style]/data-[ending-style]; this recipe carries the
1594
+ overflow, the transition, and the content fade. */
1595
+ @utility motion-disclosure {
1596
+ overflow: hidden;
1597
+ transition-property: height, opacity;
1598
+ transition-duration: var(--motion-duration-base);
1599
+ transition-timing-function: var(--motion-ease-standard);
1600
+ &[data-starting-style],
1601
+ &[data-ending-style] {
1602
+ opacity: 0;
1603
+ }
1604
+ }
1605
+
1606
+ /* Icon swap — the `transition` slot for a control whose GLYPH changes with its
1607
+ state: play→pause, mute→unmute, outline→fill, copy→check. Nothing in the
1608
+ four-slot contract covered it, so components either snapped (the icon simply
1609
+ becomes a different icon between two frames) or hand-rolled a transition.
1610
+
1611
+ Both icons stay in the DOM and are stacked on the same cell; the one that is
1612
+ not current is marked `aria-hidden="true"` (or `data-active="false"`) and
1613
+ cross-fades out while the other fades in. Keying off `aria-hidden` is
1614
+ deliberate: with both glyphs mounted you have to hide the inactive one from
1615
+ assistive tech anyway, so the correct markup IS the animation hook and there
1616
+ is no second thing to remember.
1617
+
1618
+ <span class="motion-icon-swap">
1619
+ <Icon name="play" aria-hidden={playing} />
1620
+ <Icon name="pause" aria-hidden={!playing} />
1621
+ </span>
1622
+
1623
+ Stacked with a one-cell grid rather than `position: absolute` on the second
1624
+ child: absolute positioning takes the icon out of flow, so the wrapper
1625
+ collapses and every consumer has to restate the size. `grid-area: 1 / 1`
1626
+ stacks them while the wrapper still sizes to the largest glyph.
1627
+
1628
+ Scale 0.25 + blur 4px, not a plain cross-fade. A pure opacity swap between
1629
+ two marks occupying the same box reads as a smear — for a moment you see
1630
+ both. Collapsing the outgoing glyph toward its centre out of focus (and the
1631
+ incoming one out of blur, the same 4px `sable-stream-in` uses) makes it read
1632
+ as one mark being replaced. Compositor-only properties throughout.
1633
+
1634
+ Durations come from --motion-duration-enter/--motion-duration-exit and the
1635
+ easing from the ease-emphasized token — never a literal ms or bezier, which
1636
+ would opt out of --motion-scale and keep animating after the user asked for
1637
+ stillness. Under the policy the scaled duration is 0 and the swap is instant,
1638
+ which is the correct reduced-motion behaviour: the glyph still changes. */
1639
+ @utility motion-icon-swap {
1640
+ display: inline-grid;
1641
+ place-items: center;
1642
+ & > * {
1643
+ grid-area: 1 / 1;
1644
+ transition-property: opacity, scale, filter;
1645
+ transition-duration: var(--motion-duration-enter);
1646
+ transition-timing-function: var(--motion-ease-emphasized);
1647
+ }
1648
+ & > [aria-hidden="true"],
1649
+ & > [data-active="false"] {
1650
+ opacity: 0;
1651
+ scale: 0.25;
1652
+ filter: blur(4px);
1653
+ /* Not interactive while invisible — it is still stacked over the live one. */
1654
+ pointer-events: none;
1655
+ transition-duration: var(--motion-duration-exit);
1656
+ }
1657
+ }
1658
+
1659
+ /* --------------------------------------------------------------------------
1660
+ Loader recipes — looping indicators. Durations scale with --motion-scale, so
1661
+ the policy switch freezes them to a static first frame.
1662
+ -------------------------------------------------------------------------- */
1663
+ @utility motion-spin {
1664
+ animation: sable-spin calc(700ms * var(--motion-scale)) linear infinite;
1665
+ }
1666
+ /* Orbit — a SLOW continuous rotation for ambient line-art / schematic figures
1667
+ (the "alive" loop a captured canvas illustration keeps after it draws on;
1668
+ pairs with motion-draw on the same element). Duration via
1669
+ --motion-orbit-duration (default 24s), direction via --motion-orbit-direction.
1670
+ Scaled by --motion-scale, so reduced motion collapses the duration to 0 and
1671
+ the figure holds still. transform-origin centers the spin on any viewBox. */
1672
+ @utility motion-orbit {
1673
+ animation-name: sable-spin;
1674
+ animation-duration: calc(var(--motion-orbit-duration, 24s) * var(--motion-scale, 1));
1675
+ animation-timing-function: linear;
1676
+ animation-iteration-count: infinite;
1677
+ animation-direction: var(--motion-orbit-direction, normal);
1678
+ transform-origin: center;
1679
+ }
1680
+ @utility motion-pulse {
1681
+ animation: sable-pulse-soft calc(1600ms * var(--motion-scale))
1682
+ var(--motion-ease-standard) infinite;
1683
+ }
1684
+ @utility motion-shimmer {
1685
+ background-image: linear-gradient(
1686
+ 100deg,
1687
+ transparent 40%,
1688
+ color-mix(in oklab, var(--foreground) 8%, transparent) 50%,
1689
+ transparent 60%
1690
+ );
1691
+ background-size: 200% 100%;
1692
+ background-repeat: no-repeat;
1693
+ animation: sable-shimmer calc(1800ms * var(--motion-scale)) linear infinite;
1694
+ }
1695
+ /*
1696
+ * The same sweep, but through the glyphs instead of behind them — for live
1697
+ * status *text*: "Thinking…", "Running search", "Generating response". The
1698
+ * surface `motion-shimmer` is wrong for that job: it paints a faint band on the
1699
+ * box, which reads as a loading skeleton sitting under real words.
1700
+ *
1701
+ * `background-clip: text` + a transparent fill makes the gradient BE the text,
1702
+ * so the sweep is legible at body sizes where an 8% band over the box is not.
1703
+ * The gradient runs muted → foreground → muted rather than transparent →
1704
+ * something: text that fades to transparent is text that disappears, and a
1705
+ * status line has to stay readable through every frame of its own animation.
1706
+ *
1707
+ * Named `motion-*`, not `text-*`, for two reasons. It is a motion recipe and
1708
+ * belongs with the family whose durations `--motion-scale` can freeze; and
1709
+ * `text-shimmer` would be parsed by tailwind-merge as a text utility, which is
1710
+ * the exact footgun CONTRIBUTING.md → Registers #4 documents (an unregistered
1711
+ * `text-foo` silently drops the real colour class).
1712
+ */
1713
+ @utility motion-text-shimmer {
1714
+ background-image: linear-gradient(
1715
+ 100deg,
1716
+ var(--muted-foreground) 40%,
1717
+ var(--foreground) 50%,
1718
+ var(--muted-foreground) 60%
1719
+ );
1720
+ background-size: 200% 100%;
1721
+ /*
1722
+ * `repeat`, NOT the `no-repeat` the surface shimmer uses — and the
1723
+ * difference is the whole reason this recipe is separate. The keyframe
1724
+ * travels the background from 200% to -200%, so with `no-repeat` the band
1725
+ * spends part of every cycle entirely off the element. On a surface that
1726
+ * just means no sweep for a moment. Clipped to text over a transparent
1727
+ * fill it means no *text*: the label blinks out mid-word. The gradient
1728
+ * begins and ends on the same colour, so tiling it is seamless and every
1729
+ * pixel of every glyph is always painted.
1730
+ */
1731
+ background-repeat: repeat;
1732
+ background-clip: text;
1733
+ -webkit-background-clip: text;
1734
+ color: transparent;
1735
+ animation: sable-shimmer calc(1800ms * var(--motion-scale)) linear infinite;
1736
+ }
1737
+ @utility motion-dot-bounce {
1738
+ animation: sable-dot-bounce calc(1400ms * var(--motion-scale))
1739
+ var(--motion-ease-standard) infinite;
1740
+ }
1741
+ @utility motion-ping {
1742
+ animation: sable-ping calc(1100ms * var(--motion-scale))
1743
+ var(--motion-ease-decelerate) infinite;
1744
+ }
1745
+
1746
+ /* --------------------------------------------------------------------------
1747
+ Entrance & reveal — one-shot mount animations (page transitions, lists) and
1748
+ the scroll-reveal recipe used by <Reveal>/<TextReveal>. `both` fill holds the
1749
+ from-state before start and the to-state after, so there's no flash; under
1750
+ the off/reduced policy the scaled duration is 0 and content simply appears.
1751
+ -------------------------------------------------------------------------- */
1752
+ @utility animate-fade-in {
1753
+ animation: sable-fade-in var(--motion-duration-base)
1754
+ var(--motion-ease-decelerate) both;
1755
+ }
1756
+ @utility animate-fade-up {
1757
+ animation: sable-fade-up var(--motion-duration-slow)
1758
+ var(--motion-ease-decelerate) both;
1759
+ }
1760
+ @utility animate-fade-down {
1761
+ animation: sable-fade-down var(--motion-duration-slow)
1762
+ var(--motion-ease-decelerate) both;
1763
+ }
1764
+
1765
+ /* --------------------------------------------------------------------------
1766
+ Streaming — tokens arriving from a live source, one at a time. Distinct from
1767
+ the reveal recipes above, which stage text that is ALREADY THERE: nothing here
1768
+ knows the final length, so there is no stagger to compute and no `both` fill
1769
+ to hold. Each token animates once, on mount, and never again.
1770
+ -------------------------------------------------------------------------- */
1771
+ @utility motion-stream-in {
1772
+ animation: sable-stream-in var(--motion-duration-slow)
1773
+ var(--motion-ease-decelerate) both;
1774
+ /* Blur and opacity on a text run are expensive enough to be worth hinting,
1775
+ and the hint is cheap because the element is short-lived by construction. */
1776
+ will-change: filter, opacity;
1777
+ }
1778
+
1779
+ /* The trailing edge of a live stream. A hard cut at the newest token reads as a
1780
+ sentence that has ENDED; softening it says "more is coming" without spending
1781
+ an animation on it. Static — the mask does not move, the text moves past it. */
1782
+ @utility stream-tail {
1783
+ filter: blur(1.6px);
1784
+ /* A mask reads only the ALPHA channel, so the colour here is machinery, not a
1785
+ design decision — written `rgb(0 0 0 / …)` rather than `#000`/`#0003` so it
1786
+ needs no `inline-color` exception. Ends at 20% rather than 0: the newest
1787
+ token stays faintly readable instead of vanishing mid-word. */
1788
+ mask-image: linear-gradient(90deg, rgb(0 0 0) 20%, rgb(0 0 0 / 20%));
1789
+ -webkit-mask-image: linear-gradient(90deg, rgb(0 0 0) 20%, rgb(0 0 0 / 20%));
1790
+ }
1791
+
1792
+ /* The caret. Note the inversion: it blinks only when IDLE, and holds steady
1793
+ while tokens are arriving. Everyone ships the opposite, and the opposite is
1794
+ two competing rhythms — text moving at one rate, a caret flashing at another.
1795
+ Steady-while-streaming reads calm, and the blink returning is itself the
1796
+ signal that generation finished. */
1797
+ @utility motion-caret {
1798
+ animation: sable-caret-blink calc(1000ms * var(--motion-scale)) step-end
1799
+ infinite;
1800
+ &[data-streaming="true"] {
1801
+ animation: none;
1802
+ }
1803
+ }
1804
+
1805
+ /* Scroll/enter reveal — <Reveal>/<TextReveal> flip data-reveal to "in" when the
1806
+ element intersects the viewport. Out-of-view rests hidden + nudged down;
1807
+ reduced/off policy keeps content visible (the JS forces "in"). */
1808
+ @utility motion-reveal {
1809
+ transition-property: opacity, translate;
1810
+ transition-duration: var(--motion-duration-slow);
1811
+ transition-timing-function: var(--motion-ease-decelerate);
1812
+ &[data-reveal="out"] {
1813
+ opacity: 0;
1814
+ translate: 0 0.75rem;
1815
+ }
1816
+ }
1817
+
1818
+ /* Draw — SVG line-art draw-on for illustration paths. Put `motion-draw` on a
1819
+ path/shape that ALSO sets pathLength="1" (required — it normalizes the dash
1820
+ math for any geometry), then wrap the figure in <Reveal>: the path renders
1821
+ drawn at rest and "draws on" when the ancestor's data-reveal flips to "in".
1822
+ Stagger sibling paths via --motion-draw-delay (scaled by --motion-scale so
1823
+ reduced motion shows the completed drawing instantly, with no dead delay).
1824
+ Like motion-disclosure's height, stroke-dashoffset is a deliberate
1825
+ non-compositor exception — cheap at illustration path counts. */
1826
+ @utility motion-draw {
1827
+ stroke-dasharray: 1;
1828
+ stroke-dashoffset: 0;
1829
+ transition-property: stroke-dashoffset;
1830
+ /* Default 500ms (the `slower` step); large hero-tier schematics read better
1831
+ drawn over 1.5-2s — override via --motion-draw-duration (raw ms; scaled
1832
+ here, so reduced motion still completes instantly). */
1833
+ transition-duration: calc(var(--motion-draw-duration, 500ms) * var(--motion-scale, 1));
1834
+ transition-timing-function: var(--motion-ease-decelerate);
1835
+ transition-delay: calc(var(--motion-draw-delay, 0s) * var(--motion-scale, 1));
1836
+ [data-reveal="out"] & {
1837
+ stroke-dashoffset: 1;
1838
+ }
1839
+ }
1840
+
1841
+ /* --------------------------------------------------------------------------
1842
+ Marquee — seamless infinite loop. <Marquee> renders N identical groups in a
1843
+ track and sets --sable-marquee-distance (one group + gap, in px) + the
1844
+ animation inline, so one iteration aligns the next group exactly — seamless
1845
+ for any content/gap. Pauses on hover/focus (WCAG 2.2.2); freezes under the
1846
+ motion policy (the component drops the animation when motion is off/reduced).
1847
+ -------------------------------------------------------------------------- */
1848
+ @keyframes sable-marquee-x {
1849
+ to {
1850
+ translate: calc(-1 * var(--sable-marquee-distance, 50%)) 0;
1851
+ }
1852
+ }
1853
+ @keyframes sable-marquee-y {
1854
+ to {
1855
+ translate: 0 calc(-1 * var(--sable-marquee-distance, 50%));
1856
+ }
1857
+ }
1858
+ [data-sable-marquee][data-pause-on-hover]:hover [data-sable-marquee-track],
1859
+ [data-sable-marquee]:focus-within [data-sable-marquee-track] {
1860
+ animation-play-state: paused;
1861
+ }
1862
+
1863
+ /* --------------------------------------------------------------------------
1864
+ Motion policy. Collapsing --motion-scale to 0 damps every token-driven
1865
+ duration to 0. Default (no [data-motion]) follows the OS preference; the
1866
+ <MotionProvider> sets [data-motion] to force a policy in-app.
1867
+
1868
+ The policy MUST also target [data-sable-theme] and descend into it: each
1869
+ theme block sets `--motion-scale: 1` (a required MOTION_TOKEN), so when a
1870
+ theme is applied to a SUBTREE (not :root) that nested declaration would
1871
+ otherwise re-assert 1 and defeat a :root-only policy for everything inside
1872
+ the themed element — silently breaking reduced-motion in every themed
1873
+ subtree. Equal-specificity `[data-sable-theme]` wins by later source order;
1874
+ the descendant combinators (0,2,0) beat the theme block for the in-app
1875
+ toggle case. Policy values are important because app-owned preset CSS loads
1876
+ after this stylesheet and can have more specific mode selectors. Theme values
1877
+ must not re-enable motion after the OS or provider disables it. ------------ */
1878
+ @media (prefers-reduced-motion: reduce) {
1879
+ :root,
1880
+ [data-sable-theme] {
1881
+ --motion-scale: 0 !important;
1882
+ }
1883
+ }
1884
+ [data-motion="full"],
1885
+ [data-motion="full"] [data-sable-theme] {
1886
+ --motion-scale: 1 !important;
1887
+ }
1888
+ [data-motion="reduced"],
1889
+ [data-motion="reduced"] [data-sable-theme],
1890
+ [data-motion="off"],
1891
+ [data-motion="off"] [data-sable-theme] {
1892
+ --motion-scale: 0 !important;
1893
+ }