actual-css 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +25 -6
  3. package/components.json +12 -18
  4. package/dist/actual.full.css +780 -479
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/brand/favicon.svg +5 -0
  8. package/docs/brand/lockup.svg +12 -0
  9. package/docs/brand/logo-dark.svg +9 -0
  10. package/docs/brand/logo-light.svg +9 -0
  11. package/docs/brand/logo.svg +11 -0
  12. package/docs/brand/mark.svg +11 -0
  13. package/docs/design-notes/architecture.md +67 -0
  14. package/docs/design-notes/steps.md +45 -28
  15. package/docs/navigation.json +3 -2
  16. package/docs/pages/components/alert.md +2 -10
  17. package/docs/pages/components/avatar.md +17 -11
  18. package/docs/pages/components/badge.md +3 -9
  19. package/docs/pages/components/breadcrumb.md +16 -2
  20. package/docs/pages/components/card.md +31 -2
  21. package/docs/pages/components/dialog.md +121 -33
  22. package/docs/pages/components/drawer.md +2 -2
  23. package/docs/pages/components/list.md +9 -13
  24. package/docs/pages/components/navbar.md +49 -30
  25. package/docs/pages/components/pagination.md +19 -0
  26. package/docs/pages/components/steps.md +24 -20
  27. package/docs/pages/components/table.md +3 -3
  28. package/docs/pages/enhancements/flyout.md +2 -2
  29. package/docs/pages/enhancements/scrollspy.md +7 -7
  30. package/docs/pages/enhancements/status-bar.md +2 -2
  31. package/docs/pages/enhancements/tabs.md +74 -3
  32. package/docs/pages/examples/overview.md +2 -1
  33. package/docs/pages/forms/date-picker.md +90 -0
  34. package/docs/pages/forms/detached-actions.md +37 -30
  35. package/docs/pages/forms/floating-field.md +21 -7
  36. package/docs/pages/forms/form-actions.md +58 -28
  37. package/docs/pages/forms/input-policy.md +1 -1
  38. package/docs/pages/forms/overview.md +6 -5
  39. package/docs/pages/forms/states-and-sizes.md +15 -15
  40. package/docs/pages/forms/sticky-actions.md +85 -83
  41. package/docs/pages/forms/validation.md +5 -5
  42. package/docs/pages/foundations/philosophy.md +102 -4
  43. package/docs/pages/foundations/theming.md +15 -4
  44. package/docs/pages/foundations/tokens.md +89 -36
  45. package/docs/pages/foundations/typography.md +4 -7
  46. package/docs/pages/guides/bootstrap.md +2 -2
  47. package/docs/pages/guides/design-tools.md +2 -2
  48. package/docs/pages/guides/integrating-actual-css.md +79 -9
  49. package/docs/pages/guides/introduction.md +5 -5
  50. package/docs/pages/guides/modular-import.md +3 -0
  51. package/docs/pages/guides/progressive-enhancement.md +27 -9
  52. package/docs/pages/guides/tailwind.md +70 -0
  53. package/docs/pages/guides/upgrading.md +151 -0
  54. package/docs/pages/layout/center.md +2 -1
  55. package/docs/pages/layout/cluster.md +37 -4
  56. package/docs/pages/layout/grid.md +2 -0
  57. package/docs/pages/layout/overview.md +1 -1
  58. package/docs/pages/layout/sidebar-layout.md +3 -0
  59. package/docs/pages/layout/stack.md +21 -16
  60. package/docs/pages/layout/switcher.md +2 -0
  61. package/docs/pages/layout/topbar.md +7 -5
  62. package/docs/pages/patterns/icon-slot.md +17 -1
  63. package/docs/pages/patterns/navigation-list.md +16 -46
  64. package/docs/pages/patterns/overview.md +3 -4
  65. package/docs/pages/utilities/flex.md +30 -20
  66. package/docs/pages/utilities/sizing-wrapping.md +52 -7
  67. package/docs/pages/utilities/spacing.md +4 -3
  68. package/package.json +6 -6
  69. package/reserved-classes.json +3 -3
  70. package/skills/actual-css/references/enhancements.md +10 -7
  71. package/src/cli/actual-css.js +39 -13
  72. package/src/css/components/alert.css +34 -34
  73. package/src/css/components/avatar.css +8 -17
  74. package/src/css/components/badge.css +1 -6
  75. package/src/css/components/breadcrumb.css +74 -8
  76. package/src/css/components/button.css +3 -9
  77. package/src/css/components/card.css +18 -10
  78. package/src/css/components/chat.css +4 -5
  79. package/src/css/components/drawer.css +42 -45
  80. package/src/css/components/flyout.css +3 -1
  81. package/src/css/components/index.css +0 -1
  82. package/src/css/components/join.css +2 -2
  83. package/src/css/components/list.css +10 -10
  84. package/src/css/components/modal.css +113 -134
  85. package/src/css/components/navbar.css +22 -31
  86. package/src/css/components/pagination.css +5 -0
  87. package/src/css/components/scrollspy.css +1 -1
  88. package/src/css/components/steps.css +66 -12
  89. package/src/css/components/tab.css +134 -4
  90. package/src/css/core/base.css +1 -1
  91. package/src/css/core/focus.css +8 -6
  92. package/src/css/core/reset.css +2 -1
  93. package/src/css/core/theme.css +10 -1
  94. package/src/css/core/tokens.css +32 -12
  95. package/src/css/core/variants.css +41 -14
  96. package/src/css/forms/choice-card.css +1 -1
  97. package/src/css/forms/choice.css +1 -1
  98. package/src/css/forms/control.css +1 -1
  99. package/src/css/forms/floating-field.css +20 -18
  100. package/src/css/forms/form-actions.css +14 -12
  101. package/src/css/forms/native.css +2 -2
  102. package/src/css/forms/otp.css +4 -5
  103. package/src/css/forms/switch.css +1 -1
  104. package/src/css/forms/validation.css +23 -30
  105. package/src/css/layout/center.css +2 -0
  106. package/src/css/layout/cluster.css +25 -0
  107. package/src/css/layout/grid.css +7 -0
  108. package/src/css/layout/scroller.css +19 -3
  109. package/src/css/layout/sidebar-layout.css +3 -0
  110. package/src/css/layout/switcher.css +3 -0
  111. package/src/css/layout/topbar.css +12 -6
  112. package/src/css/utilities/base.css +5 -0
  113. package/src/css/utilities/extra.css +3 -7
  114. package/src/js/full.js +4 -0
  115. package/src/js/inline-visible.js +54 -0
  116. package/src/js/parsed.js +21 -0
  117. package/src/js/tab.js +33 -7
  118. package/src/tooling/color.js +65 -0
  119. package/src/tooling/design-export.js +1 -1
  120. package/src/tooling/design-tokens.js +53 -34
  121. package/src/tooling/theme-contrast.js +217 -0
  122. package/docs/pages/patterns/actions.md +0 -52
  123. package/src/css/components/actions.css +0 -27
