actual-css 0.8.0 → 0.9.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 (91) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/components.json +2 -0
  3. package/dist/actual.css +10 -3
  4. package/dist/actual.full.css +360 -109
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/dist/actual.min.css +1 -1
  8. package/docs/pages/components/alert.md +6 -1
  9. package/docs/pages/components/dialog.md +5 -0
  10. package/docs/pages/components/drawer.md +13 -0
  11. package/docs/pages/components/skeleton.md +3 -1
  12. package/docs/pages/enhancements/flyout.md +16 -2
  13. package/docs/pages/forms/choice-cards.md +5 -4
  14. package/docs/pages/foundations/tokens.md +1 -1
  15. package/docs/pages/guides/progressive-enhancement.md +12 -8
  16. package/package.json +3 -2
  17. package/src/css/components/accordion.css +2 -0
  18. package/src/css/components/actions.css +2 -0
  19. package/src/css/components/alert.css +2 -0
  20. package/src/css/components/app-nav.css +2 -0
  21. package/src/css/components/avatar.css +2 -0
  22. package/src/css/components/badge.css +2 -0
  23. package/src/css/components/breadcrumb.css +2 -0
  24. package/src/css/components/busy.css +2 -0
  25. package/src/css/components/button.css +2 -0
  26. package/src/css/components/card.css +2 -0
  27. package/src/css/components/chat.css +2 -0
  28. package/src/css/components/data-list.css +2 -0
  29. package/src/css/components/drawer.css +55 -27
  30. package/src/css/components/fab.css +2 -0
  31. package/src/css/components/flyout.css +2 -0
  32. package/src/css/components/indicator.css +2 -0
  33. package/src/css/components/join.css +2 -0
  34. package/src/css/components/key.css +2 -0
  35. package/src/css/components/list.css +2 -0
  36. package/src/css/components/meter.css +2 -0
  37. package/src/css/components/modal.css +47 -40
  38. package/src/css/components/navbar.css +2 -0
  39. package/src/css/components/pagination.css +2 -0
  40. package/src/css/components/progress.css +2 -0
  41. package/src/css/components/rating.css +2 -0
  42. package/src/css/components/scrollspy.css +2 -0
  43. package/src/css/components/skeleton.css +17 -4
  44. package/src/css/components/spinner.css +2 -0
  45. package/src/css/components/status-bar.css +2 -0
  46. package/src/css/components/steps.css +2 -0
  47. package/src/css/components/tab.css +2 -0
  48. package/src/css/components/table.css +10 -0
  49. package/src/css/components/tooltip.css +2 -0
  50. package/src/css/core/print.css +3 -0
  51. package/src/css/core/variants.css +7 -3
  52. package/src/css/effects/aura.css +2 -0
  53. package/src/css/forms/choice-card.css +4 -0
  54. package/src/css/forms/choice.css +70 -26
  55. package/src/css/forms/control.css +2 -0
  56. package/src/css/forms/floating-field.css +5 -1
  57. package/src/css/forms/form-actions.css +2 -0
  58. package/src/css/forms/form.css +2 -0
  59. package/src/css/forms/input-icon.css +2 -0
  60. package/src/css/forms/native.css +2 -0
  61. package/src/css/forms/otp.css +10 -0
  62. package/src/css/forms/range.css +2 -0
  63. package/src/css/forms/select.css +2 -0
  64. package/src/css/forms/switch.css +11 -3
  65. package/src/css/forms/validation.css +2 -0
  66. package/src/css/layout/app-layout.css +2 -0
  67. package/src/css/layout/app-shell.css +5 -1
  68. package/src/css/layout/center.css +2 -0
  69. package/src/css/layout/cluster.css +2 -0
  70. package/src/css/layout/column-layout.css +2 -0
  71. package/src/css/layout/frame.css +2 -0
  72. package/src/css/layout/grid.css +2 -0
  73. package/src/css/layout/media.css +2 -1
  74. package/src/css/layout/scroll-snap.css +2 -0
  75. package/src/css/layout/scroll-target.css +2 -0
  76. package/src/css/layout/sidebar-layout.css +2 -0
  77. package/src/css/layout/stack.css +2 -1
  78. package/src/css/layout/switcher.css +2 -0
  79. package/src/css/layout/topbar.css +2 -0
  80. package/src/css/typography/fluid.css +2 -0
  81. package/src/css/typography/lead.css +2 -1
  82. package/src/css/typography/overline.css +2 -0
  83. package/src/css/typography/prose.css +2 -0
  84. package/src/css/utilities/base.css +6 -1
  85. package/src/js/context-menu.js +56 -15
  86. package/src/js/enhancement-loader.js +5 -1
  87. package/src/js/escape.js +11 -1
  88. package/src/js/flyout.js +6 -1
  89. package/src/js/focus.js +8 -3
  90. package/src/js/surface.js +28 -0
  91. package/src/js/tooltip.js +10 -4
@@ -748,9 +748,9 @@ small {
748
748
  }
749
749
 
