actual-css 0.12.0 → 0.14.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 (108) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -8
  3. package/components.json +124 -12
  4. package/dist/actual.full.css +1152 -309
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/architecture.md +67 -0
  8. package/docs/design-notes/steps.md +45 -28
  9. package/docs/navigation.json +2 -0
  10. package/docs/pages/components/alert.md +12 -2
  11. package/docs/pages/components/avatar.md +17 -11
  12. package/docs/pages/components/breadcrumb.md +16 -2
  13. package/docs/pages/components/button.md +3 -2
  14. package/docs/pages/components/list.md +15 -16
  15. package/docs/pages/components/navbar.md +42 -22
  16. package/docs/pages/components/pagination.md +19 -0
  17. package/docs/pages/components/segmented.md +83 -0
  18. package/docs/pages/components/steps.md +28 -22
  19. package/docs/pages/enhancements/flyout.md +5 -0
  20. package/docs/pages/enhancements/scrollspy.md +6 -6
  21. package/docs/pages/enhancements/tabs.md +79 -3
  22. package/docs/pages/examples/overview.md +3 -1
  23. package/docs/pages/forms/color-swatches.md +131 -0
  24. package/docs/pages/forms/combobox.md +2 -2
  25. package/docs/pages/forms/controls.md +6 -3
  26. package/docs/pages/forms/floating-field.md +21 -7
  27. package/docs/pages/forms/form-actions.md +3 -6
  28. package/docs/pages/forms/joined-controls.md +6 -2
  29. package/docs/pages/forms/overview.md +1 -0
  30. package/docs/pages/forms/states-and-sizes.md +15 -15
  31. package/docs/pages/forms/switch.md +4 -2
  32. package/docs/pages/foundations/philosophy.md +97 -1
  33. package/docs/pages/foundations/theming.md +1 -1
  34. package/docs/pages/foundations/tokens.md +94 -27
  35. package/docs/pages/guides/design-tools.md +14 -8
  36. package/docs/pages/guides/integrating-actual-css.md +74 -8
  37. package/docs/pages/guides/introduction.md +3 -2
  38. package/docs/pages/guides/modular-import.md +1 -1
  39. package/docs/pages/guides/progressive-enhancement.md +25 -7
  40. package/docs/pages/guides/upgrading.md +86 -9
  41. package/docs/pages/layout/center.md +2 -1
  42. package/docs/pages/layout/cluster.md +11 -4
  43. package/docs/pages/layout/grid.md +2 -0
  44. package/docs/pages/layout/scroll-snap.md +4 -2
  45. package/docs/pages/layout/sidebar-layout.md +3 -0
  46. package/docs/pages/layout/switcher.md +2 -0
  47. package/docs/pages/layout/topbar.md +6 -4
  48. package/docs/pages/patterns/icon-slot.md +17 -1
  49. package/docs/pages/utilities/flex.md +1 -1
  50. package/docs/pages/utilities/sizing-wrapping.md +51 -6
  51. package/package.json +6 -6
  52. package/reserved-classes.json +3 -0
  53. package/src/cli/actual-css.js +4 -4
  54. package/src/css/components/alert.css +99 -48
  55. package/src/css/components/app-nav.css +17 -9
  56. package/src/css/components/avatar.css +2 -18
  57. package/src/css/components/badge.css +23 -10
  58. package/src/css/components/breadcrumb.css +74 -8
  59. package/src/css/components/button.css +17 -2
  60. package/src/css/components/close.css +6 -3
  61. package/src/css/components/drawer.css +23 -42
  62. package/src/css/components/flyout.css +0 -1
  63. package/src/css/components/index.css +1 -0
  64. package/src/css/components/join.css +2 -2
  65. package/src/css/components/list.css +28 -13
  66. package/src/css/components/modal.css +17 -10
  67. package/src/css/components/navbar.css +15 -10
  68. package/src/css/components/pagination.css +5 -0
  69. package/src/css/components/scrollspy.css +1 -1
  70. package/src/css/components/segmented.css +186 -0
  71. package/src/css/components/steps.css +72 -15
  72. package/src/css/components/tab.css +135 -5
  73. package/src/css/core/focus.css +11 -6
  74. package/src/css/core/theme.css +6 -1
  75. package/src/css/core/tokens.css +24 -6
  76. package/src/css/core/variants.css +16 -5
  77. package/src/css/forms/choice-card.css +1 -1
  78. package/src/css/forms/choice.css +1 -1
  79. package/src/css/forms/color-swatch.css +159 -0
  80. package/src/css/forms/control.css +1 -1
  81. package/src/css/forms/floating-field.css +20 -18
  82. package/src/css/forms/form-actions.css +14 -8
  83. package/src/css/forms/index.css +1 -0
  84. package/src/css/forms/native.css +51 -21
  85. package/src/css/forms/otp.css +4 -5
  86. package/src/css/forms/switch.css +6 -3
  87. package/src/css/forms/validation.css +23 -30
  88. package/src/css/layout/center.css +2 -0
  89. package/src/css/layout/cluster.css +14 -0
  90. package/src/css/layout/grid.css +7 -0
  91. package/src/css/layout/scroll-snap.css +30 -0
  92. package/src/css/layout/scroller.css +19 -3
  93. package/src/css/layout/sidebar-layout.css +3 -0
  94. package/src/css/layout/switcher.css +3 -0
  95. package/src/css/layout/topbar.css +10 -0
  96. package/src/css/utilities/extra.css +3 -3
  97. package/src/js/focus.js +16 -1
  98. package/src/js/full.js +4 -0
  99. package/src/js/inline-visible.js +54 -0
  100. package/src/js/parsed.js +21 -0
  101. package/src/js/surface.js +10 -0
  102. package/src/js/tab.js +33 -7
  103. package/src/tooling/color.js +14 -1
  104. package/src/tooling/css-bundle.js +0 -5
  105. package/src/tooling/design-components.js +9 -5
  106. package/src/tooling/design-export.js +9 -1
  107. package/src/tooling/design-tokens.js +207 -76
  108. package/src/tooling/theme-contrast.js +182 -45
