actual-css 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -8
  3. package/components.json +124 -12
  4. package/dist/actual.full.css +1152 -309
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/architecture.md +67 -0
  8. package/docs/design-notes/steps.md +45 -28
  9. package/docs/navigation.json +2 -0
  10. package/docs/pages/components/alert.md +12 -2
  11. package/docs/pages/components/avatar.md +17 -11
  12. package/docs/pages/components/breadcrumb.md +16 -2
  13. package/docs/pages/components/button.md +3 -2
  14. package/docs/pages/components/list.md +15 -16
  15. package/docs/pages/components/navbar.md +42 -22
  16. package/docs/pages/components/pagination.md +19 -0
  17. package/docs/pages/components/segmented.md +83 -0
  18. package/docs/pages/components/steps.md +28 -22
  19. package/docs/pages/enhancements/flyout.md +5 -0
  20. package/docs/pages/enhancements/scrollspy.md +6 -6
  21. package/docs/pages/enhancements/tabs.md +79 -3
  22. package/docs/pages/examples/overview.md +3 -1
  23. package/docs/pages/forms/color-swatches.md +131 -0
  24. package/docs/pages/forms/combobox.md +2 -2
  25. package/docs/pages/forms/controls.md +6 -3
  26. package/docs/pages/forms/floating-field.md +21 -7
  27. package/docs/pages/forms/form-actions.md +3 -6
  28. package/docs/pages/forms/joined-controls.md +6 -2
  29. package/docs/pages/forms/overview.md +1 -0
  30. package/docs/pages/forms/states-and-sizes.md +15 -15
  31. package/docs/pages/forms/switch.md +4 -2
  32. package/docs/pages/foundations/philosophy.md +97 -1
  33. package/docs/pages/foundations/theming.md +1 -1
  34. package/docs/pages/foundations/tokens.md +94 -27
  35. package/docs/pages/guides/design-tools.md +14 -8
  36. package/docs/pages/guides/integrating-actual-css.md +74 -8
  37. package/docs/pages/guides/introduction.md +3 -2
  38. package/docs/pages/guides/modular-import.md +1 -1
  39. package/docs/pages/guides/progressive-enhancement.md +25 -7
  40. package/docs/pages/guides/upgrading.md +86 -9
  41. package/docs/pages/layout/center.md +2 -1
  42. package/docs/pages/layout/cluster.md +11 -4
  43. package/docs/pages/layout/grid.md +2 -0
  44. package/docs/pages/layout/scroll-snap.md +4 -2
  45. package/docs/pages/layout/sidebar-layout.md +3 -0
  46. package/docs/pages/layout/switcher.md +2 -0
  47. package/docs/pages/layout/topbar.md +6 -4
  48. package/docs/pages/patterns/icon-slot.md +17 -1
  49. package/docs/pages/utilities/flex.md +1 -1
  50. package/docs/pages/utilities/sizing-wrapping.md +51 -6
  51. package/package.json +6 -6
  52. package/reserved-classes.json +3 -0
  53. package/src/cli/actual-css.js +4 -4
  54. package/src/css/components/alert.css +99 -48
  55. package/src/css/components/app-nav.css +17 -9
  56. package/src/css/components/avatar.css +2 -18
  57. package/src/css/components/badge.css +23 -10
  58. package/src/css/components/breadcrumb.css +74 -8
  59. package/src/css/components/button.css +17 -2
  60. package/src/css/components/close.css +6 -3
  61. package/src/css/components/drawer.css +23 -42
  62. package/src/css/components/flyout.css +0 -1
  63. package/src/css/components/index.css +1 -0
  64. package/src/css/components/join.css +2 -2
  65. package/src/css/components/list.css +28 -13
  66. package/src/css/components/modal.css +17 -10
  67. package/src/css/components/navbar.css +15 -10
  68. package/src/css/components/pagination.css +5 -0
  69. package/src/css/components/scrollspy.css +1 -1
  70. package/src/css/components/segmented.css +186 -0
  71. package/src/css/components/steps.css +72 -15
  72. package/src/css/components/tab.css +135 -5
  73. package/src/css/core/focus.css +11 -6
  74. package/src/css/core/theme.css +6 -1
  75. package/src/css/core/tokens.css +24 -6
  76. package/src/css/core/variants.css +16 -5
  77. package/src/css/forms/choice-card.css +1 -1
  78. package/src/css/forms/choice.css +1 -1
  79. package/src/css/forms/color-swatch.css +159 -0
  80. package/src/css/forms/control.css +1 -1
  81. package/src/css/forms/floating-field.css +20 -18
  82. package/src/css/forms/form-actions.css +14 -8
  83. package/src/css/forms/index.css +1 -0
  84. package/src/css/forms/native.css +51 -21
  85. package/src/css/forms/otp.css +4 -5
  86. package/src/css/forms/switch.css +6 -3
  87. package/src/css/forms/validation.css +23 -30
  88. package/src/css/layout/center.css +2 -0
  89. package/src/css/layout/cluster.css +14 -0
  90. package/src/css/layout/grid.css +7 -0
  91. package/src/css/layout/scroll-snap.css +30 -0
  92. package/src/css/layout/scroller.css +19 -3
  93. package/src/css/layout/sidebar-layout.css +3 -0
  94. package/src/css/layout/switcher.css +3 -0
  95. package/src/css/layout/topbar.css +10 -0
  96. package/src/css/utilities/extra.css +3 -3
  97. package/src/js/focus.js +16 -1
  98. package/src/js/full.js +4 -0
  99. package/src/js/inline-visible.js +54 -0
  100. package/src/js/parsed.js +21 -0
  101. package/src/js/surface.js +10 -0
  102. package/src/js/tab.js +33 -7
  103. package/src/tooling/color.js +14 -1
  104. package/src/tooling/css-bundle.js +0 -5
  105. package/src/tooling/design-components.js +9 -5
  106. package/src/tooling/design-export.js +9 -1
  107. package/src/tooling/design-tokens.js +207 -76
  108. package/src/tooling/theme-contrast.js +182 -45
