actual-css 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+
6
+ ## [0.9.0] - 2026-09-13
7
+
8
+ ### Breaking changes
9
+
10
+ - `--choice-control-offset` is renamed `--choice-control-nudge` and adds to the
11
+ derived first-line offset instead of replacing it — an old value is a whole
12
+ offset and will sit that far too low.
13
+ - `--skeleton-width` is a maximum inline size, not a width: a placeholder fills
14
+ its container and stops there, so it no longer gives every shrink-to-fit
15
+ ancestor a min-content floor. A placeholder in a container sized by its own
16
+ content now collapses instead of forcing that container open.
17
+
18
+ ### Fixed
19
+
20
+ - `.choice` aligns its control on the first line's cap height with the `cap`
21
+ unit, so a checkbox, radio or switch is level with the label text in any
22
+ font — the previous constant was tuned for Segoe UI and sat ~2px low on
23
+ Roboto.
24
+ - `.switch` lays its knob out of flow, so the track's baseline is its own
25
+ border box.
26
+ - `dialog.drawer` is `--viewport-block` tall again, so a mobile browser's
27
+ collapsing URL bar no longer pushes its footer below the fold.
28
+ - `dialog.drawer` scrolls its body region, so the footer stays reachable
29
+ whatever the content length; the rule replaces the `nav`-only one.
30
+ - `.table-wrap` is `position: relative`, so an `.sr-only` header inside a wide
31
+ table no longer escapes the scroller and gives the page a phantom horizontal
32
+ scrollbar on small viewports.
33
+
34
+ ### Documentation and tooling
35
+
36
+ - `tests/browser/mobile-overflow.test.js` asserts that no component scrolls the
37
+ page sideways at 320px and 360px, and that wide content scrolls inside its
38
+ own container.
39
+ - `bun run report:overflow` surveys demo and site pages at phone width and
40
+ bisects each failure down to the element that owns it.
41
+ - `tests/browser/mega-menu.test.js` exercises three wide sibling nav panels —
42
+ takeover, toggle, focus handover, keyboard open, Escape, clamping at both
43
+ viewport edges, and no drawer mutation on a phone.
44
+ - The flyout hooks are documented as a preferred width and a width cap, with
45
+ the cap's `20rem` default stated: a wide panel sets both, like `width` and
46
+ `max-width`.
47
+ - The drawer and scrollable-modal pages state that a scrolling body region is
48
+ keyboard-focusable, takes a focus ring, and can claim a dialog's initial
49
+ focus — put the close control in the `header`, or use `autofocus`.
50
+ - The kitchen-sink, blocks, blog, combobox and select-intents templates no
51
+ longer scroll sideways at phone widths.
52
+ - The alert trailing-action recipe and the dashboard template add `.items-center`:
53
+ a trailing control makes the row taller than a line of text, which left the
54
+ start-aligned `.alert-icon` above the message.
55
+
5
56
  ## [0.8.0] - 2026-09-10
6
57
 
7
58
  Actual CSS 0.8 focuses on a cleaner component contract, more predictable sizing
