actual-css 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +25 -6
- package/components.json +12 -18
- package/dist/actual.full.css +780 -479
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/brand/favicon.svg +5 -0
- package/docs/brand/lockup.svg +12 -0
- package/docs/brand/logo-dark.svg +9 -0
- package/docs/brand/logo-light.svg +9 -0
- package/docs/brand/logo.svg +11 -0
- package/docs/brand/mark.svg +11 -0
- package/docs/design-notes/architecture.md +67 -0
- package/docs/design-notes/steps.md +45 -28
- package/docs/navigation.json +3 -2
- package/docs/pages/components/alert.md +2 -10
- package/docs/pages/components/avatar.md +17 -11
- package/docs/pages/components/badge.md +3 -9
- package/docs/pages/components/breadcrumb.md +16 -2
- package/docs/pages/components/card.md +31 -2
- package/docs/pages/components/dialog.md +121 -33
- package/docs/pages/components/drawer.md +2 -2
- package/docs/pages/components/list.md +9 -13
- package/docs/pages/components/navbar.md +49 -30
- package/docs/pages/components/pagination.md +19 -0
- package/docs/pages/components/steps.md +24 -20
- package/docs/pages/components/table.md +3 -3
- package/docs/pages/enhancements/flyout.md +2 -2
- package/docs/pages/enhancements/scrollspy.md +7 -7
- package/docs/pages/enhancements/status-bar.md +2 -2
- package/docs/pages/enhancements/tabs.md +74 -3
- package/docs/pages/examples/overview.md +2 -1
- package/docs/pages/forms/date-picker.md +90 -0
- package/docs/pages/forms/detached-actions.md +37 -30
- package/docs/pages/forms/floating-field.md +21 -7
- package/docs/pages/forms/form-actions.md +58 -28
- package/docs/pages/forms/input-policy.md +1 -1
- package/docs/pages/forms/overview.md +6 -5
- package/docs/pages/forms/states-and-sizes.md +15 -15
- package/docs/pages/forms/sticky-actions.md +85 -83
- package/docs/pages/forms/validation.md +5 -5
- package/docs/pages/foundations/philosophy.md +102 -4
- package/docs/pages/foundations/theming.md +15 -4
- package/docs/pages/foundations/tokens.md +89 -36
- package/docs/pages/foundations/typography.md +4 -7
- package/docs/pages/guides/bootstrap.md +2 -2
- package/docs/pages/guides/design-tools.md +2 -2
- package/docs/pages/guides/integrating-actual-css.md +79 -9
- package/docs/pages/guides/introduction.md +5 -5
- package/docs/pages/guides/modular-import.md +3 -0
- package/docs/pages/guides/progressive-enhancement.md +27 -9
- package/docs/pages/guides/tailwind.md +70 -0
- package/docs/pages/guides/upgrading.md +151 -0
- package/docs/pages/layout/center.md +2 -1
- package/docs/pages/layout/cluster.md +37 -4
- package/docs/pages/layout/grid.md +2 -0
- package/docs/pages/layout/overview.md +1 -1
- package/docs/pages/layout/sidebar-layout.md +3 -0
- package/docs/pages/layout/stack.md +21 -16
- package/docs/pages/layout/switcher.md +2 -0
- package/docs/pages/layout/topbar.md +7 -5
- package/docs/pages/patterns/icon-slot.md +17 -1
- package/docs/pages/patterns/navigation-list.md +16 -46
- package/docs/pages/patterns/overview.md +3 -4
- package/docs/pages/utilities/flex.md +30 -20
- package/docs/pages/utilities/sizing-wrapping.md +52 -7
- package/docs/pages/utilities/spacing.md +4 -3
- package/package.json +6 -6
- package/reserved-classes.json +3 -3
- package/skills/actual-css/references/enhancements.md +10 -7
- package/src/cli/actual-css.js +39 -13
- package/src/css/components/alert.css +34 -34
- package/src/css/components/avatar.css +8 -17
- package/src/css/components/badge.css +1 -6
- package/src/css/components/breadcrumb.css +74 -8
- package/src/css/components/button.css +3 -9
- package/src/css/components/card.css +18 -10
- package/src/css/components/chat.css +4 -5
- package/src/css/components/drawer.css +42 -45
- package/src/css/components/flyout.css +3 -1
- package/src/css/components/index.css +0 -1
- package/src/css/components/join.css +2 -2
- package/src/css/components/list.css +10 -10
- package/src/css/components/modal.css +113 -134
- package/src/css/components/navbar.css +22 -31
- package/src/css/components/pagination.css +5 -0
- package/src/css/components/scrollspy.css +1 -1
- package/src/css/components/steps.css +66 -12
- package/src/css/components/tab.css +134 -4
- package/src/css/core/base.css +1 -1
- package/src/css/core/focus.css +8 -6
- package/src/css/core/reset.css +2 -1
- package/src/css/core/theme.css +10 -1
- package/src/css/core/tokens.css +32 -12
- package/src/css/core/variants.css +41 -14
- package/src/css/forms/choice-card.css +1 -1
- package/src/css/forms/choice.css +1 -1
- package/src/css/forms/control.css +1 -1
- package/src/css/forms/floating-field.css +20 -18
- package/src/css/forms/form-actions.css +14 -12
- package/src/css/forms/native.css +2 -2
- package/src/css/forms/otp.css +4 -5
- package/src/css/forms/switch.css +1 -1
- package/src/css/forms/validation.css +23 -30
- package/src/css/layout/center.css +2 -0
- package/src/css/layout/cluster.css +25 -0
- package/src/css/layout/grid.css +7 -0
- package/src/css/layout/scroller.css +19 -3
- package/src/css/layout/sidebar-layout.css +3 -0
- package/src/css/layout/switcher.css +3 -0
- package/src/css/layout/topbar.css +12 -6
- package/src/css/utilities/base.css +5 -0
- package/src/css/utilities/extra.css +3 -7
- package/src/js/full.js +4 -0
- package/src/js/inline-visible.js +54 -0
- package/src/js/parsed.js +21 -0
- package/src/js/tab.js +33 -7
- package/src/tooling/color.js +65 -0
- package/src/tooling/design-export.js +1 -1
- package/src/tooling/design-tokens.js +53 -34
- package/src/tooling/theme-contrast.js +217 -0
- package/docs/pages/patterns/actions.md +0 -52
- package/src/css/components/actions.css +0 -27
package/dist/actual.full.css
CHANGED
|
@@ -41,7 +41,8 @@ select {
|
|
|
41
41
|
|
|
42
42
|
*,
|
|
43
43
|
*::before,
|
|
44
|
-
*::after
|
|
44
|
+
*::after,
|
|
45
|
+
::backdrop {
|
|
45
46
|
animation-duration: 0.01ms !important;
|
|
46
47
|
animation-delay: 0s !important;
|
|
47
48
|
animation-iteration-count: 1 !important;
|
|
@@ -72,7 +73,6 @@ select {
|
|
|
72
73
|
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
73
74
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
74
75
|
--font-width: 100%;
|
|
75
|
-
--font-width-dense: 95%;
|
|
76
76
|
--line-height: 1.5;
|
|
77
77
|
--line-height-tight: 1.25;
|
|
78
78
|
--line-height-relaxed: 1.75;
|
|
@@ -134,9 +134,11 @@ select {
|
|
|
134
134
|
without a component ring (links, summary, plain items). Components draw
|
|
135
135
|
the themed ring instead: --focus-ring-width solid --focus. See focus.css
|
|
136
136
|
for the two contracts. Both share --focus-ring-width: they differ in
|
|
137
|
-
color only.
|
|
137
|
+
color only. No --focus-outline alias here: declared on :root it would be
|
|
138
|
+
inherited already computed, so a theme island or a region setting
|
|
139
|
+
--focus-ring-width would not reach it. focus.css composes the line on the
|
|
140
|
+
focused element instead. */
|
|
138
141
|
--focus-outline-color: currentColor;
|
|
139
|
-
--focus-outline: var(--focus-ring-width) solid var(--focus-outline-color);
|
|
140
142
|
--focus-outline-offset: 2px;
|
|
141
143
|
--focus-ring-width: 2px;
|
|
142
144
|
|
|
@@ -156,8 +158,9 @@ select {
|
|
|
156
158
|
--bar-height: 0.65rem;
|
|
157
159
|
|
|
158
160
|
/* Controls — inputs and buttons share --control-size so heights always match.
|
|
159
|
-
.compact/.spacious (variants.css) switch geometry
|
|
160
|
-
--control-font-size. Local .sm/.lg
|
|
161
|
+
.compact/.spacious (variants.css) switch geometry (--control-size,
|
|
162
|
+
--control-pad-x) without changing --control-font-size. Local .sm/.lg
|
|
163
|
+
component sizes switch all three.
|
|
161
164
|
|
|
162
165
|
Inline choices (checkbox, radio, switch) are typographic, not field-sized:
|
|
163
166
|
they sit on a line of text, so they follow --control-font-size through
|
|
@@ -168,17 +171,35 @@ select {
|
|
|
168
171
|
|
|
169
172
|
--control-pad-x is the opposite kind of token: a resolved geometry length,
|
|
170
173
|
not an em ratio. Every part of one control must see the same horizontal
|
|
171
|
-
distance, so it is stated in
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
distance, so it is stated in lengths rather than an em whose value would
|
|
175
|
+
change on a descendant with its own font size (the input-icon glyph, made
|
|
176
|
+
larger by --input-icon-size).
|
|
177
|
+
|
|
178
|
+
Like --control-size, it is a scale plus a selection: themes set the
|
|
179
|
+
-sm/-md/-lg steps, and :root, .sm/.lg and .compact/.spacious (variants.css)
|
|
180
|
+
pick one. A theme that wrote --control-pad-x itself would be overwritten
|
|
181
|
+
by every size and density boundary — bootstrap-v6's 12px became a wider
|
|
182
|
+
13px inside .compact. The selection is rebound at each boundary because a
|
|
183
|
+
var() inside a custom property resolves where it is declared.
|
|
184
|
+
|
|
185
|
+
The boundaries also write --control-step-sm/-md/-lg: which step is
|
|
186
|
+
selected, as 0/1 weights that mean the same under every theme. A theme
|
|
187
|
+
that sets its own steps rebuilds the selection from them in its own block
|
|
188
|
+
(bootstrap-v6.css), so a nested island of it reads its steps without
|
|
189
|
+
resetting the density it sits in. Never rebind on every [data-theme]: a
|
|
190
|
+
palette-only island then became a geometry boundary that an inherited
|
|
191
|
+
--control-size or --control-pad-x no longer crossed. */
|
|
177
192
|
--control-size-sm: 2rem;
|
|
178
193
|
--control-size-md: 2.375rem;
|
|
179
194
|
--control-size-lg: 2.75rem;
|
|
180
195
|
--control-size: var(--control-size-md);
|
|
181
|
-
--control-pad-x: var(--font-size-
|
|
196
|
+
--control-pad-x-sm: var(--font-size-xs);
|
|
197
|
+
--control-pad-x-md: var(--font-size-md);
|
|
198
|
+
--control-pad-x-lg: var(--font-size-lg);
|
|
199
|
+
--control-pad-x: var(--control-pad-x-md);
|
|
200
|
+
--control-step-sm: 0;
|
|
201
|
+
--control-step-md: 1;
|
|
202
|
+
--control-step-lg: 0;
|
|
182
203
|
--control-font-size: var(--font-size-md);
|
|
183
204
|
--choice-control-size: 1.125em;
|
|
184
205
|
--disabled-opacity: 0.65;
|
|
@@ -296,7 +317,9 @@ select {
|
|
|
296
317
|
* Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
|
|
297
318
|
* other tokens; hard-coded overrides on a custom theme will break the intent.
|
|
298
319
|
* Exception: --surface-opaque and --surface-solid-fg are public; a theme with
|
|
299
|
-
* translucent surfaces overrides them with opaque values.
|
|
320
|
+
* translucent surfaces overrides them with opaque values. --border-control is
|
|
321
|
+
* public too: the resting edge of a neutral control, apart from structural
|
|
322
|
+
* separators.
|
|
300
323
|
* Internal: --hgroup-gap.
|
|
301
324
|
*/
|
|
302
325
|
:root {
|
|
@@ -367,6 +390,10 @@ select {
|
|
|
367
390
|
fields, a solid band), which rules out the primary itself. */
|
|
368
391
|
--focus: hsl(268 30% 55%);
|
|
369
392
|
--hover-overlay: hsl(260 20% 10% / 0.04);
|
|
393
|
+
/* Solid fills take a stronger wash than the subtle shared overlay. Black in
|
|
394
|
+
both schemes on purpose: a solid button darkens on hover even in dark mode
|
|
395
|
+
(see themes/gradient.css), unlike the neutral washes. */
|
|
396
|
+
--hover-overlay-solid: rgb(0 0 0 / 0.14);
|
|
370
397
|
|
|
371
398
|
/* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
|
|
372
399
|
--radius: 0.5rem;
|
|
@@ -394,6 +421,9 @@ select {
|
|
|
394
421
|
never a fill. --surface-solid-fg is the ink on --surface-solid. */
|
|
395
422
|
--surface-opaque: var(--surface);
|
|
396
423
|
--surface-solid-fg: var(--surface);
|
|
424
|
+
/* The edge that tells a control apart from the surface, where --border only
|
|
425
|
+
separates surfaces: fields, choices, switch, OTP cells, choice cards. */
|
|
426
|
+
--border-control: var(--border);
|
|
397
427
|
}
|
|
398
428
|
|
|
399
429
|
/* Soft foreground hooks — the soft ink is the intent rebated toward the local
|
|
@@ -515,7 +545,7 @@ select {
|
|
|
515
545
|
* Public hooks:
|
|
516
546
|
* --link
|
|
517
547
|
* --hr-space
|
|
518
|
-
* --font-width
|
|
548
|
+
* --font-width (the document's font-stretch; a theme typography choice)
|
|
519
549
|
*/
|
|
520
550
|
|
|
521
551
|
body {
|
|
@@ -665,17 +695,21 @@ small {
|
|
|
665
695
|
* components/badge.css — .badge default soft behind explicit --ui-*
|
|
666
696
|
* components/alert.css — .alert default soft behind explicit --ui-*
|
|
667
697
|
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
698
|
+
* Three axes, each predictable on its own:
|
|
699
|
+
* .sm/.lg size — typography + geometry, local to a component
|
|
700
|
+
* .compact/.spacious density — rhythm + geometry, inherited by a region
|
|
701
|
+
* .tight/.loose rhythm — --gap only, inherited by a region
|
|
702
|
+
*
|
|
703
|
+
* Density never touches typography: no font size, icon size or font width.
|
|
704
|
+
* A width bonus through font-stretch only showed on typefaces with a wdth axis,
|
|
705
|
+
* so .compact meant something different per OS and font; a condensed table is
|
|
706
|
+
* a typographic decision for the application. Controls consume --control-size
|
|
707
|
+
* and --control-pad-x, so a compact control is shorter and narrower (a height
|
|
708
|
+
* change alone read as the same control squashed); non-control components may
|
|
671
709
|
* consume --density-* when the context should affect their local rhythm.
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
* --font-width, so a spacious island inside a compact context returns to the
|
|
676
|
-
* normal width exactly as it restores its own gap, spacing, and control size.
|
|
677
|
-
* Themes whose typeface has no useful width axis opt out with
|
|
678
|
-
* --font-width-dense: 100%.
|
|
710
|
+
*
|
|
711
|
+
* .tight/.loose reuse the density --gap steps, so a tight row and a compact
|
|
712
|
+
* one share one rhythm.
|
|
679
713
|
*
|
|
680
714
|
* .sm/.lg are local size roles, not shared measurements. Shared control
|
|
681
715
|
* participants below map both typography and geometry; components with
|
|
@@ -714,18 +748,35 @@ small {
|
|
|
714
748
|
--ui-border: var(--border);
|
|
715
749
|
}
|
|
716
750
|
|
|
751
|
+
/* Each boundary selects a control step twice: the values, resolved here, and
|
|
752
|
+
the 0/1 step weights a nested [data-theme] island rebuilds them from
|
|
753
|
+
(tokens.css). */
|
|
717
754
|
.compact {
|
|
718
755
|
--gap: var(--space-20);
|
|
719
756
|
--density-space: var(--space-20);
|
|
720
757
|
--control-size: var(--control-size-sm);
|
|
721
|
-
|
|
758
|
+
--control-pad-x: var(--control-pad-x-sm);
|
|
759
|
+
--control-step-sm: 1;
|
|
760
|
+
--control-step-md: 0;
|
|
761
|
+
--control-step-lg: 0;
|
|
722
762
|
}
|
|
723
763
|
|
|
724
764
|
.spacious {
|
|
725
765
|
--gap: var(--space-50);
|
|
726
766
|
--density-space: var(--space-50);
|
|
727
767
|
--control-size: var(--control-size-lg);
|
|
728
|
-
|
|
768
|
+
--control-pad-x: var(--control-pad-x-lg);
|
|
769
|
+
--control-step-sm: 0;
|
|
770
|
+
--control-step-md: 0;
|
|
771
|
+
--control-step-lg: 1;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.tight {
|
|
775
|
+
--gap: var(--space-20);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.loose {
|
|
779
|
+
--gap: var(--space-50);
|
|
729
780
|
}
|
|
730
781
|
|
|
731
782
|
/* Shared control scale. Wrappers are listed when their descendants form one
|
|
@@ -754,7 +805,10 @@ small {
|
|
|
754
805
|
).sm {
|
|
755
806
|
--control-size: var(--control-size-sm);
|
|
756
807
|
--control-font-size: var(--font-size-sm);
|
|
757
|
-
--control-pad-x: var(--
|
|
808
|
+
--control-pad-x: var(--control-pad-x-sm);
|
|
809
|
+
--control-step-sm: 1;
|
|
810
|
+
--control-step-md: 0;
|
|
811
|
+
--control-step-lg: 0;
|
|
758
812
|
}
|
|
759
813
|
|
|
760
814
|
:where(
|
|
@@ -778,7 +832,10 @@ small {
|
|
|
778
832
|
).lg {
|
|
779
833
|
--control-size: var(--control-size-lg);
|
|
780
834
|
--control-font-size: var(--font-size-lg);
|
|
781
|
-
--control-pad-x: var(--
|
|
835
|
+
--control-pad-x: var(--control-pad-x-lg);
|
|
836
|
+
--control-step-sm: 0;
|
|
837
|
+
--control-step-md: 0;
|
|
838
|
+
--control-step-lg: 1;
|
|
782
839
|
}
|
|
783
840
|
|
|
784
841
|
/* Modern color enhancement. Baseline variant values above remain usable
|
|
@@ -826,16 +883,18 @@ small {
|
|
|
826
883
|
Documented exception: an invalid field focuses in --form-invalid-border
|
|
827
884
|
(danger), so the error stays visible at the point of correction. The border
|
|
828
885
|
carries the same hook; report:theme-contrast measures that line at 3:1
|
|
829
|
-
against the field's background alongside --focus.
|
|
886
|
+
against the field's background alongside --focus.
|
|
887
|
+
|
|
888
|
+
Public hooks:
|
|
889
|
+
--focus-outline (fallback-only: replaces the whole generic line)
|
|
890
|
+
*/
|
|
830
891
|
:focus {
|
|
831
|
-
outline: var(--focus-outline);
|
|
892
|
+
outline: var(--focus-outline, var(--focus-ring-width) solid var(--focus-outline-color));
|
|
832
893
|
outline-offset: var(--focus-outline-offset);
|
|
833
894
|
}
|
|
834
895
|
|
|
835
|
-
|
|
836
|
-
:
|
|
837
|
-
outline: none;
|
|
838
|
-
}
|
|
896
|
+
:focus:not(:focus-visible) {
|
|
897
|
+
outline: none;
|
|
839
898
|
}
|
|
840
899
|
|
|
841
900
|
@media print {
|
|
@@ -1298,7 +1357,22 @@ small {
|
|
|
1298
1357
|
* --cluster-wrap
|
|
1299
1358
|
*
|
|
1300
1359
|
* Child contract: direct-child margins are reset, spacing comes from --gap.
|
|
1360
|
+
* A ul/ol/menu keeps its semantics but loses list chrome (see below).
|
|
1361
|
+
*
|
|
1362
|
+
* The hooks describe this row's own geometry, so they stop at each cluster:
|
|
1363
|
+
* set them on the cluster itself, or target nested rows with a selector. The
|
|
1364
|
+
* gap token is context rhythm and keeps inheriting.
|
|
1301
1365
|
*/
|
|
1366
|
+
|
|
1367
|
+
/* Instance boundary: an ancestor's value never reaches a nested cluster.
|
|
1368
|
+
Zero specificity, so any class or inline style on the cluster wins without
|
|
1369
|
+
depending on import order; `initial` makes the var() fallbacks apply. */
|
|
1370
|
+
:where(.cluster) {
|
|
1371
|
+
--cluster-align: initial;
|
|
1372
|
+
--cluster-justify: initial;
|
|
1373
|
+
--cluster-wrap: initial;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1302
1376
|
.cluster {
|
|
1303
1377
|
display: flex;
|
|
1304
1378
|
flex-direction: row;
|
|
@@ -1312,6 +1386,16 @@ small {
|
|
|
1312
1386
|
margin: 0;
|
|
1313
1387
|
}
|
|
1314
1388
|
|
|
1389
|
+
/* Horizontal, wrapping layout is incompatible with list markers and native
|
|
1390
|
+
indentation: a ul/ol/menu applied with .cluster stays a list in the DOM, but
|
|
1391
|
+
its visual chrome would be accidental. .stack intentionally leaves this
|
|
1392
|
+
alone — a vertical list can keep its numbers. */
|
|
1393
|
+
:where(ul, ol, menu).cluster {
|
|
1394
|
+
margin: 0;
|
|
1395
|
+
padding: 0;
|
|
1396
|
+
list-style: none;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1315
1399
|
/*
|
|
1316
1400
|
* Centered content column with a maximum measure.
|
|
1317
1401
|
*
|
|
@@ -1321,6 +1405,8 @@ small {
|
|
|
1321
1405
|
* --center-size
|
|
1322
1406
|
* --center-pad
|
|
1323
1407
|
*/
|
|
1408
|
+
/* The hooks inherit on purpose: a site's content width is page policy, so a
|
|
1409
|
+
theme sets them once on :root for every .center. */
|
|
1324
1410
|
.center {
|
|
1325
1411
|
box-sizing: content-box;
|
|
1326
1412
|
inline-size: calc(100% - var(--center-pad, 1rem) * 2);
|
|
@@ -1493,6 +1579,9 @@ small {
|
|
|
1493
1579
|
*
|
|
1494
1580
|
* Child contract: every direct child is a peer; the flex-basis applies to all.
|
|
1495
1581
|
*/
|
|
1582
|
+
/* No instance reset, unlike .cluster and .grid: the children read the hook,
|
|
1583
|
+
and a switcher nested as a direct child takes its own flex-basis from its
|
|
1584
|
+
parent's value, so the hook must reach it. */
|
|
1496
1585
|
.switcher {
|
|
1497
1586
|
display: flex;
|
|
1498
1587
|
flex-direction: row;
|
|
@@ -1525,6 +1614,9 @@ small {
|
|
|
1525
1614
|
* visual, reading and keyboard order all agree — the role rules swap so the
|
|
1526
1615
|
* aside still takes its preferred width and the main still reclaims the row.
|
|
1527
1616
|
*/
|
|
1617
|
+
/* No instance reset, unlike .cluster and .grid: the regions read the hooks,
|
|
1618
|
+
and a layout nested as a region takes its own size from its parent's
|
|
1619
|
+
values, so the hooks must reach it. */
|
|
1528
1620
|
.sidebar-layout {
|
|
1529
1621
|
display: flex;
|
|
1530
1622
|
flex-direction: row;
|
|
@@ -1586,6 +1678,13 @@ small {
|
|
|
1586
1678
|
* the thresholds were arrived at: docs/design-notes/grid.md.
|
|
1587
1679
|
*/
|
|
1588
1680
|
|
|
1681
|
+
/* Instance boundary: a nested grid never takes its parent's track template.
|
|
1682
|
+
Zero specificity, so a class or inline style on the grid itself wins. */
|
|
1683
|
+
:where(.grid) {
|
|
1684
|
+
--grid-columns: initial;
|
|
1685
|
+
--grid-min: initial;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1589
1688
|
/* --grid-columns replaces the intrinsic auto-fit template: once set, the grid
|
|
1590
1689
|
no longer collapses on its own and the author owns narrow-container
|
|
1591
1690
|
collapse. */
|
|
@@ -1771,7 +1870,16 @@ small {
|
|
|
1771
1870
|
* draw their native scrollbar — the intended fallback, not a broken state.
|
|
1772
1871
|
*
|
|
1773
1872
|
* Styles the scrollbar only; it does not create the overflow. Pair it with
|
|
1774
|
-
* .overflow-auto or a component that already scrolls
|
|
1873
|
+
* .overflow-auto or a component that already scrolls (horizontal steps, a
|
|
1874
|
+
* table wrap; tabs hide their bar and fade their own edges). No edge fade
|
|
1875
|
+
* here: a mask would dictate the strip's scroll-padding and focus geometry for
|
|
1876
|
+
* a decorative cue, and an item clipped at the edge already says the strip
|
|
1877
|
+
* continues.
|
|
1878
|
+
*
|
|
1879
|
+
* Not on touch: Chromium trades the overlay scrollbar of a touch screen,
|
|
1880
|
+
* hidden at rest, for a classic always-visible one as soon as scrollbar-color
|
|
1881
|
+
* or scrollbar-width is set. On a phone that painted a permanent bar under
|
|
1882
|
+
* every strip; the native overlay is the better cue there.
|
|
1775
1883
|
*
|
|
1776
1884
|
* Public hooks:
|
|
1777
1885
|
* --scroller-track
|
|
@@ -1780,9 +1888,16 @@ small {
|
|
|
1780
1888
|
.scroller {
|
|
1781
1889
|
--scroller-track: transparent;
|
|
1782
1890
|
--scroller-thumb: var(--border);
|
|
1891
|
+
}
|
|
1783
1892
|
|
|
1784
|
-
|
|
1785
|
-
|
|
1893
|
+
/* Any primary pointer but touch: a keyboard-only or pointerless device
|
|
1894
|
+
(pointer: none) draws classic scrollbars too. `all and` keeps the negation
|
|
1895
|
+
valid in Media Queries 3 parsers. */
|
|
1896
|
+
@media not all and (pointer: coarse) {
|
|
1897
|
+
.scroller {
|
|
1898
|
+
scrollbar-color: var(--scroller-thumb) var(--scroller-track);
|
|
1899
|
+
scrollbar-width: thin;
|
|
1900
|
+
}
|
|
1786
1901
|
}
|
|
1787
1902
|
|
|
1788
1903
|
/* Opt-in gutter: reserves space so the layout does not shift when
|
|
@@ -1883,8 +1998,9 @@ small {
|
|
|
1883
1998
|
/*
|
|
1884
1999
|
* Sticky, frosted top bar shell for app/dashboard shells (persistent
|
|
1885
2000
|
* sidebar + topbar layouts). Owns positioning, stacking, and the frosted
|
|
1886
|
-
* background
|
|
1887
|
-
*
|
|
2001
|
+
* background; it also lays its children out as a default row that a layout
|
|
2002
|
+
* primitive on the same element (such as .cluster) replaces. Row content
|
|
2003
|
+
* (search, breadcrumb, actions) stays local to the page.
|
|
1888
2004
|
*
|
|
1889
2005
|
* Docs: docs/pages/layout/topbar.md
|
|
1890
2006
|
*
|
|
@@ -1898,16 +2014,21 @@ small {
|
|
|
1898
2014
|
inset-block-start: 0;
|
|
1899
2015
|
z-index: var(--z-sticky);
|
|
1900
2016
|
isolation: isolate;
|
|
1901
|
-
display: flex;
|
|
1902
|
-
flex-direction: row;
|
|
1903
|
-
align-items: center;
|
|
1904
|
-
gap: var(--gap);
|
|
1905
2017
|
padding-block: var(--space-30);
|
|
1906
2018
|
padding-inline: var(--space-40);
|
|
1907
2019
|
border-block-end: var(--border-width) solid var(--border);
|
|
1908
2020
|
background: var(--surface);
|
|
1909
2021
|
}
|
|
1910
2022
|
|
|
2023
|
+
/* The row is a default at zero specificity: .topbar and the primitives share
|
|
2024
|
+
layout/, so at class weight their file order alone would pick the winner. */
|
|
2025
|
+
:where(.topbar) {
|
|
2026
|
+
display: flex;
|
|
2027
|
+
flex-direction: row;
|
|
2028
|
+
align-items: center;
|
|
2029
|
+
gap: var(--gap);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1911
2032
|
/* Both conditions are required: the translucent background contains var(),
|
|
1912
2033
|
so a browser with backdrop-filter but not color-mix (e.g. Safari < 16.2)
|
|
1913
2034
|
would accept the declaration at parse time, fail it at computed-value
|
|
@@ -2040,7 +2161,7 @@ small {
|
|
|
2040
2161
|
.input,
|
|
2041
2162
|
.textarea,
|
|
2042
2163
|
.select {
|
|
2043
|
-
--control-border: var(--border);
|
|
2164
|
+
--control-border: var(--border-control);
|
|
2044
2165
|
--control-bg: var(--surface);
|
|
2045
2166
|
--control-fg: var(--text);
|
|
2046
2167
|
--control-cursor: auto;
|
|
@@ -2266,7 +2387,7 @@ small {
|
|
|
2266
2387
|
/* Control geometry. --choice-* are the per-state tokens. */
|
|
2267
2388
|
.check,
|
|
2268
2389
|
.radio {
|
|
2269
|
-
--choice-border: var(--border);
|
|
2390
|
+
--choice-border: var(--border-control);
|
|
2270
2391
|
--choice-bg: var(--surface);
|
|
2271
2392
|
--choice-mark: var(--primary-fg);
|
|
2272
2393
|
|
|
@@ -2454,7 +2575,7 @@ small {
|
|
|
2454
2575
|
calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
|
|
2455
2576
|
);
|
|
2456
2577
|
padding-inline: var(--space-20);
|
|
2457
|
-
border: var(--border-width) solid var(--border);
|
|
2578
|
+
border: var(--border-width) solid var(--border-control);
|
|
2458
2579
|
border-radius: var(--radius-sm);
|
|
2459
2580
|
background: var(--surface-subtle);
|
|
2460
2581
|
color: var(--text);
|
|
@@ -2488,7 +2609,7 @@ small {
|
|
|
2488
2609
|
inline-size: var(--control-size);
|
|
2489
2610
|
block-size: var(--control-size);
|
|
2490
2611
|
padding: var(--border-width);
|
|
2491
|
-
border: var(--border-width) solid var(--border);
|
|
2612
|
+
border: var(--border-width) solid var(--border-control);
|
|
2492
2613
|
border-radius: var(--radius);
|
|
2493
2614
|
background: var(--surface);
|
|
2494
2615
|
cursor: pointer;
|
|
@@ -2542,6 +2663,8 @@ small {
|
|
|
2542
2663
|
*
|
|
2543
2664
|
* Imported by default through forms/index.css. Custom builds may omit this
|
|
2544
2665
|
* file when an app wants to own validation styling completely.
|
|
2666
|
+
* Without :has(), automatic error display and choice-card borders drop;
|
|
2667
|
+
* native control states and the explicit .field.danger route stay available.
|
|
2545
2668
|
*
|
|
2546
2669
|
* State coverage (which states each component ships) is audited in
|
|
2547
2670
|
* tests/validation.test.js and summarized in docs/pages/forms/validation.md —
|
|
@@ -2563,11 +2686,14 @@ small {
|
|
|
2563
2686
|
display: block;
|
|
2564
2687
|
}
|
|
2565
2688
|
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2689
|
+
.field:has([aria-invalid="true"]) > .field-error,
|
|
2690
|
+
.needs-validation.was-validated .field:has(:invalid) > .field-error {
|
|
2691
|
+
display: block;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
/* biome-ignore lint/style/noDescendingSpecificity: every invalid route reveals the error; keep :user-invalid separate so unsupported engines retain the other routes. */
|
|
2695
|
+
.needs-validation .field:has(:user-invalid) > .field-error {
|
|
2696
|
+
display: block;
|
|
2571
2697
|
}
|
|
2572
2698
|
|
|
2573
2699
|
:where(.input, .textarea, .select, .check, .radio, .switch)[aria-invalid="true"],
|
|
@@ -2591,70 +2717,60 @@ small {
|
|
|
2591
2717
|
--switch-bg: var(--danger);
|
|
2592
2718
|
}
|
|
2593
2719
|
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
}
|
|
2720
|
+
.needs-validation .check:is(:checked, :indeterminate):user-invalid,
|
|
2721
|
+
.needs-validation .radio:checked:user-invalid {
|
|
2722
|
+
--choice-bg: var(--danger);
|
|
2723
|
+
}
|
|
2599
2724
|
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
}
|
|
2725
|
+
.needs-validation .switch:checked:user-invalid {
|
|
2726
|
+
--switch-bg: var(--danger);
|
|
2603
2727
|
}
|
|
2604
2728
|
|
|
2605
2729
|
/* Choice cards hide their native input, so invalid state must surface on the
|
|
2606
2730
|
card itself. Sets the same --form-invalid-border hook the card's border
|
|
2607
2731
|
consumes. */
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
--form-invalid-border: var(--danger);
|
|
2612
|
-
}
|
|
2732
|
+
.choice-card:has([aria-invalid="true"]),
|
|
2733
|
+
.needs-validation.was-validated .choice-card:has(:invalid) {
|
|
2734
|
+
--form-invalid-border: var(--danger);
|
|
2613
2735
|
}
|
|
2614
2736
|
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
--form-invalid-border: var(--danger);
|
|
2618
|
-
}
|
|
2737
|
+
.needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
|
|
2738
|
+
--form-invalid-border: var(--danger);
|
|
2619
2739
|
}
|
|
2620
2740
|
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
display: block;
|
|
2624
|
-
}
|
|
2625
|
-
|
|
2626
|
-
.needs-validation .choice-card:has(:user-invalid) {
|
|
2627
|
-
--form-invalid-border: var(--danger);
|
|
2628
|
-
}
|
|
2741
|
+
.needs-validation .choice-card:has(:user-invalid) {
|
|
2742
|
+
--form-invalid-border: var(--danger);
|
|
2629
2743
|
}
|
|
2630
2744
|
|
|
2631
2745
|
/*
|
|
2632
|
-
* Form actions —
|
|
2746
|
+
* Form actions — the role of a .cluster action row that closes a form.
|
|
2633
2747
|
*
|
|
2634
2748
|
* Docs: docs/pages/forms/form-actions.md
|
|
2635
2749
|
*
|
|
2636
|
-
*
|
|
2637
|
-
*
|
|
2638
|
-
*
|
|
2750
|
+
* Markup: `<div class="cluster form-actions">`. The cluster owns the row
|
|
2751
|
+
* (flex, wrap, gap); this role owns the form-specific alignment policy and the
|
|
2752
|
+
* opt-in sticky variant. The distance to the fields above belongs to the
|
|
2753
|
+
* parent layout (.stack on the form), never to a margin here: a margin would
|
|
2754
|
+
* add to a stack's gap. The sticky variant docks to the viewport bottom with a
|
|
2755
|
+
* translucent blur where supported; at rest it stays visually neutral so it
|
|
2756
|
+
* does not double as a separator.
|
|
2639
2757
|
*
|
|
2640
2758
|
* Public hooks:
|
|
2641
2759
|
* --form-actions-align
|
|
2642
2760
|
* --form-actions-justify
|
|
2643
|
-
* --form-actions-margin-block-start
|
|
2644
2761
|
*
|
|
2645
2762
|
* Internal:
|
|
2646
2763
|
* --form-actions-sticky-block-offset (owned by .form-actions.sticky)
|
|
2647
2764
|
* --form-actions-sticky-inline-offset (owned by .form-actions.sticky)
|
|
2648
2765
|
* --form-actions-sticky-padding (owned by .form-actions.sticky)
|
|
2649
2766
|
*/
|
|
2767
|
+
|
|
2768
|
+
/* The role relays its policy into the row's cluster hooks. Cluster hooks stop
|
|
2769
|
+
at every nested .cluster, so the relay cannot leak, and this class beats the
|
|
2770
|
+
cluster's zero-specificity reset in any import order. */
|
|
2650
2771
|
.form-actions {
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
flex-wrap: wrap;
|
|
2654
|
-
gap: var(--gap);
|
|
2655
|
-
align-items: var(--form-actions-align, center);
|
|
2656
|
-
justify-content: var(--form-actions-justify, flex-start);
|
|
2657
|
-
margin-block-start: var(--form-actions-margin-block-start, var(--space-50));
|
|
2772
|
+
--cluster-align: var(--form-actions-align, center);
|
|
2773
|
+
--cluster-justify: var(--form-actions-justify, flex-start);
|
|
2658
2774
|
}
|
|
2659
2775
|
|
|
2660
2776
|
/* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
|
|
@@ -2780,7 +2896,7 @@ small {
|
|
|
2780
2896
|
|
|
2781
2897
|
.switch {
|
|
2782
2898
|
--switch-bg: var(--border);
|
|
2783
|
-
--switch-border: var(--border);
|
|
2899
|
+
--switch-border: var(--border-control);
|
|
2784
2900
|
--switch-knob: var(--surface-raised);
|
|
2785
2901
|
--switch-block-size: calc(var(--choice-control-size) + 0.125em);
|
|
2786
2902
|
--switch-width: calc(var(--switch-block-size) * 1.8);
|
|
@@ -3117,7 +3233,7 @@ small {
|
|
|
3117
3233
|
--choice-card-pad: var(--density-space, var(--space-40));
|
|
3118
3234
|
--choice-card-gap: var(--space-30);
|
|
3119
3235
|
--choice-card-radius: var(--radius);
|
|
3120
|
-
--choice-card-border: var(--border);
|
|
3236
|
+
--choice-card-border: var(--border-control);
|
|
3121
3237
|
--choice-card-bg: var(--surface);
|
|
3122
3238
|
--choice-card-check-size: 1.25em;
|
|
3123
3239
|
|
|
@@ -3453,10 +3569,13 @@ small {
|
|
|
3453
3569
|
* messages stay in the surrounding .field, not inside the cell, so the
|
|
3454
3570
|
* resting label can center on the control alone.
|
|
3455
3571
|
*
|
|
3456
|
-
* The
|
|
3457
|
-
*
|
|
3458
|
-
*
|
|
3459
|
-
*
|
|
3572
|
+
* The cell reads its control's state through :has(), so the label may come
|
|
3573
|
+
* before or after the control (a form theme rendering label then widget needs
|
|
3574
|
+
* no reordering). Injected sibling nodes are tolerated (password managers,
|
|
3575
|
+
* extension wrappers) as long as the control stays a direct child; the cell
|
|
3576
|
+
* contract stays a single control plus one label, since a second control
|
|
3577
|
+
* would float the label on either one's state.
|
|
3578
|
+
* Every .input/.textarea needs
|
|
3460
3579
|
* placeholder=" " to expose a reliable empty state — without it the label
|
|
3461
3580
|
* only reacts to focus.
|
|
3462
3581
|
*
|
|
@@ -3506,23 +3625,22 @@ small {
|
|
|
3506
3625
|
}
|
|
3507
3626
|
|
|
3508
3627
|
/* Single-line controls rest in the middle until they receive a value. */
|
|
3509
|
-
.floating-field
|
|
3628
|
+
.floating-field:has(> :is(.input, .select)) > .field-label {
|
|
3510
3629
|
inset-block-start: 50%;
|
|
3511
3630
|
transform: translateY(-50%);
|
|
3512
3631
|
}
|
|
3513
3632
|
|
|
3514
3633
|
/* An empty textarea label follows the control's normal first-line position;
|
|
3515
3634
|
the control itself keeps the extra headroom reserved for its floated state. */
|
|
3516
|
-
.floating-field
|
|
3635
|
+
.floating-field:has(> .textarea) > .field-label {
|
|
3517
3636
|
inset-block-start: calc(1em + var(--border-width));
|
|
3518
3637
|
transform: none;
|
|
3519
3638
|
}
|
|
3520
3639
|
|
|
3521
3640
|
/* :autofill covers prefilled values before interaction, when the browser may
|
|
3522
3641
|
not have propagated the value to the placeholder state yet. */
|
|
3523
|
-
.floating-field
|
|
3524
|
-
|
|
3525
|
-
~ .field-label {
|
|
3642
|
+
.floating-field:has(> :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown)))
|
|
3643
|
+
> .field-label {
|
|
3526
3644
|
inset-block-start: var(--floating-label-top);
|
|
3527
3645
|
transform: none;
|
|
3528
3646
|
font-size: var(--font-size-xs);
|
|
@@ -3532,16 +3650,16 @@ small {
|
|
|
3532
3650
|
/* Controls without a dependable :placeholder-shown empty state stay floated:
|
|
3533
3651
|
select always, and date/time inputs whose placeholder rendering is
|
|
3534
3652
|
inconsistent across engines (Firefox shows none). */
|
|
3535
|
-
.floating-field
|
|
3653
|
+
.floating-field:has(
|
|
3536
3654
|
> :is(
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3655
|
+
.select,
|
|
3656
|
+
.input[type="date"],
|
|
3657
|
+
.input[type="time"],
|
|
3658
|
+
.input[type="datetime-local"],
|
|
3659
|
+
.input[type="month"],
|
|
3660
|
+
.input[type="week"]
|
|
3661
|
+
)
|
|
3662
|
+
) > .field-label {
|
|
3545
3663
|
inset-block-start: var(--floating-label-top);
|
|
3546
3664
|
transform: none;
|
|
3547
3665
|
font-size: var(--font-size-xs);
|
|
@@ -3577,7 +3695,7 @@ small {
|
|
|
3577
3695
|
--otp-cell-size: var(--control-size);
|
|
3578
3696
|
--otp-gap: var(--space-10);
|
|
3579
3697
|
--otp-font-size: 1.5em;
|
|
3580
|
-
--otp-cell-border: var(--border);
|
|
3698
|
+
--otp-cell-border: var(--border-control);
|
|
3581
3699
|
--otp-cell-bg: var(--surface);
|
|
3582
3700
|
--otp-caret-space: calc((var(--otp-cell-size) - 1ch) / 2 + var(--otp-gap) + 1px);
|
|
3583
3701
|
|
|
@@ -3663,10 +3781,9 @@ small {
|
|
|
3663
3781
|
--otp-cell-border: var(--danger);
|
|
3664
3782
|
}
|
|
3665
3783
|
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
}
|
|
3784
|
+
/* 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. */
|
|
3785
|
+
.needs-validation .otp > input:user-invalid ~ span {
|
|
3786
|
+
--otp-cell-border: var(--danger);
|
|
3670
3787
|
}
|
|
3671
3788
|
|
|
3672
3789
|
.otp > input:disabled {
|
|
@@ -3736,7 +3853,6 @@ small {
|
|
|
3736
3853
|
* --btn-bg
|
|
3737
3854
|
* --btn-fg
|
|
3738
3855
|
* --btn-border
|
|
3739
|
-
* --btn-default-hover-bg
|
|
3740
3856
|
* --btn-border-block-end-color
|
|
3741
3857
|
* --btn-border-block-end-width
|
|
3742
3858
|
* --btn-border-block
|
|
@@ -3762,9 +3878,8 @@ small {
|
|
|
3762
3878
|
--btn-bg: var(--ui-bg, var(--btn-default-bg, var(--intent, var(--neutral))));
|
|
3763
3879
|
--btn-fg: var(--ui-fg, var(--btn-default-fg, var(--intent-fg, var(--neutral-fg))));
|
|
3764
3880
|
--btn-border: var(--ui-border, var(--btn-default-border, transparent));
|
|
3765
|
-
--btn-default-hover-bg: var(--btn-bg);
|
|
3766
3881
|
--btn-radius: var(--radius);
|
|
3767
|
-
--btn-hover-overlay: var(--hover-overlay,
|
|
3882
|
+
--btn-hover-overlay: var(--hover-overlay-solid, var(--hover-overlay));
|
|
3768
3883
|
--btn-border-block: calc(
|
|
3769
3884
|
var(--border-width) +
|
|
3770
3885
|
var(--btn-border-block-end-width, var(--border-width))
|
|
@@ -3844,10 +3959,6 @@ small {
|
|
|
3844
3959
|
}
|
|
3845
3960
|
|
|
3846
3961
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
3847
|
-
.btn {
|
|
3848
|
-
--btn-default-hover-bg: color-mix(in oklch, var(--btn-bg) 88%, black);
|
|
3849
|
-
}
|
|
3850
|
-
|
|
3851
3962
|
.btn.soft {
|
|
3852
3963
|
--ui-hover-bg: color-mix(
|
|
3853
3964
|
in oklab,
|
|
@@ -3863,9 +3974,9 @@ small {
|
|
|
3863
3974
|
}
|
|
3864
3975
|
|
|
3865
3976
|
/* The .soft/.outline/.ghost hover recipe (--ui-hover-bg) is set locally above;
|
|
3866
|
-
the overlay darkening for solid
|
|
3977
|
+
the overlay darkening for solid is .btn-specific. */
|
|
3867
3978
|
.btn:not(:where(:disabled, [aria-disabled="true"])):hover {
|
|
3868
|
-
background: var(--ui-hover-bg, var(--btn-
|
|
3979
|
+
background: var(--ui-hover-bg, var(--btn-bg));
|
|
3869
3980
|
/* Huge inset spread paints the overlay over any border-radius without a
|
|
3870
3981
|
pseudo-element; a plain background would replace the fill instead of
|
|
3871
3982
|
darkening it. */
|
|
@@ -4198,7 +4309,6 @@ a.btn[aria-disabled="true"] {
|
|
|
4198
4309
|
--badge-size: var(--badge-dot-size);
|
|
4199
4310
|
--badge-bg: var(--intent, var(--neutral));
|
|
4200
4311
|
--badge-fg: var(--intent-fg, var(--neutral-fg));
|
|
4201
|
-
--badge-border: transparent;
|
|
4202
4312
|
padding: 0;
|
|
4203
4313
|
border: 0;
|
|
4204
4314
|
inline-size: var(--badge-size);
|
|
@@ -4230,10 +4340,6 @@ a.btn[aria-disabled="true"] {
|
|
|
4230
4340
|
}
|
|
4231
4341
|
|
|
4232
4342
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
4233
|
-
:is(a.badge, button.badge):is(.solid, :empty) {
|
|
4234
|
-
--ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
|
|
4235
|
-
}
|
|
4236
|
-
|
|
4237
4343
|
:is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
|
|
4238
4344
|
--badge-default-hover-bg: color-mix(
|
|
4239
4345
|
in oklab,
|
|
@@ -4260,7 +4366,7 @@ a.btn[aria-disabled="true"] {
|
|
|
4260
4366
|
:where(:disabled, [aria-disabled="true"])
|
|
4261
4367
|
):hover {
|
|
4262
4368
|
/* Huge inset spread paints the overlay over the pill radius (see button.css). */
|
|
4263
|
-
box-shadow: inset 0 0 0 999px var(--hover-overlay);
|
|
4369
|
+
box-shadow: inset 0 0 0 999px var(--hover-overlay-solid, var(--hover-overlay));
|
|
4264
4370
|
}
|
|
4265
4371
|
|
|
4266
4372
|
button.badge {
|
|
@@ -4402,10 +4508,11 @@ a.badge[aria-disabled="true"] {
|
|
|
4402
4508
|
}
|
|
4403
4509
|
}
|
|
4404
4510
|
|
|
4405
|
-
/* Decorative leading icon — use .alert-icon on the icon element.
|
|
4511
|
+
/* Decorative leading icon — use .alert-icon on the icon element. display and
|
|
4512
|
+
vertical-align are the no-:has() layout (Degraded tier: the icon sits inline
|
|
4513
|
+
before the text); the grid anatomy below replaces them. */
|
|
4406
4514
|
.alert > .alert-icon {
|
|
4407
4515
|
display: inline-block;
|
|
4408
|
-
flex: none;
|
|
4409
4516
|
inline-size: var(--alert-icon-size);
|
|
4410
4517
|
block-size: var(--alert-icon-size);
|
|
4411
4518
|
font-size: var(--alert-icon-size);
|
|
@@ -4437,45 +4544,44 @@ a.badge[aria-disabled="true"] {
|
|
|
4437
4544
|
|
|
4438
4545
|
/* Grid anatomy — activated by a leading icon, a trailing dismiss button, or
|
|
4439
4546
|
both. Icon and dismiss are pinned to the outer columns; anything else (the
|
|
4440
|
-
message content) flows into the remaining text column.
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
.alert:has(> .alert-icon) {
|
|
4450
|
-
grid-template-columns: auto minmax(0, 1fr);
|
|
4451
|
-
}
|
|
4547
|
+
message content) flows into the remaining text column. Without :has(), the
|
|
4548
|
+
grid rules drop together and the alert keeps ordinary block flow. */
|
|
4549
|
+
.alert:has(> .alert-icon, > .close) {
|
|
4550
|
+
display: grid;
|
|
4551
|
+
/* No grid-template-columns: the icon and close branches below always set
|
|
4552
|
+
it, since one of them is what matched. */
|
|
4553
|
+
align-items: var(--items-align, start);
|
|
4554
|
+
column-gap: var(--space-30);
|
|
4555
|
+
}
|
|
4452
4556
|
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
display: inline-grid;
|
|
4457
|
-
place-items: center;
|
|
4458
|
-
margin-block-start: 0.125em;
|
|
4459
|
-
}
|
|
4557
|
+
.alert:has(> .alert-icon) {
|
|
4558
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
4559
|
+
}
|
|
4460
4560
|
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4561
|
+
.alert:has(> .alert-icon) > .alert-icon {
|
|
4562
|
+
grid-column: 1;
|
|
4563
|
+
grid-row: 1 / -1;
|
|
4564
|
+
display: inline-grid;
|
|
4565
|
+
place-items: center;
|
|
4566
|
+
margin-block-start: 0.125em;
|
|
4567
|
+
}
|
|
4464
4568
|
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4569
|
+
.alert:has(> .alert-icon) > :not(.alert-icon, .close) {
|
|
4570
|
+
grid-column: 2;
|
|
4571
|
+
}
|
|
4468
4572
|
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4573
|
+
.alert:has(> .close) {
|
|
4574
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
4575
|
+
}
|
|
4472
4576
|
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
grid-row: 1;
|
|
4476
|
-
}
|
|
4577
|
+
.alert:has(> .alert-icon):has(> .close) {
|
|
4578
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
4477
4579
|
}
|
|
4478
4580
|
|
|
4581
|
+
.alert:has(> .close) > .close {
|
|
4582
|
+
grid-column: -2 / -1;
|
|
4583
|
+
grid-row: 1;
|
|
4584
|
+
}
|
|
4479
4585
|
/* Links */
|
|
4480
4586
|
.alert :where(a:not(.btn)) {
|
|
4481
4587
|
color: inherit;
|
|
@@ -4561,34 +4667,6 @@ a.badge[aria-disabled="true"] {
|
|
|
4561
4667
|
}
|
|
4562
4668
|
}
|
|
4563
4669
|
|
|
4564
|
-
/*
|
|
4565
|
-
* Actions — a horizontal action-button cluster.
|
|
4566
|
-
*
|
|
4567
|
-
* Docs: docs/pages/patterns/actions.md
|
|
4568
|
-
*
|
|
4569
|
-
* Composes with .btn (or any inline element) and feeds layout via the shared
|
|
4570
|
-
* --gap token.
|
|
4571
|
-
*
|
|
4572
|
-
* Internal:
|
|
4573
|
-
* --gap (local value for the shared gap token)
|
|
4574
|
-
*/
|
|
4575
|
-
.actions {
|
|
4576
|
-
--gap: var(--space-20);
|
|
4577
|
-
|
|
4578
|
-
display: flex;
|
|
4579
|
-
flex-direction: row;
|
|
4580
|
-
flex-wrap: wrap;
|
|
4581
|
-
align-items: center;
|
|
4582
|
-
gap: var(--gap);
|
|
4583
|
-
margin: 0;
|
|
4584
|
-
padding: 0;
|
|
4585
|
-
list-style: none;
|
|
4586
|
-
}
|
|
4587
|
-
|
|
4588
|
-
.actions > li {
|
|
4589
|
-
margin: 0;
|
|
4590
|
-
}
|
|
4591
|
-
|
|
4592
4670
|
|
|
4593
4671
|
/* Surfaces */
|
|
4594
4672
|
/*
|
|
@@ -4597,11 +4675,11 @@ a.badge[aria-disabled="true"] {
|
|
|
4597
4675
|
* Docs: docs/pages/components/card.md
|
|
4598
4676
|
*
|
|
4599
4677
|
* Surface, border, radius, padding, density, and elevation stay local here.
|
|
4600
|
-
* Direct header/footer children are structural slots
|
|
4601
|
-
* anchored to the card's bottom edge when extra block space
|
|
4602
|
-
* price/action rows align across equal-height cards in a
|
|
4603
|
-
* owns its direct-child rhythm
|
|
4604
|
-
* over that layout. Page layout remains the job of markup and layout
|
|
4678
|
+
* Direct header/footer children are structural slots with a default split row;
|
|
4679
|
+
* a direct footer is anchored to the card's bottom edge when extra block space
|
|
4680
|
+
* is available (so price/action rows align across equal-height cards in a
|
|
4681
|
+
* grid). A bare card owns its direct-child rhythm and its slot rows; a layout
|
|
4682
|
+
* primitive on the card or on a slot takes over that layout. Page layout remains the job of markup and layout
|
|
4605
4683
|
* primitives outside the card.
|
|
4606
4684
|
*
|
|
4607
4685
|
* Public hooks:
|
|
@@ -4639,6 +4717,9 @@ a.badge[aria-disabled="true"] {
|
|
|
4639
4717
|
|
|
4640
4718
|
.card {
|
|
4641
4719
|
--card-pad: var(--space-40);
|
|
4720
|
+
/* Local anatomy, like the padding. Never var(--gap): that token is
|
|
4721
|
+
inherited, so the --gap of a parent grid became the rhythm inside every
|
|
4722
|
+
card (+79px per card on a 38px grid). */
|
|
4642
4723
|
--card-gap: var(--space-30);
|
|
4643
4724
|
--card-radius: var(--radius-lg);
|
|
4644
4725
|
--card-max-inline-size: none;
|
|
@@ -4680,7 +4761,11 @@ a.badge[aria-disabled="true"] {
|
|
|
4680
4761
|
margin-block: 0;
|
|
4681
4762
|
}
|
|
4682
4763
|
|
|
4683
|
-
|
|
4764
|
+
/* The slot row is a convenience default, not anatomy: like the root box it
|
|
4765
|
+
lives at zero specificity, so a primitive, role or utility on the slot
|
|
4766
|
+
(.cluster, .media, .form-actions, .justify-content-*) replaces it instead of
|
|
4767
|
+
losing to components/ on source order. */
|
|
4768
|
+
:where(.card > header, .card > footer) {
|
|
4684
4769
|
display: flex;
|
|
4685
4770
|
flex-wrap: wrap;
|
|
4686
4771
|
align-items: var(--items-align, center);
|
|
@@ -4689,15 +4774,15 @@ a.badge[aria-disabled="true"] {
|
|
|
4689
4774
|
min-inline-size: 0;
|
|
4690
4775
|
}
|
|
4691
4776
|
|
|
4777
|
+
:where(.card > header) {
|
|
4778
|
+
align-items: var(--items-align, flex-start);
|
|
4779
|
+
}
|
|
4780
|
+
|
|
4692
4781
|
.card > :where(header, footer) > * {
|
|
4693
4782
|
min-inline-size: 0;
|
|
4694
4783
|
margin-block: 0;
|
|
4695
4784
|
}
|
|
4696
4785
|
|
|
4697
|
-
.card > header {
|
|
4698
|
-
align-items: var(--items-align, flex-start);
|
|
4699
|
-
}
|
|
4700
|
-
|
|
4701
4786
|
/* A direct footer is pinned to the bottom when the card has free space.
|
|
4702
4787
|
Specificity beats .stack > * so a .card.stack keeps this contract. */
|
|
4703
4788
|
.card > footer {
|
|
@@ -4712,6 +4797,7 @@ a.badge[aria-disabled="true"] {
|
|
|
4712
4797
|
}
|
|
4713
4798
|
.card.compact {
|
|
4714
4799
|
--card-pad: var(--space-20);
|
|
4800
|
+
--card-gap: var(--space-20);
|
|
4715
4801
|
}
|
|
4716
4802
|
|
|
4717
4803
|
@media print {
|
|
@@ -4732,15 +4818,19 @@ a.badge[aria-disabled="true"] {
|
|
|
4732
4818
|
* Public hooks:
|
|
4733
4819
|
* --modal-size
|
|
4734
4820
|
* --modal-pad
|
|
4735
|
-
* --modal-header-bg
|
|
4736
4821
|
* --dialog-viewport-gap
|
|
4737
4822
|
* --dialog-icon-size
|
|
4738
4823
|
* --dialog-icon-glyph-size
|
|
4739
4824
|
*
|
|
4740
4825
|
* The corner close control is .close (components/close.css).
|
|
4741
4826
|
*
|
|
4827
|
+
* The body footer is a structural slot with no layout of its own: its action
|
|
4828
|
+
* row is composed in markup (`<footer class="cluster justify-content-end">`),
|
|
4829
|
+
* as in the drawer. A default row here would fight every composed one.
|
|
4830
|
+
*
|
|
4742
4831
|
* Internal:
|
|
4743
4832
|
* --dialog-inner-radius
|
|
4833
|
+
* --dialog-close-reserve (header room for a corner close; zero without a close)
|
|
4744
4834
|
* --dialog-focus-bleed
|
|
4745
4835
|
* --dialog-viewport-block
|
|
4746
4836
|
* --dialog-viewport-gap-default
|
|
@@ -4749,10 +4839,8 @@ a.badge[aria-disabled="true"] {
|
|
|
4749
4839
|
:where(dialog).modal {
|
|
4750
4840
|
/* --dialog-viewport-gap stays fallback-only so the responsive default can
|
|
4751
4841
|
live in an internal token: a media query moves it without closing the
|
|
4752
|
-
public hook.
|
|
4753
|
-
|
|
4754
|
-
calc() multiplication is not in the supported browser range, so the
|
|
4755
|
-
doubled gap is spelled out with an additive calc. */
|
|
4842
|
+
public hook. `-2x` is the gap on the two viewport edges, shared by the
|
|
4843
|
+
width and height caps. */
|
|
4756
4844
|
--dialog-viewport-gap-default: var(--space-40);
|
|
4757
4845
|
--dialog-viewport-gap-2x: calc(
|
|
4758
4846
|
var(--dialog-viewport-gap, var(--dialog-viewport-gap-default)) +
|
|
@@ -4760,10 +4848,19 @@ a.badge[aria-disabled="true"] {
|
|
|
4760
4848
|
);
|
|
4761
4849
|
|
|
4762
4850
|
/* The panel is borderless, so its inner and outer radius are the same. Kept
|
|
4763
|
-
as one name for the padded wrapper and
|
|
4764
|
-
|
|
4851
|
+
as one name for the padded wrapper and the header band; a .bleed band
|
|
4852
|
+
inherits it from the wrapper, so none of them drifts from the corner. */
|
|
4765
4853
|
--dialog-inner-radius: var(--radius-lg);
|
|
4766
4854
|
|
|
4855
|
+
/* Header room for a corner close. Unconditional baseline: without :has()
|
|
4856
|
+
(Degraded tier) a header keeps too much room rather than a title running
|
|
4857
|
+
under the close. Zeroed when the dialog has no close. */
|
|
4858
|
+
--dialog-close-reserve: calc(
|
|
4859
|
+
var(--close-size, var(--control-size)) +
|
|
4860
|
+
var(--space-20) +
|
|
4861
|
+
var(--space-30)
|
|
4862
|
+
);
|
|
4863
|
+
|
|
4767
4864
|
/* Keep the surface anchored to the viewport in the top layer. This also
|
|
4768
4865
|
provides the containing block for the absolutely positioned close button. */
|
|
4769
4866
|
position: fixed;
|
|
@@ -4796,9 +4893,9 @@ a.badge[aria-disabled="true"] {
|
|
|
4796
4893
|
* both edges, which reads as horizontal movement.
|
|
4797
4894
|
*/
|
|
4798
4895
|
transform: translateY(0.35rem);
|
|
4799
|
-
transition:
|
|
4800
|
-
|
|
4801
|
-
|
|
4896
|
+
transition-property: opacity, transform;
|
|
4897
|
+
transition-duration: var(--duration);
|
|
4898
|
+
transition-timing-function: var(--ease-exit);
|
|
4802
4899
|
}
|
|
4803
4900
|
|
|
4804
4901
|
@media (forced-colors: active) {
|
|
@@ -4822,10 +4919,8 @@ a.badge[aria-disabled="true"] {
|
|
|
4822
4919
|
padding: var(--surface-pad);
|
|
4823
4920
|
}
|
|
4824
4921
|
|
|
4825
|
-
/*
|
|
4826
|
-
|
|
4827
|
-
the trailing cluster keeps it in flow instead of invoking the absolute
|
|
4828
|
-
corner treatment from close.css. */
|
|
4922
|
+
/* Dialog chrome beside the padded body wrapper. Wrapping the close in the
|
|
4923
|
+
trailing cluster keeps it in flow, not the absolute corner overlay. */
|
|
4829
4924
|
:where(dialog).modal > header {
|
|
4830
4925
|
display: flex;
|
|
4831
4926
|
align-items: center;
|
|
@@ -4835,7 +4930,20 @@ a.badge[aria-disabled="true"] {
|
|
|
4835
4930
|
padding: var(--modal-pad, var(--space-40));
|
|
4836
4931
|
border-start-start-radius: var(--dialog-inner-radius);
|
|
4837
4932
|
border-start-end-radius: var(--dialog-inner-radius);
|
|
4838
|
-
|
|
4933
|
+
}
|
|
4934
|
+
|
|
4935
|
+
/* Paint is not anatomy: the default band fill has zero specificity, so a
|
|
4936
|
+
.background-* utility on the header replaces it. The layout above stays at
|
|
4937
|
+
the component's weight — a composed .cluster must not reset the title/close
|
|
4938
|
+
alignment. */
|
|
4939
|
+
:where(dialog.modal > header) {
|
|
4940
|
+
background: var(--surface-subtle);
|
|
4941
|
+
}
|
|
4942
|
+
|
|
4943
|
+
/* The chrome header is a centered row, so prose margins on a bare title only
|
|
4944
|
+
push the band taller and pull the title off the close axis. */
|
|
4945
|
+
:where(dialog).modal > header > :is(h1, h2, h3, h4, h5, h6) {
|
|
4946
|
+
margin-block: 0;
|
|
4839
4947
|
}
|
|
4840
4948
|
|
|
4841
4949
|
:where(dialog).modal > header > .media {
|
|
@@ -4856,18 +4964,16 @@ a.badge[aria-disabled="true"] {
|
|
|
4856
4964
|
flex: none;
|
|
4857
4965
|
}
|
|
4858
4966
|
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
:
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
}
|
|
4967
|
+
/* The header stays fixed while the body wrapper owns overflow. Without :has()
|
|
4968
|
+
these drop individually and compact dialogs keep ordinary block flow. */
|
|
4969
|
+
:where(dialog).modal[open]:has(> header) {
|
|
4970
|
+
display: grid;
|
|
4971
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
4972
|
+
}
|
|
4866
4973
|
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
}
|
|
4974
|
+
:where(dialog).modal:has(> header) > :is(form, .stack) {
|
|
4975
|
+
min-block-size: 0;
|
|
4976
|
+
max-block-size: none;
|
|
4871
4977
|
}
|
|
4872
4978
|
|
|
4873
4979
|
@supports (block-size: 100svh) {
|
|
@@ -4886,24 +4992,37 @@ a.badge[aria-disabled="true"] {
|
|
|
4886
4992
|
|
|
4887
4993
|
:where(dialog).modal.scrollable > form,
|
|
4888
4994
|
:where(dialog).modal.scrollable > .stack {
|
|
4889
|
-
|
|
4995
|
+
display: flex;
|
|
4996
|
+
flex-direction: column;
|
|
4890
4997
|
overflow: hidden;
|
|
4891
4998
|
}
|
|
4892
4999
|
|
|
5000
|
+
/* Regions carry their own behaviour: a structural header or footer keeps its
|
|
5001
|
+
size, every other child absorbs the free space and scrolls. Nothing here has
|
|
5002
|
+
to detect which regions exist — an external header leaves the wrapper with
|
|
5003
|
+
content + footer and this still holds. Each non-chrome child is its own
|
|
5004
|
+
scrollport; wrap the body in one region for a single one. */
|
|
5005
|
+
:where(dialog).modal.scrollable > form > :is(header, footer),
|
|
5006
|
+
:where(dialog).modal.scrollable > .stack > :is(header, footer) {
|
|
5007
|
+
flex: none;
|
|
5008
|
+
}
|
|
5009
|
+
|
|
4893
5010
|
:where(dialog).modal.scrollable > form > :not(:is(header, footer)),
|
|
4894
5011
|
:where(dialog).modal.scrollable > .stack > :not(:is(header, footer)) {
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
);
|
|
5012
|
+
/* Declared here, not as a :root alias: an inherited alias arrives already
|
|
5013
|
+
computed, so a --focus-ring-width override on the dialog would not reach
|
|
5014
|
+
it. */
|
|
5015
|
+
--dialog-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
4899
5016
|
|
|
5017
|
+
flex: 1 1 auto;
|
|
4900
5018
|
min-block-size: 0;
|
|
4901
|
-
/* Keep focus paint inside the clip
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
5019
|
+
/* Keep focus paint inside the clip, on all four sides (overflow: auto clips
|
|
5020
|
+
both axes), without moving the content. Every outside indicator is
|
|
5021
|
+
--focus-ring-width wide (core/focus.css). On the block axis the reserve
|
|
5022
|
+
reaches into the slot gap, which is wider, so the scrollport never slides
|
|
5023
|
+
under the header or footer. */
|
|
5024
|
+
margin: calc(var(--dialog-focus-bleed) * -1);
|
|
5025
|
+
padding: var(--dialog-focus-bleed);
|
|
4907
5026
|
overflow: auto;
|
|
4908
5027
|
overscroll-behavior: contain;
|
|
4909
5028
|
}
|
|
@@ -4925,59 +5044,30 @@ a.badge[aria-disabled="true"] {
|
|
|
4925
5044
|
margin-block: 0;
|
|
4926
5045
|
}
|
|
4927
5046
|
|
|
5047
|
+
/* No close anywhere: reclaim the reserve. Deliberately coarse — a close in
|
|
5048
|
+
the body keeps the reserve, which costs only room, never overlap. */
|
|
5049
|
+
:where(dialog).modal:not(:has(.close)) {
|
|
5050
|
+
--dialog-close-reserve: 0px;
|
|
5051
|
+
}
|
|
5052
|
+
|
|
4928
5053
|
/* The close button is anchored to the dialog at top inline-end, out of the
|
|
4929
|
-
content flow; headers reserve inline room so titles never run under it.
|
|
4930
|
-
|
|
4931
|
-
lends .stack's specificity to
|
|
5054
|
+
content flow; headers reserve inline room so titles never run under it. A
|
|
5055
|
+
.bleed header keeps at least its band inset. The form/.stack anatomy is
|
|
5056
|
+
structural, so it stays in :where() and never lends .stack's specificity to
|
|
5057
|
+
a bare <form>. */
|
|
4932
5058
|
:where(dialog).modal > :where(form, .stack) > header {
|
|
4933
|
-
padding-inline-end:
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
);
|
|
5059
|
+
padding-inline-end: var(--dialog-close-reserve);
|
|
5060
|
+
}
|
|
5061
|
+
|
|
5062
|
+
:where(dialog).modal > :where(form, .stack) > header:where(.bleed) {
|
|
5063
|
+
padding-inline-end: max(var(--bleed-pad, 0px), var(--dialog-close-reserve));
|
|
4938
5064
|
}
|
|
4939
5065
|
|
|
4940
5066
|
/* A corner .close that is a direct child of the chrome header needs the same
|
|
4941
5067
|
reserve, but never below the header's own pad. Kept as its own rule so an
|
|
4942
5068
|
unsupported :has() drops only this geometry, not the wrapper reserve. */
|
|
4943
5069
|
:where(dialog).modal > header:has(> .close) {
|
|
4944
|
-
padding-inline-end: max(
|
|
4945
|
-
var(--modal-pad, var(--space-40)),
|
|
4946
|
-
calc(var(--close-size, var(--control-size)) + var(--space-20) + var(--space-30))
|
|
4947
|
-
);
|
|
4948
|
-
}
|
|
4949
|
-
|
|
4950
|
-
:where(dialog).modal > :where(form, .stack) > footer {
|
|
4951
|
-
display: flex;
|
|
4952
|
-
flex-wrap: wrap;
|
|
4953
|
-
justify-content: flex-end;
|
|
4954
|
-
gap: var(--gap);
|
|
4955
|
-
}
|
|
4956
|
-
|
|
4957
|
-
/* Confirmation layout — a focused message followed by a visually distinct
|
|
4958
|
-
action band. The media primitive owns icon/content alignment; this variant
|
|
4959
|
-
only removes the standard form inset so both regions can own their padding. */
|
|
4960
|
-
:where(dialog).modal.dialog-confirmation > form {
|
|
4961
|
-
--surface-pad: 0px;
|
|
4962
|
-
|
|
4963
|
-
gap: 0;
|
|
4964
|
-
padding: 0;
|
|
4965
|
-
}
|
|
4966
|
-
|
|
4967
|
-
:where(dialog).modal.dialog-confirmation > form > :is(.media, header) {
|
|
4968
|
-
gap: var(--space-40);
|
|
4969
|
-
padding: var(--space-50);
|
|
4970
|
-
}
|
|
4971
|
-
|
|
4972
|
-
/* The action band splits a decision pair: the cancel-style action holds the
|
|
4973
|
-
leading edge and the committing action the trailing one, so the destructive
|
|
4974
|
-
choice is never adjacent to the dismiss. */
|
|
4975
|
-
:where(dialog).modal.dialog-confirmation > form > footer {
|
|
4976
|
-
justify-content: space-between;
|
|
4977
|
-
padding: var(--space-40) var(--space-50);
|
|
4978
|
-
border-end-start-radius: var(--dialog-inner-radius);
|
|
4979
|
-
border-end-end-radius: var(--dialog-inner-radius);
|
|
4980
|
-
background: var(--surface-subtle);
|
|
5070
|
+
padding-inline-end: max(var(--modal-pad, var(--space-40)), var(--dialog-close-reserve));
|
|
4981
5071
|
}
|
|
4982
5072
|
|
|
4983
5073
|
/* Intent boundary — inherited intents do not tint the icon well. A local
|
|
@@ -4990,14 +5080,17 @@ a.badge[aria-disabled="true"] {
|
|
|
4990
5080
|
--ui-fg: initial;
|
|
4991
5081
|
}
|
|
4992
5082
|
|
|
5083
|
+
/* The size hooks are read here, never declared: an author sets them on the
|
|
5084
|
+
icon's media row (or the dialog) so both the circle and the title that
|
|
5085
|
+
centers on it inherit one value. A local declaration on .dialog-icon would
|
|
5086
|
+
shadow that value for the circle while the title kept the inherited one —
|
|
5087
|
+
the two centers would drift. An override on the icon alone is deliberately
|
|
5088
|
+
local and moves only the circle. */
|
|
4993
5089
|
.dialog-icon {
|
|
4994
|
-
--dialog-icon-size: 3rem;
|
|
4995
|
-
--dialog-icon-glyph-size: 1.5rem;
|
|
4996
|
-
|
|
4997
5090
|
display: inline-grid;
|
|
4998
5091
|
place-items: center;
|
|
4999
|
-
inline-size: var(--dialog-icon-size);
|
|
5000
|
-
block-size: var(--dialog-icon-size);
|
|
5092
|
+
inline-size: var(--dialog-icon-size, 3rem);
|
|
5093
|
+
block-size: var(--dialog-icon-size, 3rem);
|
|
5001
5094
|
flex: none;
|
|
5002
5095
|
border-radius: var(--radius-full);
|
|
5003
5096
|
background: var(--ui-bg, var(--surface-subtle));
|
|
@@ -5007,46 +5100,27 @@ a.badge[aria-disabled="true"] {
|
|
|
5007
5100
|
.dialog-icon > * {
|
|
5008
5101
|
display: inline-grid;
|
|
5009
5102
|
place-items: center;
|
|
5010
|
-
inline-size: var(--dialog-icon-glyph-size);
|
|
5011
|
-
block-size: var(--dialog-icon-glyph-size);
|
|
5012
|
-
font-size: var(--dialog-icon-glyph-size);
|
|
5103
|
+
inline-size: var(--dialog-icon-glyph-size, 1.5rem);
|
|
5104
|
+
block-size: var(--dialog-icon-glyph-size, 1.5rem);
|
|
5105
|
+
font-size: var(--dialog-icon-glyph-size, 1.5rem);
|
|
5013
5106
|
line-height: 1;
|
|
5014
5107
|
}
|
|
5015
5108
|
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
:where(dialog).modal.dialog-confirmation > form > .media > header > * {
|
|
5022
|
-
margin-block: 0;
|
|
5023
|
-
}
|
|
5024
|
-
|
|
5025
|
-
/* The first heading occupies the icon row, so its text centers on the icon
|
|
5026
|
-
without a font-relative nudge. Following copy stays in the same column. */
|
|
5027
|
-
:where(dialog).modal.dialog-confirmation
|
|
5028
|
-
> form
|
|
5029
|
-
> .media
|
|
5030
|
-
> header
|
|
5031
|
-
> :is(h1, h2, h3, h4, h5, h6):first-child {
|
|
5109
|
+
/* A leading icon well centers the first heading of the text column beside it:
|
|
5110
|
+
the heading occupies the icon row, so its text centers without a
|
|
5111
|
+
font-relative nudge, and following copy stays in the same column. Keyed on
|
|
5112
|
+
the media anatomy, not on the dialog, so a card or page row composes it too. */
|
|
5113
|
+
.media > .dialog-icon + * > :is(h1, h2, h3, h4, h5, h6):first-child {
|
|
5032
5114
|
display: flex;
|
|
5033
5115
|
align-items: center;
|
|
5034
5116
|
min-block-size: var(--dialog-icon-size, 3rem);
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
@media (max-width: 30rem) {
|
|
5038
|
-
:where(dialog).modal.dialog-confirmation > form > :is(.media, header),
|
|
5039
|
-
:where(dialog).modal.dialog-confirmation > form > footer {
|
|
5040
|
-
padding: var(--space-40);
|
|
5041
|
-
}
|
|
5117
|
+
margin-block: 0;
|
|
5042
5118
|
}
|
|
5043
5119
|
|
|
5044
5120
|
:where(dialog).modal[open] {
|
|
5045
5121
|
opacity: 1;
|
|
5046
5122
|
transform: none;
|
|
5047
|
-
transition:
|
|
5048
|
-
opacity var(--duration) var(--ease-enter),
|
|
5049
|
-
transform var(--duration) var(--ease-enter);
|
|
5123
|
+
transition-timing-function: var(--ease-enter);
|
|
5050
5124
|
}
|
|
5051
5125
|
|
|
5052
5126
|
@starting-style {
|
|
@@ -5061,31 +5135,22 @@ a.badge[aria-disabled="true"] {
|
|
|
5061
5135
|
opacity: 0;
|
|
5062
5136
|
/* Scrim curve stays neutral `ease`; only the panel carries the character
|
|
5063
5137
|
curves. */
|
|
5064
|
-
transition: opacity
|
|
5138
|
+
transition-property: opacity;
|
|
5139
|
+
transition-duration: var(--duration);
|
|
5140
|
+
transition-timing-function: ease;
|
|
5065
5141
|
}
|
|
5066
5142
|
|
|
5067
5143
|
@supports (transition-behavior: allow-discrete) {
|
|
5144
|
+
/* Add the discrete top-layer pair to the property list. Duration and timing
|
|
5145
|
+
stay on the base rules, so [open] still only swaps the easing. */
|
|
5068
5146
|
:where(dialog).modal {
|
|
5069
|
-
transition:
|
|
5070
|
-
|
|
5071
|
-
transform var(--duration) var(--ease-exit),
|
|
5072
|
-
overlay var(--duration) allow-discrete,
|
|
5073
|
-
display var(--duration) allow-discrete;
|
|
5074
|
-
}
|
|
5075
|
-
|
|
5076
|
-
:where(dialog).modal[open] {
|
|
5077
|
-
transition:
|
|
5078
|
-
opacity var(--duration) var(--ease-enter),
|
|
5079
|
-
transform var(--duration) var(--ease-enter),
|
|
5080
|
-
overlay var(--duration) allow-discrete,
|
|
5081
|
-
display var(--duration) allow-discrete;
|
|
5147
|
+
transition-property: opacity, transform, overlay, display;
|
|
5148
|
+
transition-behavior: allow-discrete;
|
|
5082
5149
|
}
|
|
5083
5150
|
|
|
5084
5151
|
:where(dialog).modal::backdrop {
|
|
5085
|
-
transition:
|
|
5086
|
-
|
|
5087
|
-
overlay var(--duration) allow-discrete,
|
|
5088
|
-
display var(--duration) allow-discrete;
|
|
5152
|
+
transition-property: opacity, overlay, display;
|
|
5153
|
+
transition-behavior: allow-discrete;
|
|
5089
5154
|
}
|
|
5090
5155
|
}
|
|
5091
5156
|
|
|
@@ -5159,8 +5224,10 @@ html.has-modal-open.had-scrollbar {
|
|
|
5159
5224
|
* Internal:
|
|
5160
5225
|
* --drawer-closed-translate (side/RTL positioning state)
|
|
5161
5226
|
* --drawer-focus-bleed
|
|
5227
|
+
* --drawer-close-reserve (header room for a corner close; zero without a close)
|
|
5162
5228
|
* --drawer-motion-duration (shared by panel and ::backdrop so exit duration
|
|
5163
5229
|
* changes in one place; entry state redefines it longer)
|
|
5230
|
+
* --drawer-motion-ease (panel easing owned by the open/closed state)
|
|
5164
5231
|
*/
|
|
5165
5232
|
:where(dialog).drawer {
|
|
5166
5233
|
--drawer-size: 20rem;
|
|
@@ -5168,9 +5235,14 @@ html.has-modal-open.had-scrollbar {
|
|
|
5168
5235
|
/* The inset a direct .bleed child cancels (components/bleed.css). */
|
|
5169
5236
|
--surface-pad: var(--drawer-pad);
|
|
5170
5237
|
--drawer-closed-translate: -100%;
|
|
5238
|
+
/* Header room for a corner close. Unconditional baseline: without :has()
|
|
5239
|
+
(Degraded tier) a header keeps too much room rather than a title running
|
|
5240
|
+
under the close. Zeroed when the drawer has no close. */
|
|
5241
|
+
--drawer-close-reserve: calc(var(--close-size, var(--control-size)) + var(--space-30));
|
|
5171
5242
|
/* Exit is shorter than entry so the panel clears quickly; the scrim reads
|
|
5172
5243
|
the same variable through inheritance and never lags the panel. */
|
|
5173
5244
|
--drawer-motion-duration: var(--duration);
|
|
5245
|
+
--drawer-motion-ease: var(--ease-exit);
|
|
5174
5246
|
|
|
5175
5247
|
/* Keep the surface anchored to the viewport in the top layer. This also
|
|
5176
5248
|
provides the containing block for the absolutely positioned close button.
|
|
@@ -5202,21 +5274,21 @@ html.has-modal-open.had-scrollbar {
|
|
|
5202
5274
|
box-shadow: var(--shadow);
|
|
5203
5275
|
opacity: 0;
|
|
5204
5276
|
transform: translateX(var(--drawer-closed-translate));
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5277
|
+
/* Unsupported discrete behavior drops alone, preserving opacity/transform. */
|
|
5278
|
+
transition-property: opacity, transform, overlay, display;
|
|
5279
|
+
transition-duration: var(--drawer-motion-duration);
|
|
5280
|
+
transition-timing-function: var(--drawer-motion-ease);
|
|
5281
|
+
transition-behavior: allow-discrete;
|
|
5208
5282
|
}
|
|
5209
5283
|
|
|
5210
5284
|
:where(dialog).drawer[open] {
|
|
5211
5285
|
--drawer-motion-duration: var(--duration-slow);
|
|
5286
|
+
--drawer-motion-ease: var(--ease-enter);
|
|
5212
5287
|
display: grid;
|
|
5213
5288
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
5214
5289
|
gap: var(--space-40);
|
|
5215
5290
|
opacity: 1;
|
|
5216
5291
|
transform: translateX(0);
|
|
5217
|
-
transition:
|
|
5218
|
-
opacity var(--drawer-motion-duration) var(--ease-enter),
|
|
5219
|
-
transform var(--drawer-motion-duration) var(--ease-enter);
|
|
5220
5292
|
}
|
|
5221
5293
|
|
|
5222
5294
|
/* A form is the drawer's content wrapper when actions need method="dialog".
|
|
@@ -5236,7 +5308,10 @@ html.has-modal-open.had-scrollbar {
|
|
|
5236
5308
|
opacity: 0;
|
|
5237
5309
|
/* Scrim curve stays neutral `ease`; only the panel's character curves
|
|
5238
5310
|
apply. Duration follows --drawer-motion-duration through inheritance. */
|
|
5239
|
-
transition: opacity
|
|
5311
|
+
transition-property: opacity, overlay, display;
|
|
5312
|
+
transition-duration: var(--drawer-motion-duration);
|
|
5313
|
+
transition-timing-function: ease;
|
|
5314
|
+
transition-behavior: allow-discrete;
|
|
5240
5315
|
}
|
|
5241
5316
|
|
|
5242
5317
|
:where(dialog).drawer[data-side="end"] {
|
|
@@ -5270,47 +5345,23 @@ html.has-modal-open.had-scrollbar {
|
|
|
5270
5345
|
exclusion list is easier to trust than its `position: absolute`. */
|
|
5271
5346
|
:where(dialog).drawer > :not(:is(header, footer, form, .close)),
|
|
5272
5347
|
:where(dialog).drawer > form > :not(:is(header, footer, .close)) {
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
);
|
|
5348
|
+
/* Declared here, not as a :root alias: an inherited alias arrives already
|
|
5349
|
+
computed, so a --focus-ring-width override on the drawer would not reach
|
|
5350
|
+
it. */
|
|
5351
|
+
--drawer-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
5277
5352
|
|
|
5278
5353
|
min-block-size: 0;
|
|
5279
|
-
/* Keep focus paint inside the clip
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5354
|
+
/* Keep focus paint inside the clip, on all four sides (overflow: auto clips
|
|
5355
|
+
both axes), without moving the content. Every outside indicator is
|
|
5356
|
+
--focus-ring-width wide (core/focus.css). On the block axis the reserve
|
|
5357
|
+
reaches into the slot gap, which is wider, so the scrollport never slides
|
|
5358
|
+
under the header or footer. */
|
|
5359
|
+
margin: calc(var(--drawer-focus-bleed) * -1);
|
|
5360
|
+
padding: var(--drawer-focus-bleed);
|
|
5285
5361
|
overflow: auto;
|
|
5286
5362
|
overscroll-behavior: contain;
|
|
5287
5363
|
}
|
|
5288
5364
|
|
|
5289
|
-
@supports (transition-behavior: allow-discrete) {
|
|
5290
|
-
:where(dialog).drawer {
|
|
5291
|
-
transition:
|
|
5292
|
-
opacity var(--drawer-motion-duration) var(--ease-exit),
|
|
5293
|
-
transform var(--drawer-motion-duration) var(--ease-exit),
|
|
5294
|
-
overlay var(--drawer-motion-duration) allow-discrete,
|
|
5295
|
-
display var(--drawer-motion-duration) allow-discrete;
|
|
5296
|
-
}
|
|
5297
|
-
|
|
5298
|
-
:where(dialog).drawer[open] {
|
|
5299
|
-
transition:
|
|
5300
|
-
opacity var(--drawer-motion-duration) var(--ease-enter),
|
|
5301
|
-
transform var(--drawer-motion-duration) var(--ease-enter),
|
|
5302
|
-
overlay var(--drawer-motion-duration) allow-discrete,
|
|
5303
|
-
display var(--drawer-motion-duration) allow-discrete;
|
|
5304
|
-
}
|
|
5305
|
-
|
|
5306
|
-
:where(dialog).drawer::backdrop {
|
|
5307
|
-
transition:
|
|
5308
|
-
opacity var(--drawer-motion-duration) ease,
|
|
5309
|
-
overlay var(--drawer-motion-duration) allow-discrete,
|
|
5310
|
-
display var(--drawer-motion-duration) allow-discrete;
|
|
5311
|
-
}
|
|
5312
|
-
}
|
|
5313
|
-
|
|
5314
5365
|
/* Safari before 16.4 does not support :dir(); keep the ancestor-attribute
|
|
5315
5366
|
path as the Degraded-tier fallback. The :dir() rules are dropped on their
|
|
5316
5367
|
own there, so they need no wrapper. :dir() follows inherited HTML
|
|
@@ -5347,11 +5398,22 @@ html.has-modal-open.had-scrollbar {
|
|
|
5347
5398
|
}
|
|
5348
5399
|
}
|
|
5349
5400
|
|
|
5401
|
+
/* No close anywhere: reclaim the reserve. Deliberately coarse — a close in
|
|
5402
|
+
the body keeps the reserve, which costs only room, never overlap. */
|
|
5403
|
+
:where(dialog).drawer:not(:has(.close)) {
|
|
5404
|
+
--drawer-close-reserve: 0px;
|
|
5405
|
+
}
|
|
5406
|
+
|
|
5350
5407
|
/* The header reserves inline room so the title never runs under the close
|
|
5351
|
-
control. */
|
|
5408
|
+
control. A .bleed header keeps at least its band inset. */
|
|
5352
5409
|
:where(dialog).drawer > header,
|
|
5353
|
-
:where(dialog).drawer > form > header {
|
|
5354
|
-
padding-inline-end:
|
|
5410
|
+
:where(dialog).drawer > :where(form) > header {
|
|
5411
|
+
padding-inline-end: var(--drawer-close-reserve);
|
|
5412
|
+
}
|
|
5413
|
+
|
|
5414
|
+
:where(dialog).drawer > header:where(.bleed),
|
|
5415
|
+
:where(dialog).drawer > :where(form) > header:where(.bleed) {
|
|
5416
|
+
padding-inline-end: max(var(--bleed-pad, 0px), var(--drawer-close-reserve));
|
|
5355
5417
|
}
|
|
5356
5418
|
|
|
5357
5419
|
/*
|
|
@@ -5488,6 +5550,9 @@ picture.bleed > img {
|
|
|
5488
5550
|
position: absolute;
|
|
5489
5551
|
inset-block-start: calc(100% + var(--space-10));
|
|
5490
5552
|
inset-inline-start: 0;
|
|
5553
|
+
/* Anatomy, not a default to replace: `flyout grid-3` (mega menu) takes its
|
|
5554
|
+
columns from the grid preset and keeps this tight gap, because each
|
|
5555
|
+
section pads itself — the visible gutter is section padding + gap. */
|
|
5491
5556
|
display: grid;
|
|
5492
5557
|
gap: var(--space-10);
|
|
5493
5558
|
inline-size: var(--flyout-inline-size, max-content);
|
|
@@ -5528,7 +5593,6 @@ picture.bleed > img {
|
|
|
5528
5593
|
.flyout[popover] {
|
|
5529
5594
|
position: fixed;
|
|
5530
5595
|
inset: auto;
|
|
5531
|
-
margin: 0;
|
|
5532
5596
|
}
|
|
5533
5597
|
|
|
5534
5598
|
/* A presentation class may own its *visible* display type, but must not
|
|
@@ -6497,13 +6561,13 @@ picture.bleed > img {
|
|
|
6497
6561
|
* --avatar-size
|
|
6498
6562
|
* --avatar-radius
|
|
6499
6563
|
* --avatar-font-size
|
|
6564
|
+
* --avatar-bg
|
|
6500
6565
|
* --avatar-stack-size
|
|
6501
6566
|
* --avatar-stack-font-size
|
|
6502
6567
|
* --avatar-stack-overlap
|
|
6503
6568
|
* --avatar-stack-ring
|
|
6504
6569
|
*
|
|
6505
6570
|
* Internal:
|
|
6506
|
-
* --avatar-bg
|
|
6507
6571
|
* --avatar-fg
|
|
6508
6572
|
* --avatar-status-translate-x
|
|
6509
6573
|
*/
|
|
@@ -6581,16 +6645,13 @@ picture.bleed > img {
|
|
|
6581
6645
|
|
|
6582
6646
|
.avatar > img,
|
|
6583
6647
|
.avatar > picture > img {
|
|
6648
|
+
display: block;
|
|
6584
6649
|
inline-size: 100%;
|
|
6585
6650
|
block-size: 100%;
|
|
6586
6651
|
object-fit: cover;
|
|
6587
6652
|
border-radius: inherit;
|
|
6588
6653
|
}
|
|
6589
6654
|
|
|
6590
|
-
.avatar > picture > img {
|
|
6591
|
-
display: block;
|
|
6592
|
-
}
|
|
6593
|
-
|
|
6594
6655
|
button.avatar {
|
|
6595
6656
|
appearance: none;
|
|
6596
6657
|
cursor: pointer;
|
|
@@ -6684,19 +6745,13 @@ a.avatar[aria-disabled="true"] {
|
|
|
6684
6745
|
--avatar-stack-font-size: var(--font-size-lg);
|
|
6685
6746
|
}
|
|
6686
6747
|
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
}
|
|
6692
|
-
|
|
6693
|
-
.avatar-stack:has(> .avatar.lg) {
|
|
6694
|
-
--avatar-stack-size: 3rem;
|
|
6695
|
-
--avatar-stack-font-size: var(--font-size-lg);
|
|
6696
|
-
}
|
|
6697
|
-
}
|
|
6698
|
-
|
|
6748
|
+
/* Overlapping avatars hide their neighbour: a translucent fill (--surface-subtle
|
|
6749
|
+
on a glass palette) would show the next avatar's edge through it, most of all
|
|
6750
|
+
when hover raises one above the other. The fill is layered over the opaque
|
|
6751
|
+
role; on an opaque fill the base never shows. */
|
|
6699
6752
|
.avatar-stack > .avatar {
|
|
6753
|
+
background-color: var(--surface-opaque);
|
|
6754
|
+
background-image: linear-gradient(var(--avatar-bg), var(--avatar-bg));
|
|
6700
6755
|
box-shadow: 0 0 0 var(--avatar-stack-ring) var(--surface-opaque);
|
|
6701
6756
|
}
|
|
6702
6757
|
|
|
@@ -6917,11 +6972,10 @@ a.avatar[aria-disabled="true"] {
|
|
|
6917
6972
|
}
|
|
6918
6973
|
|
|
6919
6974
|
/* Remove the reserved avatar gutter when a message has no avatar. This is a
|
|
6920
|
-
cosmetic enhancement; the two-column baseline remains fully readable
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
}
|
|
6975
|
+
cosmetic enhancement; the two-column baseline remains fully readable, and a
|
|
6976
|
+
browser without :has() drops only this rule. */
|
|
6977
|
+
.chat:not(:has(> .chat-avatar)) {
|
|
6978
|
+
column-gap: 0;
|
|
6925
6979
|
}
|
|
6926
6980
|
|
|
6927
6981
|
/*
|
|
@@ -7196,7 +7250,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7196
7250
|
* destinations. Anything else focusable in a step is out of contract.
|
|
7197
7251
|
*
|
|
7198
7252
|
* The framework never hides an authored label to solve a layout problem. A
|
|
7199
|
-
* labelled row
|
|
7253
|
+
* labelled row wraps its labels, then scrolls if its markers or authored item
|
|
7254
|
+
* minimum outgrow the available space. An `actual-container` size
|
|
7200
7255
|
* context buys it one thing: the wide representation, marker beside label.
|
|
7201
7256
|
*
|
|
7202
7257
|
* See docs/design-notes/steps.md for the threshold, the selector shapes, the
|
|
@@ -7332,10 +7387,11 @@ a.avatar[aria-disabled="true"] {
|
|
|
7332
7387
|
/* Horizontal ------------------------------------------------------------- */
|
|
7333
7388
|
|
|
7334
7389
|
.steps-horizontal {
|
|
7335
|
-
--step-min:
|
|
7390
|
+
--step-min: var(--step-size);
|
|
7336
7391
|
--step-gap: var(--space-20);
|
|
7337
7392
|
|
|
7338
7393
|
inline-size: 100%;
|
|
7394
|
+
column-gap: var(--step-gap);
|
|
7339
7395
|
overflow-x: auto;
|
|
7340
7396
|
/* Stated, never left to `overflow-x` alone: one scrolling axis computes the
|
|
7341
7397
|
other to `auto`, and a row hugs fractional content closely enough that a
|
|
@@ -7344,11 +7400,22 @@ a.avatar[aria-disabled="true"] {
|
|
|
7344
7400
|
overscroll-behavior-inline: contain;
|
|
7345
7401
|
}
|
|
7346
7402
|
|
|
7403
|
+
/* A row that loads mid-flow starts scrolled to its current step; engines
|
|
7404
|
+
without the property start at the first. */
|
|
7405
|
+
.steps-horizontal > [aria-current="step"] {
|
|
7406
|
+
/* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
|
|
7407
|
+
scroll-initial-target: nearest;
|
|
7408
|
+
}
|
|
7409
|
+
|
|
7347
7410
|
/* A scroll container clips at the padding edge, so a navigable row reserves the
|
|
7348
|
-
focus ring's own width and offset
|
|
7349
|
-
link has a ring to
|
|
7411
|
+
focus ring's own width and offset, and focus scrolls a link in that far from
|
|
7412
|
+
the inline clip. Only this composition scrolls, and only a link has a ring to
|
|
7413
|
+
protect. */
|
|
7350
7414
|
.steps-horizontal:has(a[href]) {
|
|
7415
|
+
/* Wrapped names can fill their cell right up to either inline clip edge. */
|
|
7416
|
+
padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
7351
7417
|
padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
7418
|
+
scroll-padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
7352
7419
|
}
|
|
7353
7420
|
|
|
7354
7421
|
/* One explicit row, the marker's: a `.step-label` is auto-placed into an
|
|
@@ -7360,6 +7427,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7360
7427
|
.steps-horizontal > li {
|
|
7361
7428
|
display: grid;
|
|
7362
7429
|
flex: 1 0 var(--step-min);
|
|
7430
|
+
/* Bound each label to its share so it cannot change the marker pitch. */
|
|
7431
|
+
grid-template-columns: minmax(0, 1fr);
|
|
7363
7432
|
grid-template-rows: var(--step-size);
|
|
7364
7433
|
gap: var(--step-gap);
|
|
7365
7434
|
justify-items: center;
|
|
@@ -7367,9 +7436,42 @@ a.avatar[aria-disabled="true"] {
|
|
|
7367
7436
|
text-align: center;
|
|
7368
7437
|
}
|
|
7369
7438
|
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7439
|
+
.steps-horizontal .step-label {
|
|
7440
|
+
/* Even an unbroken name must fit its share instead of overlapping a peer.
|
|
7441
|
+
Hyphenation, when `lang` allows it, is tried before an arbitrary break. */
|
|
7442
|
+
overflow-wrap: anywhere;
|
|
7443
|
+
hyphens: auto;
|
|
7444
|
+
}
|
|
7445
|
+
|
|
7446
|
+
/* Half end cells put the outer markers on the row's edges without changing
|
|
7447
|
+
their pitch, so the track lines up with the text around it, as the compact
|
|
7448
|
+
and inline forms do. The marker's half-width keeps the first connector equal
|
|
7449
|
+
to its peers.
|
|
7450
|
+
|
|
7451
|
+
Source order is load-bearing: these tie the `:empty` rules below and the
|
|
7452
|
+
inline query on specificity, and both must win over them. */
|
|
7453
|
+
.steps-horizontal > li:is(:first-child, :last-child) {
|
|
7454
|
+
flex: 0.5 0 calc((var(--step-min) + var(--step-size)) / 2);
|
|
7455
|
+
min-inline-size: max(var(--step-size), calc((var(--step-min) + var(--step-size)) / 2));
|
|
7456
|
+
}
|
|
7457
|
+
|
|
7458
|
+
.steps-horizontal > li:first-child {
|
|
7459
|
+
justify-items: start;
|
|
7460
|
+
text-align: start;
|
|
7461
|
+
}
|
|
7462
|
+
|
|
7463
|
+
.steps-horizontal > li:first-child::after {
|
|
7464
|
+
inset-inline-start: var(--step-size);
|
|
7465
|
+
inline-size: calc(200% - var(--step-size) * 2 + var(--step-gap));
|
|
7466
|
+
}
|
|
7467
|
+
|
|
7468
|
+
.steps-horizontal > li:last-child {
|
|
7469
|
+
justify-items: end;
|
|
7470
|
+
text-align: end;
|
|
7471
|
+
}
|
|
7472
|
+
|
|
7473
|
+
/* An empty step ignores any authored reading-width floor and needs only its
|
|
7474
|
+
marker. That lets five of them fit a phone without a size context.
|
|
7373
7475
|
|
|
7374
7476
|
`justify-items` is not an override of the row's centring: the marker is
|
|
7375
7477
|
centred because a label sits under it and the two share a centre, and with no
|
|
@@ -7379,9 +7481,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7379
7481
|
the text around it, the way the inline representation does.
|
|
7380
7482
|
|
|
7381
7483
|
`:empty` says exactly what the markup says, the way `.badge:empty` does, and
|
|
7382
|
-
it reads the item's own content: bare text
|
|
7383
|
-
|
|
7384
|
-
floor and skew the row. `:empty` also keeps the compact form reachable on the
|
|
7484
|
+
it reads the item's own content: bare text keeps the same authored floor as
|
|
7485
|
+
its wrapped peers. `:empty` also keeps the compact form reachable on the
|
|
7385
7486
|
Degraded tier, where only the inline bonus needs `:has()`. */
|
|
7386
7487
|
.steps-horizontal > li:empty {
|
|
7387
7488
|
flex: 1 1 0;
|
|
@@ -7391,6 +7492,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7391
7492
|
|
|
7392
7493
|
.steps-horizontal > li:empty::after {
|
|
7393
7494
|
inset-inline-start: var(--step-size);
|
|
7495
|
+
/* Marker-only end cells keep full shares, unlike labelled end cells. */
|
|
7496
|
+
inline-size: calc(100% - var(--step-size) + var(--step-gap));
|
|
7394
7497
|
}
|
|
7395
7498
|
|
|
7396
7499
|
.steps-horizontal > li:empty:last-child {
|
|
@@ -7398,12 +7501,12 @@ a.avatar[aria-disabled="true"] {
|
|
|
7398
7501
|
}
|
|
7399
7502
|
|
|
7400
7503
|
/* The pseudo-element is marker-height so --step-connector may be any complete
|
|
7401
|
-
background value. */
|
|
7504
|
+
background value; its length also crosses the gap to the next equal track. */
|
|
7402
7505
|
.steps-horizontal > li::after {
|
|
7403
7506
|
position: absolute;
|
|
7404
7507
|
inset-block-start: 0;
|
|
7405
7508
|
inset-inline-start: calc(50% + var(--step-size) / 2);
|
|
7406
|
-
inline-size: calc(100% - var(--step-size));
|
|
7509
|
+
inline-size: calc(100% - var(--step-size) + var(--step-gap));
|
|
7407
7510
|
block-size: var(--step-size);
|
|
7408
7511
|
background: var(
|
|
7409
7512
|
--step-connector,
|
|
@@ -7422,6 +7525,11 @@ a.avatar[aria-disabled="true"] {
|
|
|
7422
7525
|
* written — a row out of contract is not owed a curated fallback.
|
|
7423
7526
|
*/
|
|
7424
7527
|
@container actual-container (inline-size >= 60rem) {
|
|
7528
|
+
.steps-horizontal:has(.step-label) {
|
|
7529
|
+
/* Inline connectors already own the spacing between marker-label groups. */
|
|
7530
|
+
column-gap: 0;
|
|
7531
|
+
}
|
|
7532
|
+
|
|
7425
7533
|
.steps-horizontal:has(.step-label) > li {
|
|
7426
7534
|
display: flex;
|
|
7427
7535
|
flex: 1 1 auto;
|
|
@@ -7620,8 +7728,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7620
7728
|
*
|
|
7621
7729
|
* Docs: docs/pages/components/navbar.md
|
|
7622
7730
|
*
|
|
7623
|
-
* .navbar is a horizontal bar; .navbar-brand is the brand link;
|
|
7624
|
-
*
|
|
7731
|
+
* .navbar is a horizontal bar; .navbar-brand is the brand link; the inline
|
|
7732
|
+
* links are a .cluster using .nav-link items. .nav-list is the vertical nav
|
|
7625
7733
|
* list shared with .drawer, fed by the shared --gap token.
|
|
7626
7734
|
*
|
|
7627
7735
|
* Internal:
|
|
@@ -7670,17 +7778,6 @@ a.avatar[aria-disabled="true"] {
|
|
|
7670
7778
|
border-radius: var(--radius-sm);
|
|
7671
7779
|
}
|
|
7672
7780
|
|
|
7673
|
-
.navbar-nav {
|
|
7674
|
-
display: flex;
|
|
7675
|
-
flex-direction: row;
|
|
7676
|
-
flex-wrap: wrap;
|
|
7677
|
-
align-items: center;
|
|
7678
|
-
gap: var(--space-10);
|
|
7679
|
-
margin: 0;
|
|
7680
|
-
padding: 0;
|
|
7681
|
-
list-style: none;
|
|
7682
|
-
}
|
|
7683
|
-
|
|
7684
7781
|
.nav-link {
|
|
7685
7782
|
display: inline-flex;
|
|
7686
7783
|
align-items: center;
|
|
@@ -7699,23 +7796,19 @@ a.avatar[aria-disabled="true"] {
|
|
|
7699
7796
|
|
|
7700
7797
|
/* nav-link serves two contracts: plain page navigation (aria-current="page")
|
|
7701
7798
|
and scrollspy, which toggles aria-current="location" on the active link.
|
|
7702
|
-
|
|
7703
|
-
removed everywhere else, never serialized as aria-current="false".
|
|
7799
|
+
Those two values are the contract; any other, "false" included, is inert.
|
|
7704
7800
|
Selection never changes text metrics: the shared medium weight is kept and
|
|
7705
7801
|
the current link is carried by the selected accent. */
|
|
7706
|
-
.nav-link[aria-current] {
|
|
7707
|
-
background: var(--surface-subtle);
|
|
7708
|
-
color: var(--state-selected);
|
|
7709
|
-
}
|
|
7710
|
-
|
|
7711
7802
|
.nav-link:hover {
|
|
7712
7803
|
background: var(--surface-subtle);
|
|
7713
7804
|
color: var(--text);
|
|
7714
7805
|
}
|
|
7715
7806
|
|
|
7716
|
-
/*
|
|
7717
|
-
|
|
7718
|
-
|
|
7807
|
+
/* Current last, equal specificity to :hover (0,2,0): a hovered current link
|
|
7808
|
+
keeps the selected accent instead of dropping to --text. The surface fill is
|
|
7809
|
+
shared with the hover rule above. */
|
|
7810
|
+
.nav-link:is([aria-current="page"], [aria-current="location"]) {
|
|
7811
|
+
background: var(--surface-subtle);
|
|
7719
7812
|
color: var(--state-selected);
|
|
7720
7813
|
}
|
|
7721
7814
|
|
|
@@ -7727,7 +7820,7 @@ a.avatar[aria-disabled="true"] {
|
|
|
7727
7820
|
above keeps the light chip where color-mix is missing, with ink that is
|
|
7728
7821
|
legible on it. */
|
|
7729
7822
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
7730
|
-
.nav-link:is(:hover, [aria-current]) {
|
|
7823
|
+
.nav-link:is(:hover, [aria-current="page"], [aria-current="location"]) {
|
|
7731
7824
|
background: color-mix(in oklch, currentColor 10%, transparent);
|
|
7732
7825
|
}
|
|
7733
7826
|
|
|
@@ -7735,19 +7828,22 @@ a.avatar[aria-disabled="true"] {
|
|
|
7735
7828
|
color: var(--ui-fg, var(--text));
|
|
7736
7829
|
}
|
|
7737
7830
|
|
|
7738
|
-
.
|
|
7739
|
-
.nav-link[aria-current]
|
|
7831
|
+
/* After :hover so a hovered current link keeps the selected accent. */
|
|
7832
|
+
.nav-link:is([aria-current="page"], [aria-current="location"]) {
|
|
7740
7833
|
color: var(--ui-fg, var(--state-selected));
|
|
7741
7834
|
}
|
|
7742
7835
|
}
|
|
7743
7836
|
|
|
7744
7837
|
/* Nav list — a vertical navigation list. Used by .drawer for stacked links.
|
|
7745
7838
|
Links fill each row so the whole line is an interactive target; the
|
|
7746
|
-
horizontal .
|
|
7839
|
+
horizontal .cluster links keep their intrinsic size. */
|
|
7840
|
+
/* A vertical nav reads as one list, not a stack of separate buttons: each row
|
|
7841
|
+
is already a control-height target, so the rows sit a hair apart. The grid
|
|
7842
|
+
is the component's own layout; for a horizontal row use .cluster instead. */
|
|
7747
7843
|
.nav-list {
|
|
7748
7844
|
display: grid;
|
|
7749
7845
|
grid-template-columns: minmax(0, 1fr);
|
|
7750
|
-
--gap: var(--space-
|
|
7846
|
+
--gap: var(--space-10);
|
|
7751
7847
|
gap: var(--gap);
|
|
7752
7848
|
margin: 0;
|
|
7753
7849
|
padding: 0;
|
|
@@ -7767,13 +7863,16 @@ a.avatar[aria-disabled="true"] {
|
|
|
7767
7863
|
follows the forced palette without a forced-colors block and never touches
|
|
7768
7864
|
the row separators. Navbar horizontal links keep color + surface only. The
|
|
7769
7865
|
positioning context lives on every row link, so becoming current never moves
|
|
7770
|
-
an app-positioned decoration to a new containing block.
|
|
7771
|
-
|
|
7866
|
+
an app-positioned decoration to a new containing block.
|
|
7867
|
+
The trait sits centered in the link's inline padding with rounded ends: flush
|
|
7868
|
+
on the edge, a square line read as pasted onto the rounded fill. */
|
|
7869
|
+
.nav-list .nav-link:is([aria-current="page"], [aria-current="location"])::before {
|
|
7772
7870
|
content: "";
|
|
7773
7871
|
position: absolute;
|
|
7774
7872
|
inset-block: var(--space-20);
|
|
7775
|
-
inset-inline-start:
|
|
7873
|
+
inset-inline-start: calc((var(--space-20) - 2px) / 2);
|
|
7776
7874
|
border-inline-start: 2px solid currentColor;
|
|
7875
|
+
border-radius: var(--radius-full);
|
|
7777
7876
|
}
|
|
7778
7877
|
|
|
7779
7878
|
/*
|
|
@@ -7782,14 +7881,21 @@ a.avatar[aria-disabled="true"] {
|
|
|
7782
7881
|
* Docs: docs/pages/components/breadcrumb.md
|
|
7783
7882
|
*
|
|
7784
7883
|
* Inline list separated by "/". Mark the current page with `aria-current="page"`
|
|
7785
|
-
* either on the item or on the current link.
|
|
7884
|
+
* either on the item or on the current link. Ancestor labels sit in an <a> or a
|
|
7885
|
+
* <span>, the box that truncates.
|
|
7886
|
+
*
|
|
7887
|
+
* Breadcrumbs are a single navigation hierarchy. Wrapping makes hierarchy look
|
|
7888
|
+
* like separate rows and causes unstable header height, and scrolling hides the
|
|
7889
|
+
* root behind a scrollbar. A trail that does not fit truncates instead:
|
|
7890
|
+
* ancestors first, the current page last, each label behind an ellipsis.
|
|
7786
7891
|
*/
|
|
7892
|
+
|
|
7787
7893
|
.breadcrumb {
|
|
7788
7894
|
display: flex;
|
|
7789
|
-
flex-
|
|
7790
|
-
flex-wrap: wrap;
|
|
7895
|
+
flex-wrap: nowrap;
|
|
7791
7896
|
align-items: center;
|
|
7792
7897
|
gap: var(--space-10);
|
|
7898
|
+
min-inline-size: 0;
|
|
7793
7899
|
margin: 0;
|
|
7794
7900
|
padding: 0;
|
|
7795
7901
|
color: var(--text-subtle);
|
|
@@ -7797,15 +7903,74 @@ a.avatar[aria-disabled="true"] {
|
|
|
7797
7903
|
list-style: none;
|
|
7798
7904
|
}
|
|
7799
7905
|
|
|
7800
|
-
|
|
7801
|
-
|
|
7906
|
+
/* The landmark around the trail must shrink with it in a flex or grid row, or
|
|
7907
|
+
it keeps the trail's full width and the labels never get to truncate.
|
|
7908
|
+
Without :has() the landmark keeps its width; min-inline-size: 0 on the
|
|
7909
|
+
wrapper is the fallback. */
|
|
7910
|
+
:where(nav:has(> .breadcrumb)) {
|
|
7911
|
+
min-inline-size: 0;
|
|
7912
|
+
}
|
|
7913
|
+
|
|
7914
|
+
/* Grid, not flex: under a min-content constraint a grid track keeps its base
|
|
7915
|
+
size, which is the label's minimum below, so the item's own minimum is
|
|
7916
|
+
separator + label floor without anyone measuring a "/". A flex item would
|
|
7917
|
+
report the full label as its minimum and never shrink.
|
|
7918
|
+
|
|
7919
|
+
Ancestors take the shrink before the current page, so the location is the
|
|
7920
|
+
last thing to lose characters. The ratio is deliberately huge: at 1000 the
|
|
7921
|
+
current page still took a fraction of a pixel, enough to trigger its
|
|
7922
|
+
ellipsis while ancestors had room. Its own factor stays 1, because flex
|
|
7923
|
+
distributes only a share of the deficit when the unfrozen factors sum below
|
|
7924
|
+
one. Keyed on the last item, the current page by convention, so the order
|
|
7925
|
+
holds without :has(). */
|
|
7926
|
+
.breadcrumb > li {
|
|
7927
|
+
display: inline-grid;
|
|
7928
|
+
flex: 0 1000000 auto;
|
|
7929
|
+
grid-auto-flow: column;
|
|
7802
7930
|
align-items: center;
|
|
7803
|
-
|
|
7931
|
+
white-space: nowrap;
|
|
7804
7932
|
}
|
|
7805
7933
|
|
|
7806
|
-
.breadcrumb
|
|
7807
|
-
|
|
7934
|
+
.breadcrumb > li:last-child {
|
|
7935
|
+
flex-shrink: 1;
|
|
7936
|
+
}
|
|
7937
|
+
|
|
7938
|
+
/* A margin rather than a grid gap, so the separator keeps its space when the
|
|
7939
|
+
current page below lays it out inline. The weight stays normal there, where
|
|
7940
|
+
it would inherit the current page's. */
|
|
7941
|
+
.breadcrumb > li + li::before {
|
|
7942
|
+
margin-inline-end: var(--space-10);
|
|
7808
7943
|
color: var(--text-subtle);
|
|
7944
|
+
font-weight: var(--font-weight-normal);
|
|
7945
|
+
content: "/";
|
|
7946
|
+
}
|
|
7947
|
+
|
|
7948
|
+
/* The ellipsis lives on the label, not on the item: an element never clips
|
|
7949
|
+
its own outline, but an overflow-hidden item would clip its link's focus
|
|
7950
|
+
line, which sits outside the link box.
|
|
7951
|
+
|
|
7952
|
+
The floor keeps a truncated label readable as a step ("Po…") instead of
|
|
7953
|
+
collapsing to its separator. calc-size() lets a shorter label keep its own
|
|
7954
|
+
width; without it, the plain 2em floor leaves a label under 2em a little
|
|
7955
|
+
trailing room. */
|
|
7956
|
+
.breadcrumb > li > :is(a, span) {
|
|
7957
|
+
min-inline-size: 2em;
|
|
7958
|
+
min-inline-size: calc-size(max-content, min(size, 2em));
|
|
7959
|
+
overflow: hidden;
|
|
7960
|
+
text-overflow: ellipsis;
|
|
7961
|
+
}
|
|
7962
|
+
|
|
7963
|
+
/* The current page written as bare text has no label box, so its item
|
|
7964
|
+
truncates itself. Only with no element inside: an aria-current item that
|
|
7965
|
+
holds a link would clip that link's focus line, and keeps the label rule
|
|
7966
|
+
above instead. Its own rule, never merged with the one above: a browser
|
|
7967
|
+
without :has() drops it alone, and bare text then keeps its full width. */
|
|
7968
|
+
.breadcrumb > li[aria-current="page"]:not(:has(> *)) {
|
|
7969
|
+
display: block;
|
|
7970
|
+
min-inline-size: 2em;
|
|
7971
|
+
min-inline-size: calc-size(max-content, min(size, 2em));
|
|
7972
|
+
overflow: hidden;
|
|
7973
|
+
text-overflow: ellipsis;
|
|
7809
7974
|
}
|
|
7810
7975
|
|
|
7811
7976
|
.breadcrumb a {
|
|
@@ -7839,6 +8004,11 @@ a.avatar[aria-disabled="true"] {
|
|
|
7839
8004
|
* Truncated ranges are plain text (`.muted`, `aria-hidden="true"`), not .btn
|
|
7840
8005
|
* — they are decorative, not actionable. They still get the same box sizing
|
|
7841
8006
|
* as .btn below so the row stays visually aligned.
|
|
8007
|
+
*
|
|
8008
|
+
* The row wraps when it does not fit: every page stays visible and
|
|
8009
|
+
* clickable. A strip that scrolled hid pages behind a scrollbar that clicking
|
|
8010
|
+
* cannot move, since a click changes the page. Keeping the rendered range
|
|
8011
|
+
* compact is the application's job.
|
|
7842
8012
|
*/
|
|
7843
8013
|
.pagination {
|
|
7844
8014
|
display: flex;
|
|
@@ -7888,8 +8058,18 @@ a.avatar[aria-disabled="true"] {
|
|
|
7888
8058
|
* author class can then adjust a vertical strip without restating the
|
|
7889
8059
|
* attribute to reach its specificity.
|
|
7890
8060
|
*
|
|
8061
|
+
* A horizontal strip is one axis of navigation, so it never wraps: a second
|
|
8062
|
+
* line no longer matches the Left/Right model and reads as a nested level.
|
|
8063
|
+
* When the tabs do not fit, the strip scrolls without a scrollbar; focus
|
|
8064
|
+
* scrolls the active tab into view.
|
|
8065
|
+
*
|
|
7891
8066
|
* Public hooks:
|
|
8067
|
+
* --tab-flex
|
|
7892
8068
|
* --tab-gap
|
|
8069
|
+
*
|
|
8070
|
+
* Internal:
|
|
8071
|
+
* --tab-edge
|
|
8072
|
+
* --tab-fade-dir (read inside @keyframes, so it resolves per element)
|
|
7893
8073
|
*/
|
|
7894
8074
|
/* Intent boundary — inherited intents do not tint this component.
|
|
7895
8075
|
Local intent classes still win because :where() has zero specificity. */
|
|
@@ -7900,27 +8080,129 @@ a.avatar[aria-disabled="true"] {
|
|
|
7900
8080
|
.tabs {
|
|
7901
8081
|
display: flex;
|
|
7902
8082
|
flex-direction: row;
|
|
7903
|
-
flex-wrap:
|
|
8083
|
+
flex-wrap: nowrap;
|
|
7904
8084
|
gap: var(--space-10);
|
|
7905
8085
|
margin: 0;
|
|
7906
8086
|
padding: 0;
|
|
8087
|
+
/* Block overflow is hidden, not auto: overflow-x: auto alone computes the
|
|
8088
|
+
block axis to auto, so a sub-pixel line box would add a vertical
|
|
8089
|
+
scrollbar inside the strip. Nothing paints outside it: the focus outline
|
|
8090
|
+
is inset and the indicator sits inside the tab. */
|
|
8091
|
+
overflow: auto hidden;
|
|
8092
|
+
overscroll-behavior-inline: contain;
|
|
8093
|
+
/* why: a visible bar sits on the indicator and the hairline and reads as a
|
|
8094
|
+
second underline. The strip has its own scroll controls: selecting the
|
|
8095
|
+
clipped tab, or focusing it, brings it into view with the next one
|
|
8096
|
+
hinted by scroll-padding; arrow keys and swipes do the same. */
|
|
8097
|
+
scrollbar-width: none;
|
|
8098
|
+
/* Focus scrolls a tab in with room to spare, hinting at the next one; the
|
|
8099
|
+
edge fade below spans the same inset, so a revealed tab stays clear of it. */
|
|
8100
|
+
--tab-edge: var(--space-60);
|
|
8101
|
+
|
|
8102
|
+
scroll-padding-inline: var(--tab-edge);
|
|
7907
8103
|
border-block-end: var(--border-width) solid var(--border);
|
|
7908
8104
|
list-style: none;
|
|
7909
8105
|
}
|
|
7910
8106
|
|
|
8107
|
+
/* The landmark around link tabs is not a scroll container, so as a flex or
|
|
8108
|
+
grid item it would keep the strip's full width and push the page into
|
|
8109
|
+
scrolling. Letting it shrink hands the overflow back to the strip.
|
|
8110
|
+
Without :has() the landmark keeps its width; min-inline-size: 0 on the
|
|
8111
|
+
wrapper is the fallback. */
|
|
8112
|
+
:where(nav:has(> .tabs)) {
|
|
8113
|
+
min-inline-size: 0;
|
|
8114
|
+
}
|
|
8115
|
+
|
|
8116
|
+
/* Items keep their size and the strip scrolls instead. Covers both
|
|
8117
|
+
anatomies: .tab directly in a tablist, or <li> in a navigation list.
|
|
8118
|
+
--tab-flex: 1 1 0 shares the width equally; an item still never shrinks
|
|
8119
|
+
below its label (flex items keep min-content), so a strip that cannot fit
|
|
8120
|
+
scrolls as before. */
|
|
8121
|
+
.tabs > * {
|
|
8122
|
+
flex: var(--tab-flex, none);
|
|
8123
|
+
}
|
|
8124
|
+
|
|
7911
8125
|
.tabs:where([aria-orientation="vertical"]) {
|
|
7912
8126
|
display: grid;
|
|
8127
|
+
overflow: visible;
|
|
7913
8128
|
align-content: start;
|
|
7914
8129
|
justify-items: stretch;
|
|
7915
8130
|
border-block-end: 0;
|
|
7916
8131
|
border-inline-end: var(--border-width) solid var(--border);
|
|
7917
8132
|
}
|
|
7918
8133
|
|
|
8134
|
+
/* Edge fade: with no scrollbar, the strip fades the edges it can still scroll
|
|
8135
|
+
toward. It runs on the strip's own inline timeline, which is inactive on a
|
|
8136
|
+
strip that fits or a vertical rail, so those stay unmasked. Keep the
|
|
8137
|
+
@supports: without scroll timelines the animation would end at once and pin
|
|
8138
|
+
its last mask. It follows scroll, not time, so reduced motion keeps it. */
|
|
8139
|
+
@supports (animation-timeline: scroll()) {
|
|
8140
|
+
.tabs {
|
|
8141
|
+
--tab-fade-dir: to right;
|
|
8142
|
+
|
|
8143
|
+
animation: actual-tabs-fade linear both;
|
|
8144
|
+
animation-timeline: scroll(self inline);
|
|
8145
|
+
}
|
|
8146
|
+
|
|
8147
|
+
/* Gradient keywords are physical; :dir() follows the strip's own
|
|
8148
|
+
direction, so an LTR island in an RTL page keeps its fade on the right. */
|
|
8149
|
+
.tabs:dir(rtl) {
|
|
8150
|
+
--tab-fade-dir: to left;
|
|
8151
|
+
}
|
|
8152
|
+
}
|
|
8153
|
+
|
|
8154
|
+
/* A strip that loads on a current link tab starts scrolled to it. Horizontal
|
|
8155
|
+
only: the strip is then its nearest scroll container, while a vertical
|
|
8156
|
+
rail would hand the target to the page. Engines without the property start
|
|
8157
|
+
at the first tab, and the fade says there is more. */
|
|
8158
|
+
:where(.tabs:not([aria-orientation="vertical"]))
|
|
8159
|
+
.tab:is([aria-current="page"], [aria-current="true"]) {
|
|
8160
|
+
/* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
|
|
8161
|
+
scroll-initial-target: nearest;
|
|
8162
|
+
}
|
|
8163
|
+
|
|
8164
|
+
/* Forced colors keep every label at full contrast. */
|
|
8165
|
+
@media (forced-colors: active) {
|
|
8166
|
+
.tabs {
|
|
8167
|
+
animation: none;
|
|
8168
|
+
}
|
|
8169
|
+
}
|
|
8170
|
+
|
|
8171
|
+
/* mask-image does not interpolate, so each mask holds until halfway to the
|
|
8172
|
+
next keyframe: the start fade appears within the first pixels of scroll and
|
|
8173
|
+
the end fade drops at the very end. */
|
|
8174
|
+
@keyframes actual-tabs-fade {
|
|
8175
|
+
0% {
|
|
8176
|
+
mask-image: linear-gradient(
|
|
8177
|
+
var(--tab-fade-dir),
|
|
8178
|
+
#000 calc(100% - var(--tab-edge)),
|
|
8179
|
+
transparent
|
|
8180
|
+
);
|
|
8181
|
+
}
|
|
8182
|
+
|
|
8183
|
+
1%,
|
|
8184
|
+
99% {
|
|
8185
|
+
mask-image: linear-gradient(
|
|
8186
|
+
var(--tab-fade-dir),
|
|
8187
|
+
transparent,
|
|
8188
|
+
#000 var(--tab-edge) calc(100% - var(--tab-edge)),
|
|
8189
|
+
transparent
|
|
8190
|
+
);
|
|
8191
|
+
}
|
|
8192
|
+
|
|
8193
|
+
100% {
|
|
8194
|
+
mask-image: linear-gradient(var(--tab-fade-dir), transparent, #000 var(--tab-edge));
|
|
8195
|
+
}
|
|
8196
|
+
}
|
|
8197
|
+
|
|
7919
8198
|
.tab {
|
|
7920
8199
|
/* Positioning context for the selected indicator pseudo. */
|
|
7921
8200
|
position: relative;
|
|
7922
8201
|
display: inline-flex;
|
|
7923
8202
|
align-items: center;
|
|
8203
|
+
/* A tab wider than its label (--tab-flex) centers it; at natural width this
|
|
8204
|
+
changes nothing, and a vertical rail resets it to the start below. */
|
|
8205
|
+
justify-content: center;
|
|
7924
8206
|
gap: var(--tab-gap, 0.375em);
|
|
7925
8207
|
min-block-size: var(--control-size);
|
|
7926
8208
|
padding-inline: var(--space-30);
|
|
@@ -7934,12 +8216,18 @@ a.avatar[aria-disabled="true"] {
|
|
|
7934
8216
|
the strip. The active state is carried by color and the indicator line. */
|
|
7935
8217
|
font-weight: var(--font-weight-semibold);
|
|
7936
8218
|
text-decoration: none;
|
|
8219
|
+
/* A label breaking inside a one-line strip is the same defect as the strip
|
|
8220
|
+
wrapping. */
|
|
8221
|
+
white-space: nowrap;
|
|
7937
8222
|
cursor: pointer;
|
|
7938
8223
|
}
|
|
7939
8224
|
|
|
8225
|
+
/* A vertical strip has no inline axis to protect: long labels wrap within the
|
|
8226
|
+
rail instead of overflowing it. */
|
|
7940
8227
|
.tabs:where([aria-orientation="vertical"]) .tab {
|
|
7941
8228
|
justify-content: flex-start;
|
|
7942
8229
|
padding-inline-end: var(--space-40);
|
|
8230
|
+
white-space: normal;
|
|
7943
8231
|
}
|
|
7944
8232
|
|
|
7945
8233
|
.tab:is(:disabled, [aria-disabled="true"]) {
|
|
@@ -7970,8 +8258,13 @@ a.avatar[aria-disabled="true"] {
|
|
|
7970
8258
|
border-end-end-radius: 0;
|
|
7971
8259
|
}
|
|
7972
8260
|
|
|
8261
|
+
/* A link tab is current on aria-current="page". "true" is for the one place
|
|
8262
|
+
where no page token fits: a navigation flyout trigger whose panel holds the
|
|
8263
|
+
current page. State selectors name their values, never the attribute's
|
|
8264
|
+
presence, so "false" (what React and Vue serialize for a false binding)
|
|
8265
|
+
stays inert. */
|
|
7973
8266
|
.tab[aria-selected="true"],
|
|
7974
|
-
.tab[aria-current="page"] {
|
|
8267
|
+
.tab:is([aria-current="page"], [aria-current="true"]) {
|
|
7975
8268
|
color: var(--intent, var(--state-selected));
|
|
7976
8269
|
}
|
|
7977
8270
|
|
|
@@ -7980,7 +8273,7 @@ a.avatar[aria-disabled="true"] {
|
|
|
7980
8273
|
forced colors and erase the distinction. currentColor follows the palette the
|
|
7981
8274
|
UA forces, so no forced-colors block is needed. */
|
|
7982
8275
|
.tab[aria-selected="true"]::after,
|
|
7983
|
-
.tab[aria-current="page"]::after {
|
|
8276
|
+
.tab:is([aria-current="page"], [aria-current="true"])::after {
|
|
7984
8277
|
content: "";
|
|
7985
8278
|
position: absolute;
|
|
7986
8279
|
inset-inline: 0;
|
|
@@ -7989,13 +8282,20 @@ a.avatar[aria-disabled="true"] {
|
|
|
7989
8282
|
}
|
|
7990
8283
|
|
|
7991
8284
|
.tabs:where([aria-orientation="vertical"]) .tab[aria-selected="true"]::after,
|
|
7992
|
-
.tabs:where([aria-orientation="vertical"])
|
|
8285
|
+
.tabs:where([aria-orientation="vertical"])
|
|
8286
|
+
.tab:is([aria-current="page"], [aria-current="true"])::after {
|
|
7993
8287
|
inset-block: 0;
|
|
7994
8288
|
inset-inline: auto 0;
|
|
7995
8289
|
border-block-end: 0;
|
|
7996
8290
|
border-inline-end: 2px solid currentColor;
|
|
7997
8291
|
}
|
|
7998
8292
|
|
|
8293
|
+
/* A shared width (--tab-flex) lands on the <li> of a link tab; the tab fills
|
|
8294
|
+
it, so the indicator and the hit area span the whole share. */
|
|
8295
|
+
.tabs > li > .tab {
|
|
8296
|
+
inline-size: 100%;
|
|
8297
|
+
}
|
|
8298
|
+
|
|
7999
8299
|
/*
|
|
8000
8300
|
* Scrollspy — behavior hook for navigation that marks the active section
|
|
8001
8301
|
* while the page scrolls.
|
|
@@ -8006,7 +8306,7 @@ a.avatar[aria-disabled="true"] {
|
|
|
8006
8306
|
* <nav class="scrollspy">
|
|
8007
8307
|
* containing <a href="#section"> links and toggles aria-current="location" on
|
|
8008
8308
|
* the link whose target is in view. Pair with .nav-list and .nav-link, or style
|
|
8009
|
-
* [aria-current] yourself.
|
|
8309
|
+
* [aria-current="location"] yourself.
|
|
8010
8310
|
*
|
|
8011
8311
|
* Progressive enhancement: on browsers that support :target-current,
|
|
8012
8312
|
* this file enables the native target group and marks the active link.
|
|
@@ -8121,9 +8421,10 @@ a.avatar[aria-disabled="true"] {
|
|
|
8121
8421
|
.list > :where(.list-item),
|
|
8122
8422
|
.list > li > .list-item {
|
|
8123
8423
|
display: flex;
|
|
8124
|
-
/*
|
|
8125
|
-
|
|
8126
|
-
|
|
8424
|
+
/* Centered fits the short rows lists mostly hold. Read through
|
|
8425
|
+
--items-align so .items-start on the row or the list wins without
|
|
8426
|
+
outranking this selector — the opt-out for long supporting text. */
|
|
8427
|
+
align-items: var(--items-align, center);
|
|
8127
8428
|
gap: var(--list-item-gap);
|
|
8128
8429
|
min-block-size: var(--list-item-min-size);
|
|
8129
8430
|
padding-block: var(--list-item-pad-block);
|
|
@@ -8145,21 +8446,20 @@ a.list-item:hover {
|
|
|
8145
8446
|
background: var(--surface-subtle);
|
|
8146
8447
|
}
|
|
8147
8448
|
|
|
8148
|
-
/* Current row in a navigational list
|
|
8149
|
-
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
…) stays free for assistive technology.
|
|
8449
|
+
/* Current row in a navigational list: aria-current="page" when the row opens
|
|
8450
|
+
a page, "true" when it is the current item of a master/detail view (a
|
|
8451
|
+
conversation, a thread, a record) that is no page of its own. Any other
|
|
8452
|
+
value, "false" included, is inert.
|
|
8153
8453
|
Calm by design: the row keeps its normal text metrics and color on a subtle
|
|
8154
8454
|
surface, and the selected accent lives on an inset inline-start trait only.
|
|
8155
8455
|
The trait is a real border, so it survives forced colors without a repaint
|
|
8156
8456
|
block. The positioning context lives on every row link, so becoming current
|
|
8157
8457
|
never moves an app-positioned decoration to a new containing block. */
|
|
8158
|
-
a.list-item[aria-current] {
|
|
8458
|
+
a.list-item:is([aria-current="page"], [aria-current="true"]) {
|
|
8159
8459
|
background: var(--surface-subtle);
|
|
8160
8460
|
}
|
|
8161
8461
|
|
|
8162
|
-
a.list-item[aria-current]::before {
|
|
8462
|
+
a.list-item:is([aria-current="page"], [aria-current="true"])::before {
|
|
8163
8463
|
content: "";
|
|
8164
8464
|
position: absolute;
|
|
8165
8465
|
inset-block: var(--list-item-pad-block);
|
|
@@ -8428,7 +8728,7 @@ a.list-item[aria-current]::before {
|
|
|
8428
8728
|
}
|
|
8429
8729
|
|
|
8430
8730
|
.join-addon {
|
|
8431
|
-
--control-border: var(--border);
|
|
8731
|
+
--control-border: var(--border-control);
|
|
8432
8732
|
--control-bg: var(--surface-subtle);
|
|
8433
8733
|
--control-fg: var(--text-muted);
|
|
8434
8734
|
|
|
@@ -8533,7 +8833,7 @@ a.list-item[aria-current]::before {
|
|
|
8533
8833
|
The second :has() covers a control nested one wrapper level deep. */
|
|
8534
8834
|
.join:has(> :is(.input, .textarea, .select)) > .btn.outline,
|
|
8535
8835
|
.join:has(> * > :is(.input, .textarea, .select)) > .btn.outline {
|
|
8536
|
-
--btn-border-color: var(--intent, var(--border));
|
|
8836
|
+
--btn-border-color: var(--intent, var(--border-control));
|
|
8537
8837
|
}
|
|
8538
8838
|
|
|
8539
8839
|
/* Effects — purely visual enrichments. */
|
|
@@ -8867,6 +9167,11 @@ a.list-item[aria-current]::before {
|
|
|
8867
9167
|
align-items: end;
|
|
8868
9168
|
}
|
|
8869
9169
|
|
|
9170
|
+
.items-baseline {
|
|
9171
|
+
--items-align: baseline;
|
|
9172
|
+
align-items: baseline;
|
|
9173
|
+
}
|
|
9174
|
+
|
|
8870
9175
|
.muted {
|
|
8871
9176
|
color: var(--text-muted);
|
|
8872
9177
|
}
|
|
@@ -8988,9 +9293,6 @@ a.list-item[aria-current]::before {
|
|
|
8988
9293
|
* - utilities consume the current context instead of defining their own -sm/-lg scale.
|
|
8989
9294
|
* - optional utilities extend core utilities, they do not re-express them.
|
|
8990
9295
|
* - follow verbose .{property}-{value} naming.
|
|
8991
|
-
*
|
|
8992
|
-
* Internal:
|
|
8993
|
-
* --cluster-justify (local relay consumed by .cluster)
|
|
8994
9296
|
*/
|
|
8995
9297
|
|
|
8996
9298
|
/* Display */
|
|
@@ -9065,23 +9367,22 @@ a.list-item[aria-current]::before {
|
|
|
9065
9367
|
flex-wrap: nowrap;
|
|
9066
9368
|
}
|
|
9067
9369
|
|
|
9370
|
+
/* The property is written directly, so the utility works on any flex row, not
|
|
9371
|
+
only a .cluster. Utilities load last, so the direct declaration beats
|
|
9372
|
+
.cluster at equal specificity. */
|
|
9068
9373
|
.justify-content-start {
|
|
9069
|
-
--cluster-justify: flex-start;
|
|
9070
9374
|
justify-content: flex-start;
|
|
9071
9375
|
}
|
|
9072
9376
|
|
|
9073
9377
|
.justify-content-center {
|
|
9074
|
-
--cluster-justify: center;
|
|
9075
9378
|
justify-content: center;
|
|
9076
9379
|
}
|
|
9077
9380
|
|
|
9078
9381
|
.justify-content-end {
|
|
9079
|
-
--cluster-justify: flex-end;
|
|
9080
9382
|
justify-content: flex-end;
|
|
9081
9383
|
}
|
|
9082
9384
|
|
|
9083
9385
|
.justify-content-space-between {
|
|
9084
|
-
--cluster-justify: space-between;
|
|
9085
9386
|
justify-content: space-between;
|
|
9086
9387
|
}
|
|
9087
9388
|
|