@@ -1,6 +1,6 @@
1
1
  # Sticky Actions
2
2
 
3
- > Use `.form-actions sticky` when long forms need reachable submit actions while scrolling.
3
+ > Use `.cluster.form-actions.sticky` when long forms need reachable submit actions while scrolling.
4
4
 
5
5
  ## Inside the form
6
6
 
@@ -8,8 +8,8 @@ The `<form>` is the bounding parent. The sticky stays pinned to the viewport bot
8
8
 
9
9
  Scroll the page to see the actions stay reachable.
10
10
 
11
- ```html demo
12
- <form novalidate>
11
+ ```html demo sticky
12
+ <form class="stack" novalidate>
13
13
  <div class="stack">
14
14
  <label class="field">
15
15
  <span class="field-label">Full name <span class="required-mark" aria-hidden="true">*</span></span>
@@ -57,7 +57,7 @@ Scroll the page to see the actions stay reachable.
57
57
 
58
58
  <fieldset class="field-group">
59
59
  <legend class="field-label">Theme</legend>
60
- <div class="stack compact">
60
+ <div class="stack tight">
61
61
  <label class="choice">
62
62
  <input class="radio" type="radio" name="theme" value="light" checked />
63
63
  <span>Light</span>
@@ -79,7 +79,7 @@ Scroll the page to see the actions stay reachable.
79
79
  </label>
80
80
  </div>
81
81
 
82
- <div class="form-actions sticky">
82
+ <div class="cluster form-actions sticky">
83
83
  <button type="button" class="btn outline">Cancel</button>
84
84
  <button type="submit" class="btn primary">Save changes</button>
85
85
  </div>
@@ -92,84 +92,86 @@ For a sticky page footer, dialog footer, or card footer, detach the actions and
92
92
 
93
93
  Scroll the page to see the detached footer stay reachable.
94
94
 
