actual-css 0.13.0 → 0.14.1

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 (66) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +8 -8
  3. package/components.json +116 -10
  4. package/dist/actual.full.css +652 -77
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/widget-primitives.md +4 -0
  8. package/docs/navigation.json +2 -0
  9. package/docs/pages/components/alert.md +12 -2
  10. package/docs/pages/components/button.md +3 -2
  11. package/docs/pages/components/list.md +6 -3
  12. package/docs/pages/components/segmented.md +83 -0
  13. package/docs/pages/components/steps.md +4 -2
  14. package/docs/pages/enhancements/flyout.md +9 -3
  15. package/docs/pages/enhancements/overview.md +2 -1
  16. package/docs/pages/enhancements/tabs.md +7 -2
  17. package/docs/pages/enhancements/tooltip.md +3 -1
  18. package/docs/pages/examples/overview.md +5 -3
  19. package/docs/pages/forms/color-swatches.md +131 -0
  20. package/docs/pages/forms/combobox.md +2 -2
  21. package/docs/pages/forms/controls.md +6 -3
  22. package/docs/pages/forms/joined-controls.md +6 -2
  23. package/docs/pages/forms/overview.md +1 -0
  24. package/docs/pages/forms/switch.md +4 -2
  25. package/docs/pages/foundations/theming.md +1 -1
  26. package/docs/pages/foundations/tokens.md +54 -23
  27. package/docs/pages/guides/design-tools.md +13 -7
  28. package/docs/pages/guides/introduction.md +1 -1
  29. package/docs/pages/layout/scroll-snap.md +4 -2
  30. package/package.json +5 -4
  31. package/reserved-classes.json +3 -0
  32. package/src/cli/actual-css.js +3 -3
  33. package/src/css/components/alert.css +65 -14
  34. package/src/css/components/app-nav.css +17 -9
  35. package/src/css/components/badge.css +23 -9
  36. package/src/css/components/button.css +17 -2
  37. package/src/css/components/close.css +6 -3
  38. package/src/css/components/index.css +1 -0
  39. package/src/css/components/list.css +18 -3
  40. package/src/css/components/navbar.css +5 -2
  41. package/src/css/components/segmented.css +186 -0
  42. package/src/css/components/steps.css +6 -3
  43. package/src/css/components/tab.css +1 -1
  44. package/src/css/core/focus.css +3 -0
  45. package/src/css/core/tokens.css +20 -4
  46. package/src/css/core/variants.css +16 -5
  47. package/src/css/forms/color-swatch.css +163 -0
  48. package/src/css/forms/form-actions.css +9 -0
  49. package/src/css/forms/index.css +1 -0
  50. package/src/css/forms/native.css +50 -20
  51. package/src/css/forms/switch.css +5 -2
  52. package/src/css/layout/scroll-snap.css +30 -0
  53. package/src/css/layout/topbar.css +10 -0
  54. package/src/js/enhance.js +14 -0
  55. package/src/js/floating-space.js +26 -0
  56. package/src/js/flyout.js +3 -1
  57. package/src/js/focus.js +16 -1
  58. package/src/js/parse-config.js +5 -2
  59. package/src/js/surface.js +10 -0
  60. package/src/js/tooltip.js +2 -2
  61. package/src/tooling/color.js +14 -1
  62. package/src/tooling/css-bundle.js +0 -5
  63. package/src/tooling/design-components.js +9 -5
  64. package/src/tooling/design-export.js +8 -0
  65. package/src/tooling/design-tokens.js +163 -43
  66. package/src/tooling/theme-contrast.js +151 -22
@@ -151,34 +151,42 @@ Color should reinforce structure and meaning, not carry them alone. Information
151
151
 
152
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.
153
153
 
154
- 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:
155
167
 
156
168
  ```css
157
169
  :root {
158
- --soft-bg-mix: 88%;
159
- --soft-border-mix: 65%;
160
- --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 */
161
172
  --soft-fg-mix: 100%;
162
173
  }
163
174
  ```
164
175
 
165
- `--soft-border-mix` is the same lever for the rim. The `65%` default keeps a
166
- visibly tinted border around every soft surface; pointing it at `--soft-bg-mix`
167
- resolves the rim to the fill, and soft treatments become a tint carrying text
168
- with no contour — the flatter language of a modern component set, from one
169
- 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:
170
179
 