@@ -2164,7 +2164,7 @@ small {
2164
2164
  * geometry and states. switch.css is imported after this file.
2165
2165
  *
2166
2166
  * Public hooks:
2167
- * --choice-control-offset
2167
+ * --choice-control-nudge
2168
2168
  * --choice-border
2169
2169
  * --choice-bg
2170
2170
  * --choice-mark
@@ -2196,45 +2196,89 @@ small {
2196
2196
  cursor: pointer;
2197
2197
  }
2198
2198
 
2199
- /* Optical alignment: nudge the control down so it sits visually centered with
2200
- the first line of label text, not flush with the top of the line-box. Every
2201
- inline choice runs the same formula — center the control's own block size on
2202
- the first line box, then drop it 0.0625em so it reads level with the
2203
- cap-height rather than the em-box. A checkbox and a switch therefore stay
2204
- level with each other under any line-height or --choice-control-size, which
2205
- a per-control constant cannot do (a taller switch and a shorter checkbox
2206
- need different offsets to land on the same optical line).
2207
-
2208
- The constants are what the formula yields at the default 1.5 line-height:
2209
- 0.25em (4px) for a checkbox, 0.1875em (3px) for the taller switch track.
2210
- They serve browsers without lh units. Multi-line labels still keep the
2211
- control at the top thanks to align-items: flex-start.
2212
-
2213
- --choice-control-offset stays fallback-only rather than declared here, so an
2214
- override placed anywhere up the tree — :root included — reaches the control
2215
- instead of losing to a component-level declaration. */
2199
+ /* Optical alignment: the control reads level with the first line's cap height,
2200
+ not with the top of its line box. Every inline choice runs the same formula
2201
+ over its own block size, so a checkbox and a taller switch stay level with
2202
+ each other under any line-height or --choice-control-size — something a
2203
+ per-control constant cannot do.
2204
+
2205
+ Three tiers, each the best the engine can express:
2206
+
2207
+ - `cap` unit: exact, and the only font-independent answer. Where the cap
2208
+ center sits inside a line box depends on the font's ascent/descent/cap
2209
+ ratios, so no constant can serve both Segoe UI and Roboto — the same rule
2210
+ was a pixel high on Windows and two pixels low on Android. Baseline
2211
+ alignment puts the control's border-box bottom on the text baseline, and
2212
+ one inset drops it by half the difference between its own height and the
2213
+ cap height. Both classed controls synthesize that baseline from their
2214
+ border box because their mark and knob are out of flow.
2215
+ - `lh` only: center the control on the line box, which is within a pixel of
2216
+ the cap center for the usual UI faces.
2217
+ - neither: the constants that formula yields at the default 1.5
2218
+ line-height, 0.1875em for a checkbox and 0.125em for the switch track.
2219
+
2220
+ --choice-control-nudge adds to whichever tier applies, rather than replacing
2221
+ it: the derived value is exact now, so a design has a correction to make,
2222
+ not a formula to restate — and one number cannot stand in for three
2223
+ different references anyway. It stays fallback-only rather than declared
2224
+ here, so an override placed anywhere up the tree — :root included — reaches
2225
+ the control instead of losing to a component-level declaration. */
2216
2226
  .choice > :is(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
2217
2227
  flex: 0 0 auto;
2218
- margin-block-start: var(--choice-control-offset, 0.25em);
2228
+ margin-block-start: calc(0.1875em + var(--choice-control-nudge, 0px));
2219
2229
  }
2220
2230
 
2221
2231
  .choice > .switch {
2222
2232
  flex: 0 0 auto;
2223
- margin-block-start: var(--choice-control-offset, 0.1875em);
2233
+ margin-block-start: calc(0.125em + var(--choice-control-nudge, 0px));
2224
2234
  }
2225
2235
 
2226
2236
  @supports (margin-block-start: 1lh) {
2227
2237
  .choice > :is(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
2228
- margin-block-start: var(
2229
- --choice-control-offset,
2230
- calc((1lh - var(--choice-control-size)) / 2 + 0.0625em)
2238
+ margin-block-start: calc(
2239
+ (1lh - var(--choice-control-size)) /
2240
+ 2 +
2241
+ var(--choice-control-nudge, 0px)
2242
+ );
2243
+ }
2244
+
2245
+ .choice > .switch {
2246
+ margin-block-start: calc(
2247
+ (1lh - var(--switch-block-size)) /
2248
+ 2 +
2249
+ var(--choice-control-nudge, 0px)
2250
+ );
2251
+ }
2252
+ }
2253
+
2254
+ @supports (margin-block-start: 1cap) {
2255
+ .choice {
2256
+ align-items: baseline;
2257
+ }
2258
+
2259
+ /* Baseline alignment owns the block position now; the tier above would only
2260
+ grow the row. An unclassed native input keeps the UA's own baseline, which
2261
+ is already tuned for text — the formula below describes the framework's
2262
+ geometry and would not fit it. */
2263
+ .choice > :is(input[type="checkbox"], input[type="radio"]) {
2264
+ margin-block-start: 0;
2265
+ }
2266
+
2267
+ /* inset-block-start rides the `position: relative` these controls already
2268
+ carry for their mark and knob. */
2269
+ .choice > :is(.check, .radio) {
2270
+ inset-block-start: calc(
2271
+ (var(--choice-control-size) - 1cap) /
2272
+ 2 +
2273
+ var(--choice-control-nudge, 0px)
2231
2274
  );
2232
2275
  }
2233
2276
 
2234
2277
  .choice > .switch {
2235
- margin-block-start: var(
2236
- --choice-control-offset,
2237
- calc((1lh - var(--switch-block-size)) / 2 + 0.0625em)
2278
+ inset-block-start: calc(
2279
+ (var(--switch-block-size) - 1cap) /
2280
+ 2 +
2281
+ var(--choice-control-nudge, 0px)
2238
2282
  );
2239
2283
  }
2240
2284
  }
@@ -2782,8 +2826,6 @@ small {
2782
2826
 
2783
2827
  position: relative;
2784
2828
  display: inline-grid;
2785
- align-items: center;
2786
- justify-content: start;
2787
2829
  inline-size: var(--switch-width);
2788
2830
  block-size: var(--switch-block-size);
2789
2831
  margin: 0;
@@ -2802,9 +2844,17 @@ small {
2802
2844
  against the track is what makes the knob legible. */
2803
2845
  .switch::before {
2804
2846
  content: "";
2847
+ /* Out of flow like the checkbox mark, so the track carries no in-flow item
2848
+ and the switch synthesizes its baseline from its own border box. A knob
2849
+ laid out as a grid item makes the baseline the knob's bottom edge instead,
2850
+ which is what .choice measures its cap-height offset from. The inset is
2851
+ the same --switch-knob-margin on both axes: it resolves against the
2852
+ padding box, so the border is already out of the way. */
2853
+ position: absolute;
2854
+ inset-block-start: var(--switch-knob-margin);
2855
+ inset-inline-start: var(--switch-knob-margin);
2805
2856
  inline-size: var(--switch-knob-size);
2806
2857
  block-size: var(--switch-knob-size);
2807
- margin-inline: var(--switch-knob-margin);
2808
2858
  border-radius: var(--radius-full);
2809
2859
  background: var(--switch-knob);
2810
2860
  transition:
@@ -4491,7 +4541,7 @@ dialog.modal {
4491
4541
  /* --modal-size stays fallback-only: declaring it on dialog.modal gives it
4492
4542
  type+class specificity, which a single author class cannot outrank, so the
4493
4543
  public hook would silently keep this default. Same reason choice.css never
4494
- declares --choice-control-offset. */
4544
+ declares --choice-control-nudge. */
4495
4545
  max-inline-size: min(calc(100% - var(--dialog-viewport-gap-2x)), var(--modal-size, 32rem));
4496
4546
 
4497
4547
  /* Stable viewport cap for the panel: dvh tracks the dynamic browser toolbar
@@ -4817,15 +4867,24 @@ dialog.drawer {
4817
4867
 
4818
4868
  /* Keep the surface anchored to the viewport in the top layer. This also
4819
4869
  provides the containing block for the absolutely positioned close button.
4820
- A percentage height resolves against the fixed containing viewport, while
4821
- dynamic viewport units can be stale in embedded previews. */
4870
+
4871
+ The panel reaches both viewport edges, so it has to follow the space that
4872
+ is actually on screen: --viewport-block resolves to 100dvh and tracks a
4873
+ mobile browser's collapsing URL bar. A percentage resolves against the
4874
+ layout viewport instead, which those browsers size as if the bar were
4875
+ hidden — that put the footer below the fold for as long as it showed. The
4876
+ modal pins the small viewport for the opposite reason: it floats, so a
4877
+ stable height beats a panel that breathes while the bar moves.
4878
+
4879
+ An embedded preview that fakes a viewport overrides --viewport-block on
4880
+ its own shell; the component does not degrade to a percentage for it. */
4822
4881
  position: fixed;
4823
4882
  inset-block-start: 0;
4824
4883
 
4825
4884
  inline-size: min(calc(var(--viewport-inline) * 0.9), var(--drawer-size));
4826
- block-size: 100%;
4885
+ block-size: var(--viewport-block);
4827
4886
  max-inline-size: none;
4828
- max-block-size: 100%;
4887
+ max-block-size: var(--viewport-block);
4829
4888
  margin: 0;
4830
4889
  padding: var(--drawer-pad);
4831
4890
  border: 0;
@@ -4897,10 +4956,16 @@ dialog.drawer[open]::backdrop {
4897
4956
  }
4898
4957
  }
4899
4958
 
4900
- /* Scrollable navigation region inside the drawer. */
4901
- dialog.drawer nav {
4959
+ /* The body region absorbs the panel's height and scrolls. A drawer is a
4960
+ full-height shell by definition, so it always scrolls its body — unlike the
4961
+ modal, which sizes to its content and opts in with .scrollable. Header and
4962
+ footer keep their own tracks; .drawer-close is named because a generic
4963
+ exclusion list is easier to trust than its `position: absolute`. */
4964
+ dialog.drawer > :not(:is(header, footer, form, .drawer-close)),
4965
+ dialog.drawer > form > :not(:is(header, footer, .drawer-close)) {
4902
4966
  min-block-size: 0;
4903
- overflow-y: auto;
4967
+ overflow: auto;
4968
+ overscroll-behavior: contain;
4904
4969
  }
4905
4970
 
4906
4971
  @supports (transition-behavior: allow-discrete) {
@@ -5672,16 +5737,24 @@ dialog.drawer > form > header {
5672
5737
  * --skeleton-track
5673
5738
  * --skeleton-highlight
5674
5739
  */
5740
+ /* A placeholder fills its container up to --skeleton-width; the preset widths
5741
+ are a cap, never an intrinsic size. Stated the other way round — a rem
5742
+ `inline-size` capped by a percentage `max-inline-size` — the cap is ignored
5743
+ during intrinsic sizing, so every shrink-to-fit ancestor inherits the
5744
+ preset as a min-content floor: a card holding a title placeholder could not
5745
+ go below 18rem and pushed a phone's layout sideways. A percentage
5746
+ `inline-size` contributes nothing intrinsically and a rem cap only lowers
5747
+ the contribution, so the same geometry costs no floor. */
5675
5748
  .skeleton {
5676
5749
  --skeleton-size: 1rem;
5677
- --skeleton-width: 100%;
5750
+ --skeleton-width: none;
5678
5751
  --skeleton-radius: var(--radius);
5679
5752
  --skeleton-track: var(--surface-subtle);
5680
5753
  --skeleton-highlight: var(--surface-raised);
5681
5754
 
5682
5755
  display: block;
5683
- inline-size: var(--skeleton-width);
5684
- max-inline-size: 100%;
5756
+ inline-size: 100%;
5757
+ max-inline-size: var(--skeleton-width);
5685
5758
  block-size: var(--skeleton-size);
5686
5759
  border-radius: var(--skeleton-radius);
5687
5760
  background:
@@ -5705,11 +5778,14 @@ dialog.drawer > form > header {
5705
5778
  --skeleton-width: 18rem;
5706
5779
  }
5707
5780
 
5781
+ /* The one preset with a real intrinsic width: a disc is square, so it sizes
5782
+ rather than caps. The floor it does carry is the avatar size, which is small
5783
+ enough never to widen a layout. */
5708
5784
  .skeleton[data-shape="avatar"] {
5709
5785
  --skeleton-size: var(--avatar-size, 2.25rem);
5710
- --skeleton-width: var(--skeleton-size);
5711
5786
  --skeleton-radius: var(--radius-full);
5712
5787
 
5788
+ inline-size: var(--skeleton-size);
5713
5789
  flex: 0 0 auto;
5714
5790
  }
5715
5791
 
@@ -7623,6 +7699,14 @@ a.list-item[aria-current]::before {
7623
7699
  * --table-cell-pad
7624
7700
  */
7625
7701
  .table-wrap {
7702
+ /* Containing block for anything absolutely positioned inside the table —
7703
+ `.sr-only` on a column header above all. Without it that box resolves
7704
+ against the initial containing block, so it keeps the static position of a
7705
+ cell that may be a thousand pixels along the scrolled row, escapes this
7706
+ scroller, and extends the *document's* scrollable width. The page then
7707
+ scrolls sideways on a phone with nothing painted out there. */
7708
+ position: relative;
7709
+
7626
7710
  inline-size: 100%;
7627
7711
  max-inline-size: 100%;
7628
7712
  min-inline-size: 0;