95
- ```html demo
96
- <form id="account-form" novalidate>
97
- <div class="stack">
98
- <label class="field">
99
- <span class="field-label">Full name <span class="required-mark" aria-hidden="true">*</span></span>
100
- <input class="input" type="text" autocomplete="name" required placeholder="Jane Doe" />
101
- </label>
102
-
103
- <label class="field">
104
- <span class="field-label">Email <span class="required-mark" aria-hidden="true">*</span></span>
105
- <input class="input" type="email" autocomplete="email" required />
106
- </label>
107
-
108
- <label class="field">
109
- <span class="field-label">Phone</span>
110
- <input class="input" type="tel" autocomplete="tel" />
111
- </label>
112
-
113
- <label class="field">
114
- <span class="field-label">Bio</span>
115
- <textarea class="textarea" rows="4" placeholder="Tell us about yourself…"></textarea>
116
- </label>
117
-
118
- <label class="field">
119
- <span class="field-label">Website</span>
120
- <input class="input" type="url" autocomplete="url" placeholder="https://example.com" />
121
- </label>
122
-
123
- <label class="field">
124
- <span class="field-label">Language</span>
125
- <select class="select">
126
- <option>English</option>
127
- <option>Français</option>
128
- <option>Deutsch</option>
129
- </select>
130
- </label>
131
-
132
- <label class="field">
133
- <span class="field-label">Experience level</span>
134
- <input class="range" type="range" min="0" max="10" value="5" />
135
- </label>
136
-
137
- <label class="field">
138
- <span class="field-label">Profile picture</span>
139
- <input class="file" type="file" accept="image/*" />
140
- </label>
141
-
142
- <fieldset class="field-group">
143
- <legend class="field-label">Theme</legend>
144
- <div class="stack compact">
145
- <label class="choice">
146
- <input class="radio" type="radio" name="theme-detached" value="light" checked />
147
- <span>Light</span>
148
- </label>
149
- <label class="choice">
150
- <input class="radio" type="radio" name="theme-detached" value="dark" />
151
- <span>Dark</span>
152
- </label>
153
- <label class="choice">
154
- <input class="radio" type="radio" name="theme-detached" value="auto" />
155
- <span>System</span>
156
- </label>
157
- </div>
158
- </fieldset>
159
-
160
- <label class="choice">
161
- <input class="check" type="checkbox" />
162
- <span>Email notifications</span>
163
- </label>
164
- </div>
165
- </form>
166
-
167
- <footer class="form-actions sticky">
168
- <button type="button" class="btn outline">Cancel</button>
169
- <button type="submit" class="btn primary" form="account-form">
170
- Save changes
171
- </button>
172
- </footer>
95
+ ```html demo sticky
96
+ <div class="stack">
97
+ <form id="account-form" novalidate>
98
+ <div class="stack">
99
+ <label class="field">
100
+ <span class="field-label">Full name <span class="required-mark" aria-hidden="true">*</span></span>
101
+ <input class="input" type="text" autocomplete="name" required placeholder="Jane Doe" />
102
+ </label>
103
+
104
+ <label class="field">
105
+ <span class="field-label">Email <span class="required-mark" aria-hidden="true">*</span></span>
106
+ <input class="input" type="email" autocomplete="email" required />
107
+ </label>
108
+
109
+ <label class="field">
110
+ <span class="field-label">Phone</span>
111
+ <input class="input" type="tel" autocomplete="tel" />
112
+ </label>
113
+
114
+ <label class="field">
115
+ <span class="field-label">Bio</span>
116
+ <textarea class="textarea" rows="4" placeholder="Tell us about yourself…"></textarea>
117
+ </label>
118
+
119
+ <label class="field">
120
+ <span class="field-label">Website</span>
121
+ <input class="input" type="url" autocomplete="url" placeholder="https://example.com" />
122
+ </label>
123
+
124
+ <label class="field">
125
+ <span class="field-label">Language</span>
126
+ <select class="select">
127
+ <option>English</option>
128
+ <option>Français</option>
129
+ <option>Deutsch</option>
130
+ </select>
131
+ </label>
132
+
133
+ <label class="field">
134
+ <span class="field-label">Experience level</span>
135
+ <input class="range" type="range" min="0" max="10" value="5" />
136
+ </label>
137
+
138
+ <label class="field">
139
+ <span class="field-label">Profile picture</span>
140
+ <input class="file" type="file" accept="image/*" />
141
+ </label>
142
+
143
+ <fieldset class="field-group">
144
+ <legend class="field-label">Theme</legend>
145
+ <div class="stack tight">
146
+ <label class="choice">
147
+ <input class="radio" type="radio" name="theme-detached" value="light" checked />
148
+ <span>Light</span>
149
+ </label>
150
+ <label class="choice">
151
+ <input class="radio" type="radio" name="theme-detached" value="dark" />
152
+ <span>Dark</span>
153
+ </label>
154
+ <label class="choice">
155
+ <input class="radio" type="radio" name="theme-detached" value="auto" />
156
+ <span>System</span>
157
+ </label>
158
+ </div>
159
+ </fieldset>
160
+
161
+ <label class="choice">
162
+ <input class="check" type="checkbox" />
163
+ <span>Email notifications</span>
164
+ </label>
165
+ </div>
166
+ </form>
167
+
168
+ <footer class="cluster form-actions sticky">
169
+ <button type="button" class="btn outline">Cancel</button>
170
+ <button type="submit" class="btn primary" form="account-form">
171
+ Save changes
172
+ </button>
173
+ </footer>
174
+ </div>
173
175
  ```
174
176
 
175
177
  ## CSS hooks
@@ -62,7 +62,7 @@ there is no init call. To opt in forms you cannot mark up, call
62
62
  `actual-css/js/enhance`.
63
63
 
64
64
  ```html demo
65
- <form class="needs-validation" data-enhance="validation" data-validation-message="Please check the highlighted fields.">
65
+ <form class="stack needs-validation" data-enhance="validation" data-validation-message="Please check the highlighted fields.">
66
66
  <div class="stack">
67
67
  <label class="field">
68
68
  <span class="field-label">Password</span>
@@ -81,7 +81,7 @@ there is no init call. To opt in forms you cannot mark up, call
81
81
  </label>
82
82
  </div>
83
83
 
84
- <div class="form-actions">
84
+ <div class="cluster form-actions">
85
85
  <button class="btn primary" type="submit">Submit</button>
86
86
  <button class="btn neutral outline" type="reset">Reset</button>
87
87
  </div>
@@ -139,7 +139,7 @@ The status bar (`actual-css/js/status`) auto-wires to that event: import it and
139
139
  Switch this on to submit.
140
140
  </span>
141
141
  </div>
142
- <div class="form-actions">
142
+ <div class="cluster form-actions">
143
143
  <button class="btn primary" type="submit">Submit</button>
144
144
  <button class="btn neutral outline" type="reset">Reset</button>
145
145
  </div>