@@ -132,8 +132,10 @@ same optical line. Change the type or the control size and they stay level:
132
132
  - `--switch-width` — track width.
133
133
  - `--switch-block-size` — track height.
134
134
  - `--switch-knob-margin` — inset between knob and track.
135
+ - `--switch-knob-inline-size` — knob width; wider than its height makes a
136
+ capsule knob. Defaults to the knob height.
135
137
 
136
- Knob size and travel distance are derived from the three `--switch-*` hooks; set
137
- those rather than the derived values. The knob is concentric in its track: the
138
+ Knob size and travel distance are derived from these hooks; set them rather
139
+ than the derived values. The knob is concentric in its track: the
138
140
  inset is measured inside the border on both axes, so it clears the rails by the
139
141
  same distance it clears the ends.
@@ -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
 
@@ -56,7 +56,7 @@ Set `--form-actions-justify` (and `--form-actions-align`).
56
56
 
57
57
  ### Customize a switch's size
58
58
 
59
- Change `--switch-width`, `--switch-block-size`, and `--switch-knob-margin`; knob size and travel are derived from them. To rescale the whole row of inline choices at once, use `.sm` / `.lg` on `.choice`, or set `--choice-control-size` directly — the checkbox, the radio, and the switch track all follow it. Density contexts leave inline choices alone.
59
+ Change `--switch-width`, `--switch-block-size`, and `--switch-knob-margin`; knob size and travel are derived from them. `--switch-knob-inline-size` widens the knob into a capsule. To rescale the whole row of inline choices at once, use `.sm` / `.lg` on `.choice`, or set `--choice-control-size` directly — the checkbox, the radio, and the switch track all follow it. Density contexts leave inline choices alone.
60
60
 
61
61
  → Forms · Switch
62
62
 