171
180
  ```css
172
- [data-theme="flat"] {
173
- --soft-border-mix: var(--soft-bg-mix);
181
+ [data-theme="contoured"] {
182
+ --soft-border-mix: 65%;
174
183
  }
175
184
  ```
176
185
 
177
- It reaches the default `.badge` and `.alert` plus every explicit `.soft`
178
- (`.btn`, `.card`, `.chat`, `.navbar`, `.app-nav`), and nothing else: a component
179
- on its own surface keeps its `--border` rim. The dial is also
180
- rendering-only — the baseline path below `color-mix()` keeps `var(--border)`, so
181
- 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)`.
182
190
 
183
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.
184
192
 
@@ -193,17 +201,19 @@ hook, and the soft recipe reads it before falling back to the global mix:
193
201
  - `--soft-fg-mix` — the **global fallback derivation**, unchanged.
194
202
 
195
203
  **Soft foregrounds are calibrated against the darkest or closest interactive
196
- surface they are expected to appear on.** For interactive soft treatments the
197
- fill moves toward the intent on hover (`--soft-hover-alpha`), so the binding
198
- surface is the *hovered* one and the resting fill simply carries more margin;
199
- the default palette ships its own `--primary`/`--secondary`/… `-soft-fg` hooks
200
- to keep that pair ≥ 4.5:1 in both color schemes, enforced by
201
- `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:
202
212
  presets and custom `[data-theme]` islands keep the `--soft-fg-mix` derivation
203
213
  unless they declare the hooks themselves (the framework resets them outside
204
214
  the default boundary). A theme that overrides the soft palette is responsible
205
215
  for preserving sufficient text contrast; `bunx --bun actual-css contrast --theme FILE`
206
- measures its resting and hovered pairs.
216
+ measures its resting and hovered pairs, composited on each of those surfaces.
207
217
 
208
218
  A theme whose primary should read more muted than its statuses sets only
209
219
  `--primary-soft-fg`, and every soft `.primary` foreground follows it. Without a
@@ -599,12 +609,33 @@ A minimal recolor theme (illustrative, not a shipped theme):
599
609
 
600
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.
601
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
+
602
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.
603
632
 
604
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.
605
634
 
606
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.
607
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
+
608
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.
609
640
 
610
641
  ## Component mapping
@@ -10,16 +10,22 @@ bunx --bun actual-css design --theme src/theme.css --out design/
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
 
@@ -53,7 +53,7 @@ optimizing — the bare `actual-css` entrypoint and `actual-css/full` both resol
53
53
  to the complete compiled framework:
54
54
 
55
55
  ```html
56
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/actual-css@0.13/dist/actual.full.min.css">
56
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/actual-css@0.14/dist/actual.full.min.css">
57
57
  ```
58
58
 
59
59
  The package does not maintain separate partial bundles. Modular entrypoints map
@@ -40,8 +40,10 @@ Items snap to the inline start edge by default, with `proximity` snapping so a s
40
40
 
41
41
  - `--scroll-snap-item-size` — item `flex` basis; set it for card rails.
42
42
  - `--scroll-snap-gap` — space between items.
43
- - `--scroll-snap-padding` — inline scroll padding, so a snapped item does not sit
44
- flush against the container edge. Not declared; falls back to `--scroll-snap-gap`.
43
+ - `--scroll-snap-padding` — preferred inline scroll inset; defaults to
44
+ `--scroll-snap-gap`. Use a length (`0px` for zero); conforming values stay at
45
+ least large enough for the focus gutter, and the rail's edge items keep that
46
+ gutter even at rest.
45
47
  - `--scroll-snap-align` — item snap alignment. Prefer `data-snap-align="center"`
46
48
  over setting this directly.
47
49
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "actual-css",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Plain CSS component framework with semantic classes, universal variants, small tokens, theme hooks, and progressive enhancements.",
5
5
  "type": "module",
6
- "packageManager": "bun@1.4.2",
6
+ "packageManager": "bun@1.4.3",
7
7
  "license": "MIT",
8
8
  "author": "Thomas <thomas@lekoala.be>",
