@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.
- package/AGENTS.md +157 -0
- package/CHANGELOG.md +50 -0
- package/CONTRIBUTING.md +221 -0
- package/DESIGN.md +548 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/bin/sable-lint.mjs +0 -0
- package/bin/sable.mjs +1223 -0
- package/dist/_contract-CYScOdf5.d.ts +150 -0
- package/dist/chunk-3IWZYBGH.js +398 -0
- package/dist/chunk-5Z5KLOAU.js +167 -0
- package/dist/chunk-7MG4SHYX.js +15 -0
- package/dist/chunk-ADLLDWLW.js +15 -0
- package/dist/chunk-EP55KSYA.js +14 -0
- package/dist/chunk-J23ELQQZ.js +9396 -0
- package/dist/chunk-JI7WJP2L.js +286 -0
- package/dist/chunk-K7RUHUGC.js +14 -0
- package/dist/chunk-KO3NAEBF.js +15 -0
- package/dist/chunk-L4IEIQQA.js +14 -0
- package/dist/chunk-L6Y46C62.js +270 -0
- package/dist/chunk-NEQNXI2G.js +25 -0
- package/dist/chunk-PAOZBZME.js +40 -0
- package/dist/chunk-RWP4XBSK.js +139 -0
- package/dist/chunk-SRQWQUTW.js +63 -0
- package/dist/chunk-YXBOYWBN.js +15 -0
- package/dist/foundations/index.d.ts +88 -0
- package/dist/foundations/index.js +792 -0
- package/dist/icon/index.d.ts +60 -0
- package/dist/icon/index.js +2 -0
- package/dist/icon-sets/brands.d.ts +44 -0
- package/dist/icon-sets/brands.js +147 -0
- package/dist/icon-sets/reicon.d.ts +8 -0
- package/dist/icon-sets/reicon.js +1 -0
- package/dist/index.d.ts +7013 -0
- package/dist/index.js +15462 -0
- package/dist/ios/chrome.css +83 -0
- package/dist/ios/frame.css +5 -0
- package/dist/ios/index.d.ts +70 -0
- package/dist/ios/index.js +313 -0
- package/dist/ios/preview.d.ts +64 -0
- package/dist/ios/preview.js +133 -0
- package/dist/ios/styles.css +77 -0
- package/dist/layout/index.d.ts +280 -0
- package/dist/layout/index.js +2 -0
- package/dist/meta-6dUzgC6M.d.ts +205 -0
- package/dist/patterns/index.d.ts +36 -0
- package/dist/patterns/index.js +934 -0
- package/dist/presets/index.d.ts +37 -0
- package/dist/presets/index.js +117 -0
- package/dist/registry-CyL28OzJ.d.ts +28 -0
- package/dist/styles.css +1893 -0
- package/dist/theme-provider-BWcizhAt.d.ts +105 -0
- package/dist/themes/atelier/atelier.css +213 -0
- package/dist/themes/atelier/index.d.ts +9 -0
- package/dist/themes/atelier/index.js +1 -0
- package/dist/themes/base/base.css +443 -0
- package/dist/themes/base/index.d.ts +9 -0
- package/dist/themes/base/index.js +1 -0
- package/dist/themes/ember/ember.css +270 -0
- package/dist/themes/ember/index.d.ts +9 -0
- package/dist/themes/ember/index.js +1 -0
- package/dist/themes/index.d.ts +10 -0
- package/dist/themes/index.js +8 -0
- package/dist/themes/meridian/index.d.ts +9 -0
- package/dist/themes/meridian/index.js +1 -0
- package/dist/themes/meridian/meridian.css +255 -0
- package/dist/themes/poppy/index.d.ts +6 -0
- package/dist/themes/poppy/index.js +1 -0
- package/dist/themes/poppy/poppy.css +174 -0
- package/dist/themes/prism/index.d.ts +6 -0
- package/dist/themes/prism/index.js +1 -0
- package/dist/themes/prism/prism.css +183 -0
- package/dist/themes/verdant/index.d.ts +9 -0
- package/dist/themes/verdant/index.js +1 -0
- package/dist/themes/verdant/verdant.css +210 -0
- package/dist/themes.css +30 -0
- package/llms/components/accordion.md +93 -0
- package/llms/components/app-header.md +70 -0
- package/llms/components/app-shell.md +117 -0
- package/llms/components/attachment.md +105 -0
- package/llms/components/avatar.md +141 -0
- package/llms/components/badge.md +117 -0
- package/llms/components/banner.md +119 -0
- package/llms/components/bottom-nav.md +80 -0
- package/llms/components/breadcrumbs.md +75 -0
- package/llms/components/browser.md +67 -0
- package/llms/components/bubble.md +119 -0
- package/llms/components/button.md +199 -0
- package/llms/components/calendar.md +75 -0
- package/llms/components/card.md +135 -0
- package/llms/components/carousel.md +87 -0
- package/llms/components/chat-composer.md +126 -0
- package/llms/components/checkbox-card.md +98 -0
- package/llms/components/checkbox.md +90 -0
- package/llms/components/code-block.md +81 -0
- package/llms/components/code.md +62 -0
- package/llms/components/collapsible.md +83 -0
- package/llms/components/color-field.md +100 -0
- package/llms/components/combobox.md +119 -0
- package/llms/components/command-menu.md +158 -0
- package/llms/components/context-card.md +73 -0
- package/llms/components/context-menu.md +79 -0
- package/llms/components/data-table.md +99 -0
- package/llms/components/date-picker.md +97 -0
- package/llms/components/description.md +96 -0
- package/llms/components/destructive-action-modal.md +76 -0
- package/llms/components/dialog.md +127 -0
- package/llms/components/dots-menu.md +64 -0
- package/llms/components/empty-state.md +113 -0
- package/llms/components/entity.md +67 -0
- package/llms/components/feedback.md +63 -0
- package/llms/components/fieldset.md +106 -0
- package/llms/components/file-dropzone.md +103 -0
- package/llms/components/form.md +111 -0
- package/llms/components/gliding-highlight.md +84 -0
- package/llms/components/inline-status.md +62 -0
- package/llms/components/input.md +127 -0
- package/llms/components/kbd.md +57 -0
- package/llms/components/label.md +73 -0
- package/llms/components/marker.md +99 -0
- package/llms/components/marquee.md +66 -0
- package/llms/components/menu.md +122 -0
- package/llms/components/message-scroller.md +110 -0
- package/llms/components/message.md +114 -0
- package/llms/components/meter.md +124 -0
- package/llms/components/middle-truncate.md +62 -0
- package/llms/components/note.md +81 -0
- package/llms/components/notification-badge.md +67 -0
- package/llms/components/number-field.md +70 -0
- package/llms/components/otp-field.md +101 -0
- package/llms/components/page-header.md +76 -0
- package/llms/components/page-loader.md +67 -0
- package/llms/components/pagination.md +92 -0
- package/llms/components/panel-group.md +120 -0
- package/llms/components/panel-section.md +122 -0
- package/llms/components/phone.md +70 -0
- package/llms/components/pill.md +73 -0
- package/llms/components/popover.md +79 -0
- package/llms/components/progress.md +98 -0
- package/llms/components/radio-card.md +90 -0
- package/llms/components/radio.md +86 -0
- package/llms/components/relative-time.md +60 -0
- package/llms/components/scroller.md +88 -0
- package/llms/components/scrub-field.md +97 -0
- package/llms/components/search-input.md +84 -0
- package/llms/components/select.md +106 -0
- package/llms/components/separator.md +70 -0
- package/llms/components/settings-menu.md +139 -0
- package/llms/components/sheet.md +97 -0
- package/llms/components/show-more.md +59 -0
- package/llms/components/side-nav.md +106 -0
- package/llms/components/sidebar.md +87 -0
- package/llms/components/skeleton.md +62 -0
- package/llms/components/slider.md +114 -0
- package/llms/components/spinner.md +87 -0
- package/llms/components/status-bar.md +91 -0
- package/llms/components/status-dot.md +70 -0
- package/llms/components/stepper.md +125 -0
- package/llms/components/streaming-text.md +86 -0
- package/llms/components/switch.md +87 -0
- package/llms/components/table.md +112 -0
- package/llms/components/tabs.md +84 -0
- package/llms/components/textarea.md +88 -0
- package/llms/components/theme-selector.md +95 -0
- package/llms/components/theme-switcher.md +71 -0
- package/llms/components/thinking-orb.md +91 -0
- package/llms/components/toast.md +130 -0
- package/llms/components/toggle.md +95 -0
- package/llms/components/toolbar.md +168 -0
- package/llms/components/tooltip.md +93 -0
- package/llms/components/tree.md +227 -0
- package/llms/components/video.md +66 -0
- package/llms/components/workspace.md +138 -0
- package/llms/foundations/accessibility.md +60 -0
- package/llms/foundations/motion.md +96 -0
- package/llms/foundations/scrolling.md +41 -0
- package/llms/foundations/shape.md +73 -0
- package/llms/foundations/spacing.md +82 -0
- package/llms/foundations/surfaces.md +69 -0
- package/llms/foundations/typography.md +120 -0
- package/llms/layout/aspect-box.md +37 -0
- package/llms/layout/bleed.md +31 -0
- package/llms/layout/center.md +37 -0
- package/llms/layout/columns.md +51 -0
- package/llms/layout/grid-overlay.md +40 -0
- package/llms/layout/grid.md +47 -0
- package/llms/layout/page.md +39 -0
- package/llms/layout/region.md +49 -0
- package/llms/layout/row.md +40 -0
- package/llms/layout/safe-area.md +37 -0
- package/llms/layout/spacer.md +39 -0
- package/llms/layout/split-pane.md +41 -0
- package/llms/layout/stack.md +39 -0
- package/llms/patterns/auth-page.md +68 -0
- package/llms/patterns/brand-landing-page.md +423 -0
- package/llms/patterns/chat-panel.md +210 -0
- package/llms/patterns/dashboard-page.md +170 -0
- package/llms/patterns/feed-page.md +103 -0
- package/llms/patterns/landing-page.md +122 -0
- package/llms/patterns/list-detail-page.md +100 -0
- package/llms/patterns/settings-page.md +121 -0
- package/llms/reference/token-intent.md +324 -0
- package/llms/reference/utilities.md +129 -0
- package/llms/tasks/adding-a-component.md +113 -0
- package/llms/tasks/auditing-an-app.md +216 -0
- package/llms/tasks/building-a-page.md +109 -0
- package/llms/tasks/building-a-tool.md +141 -0
- package/llms/tasks/building-an-ios-prototype.md +105 -0
- package/llms/tasks/building-with-sable.md +73 -0
- package/llms/tasks/choosing-a-component.md +109 -0
- package/llms/tasks/converting-an-app.md +139 -0
- package/llms/tasks/creating-a-preset.md +43 -0
- package/llms/tasks/creating-design-assets.md +96 -0
- package/llms/tasks/designing-a-surface.md +115 -0
- package/llms/tasks/developing-taste.md +157 -0
- package/llms/tasks/getting-started.md +63 -0
- package/llms/tasks/learning-from-builds.md +74 -0
- package/llms/tasks/reviewing-a-design.md +114 -0
- package/llms/tasks/reviewing-a-journey.md +84 -0
- package/llms/tasks/reviewing-motion.md +92 -0
- package/llms/tasks/using-presets.md +87 -0
- package/llms/taste/asset-plan.template.json +53 -0
- package/llms/taste/composition-follows-content.md +20 -0
- package/llms/taste/hierarchy-is-a-relationship.md +22 -0
- package/llms/taste/lessons.json +57 -0
- package/llms/taste/motion-needs-a-reason.md +21 -0
- package/llms/taste/surface-states-travel-together.md +20 -0
- package/llms-full.txt +14906 -0
- package/llms.txt +405 -0
- package/package.json +185 -3
- package/registry.json +18812 -0
- package/scripts/agent-setup.mjs +54 -0
- package/scripts/design-atlas.mjs +221 -0
- package/scripts/design-directions.mjs +123 -0
- package/scripts/design-memory.mjs +166 -0
- package/scripts/design-preferences.mjs +227 -0
- package/scripts/design-presets.mjs +48 -0
- package/scripts/grade.mjs +509 -0
- package/scripts/lint-rules.mjs +1065 -0
- package/scripts/review-evidence.mjs +120 -0
- package/scripts/skeleton.mjs +248 -0
- package/skills/entry/SKILL.md +34 -0
- package/skills/sable/SKILL.md +187 -0
- package/skills/sable/agents/openai.yaml +6 -0
- package/targets/ios.json +982 -0
- package/third-party/reicon-LICENSE.txt +20 -0
package/dist/styles.css
ADDED
|
@@ -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
|
+
}
|