@@ -21,15 +21,12 @@
21
21
  * --form-actions-sticky-padding (owned by .form-actions.sticky)
22
22
  */
23
23
 
24
- /* The policy hooks write the properties themselves rather than relaying
25
- --cluster-*: a relay would inherit into any .cluster nested in the row.
26
- These are equal-specificity overrides of .cluster, so they win only because
27
- forms load after layout in the official bundle (tests/css-audit.test.js
28
- guards the order); utilities load later and keep the author's last word. On
29
- this row --cluster-justify/--cluster-align have no effect. */
24
+ /* The role relays its policy into the row's cluster hooks. Cluster hooks stop
25
+ at every nested .cluster, so the relay cannot leak, and this class beats the
26
+ cluster's zero-specificity reset in any import order. */
30
27
  .form-actions {
31
- align-items: var(--form-actions-align, center);
32
- justify-content: var(--form-actions-justify, flex-start);
28
+ --cluster-align: var(--form-actions-align, center);
29
+ --cluster-justify: var(--form-actions-justify, flex-start);
33
30
  }
34
31
 
35
32
  /* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
@@ -60,3 +57,12 @@
60
57
  background: color-mix(in oklch, var(--surface) 88%, transparent);
61
58
  }
62
59
  }
60
+
61
+ /* Same reduce override as .topbar (src/css/layout/topbar.css). */
62
+ @media (prefers-reduced-transparency: reduce) {
63
+ .form-actions.sticky {
64
+ background: var(--surface);
65
+ -webkit-backdrop-filter: none;
66
+ backdrop-filter: none;
67
+ }
68
+ }
@@ -8,6 +8,7 @@
8
8
  @import "./switch.css";
9
9
  @import "./range.css";
10
10
  @import "./choice-card.css";
11
+ @import "./color-swatch.css";
11
12
  @import "./custom-select.css";
12
13
  @import "./floating-field.css";
13
14
  @import "./otp.css";
@@ -7,6 +7,9 @@
7
7
  *
8
8
  * Range has its own custom-styled file (range.css) so the focus ring can
9
9
  * target the thumb pseudo-element rather than the rectangular CSS box.
10
+ *
11
+ * Public hooks:
12
+ * --color-size
10
13
  */
11
14
  .file {
12
15
  color: var(--text-muted);
@@ -25,7 +28,7 @@
25
28
  calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
26
29
  );
27
30
  padding-inline: var(--space-20);
28
- border: var(--border-width) solid var(--border);
31
+ border: var(--border-width) solid var(--border-control);
29
32
  border-radius: var(--radius-sm);
30
33
  background: var(--surface-subtle);
31
34
  color: var(--text);
@@ -52,16 +55,24 @@
52
55
  outline-offset: var(--focus-outline-offset);
53
56
  }
54
57
 