@@ -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.
@@ -116,34 +151,42 @@ Color should reinforce structure and meaning, not carry them alone. Information
116
151
 
117
152
  The default palette is **Ink & Terra**: a near-neutral aubergine `primary`, terracotta `secondary`, and pigment-toned statuses (sage, ochre, brick) tuned to equal perceived weight. The neutral ramp (text, borders, subtle surfaces) is tinted toward the primary hue so the whole page quietly carries the identity. Optional themes can re-introduce vivid intent palettes on top of this foundation.
118
153
 
119
- Soft surfaces (`.btn.soft`, `.badge.soft`, and the default `.alert`) are generated from intent colors through `color-mix()`. Themes can tune the mix globally instead of rewriting component selectors.
154
+ Soft surfaces are generated from intent colors through `color-mix()`, in two
155
+ recipes that share one ink:
156
+
157
+ - **Tint** — the default `.badge` and `.alert`, and `.btn.soft`: a translucent
158
+ intent laid over whatever surface is underneath, with no rim. It keeps
159
+ separating on `--surface-subtle` and `--surface-raised`, where a fixed mix
160
+ with `--surface` would match the surface it sits on.
161
+ - **Mix** — the shared `.soft` on zones (`.card`, `.chat-bubble`, `.navbar`,
162
+ `.app-nav`): an opaque mix of `--surface` with the intent and a tinted rim.
163
+ A zone may sit over an image, where a translucent fill would hand the text's
164
+ contrast to whatever is behind it.
165
+
166
+ Themes tune both globally instead of rewriting component selectors:
120
167
 
121
168
  ```css
122
169
  :root {
123
- --soft-bg-mix: 88%;
124
- --soft-border-mix: 65%;
125
- --soft-hover-alpha: 12%;
170
+ --soft-bg-mix: 88%; /* share of surface; the tint's opacity is 100% minus it */
171
+ --soft-hover-alpha: 6%; /* extra intent share on hover */
126
172
  --soft-fg-mix: 100%;
127
173
  }
128
174
  ```
129
175
 
130
- `--soft-border-mix` is the same lever for the rim. The `65%` default keeps a
131
- visibly tinted border around every soft surface; pointing it at `--soft-bg-mix`
132
- resolves the rim to the fill, and soft treatments become a tint carrying text
133
- with no contour — the flatter language of a modern component set, from one
134
- declaration:
176
+ `--soft-border-mix` has no default. Unset, the tint draws no rim and the mix
177
+ keeps a 65% one. A theme that sets it gives every soft treatment a tinted rim
178
+ of that weight — contoured badges and alerts, from one declaration:
135
179
 
136
180
  ```css
137
- [data-theme="flat"] {
138
- --soft-border-mix: var(--soft-bg-mix);
181
+ [data-theme="contoured"] {
182
+ --soft-border-mix: 65%;
139
183
  }
140
184
  ```
141
185
 
142
- It reaches the default `.badge` and `.alert` plus every explicit `.soft`
143
- (`.btn`, `.card`, `.chat`, `.navbar`, `.app-nav`), and nothing else: a component
144
- on its own surface keeps its `--border` rim. The dial is also
145
- rendering-only — the baseline path below `color-mix()` keeps `var(--border)`, so
146
- borderless soft is a look, not a contract the Degraded tier upholds.
186
+ A rule that re-derives a soft color reads it with the recipe's own fallback:
187
+ `var(--soft-border-mix, 100%)` for a tint, `var(--soft-border-mix, 65%)` for a
188
+ mix. Both recipes are rendering-only — the baseline path below `color-mix()`
189
+ keeps `var(--surface-subtle)` and `var(--border)`.
147
190
 
148
191
  `--soft-fg-mix` is the share of raw intent in soft *text*. At its `100%` default soft ink is the intent color itself, which is what a palette of dark, muted intents wants. A vivid or light palette cannot afford that: a soft badge then paints intent-tinted ink on an intent-tinted surface, and the two converge. Lowering the mix rebates the ink toward `--text`, which is the right direction in both schemes because `--text` is dark on a light theme and light on a dark one. Around `45%` a fully saturated palette recovers AA while the ink still reads as its intent.