9
9
  "repository": {
@@ -132,6 +132,7 @@
132
132
  "build:js": "bun scripts/build-js.js",
133
133
  "build:themes": "bun scripts/build-themes.js",
134
134
  "build:size": "bun scripts/build-size.js",
135
+ "build:pages": "bun scripts/pages.js",
135
136
  "export:design": "bun scripts/export-design.js",
136
137
  "check:enhance": "bun run scripts/check-enhance.js",
137
138
  "generate:reserved": "bun scripts/generate-reserved-classes.js",
@@ -157,9 +158,9 @@
157
158
  },
158
159
  "devDependencies": {
159
160
  "@biomejs/biome": "^2.5.15",
160
- "happy-dom": "^20.14.0"
161
+ "happy-dom": "^21.0.1"
161
162
  },
162
163
  "dependencies": {
163
- "@lekoala/floating": "^0.2.0"
164
+ "@lekoala/floating": "^0.3.0"
164
165
  }
165
166
  }
@@ -48,6 +48,7 @@
48
48
  "close",
49
49
  "cluster",
50
50
  "color",
51
+ "color-swatch",
51
52
  "column-gap-context",
52
53
  "column-layout",
53
54
  "compact",
@@ -198,6 +199,8 @@
198
199
  "scroller",
199
200
  "scrollspy",
200
201
  "secondary",
202
+ "segment",
203
+ "segmented",
201
204
  "select",
202
205
  "separated",
203
206
  "shadow-context",
@@ -22,9 +22,9 @@ function usage() {
22
22
  "stands for. Remote imports are kept and hoisted to the top of the bundle.",
23
23
  "Leaves modern CSS syntax untouched; this is a bundler, not a transpiler.",
24
24
  "",
25
- "design: export a theme's tokens for Figma and Penpot, plus Button, Input, Badge,",
26
- "Alert and Card measured in headless Chrome and a Penpot plugin that builds them.",
27
- "Needs Bun (bunx --bun actual-css design ...).",
25
+ "design: export a theme's tokens for Figma, Penpot and a portable DTCG profile,",
26
+ "plus Button, Input, Badge, Alert and Card measured in headless Chrome and a",
27
+ "Penpot plugin that builds them. Needs Bun (bunx --bun actual-css design ...).",
28
28
  "",
29
29
  "contrast: measure the pairs a theme tunes by hand in headless Chrome, in every",
30
30
  "scheme it declares: soft ink on its resting and hovered fill, the focus line on",
@@ -61,6 +61,16 @@
61
61
  font-size: var(--alert-font-size);
62
62
  }
63
63
 
64
+ /* Density steps the block padding only: the inline padding is the text
65
+ column's edge and stays stable. Local to the alert, like .card.compact. */
66
+ .alert.compact {
67
+ --alert-pad-block: var(--space-20);
68
+ }
69
+
70
+ .alert.spacious {
71
+ --alert-pad-block: var(--space-40);
72
+ }
73
+
64
74
  .alert > * {
65
75
  min-inline-size: 0;
66
76
  }
@@ -85,28 +95,40 @@
85
95
  }
86
96
 
87
97
  /* Alerts are soft by default. The recipe writes local defaults rather than
88
- --ui-* so an explicit shared treatment always wins without exclusions. */
98
+ --ui-* so an explicit shared treatment always wins without exclusions. The
99
+ fill is the translucent soft tint (see badge.css), with no rim unless a
100
+ theme sets --soft-border-mix. */
89
101
  @supports (color: color-mix(in oklch, red, white)) {
90
102
  .alert {
91
- /* @sync soft-recipe */
103
+ /* @sync soft-tint */
92
104
  --alert-default-bg: color-mix(
93
105
  in oklab,
94
- var(--surface) var(--soft-bg-mix),
95
- var(--intent, var(--neutral))
106
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
107
+ transparent
96
108
  );
97
109
  --alert-default-border: color-mix(
98
110
  in oklab,
99
- var(--surface) var(--soft-border-mix),
100
- var(--intent, var(--neutral))
111
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
112
+ transparent
101
113
  );
114
+ /* @sync soft-tint */
115
+ /* @sync soft-ink */
102
116
  --alert-default-fg: var(
103
117
  --intent-soft-fg,
104
118
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
105
119
  );
106
- /* @sync soft-recipe */
120
+ /* @sync soft-ink */
107
121
  }
108
122
  }
109
123
 