@@ -197,7 +197,7 @@ Add your own with `FormValidator.registerRule(name, (value, el, ...opts) => bool
197
197
  <span class="field-error" id="attachment-error">Choose a .pdf or .csv file.</span>
198
198
  </label>
199
199
 
200
- <div class="form-actions">
200
+ <div class="cluster form-actions">
201
201
  <button class="btn primary" type="submit">Submit</button>
202
202
  <button class="btn neutral outline" type="reset">Reset</button>
203
203
  </div>
@@ -246,7 +246,7 @@ The `date` rule pairs naturally with `data-mask` — the mask structures input,
246
246
  </span>
247
247
  </label>
248
248
 
249
- <div class="form-actions">
249
+ <div class="cluster form-actions">
250
250
  <button class="btn primary" type="submit">Submit</button>
251
251
  <button class="btn neutral outline" type="reset">Reset</button>
252
252
  </div>
@@ -1,6 +1,102 @@
1
1
  # Philosophy
2
2
 
3
- How components, intents, variants, and density compose — and what each layer is responsible for.
3
+ Actual CSS is a composition-first component framework: let HTML and the
4
+ cascade do the work, compose layout separately from components, and add an
5
+ abstraction only for a real, repeated need. The outlook is close to
6
+ [CUBE CSS](https://cube.fyi/), without adopting its methodology or naming.
7
+
8
+ **Related terms:** principles, architecture, CUBE, BEM, naming, methodology, guidelines.
9
+
10
+ ## Layers
11
+
12
+ | Layer | In Actual | Owns |
13
+ | --------- | --------------------------------- | ----------------------------------- |
14
+ | Baseline | reset, tokens, themes, base | HTML defaults, cascade, inheritance |
15
+ | Layout | `.stack`, `.cluster`, `.grid`, … | space between children |
16
+ | Utility | `.list-reset`, `.measure`, … | one reusable adjustment |
17
+ | Component | `.card`, `.btn`, form controls, … | intrinsic anatomy and appearance |
18
+ | State | native attributes, ARIA, `data-*` | what the element is doing |
19
+
20
+ ## Principles
21
+
22
+ **Components own their inside; compositions own the space between them.** A
23
+ card sets its padding, surface and slots. Where it sits, and how far it is from
24
+ the next card, belongs to the layout around it:
25
+
26
+ ```html
27
+ <section class="stack">
28
+ <article class="card">…</article>
29
+ <article class="card">…</article>
30
+ </section>
31
+ ```
32
+
33
+ A rule such as `.card + .card { margin-block-start: 2rem }` moves page layout
34
+ into the component; put a composition around the cards instead.
35
+
36
+ **A composition on a component takes over its default layout.** A component's
37
+ own layout sits at zero specificity, so `<article class="card media">` lays out
38
+ as a `.media` without an override, whatever the import order.
39
+
40
+ **State uses the most semantic attribute; classes choose presentation.**
41
+
42
+ | Kind | Mechanism | Examples |
43
+ | ---------------- | -------------- | -------------------------------------------- |
44
+ | Native state | HTML attribute | `disabled`, `open`, `checked` |
45
+ | Accessible state | ARIA | `aria-current`, `aria-expanded`, `aria-busy` |
46
+ | Runtime state | `data-*` | no native or ARIA equivalent |
47
+ | Presentation | class | `.soft`, `.outline`, `.sm`, `.compact` |
48
+
49
+ Write `<a class="nav-link" aria-current="page">`, not `.nav-link.active`. The
50
+ few `.is-*` classes (`.is-open`, `.is-static`) are runtime internals: neither
51
+ set nor select them. `data-enhance` activates behavior and is never a styling
52
+ hook.
53
+
54
+ **A utility does one thing.** A class that sets a surface, a border, padding and
55
+ type together is a component: give it a name in application CSS.
56
+
57
+ **Configure before overriding.** Documented public hooks (`--grid-min`,
58
+ `--card-pad`, `--cluster-justify`) are the API. Other custom properties are
59
+ internal and may change.
60
+
61
+ ## Before adding CSS
62
+
63
+ Go down the layers and stop at the first that answers the need:
64
+
65
+ 1. Does HTML already express it, or the browser already do it?
66
+ 2. Is it a relationship between children? Use a composition.
67
+ 3. Is it one small adjustment? Use a utility.
68
+ 4. Is it the anatomy of a component? Use the component and its hooks.
69
+ 5. Is it a state of that component? Use the attribute that names the state.
70
+
71
+ What remains is product-specific: write it as application CSS, as described in
72
+ [Building with Actual CSS](../guides/integrating-actual-css.md).
73
+
74
+ ## Class grammar
75
+
76
+ A component takes optional intent, variant and size classes:
77
+ `<button class="btn primary soft sm">`. The sections below describe each axis.
78
+
79
+ ### Flat names, not BEM
80
+
81
+ A modifier is a separate class that combines with others (`.btn.primary.soft`,
82
+ not `.btn--primary`), and a part gets a prefixed class (`.alert-icon`,
83
+ `.list-item-title`) only when its element does not already say what it is.
84
+ There is no `__` or `--` in Actual's names, because:
85
+
86
+ - **Modifiers are shared axes.** `.primary`, `.soft` and `.sm` mean the same
87
+ thing on every component that supports them, so one class serves `.btn`,
88
+ `.badge` and `.alert`. BEM names a modifier per block, which multiplies the
89
+ vocabulary and keeps a custom intent such as `.tertiary` from working
90
+ everywhere at once.
91
+ - **State is not a modifier.** `disabled`, `aria-current` and `open` already
92
+ name the state; a `--disabled` or `--active` class would duplicate them and
93
+ drift out of sync.
94
+ - **Elements come from HTML.** A card's `header` and `footer`, an avatar's
95
+ `<img>` need no `__element` class; a prefixed class is kept for the parts
96
+ HTML cannot name.
97
+
98
+ Application CSS can use any convention, BEM included, alongside Actual's
99
+ classes: `<article class="card pricing-plan pricing-plan--featured">`.
4
100
 
5
101
  ## Components
6
102
 
@@ -80,8 +176,9 @@ how `.field sm`, `.input-icon sm`, `.join sm`, and `.flyout sm` size the
80
176
  controls inside them, while a bare `.sm` on a plain element does nothing.
81
177
 
82
178
  `.compact` and `.spacious` establish inherited density tokens. Density arranges
83
- how tightly UI sits — spacing and component geometry — while typography and
84
- icon size stay unchanged. Participation is opt-in, and a component only joins
179
+ how tightly UI sits — spacing and component geometry — while font size and
180
+ icon size stay unchanged. `.tight` and `.loose` change the rhythm (`--gap`)
181
+ alone. Participation is opt-in, and a component only joins
85
182
  where density has an effect independent from its optical size: if the result is
86
183
  merely that the component looks smaller or larger, that is size, not density.
87
184
 
@@ -126,7 +223,8 @@ affect the same dimension:
126
223
  </div>
127
224
  ```