149
192
 
@@ -158,17 +201,19 @@ hook, and the soft recipe reads it before falling back to the global mix:
158
201
  - `--soft-fg-mix` — the **global fallback derivation**, unchanged.
159
202
 
160
203
  **Soft foregrounds are calibrated against the darkest or closest interactive
161
- surface they are expected to appear on.** For interactive soft treatments the
162
- fill moves toward the intent on hover (`--soft-hover-alpha`), so the binding
163
- surface is the *hovered* one and the resting fill simply carries more margin;
164
- the default palette ships its own `--primary`/`--secondary`/… `-soft-fg` hooks
165
- to keep that pair ≥ 4.5:1 in both color schemes, enforced by
166
- `tests/browser/soft-recipe.test.js`. The hard line stops at the default theme:
204
+ surface they are expected to appear on.** The tint is translucent, so the same
205
+ pair reads differently on each surface — usually closest on `--surface-subtle`.
206
+ For interactive soft treatments the fill moves toward the intent on hover
207
+ (`--soft-hover-alpha`), so the binding pair is the *hovered* tint over
208
+ `--surface-subtle` and the resting fill simply carries more margin; the default
209
+ palette ships its own `--primary`/`--secondary`/… `-soft-fg` hooks to keep
210
+ every pair ≥ 4.5:1 on `--surface`, `--surface-raised` and `--surface-subtle` in
211
+ both color schemes, enforced by `tests/browser/soft-recipe.test.js`. The hard line stops at the default theme:
167
212
  presets and custom `[data-theme]` islands keep the `--soft-fg-mix` derivation
168
213
  unless they declare the hooks themselves (the framework resets them outside
169
214
  the default boundary). A theme that overrides the soft palette is responsible
170
215
  for preserving sufficient text contrast; `bunx --bun actual-css contrast --theme FILE`
171
- measures its resting and hovered pairs.
216
+ measures its resting and hovered pairs, composited on each of those surfaces.
172
217
 
173
218
  A theme whose primary should read more muted than its statuses sets only
174
219
  `--primary-soft-fg`, and every soft `.primary` foreground follows it. Without a
@@ -376,7 +421,7 @@ Motion durations keep transitions consistent and easy to disable.
376
421
  }
377
422
  ```
378
423
 
379
- 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`.
424
+ 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`.
380
425
 
381
426
  `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.
382
427
 
@@ -503,7 +548,7 @@ Rules:
503
548
 
504
549
  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`.
505
550
 
506
- 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.
551
+ 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.
507
552
 
508
553
  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`.
509
554
 
@@ -518,6 +563,7 @@ A full theme can override:
518
563
  - intent colors and foreground pairs
519
564
  - surface, text, border, focus, and overlay colors
520
565
  - `--surface-opaque` and `--surface-solid-fg`, when surfaces are translucent
566
+ - `--border-control`, when controls need a stronger edge than separators
521
567
  - radius tokens
522
568
  - optional shadow, motion, typography, and soft-variant mix tokens
523
569
 
@@ -563,12 +609,33 @@ A minimal recolor theme (illustrative, not a shipped theme):
563
609
 
564
610
  Several tokens are theme-derived aliases that reference other tokens — `--state-selected`/`--state-selected-fg`/`--state-disabled`, `--indicator-ring`, `--shadow`/`--shadow-popout`, `--heading`, `--selection-bg`/`--selection-fg`, and `--surface-opaque`/`--surface-solid-fg`. They are declared on `:root, [data-theme]` so they recompute on every theme boundary: a custom property resolves its `var()` references at computed-value time on the element that declares it, so an alias declared only on `:root` would be inherited as an already-resolved value and would not follow a `[data-theme]` island's overridden tokens. A theme that wants a distinct alias (e.g. a `--selection-bg` of its own) overrides it explicitly afterwards, which wins by cascade order.