124
+ /* Soft is the alert's own default: an explicit .soft must not swap the tint
125
+ for the shared opaque .soft recipe, so it defers to the default. */
126
+ .alert.soft {
127
+ --ui-bg: initial;
128
+ --ui-fg: initial;
129
+ --ui-border: initial;
130
+ }
131
+
110
132
  /* Decorative leading icon — use .alert-icon on the icon element. display and
111
133
  vertical-align are the no-:has() layout (Degraded tier: the icon sits inline
112
134
  before the text); the grid anatomy below replaces them. */
@@ -205,7 +227,10 @@
205
227
 
206
228
  /* Admonition — structured static box with a tinted title bar and a body
207
229
  that rests on the page surface. Not collapsible — use .accordion inside
208
- the body if the content needs to be expandable. */
230
+ the body if the content needs to be expandable. The title bar is what a
231
+ shared treatment paints (.solid fills it, .surface/.outline clear it): the
232
+ root's fill never shows behind the opaque title and body, and the title ink
233
+ already follows --ui-fg, so a bar left on the soft tint is unreadable. */
209
234
  .alert.admonition {
210
235
  padding: 0;
211
236
  display: block;
@@ -219,6 +244,7 @@
219
244
  gap: var(--space-20);
220
245
  padding-block: var(--alert-pad-block);
221
246
  padding-inline: var(--alert-pad-inline);
247
+ font-weight: var(--font-weight-semibold);
222
248
  border-start-start-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
223
249
  border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
224
250
  }
@@ -230,18 +256,17 @@
230
256
 
231
257
  @supports (color: color-mix(in oklch, red, white)) {
232
258
  .alert.admonition > .alert-title {
233
- background: color-mix(
234
- in oklab,
235
- var(--surface) var(--soft-bg-mix),
236
- var(--intent, var(--neutral))
259
+ background: var(
260
+ --ui-bg,
261
+ color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)))
237
262
  );
238
263
  }
239
264
  }
240
265
 
241
266
  @supports not (color: color-mix(in oklch, red, white)) {
242
267
  .alert.admonition > .alert-title {
243
- background: var(--intent, var(--neutral));
244
- color: var(--intent-fg, var(--neutral-fg));
268
+ background: var(--ui-bg, var(--intent, var(--neutral)));
269
+ color: var(--ui-fg, var(--intent-fg, var(--neutral-fg)));
245
270
  }
246
271
  }
247
272
 
@@ -252,6 +277,32 @@
252
277
  color: var(--text);
253
278
  }
254
279
 
280
+ /* Solid: the filled title bar already carries the intent, so the frame moves
281
+ to the body and stays neutral. The root border goes because a transparent
282
+ one would show the root's intent fill around the body. */
283
+ .alert.admonition.solid {
284
+ border: 0;
285
+ }
286
+
287
+ .alert.admonition.solid > .alert-body {
288
+ border: var(--border-width) solid var(--border);
289
+ border-block-start: 0;
290
+ border-end-start-radius: var(--alert-radius, var(--radius-lg));
291
+ border-end-end-radius: var(--alert-radius, var(--radius-lg));
292
+ }
293
+
294
+ /* Forced colors drop the title fill, so the bar would lose its edge: the
295
+ frame returns to the root as one uniform system-colored border. */
296
+ @media (forced-colors: active) {
297
+ .alert.admonition.solid {
298
+ border: var(--border-width) solid CanvasText;
299
+ }
300
+
301
+ .alert.admonition.solid > .alert-body {
302
+ border: 0;
303
+ }
304
+ }
305
+
255
306
  .alert.admonition > .alert-body > :first-child {
256
307
  margin-block-start: 0;
257
308
  }
@@ -40,6 +40,7 @@
40
40
  }
41
41
 
42
42
  .app-nav > a {
43
+ position: relative;
43
44
  display: grid;
44
45
  grid-template-rows: auto auto;
45
46
  place-content: center;
@@ -80,18 +81,25 @@
80
81
  }
81
82
 