55
- /* Native color picker — keep the browser's dialog and selected-color UI. The
56
- swatch fills almost the whole control so it reads as a color, not as a tiny
57
- square inside a button; the 1px padding keeps a hair of --surface around it. */
58
+ /* Native color picker — keep the browser's dialog and selected-color UI. Same
59
+ geometry as .color-swatch: a --control-size hit area around a round swatch
60
+ the height of a .switch track, so a picker, a palette and a switch compose.
61
+ The input is the hit area; its padding leaves the swatch, which carries the
62
+ edge and the focus ring. Vendor pseudos cannot share a rule. */
58
63
  .color {
59
- inline-size: var(--control-size);
60
- block-size: var(--control-size);
61
- padding: var(--border-width);
62
- border: var(--border-width) solid var(--border);
63
- border-radius: var(--radius);
64
- background: var(--surface);
64
+ --color-size: calc(var(--choice-control-size) + 0.125em);
65
+
66
+ inline-size: max(var(--control-size), var(--color-size));
67
+ block-size: max(var(--control-size), var(--color-size));
68
+ padding: calc((max(var(--control-size), var(--color-size)) - var(--color-size)) / 2);
69
+ /* Lay out at the visible width, as .color-swatch does: the hit area
70
+ overflows on both sides so a label gap is measured from the swatch. */
71
+ margin: 0 calc((var(--color-size) - max(var(--control-size), var(--color-size))) / 2);
72
+ border: 0;
73
+ background: transparent;
74
+ /* Consumed so .sm/.lg reach the em-sized swatch. */
75
+ font-size: var(--control-font-size);
65
76
  cursor: pointer;
66
77
  }
67
78
 
@@ -70,13 +81,21 @@
70
81
  }
71
82
 
72
83
  .color::-webkit-color-swatch {
73
- border: 0;
74
- border-radius: calc(var(--radius) - var(--border-width));
84
+ border: var(--border-width) solid var(--border-control);
85
+ border-radius: var(--radius-full);
75
86
  }
76
87
 
77
88
  .color::-moz-color-swatch {
78
- border: 0;
79
- border-radius: calc(var(--radius) - var(--border-width));
89
+ border: var(--border-width) solid var(--border-control);
90
+ border-radius: var(--radius-full);
91
+ }
92
+
93
+ .color:hover::-webkit-color-swatch {
94
+ border-color: var(--text-subtle);
95
+ }
96
+
97
+ .color:hover::-moz-color-swatch {
98
+ border-color: var(--text-subtle);
80
99
  }
81
100
 
82
101
  .color:disabled {
@@ -84,24 +103,35 @@
84
103
  opacity: var(--disabled-opacity);
85
104
  }
86
105
 
106
+ /* The ring goes around the visible swatch, not the larger hit area. */
87
107
  .color:focus-visible {
108
+ outline: none;
109
+ }
110
+
111
+ .color:focus-visible::-webkit-color-swatch {
88
112
  outline: var(--focus-ring-width) solid var(--focus);
89
113
  outline-offset: var(--focus-outline-offset);
90
114
  }
91
115
 
92
- @media (forced-colors: active) {
93
- .color {
94
- border-color: ButtonText;
95
- background: ButtonFace;
96
- }
116
+ .color:focus-visible::-moz-color-swatch {
117
+ outline: var(--focus-ring-width) solid var(--focus);
118
+ outline-offset: var(--focus-outline-offset);
119
+ }
97
120
 
98
- /* The swatch color is content, so preserve it without disabling the host's
99
- native forced-color focus treatment. Vendor pseudos cannot share a rule. */
121
+ @media (forced-colors: active) {
122
+ /* The swatch color is content, so the swatch opts out of forcing and takes
123
+ system colors for its edge and ring itself. */
100
124
  .color::-webkit-color-swatch {
125
+ --focus: CanvasText;
126
+
101
127
  forced-color-adjust: none;
128
+ border-color: CanvasText;
102
129
  }
103
130
 
104
131
  .color::-moz-color-swatch {
132
+ --focus: CanvasText;
133
+
105
134
  forced-color-adjust: none;
135
+ border-color: CanvasText;
106
136
  }
107
137
  }
@@ -21,7 +21,7 @@
21
21
  --otp-cell-size: var(--control-size);
22
22
  --otp-gap: var(--space-10);
23
23
  --otp-font-size: 1.5em;
24
- --otp-cell-border: var(--border);
24
+ --otp-cell-border: var(--border-control);
25
25
  --otp-cell-bg: var(--surface);
26
26
  --otp-caret-space: calc((var(--otp-cell-size) - 1ch) / 2 + var(--otp-gap) + 1px);
27
27
 
@@ -107,10 +107,9 @@
107
107
  --otp-cell-border: var(--danger);
108
108
  }