128
225
 
129
- The density tokens are `--gap`, `--density-space`, and `--control-size`.
226
+ The density tokens are `--gap`, `--density-space`, `--control-size`, and
227
+ `--control-pad-x`.
130
228
  Components consume the dimensions that make sense for them and opt out of the
131
229
  rest. The absolute `--space-*` scale is not rebound by density scopes.
132
230
  Components with bespoke geometry (`.badge`, `.avatar`,
@@ -184,16 +184,27 @@ Override the whole minimal recolor set (Tokens · Theme contract), not just
184
184
  `--primary`. Every neutral of the default theme — surfaces, text, border,
185
185
  `--neutral`, `--hover-overlay`, `--shadow-color` — is tinted toward the default
186
186
  primary and never derived from `--primary` at runtime, so a partial override
187
- leaves the default identity in the greys. `report:theme-contrast` checks
187
+ leaves the default identity in the greys. `actual-css contrast` checks
188
188
  contrast, not a leftover tint.
189
189
 
190
190
  Soft ink is the other half. The default palette ships calibrated
191
191
  `--*-soft-fg` hooks; any other theme falls back to the `--soft-fg-mix`
192
192
  derivation, which is a single global percentage and therefore guarantees
193
- nothing about your intents. Verify with `bun run report:theme-contrast`, then
194
- lower `--soft-fg-mix` or declare the per-role hooks for the intents that miss.
193
+ nothing about your intents. Measure it, then lower `--soft-fg-mix` or declare
194
+ the per-role hooks for the intents that miss:
195
195
 
196
- → Foundations · Tokens (theme contract)
196
+ ```sh
197
+ bunx --bun actual-css contrast --theme src/theme.css
198
+ ```
199
+
200
+ It reads the theme in headless Chrome, in every scheme it declares, and exits 1
201
+ when a soft, focus, invalid-focus or inverse pair misses its threshold, so it
202
+ can gate a CI job.
203
+
204
+ Starting from a color scale (Tailwind `--color-zinc-*`, a brand ramp): map one
205
+ step per role rather than importing the scale.
206
+
207
+ → Foundations · Tokens (theme contract) · Guides · Tailwind (Map the palette to theme roles)
197
208
 
198
209
  ### Use or adapt a preset palette
199
210
 
@@ -35,6 +35,28 @@ Global tokens are the ones to reach for in application CSS. Write a relay only t
35
35
  }