82
83
  /* Current tile is already a full selected surface. Selection never changes
83
- text metrics: the shared medium weight is kept, no permanent indicator is
84
- added here. */
84
+ text metrics: the shared medium weight is kept. The forced-colors cue is a
85
+ transparent border on a ::before overlay, not on the tile: an element border
86
+ shrinks the content box, and the side layout starts its content at the inline
87
+ edge, so the icon and label shifted by --border-width when a tile became
88
+ current. The overlay costs no layout, yet forced colors still turn its
89
+ transparent border into a system-color frame once --state-selected flattens
90
+ to the bar's own Canvas. No forced-colors repaint: a Highlight fill put
91
+ HighlightText on Chromium's Canvas backplate, which hid the label. The
92
+ positioning context is on every link so it does not move when one becomes
93
+ current or an application places its own decoration. */
85
94
  .app-nav > a[aria-current="page"] {
86
95
  background: var(--state-selected);
87
96
  color: var(--state-selected-fg);
88
97
  }
89
98
 
90
- @media (forced-colors: active) {
91
- /* --state-selected flattens to a UA color that no longer distinguishes the
92
- current tile from the rest of the bar, so paint the system Highlight pair. */
93
- .app-nav > a[aria-current="page"] {
94
- background: Highlight;
95
- color: HighlightText;
96
- }
99
+ .app-nav > a[aria-current="page"]::before {
100
+ content: "";
101
+ position: absolute;
102
+ inset: 0;
103
+ border: var(--border-width) solid transparent;
104
+ border-radius: inherit;
97
105
  }
@@ -111,28 +111,42 @@
111
111
  }
112
112
 
113
113
  /* Soft by default. Local defaults remain behind --ui-* in the resolution
114
- chain, so explicit shared treatments compose without selector exclusions. */
114
+ chain, so explicit shared treatments compose without selector exclusions.
115
+ The fill is a translucent intent tint, so it shows the surface it sits on
116
+ instead of a fixed mix with --surface: a mix stops separating on a subtle
117
+ or raised surface of its own lightness. No rim unless a theme sets
118
+ --soft-border-mix (tokens.css). */
115
119
  @supports (color: color-mix(in oklch, red, white)) {
116
120
  .badge {
117
- /* @sync soft-recipe */
121
+ /* @sync soft-tint */
118
122
  --badge-default-bg: color-mix(
119
123
  in oklab,
120
- var(--surface) var(--soft-bg-mix),
121
- var(--intent, var(--neutral))
124
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
125
+ transparent
122
126
  );
123
127
  --badge-default-border: color-mix(
124
128
  in oklab,
125
- var(--surface) var(--soft-border-mix),
126
- var(--intent, var(--neutral))
129
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
130
+ transparent
127
131
  );
132
+ /* @sync soft-tint */
133
+ /* @sync soft-ink */
128
134
  --badge-default-fg: var(
129
135
  --intent-soft-fg,
130
136
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
131
137
  );
132
- /* @sync soft-recipe */
138
+ /* @sync soft-ink */
133
139
  }
134
140
  }
135
141
 
142
+ /* Soft is the badge's own default: an explicit .soft must not swap the tint
143
+ for the shared opaque .soft recipe, so it defers to the default. */
144
+ .badge.soft {
145
+ --ui-bg: initial;
146
+ --ui-fg: initial;
147
+ --ui-border: initial;
148
+ }
149
+
136
150
  .badge:empty {
137
151
  --badge-size: var(--badge-dot-size);
138
152
  --badge-bg: var(--intent, var(--neutral));
@@ -171,8 +185,8 @@
171
185
  :is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
172
186
  --badge-default-hover-bg: color-mix(
173
187
  in oklab,
174
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
175
- var(--intent, var(--neutral))
188
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
189
+ transparent
176
190
  );
177
191
  }
178
192
 
@@ -133,12 +133,27 @@
133
133
  --ui-hover-bg: transparent;
134
134
  }
135
135
 
136
+ /* A soft button takes the badge and alert tint, not the shared opaque .soft:
137
+ it carries a short label like them, and the translucent fill keeps
138
+ separating on a subtle or raised surface. Ink stays the shared .soft one. */
136
139
  @supports (color: color-mix(in oklch, red, white)) {
137
140
  .btn.soft {
141
+ /* @sync soft-tint */
142
+ --ui-bg: color-mix(
143
+ in oklab,
144
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
145
+ transparent
146
+ );
147
+ --ui-border: color-mix(
148
+ in oklab,
149
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
150
+ transparent
151
+ );
152
+ /* @sync soft-tint */
138
153
  --ui-hover-bg: color-mix(
139
154
  in oklab,
140
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
141
- var(--intent, var(--neutral))
155
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
156
+ transparent
142
157
  );
143
158
  }
144
159
 
@@ -93,10 +93,13 @@
93
93
  }