109
109
 
110
- @supports selector(:user-invalid) {
111
- .needs-validation .otp > input:user-invalid ~ span {
112
- --otp-cell-border: var(--danger);
113
- }
110
+ /* biome-ignore lint/style/noDescendingSpecificity: all invalid routes write the same border, so their order cannot change the state. Keep :user-invalid separate so unsupported engines retain the other routes. */
111
+ .needs-validation .otp > input:user-invalid ~ span {
112
+ --otp-cell-border: var(--danger);
114
113
  }
115
114
 
116
115
  .otp > input:disabled {
@@ -14,6 +14,7 @@
14
14
  * --switch-block-size
15
15
  * --switch-knob-margin
16
16
  * --switch-knob-size
17
+ * --switch-knob-inline-size
17
18
  * --switch-travel
18
19
  *
19
20
  * The track is sized from --choice-control-size (tokens.css), not
@@ -29,7 +30,7 @@
29
30
 
30
31
  .switch {
31
32
  --switch-bg: var(--border);
32
- --switch-border: var(--border);
33
+ --switch-border: var(--border-control);
33
34
  --switch-knob: var(--surface-raised);
34
35
  --switch-block-size: calc(var(--choice-control-size) + 0.125em);
35
36
  --switch-width: calc(var(--switch-block-size) * 1.8);
@@ -47,9 +48,11 @@
47
48
  2 *
48
49
  var(--switch-knob-margin)
49
50
  );
51
+ /* A wider knob is a capsule; the travel follows its width. */
52
+ --switch-knob-inline-size: var(--switch-knob-size);
50
53
  --switch-travel: calc(
51
54
  var(--switch-width) -
52
- var(--switch-knob-size) -
55
+ var(--switch-knob-inline-size) -
53
56
  2 *
54
57
  var(--border-width) -
55
58
  2 *
@@ -85,7 +88,7 @@
85
88
  position: absolute;
86
89
  inset-block-start: var(--switch-knob-margin);
87
90
  inset-inline-start: var(--switch-knob-margin);
88
- inline-size: var(--switch-knob-size);
91
+ inline-size: var(--switch-knob-inline-size);
89
92
  block-size: var(--switch-knob-size);
90
93
  border-radius: var(--radius-full);
91
94
  background: var(--switch-knob);
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * Imported by default through forms/index.css. Custom builds may omit this
7
7
  * file when an app wants to own validation styling completely.
8
+ * Without :has(), automatic error display and choice-card borders drop;
9
+ * native control states and the explicit .field.danger route stay available.
8
10
  *
9
11
  * State coverage (which states each component ships) is audited in
10
12
  * tests/validation.test.js and summarized in docs/pages/forms/validation.md —
@@ -26,11 +28,14 @@
26
28
  display: block;
27
29
  }
28
30
 
29
- @supports selector(:has(*)) {
30
- .field:has([aria-invalid="true"]) > .field-error,
31
- .needs-validation.was-validated .field:has(:invalid) > .field-error {
32
- display: block;
33
- }
31
+ .field:has([aria-invalid="true"]) > .field-error,
32
+ .needs-validation.was-validated .field:has(:invalid) > .field-error {
33
+ display: block;
34
+ }
35
+
36
+ /* biome-ignore lint/style/noDescendingSpecificity: every invalid route reveals the error; keep :user-invalid separate so unsupported engines retain the other routes. */
37
+ .needs-validation .field:has(:user-invalid) > .field-error {
38
+ display: block;
34
39
  }
35
40
 
36
41
  :where(.input, .textarea, .select, .check, .radio, .switch)[aria-invalid="true"],
@@ -54,39 +59,27 @@
54
59
  --switch-bg: var(--danger);
55
60
  }
56
61
 
57
- @supports selector(:user-invalid) {
58
- .needs-validation .check:is(:checked, :indeterminate):user-invalid,
59
- .needs-validation .radio:checked:user-invalid {
60
- --choice-bg: var(--danger);
61
- }
62
+ .needs-validation .check:is(:checked, :indeterminate):user-invalid,
63
+ .needs-validation .radio:checked:user-invalid {
64
+ --choice-bg: var(--danger);
65
+ }
62
66
 
63
- .needs-validation .switch:checked:user-invalid {
64
- --switch-bg: var(--danger);
65
- }
67
+ .needs-validation .switch:checked:user-invalid {
68
+ --switch-bg: var(--danger);
66
69
  }
67
70
 
68
71
  /* Choice cards hide their native input, so invalid state must surface on the
69
72
  card itself. Sets the same --form-invalid-border hook the card's border
70
73
  consumes. */
71
- @supports selector(:has(*)) {
72
- .choice-card:has([aria-invalid="true"]),
73
- .needs-validation.was-validated .choice-card:has(:invalid) {
74
- --form-invalid-border: var(--danger);
75
- }
74
+ .choice-card:has([aria-invalid="true"]),
75
+ .needs-validation.was-validated .choice-card:has(:invalid) {
76
+ --form-invalid-border: var(--danger);
76
77
  }
77
78
 
78
- @supports selector(:user-invalid) {
79
- .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
80
- --form-invalid-border: var(--danger);
81
- }
79
+ .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
80
+ --form-invalid-border: var(--danger);
82
81
  }
83
82
 
84
- @supports selector(:has(:user-invalid)) {
85
- .needs-validation .field:has(:user-invalid) > .field-error {
86
- display: block;
87
- }
88
-
89
- .needs-validation .choice-card:has(:user-invalid) {
90
- --form-invalid-border: var(--danger);
91
- }
83
+ .needs-validation .choice-card:has(:user-invalid) {
84
+ --form-invalid-border: var(--danger);
92
85
  }
@@ -7,6 +7,8 @@
7
7
  * --center-size
8
8
  * --center-pad
9
9
  */
10
+ /* The hooks inherit on purpose: a site's content width is page policy, so a
11
+ theme sets them once on :root for every .center. */
10
12
  .center {
11
13
  box-sizing: content-box;
12
14
  inline-size: calc(100% - var(--center-pad, 1rem) * 2);
@@ -10,7 +10,21 @@
10
10
  *
11
11
  * Child contract: direct-child margins are reset, spacing comes from --gap.
12
12
  * A ul/ol/menu keeps its semantics but loses list chrome (see below).
13
+ *
14
+ * The hooks describe this row's own geometry, so they stop at each cluster:
15
+ * set them on the cluster itself, or target nested rows with a selector. The
16
+ * gap token is context rhythm and keeps inheriting.
13
17
  */
18
+
19
+ /* Instance boundary: an ancestor's value never reaches a nested cluster.
20
+ Zero specificity, so any class or inline style on the cluster wins without
21
+ depending on import order; `initial` makes the var() fallbacks apply. */
22
+ :where(.cluster) {
23
+ --cluster-align: initial;
24
+ --cluster-justify: initial;
25
+ --cluster-wrap: initial;
26
+ }
27
+
14
28
  .cluster {
15
29
  display: flex;
16
30
  flex-direction: row;
@@ -22,6 +22,13 @@
22
22
  * the thresholds were arrived at: docs/design-notes/grid.md.
23
23
  */
24
24
 
25
+ /* Instance boundary: a nested grid never takes its parent's track template.
26
+ Zero specificity, so a class or inline style on the grid itself wins. */
27
+ :where(.grid) {
28
+ --grid-columns: initial;
29
+ --grid-min: initial;
30
+ }
31
+
25
32
  /* --grid-columns replaces the intrinsic auto-fit template: once set, the grid
26
33
  no longer collapses on its own and the author owns narrow-container
27
34
  collapse. */
@@ -13,16 +13,36 @@
13
13
  * --scroll-snap-align
14
14
  * --scroll-snap-item-size
15
15
  * --scroll-snap-padding
16
+ *
17
+ * Internal:
18
+ * --scroll-snap-focus-bleed
16
19
  */
17
20
  .scroll-snap {
18
21
  --scroll-snap-gap: var(--gap);
19
22
  --scroll-snap-align: start;
20
23
  --scroll-snap-item-size: auto;
24
+ /* Declared here, not as a :root alias: an inherited alias arrives already
25
+ computed, so a --focus-ring-width override on the rail would not reach it. */
26
+ --scroll-snap-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
21
27
 
22
28
  display: flex;
23
29
  flex-direction: row;
24
30
  gap: var(--scroll-snap-gap);
31
+ /* Both axes clip: `auto` on one computes the other to `auto`, so a ring on
32
+ the top or bottom item would be lost too. Every side reserves the ring's
33
+ own width; the block reserve returns to the flow through a negative
34
+ margin, while the inline reserve stays as the rail's focus gutter.
35
+ Bleeding the inline reserve outward instead would make the border box
36
+ `containing block + 2 * reserve` wide, which at the viewport edge grows
37
+ the page: a rail cannot be flush with arbitrary surrounding content and
38
+ free of page overflow at once. */
39
+ margin-block: calc(var(--scroll-snap-focus-bleed) * -1);
40
+ padding-block: var(--scroll-snap-focus-bleed);
41
+ padding-inline: var(--scroll-snap-focus-bleed);
25
42
  overflow-x: auto;
43
+ /* One scroll axis, stated: a fractional content height could otherwise raise
44
+ a phantom vertical scrollbar under one font or zoom step. */
45
+ overflow-y: hidden;
26
46
  }
27
47
 
28
48
  .scroll-snap > * {
@@ -32,7 +52,17 @@
32
52
  @supports (scroll-snap-type: x mandatory) {
33
53
  .scroll-snap {
34
54
  overscroll-behavior-inline: contain;
55
+ /* Fallback only, never the ring's protector: padding-inline above already
56
+ guarantees paint. A unitless `0` drops the clamp below but keeps this
57
+ declaration, so the worst case is a snapped item flush with the gutter,
58
+ not a clipped ring. */
35
59
  scroll-padding-inline: var(--scroll-snap-padding, var(--scroll-snap-gap));
60
+ /* A conforming <length> stays at least the focus reserve, so scroll and
61
+ snap stop with the gutter's air. */
62
+ scroll-padding-inline: max(
63
+ var(--scroll-snap-padding, var(--scroll-snap-gap)),
64
+ var(--scroll-snap-focus-bleed)
65
+ );
36
66
  /* x then inline: an engine that lacks the logical keyword keeps x.
37
67
  proximity is the default, so a short rail cannot trap the pointer. */
38
68
  scroll-snap-type: x proximity;
@@ -13,7 +13,16 @@
13
13
  * draw their native scrollbar — the intended fallback, not a broken state.
14
14
  *
15
15
  * Styles the scrollbar only; it does not create the overflow. Pair it with
16
- * .overflow-auto or a component that already scrolls.
16
+ * .overflow-auto or a component that already scrolls (horizontal steps, a
17
+ * table wrap; tabs hide their bar and fade their own edges). No edge fade
18
+ * here: a mask would dictate the strip's scroll-padding and focus geometry for
19
+ * a decorative cue, and an item clipped at the edge already says the strip
20
+ * continues.
21
+ *
22
+ * Not on touch: Chromium trades the overlay scrollbar of a touch screen,
23
+ * hidden at rest, for a classic always-visible one as soon as scrollbar-color
24
+ * or scrollbar-width is set. On a phone that painted a permanent bar under
25
+ * every strip; the native overlay is the better cue there.
17
26
  *
18
27
  * Public hooks:
19
28
  * --scroller-track
@@ -22,9 +31,16 @@
22
31
  .scroller {
23
32
  --scroller-track: transparent;
24
33
  --scroller-thumb: var(--border);
34
+ }
25
35
 
26
- scrollbar-color: var(--scroller-thumb) var(--scroller-track);
27
- scrollbar-width: thin;
36
+ /* Any primary pointer but touch: a keyboard-only or pointerless device
37
+ (pointer: none) draws classic scrollbars too. `all and` keeps the negation
38
+ valid in Media Queries 3 parsers. */
39
+ @media not all and (pointer: coarse) {
40
+ .scroller {
41
+ scrollbar-color: var(--scroller-thumb) var(--scroller-track);
42
+ scrollbar-width: thin;
43
+ }
28
44
  }
29
45
 
30
46
  /* Opt-in gutter: reserves space so the layout does not shift when
@@ -17,6 +17,9 @@
17
17
  * visual, reading and keyboard order all agree — the role rules swap so the
18
18
  * aside still takes its preferred width and the main still reclaims the row.
19
19
  */
20
+ /* No instance reset, unlike .cluster and .grid: the regions read the hooks,
21
+ and a layout nested as a region takes its own size from its parent's
22
+ values, so the hooks must reach it. */
20
23
  .sidebar-layout {
21
24
  display: flex;
22
25
  flex-direction: row;
@@ -12,6 +12,9 @@
12
12
  *
13
13
  * Child contract: every direct child is a peer; the flex-basis applies to all.
14
14
  */
15
+ /* No instance reset, unlike .cluster and .grid: the children read the hook,
16
+ and a switcher nested as a direct child takes its own flex-basis from its
17
+ parent's value, so the hook must reach it. */
15
18
  .switcher {
16
19
  display: flex;
17
20
  flex-direction: row;
@@ -45,3 +45,13 @@
45
45
  -webkit-backdrop-filter: blur(0.75rem);
46
46
  }
47
47
  }
48
+
49
+ /* A reduce override, not a no-preference gate: a browser without the feature
50
+ evaluates it false and keeps the frosted bar instead of losing it. */
51
+ @media (prefers-reduced-transparency: reduce) {
52
+ .topbar {
53
+ background: var(--surface);
54
+ backdrop-filter: none;
55
+ -webkit-backdrop-filter: none;
56
+ }
57
+ }
@@ -84,9 +84,9 @@
84
84
  flex-wrap: nowrap;
85
85
  }
86
86
 
87
- /* The property is written directly, never relayed through the cluster hook:
88
- a relay would inherit into every .cluster nested inside. Utilities load last,
89
- so the direct declaration already beats .cluster at equal specificity. */
87
+ /* The property is written directly, so the utility works on any flex row, not
88
+ only a .cluster. Utilities load last, so the direct declaration beats
89
+ .cluster at equal specificity. */
90
90
  .justify-content-start {
91
91
  justify-content: flex-start;
92
92
  }
package/src/js/focus.js CHANGED
@@ -44,8 +44,23 @@ export function getFocusable(root) {
44
44
  return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
45
45
  }
46
46
 
47
+ /* Tab reaches a radio group at its checked radio, or at its first one when none
48
+ is checked; the other radios are arrow-key stops only. Entering a panel on
49
+ an unchecked radio put focus on a value the user had not picked. */
50
+ function isRadioTabStop(el) {
51
+ if (el.type !== "radio" || el.checked || !el.name) return true;
52
+ // form.elements also holds controls associated through form="…".
53
+ const candidates = el.form
54
+ ? [...el.form.elements]
55
+ : [...el.getRootNode().querySelectorAll('input[type="radio"]')];
56
+ return !candidates.some(
57
+ (radio) =>
58
+ radio.type === "radio" && radio.name === el.name && radio.form === el.form && radio.checked,
59
+ );
60
+ }
61
+
47
62
  export function focusFirstDescendant(root) {
48
- const target = getFocusable(root)[0];
63
+ const target = getFocusable(root).filter(isRadioTabStop)[0];
49
64
  target?.focus();
50
65
  return !!target;
51
66
  }
package/src/js/full.js CHANGED
@@ -4,6 +4,8 @@
4
4
  * Each module self-registers via observer: importing this bundle enables
5
5
  * common behaviors. No DOMContentLoaded ceremony, no init calls.
6
6
  * Injected content wires automatically; removal cleans up automatically.
7
+ * The entry also exposes the public enhancement API from the same module
8
+ * graph, so adopters extend the runtime they already run.
7
9
  *
8
10
  * A plain `<script src="actual.full.js"></script>` (no bundler, no import) still
9
11
  * gets the full runtime, including declarative triggers like status's
@@ -25,3 +27,5 @@ import "./range.js";
25
27
  import "./validation.js";
26
28
  import "./status.js";
27
29
  import "./index.js";
30
+
31
+ export { applyEnhancement, default as enhance, registerEnhancement } from "./enhance.js";
@@ -0,0 +1,54 @@
1
+ /*
2
+ * Brings a target into view inside one horizontal scrollport — internal, not
3
+ * a package export. Tabs call it at connect, so a selected tab rendered
4
+ * off-screen starts visible, and on every selection.
5
+ *
6
+ * Contract:
7
+ * - Scrolls `container` only. scrollIntoView() would also scroll ancestors and
8
+ * the page, so a strip below the fold would yank the reader down to it.
9
+ * - Horizontal, nearest only: a target already visible stays put; one that
10
+ * overflows a single edge moves the minimum to show that edge; one wider
11
+ * than the visible area that covers it on both sides stays put too.
12
+ * - The visible area is the scrollport minus its computed scroll-padding, the
13
+ * native vocabulary also honoured by focus scrolling: a component that
14
+ * keeps an edge clear (a strip's focus line) declares it there, not here.
15
+ * - The delta comes from physical rects and goes through scrollBy(), so RTL
16
+ * needs no scrollLeft sign convention.
17
+ * - Rects are in transformed pixels; clientWidth, scroll-padding and scrollBy()
18
+ * are in layout pixels. The strip's own scale converts between them, so a
19
+ * strip under transform: scale() reveals the same item. Rotation is out of
20
+ * scope.
21
+ * - Inline axis only: the block scroll position is left as it is. Callers are
22
+ * one-row strips whose block axis does not scroll.
23
+ * - Requires a laid-out, visible container and target. A strip hidden at
24
+ * connect (inside a closed dialog or drawer) or a target without a box (a
25
+ * current link inside a closed flyout) gets a no-op, not a deferred retry.
26
+ */
27
+
28
+ // Sub-pixel layout must not read as overflow and nudge an aligned strip.
29
+ const TOLERANCE = 1;
30
+
31
+ // scroll-padding computes to `auto`, a length, or a percentage of the
32
+ // scrollport; auto adds no inset. A calc() mixing the two reads as none.
33
+ const inset = (value, scrollport) =>
34
+ value.endsWith("%")
35
+ ? (Number.parseFloat(value) * scrollport) / 100
36
+ : Number.parseFloat(value) || 0;
37
+
38
+ export function ensureInlineVisible(container, target) {
39
+ const width = container?.clientWidth;
40
+ if (!width || !target?.getClientRects().length) return;
41
+ const style = getComputedStyle(container);
42
+ const box = container.getBoundingClientRect();
43
+ const scale = box.width / container.offsetWidth || 1;
44
+ const start = box.left + (container.clientLeft + inset(style.scrollPaddingLeft, width)) * scale;
45
+ const end =
46
+ box.left + (container.clientLeft + width - inset(style.scrollPaddingRight, width)) * scale;
47
+ const rect = target.getBoundingClientRect();
48
+ const before = rect.left < start - TOLERANCE;
49
+ const after = rect.right > end + TOLERANCE;
50
+ if (before === after) return;
51
+ const delta = before ? rect.left - start : rect.right - end;
52
+ // A placement, not a transition: it must not animate under scroll-behavior: smooth.
53
+ container.scrollBy({ left: delta / scale, behavior: "instant" });
54
+ }
@@ -0,0 +1,21 @@
1
+ /*
2
+ * Runs a callback once the document has finished parsing — internal, not a
3
+ * package export.
4
+ *
5
+ * The runtime connects an element as soon as the parser inserts it. With a
6
+ * classic script in <head>, a <script> inside the element hands control back
7
+ * mid-parse, so an enhancer that reads its children at connect can find them
8
+ * missing. Deferring that one read to DOMContentLoaded needs no observer; after
9
+ * parsing the callback runs at once. Returns a cancel for the enhancer's
10
+ * cleanup, so an element removed before the document is parsed does nothing.
11
+ */
12
+ export function afterParse(node, callback) {
13
+ const doc = node.ownerDocument;
14
+ if (doc.readyState !== "loading") {
15
+ callback();
16
+ return () => {};
17
+ }
18
+ const controller = new AbortController();
19
+ doc.addEventListener("DOMContentLoaded", callback, { once: true, signal: controller.signal });
20
+ return () => controller.abort();
21
+ }
package/src/js/surface.js CHANGED
@@ -386,8 +386,17 @@ export function disconnectSurface(menu) {
386
386
  }
387
387
  }
388
388
 
389
+ /* Arrow keys in a radio group check the next radio by dispatching a click
390
+ (detail 0, no pointer). That click is navigation, not a pick: closing on it
391
+ shut a palette on the first arrow press, before the user could reach the
392
+ color they wanted. Space dispatches the same click and is left alone too. */
393
+ function isKeyboardRadioMove(e) {
394
+ return e.detail === 0 && e.target?.matches?.('input[type="radio"]') === true;
395
+ }
396
+
389
397
  function onDocumentClick(e) {
390
398
  const path = e.composedPath();
399
+ const radioMove = isKeyboardRadioMove(e);
391
400
 
392
401
  for (const menu of openSurfaces) {
393
402
  if (menu.ownerDocument !== e.currentTarget) continue;
@@ -396,6 +405,7 @@ function onDocumentClick(e) {
396
405
 
397
406
  const menuIndex = path.indexOf(menu);
398
407
  const isInside = menuIndex >= 0;
408
+ if (isInside && radioMove) continue;
399
409
  const hasCloseTrigger =
400
410
  isInside &&
401
411
  path