36
36
  ```
37
37
 
38
+ ### Which hooks inherit
39
+
40
+ A hook either describes one element's own layout, or a policy its context
41
+ hands down. Instance geometry stops at every new instance; policy inherits.
42
+
43
+ | Kind | Examples | Inherits |
44
+ | ----------------- | ----------------------------------------- | -------- |
45
+ | Palette, theme | `--text`, `--primary` | yes |
46
+ | Rhythm, density | `--gap`, `--control-size` | yes |
47
+ | Page policy | `--center-size`, `--measure` | yes |
48
+ | Role policy | `--form-actions-justify`, `--frame-ratio` | yes |
49
+ | Instance geometry | `--cluster-justify`, `--grid-columns` | no |
50
+
51
+ Set instance geometry on the element itself (a class or inline style), and
52
+ select nested instances to change them (`.toolbar > .cluster`). Set a policy
53
+ once on the region or on `:root` that owns it. A new hook follows the same
54
+ test: "how this element lays out" stops at the element, "what this context
55
+ hands down" inherits. The stop needs the instance to read its own hook:
56
+ `.switcher` and `.sidebar-layout` hooks are read by their children, and a
57
+ nested instance that is itself such a child still needs its parent's value,
58
+ so those keep inheriting.
59
+
38
60
  ### Color
39
61
 
40
62
  Intent tokens are used by `.primary`, `.secondary`, `.success`, `.warning`, `.danger`, and `.neutral`. They are curated color/foreground pairs. Actual CSS does not compute foregrounds automatically in the core theme contract.
@@ -85,21 +107,34 @@ Surfaces are painted material and may be translucent. Two roles must stay opaque
85
107
 
86
108
  A theme with translucent surfaces overrides both with opaque values.
87
109
 
110
+ `--border` separates surfaces: cards, tables, dividers, panels. `--border-control`
111
+ is the resting edge that tells a control apart from the surface — fields,
112
+ checkboxes and radios, switches, OTP cells, choice cards, join addons, the color
113
+ and file inputs. It defaults to `--border`; set it alone to make controls
114
+ stand out (input border, field outline, form control contrast) without
115
+ darkening every separator. Hover, checked, invalid and disabled states keep
116
+ their own colors.
117
+
88
118
  Use `*-fg` pairs only for solid backgrounds where the component controls both foreground and background. Do not require a foreground token for every surface token.
89
119
 
90
- Focus styling is outline-first. A global focus baseline applies `--focus-outline` and `--focus-outline-offset`: older browsers show it on `:focus`, while modern browsers limit it to `:focus-visible`. The baseline is a `currentColor` line, so text-like elements (links, `summary`, nav and menu items) follow whatever surface the application painted. Boxed components draw a solid `--focus-ring-width` line in `--focus`: inside the border box on text fields (`.input`, `.textarea`, `.select`), outside at `--focus-outline-offset` on actions (`.btn`, `.close`, choices, switches). The color never follows an intent or a local surface, so `--focus` must hold 3:1 against both `--surface` and `--surface-solid` — the page, fields and a solid band all rely on it. Both lines are `--focus-ring-width` wide. Under `forced-colors: active`, outlines map to the system focus color.
120
+ Focus styling is outline-first. A global focus baseline draws a `--focus-ring-width` line in `--focus-outline-color`, offset by `--focus-outline-offset`: older browsers show it on `:focus`, while modern browsers limit it to `:focus-visible`. The baseline is a `currentColor` line, so text-like elements (links, `summary`, nav and menu items) follow whatever surface the application painted. Boxed components draw a solid `--focus-ring-width` line in `--focus`: inside the border box on text fields (`.input`, `.textarea`, `.select`), outside at `--focus-outline-offset` on actions (`.btn`, `.close`, choices, switches). The color never follows an intent or a local surface, so `--focus` must hold 3:1 against both `--surface` and `--surface-solid` — the page, fields and a solid band all rely on it. Both lines are `--focus-ring-width` wide. Under `forced-colors: active`, outlines map to the system focus color.
91
121
 
92
122
  ```css
93
123
  :root {
94
124
  --focus: hsl(268 30% 55%);
95
125
 
96
126
  --focus-outline-color: currentColor;
97
- --focus-outline: var(--focus-ring-width) solid var(--focus-outline-color);
98
127
  --focus-outline-offset: 2px;
99
128
  --focus-ring-width: 2px;
100
129
  }
101
130
  ```
102
131
 
132
+ The line is composed on the focused element, so a theme island or a region
133
+ that sets `--focus-ring-width` widens links and buttons alike. Set
134
+ `--focus-outline` to replace the whole generic line (`4px dashed currentColor`);
135
+ it has no default value. Application CSS that draws the generic line itself
136
+ writes `var(--focus-ring-width) solid var(--focus-outline-color)`.
137
+
103
138
  ### Palette coherence
104
139
 
105
140
  A coherent palette treats surfaces, text, borders, intents, and shadows as related roles rather than isolated colors.
@@ -167,8 +202,8 @@ to keep that pair ≥ 4.5:1 in both color schemes, enforced by
167
202
  presets and custom `[data-theme]` islands keep the `--soft-fg-mix` derivation
168
203
  unless they declare the hooks themselves (the framework resets them outside
169
204
  the default boundary). A theme that overrides the soft palette is responsible
170
- for preserving sufficient text contrast; `bun run report:theme-contrast`
171
- reports every preset's resting and hovered pairs without failing the build.
205
+ for preserving sufficient text contrast; `bunx --bun actual-css contrast --theme FILE`
206
+ measures its resting and hovered pairs.
172
207
 
173
208
  A theme whose primary should read more muted than its statuses sets only
174
209
  `--primary-soft-fg`, and every soft `.primary` foreground follows it. Without a
@@ -236,7 +271,6 @@ Typography tokens cover the document baseline and common component needs.
236
271
  --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
237
272
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
238
273
  --font-width: 100%;
239
- --font-width-dense: 95%;
240
274
 
241
275
  --line-height: 1.5;
242
276
  --font-weight-normal: 400;
@@ -265,18 +299,45 @@ Local `.sm`/`.lg` sizes map `--control-size`, `--control-font-size`, and
265
299
  --control-size-md: 2.375rem; /* 38px → default */
266
300
  --control-size-lg: 2.75rem; /* 44px → .lg */
267
301
  --control-size: var(--control-size-md);
268
- --control-pad-x: var(--font-size-md);
302
+ --control-pad-x-sm: var(--font-size-xs); /* .sm, .compact */
303
+ --control-pad-x-md: var(--font-size-md); /* default */
304
+ --control-pad-x-lg: var(--font-size-lg); /* .lg, .spacious */
305
+ --control-pad-x: var(--control-pad-x-md);
269
306
  --control-font-size: var(--font-size-md);
270
307
  --disabled-opacity: 0.65;
271
308
  }
272
309
  ```
273
310
 