565
611
 
612
+ Selected state has three roles:
613
+
614
+ - `--state-selected` — the selected fill or accent: a pressed button, a checked switch, a completed step.
615
+ - `--state-selected-fg` — ink on that fill.
616
+ - `--state-selected-text` — selected ink on a regular surface: the current nav link, the selected tab, the current step's number and ring.
617
+
618
+ `--state-selected-text` has no default: the core resets it to the guaranteed-invalid value on every theme boundary, and each consumer reads `var(--state-selected-text, var(--state-selected))`. The reset is what lets a nested theme island reach its own `--state-selected`: a theme that resolves the hook to another token bakes the reference where it is declared, so an island that sets nothing would otherwise inherit the resolved value and never fall back. Set it when one color cannot be both a fill under its `-fg` and text on the page — typically a dark scheme, where a fill dark enough for white ink is too dark to read as text. Pulling the accent toward `--text` keeps its hue in both schemes:
619
+
620
+ ```css
621
+ [data-theme="my-theme"] {
622
+ --state-selected-text: light-dark(
623
+ var(--primary),
624
+ color-mix(in oklab, var(--primary) 50%, var(--text))
625
+ );
626
+ }
627
+ ```
628
+
629
+ `actual-css contrast` measures it on each surface (4.5:1 for text, 3:1 for the step ring).
630
+
566
631
  Without `data-theme`, the default theme advertises `color-scheme: light dark` and follows the user's OS preference in browsers that support `light-dark()`. Dark themes should set `color-scheme: dark`. Light themes should set `color-scheme: light`. Browsers without `light-dark()` receive the light fallback.
567
632
 
568
633
  Because the native select chevron is an SVG background image and cannot inherit `currentColor`, the select hands the arrow back to the browser under `prefers-contrast: more` (same treatment as `forced-colors: active`) instead of shipping a recolored chevron per theme — one native fallback covers every theme without a high-contrast icon variant to maintain.
569
634
 
570
635
  Under `prefers-contrast: more`, the user's contrast preference wins over a theme's palette by contract: the core collapses `--text-muted`/`--text-subtle` to `--text` and thickens borders and focus so no theme can defeat a legibility requirement. This is a deliberate decision, not a specificity quirk — the repeated `[data-theme][data-theme]` attribute only outranks a preset theme stylesheet loaded after the core.
571
636
 
637
+ Under `prefers-reduced-transparency: reduce`, `.topbar` and `.form-actions.sticky` paint `--surface` without a backdrop blur. A theme with translucent `--surface-*` redeclares them opaque inside the same media query; the glass demo theme shows the recipe.
638
+
572
639
  Under `forced-colors: active`, the browser automatically remaps `color`, `background`, and `border` properties to system colors. The core only adjusts what the remapping cannot express on its own — `--state-disabled` becomes `GrayText` and `--disabled-opacity` becomes `1` — declared on `:root` and `[data-theme]` so every theme boundary follows. Keep local forced-color rules only for custom control geometry, native bar parts, or selected/checked states that would otherwise be communicated only by background color.
573
640
 
574
641
  ## Component mapping
@@ -6,20 +6,26 @@
6
6
  bunx --bun actual-css design --theme src/theme.css --out design/