94
94
 
95
95
  @media (forced-colors: active) {
96
- /* The X is a currentColor mask fill: without this the system remaps the
97
- background to Canvas and the glyph disappears. Kept on the pseudo so the
98
- host keeps its native color and focus adaptation. */
96
+ /* The X is a mask fill: without this the system remaps the background to
97
+ Canvas and the glyph disappears. Kept on the pseudo so the host keeps its
98
+ native color and focus adaptation. The fill is the system color, not
99
+ currentColor: an unadjusted pseudo inherits the authored ink, so a white
100
+ .solid ink painted a white X on Canvas. */
99
101
  .close::before {
100
102
  forced-color-adjust: none;
103
+ background: ButtonText;
101
104
  }
102
105
  }
@@ -40,6 +40,7 @@
40
40
  @import "./breadcrumb.css";
41
41
  @import "./pagination.css";
42
42
  @import "./tab.css";
43
+ @import "./segmented.css";
43
44
  @import "./scrollspy.css";
44
45
 
45
46
  /* Data */
@@ -43,6 +43,9 @@
43
43
  .list > :where(.list-item),
44
44
  .list > li > .list-item {
45
45
  display: flex;
46
+ /* A trailing control that no longer fits beside the content's longest word
47
+ moves to its own line instead of breaking that word. */
48
+ flex-wrap: wrap;
46
49
  /* Centered fits the short rows lists mostly hold. Read through
47
50
  --items-align so .items-start on the row or the list wins without
48
51
  outranking this selector — the opt-out for long supporting text. */
@@ -89,17 +92,29 @@ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
89
92
  border-inline-start: 2px solid var(--state-selected);
90
93
  }
91
94
 
92
- /* The content region is the one that flexes, whichever position it holds. */
95
+ /* The content region is the one that flexes, whichever position it holds.
96
+ Its automatic minimum is its longest word, which is what wraps the row; the
97
+ max size caps that minimum at the row, so a long token breaks rather than
98
+ widening the page. A zero minimum shrank it until words broke mid-way. */
93
99
  .list-item-content {
94
100
  flex: 1 1 0;
95
- min-inline-size: 0;
101
+ max-inline-size: 100%;
96
102
  }
97
103
 
104
+ /* break-word, not anywhere: anywhere also lowers the min-content to a single
105
+ character, and the row would never wrap. */
98
106
  .list-item-title,
99
107
  .list-item-text {
100
108
  display: block;
101
109
  margin: 0;
102
- overflow-wrap: anywhere;
110
+ overflow-wrap: break-word;
111
+ }
112
+
113
+ /* Truncated text gives up its width by design: without containment its
114
+ unbreakable line would become the content's minimum and push the trailing
115
+ region onto a line of its own. */
116
+ .list-item-content .truncate {
117
+ contain: inline-size;
103
118
  }
104
119
 
105
120
  .list-item-text {
@@ -7,6 +7,9 @@
7
7
  * links are a .cluster using .nav-link items. .nav-list is the vertical nav
8
8
  * list shared with .drawer, fed by the shared --gap token.
9
9
  *
10
+ * Public hooks:
11
+ * --state-selected-text (fallback-only: current-link ink, see tokens.css)
12
+ *
10
13
  * Internal:
11
14
  * --gap (local value for the shared gap token)
12
15
  */
@@ -84,7 +87,7 @@
84
87
  shared with the hover rule above. */
85
88
  .nav-link:is([aria-current="page"], [aria-current="location"]) {
86
89
  background: var(--surface-subtle);
87
- color: var(--state-selected);
90
+ color: var(--state-selected-text, var(--state-selected));
88
91
  }
89
92
 
90
93
  /* On a bar with its own --ui-bg/--ui-fg the fill is a tint of the link's own
@@ -105,7 +108,7 @@
105
108
 
106
109
  /* After :hover so a hovered current link keeps the selected accent. */
107
110
  .nav-link:is([aria-current="page"], [aria-current="location"]) {
108
- color: var(--ui-fg, var(--state-selected));
111
+ color: var(--ui-fg, var(--state-selected-text, var(--state-selected)));
109
112
  }
110
113
  }
111
114