274
- `--control-pad-x` is a resolved geometry length, stated in the typographic
275
- tokens rather than an `em`. Every part of one control must see the same
276
- horizontal distance; an `em` would change value on a descendant with its own
277
- font size, which is exactly what `--input-icon-size` does to the icon glyph. It
278
- mirrors `--control-font-size` and is rebound beside it at the `.sm`/`.lg`
279
- boundaries.
311
+ A theme sets the steps (`--control-size-*`, `--control-pad-x-*`); `.sm`/`.lg`
312
+ and `.compact`/`.spacious` select one. `--control-size` and `--control-pad-x`
313
+ are the selected values: override them on one instance, never in a theme,
314
+ where every size and density boundary would overwrite them.
315
+
316
+ A `data-theme` island inherits the selected values, so an override set above it
317
+ crosses it. A theme that changes the steps and is used as a nested island must
318
+ re-select them in its own block, from the step weights that size and density
319
+ classes set, so the island keeps the density around it:
320
+
321
+ ```css
322
+ [data-theme="brand"] {
323
+ --control-pad-x-sm: 0.5rem;
324
+ --control-pad-x-md: 0.75rem;
325
+ --control-pad-x-lg: 1rem;
326
+ --control-pad-x: calc(
327
+ (var(--control-pad-x-sm) * var(--control-step-sm)) +
328
+ (var(--control-pad-x-md) * var(--control-step-md)) +
329
+ (var(--control-pad-x-lg) * var(--control-step-lg))
330
+ );
331
+ }
332
+ ```
333
+
334
+ Re-select only the token whose steps the theme changes: the same rule for
335
+ `--control-size` would stop an inherited `--control-size` at the island.
336
+
337
+ `--control-pad-x` is a length, not an `em`: every part of one control must see
338
+ the same horizontal distance, and an `em` would change value on a descendant
339
+ with its own font size, which is exactly what `--input-icon-size` does to the
340
+ icon glyph.
280
341
 
281
342
  - `.lg` — 44px control height
282
343
  - default — 38px control height
@@ -290,42 +351,33 @@ Disabled controls and disabled-prone components read `--disabled-opacity` for th
290
351
 
291
352
  ### Density
292
353
 
293
- Density contexts swap shared spacing and geometry, plus a slight typographic
294
- width bonus:
354
+ Density contexts swap shared rhythm and geometry, and never typography:
295
355
 
296
356
  ```css
297
- :root {
298
- --font-width: 100%;
299
- --font-width-dense: 95%;
300
- }
301
-
302
357
  .compact {
303
358
  --gap: var(--space-20);
304
359
  --density-space: var(--space-20);
305
360
  --control-size: var(--control-size-sm);
306
- font-stretch: var(--font-width-dense);
361
+ --control-pad-x: var(--control-pad-x-sm);
307
362
  }
308
363
 
309
364
  .spacious {
310
365
  --gap: var(--space-50);
311
366
  --density-space: var(--space-50);
312
367
  --control-size: var(--control-size-lg);
313
- font-stretch: var(--font-width);
368
+ --control-pad-x: var(--control-pad-x-lg);
314
369
  }
315
370
  ```
316
371
 
317
- Density never changes font size or icon size — it may only use the denser font
318
- width. `font-stretch` uses a variable `wdth` axis when available, or
319
- participates in normal width-face matching for families that expose multiple
320
- widths. Layout must never depend on the narrowing being available. Compact
321
- density may slightly narrow variable typefaces when they support a width axis.
322
- This is a density enhancement, not an overflow strategy: never condense a
323
- component just because its label overflows at some viewport — that hides a
324
- composition problem. A theme whose typeface has no useful width axis opts out
325
- with `--font-width-dense: 100%`.
372
+ - **Size** (`.sm`, `.lg`) — typography + geometry, on one component.
373
+ - **Density** (`.compact`, `.spacious`) — rhythm + geometry, on a region.
374
+ - **Rhythm** (`.tight`, `.loose`) — `--gap` only, on a region.
375
+
376
+ Font size, icon size and font width stay put under density. A condensed table
377
+ or panel is a typographic choice: set `font-stretch` on it in the application.
326
378
 
327
379
  Participation is opt-in and selective: controls consume `--control-size`
328
- for geometry; spacing helpers and rhythms consume `--density-space` and `--gap`;
380
+ and `--control-pad-x` for geometry; spacing helpers and rhythms consume `--density-space` and `--gap`;
329
381
  components whose size is intrinsic (badge, avatar, spinner, rating, key, prose,
330
382
  inline choice/switch) keep their own scales. A local component size overrides
331
383
  inherited density for the dimensions they share.
@@ -359,7 +411,7 @@ Motion durations keep transitions consistent and easy to disable.
359
411
  }
