actual-css 0.3.0 → 0.3.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.
@@ -15,4 +15,41 @@ Vertical flow with consistent spacing, ideal for forms, card content, and conten
15
15
  </section>
16
16
  ```
17
17
 
18
- Use stack for forms, card content, modal bodies, side panels, and documentation blocks.
18
+ Use stack for forms, card content, modal bodies, side panels, and documentation blocks.
19
+
20
+ Like `.cluster`, stack owns its children's block-axis spacing: direct-child
21
+ block margins are reset, so vertical rhythm always comes from `--gap`. Inline
22
+ margins stay free — for example `margin-inline: auto` on a child centers it
23
+ without fighting the primitive.
24
+
25
+ ## Grouping items tightly
26
+
27
+ Stack spacing is a `--gap` between every direct child; the primitive cannot
28
+ tighten two specific children by itself. To group a few items into one unit
29
+ (a title and its description), nest a `.stack gap-none`:
30
+
31
+ ```html demo
32
+ <div class="stack">
33
+ <div class="stack gap-none">
34
+ <strong>Ada Meridian</strong>
35
+ <span class="muted">Senior designer working on design systems.</span>
36
+ </div>
37
+ <span class="badge success soft">Active</span>
38
+ </div>
39
+ ```
40
+
41
+ The outer stack keeps its rhythm between groups; the inner `gap-none` unit owns
42
+ the tight pairing.
43
+
44
+ For a one-off adjacency — pull a single child closer to its neighbour without
45
+ adding a wrapper — the escape hatch is app CSS. `gap` and `margin-block` are
46
+ additive, so a negative block margin on the child cancels the following gap:
47
+
48
+ ```css
49
+ .stack > .profile-row {
50
+ margin-block-end: calc(var(--gap) * -1);
51
+ }
52
+ ```
53
+
54
+ The stack resets child block margins to zero, so this re-adds a margin on that
55
+ one child only; the rest of the stack keeps its rhythm.
@@ -28,4 +28,5 @@ The overline exists because categories, kicker labels, and section markers recur
28
28
 
29
29
  ## CSS hooks
30
30
 
31
- - `--prose-measure` — the readable measure `.measure` caps at; defaults to `70ch`.
31
+ - `--prose-measure` — the readable measure `.measure` caps at; defaults to `70ch`.
32
+ - `--overline-radius` — the pill chip's corner radius; defaults to `--radius-full`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "actual-css",
3
- "version": "0.3.0",
3
+ "version": "0.3.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
6
  "packageManager": "bun@1.3.14",
@@ -101,6 +101,7 @@
101
101
  "check:links": "bun scripts/check-doc-links.js",
102
102
  "check:templates": "bun scripts/check-templates.js",
103
103
  "shot:forced": "bun scripts/forced-colors-shot.js",
104
+ "shot:multi": "bun scripts/multi-shot.js",
104
105
  "shot:page": "bun scripts/page-shot.js",
105
106
  "probe": "bun scripts/probe.js",
106
107
  "check:sync": "bun scripts/check-sync.js",
@@ -3,10 +3,13 @@
3
3
  @import "./theme.css";
4
4
  @import "./base.css";
5
5
  @import "./intents.css";
6
+ /* Prose before layout: layout primitives (.stack, .cluster) reset child
7
+ margins and must win over .prose's flow margins at equal specificity —
8
+ source order is the tie-breaker, so prose loads first. */
9
+ @import "./prose.css";
6
10
  @import "./layout.css";
7
11
  @import "./grid.css";
8
12
  @import "./variants.css";
9
- @import "./prose.css";
10
13
  @import "./components/index.css";
11
14
  @import "./forms/index.css";
12
15
  @import "./utilities.css";
@@ -15,8 +15,12 @@
15
15
  * --alert-radius
16
16
  * --alert-icon-size
17
17
  * --alert-dismiss-size
18
+ * --alert-border-inline-start-color
19
+ * --alert-border-inline-start-width
18
20
  *
19
21
  * Internal:
22
+ * --alert-default-bg
23
+ * --alert-default-border
20
24
  * --alert-bg
21
25
  * --alert-fg
22
26
  * --alert-border
@@ -37,9 +41,11 @@
37
41
  --alert-pad-inline: var(--space-40);
38
42
  --alert-font-size: var(--font-size-md);
39
43
  --alert-icon-size: 1.25rem;
40
- --alert-bg: var(--ui-bg, var(--surface-subtle));
44
+ --alert-default-bg: var(--surface-subtle);
45
+ --alert-default-border: var(--border);
46
+ --alert-bg: var(--ui-bg, var(--alert-default-bg));
41
47
  --alert-fg: var(--ui-fg, var(--intent, var(--text)));
42
- --alert-border: var(--ui-border, var(--border));
48
+ --alert-border: var(--ui-border, var(--alert-default-border));
43
49
 
44
50
  padding-block: var(--alert-pad-block);
45
51
  padding-inline: var(--alert-pad-inline);
@@ -64,13 +70,17 @@
64
70
  margin-block-end: 0;
65
71
  }
66
72
 
67
- /* Alerts are soft by default. .solid and .outline need no local rules — the
68
- shared variant plumbing in variants.css sets --ui-* that alert consumes. */
73
+ /* Alerts are soft by default. The recipe writes local defaults rather than
74
+ --ui-* so an explicit shared treatment always wins without exclusions. */
69
75
  @supports (color: color-mix(in oklch, red, white)) {
70
- .alert:where(:not(.solid, .outline, .callout)) {
76
+ .alert {
71
77
  /* @sync soft-recipe */
72
- --ui-bg: color-mix(in oklch, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
73
- --ui-border: color-mix(
78
+ --alert-default-bg: color-mix(
79
+ in oklch,
80
+ var(--surface) var(--soft-bg-mix),
81
+ var(--intent, var(--neutral))
82
+ );
83
+ --alert-default-border: color-mix(
74
84
  in oklch,
75
85
  var(--surface) var(--soft-border-mix),
76
86
  var(--intent, var(--neutral))
@@ -13,6 +13,10 @@
13
13
  * --badge-radius
14
14
  *
15
15
  * Internal:
16
+ * --badge-default-bg
17
+ * --badge-default-fg
18
+ * --badge-default-border
19
+ * --badge-default-hover-bg
16
20
  * --badge-bg
17
21
  * --badge-fg
18
22
  * --badge-border
@@ -33,9 +37,13 @@
33
37
  --badge-pad-x: 0.55em;
34
38
  --badge-font-size: 0.8125rem;
35
39
  --badge-dot-size: 0.5rem;
36
- --badge-bg: var(--ui-bg, var(--surface-subtle));
37
- --badge-fg: var(--ui-fg, var(--intent, var(--text)));
38
- --badge-border: var(--ui-border, var(--border));
40
+ --badge-default-bg: var(--surface-subtle);
41
+ --badge-default-fg: var(--intent, var(--text));
42
+ --badge-default-border: var(--border);
43
+ --badge-default-hover-bg: var(--badge-bg);
44
+ --badge-bg: var(--ui-bg, var(--badge-default-bg));
45
+ --badge-fg: var(--ui-fg, var(--badge-default-fg));
46
+ --badge-border: var(--ui-border, var(--badge-default-border));
39
47
  --badge-radius: var(--radius-full);
40
48
 
41
49
  display: inline-flex;
@@ -62,18 +70,17 @@
62
70
  box-shadow var(--duration);
63
71
  }
64
72
 
65
- /* Soft by default — duplicated here with @sync markers for check-sync.js
66
- to guard, the same way alert.css guards its own soft recipe. */
73
+ /* Soft by default. Local defaults remain behind --ui-* in the resolution
74
+ chain, so explicit shared treatments compose without selector exclusions. */
67
75
  @supports (color: color-mix(in oklch, red, white)) {
68
- .badge:where(:not(.solid, .outline)) {
76
+ .badge {
69
77
  /* @sync soft-recipe */
70
- --badge-bg: color-mix(
78
+ --badge-default-bg: color-mix(
71
79
  in oklch,
72
80
  var(--surface) var(--soft-bg-mix),
73
81
  var(--intent, var(--neutral))
74
82
  );
75
- --badge-fg: var(--intent, var(--text));
76
- --badge-border: color-mix(
83
+ --badge-default-border: color-mix(
77
84
  in oklch,
78
85
  var(--surface) var(--soft-border-mix),
79
86
  var(--intent, var(--neutral))
@@ -119,7 +126,7 @@
119
126
  /* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
120
127
  Declared outside :hover like button.css for the same reason. */
121
128
  :is(a.badge, button.badge).outline {
122
- --ui-hover-bg: var(--surface-subtle);
129
+ --ui-hover-bg: transparent;
123
130
  }
124
131
 
125
132
  @supports (color: color-mix(in oklch, red, white)) {
@@ -128,7 +135,7 @@
128
135
  }
129
136
 
130
137
  :is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
131
- --ui-hover-bg: color-mix(
138
+ --badge-default-hover-bg: color-mix(
132
139
  in oklch,
133
140
  var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
134
141
  var(--intent, var(--neutral))
@@ -136,12 +143,12 @@
136
143
  }
137
144
 
138
145
  :is(a.badge, button.badge).outline {
139
- --ui-hover-bg: color-mix(in oklch, var(--surface-subtle) 92%, var(--intent, var(--neutral)));
146
+ --ui-hover-bg: color-mix(in oklch, currentColor 10%, transparent);
140
147
  }
141
148
  }
142
149
 
143
150
  :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"])):hover {
144
- background: var(--ui-hover-bg, var(--badge-bg));
151
+ background: var(--ui-hover-bg, var(--badge-default-hover-bg));
145
152
  }
146
153
 
147
154
  :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"], .outline)):hover {
@@ -18,11 +18,16 @@
18
18
  * --btn-focus-color
19
19
  * --btn-gap
20
20
  * --btn-hover-overlay
21
+ * --btn-default-bg
22
+ * --btn-default-fg
23
+ * --btn-default-border
24
+ * --btn-border-color
21
25
  *
22
26
  * Internal:
23
27
  * --btn-bg
24
28
  * --btn-fg
25
29
  * --btn-border
30
+ * --btn-default-hover-bg
26
31
  * --btn-focus-ring-color
27
32
  * --btn-border-block-end-color
28
33
  * --btn-border-block-end-width
@@ -47,6 +52,7 @@
47
52
  --btn-bg: var(--ui-bg, var(--btn-default-bg, var(--intent, var(--neutral))));
48
53
  --btn-fg: var(--ui-fg, var(--btn-default-fg, var(--intent-fg, var(--neutral-fg))));
49
54
  --btn-border: var(--ui-border, var(--btn-default-border, transparent));
55
+ --btn-default-hover-bg: var(--btn-bg);
50
56
  --btn-radius: var(--radius);
51
57
  --btn-focus-color: var(--intent, var(--focus));
52
58
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
@@ -99,7 +105,7 @@
99
105
  fallbacks make .btn solid by default; .ghost/.link are .btn-only variants. */
100
106
  .btn.ghost {
101
107
  --ui-bg: transparent;
102
- --ui-fg: var(--intent, var(--text));
108
+ --ui-fg: var(--intent, currentColor);
103
109
  --ui-border: transparent;
104
110
  }
105
111
 
@@ -117,12 +123,12 @@
117
123
  Declared outside :hover: the variables resolve only when the hover rule
118
124
  reads them, so the values are inert until interaction. */
119
125
  .btn:is(.outline, .ghost) {
120
- --ui-hover-bg: var(--surface-subtle);
126
+ --ui-hover-bg: transparent;
121
127
  }
122
128
 
123
129
  @supports (color: color-mix(in oklch, red, white)) {
124
- .btn:not(:where(.soft, .outline, .ghost, .link)) {
125
- --ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
130
+ .btn {
131
+ --btn-default-hover-bg: color-mix(in oklch, var(--btn-bg) 88%, black);
126
132
  }
127
133
 
128
134
  .btn.soft {
@@ -133,19 +139,16 @@
133
139
  );
134
140
  }
135
141
 
136
- .btn.outline {
137
- --ui-hover-bg: color-mix(in oklch, var(--surface-subtle) 92%, var(--intent, var(--neutral)));
138
- }
139
-
142
+ .btn.outline,
140
143
  .btn.ghost {
141
- --ui-hover-bg: color-mix(in oklch, var(--intent, var(--text)) 8%, transparent);
144
+ --ui-hover-bg: color-mix(in oklch, currentColor 10%, transparent);
142
145
  }
143
146
  }
144
147
 
145
148
  /* The .soft/.outline/.ghost hover recipe (--ui-hover-bg) is set locally above;
146
149
  the overlay darkening for solid/soft is .btn-specific. */
147
150
  .btn:not(:where(:disabled, [aria-disabled="true"])):hover {
148
- background: var(--ui-hover-bg, var(--btn-bg));
151
+ background: var(--ui-hover-bg, var(--btn-default-hover-bg));
149
152
  box-shadow: inset 0 0 0 999px var(--btn-hover-overlay);
150
153
  }
151
154
 
@@ -155,7 +158,7 @@
155
158
 
156
159
  .btn.link {
157
160
  --ui-bg: transparent;
158
- --ui-fg: var(--intent, var(--text));
161
+ --ui-fg: var(--intent, currentColor);
159
162
  --ui-border: transparent;
160
163
  --btn-radius: var(--radius-sm);
161
164
  --btn-border-block-end-color: transparent;
@@ -163,6 +166,8 @@
163
166
 
164
167
  min-block-size: auto;
165
168
  padding-inline: 0;
169
+ inline-size: fit-content;
170
+ max-inline-size: 100%;
166
171
  border-color: transparent;
167
172
  text-decoration: underline;
168
173
  text-underline-offset: 0.18em;
@@ -173,7 +178,6 @@
173
178
 
174
179
  .btn.link:not(:where(:disabled, [aria-disabled="true"])):hover {
175
180
  background: transparent;
176
- color: var(--intent, var(--text));
177
181
  text-decoration-thickness: 0.12em;
178
182
  }
179
183
 
@@ -15,6 +15,8 @@
15
15
  * edge children inherit the card corners. .bleed must be a direct child.
16
16
  *
17
17
  * Internal:
18
+ * --card-bg
19
+ * --card-fg
18
20
  * --heading
19
21
  * --busy-overlay-bg
20
22
  */
@@ -30,11 +32,17 @@
30
32
  --card-pad: var(--space-40);
31
33
  --card-radius: var(--radius-lg);
32
34
  --card-max-inline-size: none;
35
+ --card-bg: var(--ui-bg, var(--surface-raised));
36
+ --card-fg: var(--ui-fg, var(--text));
37
+
38
+ /* Keep contextual descendants tied to the surface this card actually owns. */
39
+ --heading: var(--card-fg);
40
+ --busy-overlay-bg: var(--card-bg);
33
41
 
34
42
  max-inline-size: var(--card-max-inline-size);
35
43
  padding: var(--card-pad);
36
- background: var(--ui-bg, var(--surface-raised));
37
- color: var(--ui-fg, var(--text));
44
+ background: var(--card-bg);
45
+ color: var(--card-fg);
38
46
  border: var(--border-width) solid var(--ui-border, var(--border));
39
47
  border-radius: var(--card-radius);
40
48
  }
@@ -8,6 +8,14 @@
8
8
  * Internal:
9
9
  * --gap (local value for the shared gap token)
10
10
  */
11
+ /* Intent boundary — inherited intents do not tint this component.
12
+ Local intent classes still win because :where() has zero specificity. */
13
+ :where(.navbar) {
14
+ --ui-bg: initial;
15
+ --ui-fg: initial;
16
+ --ui-border: initial;
17
+ }
18
+
11
19
  .navbar {
12
20
  display: flex;
13
21
  flex-wrap: wrap;
@@ -16,17 +24,17 @@
16
24
  gap: var(--gap);
17
25
  padding-block: var(--space-30);
18
26
  padding-inline: var(--space-40);
19
- border: var(--border-width) solid var(--border);
27
+ border: var(--border-width) solid var(--ui-border, var(--border));
20
28
  border-radius: var(--radius-lg);
21
- background: var(--surface-raised);
22
- color: var(--text);
29
+ background: var(--ui-bg, var(--surface-raised));
30
+ color: var(--ui-fg, var(--text));
23
31
  }
24
32
 
25
33
  .navbar-brand {
26
34
  display: inline-flex;
27
35
  align-items: center;
28
36
  gap: var(--space-20);
29
- color: var(--text);
37
+ color: var(--ui-fg, var(--text));
30
38
  font-weight: var(--font-weight-bold);
31
39
  text-decoration: none;
32
40
  }
@@ -51,7 +59,7 @@
51
59
  min-block-size: var(--control-size-sm);
52
60
  padding-inline: var(--space-20);
53
61
  border-radius: var(--radius);
54
- color: var(--text-muted);
62
+ color: var(--ui-fg, var(--text-muted));
55
63
  font-weight: var(--font-weight-medium);
56
64
  text-decoration: none;
57
65
  }
@@ -4,6 +4,9 @@
4
4
  * Base form is unstyled chrome that takes its surrounding color. Add .pill for
5
5
  * the bordered rounded shape, and combine with an intent class (.primary,
6
6
  * .success, etc.) to tint the text, border, and background.
7
+ *
8
+ * Public hooks:
9
+ * --overline-radius
7
10
  */
8
11
  /* Intent boundary — inherited intents do not tint this component.
9
12
  Local intent classes still win because :where() has zero specificity. */
@@ -12,7 +15,6 @@
12
15
  }
13
16
 
14
17
  .overline {
15
- align-self: flex-start;
16
18
  color: var(--text-muted);
17
19
  font-size: var(--font-size-sm);
18
20
  font-weight: var(--font-weight-medium);
@@ -21,14 +23,18 @@
21
23
  }
22
24
 
23
25
  .overline.pill {
26
+ --overline-radius: var(--radius-full);
27
+
24
28
  display: inline-flex;
25
29
  align-items: center;
26
30
  gap: 0.5em;
27
31
  padding-inline: 0.6em;
28
32
  padding-block: 0.25em;
33
+ inline-size: fit-content;
34
+ max-inline-size: 100%;
29
35
  color: var(--intent, var(--text-muted));
30
36
  border: var(--border-width) solid var(--border);
31
- border-radius: var(--radius-full);
37
+ border-radius: var(--overline-radius);
32
38
  background: var(--surface);
33
39
  }
34
40
 
@@ -20,6 +20,8 @@
20
20
  * --control-bg
21
21
  * --control-fg
22
22
  * --control-cursor
23
+ * --control-border-block-end-color
24
+ * --control-border-block-end-width
23
25
  */
24
26
  .input,
25
27
  .textarea,
package/src/css/grid.css CHANGED
@@ -5,6 +5,19 @@
5
5
  * --grid-min
6
6
  * --grid-columns
7
7
  */
8
+
9
+ /* --grid-columns replaces the intrinsic auto-fit template: once set, the
10
+ grid no longer collapses on its own — the author owns narrow-container
11
+ collapse. Prefer .sidebar-layout inside .container-query for content +
12
+ aside layouts; reserve --grid-columns for genuinely fixed or asymmetric
13
+ templates. */
14
+
15
+ /* Recipe:
16
+ - N equal cards that collapse when narrow -> .grid (auto-fit, tune
17
+ --grid-min) or .grid-2/3/4/6 inside .container-query
18
+ - content + aside -> .sidebar-layout inside .container-query
19
+ - fixed or asymmetric columns -> --grid-columns with an explicit collapse
20
+ rule of your own */
8
21
  .grid {
9
22
  display: grid;
10
23
  gap: var(--gap);
@@ -13,13 +13,26 @@
13
13
  * --sidebar-layout-size
14
14
  *
15
15
  * Child contracts:
16
- * .stack owns vertical rhythm: direct-child margins are reset, spacing comes
17
- * from --gap.
16
+ * .stack owns vertical rhythm: direct-child block-axis margins are reset,
17
+ * spacing comes from --gap, inline margins stay free.
18
18
  * .cluster owns inline rhythm: direct-child margins are reset, spacing comes
19
19
  * from --gap.
20
20
  * .frame sizes direct media children (img/video/iframe/canvas/svg) to fill the
21
21
  * frame.
22
22
  */
23
+
24
+ /* Responsive recipes:
25
+ - avatar/icon + flexible content -> .media
26
+ - inline items that wrap when space runs out -> .cluster (keep wrapping)
27
+ - content + aside -> .sidebar-layout inside .container-query
28
+ - row that becomes a column when constrained -> .switcher (optional module)
29
+
30
+ Anti-patterns:
31
+ - viewport breakpoints for ordinary composition
32
+ - fixed grid templates (--grid-columns) where .grid/.sidebar-layout express
33
+ the layout
34
+ - nowrap on page-level compositions; prefer wrapping and container-aware
35
+ primitives */
23
36
  .container-query {
24
37
  container-type: inline-size;
25
38
  }
@@ -30,8 +43,10 @@
30
43
  gap: var(--gap);
31
44
  }
32
45
 
46
+ /* .stack owns the block axis only: block margins reset, inline margins stay
47
+ free (self-centering children via margin-inline: auto keep working). */
33
48
  .stack > * {
34
- margin: 0;
49
+ margin-block: 0;
35
50
  }
36
51
 
37
52
  .cluster {
@@ -1,4 +1,8 @@
1
- /* Opt-in theme bundle. Importing this file pulls in every theme below, each
1
+ /*
2
+ * Reference theme presets. Shipped for inspection and copying; not public
3
+ * package entry points (package.json blocks ./css/themes exports).
4
+ *
5
+ * Opt-in theme bundle. Importing this file pulls in every theme below, each
2
6
  * gated by [data-theme="…"]. The light baseline is :root in src/css/theme.css —
3
7
  * there is no light.css here.
4
8
  *
@@ -2,14 +2,18 @@
2
2
  * Cross-cutting variant token mapping.
3
3
  *
4
4
  * Variant scope: .solid/.soft/.outline set --ui-* for components that opt into
5
- * shared variants. .inverted is a surface modifier, not a variant: it also
6
- * paints the element directly (background/color/border-color) so it works on
7
- * any block, competing with component surface treatments (.card.subtle).
5
+ * shared variants. .inverted is a surface modifier, not a variant: it paints
6
+ * the element directly (background/color/border-color) so it works on any bare
7
+ * block, and remaps the shared --ui-* tokens so participating components
8
+ * (.card, .navbar, .alert, .badge) invert through their own rules. Loaded
9
+ * before components, so a component with its own explicit surface (e.g.
10
+ * .accordion) naturally keeps that surface — .inverted never forces a
11
+ * non-participant.
8
12
  * Component-specific defaults (.btn solid-by-default, .badge
9
13
  * soft-by-default, .alert soft-by-default) live with their components:
10
14
  * components/button.css — .btn default solid + .btn.ghost + .btn.link
11
- * components/badge.css — .badge default soft
12
- * components/alert.css — .alert default soft (consumes --ui-* directly)
15
+ * components/badge.css — .badge default soft behind explicit --ui-*
16
+ * components/alert.css — .alert default soft behind explicit --ui-*
13
17
  *
14
18
  * .sm/.lg set shared density tokens: spacing rhythm and geometry only.
15
19
  * Density never changes typography — controls keep their font-size and
@@ -37,18 +41,23 @@
37
41
 
38
42
  .outline {
39
43
  --ui-bg: transparent;
40
- --ui-fg: var(--intent, var(--text));
41
- --ui-border: var(--intent, var(--border));
44
+ --ui-fg: var(--intent, currentColor);
45
+ --ui-border: var(--intent, currentColor);
42
46
  }
43
47
 
44
- /* Inverse surface treatment. Components (e.g. .card) compose through --ui-*;
45
- the direct background/color/border-color also make .inverted useful on a
46
- generic element. --heading is set so global headings (var(--heading, inherit))
47
- do not inherit an outside heading color onto the dark surface. */
48
+ /* Inverse surface treatment. Paints the element directly (so it works on any
49
+ bare block) and remaps the shared --ui-* tokens so opt-in components
50
+ (.card, .navbar) invert through their own rules. Because this loads before
51
+ component base surfaces, components with their own explicit surface
52
+ (e.g. .accordion) keep it — participation is opt-in, never forced. --heading
53
+ is set so global headings (var(--heading, inherit)) do not inherit an outside
54
+ heading color onto the dark surface; surface-owning descendants derive
55
+ their contextual aliases from their own local surface. */
48
56
  .inverted {
49
57
  --ui-bg: var(--surface-solid);
50
58
  --ui-fg: var(--surface);
51
59
  --ui-border: transparent;
60
+ --ui-hover-bg: var(--surface-solid);
52
61
 
53
62
  --heading: var(--surface);
54
63
  --busy-overlay-bg: var(--surface-solid);
@@ -84,6 +93,10 @@
84
93
  );
85
94
  /* @sync soft-recipe */
86
95
  }
96
+
97
+ .inverted {
98
+ --ui-hover-bg: color-mix(in oklch, var(--surface-solid) 88%, black);
99
+ }
87
100
  }
88
101
 
89
102
  /* Interactive hover recipes live in the components that consume them:
package/src/js/surface.js CHANGED
@@ -274,7 +274,9 @@ export function retainSurface(panel) {
274
274
  released = true;
275
275
  entry.count--;
276
276
  if (entry.count <= 0) {
277
- disconnectSurface(panel, { restore: false });
277
+ // A released retainer may leave a mounted panel whose original parent
278
+ // either still needs it back or has disappeared along with the trigger.
279
+ disconnectSurface(panel);
278
280
  surfaceRetainers.delete(panel);
279
281
  }
280
282
  };