@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/DESIGN.md
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
# Sable — Design System Spec
|
|
2
|
+
|
|
3
|
+
The system-level design spec for **Sable**, an owned, themeable component system. This document is **theme-agnostic**: it describes the structure, tokens, and rules that every theme inherits. The **default theme is "Base"** (a dark-by-default, content-first, fluid product aesthetic); its concrete values live in `src/themes/base/`. Other themes supply their own values for the same token contract.
|
|
4
|
+
|
|
5
|
+
For building with Sable see `AGENTS.md`; for changing the package see `CONTRIBUTING.md`. For the machine-readable catalog see `registry.json` / `llms.txt`.
|
|
6
|
+
|
|
7
|
+
## Principles
|
|
8
|
+
|
|
9
|
+
1. **Own the surface, theme the values.** Components reference semantic roles, not colors. Swapping a theme re-skins everything without touching a component.
|
|
10
|
+
2. **Content over chrome.** The UI recedes so content advances. Restraint over decoration.
|
|
11
|
+
3. **Constraint is generative.** A small set of tokens, tiers, and primitives yields a large space of good UIs. Reach for what exists before inventing.
|
|
12
|
+
4. **A deliberate visual world.** Base starts dark. A consuming product chooses its modes, typography, shape, and art direction from its brief; the token contract travels across those choices.
|
|
13
|
+
5. **Accessible by construction.** Base UI carries behavior/ARIA; semantic token pairs are pre-checked to WCAG AA.
|
|
14
|
+
6. **AI-native.** The system is documented for machines as well as people (`registry.json`, per-component `*.meta.ts`).
|
|
15
|
+
|
|
16
|
+
## Guardrails and design judgment
|
|
17
|
+
|
|
18
|
+
An inspected visual atlas supplies contextual examples; it does not prescribe a
|
|
19
|
+
house style. `sable atlas` retrieves attributed captures and transfer principles.
|
|
20
|
+
For substantial new work, `sable explore` makes three rendered directions
|
|
21
|
+
reviewable with their factual brief and primary flow held constant. `sable prefer`
|
|
22
|
+
stores an explicit human or agent judgment in an applicable scope; it never
|
|
23
|
+
silently promotes one preference into a universal rule. A validator checks the
|
|
24
|
+
evidence contract. A reviewer still has to see and operate the result. Read
|
|
25
|
+
`sable docs developing-taste` for the linked workflow.
|
|
26
|
+
|
|
27
|
+
The semantic contract protects meaning, behavior, and consistency. It does not
|
|
28
|
+
choose the story, visual hierarchy, imagery, or composition for every product.
|
|
29
|
+
Statements about Base's accent, shape, and dark-first identity describe the
|
|
30
|
+
default theme; consumers can choose a different art direction while preserving
|
|
31
|
+
semantic roles and accessibility. A palette change alone is not a new design.
|
|
32
|
+
|
|
33
|
+
Before a new surface, write a brief for the user's outcome, content, mode, register,
|
|
34
|
+
hierarchy, rhythm, color/material, behavior, motion, and narrow-screen adaptation.
|
|
35
|
+
Use a canonical pattern when it fits the information architecture. Original
|
|
36
|
+
composition from layout primitives is equally valid. Select the semantic heading
|
|
37
|
+
level by document structure and its paired typography utility by visual role.
|
|
38
|
+
|
|
39
|
+
Judge rendered results against the brief. Inspect desktop and mobile, complete
|
|
40
|
+
the primary flow, use the keyboard, and test reduced motion and supported color
|
|
41
|
+
modes. Record specific defects with before/after artifacts. Source lint and
|
|
42
|
+
pattern similarity cannot establish whether hierarchy, timing, story, or polish
|
|
43
|
+
works. `sable grade --review` validates review evidence; it does not replace eyes.
|
|
44
|
+
|
|
45
|
+
`sable learn` retrieves curated precedents and project-owned lessons with explicit
|
|
46
|
+
applicability, avoidance, and provenance. Keep a new observation provisional until
|
|
47
|
+
it transfers to another context. Read `sable docs designing-a-surface`,
|
|
48
|
+
`reviewing-a-design`, and `learning-from-builds` for the complete workflow.
|
|
49
|
+
|
|
50
|
+
## The token layers
|
|
51
|
+
|
|
52
|
+
Three layers, outermost is what components use:
|
|
53
|
+
|
|
54
|
+
| Layer | Example | Who uses it |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| **Palette** (theme-internal) | `--purple-heart-700`, `--zinc-950` | Only the theme CSS. Never components. |
|
|
57
|
+
| **Semantic** (the contract) | `--primary`, `--card`, `--muted-foreground`, `--tone-edition`, `--ring` | **Components** (via `bg-*`/`text-*` utilities). |
|
|
58
|
+
| **Component** (local) | a cva variant string | Inside one component. |
|
|
59
|
+
|
|
60
|
+
`themes/_contract.ts` enumerates the semantic tokens every theme must define. The default (Base) palette is authored in **OKLCH** for perceptually-uniform ramps, locked hue, gamut-safe chroma, and dark-mode vibrancy.
|
|
61
|
+
|
|
62
|
+
### Semantic tokens (the contract)
|
|
63
|
+
|
|
64
|
+
Surfaces & text: `background`, `foreground`, `card`, `card-foreground`, `popover`, `popover-foreground`, `muted`, `muted-foreground`, `border`, `input`, `ring`.
|
|
65
|
+
Actions: `primary`, `primary-foreground`, `secondary`, `secondary-foreground`, `accent`, `accent-foreground`, `destructive`, `destructive-foreground`, `success`, `success-foreground`. (`success` is the positive-state counterpart to `destructive` — a **flat** semantic token backed by caribbean-green; it is deliberately separate from the `tone-standard` post-type, and there is no `tone-success`.)
|
|
66
|
+
Tones (semantic, carry meaning): `tone-standard`, `tone-collectible`, `tone-edition`, `tone-info`, `tone-warning`, `highlight`.
|
|
67
|
+
Tone ink (legible text/icon on a tone fill, theme-adaptive): `tone-standard-foreground`, `tone-collectible-foreground`, `tone-edition-foreground`, `tone-info-foreground`, `tone-warning-foreground`, `highlight-foreground`.
|
|
68
|
+
Overlay (fixed dark surface over content — scrims, media letterbox, pills on imagery): `overlay`, `overlay-foreground`.
|
|
69
|
+
Sidebar set: `sidebar`, `sidebar-foreground`, `sidebar-primary(-foreground)`, `sidebar-accent(-foreground)`, `sidebar-border`, `sidebar-ring`.
|
|
70
|
+
|
|
71
|
+
**Base inversion rule:** in dark mode `primary` is light (a near-white pill); in light mode it is near-black. Other themes may use a different action palette. Never call `primary` "black" or `primary-foreground` "white" — use the semantic pair, whose values belong to the theme.
|
|
72
|
+
|
|
73
|
+
### Tone system
|
|
74
|
+
|
|
75
|
+
Tones encode meaning, not decoration. Choose item categories for your product:
|
|
76
|
+
- `tone-standard` — the default item category.
|
|
77
|
+
- `tone-collectible` — a distinct item or collection category.
|
|
78
|
+
- `tone-edition` — a limited edition or premium category.
|
|
79
|
+
Plus `tone-info`, `tone-warning`, and `highlight` (selection). Dark mode uses dedicated, higher-chroma values so tones stay vivid on a near-black canvas. Text/icons ON a tone fill use the matching `tone-*-foreground` ink — a theme-adaptive intent token that flips lightness with the fill (light ink over the dark 600/700 fills in light mode; dark ink over the lighter dark-tuned fills in dark mode). A component never names a palette step for this; there is no longer a "media-overlay palette exception."
|
|
80
|
+
|
|
81
|
+
**Status-tone vocabulary.** Every status-signaling component (StatusDot, Badge with `dot`, Note, Banner, InlineStatus, EmptyState) takes one canonical severity set on its `tone` prop: **`neutral` · `info` · `success` · `warning` · `destructive`** — exported as `STATUS_TONES` / `StatusTone`, with one icon per tone in `STATUS_TONE_ICON`. There is no `error` alias anywhere: use `destructive`. A state label (live, failed, draft) is `<Badge appearance="plain" tone="…" dot>`; map domain states (`ready`, `building`, `failed`, …) to tones once in the app. Consumers can therefore feed one app-level tone straight into any of these components without a translation shim; new status-flavored components must accept the canonical five (`tone-vocabulary.test.ts` holds this).
|
|
82
|
+
|
|
83
|
+
## Registers
|
|
84
|
+
|
|
85
|
+
Sable has **two registers**. Almost everything above and below is shared — the token
|
|
86
|
+
layer, the semantic vocabulary, the accessibility floor, the motion policy — but a
|
|
87
|
+
handful of values and two outright rules differ, and picking the wrong register is the
|
|
88
|
+
single most common way a surface comes out looking wrong while passing every lint.
|
|
89
|
+
|
|
90
|
+
**Classify the surface before you build it.**
|
|
91
|
+
|
|
92
|
+
| | **Page register** (default) | **Workspace register** |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| The unit | a document | a fixed viewport |
|
|
95
|
+
| Viewport | the document scrolls | the shell owns `100dvh`; only regions scroll |
|
|
96
|
+
| Width | opt-in `Region` cap | edge-to-edge; panels are fixed-width chrome outside the grid |
|
|
97
|
+
| Controls | `md` = 40px | 24 / 28 / 32 / 36, default **32** |
|
|
98
|
+
| Type | 14–16px, weights 400/600 | **10–12px, weights 450–550** (`text-ui-*`) |
|
|
99
|
+
| Spacing | 4 base / 8 rhythm | 2 base, 6 is a real step |
|
|
100
|
+
| Grouping | `Card` | `PanelSection` + hairlines |
|
|
101
|
+
| Shell | `AppShell` | `Workspace` |
|
|
102
|
+
|
|
103
|
+
Reach for the workspace register when the primary interaction is *operating on something*
|
|
104
|
+
— an editor, a canvas app, a console. Reach for the page register when it's *reading and
|
|
105
|
+
navigating*. A dashboard that scrolls is a page with dense content, not a workspace.
|
|
106
|
+
|
|
107
|
+
### The mechanism: one density ladder
|
|
108
|
+
|
|
109
|
+
Nothing in a component hardcodes a height. Every sized control reads `--control-*`, and a
|
|
110
|
+
density scope redefines the ladder:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
comfortable compact
|
|
114
|
+
--control-xs 28px 24px ← icon buttons, numeric fields
|
|
115
|
+
--control-sm 32px 28px
|
|
116
|
+
--control-md 40px 32px ← the default control
|
|
117
|
+
--control-cta 44px 36px
|
|
118
|
+
--control-lg 48px 40px
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Type retunes with it — including in your own markup.** The ladder above only covers
|
|
122
|
+
heights, because `--control-*` is a custom property. Typography is a set of *paired
|
|
123
|
+
utilities*, so `text-body-md` and `text-ui-md` are different classes and nothing about a
|
|
124
|
+
density scope changed which one was rendered. That asymmetry is the single most common
|
|
125
|
+
way a workspace comes out looking wrong: the box shrinks to 32px and the text stays at
|
|
126
|
+
15px, and every form reads oversized. It was reported exactly that way from a consuming
|
|
127
|
+
app, after being fixed locally three times (menus 0.44.2, the conversation set 0.48.0)
|
|
128
|
+
without ever being generalised.
|
|
129
|
+
|
|
130
|
+
So inside `[data-density="compact"]` the page-register type utilities are **redefined**
|
|
131
|
+
onto the workspace scale — the same idea as the control ladder, applied to the half that
|
|
132
|
+
was never automatic. Two properties make it safe:
|
|
133
|
+
|
|
134
|
+
- **It only ever compresses.** Every value is `min(page, workspace)`. The page tiers are
|
|
135
|
+
`rem` and the workspace tiers are absolute px, so on a 14px-root app `text-label-md`
|
|
136
|
+
is 10.5px and would otherwise *grow* to 12px inside a tool panel — a denser register
|
|
137
|
+
rendering larger text than the page beside it.
|
|
138
|
+
- **Weight moves onto the workspace scale too**, not just size. Otherwise a compact panel
|
|
139
|
+
carries 600 labels from raw utilities beside 500 labels from components using
|
|
140
|
+
`useTypeTier()` — one nominal tier rendering two weights in one panel.
|
|
141
|
+
|
|
142
|
+
This is also the only half of the problem a component sweep can reach: `<p
|
|
143
|
+
class="text-body-md">` written in a consuming app is the same bug in code this package
|
|
144
|
+
does not own. Sable's own components still ask for a tier explicitly via `useTypeTier()`
|
|
145
|
+
where intent matters — a chat bubble is prose (400), an input is a control (500), and
|
|
146
|
+
both are `text-body-md` on a page. `lib/register-type.test.ts` fails the build if a new
|
|
147
|
+
paired tier ships without its compact counterpart.
|
|
148
|
+
|
|
149
|
+
`<Workspace>` sets `data-density="compact"` for its whole subtree; a `Dialog` opened from
|
|
150
|
+
inside it resets to comfortable, because a modal is a page-register surface even when it
|
|
151
|
+
was launched from a tool. This is why the register is a *scope* rather than a set of
|
|
152
|
+
variants — it composes, and it needs no selector overriding a height after the fact.
|
|
153
|
+
`design-lint`'s `raw-control-height` rule keeps it honest.
|
|
154
|
+
|
|
155
|
+
**Which portaled surfaces follow the register.** CSS cannot cross a portal, so every
|
|
156
|
+
popup opts in through React context (`useAnchoredDensity` / `useTypeTier`, see
|
|
157
|
+
`lib/density.tsx`). The split is by *what the surface is for*, not by how it is built:
|
|
158
|
+
|
|
159
|
+
| Follows the register | Always page register |
|
|
160
|
+
|---|---|
|
|
161
|
+
| Menu, ContextMenu, Popover, Tooltip, Select, Combobox, **CommandMenu** | Dialog (including fullscreen), Sheet |
|
|
162
|
+
|
|
163
|
+
Chrome is summoned *from* a surface, acts *on* it, and is read at a glance while the work
|
|
164
|
+
stays visible — so it belongs to the register that summoned it. A task surface takes the
|
|
165
|
+
screen for one job, usually holds prose, and gains nothing from density because nothing is
|
|
166
|
+
competing for the space.
|
|
167
|
+
|
|
168
|
+
**`CommandMenu` is the exception that proves the rule needed writing down.** It is *built
|
|
169
|
+
on* Dialog, so until 0.44.2 it inherited "modal ⇒ page register" by construction — and a
|
|
170
|
+
command palette is the opposite of a task surface: it is the fastest way to do anything in
|
|
171
|
+
the tool, and it was the one surface in a tool that did not look like the tool. Being built
|
|
172
|
+
on Dialog is an implementation detail; what a surface is *for* decides its register. A
|
|
173
|
+
consumer who wants a compact dialog can wrap its content in `<Density value="compact">`.
|
|
174
|
+
|
|
175
|
+
### Tree geometry, and the invariant
|
|
176
|
+
|
|
177
|
+
The ladder also carries the geometry of a hierarchy panel, because a layer list is the
|
|
178
|
+
other shape (with the property grid) that every tool has, and its **left edges** are the
|
|
179
|
+
part that most visibly goes wrong:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
comfortable compact
|
|
183
|
+
--tree-rail 12px 8px ← headers and depth-0 rows
|
|
184
|
+
--tree-column 20px 18px ← the disclosure and type columns
|
|
185
|
+
--tree-indent 20px 18px ← one level of nesting
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
A row is `[rail][disclosure][type][name ……][actions]`, and there is exactly one rule:
|
|
189
|
+
|
|
190
|
+
> **`--tree-indent` must equal `--tree-column`.**
|
|
191
|
+
|
|
192
|
+
When the step is *smaller* than the column, a child's content lands to the **left** of
|
|
193
|
+
where its parent's content starts, so the nesting reads **inverted** — the tree appears to
|
|
194
|
+
outdent as it descends. That shipped (a 14px step against a 16px column) and read as
|
|
195
|
+
"the effects are indented the other way", which is a precise description of the symptom
|
|
196
|
+
and gives no hint of the cause. `themes.test.ts` now asserts the equality in both
|
|
197
|
+
densities, and the `Tree` docs page ships a switch that reproduces the broken state,
|
|
198
|
+
because seeing it is more convincing than reading this paragraph.
|
|
199
|
+
|
|
200
|
+
Everything that needs a tree inset — rows, section headers, add affordances, empty states —
|
|
201
|
+
reads `treeIndent(depth)`, which returns a `calc()` over these tokens rather than a number.
|
|
202
|
+
Nothing computes pixels. The original defect was four different left edges in one panel
|
|
203
|
+
(rows at 4, headers at 8, a 14px step, affordances at 18) precisely because four different
|
|
204
|
+
places each picked their own.
|
|
205
|
+
|
|
206
|
+
**Tree owns its disclosure and add marks**, which is a narrow exception to keeping glyphs
|
|
207
|
+
out of components — see rule #4 in `CONTRIBUTING.md`. The reason is arithmetic, not taste:
|
|
208
|
+
general icon sets author on a 24-unit grid, and Reicon's documented 12/18/24/32 are *render
|
|
209
|
+
sizes*, not per-size geometry (`createIcon` hardcodes `viewBox="0 0 24 24"`; `size` only
|
|
210
|
+
sets width/height). At the ~18px a tree row wants, those strokes land off the device-pixel
|
|
211
|
+
grid and the mark goes soft. Because that is a property of the **size**, no icon set and no
|
|
212
|
+
choice of name can fix it — so the component carries geometry cut for the size it renders
|
|
213
|
+
at. Both marks are overridable per row. The *type* icon is still the consumer's, and the
|
|
214
|
+
same warning applies to it.
|
|
215
|
+
|
|
216
|
+
### Where the numbers came from
|
|
217
|
+
|
|
218
|
+
Measured in-page from the **Figma** and **Paper** editors (2026-07-26), then cross-checked
|
|
219
|
+
against Framer 3.0 — not estimated:
|
|
220
|
+
|
|
221
|
+
| | Figma | Paper |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| Panel width | 241 / 241 | 240 / 281 |
|
|
224
|
+
| Control heights | **32** (34×), 24 (18×) | **24** (30×), 36 (10×) |
|
|
225
|
+
| Radius | **5px** (42×) | **5px** (30×) |
|
|
226
|
+
| Chrome type | 11/400 lh16 · 11/550 · 9/450 | 12/500 lh16 · 12/400 · 12/465 |
|
|
227
|
+
| Gaps | 8, 4, **1px** (11×), 2 | 8, **6**, 12, 4, 2 |
|
|
228
|
+
| Surfaces | 255 → 245 | 249 → 242 → 238 |
|
|
229
|
+
| Borders | 1px, #E6E6E6 | 1px, #E2E2E2 |
|
|
230
|
+
|
|
231
|
+
### Two rules this register deliberately reverses
|
|
232
|
+
|
|
233
|
+
These are the interesting part, and the reason the register needs writing down rather than
|
|
234
|
+
being left to taste. Both are stated elsewhere in this document as rules; in the workspace
|
|
235
|
+
register they are inverted, on purpose:
|
|
236
|
+
|
|
237
|
+
1. **"Actions are always `full` (pill)."** Neither reference tool has a single pill in its
|
|
238
|
+
chrome — both use one ~5px radius on essentially everything. The "tight container /
|
|
239
|
+
round action" contrast that signals *this is a control* in the page register is carried
|
|
240
|
+
here by surface and hairline instead. A theme flattens buttons via `--button-radius`
|
|
241
|
+
without changing what `rounded-full` means for avatars, dots and badges.
|
|
242
|
+
2. **"Prefer a tonal step or space over a divider line."** Paper's entire panel ladder
|
|
243
|
+
spans 249 → 242 → 238 — three to seven RGB units — and delegates *all* structural
|
|
244
|
+
separation to a 1px low-contrast hairline. Tool chrome lives in a compressed tonal band
|
|
245
|
+
where hairlines carry structure. Reaching for a bigger tonal jump to separate two
|
|
246
|
+
regions is the wrong instinct here.
|
|
247
|
+
|
|
248
|
+
### Surfaces
|
|
249
|
+
|
|
250
|
+
`canvas` is the work surface — **not** `background`, because the thing being edited is not
|
|
251
|
+
the page behind the UI. `panel` / `panel-raised` are docked chrome in that compressed band;
|
|
252
|
+
`hairline` / `hairline-strong` are the structural edge. All of them are *derived* from each
|
|
253
|
+
theme's own semantic tokens (see `styles.css`), so a theme inherits a coherent workspace
|
|
254
|
+
band for free and only declares a `--theme-*` override where it wants to tune.
|
|
255
|
+
|
|
256
|
+
### Components
|
|
257
|
+
|
|
258
|
+
`Workspace` (+ `WorkspaceBar`, `WorkspaceCanvas`) · `PanelGroup` (+ `Panel`,
|
|
259
|
+
`PanelResizer`, `usePanelWidth`) · `PanelSection` (+ `PropertyRow`, `PropertyPair`) ·
|
|
260
|
+
`ScrubField` · `StatusBar`. `SplitPane` stays a page-register primitive: it switches
|
|
261
|
+
between side-by-side and stacked at a breakpoint and cannot be resized, which is a
|
|
262
|
+
different job from a docked panel.
|
|
263
|
+
|
|
264
|
+
### Anti-patterns specific to this register
|
|
265
|
+
|
|
266
|
+
- A `Card` inside a panel. In a 240px panel a card spends its border and inset on nothing
|
|
267
|
+
and pushes every row inward — the strongest single tell that a panel was built with
|
|
268
|
+
page-register instincts.
|
|
269
|
+
- A `Region` cap or page `py` rhythm inside a `Workspace`.
|
|
270
|
+
- A slider **and** a scrub glyph on the same property row: the slider already *is* the drag
|
|
271
|
+
affordance, so that's the same gesture offered twice.
|
|
272
|
+
- Missing `min-h-0` on a panel's ancestors. A grid/flex child refuses to shrink below its
|
|
273
|
+
content, so `overflow-y-auto` never activates and panels grow instead of scrolling —
|
|
274
|
+
the reason a tool shell ends up with no scroll containers at all.
|
|
275
|
+
- A custom `text-*` utility. Anything not registered in `lib/cn.ts`'s `ds-typography` group
|
|
276
|
+
lands in tailwind-merge's **colour** group and silently evicts the real colour class.
|
|
277
|
+
|
|
278
|
+
## Typography
|
|
279
|
+
|
|
280
|
+
Typography belongs to the whole surface. In addition to the display/heading
|
|
281
|
+
contract, optional `TYPE_REFINEMENT_TOKENS` let a theme tune body, label, caption,
|
|
282
|
+
mono, title sizes, and the largest display size while components keep using paired
|
|
283
|
+
utilities. Retrieve their names and intent with `sable token typographyOverrides`.
|
|
284
|
+
Review the loaded font, measure, and wrapping together with those values.
|
|
285
|
+
|
|
286
|
+
Six tiers; every utility bundles size + weight + line-height + tracking — **compose with color/margin only**, never decompose.
|
|
287
|
+
|
|
288
|
+
Pick a tier by **role + intent**, not by eyeballing a size. The six families and *when to reach for each* (this is the part agents get wrong — the semantic name isn't enough):
|
|
289
|
+
|
|
290
|
+
| Family | Role & intent | Reach for it | Avoid it for |
|
|
291
|
+
|---|---|---|---|
|
|
292
|
+
| **Display** `display-lg…4xl` | Type as **image** — oversized, very tight leading/tracking. A marketing voice, not a UI one. | Landing/hero headlines, splash screens, big campaign numbers. Weight 700–800. | Anything inside product UI or app chrome; more than one per view. |
|
|
293
|
+
| **Heading** `heading-1…4` | The **document outline** — semantic `h1`–`h4`. Hierarchy *and* accessibility ride here. | `heading-1` = the page title (**exactly one per route**); `-2` major section; `-3` subsection; `-4` card-group / sub-subsection. Weight 600. | Repeating component titles (use **Title**); jumping a level to get a size — keep the order, restyle with a tier. |
|
|
294
|
+
| **Title** `title-lg` · `title-sm` | Names a **UI primitive** without claiming a document heading. Weight 600. | Card / dialog / sheet titles, popover headers, list-row titles (`-lg`); dense rows, compact cards, table-section titles (`-sm`). | Page/section structure (use **Heading**); reading copy. |
|
|
295
|
+
| **Body** `body-lg/md/sm/xs` | **Reading** text — weight 400, open leading, near-zero tracking. | Lede / intro paragraph (`-lg`); the default paragraph + descriptions + help text (`-md`); secondary copy, dense table cells (`-sm`); the dense floor — operator consoles, dense data cells, wrapping metadata (`-xs`, same size as `caption` but with body leading for multi-line text). | Buttons / labels / chips (use **Label**); long lines past the `--measure-prose` cap; long-form reading at `-xs`. |
|
|
296
|
+
| **Label** `label-lg/md/sm` · `caption` | **Functional, scannable** UI text — weight 600 (caption 400), tracked for short strings. | Button text, field labels, tabs (`-lg`); badges, chips, table headers, compact buttons (`-md`); fine meta (`-sm`); timestamps / footnotes / image captions (`caption`). | Sentences and paragraphs — it's tuned for 1–3 words, not prose. |
|
|
297
|
+
| **Eyebrow** `label-xs` | An **auto-uppercase, letter-spaced kicker** (a Label variant). Editorial only. | A marketing section kicker, at most one per view. | Anything in product chrome: nav and menu group labels, table headers, page headers, form sections — those are sentence-case `caption` or `label-md`. Tracked capitals spread across a UI read as shouting. |
|
|
298
|
+
| **Mono** `mono-xl` · `mono-lg` · `mono-md` · `mono-caption` · `mono-sm` | **Technical / tabular** — the theme mono family, always tabular figures. | KPI/metric values where the number IS the content — stat cards, prominent counts (`-xl`); secondary metrics (`-lg`); code, IDs, hashes, wallet addresses, on-chain data, and numbers that must align in tables/ledgers (`-md`); the same at caption size for dense data surfaces (`mono-caption` — never recompose `font-mono text-caption`); tiny tracked mono chrome like kbd hints (`-sm`). | Prose or labels. Never a heading tier for a big number — metrics are mono, headings are semantic. |
|
|
299
|
+
|
|
300
|
+
Every utility bundles **size + weight + line-height + tracking** — compose with color/margin only, **never decompose** into `text-* + font-* + tracking-*`.
|
|
301
|
+
|
|
302
|
+
Default theme families: **Figtree** (sans) + **DM Mono** (mono). Two weights per screen max (400 body / 600 heading; mono 400 the rare third). Sizes are rem against the browser's 16px root — the package never sets `html { font-size }`, so a user's browser text-size preference scales the whole ramp. The ramp is roughly a 1.2 minor third.
|
|
303
|
+
|
|
304
|
+
**The rules as data:** `src/foundations/typography.ts` → `sable docs typography`. It holds the everyday set of eight tiers, the rules, and the no-gos, and the docs page renders the same object.
|
|
305
|
+
|
|
306
|
+
**Scale & intent.** The ramp is a ~1.2 (minor-third) ratio, tuned tight: as size grows, line-height *shrinks* and negative tracking *grows* (display sits at 0.9–1.05 leading with −0.05…−0.03em tracking; body opens to 1.5–1.6 with near-zero tracking) — big type reads as **shape**, body as **texture**. Pick a tier by **role, not pixel size**: a card title is `title-*` even when it renders at the same size as a `body-lg`, because role fixes weight (600 vs 400) and rhythm. Hierarchy is carried by **tier + weight + space**, not size alone — two adjacent sizes a step apart read as one level, so separate levels by a clear jump (heading → title → body), and lean on weight and spacing before reaching for a bigger size.
|
|
307
|
+
|
|
308
|
+
**Usage.** One `heading-1` per route (the page h1); never skip heading *levels* to get a size — keep the semantic order and restyle with a tier. Cap reading text at `--measure-prose` (≈65ch); past ~75ch the eye loses the line return. Set type color with a semantic token (`text-foreground` / `text-muted-foreground`) — `muted-foreground` for secondary/supporting copy, full `foreground` for primary. Numerals that must align (tables, data, money) use the mono tiers (tabular figures). **Don't** decompose a tier into `text-* + font-* + tracking-*`, stack two display sizes, set reading paragraphs below `body-sm` (12px `body-xs` is a dense-UI floor, not a prose size), justify text, or use a heading tier for non-heading emphasis (use weight/color).
|
|
309
|
+
|
|
310
|
+
## Spacing
|
|
311
|
+
|
|
312
|
+
One scale, built on a **4px base** with an **8px rhythm**. Every margin, padding, and gap is a step on it — never a hand-picked px value — so vertical and horizontal rhythm stay locked together system-wide. Spacing, not borders, is the primary tool for grouping: **related things sit closer than unrelated things** (proximity does the work a divider line otherwise would).
|
|
313
|
+
|
|
314
|
+
**The scale** (Tailwind's spacing utilities; the steps Sable actually leans on):
|
|
315
|
+
`0` · `0.5` 2 (hairline / optical nudge) · `1` 4 · `1.5` 6 · `2` 8 (**the base rhythm unit**) · `3` 12 · `4` 16 (default control padding / gap) · `5` 20 · `6` 24 (group separation) · `8` 32 · `10` 40 · `12` 48 (sub-sections) · `16` 64 · `24` 96 (major sections). Steps below `2` are for optical correction only.
|
|
316
|
+
|
|
317
|
+
**Space by role, not by eye:**
|
|
318
|
+
- **Inset** — padding *inside* a surface; `p-4` (16) is the default card inset, `px-3 py-2`–`py-2.5` a dense row.
|
|
319
|
+
- **Stack** — vertical gap *between* blocks, the dominant rhythm: tight within a group (`gap-1.5`–`gap-2`), looser between groups (`gap-4`–`gap-6`), generous between sections (`gap-12`+).
|
|
320
|
+
- **Inline** — horizontal gap between siblings (icon↔label, label↔control): `gap-2`–`gap-3`.
|
|
321
|
+
- **Density** — a compact surface drops one step (inset `p-3`, stack `gap-1.5`); a spacious one adds one. Move in whole steps, never a custom px.
|
|
322
|
+
- **On the scale, so density follows.** The docs' density ("Character") control and a consumer's override retune `--spacing`; every step and every `--spacing(n)` moves with it. A component's padding, gap or inset written as a literal (`p-[1.5rem]`, `padding: "12px"`) is the one measure that stays put. Even derived geometry sits on the scale: `popup-inset` floors at `--spacing(1)`, `sable-frame` pads `--spacing(4)`, Card's `--card-p` is `--spacing(6)` / `--spacing(4)`. Fixed units are for what must not scale (a 1px hairline, a ring width). *(CI-enforced in the package: `fixed-spacing`.)* Layout primitives follow too: `--rhythm-unit` is two spacing steps, so a `Stack`/`Row`/`Grid`/`Page` gap number moves with density like everything else.
|
|
323
|
+
|
|
324
|
+
**Don't** invent off-scale values (`p-[15px]`), simulate a `gap` with per-child margins, or hang layout spacing on a text node (put it on the container).
|
|
325
|
+
|
|
326
|
+
## Dimensions (sizing)
|
|
327
|
+
|
|
328
|
+
The same 4px scale governs **sizes**, so controls, icons, and avatars all land on one grid. Sizes are named by **role**; the canonical set:
|
|
329
|
+
|
|
330
|
+
- **Controls** — `sm` h-8 (32, compact / chips) · **`md` h-10 (40) — the default** (Input, Button, Select, the field row) · `lg` h-12 (48, prominent / touch-first) · `xl` h-14 (56, hero / mobile CTA).
|
|
331
|
+
- **Icons** — `xs` 14 · `sm` 16 · **`md` 20 — default UI glyph** · `lg` 24 · `xl` 32. Icons are em-sized with `text-{size}` (never type utilities) and inherit text color.
|
|
332
|
+
- **Avatars / media** — `sm` 24 · `md` 32 · `lg` 40 · `xl` 48, then up the scale (`size-16`, `size-24`).
|
|
333
|
+
- **Touch targets** — interactive elements clear **44×44px** (`size-11`, WCAG 2.5.5). A visually smaller control (a 32px icon button) keeps the 44px *hit* area via padding or hit-slop, not a bigger glyph. The `hit-target` utility is the sanctioned implementation: a centered 44px `::after`, so the hit area grows without the layout moving. Never on an `<input>` — replaced elements don't render pseudo-elements reliably; wrap it in a label instead.
|
|
334
|
+
|
|
335
|
+
**Borderless controls need roughly twice the clearance of bordered ones.** A bordered control declares where it ends; a borderless one has nothing but the gap to say so, and two ghost buttons 12px apart read as one wide target. Budget around 24px between adjacent borderless controls where 12px would be right for bordered ones.
|
|
336
|
+
|
|
337
|
+
**Align optically when geometric centering looks wrong.** A trailing icon makes symmetric padding look lopsided — take about 2px off the icon side (`padding-inline-end: 14px` against a `16px` start). A play triangle needs roughly `translateX(2px)` to sit centered in a circle, because its visual mass is left of its bounding box. Where the glyph itself is the problem, fix it in the SVG rather than papering over it with wrapper margin, or every consumer inherits the correction.
|
|
338
|
+
|
|
339
|
+
**Widths** are fluid (see Grid & layout); the only fixed caps are opt-in — `--measure-prose` (65ch reading column) and the named region caps. Control/field max-widths are set per context, never page-global.
|
|
340
|
+
|
|
341
|
+
**Don't** hardcode an off-scale height (`h-[37px]`), size icons with type utilities, or ship a sub-44px touch target without a padded hit area.
|
|
342
|
+
|
|
343
|
+
## Grid & layout (fluid-first, no global max-width)
|
|
344
|
+
|
|
345
|
+
Sable's layout foundation is deliberately fluid: **edge-to-edge, with a fluid page inset** rather than a centered fixed-width column. It keeps the surviving grid discipline — register every element to a column edge and an 8px (two spacing steps) vertical rhythm, cap the reading measure — while letting the canvas breathe across viewports and native shells.
|
|
346
|
+
|
|
347
|
+
**The doctrine — two roles, never conflated:**
|
|
348
|
+
1. **A page's width is a `Region`** — a centered, opt-in, purpose-named cap. Pick by ROLE, not size.
|
|
349
|
+
2. **The 12-column grid is used *within* a region** — for genuinely multi-column content (card grids, paired form fields, list-detail). Never use a column *span* as the page's reading width; that leaves the page uncapped and stretching on wide displays.
|
|
350
|
+
3. **Chrome is not on the grid.** The app `Sidebar` and any secondary rail are fixed-width chrome *outside* the content grid; the grid covers content, not rails. `GridOverlay` covers the content area, not the rails.
|
|
351
|
+
4. **Measure ≠ width.** Cap text line-length (`prose`, in `ch`) independently of a region's layout width — a 1280px dashboard can still hold a 65ch reading column.
|
|
352
|
+
|
|
353
|
+
**Layout tokens:**
|
|
354
|
+
- `--rhythm-unit: 8px` — the relaxed baseline; every vertical measure is a multiple.
|
|
355
|
+
- `--grid-columns: 12` — the proportioning resource; folds 12 → 6 → 4 → 2 → 1.
|
|
356
|
+
- `--grid-gutter: clamp(1rem, …, 1.5rem)` — column gap, fluid 16 → 24px.
|
|
357
|
+
- `--page-inset: clamp(16px, 4vw, 32px)` — fluid page margin; honors `env(safe-area-inset-*)`.
|
|
358
|
+
|
|
359
|
+
**Content-width scale** — opt-in per `Region` (`max="…"`), named by purpose (**not** page-global):
|
|
360
|
+
|
|
361
|
+
| `max` | Token | Width | Use |
|
|
362
|
+
|---|---|---|---|
|
|
363
|
+
| `prose` | `--measure-prose` | 65ch | long-form reading text (post body, descriptions, articles) |
|
|
364
|
+
| `form` | `--region-form` | 480px | narrow single-column forms, auth, dialogs |
|
|
365
|
+
| `feed` | `--region-feed` | 640px | single-column timeline / reading feed |
|
|
366
|
+
| `content` | `--region-content` | 896px | general content + settings/admin panes (some 2-up fields) |
|
|
367
|
+
| `wide` | `--region-wide` | 1280px | dashboards, tables, multi-column grids |
|
|
368
|
+
| `full` | `--region-full` | 1536px | full-bleed safety ceiling |
|
|
369
|
+
|
|
370
|
+
(`--region-ultrawide` 1800 is legacy; prefer `full`.)
|
|
371
|
+
|
|
372
|
+
**Primitives** (in `src/layout/`): `Region` (fluid page region: `inset`, opt-in `max` as a named region *or any CSS length*, per-edge `safeArea`) · `Page` (route shell — `Region` + a vertically-rhythmic `Stack`) · `Grid` (CSS Grid; explicit `cols` or `auto-fit minmax` so cells breathe) · `Columns`/`Col` (track + spans) · `Bleed` (escapes a capped/inset ancestor to the **content-area** edge, via container-query units, so it stops at a fixed sidebar) · `SplitPane` (responsive two-pane, side-by-side above a breakpoint, stacked below) · `Stack` (vertical rhythm) · `Row` (horizontal flex) · `Spacer` · `Center` (optional prose cap) · `AspectBox` (reserves space, no layout shift) · `SafeArea` · `GridOverlay` (structure visualizer for docs/dev).
|
|
373
|
+
|
|
374
|
+
`Columns count`, `Col span`/`start`, and `Grid cols` accept either a number **or a responsive breakpoint map** (`{ base, sm, md, lg, xl, "2xl" }`) — resolved mobile-first by the stylesheet, so a grid can thin to fewer columns on small screens without the consumer's Tailwind scanning a class.
|
|
375
|
+
|
|
376
|
+
Breakpoints are content-driven: sm 640 / md 768 / lg 1024 / xl 1280 / 2xl 1536. Keep reading + focus order logical; survive 400% zoom.
|
|
377
|
+
|
|
378
|
+
## Shape & borders
|
|
379
|
+
|
|
380
|
+
**Radii.** Tight radii, pill actions, soft-square containers. `none` (media) · `xs` 4 · `sm` 8 (inputs) · `md` 12 (popovers/toasts) · `lg` 16 (cards/dialogs/sheets) · `xl` 20 (hero) · `full` (buttons, avatars, pills). Never mix radii within one component; actions are always `full` by default. Radius reads as **tone**: tighter corners feel precise/technical, fuller corners feel soft/friendly — Sable stays tight on containers and full on actions, so the contrast itself signals "this is a control." `Button`'s corner radius is theme-driven through a dedicated `--button-radius` var (falls back to full pill; see `button.variants.ts`) — a reference with a rectilinear shape language can flatten buttons without touching every other `rounded-full` use (avatars, pills, badges, dots) that should stay round.
|
|
381
|
+
|
|
382
|
+
**Concentric nesting.** When a rounded element sits inside another, keep the corners concentric: `inner = outer − inset` (the padding/gap between them), snapped to the ladder. A `md` (12) popup with a 4px inset → `sm` (8) items; Sable writes that pair as `popup-inset` + `rounded-popup-item` (and `popup-bleed` for a separator), so it stays concentric when the radius ladder or the density changes. The outer radius is always **≥** the inner. If the inset is ≥ the outer radius, the inner reads better squared, or step the container up a notch. The 4px ladder is built for this — every step differs by 4px, so common insets land on a real token. Prefer flattening over nesting (see "No nested cards"); a thin-bordered frame around a card with the *same* radius is the tell that the corners aren't concentric.
|
|
383
|
+
|
|
384
|
+
**Relative CSS primitives.** Sable's own `styles.css` defines `sable-*` custom variants for stable component states (`sable-highlighted`, `sable-selected`, `sable-open`, `sable-disabled`, `sable-invalid`), semantic surface helpers (`sable-surface`, `sable-surface-muted`, `sable-surface-popover`, `sable-surface-interactive`), and frame/radius helpers (`sable-frame`, `sable-frame-inner`, `rounded-concentric-*`). Use these when a component would otherwise repeat low-level data selectors or hand-calculate `outer radius - inset`. These recipes stay inside Sable's semantic token contract and do not introduce an external styling vocabulary. What each state variant matches: `sable-highlighted` = `[data-highlighted]`, `[data-active]`, `[data-active-item]`; `sable-selected` = `[data-selected]`, `[data-checked]`, `[aria-selected=true]`, `[aria-checked=true]`; `sable-open` = `[data-open]`, `[data-popup-open]`, `[data-panel-open]`, `[aria-expanded=true]`; `sable-disabled` = `:disabled`, `[disabled]`, `[data-disabled]`, `[aria-disabled=true]`; `sable-invalid` = `[aria-invalid=true]`, `[data-invalid]`. `sable-surface` sets background, text, edge and the derived hover/active colors from one semantic role, so a component picks the role and the nearby states are computed. Size a frame with `sable-frame-r-<radius>` and `sable-frame-p-<step>` (or `sable-frame-popover` for the popup recipe); `sable-frame-inner` then takes outer radius minus inset, clamped at zero. One-off nesting uses `rounded-concentric-md/1` = radius-md minus spacing-1.
|
|
385
|
+
|
|
386
|
+
**Borders.** The default edge is a hairline `border` (1px) in `--border`; reserve `border-2` for genuine emphasis (focus is a `ring-2`, not a thick border). A border marks an edge where a tonal step isn't enough — but prefer a tonal surface step or space over a divider line where you can; the flattest thing that reads is best. Floating surfaces (popover, menu, dropdown) use `--border-floating`, a translucent, slightly-brighter edge that holds against any background. One edge treatment per element — don't stack a border, a ring, and a shadow on the same boundary.
|
|
387
|
+
|
|
388
|
+
## Elevation
|
|
389
|
+
|
|
390
|
+
Sable is **flat by default** — depth is a step up the neutral surface ladder (`background` → `card` / `muted` → `popover`) plus a hairline border, *not* a drop shadow. Shadows are a **light-mode reinforcement** and a **transient-surface** cue, never decoration.
|
|
391
|
+
|
|
392
|
+
**Two mechanisms, split by mode:**
|
|
393
|
+
- **Dark mode** (the default) carries elevation with **tonal contrast + translucent borders** — a raised surface is a lighter zinc step with a faintly brighter edge. Shadows do little on a near-black canvas, so they barely register here.
|
|
394
|
+
- **Light mode** adds a **whisper-soft shadow** (3–5% opacity, `--shadow-sm`…`--shadow-xl`) to lift transient surfaces off the page where a tonal step alone is too quiet.
|
|
395
|
+
|
|
396
|
+
**The ladder maps to elevation roles, by *layer and transience* — not importance:**
|
|
397
|
+
- **0 — Resting:** page content, flat cards. No shadow; a hairline border if it needs an edge.
|
|
398
|
+
- **`shadow-sm` / `shadow`:** subtly raised — a hoverable/draggable card, a sticky header over scrolled content.
|
|
399
|
+
- **`shadow-md`:** surfaces that open *over* content — dropdowns, popovers, menus, selects.
|
|
400
|
+
- **`shadow-lg`:** focus-stealing surfaces with a scrim — dialogs, sheets, command menu.
|
|
401
|
+
- **`shadow-xl`:** the rare top-most floater — a toast stack, a hero card.
|
|
402
|
+
|
|
403
|
+
**Guidance.** Raise on **transience**: the more an element floats over content, the higher it sits. Don't elevate a *static* section to make it "stand out" — use a tonal surface step or space instead. **One shadow per surface** — never stack shadows or pair a heavy shadow with a heavy border. For modals the **scrim** (`overlay`) is the primary depth cue; the shadow is secondary. Elevation is a system of ~five steps, not a free dimension — pick the role, don't dial in a custom `box-shadow`.
|
|
404
|
+
|
|
405
|
+
## Layering (z-index)
|
|
406
|
+
|
|
407
|
+
A single ordered z-index scale, named so stacking is intentional rather than a bidding war of `z-[9999]`:
|
|
408
|
+
- `--z-sticky` (30) — sticky in-content chrome: table headers, sticky banners.
|
|
409
|
+
- `--z-nav` (40) — app chrome: `AppHeader`, `Sidebar`, `BottomNav`, status banners.
|
|
410
|
+
- `--z-overlay` (50) — portaled transient surfaces: dialog, sheet, popover, menu, select, tooltip.
|
|
411
|
+
- `--z-toast` (60) — toasts, above modals so a confirmation is never hidden.
|
|
412
|
+
- `--z-skip-link` (100) — the skip-to-content link, above everything.
|
|
413
|
+
|
|
414
|
+
Reference via `z-(--z-overlay)`, never a raw `z-[9999]`. Base UI overlays portal to the end of `<body>`, so **among themselves** they stack by DOM/open order — but they still carry `--z-overlay` so the whole layer clears the in-flow chrome (a popover must cover the sticky header). Don't give each overlay a bespoke z-index; the *layer* is what's ordered, not the individual surface.
|
|
415
|
+
|
|
416
|
+
**The scale is declared in `src/styles.css`, not in a theme.** Every preset inherits it. A theme may override an individual step; it may not be the only place a step exists. Portaled surfaces use `z-(--z-overlay)` to stay above page content.
|
|
417
|
+
|
|
418
|
+
## Scrolling
|
|
419
|
+
|
|
420
|
+
**A scrollbar is the affordance of last resort.** It is platform chrome that no theme controls, it costs a permanent gutter, and its default rendering is worse than anything else on the surface — on Windows it draws stepper arrows hard against the padding edge, which inside a rounded container poke out of the corner. Sable's position: prefer an affordance that carries information, and where a bar is genuinely the best answer, keep it quiet.
|
|
421
|
+
|
|
422
|
+
**Every scroll container declares a policy.** There is no third option, and CI enforces it in both places it can leak:
|
|
423
|
+
|
|
424
|
+
| | |
|
|
425
|
+
|---|---|
|
|
426
|
+
| `scrollbar-quiet` | A 4px thumb floating 2px off the edge in a 10px gutter, **invisible at rest**. Appears at 12% while the region is scrolling, hovered, or holds focus; 16% and 6px wide under the pointer; 20% while dragging. Fades out a beat after. The default for vertical scrolling. |
|
|
427
|
+
| `scrollbar-hidden` | No bar at all. Only when something else carries the affordance — and you should be able to name it. |
|
|
428
|
+
|
|
429
|
+
`design-lint`'s `unmanaged-overflow` rule fails the build on an `overflow-*-auto|scroll` utility with neither, and `build-registry` runs the same check over every example in `registry.json`. That second gate is the one that matters: the catalog is what agents copy, and it shipped `<Tree className="min-h-0 flex-1 overflow-y-auto">` for several releases — a contract teaching the exact thing the linter forbade in our own source.
|
|
430
|
+
|
|
431
|
+
**Vertical gets a bar; horizontal gets a carousel.** A vertical list needs position feedback — how far through a long transcript am I — and a thin bar is the only thing that gives it. A horizontal strip does not: what a reader needs there is "there is more this way", which an **edge fade says better than a 4px rail**, and which the rail obscures by adding chrome under the content. So `Scroller` draws a bar on its vertical axis and none on its horizontal one, where its alpha-masked edges already do the work. Reach for the same split in anything strip-shaped: media rows, chip rows, tab overflow.
|
|
432
|
+
|
|
433
|
+
**A horizontal region must be operable without a horizontal input.** Hiding the bar removed the last non-drag affordance on that axis, and most mice have no horizontal wheel — so `Scroller` maps a vertical wheel to sideways movement, and is focusable with arrow / Home / End / PageUp / PageDown handling. Both are on by default; the wheel mapping is `wheelScroll`, the name is `label`. The four things that make a naive wheel mapping feel broken, all handled in `useWheelToHorizontal`: it **releases at both ends** so it never traps a reader scrolling the page, it **ignores trackpad `deltaX` gestures** (`|deltaX| > |deltaY|` means the browser is already doing it, and handling `deltaY` too double-scrolls a diagonal swipe), it **ignores `shift+wheel`** (already native), and it **normalises `deltaMode`** (mice report *lines*, so the raw value moves ~3px per notch). Keyboard focus is declared rather than inherited: Chrome 128+ and Firefox make scroll regions focusable on their own, Safari does not.
|
|
434
|
+
|
|
435
|
+
**Reveal is driven by scrolling, not by hover.** Hover is a decent proxy and a bad one for the cases that matter: a touch drag hovers nothing, and neither does a programmatic scroll — a chat auto-following a streamed reply, a jump-to-message, a restored read position — which are exactly the moments a reader needs to see where they now are. `useScrollActivity` sets `data-scrolling` on the element for ~600ms after each scroll event and `scrollbar-quiet` keys off it. Hover and `:focus-within` remain in the CSS as the no-JS baseline, so the bare utility still behaves for a consumer who never reaches for the hook. The fade honours `--motion-scale`, so reduced motion collapses it to an instant swap.
|
|
436
|
+
|
|
437
|
+
**Do not hide a bar to tidy something up.** `scrollbar-hidden` on a vertical region with no fade and no paging control leaves a reader with no sense of position and nothing to discover. If you cannot name the affordance replacing it, you want `scrollbar-quiet`.
|
|
438
|
+
|
|
439
|
+
## Motion
|
|
440
|
+
|
|
441
|
+
Motion is a first-class token layer, not an afterthought. **Subtle by default, with room for tasteful delight** — quick, deliberate, skippable, and never gaudy. The feel is restrained but alive, built compositor-first (opacity + transform only).
|
|
442
|
+
|
|
443
|
+
**Three parts:**
|
|
444
|
+
|
|
445
|
+
1. **Tokens** (`themes/base/base.css` → Motion tokens; contract in `themes/_contract.ts` → `MOTION_TOKENS`).
|
|
446
|
+
- **Durations:** `instant` 75 · `fast` 150 · `base` 200 · `slow` 300 · `slower` 500, plus semantic `enter`/`exit`. Every duration is multiplied by `--motion-scale`.
|
|
447
|
+
- **Easings:** `standard` (≈ease-in-out, default) · `decelerate` (≈ease-out, entrances) · `emphasized` (large surfaces) · `linear` (indeterminate loaders) · **`spring`** (gentle ~+4% single overshoot — the default for delightful pops) · **`bounce`** (~+10%, opt-in playful accents). Springs apply on **enter only**; exits stay overshoot-free.
|
|
448
|
+
- Consume easings via the `ease-*` utilities; durations via `duration-(--motion-duration-*)` (so the kill-switch always applies).
|
|
449
|
+
|
|
450
|
+
2. **Recipes** (`styles.css`) — named, reusable behaviours a component opts into with one class instead of a bespoke transition string:
|
|
451
|
+
- `motion-interactive` (hover/focus colour + transform transitions) · `motion-press` (tactile press settle) · `motion-fade` (scrims) · `motion-pop` (anchored/centred surfaces — gentle spring on open) · `motion-slide-{up,down,left,right}` (directional fade-slide) · `motion-disclosure` (height) · `motion-draw` (SVG line-art draw-on — a `pathLength="1"` path inside a `<Reveal>` draws in when scrolled into view; stagger siblings via `--motion-draw-delay`) · loaders `motion-spin`/`motion-pulse`/`motion-shimmer`/`motion-dot-bounce`. Enter/exit recipes drive off Base UI's `data-[starting-style]`/`data-[ending-style]`.
|
|
452
|
+
|
|
453
|
+
3. **Policy** — `prefers-reduced-motion` and `<MotionProvider>` collapse `--motion-scale` to 0 in one place: transitions resolve instantly, loaders hold their first frame. Motion is fully **toggleable on/off** in-app via `useMotion()`.
|
|
454
|
+
|
|
455
|
+
Richer physics (interruptible velocity-springs, FLIP layout, drag/gesture) is intentionally **out of core** — it's the future optional `@sableui/motion` adapter (peer-dep `motion`), reached for only when CSS genuinely can't express a surface.
|
|
456
|
+
|
|
457
|
+
## Icons
|
|
458
|
+
|
|
459
|
+
Pluggable adapter (see `CONTRIBUTING.md` → Icon contract). Components reference **semantic names**; the app injects a concrete set via `<IconProvider>`. Icons inherit text color (except post-type action buttons where tone is the point) and are sized with `text-{size}` scales, never type utilities.
|
|
460
|
+
|
|
461
|
+
## Components
|
|
462
|
+
|
|
463
|
+
Behavior comes from Base UI (`@base-ui/react`), wrapped inside each component's own folder in `components/`, which is the themed, public API. The catalog is about 100 components (`sable manifest` has the live count): the core set (Button, Badge, Card, Input, Select, Checkbox, Switch, Tabs, Tooltip, Popover, Menu, Dialog, Sheet, Table, Skeleton, Avatar, Toast, …), an **app-shell / navigation** family (`AppShell`, `Sidebar` — collapsible icon-rail via `collapsed` + `icons` + `useSidebar()` — `SideNav`, `BottomNav`, `AppHeader`, `PageHeader`), and a **form-and-data** tier (`Form` + `Field` for auto-wired, validation-library-agnostic forms; `DataTable` for sortable/selectable tables over `Table`; `NumberField`).
|
|
464
|
+
|
|
465
|
+
Each component's full prop/variant/token/a11y/example contract is in its `*.meta.ts` — the **source of truth**. `registry.json`'s `components[]` is **generated** from the metas by `scripts/build-registry.mjs` (carrying the full contract, not one-line summaries); `pnpm build` regenerates it and CI's `registry:check` fails on drift. Edit the meta, not the registry.
|
|
466
|
+
|
|
467
|
+
## Enforcement (docs are a suggestion; CI is a contract)
|
|
468
|
+
|
|
469
|
+
The rules above are not honor-system. **`design-lint`** (`scripts/design-lint.mjs`, run by `pnpm design-lint` and in CI) is a zero-dependency lexical linter over the package's own `src/` (minus `themes/`, where literal OKLCH lives, and `*.meta.ts`) that **fails the build** on the drift the rules forbid:
|
|
470
|
+
|
|
471
|
+
| Rule id | Catches |
|
|
472
|
+
|---|---|
|
|
473
|
+
| `inline-color` | inline `oklch()` / hex literals |
|
|
474
|
+
| `palette-ref` | raw palette-step refs (`--zinc-950`, `text-(--purple-heart-700)`) — **zero sanctioned exceptions** since the tone-foreground + overlay tokens landed |
|
|
475
|
+
| `raw-black-white` | `bg-black` / `text-white` etc. (use `overlay` / `overlay-foreground` or a semantic token) |
|
|
476
|
+
| `transition-colors` | bare `transition-colors` (use the `motion-interactive` recipe) |
|
|
477
|
+
| `transition-all` | `transition-all` / `transition: all` — it animates every property, including ones you never meant to move, and blocks the browser from optimizing. Name the properties, or use the recipe |
|
|
478
|
+
| `physical-direction` | physical direction utilities (`ml-*`, `pr-*`, `text-left`, `left-*`) where a logical property belongs, so RTL mirrors for free. **A ratchet**, not a gate: the grandfathered occurrences (seeded at 58, falling) are recorded per-file in `scripts/lint-baseline.json` and only an *increase* fails. Exempts genuinely physical geometry — safe-area/notch edges, transform-centering idioms, and `data-[side=…]` utilities the positioner already resolved. Package-scoped until the baseline reaches zero, so consumers never meet it |
|
|
479
|
+
| `builtin-animate` | Tailwind `animate-{spin,pulse,bounce,ping}` (bypass `--motion-scale`) |
|
|
480
|
+
| `raw-duration` | raw `duration-150` (use `duration-(--motion-duration-*)`) |
|
|
481
|
+
| `raw-control-height` | a control-ladder value hardcoded as a height (`h-[40px]`) instead of read from the ladder (`h-(--control-md)`) — deliberately narrow, so only the ladder's own values fire |
|
|
482
|
+
| `decomposed-type` | a bare `text-{size}` + `font-{weight}` (use a paired type utility) |
|
|
483
|
+
| `raw-z-index` | `z-50` / `z-[9999]` — name a step on the layering scale instead. Ignores `z-0`…`z-20`, which is local stacking inside one component's box; the scale starts at 30 so the two never overlap |
|
|
484
|
+
| `unmanaged-overflow` | an `overflow-*-auto\|scroll` utility with neither `scrollbar-quiet` nor `scrollbar-hidden` — a scroll container with no policy gets the platform's, arrows and all. Checked against the comment-stripped line, so doc comments discussing overflow don't fire. `build-registry` runs the same check over registry examples, which is where it actually leaked |
|
|
485
|
+
| `unknown-token` / `unknown-motion` | typo'd tone/overlay/motion tokens — validated against `themes/_contract.ts` (Tailwind silently no-ops a typo) |
|
|
486
|
+
| `self-import` | importing the package's own `@sableui/react` barrel from inside `src/` — deep-import the relative module instead |
|
|
487
|
+
| `icon-contract` | a hard-coded `<Icon name>` not declared in `registry.json → iconContract.names` (keeps the consumer-buildable icon set complete) |
|
|
488
|
+
| `version-sync` | `SABLE_UI_VERSION` (`src/index.ts`) **or** `registry.json`'s version ≠ `package.json` |
|
|
489
|
+
| `unknown-meta-token` | a `*.meta.ts` `tokensUsed` entry that isn't a real semantic token (component meta can't advertise a token that doesn't exist) |
|
|
490
|
+
| `component-restyle` | a look-changing class (`border*`, `bg-*`, `rounded*`, `ring-*`, `shadow-*`) on a `Button`, `Pill` or `Badge` — use a variant (Button `floating` / `on-color`, Pill `appearance`, Badge `appearance`/`tone`) or add one. Layout classes stay allowed |
|
|
491
|
+
| `destructive-item-ink` | `text-destructive` (or another tone ink) in a `MenuItem` / `ContextMenuItem` `className` — use `variant="destructive"`, which keeps the ink while highlighted |
|
|
492
|
+
| `table-edge-padding` | `pl-*` / `pr-*` / `ps-*` / `pe-*` on a `TableHead` / `TableCell` — use `<CardContent flush>`, which sets `--table-edge-px` to the card's padding |
|
|
493
|
+
| `popup-separator-bleed` | a fixed `-mx-*` on a separator (a `*Separator` part, or a `h-px` rule) — use `popup-bleed`, which bleeds by exactly `popup-inset` |
|
|
494
|
+
| `fixed-spacing` | (package only) a fixed `rem`/`px` padding, margin, gap or inset in a component (`p-[1.5rem]`, `px-[12px]`, `padding: "12px"`, `"--card-p": "1.5rem"`) — put it on the spacing scale so density moves it. Ignores hairlines (≤2px), em values and positional offsets |
|
|
495
|
+
|
|
496
|
+
A genuine, rare exception is allowed only through an **auditable** comment that *requires* a reason: `// design-lint-disable-next-line <ruleId> -- <why>` (also `-line` and block `disable`/`enable`). A disable with no reason is itself an error.
|
|
497
|
+
|
|
498
|
+
This is a deliberately scoped contract: `design-lint` enforces the package's own
|
|
499
|
+
source. Consumers opt into the shipped `sable-lint` command, which applies the
|
|
500
|
+
consumer rule subset and installed token vocabulary to their app. Both checks
|
|
501
|
+
protect implementation consistency; neither determines the consumer's art direction.
|
|
502
|
+
|
|
503
|
+
### Reviewing a change: read the deleted side
|
|
504
|
+
|
|
505
|
+
Every rule above fires on code that is *present and wrong*. None of them can fire on something a change **removed** — and the removals are where regressions live, because the after-state looks clean. A diff is not a surface: read the `-` lines.
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
git diff -U0 BASE...HEAD | grep -E '^-[^-]' | grep -E 'aria-|role=|alt=|focus|tabindex|prefers-'
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
| Removed | Why it matters |
|
|
512
|
+
|---|---|
|
|
513
|
+
| `aria-*`, `role=`, `alt=` | an accessible name or role disappeared; nothing renders differently |
|
|
514
|
+
| a semantic element replaced by `div` | keyboard behavior and landmark structure went with it |
|
|
515
|
+
| `:focus-visible`, `outline`, `tabindex` | the control is still reachable and no longer *looks* reachable |
|
|
516
|
+
| `prefers-reduced-motion` / a motion token | the kill-switch stopped reaching this animation |
|
|
517
|
+
| a color token swapped for a literal | the contrast gate can no longer see this value, and the theme can no longer retheme it |
|
|
518
|
+
| a user-facing string shortened or deleted | copy is contract too — an error that stopped saying how to fix it is a regression |
|
|
519
|
+
|
|
520
|
+
Not every removal is a regression; check for an **equivalent replacement** before flagging one. `aria-label` → `aria-labelledby` pointing at visible text is an upgrade. `role="button"` dropped because the element became a real `<button>` is the fix, not the bug.
|
|
521
|
+
|
|
522
|
+
**Blast radius: one hop by default, two for shared things.** Review the changed file plus its direct importers and callers. Expand to a second hop only for design tokens, theme values, and shared primitives — `_contract.ts`, `styles.css`, a layout primitive — where one line reaches the entire product.
|
|
523
|
+
|
|
524
|
+
## Anti-patterns
|
|
525
|
+
|
|
526
|
+
- Inline `oklch()`/hex or raw palette steps in components. *(CI-enforced: `inline-color`, `palette-ref`.)*
|
|
527
|
+
- Decomposing paired typography utilities. *(CI-enforced: `decomposed-type`.)*
|
|
528
|
+
- Pure `#000`/`#fff` (use `zinc-950`/`zinc-50`, or `overlay`/`overlay-foreground` for scrims). *(CI-enforced: `raw-black-white`.)*
|
|
529
|
+
- In Base, purple/brand for primary CTAs (brand = editions + selection only). A different theme chooses its action palette through the semantic pair.
|
|
530
|
+
- Gradient text, side-stripe borders, nested cards, mixed radii.
|
|
531
|
+
- Concrete icon-package imports scattered through components. Reicon supplies the default through the shared adapter; IconProvider allows replacement.
|
|
532
|
+
- A global page max-width (use opt-in region caps).
|
|
533
|
+
- Off-scale spacing or sizing (`p-[15px]`, `h-[37px]`) — snap to the 4px scale. In a component, a fixed `rem`/`px` padding or gap also escapes the density control. *(CI-enforced in the package: `fixed-spacing`.)*
|
|
534
|
+
- Restyling a library component with override classes to get a look it lacks (`<Button className="rounded-full bg-…">`, `<MenuItem className="text-destructive">`, `pl-6` on a table cell). Use or add a real variant. *(CI-enforced: `component-restyle`, `destructive-item-ink`, `table-edge-padding`.)*
|
|
535
|
+
- Single-column card content laid out with CSS grid — a grid track sizes to its widest min-content child, so one long string blows the card out. Use `flex flex-col` with a gap.
|
|
536
|
+
- A dot, bullet or icon attached to a line of type nudged into place with px (`mt-[3px]`). Put it in an `h-[1lh] items-center` box and size it in em.
|
|
537
|
+
- Raw `z-[9999]` or `z-50` — use the layering scale (`z-(--z-nav)`, `z-(--z-overlay)`). *(CI-enforced since 0.44.0: `raw-z-index`.)*
|
|
538
|
+
- Stacking shadows, pairing a heavy shadow with a heavy border, or elevating static content to "stand out" (use a tonal step or space).
|
|
539
|
+
- A sub-44px touch target without a padded hit area — including in the compact register, which retunes control *heights* and never the hit-area floor.
|
|
540
|
+
- Hardcoding a control height instead of reading the density ladder. *(CI-enforced: `raw-control-height`.)*
|
|
541
|
+
- `transition-all` — it watches every property, animates padding and shadows you never meant to move, and blocks the browser from optimizing. Name the properties, or use `motion-interactive`. *(CI-enforced: `transition-all`.)*
|
|
542
|
+
- Physical direction utilities (`ml-*`, `pr-*`, `text-left`) for direction-dependent position — use logical properties so RTL mirrors for free. *(CI-ratcheted: `physical-direction`.)*
|
|
543
|
+
- A decorative overlay painted across interactive content without `pointer-events-none` — the control looks live and does nothing.
|
|
544
|
+
- A fixed width sized to an English label, or a fixed height on a text container — translated strings run 30–50% longer and overflow it.
|
|
545
|
+
- A scroll container with no scrollbar policy — the platform then draws its own, arrows included. *(CI-enforced: `unmanaged-overflow`, in source **and** in registry examples.)* Related: putting a scrollbar under a horizontal strip, where an edge fade says the same thing without adding chrome; and hiding a vertical bar with `scrollbar-hidden` when nothing else carries the affordance, which leaves a reader no sense of position. See **Scrolling**.
|
|
546
|
+
- Building tool chrome with page-register instincts, or a reading surface with workspace ones — see **Registers** for the full split and the two rules it deliberately reverses.
|
|
547
|
+
- Hand-writing a left inset in a hierarchy panel instead of `treeIndent(depth)` — the fastest way to end up with several different left edges in one column. And an indent step that differs from `--tree-column` makes nesting read *inverted*. *(Test-enforced; see **Registers → Tree geometry**.)*
|
|
548
|
+
- Reaching for a general icon set below ~20px and expecting Figma-grade crispness. General sets author on a 24-unit grid and only scale — Reicon's 12/18/24/32 are render sizes, not per-size geometry — so their strokes land off the device-pixel grid at chrome sizes. Author the mark at the size it renders (rule #4, amendment 2).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sable contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|