7
7
  ```
8
8
 
9
- `--theme` is a file declaring one `[data-theme="…"]` block (see [Theming](../foundations/theming.md)); without it the default theme is exported. The command needs Bun, and `--bun` makes `bunx` run it under Bun instead of the CLI's Node shebang: it reads the theme in headless Chrome, so every `light-dark()`, `color-mix()` and `var()` chain resolves exactly as the browser paints it.
9
+ `--theme` is a file declaring one theme, on `:root` or in a `[data-theme="…"]` block (see [Theming](../foundations/theming.md)); without it the default theme is exported. The command needs Bun, and `--bun` makes `bunx` run it under Bun instead of the CLI's Node shebang: it reads the theme in headless Chrome, so every `light-dark()`, `color-mix()` and `var()` chain resolves exactly as the browser paints it.
10
10
 
11
11
  ## What is exported
12
12
 
13
- | File | Content |
14
- | --------------------------------------------- | ------------------------------------ |
15
- | `figma/foundations.tokens.json` | spacing, radius, borders, type sizes |
16
- | `figma/light.tokens.json`, `dark.tokens.json` | colors, one file per scheme |
17
- | `penpot/tokens.json` | all tokens, as sets and themes |
18
- | `penpot/plugin/` | the plugin and component spec |
13
+ | File | Content |
14
+ | --------------------------------------------- | -------------------------------------- |
15
+ | `figma/foundations.tokens.json` | spacing, radius, borders, type sizes |
16
+ | `figma/light.tokens.json`, `dark.tokens.json` | colors, one file per scheme |
17
+ | `dtcg/foundations.tokens.json` | the portable profile, every foundation |
18
+ | `dtcg/light.tokens.json`, `dark.tokens.json` | colors, one file per scheme |
19
+ | `penpot/tokens.json` | all tokens, as sets and themes |
20
+ | `penpot/plugin/` | the plugin and component spec |
19
21
 
20
22
  Token names are the CSS names without `--`: `surface-raised` in the design tool is `var(--surface-raised)` in code. A scheme exists when the theme declares it: `color-scheme: light dark` exports both, anything else one. Lengths are exported in px, with the CSS value kept in `$extensions["actual-css"]`.
21
23
 
22
- Colors no theme token paints — the soft fills and borders of `.soft` — are exported as recipe tokens named `<variant>-<intent>-<role>` (`soft-danger-bg`, `soft-danger-border`, `soft-danger-fg`), next to the theme colors so they switch with the scheme. Font stacks, line heights, shadows, motion and z-index have no design-tool equivalent and are not exported.
24
+ Colors no theme token paints — the soft fills and borders of `.soft` — are exported as recipe tokens named `<variant>-<intent>-<role>` (`soft-danger-bg`, `soft-danger-border`, `soft-danger-fg`), next to the theme colors so they switch with the scheme.
25
+
26
+ ## DTCG
27
+
28
+ `dtcg/` is the portable profile, for any reader that follows the [Design Tokens Format](https://www.designtokens.org/format/): every foundation carries the `$type` the spec defines (`dimension`, `fontWeight`, `fontFamily`, `duration`, `cubicBezier`, `number`), and each color carries a 6-digit `hex` with `alpha` separate. It keeps font stacks, line heights and motion, which `figma/` drops, and omits Figma's adaptations: a font weight is a `fontWeight` here, a `number` there. Shadows and z-index are not exported anywhere.
23
29
 
24
30
  ## Figma
25
31
 
@@ -8,7 +8,8 @@ In short: **use Actual for generic structure and behavior; add product-specific
8
8
  classes for brand and compositions rather than forcing a generic primitive.** A
9
9
  local rule is not a failure of Actual — overfitting the framework to a
10
10
  product-specific look is. Reach for an Actual primitive first, and write
11
- application CSS when the need is genuinely product-specific (see §6–7).
11
+ application CSS when the need is genuinely product-specific (see §9–10). The
12
+ principles behind this order are in [Philosophy](../foundations/philosophy.md).
12
13
 
13
14
  ## 1. Start with the theme
14
15
 
@@ -60,7 +61,29 @@ For example, three panels that must all stack together are a `.switcher`, not au
60
61
 
61
62
  If local CSS mainly makes one layout primitive behave like another, reconsider the primitive first.
62
63
 
63
- ## 3. Tune before replacing
64
+ ## 3. Keep outer spacing in the layout
65
+
66
+ Components own their internal rhythm; layout primitives own the space between
67
+ components.
68
+
69
+ ```html
70
+ <div class="stack">
71
+ <article class="card">…</article>
72
+ <article class="card">…</article>
73
+ </div>
74
+ ```
75
+
76
+ ```css
77
+ /* Avoid */
78
+ .card + .card {
79
+ margin-block-start: var(--space-50);
80
+ }
81
+ ```
82
+
83
+ A card then moves between a stack, a grid or a sidebar without its outer
84
+ spacing to undo.
85
+
86
+ ## 4. Tune before replacing
64
87
 
65
88
  Actual primitives expose public hooks for common adjustments.
66
89
 
@@ -97,7 +120,12 @@ over reimplementing their layout:
97
120
 
98
121
  **Rule:** tune the recipe before replacing the recipe.
99
122
 
100
- ## 4. Reuse the existing vocabulary
123
+ `--gap` is inherited: set on a region, it re-spaces every nested stack, cluster
124
+ and grid too. Set it on the layout that reads it, as `.feature-grid` does above.
125
+ For a region's own rhythm, set the `gap` property instead; see
126
+ [Region rhythm](../layout/stack.md#region-rhythm-goes-on-gap-not-on-gap).
127
+
128
+ ## 5. Reuse the existing vocabulary
101
129
 
102
130
  Before introducing a generic application class, check whether Actual already expresses the idea.
103
131
 
@@ -113,7 +141,7 @@ Small alignment change → existing utility
113
141
 
114
142
  Application classes should primarily describe product-specific concepts, not recreate generic framework utilities.
115
143
 
116
- ## 5. Use structural component markup
144
+ ## 6. Use structural component markup
117
145
 
118
146
  When a component exposes meaningful structure, use it.
119
147
 
@@ -140,7 +168,44 @@ Connection restored.
140
168
 
141
169
  Persistent state such as metrics, availability, queue size or account information belongs in normal page content.
142
170
 
143
- ## 6. Keep application CSS for application identity
171
+ ## 7. Style state through its attribute
172
+
173
+ Use the native attribute or the ARIA state the markup already needs, then
174
+ `data-*` for state with no equivalent. Classes stay for presentation.
175
+
176
+ ```html
177
+ <a class="nav-link" aria-current="page">Settings</a>
178
+ <button class="btn primary" disabled>Save</button>
179
+ ```
180
+
181
+ ```css
182
+ .checkout-form[aria-busy="true"] { … }
183
+ ```
184
+
185
+ Not `.nav-link.active` or `.is-disabled`: a state class duplicates the attribute
186
+ and drifts from it. Two runtime markers are off limits too:
187
+
188
+ - `data-enhance` turns behavior on; it is never a styling hook.
189
+ - `.is-*` classes (`.is-open`, `.is-static`) are runtime internals: neither set
190
+ nor select them.
191
+
192
+ ## 8. Bring third-party widgets onto the tokens
193
+
194
+ A date picker, rich text editor or data grid can come from a dedicated package.
195
+ Map its public visual hooks to Actual tokens instead of restyling it:
196
+
197
+ ```css
198
+ .third-party-picker {
199
+ --picker-bg: var(--surface-raised);
200
+ --picker-border: var(--border);
201
+ --picker-text: var(--text);
202
+ --picker-focus: var(--focus);
203
+ }
204
+ ```
205
+
206
+ The package keeps its behavior; the theme reaches it through the tokens.
207
+
208
+ ## 9. Keep application CSS for application identity
144
209
 
145
210
  Custom CSS is expected for things such as:
146
211
 
@@ -166,7 +231,7 @@ A better target is:
166
231
 
167
232
  > No duplicated framework behavior and no palette literals outside the token layer.
168
233
 
169
- ## 7. Before adding a CSS rule
234
+ ## 10. Before adding a CSS rule
170
235
 
171
236
  Ask, in this order:
172
237
 
@@ -175,9 +240,10 @@ Ask, in this order:
175
240
  3. Can a public hook configure it?
176
241
  4. Does an existing utility express the adjustment?
177
242
  5. Can the color be derived from a theme token?
178
- 6. Is this genuinely application-specific CSS?
243
+ 6. Does an attribute already name the state?
244
+ 7. Is this genuinely application-specific CSS?
179
245
 
180
- If the answer reaches step 6, write the CSS.
246
+ If the answer reaches step 7, write the CSS.
181
247
 
182
248
  That is the intended integration model:
183
249
 
@@ -22,7 +22,8 @@ cascade layers, import order, or a project-owned build-time prefix transform.
22
22
  The distinction is structural, not stylistic: presentation lives in classes,
23
23
  JavaScript behavior opts in through `data-enhance` tokens, semantics come from
24
24
  ARIA and native HTML, and per-widget configuration uses self-describing
25
- `data-*` attributes.
25
+ `data-*` attributes. [Philosophy](../foundations/philosophy.md) lays out the
26
+ layers and the order to follow before adding CSS.
26
27
 
27
28
  ## Install
28
29
 
@@ -52,7 +53,7 @@ optimizing — the bare `actual-css` entrypoint and `actual-css/full` both resol
52
53
  to the complete compiled framework:
53
54
 
54
55
  ```html