360
412
  ```
361
413
 
362
- Ordinary interaction/state transitions have **no easing token**: they use the CSS default `ease` directly, and the dialog shake feedback plus the `actual-dialog` View Transition keep that default deliberately. The presence pair exists only for one-shot open/close where open and closed states own separate transition declarations (`.status-bar`/`.is-open`, `dialog.modal[open]`, `dialog.drawer[open]`): `--ease-enter` decelerates the appearance, `--ease-exit` accelerates the departure. The pair follows Material's standard rhythm; the emphasized-decelerate entrance is deliberately not offered because it only pays off at 250–400ms durations, beyond this vocabulary. Backdrops keep `ease` — a scrim only fades, it does not travel — but track their surface's duration. Toggles that share one transition list at `--duration-fast` (flyout, tooltip) also stay on `ease`: at 100ms the curve barely resolves and entry/exit cannot split on a single rule. These absences are deliberate, not gaps; a theme with a different motion character overrides `--ease-enter` / `--ease-exit`.
414
+ Ordinary interaction/state transitions have **no easing token**: they use the CSS default `ease` directly, and the dialog shake feedback plus the `actual-dialog` View Transition keep that default deliberately. The presence pair exists only for one-shot open/close where open and closed states own separate easing values (`.status-bar`/`.is-open`, `dialog.modal[open]`, `dialog.drawer[open]`): `--ease-enter` decelerates the appearance, `--ease-exit` accelerates the departure. The pair follows Material's standard rhythm; the emphasized-decelerate entrance is deliberately not offered because it only pays off at 250–400ms durations, beyond this vocabulary. Backdrops keep `ease` — a scrim only fades, it does not travel — but track their surface's duration. Toggles that share one transition list at `--duration-fast` (flyout, tooltip) also stay on `ease`: at 100ms the curve barely resolves and entry/exit cannot split on a single rule. These absences are deliberate, not gaps; a theme with a different motion character overrides `--ease-enter` / `--ease-exit`.
363
415
 
364
416
  `reset.css` clamps raw animation and transition durations to `0.01ms`, resets their delays, and restores `html` scroll behavior under `prefers-reduced-motion: reduce`. Decorative loops effectively complete immediately without blanket-disabling animation support.
365
417
 
@@ -486,13 +538,13 @@ Rules:
486
538
 
487
539
  Themes override tokens, not selectors. The themes in `src/css/themes/` are repository-only demo examples, not included in the default stylesheet or the npm package; they exist to show valid ways to use this contract, such as `ocean`, `square`, `cyberpunk`, and `brutalist`.
488
540
 
489
- A minimal recolor theme overrides the intent pairs, surfaces, text colors, border, focus, hover overlay, and shadow color. Pick `--focus` as a mid-tone that holds 3:1 against both `--surface` and `--surface-solid` in every scheme the theme declares; a pastel dark-scheme primary usually fails on the light solid surface. `bun run report:theme-contrast` measures both pairs.
541
+ A minimal recolor theme overrides the intent pairs, surfaces, text colors, border, focus, hover overlay, and shadow color. Pick `--focus` as a mid-tone that holds 3:1 against both `--surface` and `--surface-solid` in every scheme the theme declares; a pastel dark-scheme primary usually fails on the light solid surface. `bunx --bun actual-css contrast --theme FILE` measures both pairs, and exits 1 when a pair misses. The file may declare a `[data-theme]` island or set the tokens on `:root`; the tool reads each scheme through `color-scheme`, so write dark values with `light-dark()` — a `prefers-color-scheme` media block is not measured.
490
542
 
491
- When overriding an intent color, review its paired `--*-fg` and `--*-soft-fg`. A theme that declares no hook falls back to the `--soft-fg-mix` derivation: one global percentage applied to every role, so it carries no contrast guarantee for a palette it was not tuned against. `bun run report:theme-contrast` prints the resting and hovered soft pair for every intent of every island, which is how a theme finds the roles that need a hook or a lower `--soft-fg-mix`.
543
+ When overriding an intent color, review its paired `--*-fg` and `--*-soft-fg`. A theme that declares no hook falls back to the `--soft-fg-mix` derivation: one global percentage applied to every role, so it carries no contrast guarantee for a palette it was not tuned against. `actual-css contrast` prints the resting and hovered soft pair for every intent in every scheme the theme declares, which is how a theme finds the roles that need a hook or a lower `--soft-fg-mix`.
492
544
 
493
545
  The default palette's hooks apply to `:root` and to the `light`/`dark` boundaries only. Any other `data-theme` value resets them to the derivation, whether the attribute sits on `<html>` or on a nested island.
494
546
 
495
- Contrast is measured between opaque colors. A translucent `--surface` has no ratio until it is composited over its backdrop, so `report:theme-contrast` refuses such a theme, and `.soft` mixes over it come out more opaque and more saturated than the surface. A translucent theme owns both: it measures against its real backdrop and replaces any recipe that does not hold there.
547
+ Contrast is measured between opaque colors. A translucent `--surface` has no ratio until it is composited over its backdrop, so `actual-css contrast` reports its pairs as n/a, and `.soft` mixes over it come out more opaque and more saturated than the surface. A translucent theme owns both: it measures against its real backdrop and replaces any recipe that does not hold there.
496
548
 
497
549
  Shape, shadow, motion, typography, and soft-variant mix tokens are optional knobs. Override them only when the theme actually changes that part of the system.
498
550
 
@@ -501,6 +553,7 @@ A full theme can override:
501
553
  - intent colors and foreground pairs
502
554
  - surface, text, border, focus, and overlay colors
503
555
  - `--surface-opaque` and `--surface-solid-fg`, when surfaces are translucent
556
+ - `--border-control`, when controls need a stronger edge than separators
504
557
  - radius tokens
505
558
  - optional shadow, motion, typography, and soft-variant mix tokens
506
559
 
@@ -532,7 +585,7 @@ A minimal recolor theme (illustrative, not a shipped theme):
532
585
  --text-muted: hsl(260 10% 42%);
533
586
  --text-subtle: hsl(260 8% 56%);
534
587
  --border: hsl(260 24% 84%);
535
- --focus: var(--primary);
588
+ --focus: hsl(260 60% 62%);
536
589
  --hover-overlay: hsl(260 30% 10% / 0.04);
537
590
  --shadow-color: hsl(260 30% 10%);
538
591
  --soft-bg-mix: 84%;