750
750
  /* Shared control scale. Wrappers are listed when their descendants form one
751
- component (field, input-icon, join, pagination, tabs, flyout). A local size
752
- declaration beats inherited density because it is declared on the sized
753
- component itself. */
751
+ component (field, choice-card, floating-field, input-icon, join, pagination,
752
+ tabs, flyout). A local size declaration beats inherited density because it
753
+ is declared on the sized component itself. */
754
754
  :where(
755
755
  .btn,
756
756
  .input,
@@ -760,7 +760,9 @@ small {
760
760
  .file,
761
761
  .field,
762
762
  .choice,
763
+ .choice-card,
763
764
  .input-icon,
765
+ .floating-field,
764
766
  .otp,
765
767
  .join,
766
768
  .pagination,
@@ -782,7 +784,9 @@ small {
782
784
  .file,
783
785
  .field,
784
786
  .choice,
787
+ .choice-card,
785
788
  .input-icon,
789
+ .floating-field,
786
790
  .otp,
787
791
  .join,
788
792
  .pagination,
@@ -842,6 +846,9 @@ small {
842
846
 
843
847
  @media print {
844
848
  :root,
849
+ /* The doubled attribute intentionally outranks single-attribute preset
850
+ islands (data-theme="dark", paired themes) loaded after the core. */
851
+ [data-theme][data-theme],
845
852
  body {
846
853
  /* Save some ink in dark mode */
847
854
  color-scheme: light;
@@ -863,6 +870,8 @@ small {
863
870
  /*
864
871
  * Prose is the opt-in rich text scope for authored content.
865
872
  *
873
+ * Docs: docs/pages/foundations/typography.md
874
+ *
866
875
  * Global element styles stay mild so app UI is not shaped accidentally. Page
867
876
  * layout, forms, cards, navigation, and app tables remain outside this scope.
868
877
  *
@@ -1079,7 +1088,8 @@ small {
1079
1088
 
1080
1089
  /* Lead paragraph: a slightly larger, more relaxed reading-introduction text.
1081
1090
  Non-fluid and always available — use .text-lead from the optional fluid
1082
- module for marketing/display surfaces. */
1091
+ module for marketing/display surfaces.
1092
+ Docs: docs/pages/foundations/typography.md */
1083
1093
  .lead {
1084
1094
  font-size: 1.125em;
1085
1095
  line-height: var(--line-height-relaxed);
@@ -1088,6 +1098,8 @@ small {
1088
1098
  /*
1089
1099
  * Overline — editorial small-cap label for section openers.
1090
1100
  *
1101
+ * Docs: docs/pages/foundations/typography.md
1102
+ *
1091
1103
  * Base form is unstyled chrome that takes its surrounding color. Add .pill for
1092
1104
  * the bordered rounded shape, and combine with an intent class (.primary,
1093
1105
  * .success, etc.) to tint the text, border, and background.
@@ -1137,6 +1149,8 @@ small {
1137
1149
  /*
1138
1150
  * Optional fluid typography module.
1139
1151
  *
1152
+ * Docs: docs/pages/foundations/typography.md
1153
+ *
1140
1154
  * Part of the typography family (typography/index.css) and the full bundle; it
1141
1155
  * is not in the minimal core, so a core-only import adds it explicitly when
1142
1156
  * display, title, and lead sizes must scale with the viewport. Typical use
@@ -1267,7 +1281,8 @@ small {
1267
1281
  region re-spaces every nested stack, cluster and grid at any depth. Setting
1268
1282
  the token stays correct on the element that consumes it, and
1269
1283
  .compact/.spacious set it deliberately so a density context reaches nested layouts. See
1270
- docs/pages/layout/stack.md. */
1284
+ docs/pages/layout/stack.md.
1285
+ Docs: docs/pages/layout/stack.md */
1271
1286
  .stack {
1272
1287
  display: flex;
1273
1288
  flex-direction: column;
@@ -1281,6 +1296,8 @@ small {
1281
1296
  /*
1282
1297
  * Inline group that wraps when space runs out.
1283
1298
  *
1299
+ * Docs: docs/pages/layout/cluster.md
1300
+ *
1284
1301
  * Public hooks:
1285
1302
  * --cluster-align
1286
1303
  * --cluster-justify
@@ -1304,6 +1321,8 @@ small {
1304
1321
  /*
1305
1322
  * Centered content column with a maximum measure.
1306
1323
  *
1324
+ * Docs: docs/pages/layout/center.md
1325
+ *
1307
1326
  * Public hooks:
1308
1327
  * --center-size
1309
1328
  * --center-pad
@@ -1319,6 +1338,8 @@ small {
1319
1338
  /*
1320
1339
  * Media frame with a fixed ratio.
1321
1340
  *
1341
+ * Docs: docs/pages/layout/frame.md
1342
+ *
1322
1343
  * Public hooks:
1323
1344
  * --frame-ratio
1324
1345
  *
@@ -1336,7 +1357,11 @@ small {
1336
1357
  object-fit: cover;
1337
1358
  }
1338
1359
 
1339
- /* Full-height single-column document shell. */
1360
+ /*
1361
+ * Full-height single-column document shell.
1362
+ *
1363
+ * Docs: docs/pages/layout/app-shell.md
1364
+ */
1340
1365
  .app-shell {
1341
1366
  min-block-size: var(--viewport-block);
1342
1367
  display: flex;
@@ -1350,6 +1375,8 @@ small {
1350
1375
  * chrome available without fixed offsets. This is not a generic page wrapper
1351
1376
  * or a public-site layout; all three direct regions are part of the contract.
1352
1377
  *
1378
+ * Docs: docs/pages/layout/app-layout.md
1379
+ *
1353
1380
  * Public hook:
1354
1381
  * --app-nav-side-size
1355
1382
  */
@@ -1449,7 +1476,8 @@ small {
1449
1476
  /* Media object: fixed-width leading element (avatar, icon, image) + flexible
1450
1477
  trailing content. Replaces the repeated "auto minmax(0, 1fr)" pattern in
1451
1478
  author cards, comments, meta rows, and similar compositions.
1452
- Child contract: exactly two direct children (leading media + content). */
1479
+ Child contract: exactly two direct children (leading media + content).
1480
+ Docs: docs/pages/layout/media.md */
1453
1481
  .media {
1454
1482
  display: grid;
1455
1483
  grid-template-columns: auto minmax(0, 1fr);
@@ -1460,6 +1488,8 @@ small {
1460
1488
  /*
1461
1489
  * Peer regions that switch as a group.
1462
1490
  *
1491
+ * Docs: docs/pages/layout/switcher.md
1492
+ *
1463
1493
  * All children sit side by side when they all fit; otherwise every child
1464
1494
  * stacks. Unlike .grid, this recipe cannot create a partial row such as two
1465
1495
  * items plus one orphan.
@@ -1485,6 +1515,8 @@ small {
1485
1515
  /*
1486
1516
  * Main content + secondary region.
1487
1517
  *
1518
+ * Docs: docs/pages/layout/sidebar-layout.md
1519
+ *
1488
1520
  * The children wrap according to the space allocated to this layout, without
1489
1521
  * an ancestor opt-in or viewport threshold. The large growth ratio lets the
1490
1522
  * main region reclaim the full row when the aside wraps instead of remaining
@@ -1539,6 +1571,8 @@ small {
1539
1571
  /*
1540
1572
  * Responsive equal-width grids.
1541
1573
  *
1574
+ * Docs: docs/pages/layout/grid.md
1575
+ *
1542
1576
  * .grid is space-driven: it fits as many tracks as --grid-min allows.
1543
1577
  * .grid-N is count-driven: it declares a structural density of N and collapses
1544
1578
  * through natural divisors of N.
@@ -1669,6 +1703,8 @@ small {
1669
1703
  /*
1670
1704
  * Explicit 12-column composition canvas.
1671
1705
  *
1706
+ * Docs: docs/pages/layout/column-layout.md
1707
+ *
1672
1708
  * Neither space-driven like .grid nor count-driven like .grid-N: this is a
1673
1709
  * coordinate system. The author places regions with .column-span-N /
1674
1710
  * .column-start-N and owns narrow-container recomposition. There is
@@ -1856,6 +1892,8 @@ small {
1856
1892
  * Scroll snap — optional horizontal rails for niche card strips and simple
1857
1893
  * touch-friendly overflow areas.
1858
1894
  *
1895
+ * Docs: docs/pages/layout/scroll-snap.md
1896
+ *
1859
1897
  * This is not a full carousel. For previous/next controls, pagination,
1860
1898
  * active state, drag behavior, looping, or robust desktop mouse interaction,
1861
1899
  * use a dedicated carousel library.
@@ -1920,6 +1958,8 @@ small {
1920
1958
  * header (.topbar) or underlaps it; scroll-margin reserves space on the
1921
1959
  * target itself so the browser stops early.
1922
1960
  *
1961
+ * Docs: docs/pages/layout/scroll-target.md
1962
+ *
1923
1963
  * Apply to the elements that are link targets: <section id="...">, headings,
1924
1964
  * etc. The scrollspy enhancer only reads ids — this offset is what actually
1925
1965
  * shapes where the page lands, and the two are independent.
@@ -1937,6 +1977,8 @@ small {
1937
1977
  * background only — row content (search, breadcrumb, actions) stays local
1938
1978
  * to the page.
1939
1979
  *
1980
+ * Docs: docs/pages/layout/topbar.md
1981
+ *
1940
1982
  * Not for marketing/editorial site headers — those are one-off visual
1941
1983
  * identity per page; see docs/pages/patterns/overview.md's Header Navigation
1942
1984
  * section. This is for the structural app-shell topbar, which recurs unchanged
@@ -1990,6 +2032,8 @@ small {
1990
2032
  /*
1991
2033
  * Form layout and supporting text.
1992
2034
  *
2035
+ * Docs: docs/pages/forms/overview.md
2036
+ *
1993
2037
  * Individual controls keep their own files; .choice label layout lives in
1994
2038
  * choice.css; .form-actions lives in form-actions.css.
1995
2039
  *
@@ -2052,6 +2096,8 @@ small {
2052
2096
  /*
2053
2097
  * Text controls — input, textarea, select.
2054
2098
  *
2099
+ * Docs: docs/pages/forms/overview.md
2100
+ *
2055
2101
  * Neutral geometry and interaction states shared across all text-like
2056
2102
  * controls. select shares this base recipe; its chevron/appearance layer
2057
2103
  * lives in select.css. checkbox, radio, range, switch, and file have their
@@ -2164,7 +2210,7 @@ small {
2164
2210
  * geometry and states. switch.css is imported after this file.
2165
2211
  *
2166
2212
  * Public hooks:
2167
- * --choice-control-offset
2213
+ * --choice-control-nudge
2168
2214
  * --choice-border
2169
2215
  * --choice-bg
2170
2216
  * --choice-mark
@@ -2196,45 +2242,89 @@ small {
2196
2242
  cursor: pointer;
2197
2243
  }
2198
2244
 
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. */
2245
+ /* Optical alignment: the control reads level with the first line's cap height,
2246
+ not with the top of its line box. Every inline choice runs the same formula
2247
+ over its own block size, so a checkbox and a taller switch stay level with
2248
+ each other under any line-height or --choice-control-size — something a
2249
+ per-control constant cannot do.
2250
+
2251
+ Three tiers, each the best the engine can express:
2252
+
2253
+ - `cap` unit: exact, and the only font-independent answer. Where the cap
2254
+ center sits inside a line box depends on the font's ascent/descent/cap
2255
+ ratios, so no constant can serve both Segoe UI and Roboto — the same rule
2256
+ was a pixel high on Windows and two pixels low on Android. Baseline
2257
+ alignment puts the control's border-box bottom on the text baseline, and
2258
+ one inset drops it by half the difference between its own height and the
2259
+ cap height. Both classed controls synthesize that baseline from their
2260
+ border box because their mark and knob are out of flow.
2261
+ - `lh` only: center the control on the line box, which is within a pixel of
2262
+ the cap center for the usual UI faces.
2263
+ - neither: the constants that formula yields at the default 1.5
2264
+ line-height, 0.1875em for a checkbox and 0.125em for the switch track.
2265
+
2266
+ --choice-control-nudge adds to whichever tier applies, rather than replacing
2267
+ it: the derived value is exact now, so a design has a correction to make,
2268
+ not a formula to restate — and one number cannot stand in for three
2269
+ different references anyway. It stays fallback-only rather than declared
2270
+ here, so an override placed anywhere up the tree — :root included — reaches
2271
+ the control instead of losing to a component-level declaration. */
2216
2272
  .choice > :is(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
2217
2273
  flex: 0 0 auto;
2218
- margin-block-start: var(--choice-control-offset, 0.25em);
2274
+ margin-block-start: calc(0.1875em + var(--choice-control-nudge, 0px));
2219
2275
  }
2220
2276
 
2221
2277
  .choice > .switch {
2222
2278
  flex: 0 0 auto;
2223
- margin-block-start: var(--choice-control-offset, 0.1875em);
2279
+ margin-block-start: calc(0.125em + var(--choice-control-nudge, 0px));
2224
2280
  }
2225
2281
 
2226
2282
  @supports (margin-block-start: 1lh) {
2227
2283
  .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)
2284
+ margin-block-start: calc(
2285
+ (1lh - var(--choice-control-size)) /
2286
+ 2 +
2287
+ var(--choice-control-nudge, 0px)
2231
2288
  );
2232
2289
  }
2233
2290
 
2234
2291
  .choice > .switch {
2235
- margin-block-start: var(
2236
- --choice-control-offset,
2237
- calc((1lh - var(--switch-block-size)) / 2 + 0.0625em)
2292
+ margin-block-start: calc(
2293
+ (1lh - var(--switch-block-size)) /
2294
+ 2 +
2295
+ var(--choice-control-nudge, 0px)
2296
+ );
2297
+ }
2298
+ }
2299
+
2300
+ @supports (margin-block-start: 1cap) {
2301
+ .choice {
2302
+ align-items: baseline;
2303
+ }
2304
+
2305
+ /* Baseline alignment owns the block position now; the tier above would only
2306
+ grow the row. An unclassed native input keeps the UA's own baseline, which
2307
+ is already tuned for text — the formula below describes the framework's
2308
+ geometry and would not fit it. */
2309
+ .choice > :is(input[type="checkbox"], input[type="radio"]) {
2310
+ margin-block-start: 0;
2311
+ }
2312
+
2313
+ /* inset-block-start rides the `position: relative` these controls already
2314
+ carry for their mark and knob. */
2315
+ .choice > :is(.check, .radio) {
2316
+ inset-block-start: calc(
2317
+ (var(--choice-control-size) - 1cap) /
2318
+ 2 +
2319
+ var(--choice-control-nudge, 0px)
2320
+ );
2321
+ }
2322
+
2323
+ .choice > .switch {
2324
+ inset-block-start: calc(
2325
+ (var(--switch-block-size) - 1cap) /
2326
+ 2 +
2327
+ var(--choice-control-nudge, 0px)
2238
2328
  );
2239
2329
  }
2240
2330
  }
@@ -2361,6 +2451,8 @@ small {
2361
2451
  * Select — chevron and appearance layer on top of the shared text-control
2362
2452
  * recipe.
2363
2453
  *
2454
+ * Docs: docs/pages/forms/overview.md
2455
+ *
2364
2456
  * Base geometry and interaction states come from control.css (select shares
2365
2457
  * the --control-* tokens). This file only removes the native arrow and draws
2366
2458
  * the CSS chevron, so the picker (appearance: base-select) enhancement in
@@ -2419,6 +2511,8 @@ small {
2419
2511
  * custom styling, so the framework stays out of the way and just keeps them
2420
2512
  * readable and theme-aware.
2421
2513
  *
2514
+ * Docs: docs/pages/forms/controls.md
2515
+ *
2422
2516
  * Range has its own custom-styled file (range.css) so the focus ring can
2423
2517
  * target the thumb pseudo-element rather than the rectangular CSS box.
2424
2518
  */
@@ -2519,6 +2613,8 @@ small {
2519
2613
  /*
2520
2614
  * Form validation states.
2521
2615
  *
2616
+ * Docs: docs/pages/forms/validation.md
2617
+ *
2522
2618
  * Imported by default through forms/index.css. Custom builds may omit this
2523
2619
  * file when an app wants to own validation styling completely.
2524
2620
  *
@@ -2610,6 +2706,8 @@ small {
2610
2706
  /*
2611
2707
  * Form actions — bottom form bar with sticky variant.
2612
2708
  *
2709
+ * Docs: docs/pages/forms/form-actions.md
2710
+ *
2613
2711
  * A flex cluster of buttons/actions that closes a form. The sticky variant
2614
2712
  * docks to the viewport bottom with a translucent blur where supported; at
2615
2713
  * rest it stays visually neutral so it does not double as a separator.
@@ -2666,6 +2764,8 @@ small {
2666
2764
  /*
2667
2765
  * Input Icon — decorative icon inside a relative wrapper, leading or trailing.
2668
2766
  *
2767
+ * Docs: docs/pages/patterns/icon-input.md
2768
+ *
2669
2769
  * Icon-library agnostic: detects position by DOM order. First child that is
2670
2770
  * not .input = leading icon; icon after .input = trailing. The icon follows
2671
2771
  * local typography; density contexts shrink the control geometry around it
@@ -2727,6 +2827,8 @@ small {
2727
2827
  /*
2728
2828
  * Switch controls for checkbox inputs with switch semantics.
2729
2829
  *
2830
+ * Docs: docs/pages/forms/switch.md
2831
+ *
2730
2832
  * The switch remains an input control, not a decorative toggle. Checked, hover,
2731
2833
  * focus-visible, and disabled states stay clear.
2732
2834
  *
@@ -2782,8 +2884,6 @@ small {
2782
2884
 
2783
2885
  position: relative;
2784
2886
  display: inline-grid;
2785
- align-items: center;
2786
- justify-content: start;
2787
2887
  inline-size: var(--switch-width);
2788
2888
  block-size: var(--switch-block-size);
2789
2889
  margin: 0;
@@ -2802,9 +2902,17 @@ small {
2802
2902
  against the track is what makes the knob legible. */
2803
2903
  .switch::before {
2804
2904
  content: "";
2905
+ /* Out of flow like the checkbox mark, so the track carries no in-flow item
2906
+ and the switch synthesizes its baseline from its own border box. A knob
2907
+ laid out as a grid item makes the baseline the knob's bottom edge instead,
2908
+ which is what .choice measures its cap-height offset from. The inset is
2909
+ the same --switch-knob-margin on both axes: it resolves against the
2910
+ padding box, so the border is already out of the way. */
2911
+ position: absolute;
2912
+ inset-block-start: var(--switch-knob-margin);
2913
+ inset-inline-start: var(--switch-knob-margin);
2805
2914
  inline-size: var(--switch-knob-size);
2806
2915
  block-size: var(--switch-knob-size);
2807
- margin-inline: var(--switch-knob-margin);
2808
2916
  border-radius: var(--radius-full);
2809
2917
  background: var(--switch-knob);
2810
2918
  transition:
@@ -2873,6 +2981,8 @@ small {
2873
2981
  * Range slider — custom-styled so the focus ring always targets the
2874
2982
  * user-manipulated surface (the thumb) rather than the CSS box.
2875
2983
  *
2984
+ * Docs: docs/pages/forms/range.md
2985
+ *
2876
2986
  * appearance: none gives us full control over track and thumb; without it,
2877
2987
  * pseudo-element rules for ::-webkit-slider-thumb and ::-moz-range-thumb
2878
2988
  * are not reliably applied across browsers.
@@ -2988,6 +3098,8 @@ small {
2988
3098
  /*
2989
3099
  * Choice cards — card-style radio and checkbox labels.
2990
3100
  *
3101
+ * Docs: docs/pages/forms/choice-cards.md
3102
+ *
2991
3103
  * Each .choice-card is a <label> wrapping a native radio or checkbox. When
2992
3104
  * :has() is supported the input is visually hidden and the card mirrors its
2993
3105
  * state: checked border and tint, checkmark badge (round for radio, rounded
@@ -3024,6 +3136,8 @@ small {
3024
3136
  border-radius: var(--choice-card-radius);
3025
3137
  background: var(--choice-card-bg);
3026
3138
  color: var(--text);
3139
+ /* Consumed so .sm/.lg reach the card; the badge's em sizing follows. */
3140
+ font-size: var(--control-font-size);
3027
3141
  cursor: pointer;
3028
3142
  transition:
3029
3143
  border-color var(--duration),
@@ -3232,6 +3346,8 @@ small {
3232
3346
  * Floating labels — optional composition over the text controls
3233
3347
  * (.input, .textarea, .select) and .field-label.
3234
3348
  *
3349
+ * Docs: docs/pages/forms/floating-field.md
3350
+ *
3235
3351
  * The cell owns a control plus its label; the label overlays the control and
3236
3352
  * floats to the top when the control is focused or non-empty. Help and error
3237
3353
  * messages stay in the surrounding .field, not inside the cell, so the
@@ -3273,7 +3389,9 @@ small {
3273
3389
 
3274
3390
  .floating-field > .field-label {
3275
3391
  position: absolute;
3276
- inset-inline-start: calc(var(--space-30) + var(--border-width));
3392
+ /* Same start edge as the control's text: --control-pad-x owns that shared
3393
+ geometry, so .sm/.lg keep label and value aligned. */
3394
+ inset-inline-start: calc(var(--control-pad-x) + var(--border-width));
3277
3395
  color: var(--text-muted);
3278
3396
  font-size: var(--control-font-size);
3279
3397
  font-weight: var(--font-weight-normal);
@@ -3339,6 +3457,8 @@ small {
3339
3457
  /*
3340
3458
  * One-time code input with visual cells and one native input.
3341
3459
  *
3460
+ * Docs: docs/pages/forms/otp.md
3461
+ *
3342
3462
  * Add one direct span per accepted character. The cells determine the
3343
3463
  * component width; the overlaid input remains the only interactive control.
3344
3464
  *
@@ -3467,12 +3587,22 @@ small {
3467
3587
  .needs-validation.was-validated .otp > input:invalid ~ span {
3468
3588
  --otp-cell-border: Mark;
3469
3589
  }
3590
+
3591
+ /* biome-ignore lint/style/noDescendingSpecificity: the :user-invalid route
3592
+ mirrors the non-forced-colors block above and must come last — the three
3593
+ invalid markers write the same value, so source order is inert. An engine
3594
+ without the pseudo drops this rule alone. */
3595
+ .needs-validation .otp > input:user-invalid ~ span {
3596
+ --otp-cell-border: Mark;
3597
+ }
3470
3598
  }
3471
3599
 
3472
3600
  /* Controls */
3473
3601
  /*
3474
3602
  * Buttons for actions and button-like links.
3475
3603
  *
3604
+ * Docs: docs/pages/components/button.md
3605
+ *
3476
3606
  * .btn is primarily for actionable elements: <button>, <a href>,
3477
3607
  * input[type="button"|"submit"|"reset"].
3478
3608
  *
@@ -3719,6 +3849,8 @@ a.btn[aria-disabled="true"] {
3719
3849
  /*
3720
3850
  * Badges for compact labels, counts, and state hints.
3721
3851
  *
3852
+ * Docs: docs/pages/components/badge.md
3853
+ *
3722
3854
  * Soft by default. .solid for counters and notification badges. The dot
3723
3855
  * (.badge:empty) stays solid regardless — a soft dot is pointless.
3724
3856
  * Shared .outline variant stays available.
@@ -3979,6 +4111,8 @@ a.badge[aria-disabled="true"] {
3979
4111
  /*
3980
4112
  * Alerts for inline feedback and status messages.
3981
4113
  *
4114
+ * Docs: docs/pages/components/alert.md
4115
+ *
3982
4116
  * Keeps alert defaults local while consuming the shared --ui-* variant
3983
4117
  * plumbing for emphasis.
3984
4118
  *
@@ -4265,6 +4399,8 @@ a.badge[aria-disabled="true"] {
4265
4399
  /*
4266
4400
  * Actions — a horizontal action-button cluster.
4267
4401
  *
4402
+ * Docs: docs/pages/patterns/actions.md
4403
+ *
4268
4404
  * Composes with .btn (or any inline element) and feeds layout via the shared
4269
4405
  * --gap token.
4270
4406
  *
@@ -4293,6 +4429,8 @@ a.badge[aria-disabled="true"] {
4293
4429
  /*
4294
4430
  * Cards provide a neutral raised surface for grouped content.
4295
4431
  *
4432
+ * Docs: docs/pages/components/card.md
4433
+ *
4296
4434
  * Surface, border, radius, padding, density, and elevation stay local here.
4297
4435
  * Direct header/footer children are structural slots; a direct footer is
4298
4436
  * anchored to the card's bottom edge when extra block space is available (so
@@ -4454,6 +4592,8 @@ a.badge[aria-disabled="true"] {
4454
4592
  /*
4455
4593
  * Modal shell styling for native dialog elements.
4456
4594
  *
4595
+ * Docs: docs/pages/components/dialog.md
4596
+ *
4457
4597
  * Spacing, radius, transitions, and focus treatment come from shared tokens.
4458
4598
  * Opening behavior and intent-specific decisions stay outside this component.
4459
4599
  *
@@ -4465,16 +4605,15 @@ a.badge[aria-disabled="true"] {
4465
4605
  *
4466
4606
  * Internal:
4467
4607
  * --dialog-close-icon-size
4608
+ * --dialog-focus-bleed
4468
4609
  * --dialog-viewport-block
4469
4610
  * --dialog-viewport-gap-default
4470
4611
  * --dialog-viewport-gap-2x
4471
4612
  */
4472
- dialog.modal {
4473
- /* --dialog-viewport-gap stays fallback-only for the same reason as
4474
- --modal-size below: declaring the public hook here would give it
4475
- type+class specificity that a single author class cannot outrank. The
4476
- responsive default lives in an internal token instead, so a media query
4477
- can still move it without closing the hook.
4613
+ :where(dialog).modal {
4614
+ /* --dialog-viewport-gap stays fallback-only so the responsive default can
4615
+ live in an internal token: a media query moves it without closing the
4616
+ public hook.
4478
4617
 
4479
4618
  calc() multiplication is not in the supported browser range, so the
4480
4619
  doubled gap is spelled out with an additive calc. */
@@ -4488,10 +4627,8 @@ dialog.modal {
4488
4627
  provides the containing block for the absolutely positioned close button. */
4489
4628
  position: fixed;
4490
4629
 
4491
- /* --modal-size stays fallback-only: declaring it on dialog.modal gives it
4492
- type+class specificity, which a single author class cannot outrank, so the
4493
- public hook would silently keep this default. Same reason choice.css never
4494
- declares --choice-control-offset. */
4630
+ /* The root is :where(dialog).modal (0-1-0), so a single author class can
4631
+ outrank these defaults — no fallback-only indirection needed here. */
4495
4632
  max-inline-size: min(calc(100% - var(--dialog-viewport-gap-2x)), var(--modal-size, 32rem));
4496
4633
 
4497
4634
  /* Stable viewport cap for the panel: dvh tracks the dynamic browser toolbar
@@ -4521,8 +4658,8 @@ dialog.modal {
4521
4658
  transform var(--duration) var(--ease-exit);
4522
4659
  }
4523
4660
 
4524
- dialog.modal > form,
4525
- dialog.modal > .stack {
4661
+ :where(dialog).modal > form,
4662
+ :where(dialog).modal > .stack {
4526
4663
  display: grid;
4527
4664
  gap: var(--space-40);
4528
4665
  max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
@@ -4532,7 +4669,7 @@ dialog.modal > .stack {
4532
4669
  }
4533
4670
 
4534
4671
  @supports (block-size: 100svh) {
4535
- dialog.modal {
4672
+ :where(dialog).modal {
4536
4673
  --dialog-viewport-block: 100svh;
4537
4674
  }
4538
4675
  }
@@ -4540,42 +4677,50 @@ dialog.modal > .stack {
4540
4677
  /* Keep the dialog off the viewport edges on small screens. Moves the internal
4541
4678
  default, not the public hook, so an author override still wins here too. */
4542
4679
  @media (max-width: 30rem) {
4543
- dialog.modal {
4680
+ :where(dialog).modal {
4544
4681
  --dialog-viewport-gap-default: var(--space-30);
4545
4682
  }
4546
4683
  }
4547
4684
 
4548
- dialog.modal.scrollable > form,
4549
- dialog.modal.scrollable > .stack {
4685
+ :where(dialog).modal.scrollable > form,
4686
+ :where(dialog).modal.scrollable > .stack {
4550
4687
  grid-template-rows: auto minmax(0, 1fr) auto;
4551
4688
  overflow: hidden;
4552
4689
  }
4553
4690
 
4554
- dialog.modal.scrollable > form > :not(:is(header, footer)),
4555
- dialog.modal.scrollable > .stack > :not(:is(header, footer)) {
4691
+ :where(dialog).modal.scrollable > form > :not(:is(header, footer)),
4692
+ :where(dialog).modal.scrollable > .stack > :not(:is(header, footer)) {
4693
+ --dialog-focus-bleed: calc(var(--focus-outline-offset) + var(--border-width) * 2);
4694
+
4556
4695
  min-block-size: 0;
4696
+ /* Keep focus paint inside the clip without moving the body off the fixed
4697
+ header and footer edges. */
4698
+ margin-inline: calc(var(--dialog-focus-bleed) * -1);
4699
+ padding-inline: var(--dialog-focus-bleed);
4557
4700
  overflow: auto;
4558
4701
  overscroll-behavior: contain;
4559
4702
  }
4560
4703
 
4561
- dialog.modal header > :first-child,
4562
- dialog.modal footer > :first-child {
4704
+ /* Anatomy is bounded to the structural slots (direct children of the
4705
+ form/.stack body): a header or footer deeper in the content keeps its own
4706
+ rhythm and is not restyled as the modal's chrome. */
4707
+ :where(dialog).modal > :where(form, .stack) > :is(header, footer) > :first-child {
4563
4708
  margin-block-start: 0;
4564
4709
  }
4565
4710
 
4566
- dialog.modal header > :last-child,
4567
- dialog.modal footer > :last-child {
4711
+ :where(dialog).modal > :where(form, .stack) > :is(header, footer) > :last-child {
4568
4712
  margin-block-end: 0;
4569
4713
  }
4570
4714
 
4571
4715
  /* The close button is anchored to the dialog at top inline-end, out of the
4572
- content flow; headers reserve inline room so titles never run under it. */
4573
- dialog.modal > form > header,
4574
- dialog.modal > .stack > header {
4716
+ content flow; headers reserve inline room so titles never run under it.
4717
+ The form/.stack anatomy is structural, so it stays in :where() and never
4718
+ lends .stack's specificity to a bare <form>. */
4719
+ :where(dialog).modal > :where(form, .stack) > header {
4575
4720
  padding-inline-end: calc(var(--control-size) + var(--space-30));
4576
4721
  }
4577
4722
 
4578
- dialog.modal footer {
4723
+ :where(dialog).modal > :where(form, .stack) > footer {
4579
4724
  display: flex;
4580
4725
  flex-wrap: wrap;
4581
4726
  justify-content: flex-end;
@@ -4585,17 +4730,17 @@ dialog.modal footer {
4585
4730
  /* Confirmation layout — a focused message followed by a visually distinct
4586
4731
  action band. The media primitive owns icon/content alignment; this variant
4587
4732
  only removes the standard form inset so both regions can own their padding. */
4588
- dialog.modal.dialog-confirmation > form {
4733
+ :where(dialog).modal.dialog-confirmation > form {
4589
4734
  gap: 0;
4590
4735
  padding: 0;
4591
4736
  }
4592
4737
 
4593
- dialog.modal.dialog-confirmation > form > .media {
4738
+ :where(dialog).modal.dialog-confirmation > form > .media {
4594
4739
  gap: var(--space-40);
4595
4740
  padding: var(--space-50);
4596
4741
  }
4597
4742
 
4598
- dialog.modal.dialog-confirmation > form > footer {
4743
+ :where(dialog).modal.dialog-confirmation > form > footer {
4599
4744
  padding: var(--space-40) var(--space-50);
4600
4745
  border-end-start-radius: calc(var(--radius-lg) - var(--border-width));
4601
4746
  border-end-end-radius: calc(var(--radius-lg) - var(--border-width));
@@ -4636,13 +4781,13 @@ dialog.modal.dialog-confirmation > form > footer {
4636
4781
  }
4637
4782
 
4638
4783
  @media (max-width: 30rem) {
4639
- dialog.modal.dialog-confirmation > form > .media,
4640
- dialog.modal.dialog-confirmation > form > footer {
4784
+ :where(dialog).modal.dialog-confirmation > form > .media,
4785
+ :where(dialog).modal.dialog-confirmation > form > footer {
4641
4786
  padding: var(--space-40);
4642
4787
  }
4643
4788
  }
4644
4789
 
4645
- dialog.modal[open] {
4790
+ :where(dialog).modal[open] {
4646
4791
  opacity: 1;
4647
4792
  transform: none;
4648
4793
  transition:
@@ -4651,13 +4796,13 @@ dialog.modal[open] {
4651
4796
  }
4652
4797
 
4653
4798
  @starting-style {
4654
- dialog.modal[open] {
4799
+ :where(dialog).modal[open] {
4655
4800
  opacity: 0;
4656
4801
  transform: translateY(0.35rem);
4657
4802
  }
4658
4803
  }
4659
4804
 
4660
- dialog.modal::backdrop {
4805
+ :where(dialog).modal::backdrop {
4661
4806
  background: var(--backdrop-fill);
4662
4807
  opacity: 0;
4663
4808
  /* Scrim curve stays neutral `ease`; only the panel carries the character
@@ -4666,7 +4811,7 @@ dialog.modal::backdrop {
4666
4811
  }
4667
4812
 
4668
4813
  @supports (transition-behavior: allow-discrete) {
4669
- dialog.modal {
4814
+ :where(dialog).modal {
4670
4815
  transition:
4671
4816
  opacity var(--duration) var(--ease-exit),
4672
4817
  transform var(--duration) var(--ease-exit),
@@ -4674,7 +4819,7 @@ dialog.modal::backdrop {
4674
4819
  display var(--duration) allow-discrete;
4675
4820
  }
4676
4821
 
4677
- dialog.modal[open] {
4822
+ :where(dialog).modal[open] {
4678
4823
  transition:
4679
4824
  opacity var(--duration) var(--ease-enter),
4680
4825
  transform var(--duration) var(--ease-enter),
@@ -4682,7 +4827,7 @@ dialog.modal::backdrop {
4682
4827
  display var(--duration) allow-discrete;
4683
4828
  }
4684
4829
 
4685
- dialog.modal::backdrop {
4830
+ :where(dialog).modal::backdrop {
4686
4831
  transition:
4687
4832
  opacity var(--duration) ease,
4688
4833
  overlay var(--duration) allow-discrete,
@@ -4690,7 +4835,7 @@ dialog.modal::backdrop {
4690
4835
  }
4691
4836
  }
4692
4837
 
4693
- dialog.modal[open]::backdrop {
4838
+ :where(dialog).modal[open]::backdrop {
4694
4839
  opacity: 1;
4695
4840
  }
4696
4841
 
@@ -4703,7 +4848,7 @@ html.has-modal-open.had-scrollbar {
4703
4848
  }
4704
4849
 
4705
4850
  @starting-style {
4706
- dialog.modal[open]::backdrop {
4851
+ :where(dialog).modal[open]::backdrop {
4707
4852
  opacity: 0;
4708
4853
  }
4709
4854
  }
@@ -4711,7 +4856,7 @@ html.has-modal-open.had-scrollbar {
4711
4856
  @media (prefers-reduced-motion: no-preference) {
4712
4857
  /* Wrong-close feedback and view-transition transport are motion quirks, not
4713
4858
  presence enter/exit, so they stay on the plain CSS default easing. */
4714
- dialog.modal.is-static {
4859
+ :where(dialog).modal.is-static {
4715
4860
  animation: actual-dialog-static var(--duration-slow) ease;
4716
4861
  }
4717
4862
 
@@ -4792,6 +4937,12 @@ html.has-modal-open.had-scrollbar {
4792
4937
  /*
4793
4938
  * Drawer — modal side-sheet built on the native dialog element.
4794
4939
  *
4940
+ * Docs: docs/pages/components/drawer.md
4941
+ *
4942
+ * Open with a native command button:
4943
+ * command="show-modal" commandfor="<drawer-id>".
4944
+ * For stacked navigation, compose with .nav-list from navbar.css.
4945
+ *
4795
4946
  * Top-layer rendering, ::backdrop, Escape, and modal focus come from the
4796
4947
  * native dialog. CSS owns progressive enter/exit motion where browsers support
4797
4948
  * discrete display/overlay transitions. The drawer is a layout shell only —
@@ -4804,10 +4955,11 @@ html.has-modal-open.had-scrollbar {
4804
4955
  *
4805
4956
  * Internal:
4806
4957
  * --drawer-closed-translate (side/RTL positioning state)
4958
+ * --drawer-focus-bleed
4807
4959
  * --drawer-motion-duration (shared by panel and ::backdrop so exit duration
4808
4960
  * changes in one place; entry state redefines it longer)
4809
4961
  */
4810
- dialog.drawer {
4962
+ :where(dialog).drawer {
4811
4963
  --drawer-size: 20rem;
4812
4964
  --drawer-pad: var(--space-40);
4813
4965
  --drawer-closed-translate: -100%;
@@ -4817,15 +4969,24 @@ dialog.drawer {
4817
4969
 
4818
4970
  /* Keep the surface anchored to the viewport in the top layer. This also
4819
4971
  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. */
4972
+
4973
+ The panel reaches both viewport edges, so it has to follow the space that
4974
+ is actually on screen: --viewport-block resolves to 100dvh and tracks a
4975
+ mobile browser's collapsing URL bar. A percentage resolves against the
4976
+ layout viewport instead, which those browsers size as if the bar were
4977
+ hidden — that put the footer below the fold for as long as it showed. The
4978
+ modal pins the small viewport for the opposite reason: it floats, so a
4979
+ stable height beats a panel that breathes while the bar moves.
4980
+
4981
+ An embedded preview that fakes a viewport overrides --viewport-block on
4982
+ its own shell; the component does not degrade to a percentage for it. */
4822
4983
  position: fixed;
4823
4984
  inset-block-start: 0;
4824
4985
 
4825
4986
  inline-size: min(calc(var(--viewport-inline) * 0.9), var(--drawer-size));
4826
- block-size: 100%;
4987
+ block-size: var(--viewport-block);
4827
4988
  max-inline-size: none;
4828
- max-block-size: 100%;
4989
+ max-block-size: var(--viewport-block);
4829
4990
  margin: 0;
4830
4991
  padding: var(--drawer-pad);
4831
4992
  border: 0;
@@ -4841,7 +5002,7 @@ dialog.drawer {
4841
5002
  transform var(--drawer-motion-duration) var(--ease-exit);
4842
5003
  }
4843
5004
 
4844
- dialog.drawer[open] {
5005
+ :where(dialog).drawer[open] {
4845
5006
  --drawer-motion-duration: var(--duration-slow);
4846
5007
  display: grid;
4847
5008
  grid-template-rows: auto minmax(0, 1fr) auto;
@@ -4856,7 +5017,7 @@ dialog.drawer[open] {
4856
5017
  /* A form is the drawer's content wrapper when actions need method="dialog".
4857
5018
  Span the drawer tracks so the form can keep the header at the top, let the
4858
5019
  body absorb the available height, and keep the footer at the bottom. */
4859
- dialog.drawer > form {
5020
+ :where(dialog).drawer > form {
4860
5021
  display: grid;
4861
5022
  grid-row: 1 / -1;
4862
5023
  grid-template-rows: auto minmax(0, 1fr) auto;
@@ -4865,7 +5026,7 @@ dialog.drawer > form {
4865
5026
  }
4866
5027
 
4867
5028
  /* The shared backdrop token keeps drawer and modal scrims consistent. */
4868
- dialog.drawer::backdrop {
5029
+ :where(dialog).drawer::backdrop {
4869
5030
  background: var(--backdrop-fill);
4870
5031
  opacity: 0;
4871
5032
  /* Scrim curve stays neutral `ease`; only the panel's character curves
@@ -4873,7 +5034,7 @@ dialog.drawer::backdrop {
4873
5034
  transition: opacity var(--drawer-motion-duration) ease;
4874
5035
  }
4875
5036
 
4876
- dialog.drawer[data-side="end"] {
5037
+ :where(dialog).drawer[data-side="end"] {
4877
5038
  --drawer-closed-translate: 100%;
4878
5039
 
4879
5040
  margin-inline-start: auto;
@@ -4882,29 +5043,41 @@ dialog.drawer[data-side="end"] {
4882
5043
  border-inline-start: var(--border-width) solid var(--border);
4883
5044
  }
4884
5045
 
4885
- dialog.drawer[open]::backdrop {
5046
+ :where(dialog).drawer[open]::backdrop {
4886
5047
  opacity: 1;
4887
5048
  }
4888
5049
 
4889
5050
  @starting-style {
4890
- dialog.drawer[open] {
5051
+ :where(dialog).drawer[open] {
4891
5052
  opacity: 0;
4892
5053
  transform: translateX(var(--drawer-closed-translate));
4893
5054
  }
4894
5055
 
4895
- dialog.drawer[open]::backdrop {
5056
+ :where(dialog).drawer[open]::backdrop {
4896
5057
  opacity: 0;
4897
5058
  }
4898
5059
  }
4899
5060
 
4900
- /* Scrollable navigation region inside the drawer. */
4901
- dialog.drawer nav {
5061
+ /* The body region absorbs the panel's height and scrolls. A drawer is a
5062
+ full-height shell by definition, so it always scrolls its body — unlike the
5063
+ modal, which sizes to its content and opts in with .scrollable. Header and
5064
+ footer keep their own tracks; .drawer-close is named because a generic
5065
+ exclusion list is easier to trust than its `position: absolute`. */
5066
+ :where(dialog).drawer > :not(:is(header, footer, form, .drawer-close)),
5067
+ :where(dialog).drawer > form > :not(:is(header, footer, .drawer-close)) {
5068
+ --drawer-focus-bleed: calc(var(--focus-outline-offset) + var(--border-width) * 2);
5069
+
4902
5070
  min-block-size: 0;
4903
- overflow-y: auto;
5071
+ /* Keep focus paint inside the clip without moving the body off the fixed
5072
+ header and footer edges. */
5073
+ margin-inline: calc(var(--drawer-focus-bleed) * -1);
5074
+ padding-inline: var(--drawer-focus-bleed);
5075
+ overflow: auto;
5076
+ overscroll-behavior: contain;
4904
5077
  }
4905
5078
 
4906
5079
  @supports (transition-behavior: allow-discrete) {
4907
- dialog.drawer {
5080
+ :where(dialog).drawer {
4908
5081
  transition:
4909
5082
  opacity var(--drawer-motion-duration) var(--ease-exit),
4910
5083
  transform var(--drawer-motion-duration) var(--ease-exit),
@@ -4912,7 +5085,7 @@ dialog.drawer nav {
4912
5085
  display var(--drawer-motion-duration) allow-discrete;
4913
5086
  }
4914
5087
 
4915
- dialog.drawer[open] {
5088
+ :where(dialog).drawer[open] {
4916
5089
  transition:
4917
5090
  opacity var(--drawer-motion-duration) var(--ease-enter),
4918
5091
  transform var(--drawer-motion-duration) var(--ease-enter),
@@ -4920,7 +5093,7 @@ dialog.drawer nav {
4920
5093
  display var(--drawer-motion-duration) allow-discrete;
4921
5094
  }
4922
5095
 
4923
- dialog.drawer::backdrop {
5096
+ :where(dialog).drawer::backdrop {
4924
5097
  transition:
4925
5098
  opacity var(--drawer-motion-duration) ease,
4926
5099
  overlay var(--drawer-motion-duration) allow-discrete,
@@ -4934,40 +5107,40 @@ dialog.drawer nav {
4934
5107
  directionality and handles nested direction changes; the explicit ltr rules
4935
5108
  neutralize an outer RTL fallback selector. Each specificity group keeps the
4936
5109
  fallback before the :dir() rules. */
4937
- [dir="rtl"] dialog.drawer {
5110
+ [dir="rtl"] :where(dialog).drawer {
4938
5111
  --drawer-closed-translate: 100%;
4939
5112
  }
4940
5113
 
4941
- dialog.drawer:dir(ltr) {
5114
+ :where(dialog).drawer:dir(ltr) {
4942
5115
  --drawer-closed-translate: -100%;
4943
5116
  }
4944
5117
 
4945
- dialog.drawer:dir(rtl) {
5118
+ :where(dialog).drawer:dir(rtl) {
4946
5119
  --drawer-closed-translate: 100%;
4947
5120
  }
4948
5121
 
4949
- [dir="rtl"] dialog.drawer[data-side="end"] {
5122
+ [dir="rtl"] :where(dialog).drawer[data-side="end"] {
4950
5123
  --drawer-closed-translate: -100%;
4951
5124
  }
4952
5125
 
4953
- dialog.drawer[data-side="end"]:dir(ltr) {
5126
+ :where(dialog).drawer[data-side="end"]:dir(ltr) {
4954
5127
  --drawer-closed-translate: 100%;
4955
5128
  }
4956
5129
 
4957
- dialog.drawer[data-side="end"]:dir(rtl) {
5130
+ :where(dialog).drawer[data-side="end"]:dir(rtl) {
4958
5131
  --drawer-closed-translate: -100%;
4959
5132
  }
4960
5133
 
4961
5134
  @media (prefers-reduced-motion: no-preference) {
4962
- dialog.drawer.is-static {
5135
+ :where(dialog).drawer.is-static {
4963
5136
  animation: actual-dialog-static var(--duration-slow) ease;
4964
5137
  }
4965
5138
  }
4966
5139
 
4967
5140
  /* The header reserves inline room so the title never runs under the close
4968
5141
  control. */
4969
- dialog.drawer > header,
4970
- dialog.drawer > form > header {
5142
+ :where(dialog).drawer > header,
5143
+ :where(dialog).drawer > form > header {
4971
5144
  padding-inline-end: calc(var(--control-size) + var(--space-30));
4972
5145
  }
4973
5146
 
@@ -5026,6 +5199,8 @@ dialog.drawer > form > header {
5026
5199
  /*
5027
5200
  * Flyout — floating panel for actions and links.
5028
5201
  *
5202
+ * Docs: docs/pages/enhancements/flyout.md
5203
+ *
5029
5204
  * JavaScript promotes the panel to the top layer and chooses viewport or
5030
5205
  * document coordinates from its anchor. It also owns state (.is-open), focus,
5031
5206
  * and keyboard behavior. CSS owns the surfaced panel appearance, item styles,
@@ -5328,6 +5503,8 @@ dialog.drawer > form > header {
5328
5503
  /*
5329
5504
  * Tooltip — supplemental label overlaid near a trigger.
5330
5505
  *
5506
+ * Docs: docs/pages/enhancements/tooltip.md
5507
+ *
5331
5508
  * Position, placement, and arrow position are managed by JavaScript via left,
5332
5509
  * top, data-placement, and --arrow-x. The runtime also owns `position`: it
5333
5510
  * writes `absolute` for a tip whose trigger scrolls with the page, so the
@@ -5470,6 +5647,8 @@ dialog.drawer > form > header {
5470
5647
  /*
5471
5648
  * Accordion disclosures built on native details and summary.
5472
5649
  *
5650
+ * Docs: docs/pages/components/accordion.md
5651
+ *
5473
5652
  * Keeps the clickable summary affordance aligned and usable without JavaScript
5474
5653
  * or demo styles. Decorative palettes and custom markers belong outside the
5475
5654
  * base component.
@@ -5597,6 +5776,8 @@ dialog.drawer > form > header {
5597
5776
  /*
5598
5777
  * Spinner — compact loading feedback.
5599
5778
  *
5779
+ * Docs: docs/pages/components/spinner.md
5780
+ *
5600
5781
  * Uses currentColor so it adapts to buttons, alerts, badges, and text. The
5601
5782
  * [aria-busy] overlay composition lives in components/busy.css.
5602
5783
  *
@@ -5662,6 +5843,8 @@ dialog.drawer > form > header {
5662
5843
  /*
5663
5844
  * Skeleton placeholders for loading states.
5664
5845
  *
5846
+ * Docs: docs/pages/components/skeleton.md
5847
+ *
5665
5848
  * Shows approximate loading structure without implying real content hierarchy.
5666
5849
  * Geometry, animation, and color stay theme-aware.
5667
5850
  *
@@ -5672,16 +5855,24 @@ dialog.drawer > form > header {
5672
5855
  * --skeleton-track
5673
5856
  * --skeleton-highlight
5674
5857
  */
5858
+ /* A placeholder fills its container up to --skeleton-width; the preset widths
5859
+ are a cap, never an intrinsic size. Stated the other way round — a rem
5860
+ `inline-size` capped by a percentage `max-inline-size` — the cap is ignored
5861
+ during intrinsic sizing, so every shrink-to-fit ancestor inherits the
5862
+ preset as a min-content floor: a card holding a title placeholder could not
5863
+ go below 18rem and pushed a phone's layout sideways. A percentage
5864
+ `inline-size` contributes nothing intrinsically and a rem cap only lowers
5865
+ the contribution, so the same geometry costs no floor. */
5675
5866
  .skeleton {
5676
5867
  --skeleton-size: 1rem;
5677
- --skeleton-width: 100%;
5868
+ --skeleton-width: none;
5678
5869
  --skeleton-radius: var(--radius);
5679
5870
  --skeleton-track: var(--surface-subtle);
5680
5871
  --skeleton-highlight: var(--surface-raised);
5681
5872
 
5682
5873
  display: block;
5683
- inline-size: var(--skeleton-width);
5684
- max-inline-size: 100%;
5874
+ inline-size: 100%;
5875
+ max-inline-size: var(--skeleton-width);
5685
5876
  block-size: var(--skeleton-size);
5686
5877
  border-radius: var(--skeleton-radius);
5687
5878
  background:
@@ -5705,11 +5896,14 @@ dialog.drawer > form > header {
5705
5896
  --skeleton-width: 18rem;
5706
5897
  }
5707
5898
 
5899
+ /* The one preset with a real intrinsic width: a disc is square, so it sizes
5900
+ rather than caps. The floor it does carry is the avatar size, which is small
5901
+ enough never to widen a layout. */
5708
5902
  .skeleton[data-shape="avatar"] {
5709
5903
  --skeleton-size: var(--avatar-size, 2.25rem);
5710
- --skeleton-width: var(--skeleton-size);
5711
5904
  --skeleton-radius: var(--radius-full);
5712
5905
 
5906
+ inline-size: var(--skeleton-size);
5713
5907
  flex: 0 0 auto;
5714
5908
  }
5715
5909
 
@@ -5737,6 +5931,8 @@ dialog.drawer > form > header {
5737
5931
  /*
5738
5932
  * Busy overlay — non-button loading state.
5739
5933
  *
5934
+ * Docs: docs/pages/components/spinner.md
5935
+ *
5740
5936
  * A non-button element with aria-busy="true" and a direct last-child .spinner
5741
5937
  * shows that spinner as a centered overlay over a faded surface. Buttons keep
5742
5938
  * their spinners inline (e.g. .btn[aria-busy] > .spinner stays inline-flex).
@@ -5789,6 +5985,8 @@ dialog.drawer > form > header {
5789
5985
  /*
5790
5986
  * Progress — native progress bar with theme-aware track and value.
5791
5987
  *
5988
+ * Docs: docs/pages/components/progress.md
5989
+ *
5792
5990
  * Consumes the shared --intent/--primary for the value bar and --bar-height
5793
5991
  * for thickness. Indeterminate state animates a shimmer using --duration-shimmer.
5794
5992
  *
@@ -5903,6 +6101,8 @@ dialog.drawer > form > header {
5903
6101
  /*
5904
6102
  * Status bar — singleton floating status area.
5905
6103
  *
6104
+ * Docs: docs/pages/enhancements/status-bar.md
6105
+ *
5906
6106
  * One element in the HTML, empty by default. .is-open is the visual state, set
5907
6107
  * by status.js like any other surface: it lands after the message is written
5908
6108
  * and lifts before the bar is cleared, so the bar leaves in the exact state it
@@ -5994,6 +6194,8 @@ dialog.drawer > form > header {
5994
6194
  /*
5995
6195
  * Avatars for initials, images, links, and buttons.
5996
6196
  *
6197
+ * Docs: docs/pages/components/avatar.md
6198
+ *
5997
6199
  * The visual box stays the same across semantic elements. Grouping and status
5998
6200
  * dots (an empty .badge child) compose around the avatar without changing its
5999
6201
  * meaning.
@@ -6216,6 +6418,8 @@ a.avatar[aria-disabled="true"] {
6216
6418
  /*
6217
6419
  * Indicator — generic corner positioning for overlaid component content.
6218
6420
  *
6421
+ * Docs: docs/pages/components/indicator.md
6422
+ *
6219
6423
  * The container only establishes the containing block. The item owns no
6220
6424
  * paint, size, or ring so badges and other components keep their own visual
6221
6425
  * contract.
@@ -6285,6 +6489,8 @@ a.avatar[aria-disabled="true"] {
6285
6489
  /*
6286
6490
  * Key — keyboard keys and shortcut tokens for UI chrome.
6287
6491
  *
6492
+ * Docs: docs/pages/components/key.md
6493
+ *
6288
6494
  * Use .key outside prose. Plain kbd inside .prose gets the same visual
6289
6495
  * treatment from prose.css so authored content stays convenient.
6290
6496
  *
@@ -6325,6 +6531,8 @@ a.avatar[aria-disabled="true"] {
6325
6531
  /*
6326
6532
  * Message rows composed from an optional avatar, metadata, and bubble.
6327
6533
  *
6534
+ * Docs: docs/pages/components/chat.md
6535
+ *
6328
6536
  * Public hooks:
6329
6537
  * --chat-gap
6330
6538
  * --chat-max-size
@@ -6428,6 +6636,8 @@ a.avatar[aria-disabled="true"] {
6428
6636
  * Button owns the trigger's size, shape, and intent; FAB owns placement and
6429
6637
  * the speed dial's structural open/close markers (--icon-plus / --icon-close).
6430
6638
  *
6639
+ * Docs: docs/pages/components/fab.md
6640
+ *
6431
6641
  * Public hooks:
6432
6642
  * --fab-offset
6433
6643
  * --fab-gap
@@ -6555,6 +6765,8 @@ a.avatar[aria-disabled="true"] {
6555
6765
  /*
6556
6766
  * Rating — integer star rating drawn on native radio inputs.
6557
6767
  *
6768
+ * Docs: docs/pages/components/rating.md
6769
+ *
6558
6770
  * The checked radio remains the functional baseline. :has() progressively
6559
6771
  * fills preceding stars without reversing DOM or keyboard order. Forced
6560
6772
  * colors restores the platform radio presentation.
@@ -6657,6 +6869,8 @@ a.avatar[aria-disabled="true"] {
6657
6869
  /*
6658
6870
  * Steps — semantic ordered-list progression for a multi-step process.
6659
6871
  *
6872
+ * Docs: docs/pages/components/steps.md
6873
+ *
6660
6874
  * One primitive, two explicit compositions. Both classes are required:
6661
6875
  *
6662
6876
  * <ol class="steps steps-horizontal"> horizontal, 2 to 5 stages
@@ -7005,6 +7219,8 @@ a.avatar[aria-disabled="true"] {
7005
7219
  * and item states; app-layout owns whether that surface is a bottom bar or
7006
7220
  * labelled side navigation.
7007
7221
  *
7222
+ * Docs: docs/pages/components/app-navigation.md
7223
+ *
7008
7224
  * Public hooks:
7009
7225
  * --app-nav-size
7010
7226
  * --app-nav-gap
@@ -7090,6 +7306,8 @@ a.avatar[aria-disabled="true"] {
7090
7306
  /*
7091
7307
  * Navbar — top-level navigation bar.
7092
7308
  *
7309
+ * Docs: docs/pages/components/navbar.md
7310
+ *
7093
7311
  * .navbar is a horizontal bar; .navbar-brand is the brand link; .navbar-nav is
7094
7312
  * the inline link list using .nav-link items. .nav-list is the vertical nav
7095
7313
  * list shared with .drawer, fed by the shared --gap token.
@@ -7222,6 +7440,8 @@ a.avatar[aria-disabled="true"] {
7222
7440
  /*
7223
7441
  * Breadcrumb — current-location trail from the site root.
7224
7442
  *
7443
+ * Docs: docs/pages/components/breadcrumb.md
7444
+ *
7225
7445
  * Inline list separated by "/". Mark the current page with `aria-current="page"`
7226
7446
  * either on the item or on the current link.
7227
7447
  */
@@ -7271,6 +7491,8 @@ a.avatar[aria-disabled="true"] {
7271
7491
  /*
7272
7492
  * Pagination — page navigation for paginated lists.
7273
7493
  *
7494
+ * Docs: docs/pages/components/pagination.md
7495
+ *
7274
7496
  * Render .btn elements inside a .pagination list. The active page is marked
7275
7497
  * with aria-current="page"; .btn owns the active state styling.
7276
7498
  *
@@ -7315,6 +7537,8 @@ a.avatar[aria-disabled="true"] {
7315
7537
  /*
7316
7538
  * Tabs — in-place panel switcher with real tab semantics.
7317
7539
  *
7540
+ * Docs: docs/pages/enhancements/tabs.md
7541
+ *
7318
7542
  * JavaScript owns roving tabindex, aria-selected, panel visibility,
7319
7543
  * and arrow-key navigation. CSS owns the tab strip appearance and
7320
7544
  * active indicator.
@@ -7436,6 +7660,8 @@ a.avatar[aria-disabled="true"] {
7436
7660
  * Scrollspy — behavior hook for navigation that marks the active section
7437
7661
  * while the page scrolls.
7438
7662
  *
7663
+ * Docs: docs/pages/enhancements/scrollspy.md
7664
+ *
7439
7665
  * Does not add visual styles by itself. scrollspy.js watches
7440
7666
  * <nav class="scrollspy">
7441
7667
  * containing <a href="#section"> links and toggles aria-current="location" on
@@ -7465,6 +7691,8 @@ a.avatar[aria-disabled="true"] {
7465
7691
  /*
7466
7692
  * Data list — term/value pairs for the properties of a single object.
7467
7693
  *
7694
+ * Docs: docs/pages/components/data-list.md
7695
+ *
7468
7696
  * Expects alternating <dt>/<dd> children: one term, one value per property.
7469
7697
  * A plain <dl> handles richer relationships (several terms or descriptions)
7470
7698
  * and stays untouched by this component.
@@ -7495,6 +7723,8 @@ a.avatar[aria-disabled="true"] {
7495
7723
  * Content determines the number of lines; the component only owns the row
7496
7724
  * geometry, dividers, and supporting-text treatment.
7497
7725
  *
7726
+ * Docs: docs/pages/components/list.md
7727
+ *
7498
7728
  * Public hooks:
7499
7729
  * --list-item-min-size
7500
7730
  * --list-item-pad-block
@@ -7615,6 +7845,8 @@ a.list-item[aria-current]::before {
7615
7845
  /*
7616
7846
  * Table — structured tabular data.
7617
7847
  *
7848
+ * Docs: docs/pages/components/table.md
7849
+ *
7618
7850
  * .table-wrap provides the scroll container and outer chrome; .table owns row
7619
7851
  * and cell rules. Tokens are sourced from the shared scale.
7620
7852
  *
@@ -7623,6 +7855,14 @@ a.list-item[aria-current]::before {
7623
7855
  * --table-cell-pad
7624
7856
  */
7625
7857
  .table-wrap {
7858
+ /* Containing block for anything absolutely positioned inside the table —
7859
+ `.sr-only` on a column header above all. Without it that box resolves
7860
+ against the initial containing block, so it keeps the static position of a
7861
+ cell that may be a thousand pixels along the scrolled row, escapes this
7862
+ scroller, and extends the *document's* scrollable width. The page then
7863
+ scrolls sideways on a phone with nothing painted out there. */
7864
+ position: relative;
7865
+
7626
7866
  inline-size: 100%;
7627
7867
  max-inline-size: 100%;
7628
7868
  min-inline-size: 0;
@@ -7688,6 +7928,8 @@ a.list-item[aria-current]::before {
7688
7928
  /*
7689
7929
  * Meter — native and segmented gauges for known-range values.
7690
7930
  *
7931
+ * Docs: docs/pages/components/meter.md
7932
+ *
7691
7933
  * Maps the three standard meter zones (optimum / suboptimum / even-less-good)
7692
7934
  * to the shared success / warning / danger intent colors.
7693
7935
  *
@@ -7780,6 +8022,8 @@ a.list-item[aria-current]::before {
7780
8022
  /*
7781
8023
  * Join — visually groups adjacent controls into a single unit.
7782
8024
  *
8025
+ * Docs: docs/pages/forms/joined-controls.md
8026
+ *
7783
8027
  * This is a cross-cutting control primitive, not a forms-only component:
7784
8028
  * it applies equally to inputs, selects, buttons, and button-like links.
7785
8029
  *
@@ -7913,6 +8157,8 @@ a.list-item[aria-current]::before {
7913
8157
  /*
7914
8158
  * Decorative intent-colored frame for one direct child.
7915
8159
  *
8160
+ * Docs: docs/pages/components/aura.md
8161
+ *
7916
8162
  * The effect is built from three layers with distinct roles: a frame carrying
7917
8163
  * a short accent of light, a small halo that belongs to the frame and keeps
7918
8164
  * that light attached to the edge, and a wide static halo that belongs to the
@@ -8231,7 +8477,12 @@ a.list-item[aria-current]::before {
8231
8477
  text-decoration: none;
8232
8478
  }
8233
8479
 
8234
- .link-muted:hover,
8480
+ /* :focus-visible stays in its own rule: in a mixed selector list an engine
8481
+ without it would drop the whole rule, taking the :hover styling with it. */
8482
+ .link-muted:hover {
8483
+ color: var(--text);
8484
+ }
8485
+
8235
8486
  .link-muted:focus-visible {
8236
8487
  color: var(--text);
8237
8488
  }