55
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/actual-css@0.12/dist/actual.full.min.css">
56
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/actual-css@0.14/dist/actual.full.min.css">
56
57
  ```
57
58
 
58
59
  The package does not maintain separate partial bundles. Modular entrypoints map
@@ -99,7 +99,7 @@ cells, so they read as a list:
99
99
  base and extra utilities
100
100
 
101
101
  `.form-actions` composes with `.cluster`: a build that takes forms without the
102
- layout family imports `actual-css/css/layout/cluster` before the forms.
102
+ layout family also imports `actual-css/css/layout/cluster`, in any order.
103
103
 
104
104
  Individual modules can be imported through their domain path:
105
105
 
@@ -63,11 +63,30 @@ handle, and cleanup happens per element when an element leaves the DOM.
63
63
 
64
64
  ## The full runtime
65
65
 
66
- Loading `dist/actual.full.js` as a plain `<script>` (no `type="module"`, no
67
- bundler, no `import`) gets the full runtime. Static HTML that needs to trigger
68
- something from its own inline `<script>` dispatches the module's public event
69
- instead of calling into a global — `actual:status` for the status bar, for
70
- example:
66
+ Import `actual-css/js/full` to enable the full runtime:
67
+
68
+ ```js
69
+ import "actual-css/js/full";
70
+ ```
71
+
72
+ The same entrypoint exposes the public enhancement API from the same runtime
73
+ instance, for adopters that extend the runtime they already run:
74
+
75
+ ```js
76
+ import { applyEnhancement, enhance, registerEnhancement } from "actual-css/js/full";
77
+
78
+ registerEnhancement("autosubmit", (form) => {
79
+ // ...
80
+ });
81
+ ```
82
+
83
+ For anything beyond that extension API, keep using `actual-css/js/*`.
84
+
85
+ The same entrypoint produces the `dist/actual.full.js` standalone for a
86
+ classic `<script>`, where no global API is exposed. Static HTML that needs to
87
+ trigger something from its own inline `<script>` dispatches the module's
88
+ public event instead of calling into a global — `actual:status` for the
89
+ status bar, for example:
71
90
 
72
91
  ```html
73
92
  <script src="actual.full.js"></script>
@@ -110,8 +129,7 @@ needed.
110
129
  The JS is designed to run on its own, without Actual's stylesheet.
111
130
 
112
131
  - **`data-enhance` tokens** register named behaviors. The runtime discovers
113
- components through `data-enhance="tabs"`, `data-enhance="flyout"`,
114
- `data-enhance="range"`, `data-enhance="scrollspy"`, and
132
+ components through `data-enhance="tabs"`, `data-enhance="flyout"`, `data-enhance="range"`, `data-enhance="scrollspy"`, and
115
133
  `data-enhance="validation"` — no Actual presentation class needed.
116
134
  Self-describing attributes (`data-mask`, `data-tooltip`,
117
135
  `data-context-menu`) stay framework-neutral by construction.