actual-css 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/README.md +7 -8
- package/components.json +124 -12
- package/dist/actual.full.css +1152 -309
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/design-notes/architecture.md +67 -0
- package/docs/design-notes/steps.md +45 -28
- package/docs/navigation.json +2 -0
- package/docs/pages/components/alert.md +12 -2
- package/docs/pages/components/avatar.md +17 -11
- package/docs/pages/components/breadcrumb.md +16 -2
- package/docs/pages/components/button.md +3 -2
- package/docs/pages/components/list.md +15 -16
- package/docs/pages/components/navbar.md +42 -22
- package/docs/pages/components/pagination.md +19 -0
- package/docs/pages/components/segmented.md +83 -0
- package/docs/pages/components/steps.md +28 -22
- package/docs/pages/enhancements/flyout.md +5 -0
- package/docs/pages/enhancements/scrollspy.md +6 -6
- package/docs/pages/enhancements/tabs.md +79 -3
- package/docs/pages/examples/overview.md +3 -1
- package/docs/pages/forms/color-swatches.md +131 -0
- package/docs/pages/forms/combobox.md +2 -2
- package/docs/pages/forms/controls.md +6 -3
- package/docs/pages/forms/floating-field.md +21 -7
- package/docs/pages/forms/form-actions.md +3 -6
- package/docs/pages/forms/joined-controls.md +6 -2
- package/docs/pages/forms/overview.md +1 -0
- package/docs/pages/forms/states-and-sizes.md +15 -15
- package/docs/pages/forms/switch.md +4 -2
- package/docs/pages/foundations/philosophy.md +97 -1
- package/docs/pages/foundations/theming.md +1 -1
- package/docs/pages/foundations/tokens.md +94 -27
- package/docs/pages/guides/design-tools.md +14 -8
- package/docs/pages/guides/integrating-actual-css.md +74 -8
- package/docs/pages/guides/introduction.md +3 -2
- package/docs/pages/guides/modular-import.md +1 -1
- package/docs/pages/guides/progressive-enhancement.md +25 -7
- package/docs/pages/guides/upgrading.md +86 -9
- package/docs/pages/layout/center.md +2 -1
- package/docs/pages/layout/cluster.md +11 -4
- package/docs/pages/layout/grid.md +2 -0
- package/docs/pages/layout/scroll-snap.md +4 -2
- package/docs/pages/layout/sidebar-layout.md +3 -0
- package/docs/pages/layout/switcher.md +2 -0
- package/docs/pages/layout/topbar.md +6 -4
- package/docs/pages/patterns/icon-slot.md +17 -1
- package/docs/pages/utilities/flex.md +1 -1
- package/docs/pages/utilities/sizing-wrapping.md +51 -6
- package/package.json +6 -6
- package/reserved-classes.json +3 -0
- package/src/cli/actual-css.js +4 -4
- package/src/css/components/alert.css +99 -48
- package/src/css/components/app-nav.css +17 -9
- package/src/css/components/avatar.css +2 -18
- package/src/css/components/badge.css +23 -10
- package/src/css/components/breadcrumb.css +74 -8
- package/src/css/components/button.css +17 -2
- package/src/css/components/close.css +6 -3
- package/src/css/components/drawer.css +23 -42
- package/src/css/components/flyout.css +0 -1
- package/src/css/components/index.css +1 -0
- package/src/css/components/join.css +2 -2
- package/src/css/components/list.css +28 -13
- package/src/css/components/modal.css +17 -10
- package/src/css/components/navbar.css +15 -10
- package/src/css/components/pagination.css +5 -0
- package/src/css/components/scrollspy.css +1 -1
- package/src/css/components/segmented.css +186 -0
- package/src/css/components/steps.css +72 -15
- package/src/css/components/tab.css +135 -5
- package/src/css/core/focus.css +11 -6
- package/src/css/core/theme.css +6 -1
- package/src/css/core/tokens.css +24 -6
- package/src/css/core/variants.css +16 -5
- package/src/css/forms/choice-card.css +1 -1
- package/src/css/forms/choice.css +1 -1
- package/src/css/forms/color-swatch.css +159 -0
- 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 -8
- package/src/css/forms/index.css +1 -0
- package/src/css/forms/native.css +51 -21
- package/src/css/forms/otp.css +4 -5
- package/src/css/forms/switch.css +6 -3
- package/src/css/forms/validation.css +23 -30
- package/src/css/layout/center.css +2 -0
- package/src/css/layout/cluster.css +14 -0
- package/src/css/layout/grid.css +7 -0
- package/src/css/layout/scroll-snap.css +30 -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 +10 -0
- package/src/css/utilities/extra.css +3 -3
- package/src/js/focus.js +16 -1
- 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/surface.js +10 -0
- package/src/js/tab.js +33 -7
- package/src/tooling/color.js +14 -1
- package/src/tooling/css-bundle.js +0 -5
- package/src/tooling/design-components.js +9 -5
- package/src/tooling/design-export.js +9 -1
- package/src/tooling/design-tokens.js +207 -76
- package/src/tooling/theme-contrast.js +182 -45
package/dist/actual.full.css
CHANGED
|
@@ -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
|
|
|
@@ -213,9 +215,20 @@ select {
|
|
|
213
215
|
[data-theme] {
|
|
214
216
|
/* State tokens — semantic colors for selected/pressed/current and disabled.
|
|
215
217
|
Components consume these instead of hard-wiring --primary/--text-subtle
|
|
216
|
-
so forced-colors can override them in one place.
|
|
218
|
+
so forced-colors can override them in one place. --state-selected is a
|
|
219
|
+
selected fill or accent, --state-selected-fg the ink on that fill.
|
|
220
|
+
--state-selected-text is selected ink on a regular surface (current nav
|
|
221
|
+
link, selected tab, current step number). It is reset to the
|
|
222
|
+
guaranteed-invalid value on every theme boundary, so a consumer reads its
|
|
223
|
+
local --state-selected through the fallback var(--state-selected-text,
|
|
224
|
+
var(--state-selected)): a theme that resolves it to var(--link) bakes that
|
|
225
|
+
reference where it is declared, so a nested island that does not set it
|
|
226
|
+
inherits an already-resolved value the local fallback can never reach. A
|
|
227
|
+
theme sets it when one color cannot be both a fill under its -fg and text
|
|
228
|
+
on the page, typically in a dark scheme. */
|
|
217
229
|
--state-selected: var(--primary);
|
|
218
230
|
--state-selected-fg: var(--primary-fg);
|
|
231
|
+
--state-selected-text: initial;
|
|
219
232
|
--state-disabled: var(--text-subtle);
|
|
220
233
|
|
|
221
234
|
/* Status dot ring — cuts the dot out of what it overlaps. */
|
|
@@ -224,10 +237,15 @@ select {
|
|
|
224
237
|
|
|
225
238
|
:root {
|
|
226
239
|
/* Variant tuning — themes may adjust generated soft surfaces without
|
|
227
|
-
replacing the component rules.
|
|
240
|
+
replacing the component rules. --soft-bg-mix is the share of surface in a
|
|
241
|
+
soft fill: an opaque mix on the shared .soft, an opacity (100% minus it)
|
|
242
|
+
on the translucent badge, alert and soft-button tint. --soft-hover-alpha
|
|
243
|
+
is the extra intent share on hover. --soft-border-mix has no default: it
|
|
244
|
+
is a fallback-only hook whose absence lets each recipe keep its own rim
|
|
245
|
+
(a tinted rim on .soft, none on the tint), and a theme that sets it gives
|
|
246
|
+
both a rim of that weight. */
|
|
228
247
|
--soft-bg-mix: 88%;
|
|
229
|
-
--soft-
|
|
230
|
-
--soft-hover-alpha: 12%;
|
|
248
|
+
--soft-hover-alpha: 6%;
|
|
231
249
|
/* Soft foreground: the share of raw intent in soft text. 100% keeps the
|
|
232
250
|
pure intent color; lower values rebate it toward --text, which vivid or
|
|
233
251
|
light intent palettes need to stay legible on their own soft surface.
|
|
@@ -315,7 +333,9 @@ select {
|
|
|
315
333
|
* Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
|
|
316
334
|
* other tokens; hard-coded overrides on a custom theme will break the intent.
|
|
317
335
|
* Exception: --surface-opaque and --surface-solid-fg are public; a theme with
|
|
318
|
-
* translucent surfaces overrides them with opaque values.
|
|
336
|
+
* translucent surfaces overrides them with opaque values. --border-control is
|
|
337
|
+
* public too: the resting edge of a neutral control, apart from structural
|
|
338
|
+
* separators.
|
|
319
339
|
* Internal: --hgroup-gap.
|
|
320
340
|
*/
|
|
321
341
|
:root {
|
|
@@ -417,6 +437,9 @@ select {
|
|
|
417
437
|
never a fill. --surface-solid-fg is the ink on --surface-solid. */
|
|
418
438
|
--surface-opaque: var(--surface);
|
|
419
439
|
--surface-solid-fg: var(--surface);
|
|
440
|
+
/* The edge that tells a control apart from the surface, where --border only
|
|
441
|
+
separates surfaces: fields, choices, switch, OTP cells, choice cards. */
|
|
442
|
+
--border-control: var(--border);
|
|
420
443
|
}
|
|
421
444
|
|
|
422
445
|
/* Soft foreground hooks — the soft ink is the intent rebated toward the local
|
|
@@ -711,6 +734,9 @@ small {
|
|
|
711
734
|
*
|
|
712
735
|
* Intent list source of truth: intents.css. Components consume --intent* tokens
|
|
713
736
|
* instead of re-listing intent classes.
|
|
737
|
+
*
|
|
738
|
+
* Public hooks:
|
|
739
|
+
* --soft-border-mix (fallback-only: a soft rim of that weight on every recipe)
|
|
714
740
|
*/
|
|
715
741
|
.solid {
|
|
716
742
|
--ui-bg: var(--intent, var(--neutral));
|
|
@@ -774,7 +800,7 @@ small {
|
|
|
774
800
|
|
|
775
801
|
/* Shared control scale. Wrappers are listed when their descendants form one
|
|
776
802
|
component (field, choice-card, floating-field, input-icon, join, pagination,
|
|
777
|
-
tabs, flyout). A local size declaration beats inherited density because it
|
|
803
|
+
tabs, segmented, flyout). A local size declaration beats inherited density because it
|
|
778
804
|
is declared on the sized component itself. Guarded by check:sync
|
|
779
805
|
(size-scale-participants): .sm and .lg must list the same components. */
|
|
780
806
|
:where(
|
|
@@ -787,6 +813,7 @@ small {
|
|
|
787
813
|
.field,
|
|
788
814
|
.choice,
|
|
789
815
|
.choice-card,
|
|
816
|
+
.color-swatch,
|
|
790
817
|
.input-icon,
|
|
791
818
|
.floating-field,
|
|
792
819
|
.otp,
|
|
@@ -794,6 +821,7 @@ small {
|
|
|
794
821
|
.pagination,
|
|
795
822
|
.tabs,
|
|
796
823
|
.tab,
|
|
824
|
+
.segmented,
|
|
797
825
|
.flyout
|
|
798
826
|
).sm {
|
|
799
827
|
--control-size: var(--control-size-sm);
|
|
@@ -814,6 +842,7 @@ small {
|
|
|
814
842
|
.field,
|
|
815
843
|
.choice,
|
|
816
844
|
.choice-card,
|
|
845
|
+
.color-swatch,
|
|
817
846
|
.input-icon,
|
|
818
847
|
.floating-field,
|
|
819
848
|
.otp,
|
|
@@ -821,6 +850,7 @@ small {
|
|
|
821
850
|
.pagination,
|
|
822
851
|
.tabs,
|
|
823
852
|
.tab,
|
|
853
|
+
.segmented,
|
|
824
854
|
.flyout
|
|
825
855
|
).lg {
|
|
826
856
|
--control-size: var(--control-size-lg);
|
|
@@ -832,21 +862,25 @@ small {
|
|
|
832
862
|
}
|
|
833
863
|
|
|
834
864
|
/* Modern color enhancement. Baseline variant values above remain usable
|
|
835
|
-
everywhere. Interactive states live in the components that consume them.
|
|
865
|
+
everywhere. Interactive states live in the components that consume them.
|
|
866
|
+
The shared .soft is opaque: it paints zones (.card, .navbar, .app-nav,
|
|
867
|
+
.chat-bubble) an author may lay over an image, where a translucent fill
|
|
868
|
+
would hand the text's contrast to whatever is behind it. The badge, alert
|
|
869
|
+
and soft button own a translucent tint instead (soft-tint). */
|
|
836
870
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
837
871
|
.soft {
|
|
838
|
-
/* @sync soft-recipe */
|
|
839
872
|
--ui-bg: color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
|
|
840
873
|
--ui-border: color-mix(
|
|
841
874
|
in oklab,
|
|
842
|
-
var(--surface) var(--soft-border-mix),
|
|
875
|
+
var(--surface) var(--soft-border-mix, 65%),
|
|
843
876
|
var(--intent, var(--neutral))
|
|
844
877
|
);
|
|
878
|
+
/* @sync soft-ink */
|
|
845
879
|
--ui-fg: var(
|
|
846
880
|
--intent-soft-fg,
|
|
847
881
|
color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
|
|
848
882
|
);
|
|
849
|
-
/* @sync soft-
|
|
883
|
+
/* @sync soft-ink */
|
|
850
884
|
}
|
|
851
885
|
}
|
|
852
886
|
|
|
@@ -873,19 +907,24 @@ small {
|
|
|
873
907
|
--surface-solid, so one token reads on the page, on a solid band and on
|
|
874
908
|
any nested surface.
|
|
875
909
|
|
|
910
|
+
A .segment is an action but takes the inset ring: its track's padding is
|
|
911
|
+
narrower than an offset ring, which would cover the neighbouring segment.
|
|
912
|
+
|
|
876
913
|
Documented exception: an invalid field focuses in --form-invalid-border
|
|
877
914
|
(danger), so the error stays visible at the point of correction. The border
|
|
878
915
|
carries the same hook; report:theme-contrast measures that line at 3:1
|
|
879
|
-
against the field's background alongside --focus.
|
|
916
|
+
against the field's background alongside --focus.
|
|
917
|
+
|
|
918
|
+
Public hooks:
|
|
919
|
+
--focus-outline (fallback-only: replaces the whole generic line)
|
|
920
|
+
*/
|
|
880
921
|
:focus {
|
|
881
|
-
outline: var(--focus-outline);
|
|
922
|
+
outline: var(--focus-outline, var(--focus-ring-width) solid var(--focus-outline-color));
|
|
882
923
|
outline-offset: var(--focus-outline-offset);
|
|
883
924
|
}
|
|
884
925
|
|
|
885
|
-
|
|
886
|
-
:
|
|
887
|
-
outline: none;
|
|
888
|
-
}
|
|
926
|
+
:focus:not(:focus-visible) {
|
|
927
|
+
outline: none;
|
|
889
928
|
}
|
|
890
929
|
|
|
891
930
|
@media print {
|
|
@@ -1349,7 +1388,21 @@ small {
|
|
|
1349
1388
|
*
|
|
1350
1389
|
* Child contract: direct-child margins are reset, spacing comes from --gap.
|
|
1351
1390
|
* A ul/ol/menu keeps its semantics but loses list chrome (see below).
|
|
1391
|
+
*
|
|
1392
|
+
* The hooks describe this row's own geometry, so they stop at each cluster:
|
|
1393
|
+
* set them on the cluster itself, or target nested rows with a selector. The
|
|
1394
|
+
* gap token is context rhythm and keeps inheriting.
|
|
1352
1395
|
*/
|
|
1396
|
+
|
|
1397
|
+
/* Instance boundary: an ancestor's value never reaches a nested cluster.
|
|
1398
|
+
Zero specificity, so any class or inline style on the cluster wins without
|
|
1399
|
+
depending on import order; `initial` makes the var() fallbacks apply. */
|
|
1400
|
+
:where(.cluster) {
|
|
1401
|
+
--cluster-align: initial;
|
|
1402
|
+
--cluster-justify: initial;
|
|
1403
|
+
--cluster-wrap: initial;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1353
1406
|
.cluster {
|
|
1354
1407
|
display: flex;
|
|
1355
1408
|
flex-direction: row;
|
|
@@ -1382,6 +1435,8 @@ small {
|
|
|
1382
1435
|
* --center-size
|
|
1383
1436
|
* --center-pad
|
|
1384
1437
|
*/
|
|
1438
|
+
/* The hooks inherit on purpose: a site's content width is page policy, so a
|
|
1439
|
+
theme sets them once on :root for every .center. */
|
|
1385
1440
|
.center {
|
|
1386
1441
|
box-sizing: content-box;
|
|
1387
1442
|
inline-size: calc(100% - var(--center-pad, 1rem) * 2);
|
|
@@ -1554,6 +1609,9 @@ small {
|
|
|
1554
1609
|
*
|
|
1555
1610
|
* Child contract: every direct child is a peer; the flex-basis applies to all.
|
|
1556
1611
|
*/
|
|
1612
|
+
/* No instance reset, unlike .cluster and .grid: the children read the hook,
|
|
1613
|
+
and a switcher nested as a direct child takes its own flex-basis from its
|
|
1614
|
+
parent's value, so the hook must reach it. */
|
|
1557
1615
|
.switcher {
|
|
1558
1616
|
display: flex;
|
|
1559
1617
|
flex-direction: row;
|
|
@@ -1586,6 +1644,9 @@ small {
|
|
|
1586
1644
|
* visual, reading and keyboard order all agree — the role rules swap so the
|
|
1587
1645
|
* aside still takes its preferred width and the main still reclaims the row.
|
|
1588
1646
|
*/
|
|
1647
|
+
/* No instance reset, unlike .cluster and .grid: the regions read the hooks,
|
|
1648
|
+
and a layout nested as a region takes its own size from its parent's
|
|
1649
|
+
values, so the hooks must reach it. */
|
|
1589
1650
|
.sidebar-layout {
|
|
1590
1651
|
display: flex;
|
|
1591
1652
|
flex-direction: row;
|
|
@@ -1647,6 +1708,13 @@ small {
|
|
|
1647
1708
|
* the thresholds were arrived at: docs/design-notes/grid.md.
|
|
1648
1709
|
*/
|
|
1649
1710
|
|
|
1711
|
+
/* Instance boundary: a nested grid never takes its parent's track template.
|
|
1712
|
+
Zero specificity, so a class or inline style on the grid itself wins. */
|
|
1713
|
+
:where(.grid) {
|
|
1714
|
+
--grid-columns: initial;
|
|
1715
|
+
--grid-min: initial;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1650
1718
|
/* --grid-columns replaces the intrinsic auto-fit template: once set, the grid
|
|
1651
1719
|
no longer collapses on its own and the author owns narrow-container
|
|
1652
1720
|
collapse. */
|
|
@@ -1832,7 +1900,16 @@ small {
|
|
|
1832
1900
|
* draw their native scrollbar — the intended fallback, not a broken state.
|
|
1833
1901
|
*
|
|
1834
1902
|
* Styles the scrollbar only; it does not create the overflow. Pair it with
|
|
1835
|
-
* .overflow-auto or a component that already scrolls
|
|
1903
|
+
* .overflow-auto or a component that already scrolls (horizontal steps, a
|
|
1904
|
+
* table wrap; tabs hide their bar and fade their own edges). No edge fade
|
|
1905
|
+
* here: a mask would dictate the strip's scroll-padding and focus geometry for
|
|
1906
|
+
* a decorative cue, and an item clipped at the edge already says the strip
|
|
1907
|
+
* continues.
|
|
1908
|
+
*
|
|
1909
|
+
* Not on touch: Chromium trades the overlay scrollbar of a touch screen,
|
|
1910
|
+
* hidden at rest, for a classic always-visible one as soon as scrollbar-color
|
|
1911
|
+
* or scrollbar-width is set. On a phone that painted a permanent bar under
|
|
1912
|
+
* every strip; the native overlay is the better cue there.
|
|
1836
1913
|
*
|
|
1837
1914
|
* Public hooks:
|
|
1838
1915
|
* --scroller-track
|
|
@@ -1841,9 +1918,16 @@ small {
|
|
|
1841
1918
|
.scroller {
|
|
1842
1919
|
--scroller-track: transparent;
|
|
1843
1920
|
--scroller-thumb: var(--border);
|
|
1921
|
+
}
|
|
1844
1922
|
|
|
1845
|
-
|
|
1846
|
-
|
|
1923
|
+
/* Any primary pointer but touch: a keyboard-only or pointerless device
|
|
1924
|
+
(pointer: none) draws classic scrollbars too. `all and` keeps the negation
|
|
1925
|
+
valid in Media Queries 3 parsers. */
|
|
1926
|
+
@media not all and (pointer: coarse) {
|
|
1927
|
+
.scroller {
|
|
1928
|
+
scrollbar-color: var(--scroller-thumb) var(--scroller-track);
|
|
1929
|
+
scrollbar-width: thin;
|
|
1930
|
+
}
|
|
1847
1931
|
}
|
|
1848
1932
|
|
|
1849
1933
|
/* Opt-in gutter: reserves space so the layout does not shift when
|
|
@@ -1873,16 +1957,36 @@ small {
|
|
|
1873
1957
|
* --scroll-snap-align
|
|
1874
1958
|
* --scroll-snap-item-size
|
|
1875
1959
|
* --scroll-snap-padding
|
|
1960
|
+
*
|
|
1961
|
+
* Internal:
|
|
1962
|
+
* --scroll-snap-focus-bleed
|
|
1876
1963
|
*/
|
|
1877
1964
|
.scroll-snap {
|
|
1878
1965
|
--scroll-snap-gap: var(--gap);
|
|
1879
1966
|
--scroll-snap-align: start;
|
|
1880
1967
|
--scroll-snap-item-size: auto;
|
|
1968
|
+
/* Declared here, not as a :root alias: an inherited alias arrives already
|
|
1969
|
+
computed, so a --focus-ring-width override on the rail would not reach it. */
|
|
1970
|
+
--scroll-snap-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
1881
1971
|
|
|
1882
1972
|
display: flex;
|
|
1883
1973
|
flex-direction: row;
|
|
1884
1974
|
gap: var(--scroll-snap-gap);
|
|
1975
|
+
/* Both axes clip: `auto` on one computes the other to `auto`, so a ring on
|
|
1976
|
+
the top or bottom item would be lost too. Every side reserves the ring's
|
|
1977
|
+
own width; the block reserve returns to the flow through a negative
|
|
1978
|
+
margin, while the inline reserve stays as the rail's focus gutter.
|
|
1979
|
+
Bleeding the inline reserve outward instead would make the border box
|
|
1980
|
+
`containing block + 2 * reserve` wide, which at the viewport edge grows
|
|
1981
|
+
the page: a rail cannot be flush with arbitrary surrounding content and
|
|
1982
|
+
free of page overflow at once. */
|
|
1983
|
+
margin-block: calc(var(--scroll-snap-focus-bleed) * -1);
|
|
1984
|
+
padding-block: var(--scroll-snap-focus-bleed);
|
|
1985
|
+
padding-inline: var(--scroll-snap-focus-bleed);
|
|
1885
1986
|
overflow-x: auto;
|
|
1987
|
+
/* One scroll axis, stated: a fractional content height could otherwise raise
|
|
1988
|
+
a phantom vertical scrollbar under one font or zoom step. */
|
|
1989
|
+
overflow-y: hidden;
|
|
1886
1990
|
}
|
|
1887
1991
|
|
|
1888
1992
|
.scroll-snap > * {
|
|
@@ -1892,7 +1996,17 @@ small {
|
|
|
1892
1996
|
@supports (scroll-snap-type: x mandatory) {
|
|
1893
1997
|
.scroll-snap {
|
|
1894
1998
|
overscroll-behavior-inline: contain;
|
|
1999
|
+
/* Fallback only, never the ring's protector: padding-inline above already
|
|
2000
|
+
guarantees paint. A unitless `0` drops the clamp below but keeps this
|
|
2001
|
+
declaration, so the worst case is a snapped item flush with the gutter,
|
|
2002
|
+
not a clipped ring. */
|
|
1895
2003
|
scroll-padding-inline: var(--scroll-snap-padding, var(--scroll-snap-gap));
|
|
2004
|
+
/* A conforming <length> stays at least the focus reserve, so scroll and
|
|
2005
|
+
snap stop with the gutter's air. */
|
|
2006
|
+
scroll-padding-inline: max(
|
|
2007
|
+
var(--scroll-snap-padding, var(--scroll-snap-gap)),
|
|
2008
|
+
var(--scroll-snap-focus-bleed)
|
|
2009
|
+
);
|
|
1896
2010
|
/* x then inline: an engine that lacks the logical keyword keeps x.
|
|
1897
2011
|
proximity is the default, so a short rail cannot trap the pointer. */
|
|
1898
2012
|
scroll-snap-type: x proximity;
|
|
@@ -1989,6 +2103,16 @@ small {
|
|
|
1989
2103
|
}
|
|
1990
2104
|
}
|
|
1991
2105
|
|
|
2106
|
+
/* A reduce override, not a no-preference gate: a browser without the feature
|
|
2107
|
+
evaluates it false and keeps the frosted bar instead of losing it. */
|
|
2108
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
2109
|
+
.topbar {
|
|
2110
|
+
background: var(--surface);
|
|
2111
|
+
backdrop-filter: none;
|
|
2112
|
+
-webkit-backdrop-filter: none;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
1992
2116
|
/* Readable content measure. Composes with .center for centering or with
|
|
1993
2117
|
margin-inline: auto for a one-off centered block.
|
|
1994
2118
|
*
|
|
@@ -2107,7 +2231,7 @@ small {
|
|
|
2107
2231
|
.input,
|
|
2108
2232
|
.textarea,
|
|
2109
2233
|
.select {
|
|
2110
|
-
--control-border: var(--border);
|
|
2234
|
+
--control-border: var(--border-control);
|
|
2111
2235
|
--control-bg: var(--surface);
|
|
2112
2236
|
--control-fg: var(--text);
|
|
2113
2237
|
--control-cursor: auto;
|
|
@@ -2333,7 +2457,7 @@ small {
|
|
|
2333
2457
|
/* Control geometry. --choice-* are the per-state tokens. */
|
|
2334
2458
|
.check,
|
|
2335
2459
|
.radio {
|
|
2336
|
-
--choice-border: var(--border);
|
|
2460
|
+
--choice-border: var(--border-control);
|
|
2337
2461
|
--choice-bg: var(--surface);
|
|
2338
2462
|
--choice-mark: var(--primary-fg);
|
|
2339
2463
|
|
|
@@ -2503,6 +2627,9 @@ small {
|
|
|
2503
2627
|
*
|
|
2504
2628
|
* Range has its own custom-styled file (range.css) so the focus ring can
|
|
2505
2629
|
* target the thumb pseudo-element rather than the rectangular CSS box.
|
|
2630
|
+
*
|
|
2631
|
+
* Public hooks:
|
|
2632
|
+
* --color-size
|
|
2506
2633
|
*/
|
|
2507
2634
|
.file {
|
|
2508
2635
|
color: var(--text-muted);
|
|
@@ -2521,7 +2648,7 @@ small {
|
|
|
2521
2648
|
calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
|
|
2522
2649
|
);
|
|
2523
2650
|
padding-inline: var(--space-20);
|
|
2524
|
-
border: var(--border-width) solid var(--border);
|
|
2651
|
+
border: var(--border-width) solid var(--border-control);
|
|
2525
2652
|
border-radius: var(--radius-sm);
|
|
2526
2653
|
background: var(--surface-subtle);
|
|
2527
2654
|
color: var(--text);
|
|
@@ -2548,16 +2675,24 @@ small {
|
|
|
2548
2675
|
outline-offset: var(--focus-outline-offset);
|
|
2549
2676
|
}
|
|
2550
2677
|
|
|
2551
|
-
/* Native color picker — keep the browser's dialog and selected-color UI.
|
|
2552
|
-
|
|
2553
|
-
|
|
2678
|
+
/* Native color picker — keep the browser's dialog and selected-color UI. Same
|
|
2679
|
+
geometry as .color-swatch: a --control-size hit area around a round swatch
|
|
2680
|
+
the height of a .switch track, so a picker, a palette and a switch compose.
|
|
2681
|
+
The input is the hit area; its padding leaves the swatch, which carries the
|
|
2682
|
+
edge and the focus ring. Vendor pseudos cannot share a rule. */
|
|
2554
2683
|
.color {
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2684
|
+
--color-size: calc(var(--choice-control-size) + 0.125em);
|
|
2685
|
+
|
|
2686
|
+
inline-size: max(var(--control-size), var(--color-size));
|
|
2687
|
+
block-size: max(var(--control-size), var(--color-size));
|
|
2688
|
+
padding: calc((max(var(--control-size), var(--color-size)) - var(--color-size)) / 2);
|
|
2689
|
+
/* Lay out at the visible width, as .color-swatch does: the hit area
|
|
2690
|
+
overflows on both sides so a label gap is measured from the swatch. */
|
|
2691
|
+
margin: 0 calc((var(--color-size) - max(var(--control-size), var(--color-size))) / 2);
|
|
2692
|
+
border: 0;
|
|
2693
|
+
background: transparent;
|
|
2694
|
+
/* Consumed so .sm/.lg reach the em-sized swatch. */
|
|
2695
|
+
font-size: var(--control-font-size);
|
|
2561
2696
|
cursor: pointer;
|
|
2562
2697
|
}
|
|
2563
2698
|
|
|
@@ -2566,13 +2701,21 @@ small {
|
|
|
2566
2701
|
}
|
|
2567
2702
|
|
|
2568
2703
|
.color::-webkit-color-swatch {
|
|
2569
|
-
border:
|
|
2570
|
-
border-radius:
|
|
2704
|
+
border: var(--border-width) solid var(--border-control);
|
|
2705
|
+
border-radius: var(--radius-full);
|
|
2571
2706
|
}
|
|
2572
2707
|
|
|
2573
2708
|
.color::-moz-color-swatch {
|
|
2574
|
-
border:
|
|
2575
|
-
border-radius:
|
|
2709
|
+
border: var(--border-width) solid var(--border-control);
|
|
2710
|
+
border-radius: var(--radius-full);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
.color:hover::-webkit-color-swatch {
|
|
2714
|
+
border-color: var(--text-subtle);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
.color:hover::-moz-color-swatch {
|
|
2718
|
+
border-color: var(--text-subtle);
|
|
2576
2719
|
}
|
|
2577
2720
|
|
|
2578
2721
|
.color:disabled {
|
|
@@ -2580,25 +2723,36 @@ small {
|
|
|
2580
2723
|
opacity: var(--disabled-opacity);
|
|
2581
2724
|
}
|
|
2582
2725
|
|
|
2726
|
+
/* The ring goes around the visible swatch, not the larger hit area. */
|
|
2583
2727
|
.color:focus-visible {
|
|
2728
|
+
outline: none;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
.color:focus-visible::-webkit-color-swatch {
|
|
2584
2732
|
outline: var(--focus-ring-width) solid var(--focus);
|
|
2585
2733
|
outline-offset: var(--focus-outline-offset);
|
|
2586
2734
|
}
|
|
2587
2735
|
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
}
|
|
2736
|
+
.color:focus-visible::-moz-color-swatch {
|
|
2737
|
+
outline: var(--focus-ring-width) solid var(--focus);
|
|
2738
|
+
outline-offset: var(--focus-outline-offset);
|
|
2739
|
+
}
|
|
2593
2740
|
|
|
2594
|
-
|
|
2595
|
-
|
|
2741
|
+
@media (forced-colors: active) {
|
|
2742
|
+
/* The swatch color is content, so the swatch opts out of forcing and takes
|
|
2743
|
+
system colors for its edge and ring itself. */
|
|
2596
2744
|
.color::-webkit-color-swatch {
|
|
2745
|
+
--focus: CanvasText;
|
|
2746
|
+
|
|
2597
2747
|
forced-color-adjust: none;
|
|
2748
|
+
border-color: CanvasText;
|
|
2598
2749
|
}
|
|
2599
2750
|
|
|
2600
2751
|
.color::-moz-color-swatch {
|
|
2752
|
+
--focus: CanvasText;
|
|
2753
|
+
|
|
2601
2754
|
forced-color-adjust: none;
|
|
2755
|
+
border-color: CanvasText;
|
|
2602
2756
|
}
|
|
2603
2757
|
}
|
|
2604
2758
|
|
|
@@ -2609,6 +2763,8 @@ small {
|
|
|
2609
2763
|
*
|
|
2610
2764
|
* Imported by default through forms/index.css. Custom builds may omit this
|
|
2611
2765
|
* file when an app wants to own validation styling completely.
|
|
2766
|
+
* Without :has(), automatic error display and choice-card borders drop;
|
|
2767
|
+
* native control states and the explicit .field.danger route stay available.
|
|
2612
2768
|
*
|
|
2613
2769
|
* State coverage (which states each component ships) is audited in
|
|
2614
2770
|
* tests/validation.test.js and summarized in docs/pages/forms/validation.md —
|
|
@@ -2630,11 +2786,14 @@ small {
|
|
|
2630
2786
|
display: block;
|
|
2631
2787
|
}
|
|
2632
2788
|
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2789
|
+
.field:has([aria-invalid="true"]) > .field-error,
|
|
2790
|
+
.needs-validation.was-validated .field:has(:invalid) > .field-error {
|
|
2791
|
+
display: block;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
/* biome-ignore lint/style/noDescendingSpecificity: every invalid route reveals the error; keep :user-invalid separate so unsupported engines retain the other routes. */
|
|
2795
|
+
.needs-validation .field:has(:user-invalid) > .field-error {
|
|
2796
|
+
display: block;
|
|
2638
2797
|
}
|
|
2639
2798
|
|
|
2640
2799
|
:where(.input, .textarea, .select, .check, .radio, .switch)[aria-invalid="true"],
|
|
@@ -2658,41 +2817,29 @@ small {
|
|
|
2658
2817
|
--switch-bg: var(--danger);
|
|
2659
2818
|
}
|
|
2660
2819
|
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
}
|
|
2820
|
+
.needs-validation .check:is(:checked, :indeterminate):user-invalid,
|
|
2821
|
+
.needs-validation .radio:checked:user-invalid {
|
|
2822
|
+
--choice-bg: var(--danger);
|
|
2823
|
+
}
|
|
2666
2824
|
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
}
|
|
2825
|
+
.needs-validation .switch:checked:user-invalid {
|
|
2826
|
+
--switch-bg: var(--danger);
|
|
2670
2827
|
}
|
|
2671
2828
|
|
|
2672
2829
|
/* Choice cards hide their native input, so invalid state must surface on the
|
|
2673
2830
|
card itself. Sets the same --form-invalid-border hook the card's border
|
|
2674
2831
|
consumes. */
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
--form-invalid-border: var(--danger);
|
|
2679
|
-
}
|
|
2832
|
+
.choice-card:has([aria-invalid="true"]),
|
|
2833
|
+
.needs-validation.was-validated .choice-card:has(:invalid) {
|
|
2834
|
+
--form-invalid-border: var(--danger);
|
|
2680
2835
|
}
|
|
2681
2836
|
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
--form-invalid-border: var(--danger);
|
|
2685
|
-
}
|
|
2837
|
+
.needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
|
|
2838
|
+
--form-invalid-border: var(--danger);
|
|
2686
2839
|
}
|
|
2687
2840
|
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
display: block;
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
.needs-validation .choice-card:has(:user-invalid) {
|
|
2694
|
-
--form-invalid-border: var(--danger);
|
|
2695
|
-
}
|
|
2841
|
+
.needs-validation .choice-card:has(:user-invalid) {
|
|
2842
|
+
--form-invalid-border: var(--danger);
|
|
2696
2843
|
}
|
|
2697
2844
|
|
|
2698
2845
|
/*
|
|
@@ -2718,15 +2865,12 @@ small {
|
|
|
2718
2865
|
* --form-actions-sticky-padding (owned by .form-actions.sticky)
|
|
2719
2866
|
*/
|
|
2720
2867
|
|
|
2721
|
-
/* The policy
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
forms load after layout in the official bundle (tests/css-audit.test.js
|
|
2725
|
-
guards the order); utilities load later and keep the author's last word. On
|
|
2726
|
-
this row --cluster-justify/--cluster-align have no effect. */
|
|
2868
|
+
/* The role relays its policy into the row's cluster hooks. Cluster hooks stop
|
|
2869
|
+
at every nested .cluster, so the relay cannot leak, and this class beats the
|
|
2870
|
+
cluster's zero-specificity reset in any import order. */
|
|
2727
2871
|
.form-actions {
|
|
2728
|
-
align
|
|
2729
|
-
justify
|
|
2872
|
+
--cluster-align: var(--form-actions-align, center);
|
|
2873
|
+
--cluster-justify: var(--form-actions-justify, flex-start);
|
|
2730
2874
|
}
|
|
2731
2875
|
|
|
2732
2876
|
/* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
|
|
@@ -2758,6 +2902,15 @@ small {
|
|
|
2758
2902
|
}
|
|
2759
2903
|
}
|
|
2760
2904
|
|
|
2905
|
+
/* Same reduce override as .topbar (src/css/layout/topbar.css). */
|
|
2906
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
2907
|
+
.form-actions.sticky {
|
|
2908
|
+
background: var(--surface);
|
|
2909
|
+
-webkit-backdrop-filter: none;
|
|
2910
|
+
backdrop-filter: none;
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2761
2914
|
/*
|
|
2762
2915
|
* Input Icon — decorative icon inside a relative wrapper, leading or trailing.
|
|
2763
2916
|
*
|
|
@@ -2837,6 +2990,7 @@ small {
|
|
|
2837
2990
|
* --switch-block-size
|
|
2838
2991
|
* --switch-knob-margin
|
|
2839
2992
|
* --switch-knob-size
|
|
2993
|
+
* --switch-knob-inline-size
|
|
2840
2994
|
* --switch-travel
|
|
2841
2995
|
*
|
|
2842
2996
|
* The track is sized from --choice-control-size (tokens.css), not
|
|
@@ -2852,7 +3006,7 @@ small {
|
|
|
2852
3006
|
|
|
2853
3007
|
.switch {
|
|
2854
3008
|
--switch-bg: var(--border);
|
|
2855
|
-
--switch-border: var(--border);
|
|
3009
|
+
--switch-border: var(--border-control);
|
|
2856
3010
|
--switch-knob: var(--surface-raised);
|
|
2857
3011
|
--switch-block-size: calc(var(--choice-control-size) + 0.125em);
|
|
2858
3012
|
--switch-width: calc(var(--switch-block-size) * 1.8);
|
|
@@ -2870,9 +3024,11 @@ small {
|
|
|
2870
3024
|
2 *
|
|
2871
3025
|
var(--switch-knob-margin)
|
|
2872
3026
|
);
|
|
3027
|
+
/* A wider knob is a capsule; the travel follows its width. */
|
|
3028
|
+
--switch-knob-inline-size: var(--switch-knob-size);
|
|
2873
3029
|
--switch-travel: calc(
|
|
2874
3030
|
var(--switch-width) -
|
|
2875
|
-
var(--switch-knob-size) -
|
|
3031
|
+
var(--switch-knob-inline-size) -
|
|
2876
3032
|
2 *
|
|
2877
3033
|
var(--border-width) -
|
|
2878
3034
|
2 *
|
|
@@ -2908,7 +3064,7 @@ small {
|
|
|
2908
3064
|
position: absolute;
|
|
2909
3065
|
inset-block-start: var(--switch-knob-margin);
|
|
2910
3066
|
inset-inline-start: var(--switch-knob-margin);
|
|
2911
|
-
inline-size: var(--switch-knob-size);
|
|
3067
|
+
inline-size: var(--switch-knob-inline-size);
|
|
2912
3068
|
block-size: var(--switch-knob-size);
|
|
2913
3069
|
border-radius: var(--radius-full);
|
|
2914
3070
|
background: var(--switch-knob);
|
|
@@ -3189,7 +3345,7 @@ small {
|
|
|
3189
3345
|
--choice-card-pad: var(--density-space, var(--space-40));
|
|
3190
3346
|
--choice-card-gap: var(--space-30);
|
|
3191
3347
|
--choice-card-radius: var(--radius);
|
|
3192
|
-
--choice-card-border: var(--border);
|
|
3348
|
+
--choice-card-border: var(--border-control);
|
|
3193
3349
|
--choice-card-bg: var(--surface);
|
|
3194
3350
|
--choice-card-check-size: 1.25em;
|
|
3195
3351
|
|
|
@@ -3420,6 +3576,166 @@ small {
|
|
|
3420
3576
|
}
|
|
3421
3577
|
}
|
|
3422
3578
|
|
|
3579
|
+
/*
|
|
3580
|
+
* Color swatch — a color chosen from a palette the application defines.
|
|
3581
|
+
*
|
|
3582
|
+
* Docs: docs/pages/forms/color-swatches.md
|
|
3583
|
+
*
|
|
3584
|
+
* A <label class="color-swatch"> wraps a native radio (one color) or checkbox
|
|
3585
|
+
* (several) and paints --color-swatch-value. The same class on a <button> is
|
|
3586
|
+
* the trigger of a palette flyout. The application owns the colors; Actual
|
|
3587
|
+
* owns the shape, the selection mark and the focus ring.
|
|
3588
|
+
*
|
|
3589
|
+
* Two sizes: the visible swatch (::before) is the height of a .switch track, so
|
|
3590
|
+
* a swatch and a switch in neighbouring rows match; the element is the hit
|
|
3591
|
+
* area around it. The hit area reaches half the inherited --gap past the
|
|
3592
|
+
* swatch, up to --control-size, and overflows its layout box: neighbours in a
|
|
3593
|
+
* .cluster or .grid meet in the middle of the gap instead of overlapping, and
|
|
3594
|
+
* rows sit one gap apart like columns. Edge, selection ring and focus ring all
|
|
3595
|
+
* belong to the visible swatch.
|
|
3596
|
+
*
|
|
3597
|
+
* The color is arbitrary, so nothing drawn on it may assume a contrast: the
|
|
3598
|
+
* selected mark is a double ring (--text outside, --surface-opaque inside) that
|
|
3599
|
+
* reads on any fill, and a hairline edge keeps a near-white color apart from
|
|
3600
|
+
* the page. ::before opts out of forced colors to keep the color, so it takes
|
|
3601
|
+
* system colors itself there.
|
|
3602
|
+
*
|
|
3603
|
+
* Without :has() the native radio stays visible in the swatch and carries the
|
|
3604
|
+
* state; the ring needs :has() to mirror it.
|
|
3605
|
+
*
|
|
3606
|
+
* Public hooks:
|
|
3607
|
+
* --color-swatch-value (fallback-only: the color, set per swatch)
|
|
3608
|
+
* --color-swatch-size
|
|
3609
|
+
* --color-swatch-radius
|
|
3610
|
+
*/
|
|
3611
|
+
.color-swatch {
|
|
3612
|
+
/* The .switch track height (switch.css); a browser test holds them equal. */
|
|
3613
|
+
--color-swatch-size: calc(var(--choice-control-size) + 0.125em);
|
|
3614
|
+
--color-swatch-radius: var(--radius-full);
|
|
3615
|
+
/* Internal: what the hit area adds to the swatch, half on each side. One gap
|
|
3616
|
+
at most, so two neighbours never claim the same point — a wider reach let
|
|
3617
|
+
the next swatch take a click on this one's center. */
|
|
3618
|
+
--color-swatch-reach: clamp(0px, var(--control-size) - var(--color-swatch-size), var(--gap));
|
|
3619
|
+
|
|
3620
|
+
position: relative;
|
|
3621
|
+
display: inline-grid;
|
|
3622
|
+
place-items: center;
|
|
3623
|
+
flex: none;
|
|
3624
|
+
inline-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
|
|
3625
|
+
block-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
|
|
3626
|
+
/* Lay out at the visible size: gaps are measured from the swatch, not from
|
|
3627
|
+
its invisible reach. */
|
|
3628
|
+
margin: calc(var(--color-swatch-reach) / -2);
|
|
3629
|
+
padding: 0;
|
|
3630
|
+
border: 0;
|
|
3631
|
+
background: transparent;
|
|
3632
|
+
color: inherit;
|
|
3633
|
+
/* Consumed so .sm/.lg reach the em-sized swatch, as on .choice-card. */
|
|
3634
|
+
font: inherit;
|
|
3635
|
+
font-size: var(--control-font-size);
|
|
3636
|
+
cursor: pointer;
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
.color-swatch::before {
|
|
3640
|
+
content: "";
|
|
3641
|
+
grid-area: 1 / 1;
|
|
3642
|
+
inline-size: var(--color-swatch-size);
|
|
3643
|
+
block-size: var(--color-swatch-size);
|
|
3644
|
+
box-sizing: border-box;
|
|
3645
|
+
border: var(--border-width) solid var(--border-control);
|
|
3646
|
+
border-radius: var(--color-swatch-radius);
|
|
3647
|
+
background: var(--color-swatch-value, transparent) padding-box;
|
|
3648
|
+
/* The color is the content, as on .color (native.css). */
|
|
3649
|
+
forced-color-adjust: none;
|
|
3650
|
+
transition:
|
|
3651
|
+
border-color var(--duration),
|
|
3652
|
+
box-shadow var(--duration);
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
/* Shares the swatch's cell and paints after ::before, so a native radio left
|
|
3656
|
+
visible sits on the color. */
|
|
3657
|
+
.color-swatch > input {
|
|
3658
|
+
grid-area: 1 / 1;
|
|
3659
|
+
position: relative;
|
|
3660
|
+
margin: 0;
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
/* Visually hidden but focusable, submitted and arrow-key navigable (sr-only
|
|
3664
|
+
pattern). Guarded: without :has() the swatch cannot mirror :checked, so the
|
|
3665
|
+
visible native radio is the fallback. */
|
|
3666
|
+
@supports selector(:has(*)) {
|
|
3667
|
+
.color-swatch > input {
|
|
3668
|
+
position: absolute;
|
|
3669
|
+
inline-size: 1px;
|
|
3670
|
+
block-size: 1px;
|
|
3671
|
+
padding: 0;
|
|
3672
|
+
margin: -1px;
|
|
3673
|
+
overflow: hidden;
|
|
3674
|
+
clip: rect(0, 0, 0, 0);
|
|
3675
|
+
white-space: nowrap;
|
|
3676
|
+
border: 0;
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
.color-swatch:hover::before {
|
|
3681
|
+
border-color: var(--text-subtle);
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
.color-swatch:has(> input:checked)::before {
|
|
3685
|
+
box-shadow:
|
|
3686
|
+
inset 0 0 0 2px var(--text),
|
|
3687
|
+
inset 0 0 0 4px var(--surface-opaque);
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
/* The ring goes around the visible swatch, not the larger hit area. */
|
|
3691
|
+
.color-swatch:focus-visible {
|
|
3692
|
+
outline: none;
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
.color-swatch:focus-visible::before {
|
|
3696
|
+
outline: var(--focus-ring-width) solid var(--focus);
|
|
3697
|
+
outline-offset: var(--focus-outline-offset);
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
.color-swatch:has(> input:focus-visible)::before {
|
|
3701
|
+
outline: var(--focus-ring-width) solid var(--focus);
|
|
3702
|
+
outline-offset: var(--focus-outline-offset);
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
.color-swatch:disabled {
|
|
3706
|
+
opacity: var(--disabled-opacity);
|
|
3707
|
+
cursor: not-allowed;
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
.color-swatch:has(> input:disabled) {
|
|
3711
|
+
opacity: var(--disabled-opacity);
|
|
3712
|
+
cursor: not-allowed;
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
/* A disabled swatch keeps its color: --disabled-opacity is 1 here
|
|
3716
|
+
(tokens.css), and the edge carries the state. */
|
|
3717
|
+
@media (forced-colors: active) {
|
|
3718
|
+
.color-swatch::before {
|
|
3719
|
+
--focus: CanvasText;
|
|
3720
|
+
|
|
3721
|
+
border-color: CanvasText;
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
.color-swatch:has(> input:checked)::before {
|
|
3725
|
+
box-shadow:
|
|
3726
|
+
inset 0 0 0 2px CanvasText,
|
|
3727
|
+
inset 0 0 0 4px Canvas;
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
.color-swatch:disabled::before {
|
|
3731
|
+
border-color: GrayText;
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
.color-swatch:has(> input:disabled)::before {
|
|
3735
|
+
border-color: GrayText;
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3423
3739
|
/*
|
|
3424
3740
|
* Customizable select — appearance: base-select picker enhancement.
|
|
3425
3741
|
*
|
|
@@ -3525,10 +3841,13 @@ small {
|
|
|
3525
3841
|
* messages stay in the surrounding .field, not inside the cell, so the
|
|
3526
3842
|
* resting label can center on the control alone.
|
|
3527
3843
|
*
|
|
3528
|
-
* The
|
|
3529
|
-
*
|
|
3530
|
-
*
|
|
3531
|
-
*
|
|
3844
|
+
* The cell reads its control's state through :has(), so the label may come
|
|
3845
|
+
* before or after the control (a form theme rendering label then widget needs
|
|
3846
|
+
* no reordering). Injected sibling nodes are tolerated (password managers,
|
|
3847
|
+
* extension wrappers) as long as the control stays a direct child; the cell
|
|
3848
|
+
* contract stays a single control plus one label, since a second control
|
|
3849
|
+
* would float the label on either one's state.
|
|
3850
|
+
* Every .input/.textarea needs
|
|
3532
3851
|
* placeholder=" " to expose a reliable empty state — without it the label
|
|
3533
3852
|
* only reacts to focus.
|
|
3534
3853
|
*
|
|
@@ -3578,23 +3897,22 @@ small {
|
|
|
3578
3897
|
}
|
|
3579
3898
|
|
|
3580
3899
|
/* Single-line controls rest in the middle until they receive a value. */
|
|
3581
|
-
.floating-field
|
|
3900
|
+
.floating-field:has(> :is(.input, .select)) > .field-label {
|
|
3582
3901
|
inset-block-start: 50%;
|
|
3583
3902
|
transform: translateY(-50%);
|
|
3584
3903
|
}
|
|
3585
3904
|
|
|
3586
3905
|
/* An empty textarea label follows the control's normal first-line position;
|
|
3587
3906
|
the control itself keeps the extra headroom reserved for its floated state. */
|
|
3588
|
-
.floating-field
|
|
3907
|
+
.floating-field:has(> .textarea) > .field-label {
|
|
3589
3908
|
inset-block-start: calc(1em + var(--border-width));
|
|
3590
3909
|
transform: none;
|
|
3591
3910
|
}
|
|
3592
3911
|
|
|
3593
3912
|
/* :autofill covers prefilled values before interaction, when the browser may
|
|
3594
3913
|
not have propagated the value to the placeholder state yet. */
|
|
3595
|
-
.floating-field
|
|
3596
|
-
|
|
3597
|
-
~ .field-label {
|
|
3914
|
+
.floating-field:has(> :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown)))
|
|
3915
|
+
> .field-label {
|
|
3598
3916
|
inset-block-start: var(--floating-label-top);
|
|
3599
3917
|
transform: none;
|
|
3600
3918
|
font-size: var(--font-size-xs);
|
|
@@ -3604,16 +3922,16 @@ small {
|
|
|
3604
3922
|
/* Controls without a dependable :placeholder-shown empty state stay floated:
|
|
3605
3923
|
select always, and date/time inputs whose placeholder rendering is
|
|
3606
3924
|
inconsistent across engines (Firefox shows none). */
|
|
3607
|
-
.floating-field
|
|
3925
|
+
.floating-field:has(
|
|
3608
3926
|
> :is(
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3927
|
+
.select,
|
|
3928
|
+
.input[type="date"],
|
|
3929
|
+
.input[type="time"],
|
|
3930
|
+
.input[type="datetime-local"],
|
|
3931
|
+
.input[type="month"],
|
|
3932
|
+
.input[type="week"]
|
|
3933
|
+
)
|
|
3934
|
+
) > .field-label {
|
|
3617
3935
|
inset-block-start: var(--floating-label-top);
|
|
3618
3936
|
transform: none;
|
|
3619
3937
|
font-size: var(--font-size-xs);
|
|
@@ -3649,7 +3967,7 @@ small {
|
|
|
3649
3967
|
--otp-cell-size: var(--control-size);
|
|
3650
3968
|
--otp-gap: var(--space-10);
|
|
3651
3969
|
--otp-font-size: 1.5em;
|
|
3652
|
-
--otp-cell-border: var(--border);
|
|
3970
|
+
--otp-cell-border: var(--border-control);
|
|
3653
3971
|
--otp-cell-bg: var(--surface);
|
|
3654
3972
|
--otp-caret-space: calc((var(--otp-cell-size) - 1ch) / 2 + var(--otp-gap) + 1px);
|
|
3655
3973
|
|
|
@@ -3735,10 +4053,9 @@ small {
|
|
|
3735
4053
|
--otp-cell-border: var(--danger);
|
|
3736
4054
|
}
|
|
3737
4055
|
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
}
|
|
4056
|
+
/* 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. */
|
|
4057
|
+
.needs-validation .otp > input:user-invalid ~ span {
|
|
4058
|
+
--otp-cell-border: var(--danger);
|
|
3742
4059
|
}
|
|
3743
4060
|
|
|
3744
4061
|
.otp > input:disabled {
|
|
@@ -3913,12 +4230,27 @@ small {
|
|
|
3913
4230
|
--ui-hover-bg: transparent;
|
|
3914
4231
|
}
|
|
3915
4232
|
|
|
4233
|
+
/* A soft button takes the badge and alert tint, not the shared opaque .soft:
|
|
4234
|
+
it carries a short label like them, and the translucent fill keeps
|
|
4235
|
+
separating on a subtle or raised surface. Ink stays the shared .soft one. */
|
|
3916
4236
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
3917
4237
|
.btn.soft {
|
|
4238
|
+
/* @sync soft-tint */
|
|
4239
|
+
--ui-bg: color-mix(
|
|
4240
|
+
in oklab,
|
|
4241
|
+
var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
|
|
4242
|
+
transparent
|
|
4243
|
+
);
|
|
4244
|
+
--ui-border: color-mix(
|
|
4245
|
+
in oklab,
|
|
4246
|
+
var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
|
|
4247
|
+
transparent
|
|
4248
|
+
);
|
|
4249
|
+
/* @sync soft-tint */
|
|
3918
4250
|
--ui-hover-bg: color-mix(
|
|
3919
4251
|
in oklab,
|
|
3920
|
-
var(--
|
|
3921
|
-
|
|
4252
|
+
var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
|
|
4253
|
+
transparent
|
|
3922
4254
|
);
|
|
3923
4255
|
}
|
|
3924
4256
|
|
|
@@ -4117,11 +4449,14 @@ a.btn[aria-disabled="true"] {
|
|
|
4117
4449
|
}
|
|
4118
4450
|
|
|
4119
4451
|
@media (forced-colors: active) {
|
|
4120
|
-
/* The X is a
|
|
4121
|
-
|
|
4122
|
-
|
|
4452
|
+
/* The X is a mask fill: without this the system remaps the background to
|
|
4453
|
+
Canvas and the glyph disappears. Kept on the pseudo so the host keeps its
|
|
4454
|
+
native color and focus adaptation. The fill is the system color, not
|
|
4455
|
+
currentColor: an unadjusted pseudo inherits the authored ink, so a white
|
|
4456
|
+
.solid ink painted a white X on Canvas. */
|
|
4123
4457
|
.close::before {
|
|
4124
4458
|
forced-color-adjust: none;
|
|
4459
|
+
background: ButtonText;
|
|
4125
4460
|
}
|
|
4126
4461
|
}
|
|
4127
4462
|
|
|
@@ -4238,33 +4573,46 @@ a.btn[aria-disabled="true"] {
|
|
|
4238
4573
|
}
|
|
4239
4574
|
|
|
4240
4575
|
/* Soft by default. Local defaults remain behind --ui-* in the resolution
|
|
4241
|
-
chain, so explicit shared treatments compose without selector exclusions.
|
|
4576
|
+
chain, so explicit shared treatments compose without selector exclusions.
|
|
4577
|
+
The fill is a translucent intent tint, so it shows the surface it sits on
|
|
4578
|
+
instead of a fixed mix with --surface: a mix stops separating on a subtle
|
|
4579
|
+
or raised surface of its own lightness. No rim unless a theme sets
|
|
4580
|
+
--soft-border-mix (tokens.css). */
|
|
4242
4581
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
4243
4582
|
.badge {
|
|
4244
|
-
/* @sync soft-
|
|
4583
|
+
/* @sync soft-tint */
|
|
4245
4584
|
--badge-default-bg: color-mix(
|
|
4246
4585
|
in oklab,
|
|
4247
|
-
var(--
|
|
4248
|
-
|
|
4586
|
+
var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
|
|
4587
|
+
transparent
|
|
4249
4588
|
);
|
|
4250
4589
|
--badge-default-border: color-mix(
|
|
4251
4590
|
in oklab,
|
|
4252
|
-
var(--
|
|
4253
|
-
|
|
4591
|
+
var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
|
|
4592
|
+
transparent
|
|
4254
4593
|
);
|
|
4594
|
+
/* @sync soft-tint */
|
|
4595
|
+
/* @sync soft-ink */
|
|
4255
4596
|
--badge-default-fg: var(
|
|
4256
4597
|
--intent-soft-fg,
|
|
4257
4598
|
color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
|
|
4258
4599
|
);
|
|
4259
|
-
/* @sync soft-
|
|
4600
|
+
/* @sync soft-ink */
|
|
4260
4601
|
}
|
|
4261
4602
|
}
|
|
4262
4603
|
|
|
4604
|
+
/* Soft is the badge's own default: an explicit .soft must not swap the tint
|
|
4605
|
+
for the shared opaque .soft recipe, so it defers to the default. */
|
|
4606
|
+
.badge.soft {
|
|
4607
|
+
--ui-bg: initial;
|
|
4608
|
+
--ui-fg: initial;
|
|
4609
|
+
--ui-border: initial;
|
|
4610
|
+
}
|
|
4611
|
+
|
|
4263
4612
|
.badge:empty {
|
|
4264
4613
|
--badge-size: var(--badge-dot-size);
|
|
4265
4614
|
--badge-bg: var(--intent, var(--neutral));
|
|
4266
4615
|
--badge-fg: var(--intent-fg, var(--neutral-fg));
|
|
4267
|
-
--badge-border: transparent;
|
|
4268
4616
|
padding: 0;
|
|
4269
4617
|
border: 0;
|
|
4270
4618
|
inline-size: var(--badge-size);
|
|
@@ -4299,8 +4647,8 @@ a.btn[aria-disabled="true"] {
|
|
|
4299
4647
|
:is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
|
|
4300
4648
|
--badge-default-hover-bg: color-mix(
|
|
4301
4649
|
in oklab,
|
|
4302
|
-
var(--
|
|
4303
|
-
|
|
4650
|
+
var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
|
|
4651
|
+
transparent
|
|
4304
4652
|
);
|
|
4305
4653
|
}
|
|
4306
4654
|
|
|
@@ -4418,6 +4766,16 @@ a.badge[aria-disabled="true"] {
|
|
|
4418
4766
|
font-size: var(--alert-font-size);
|
|
4419
4767
|
}
|
|
4420
4768
|
|
|
4769
|
+
/* Density steps the block padding only: the inline padding is the text
|
|
4770
|
+
column's edge and stays stable. Local to the alert, like .card.compact. */
|
|
4771
|
+
.alert.compact {
|
|
4772
|
+
--alert-pad-block: var(--space-20);
|
|
4773
|
+
}
|
|
4774
|
+
|
|
4775
|
+
.alert.spacious {
|
|
4776
|
+
--alert-pad-block: var(--space-40);
|
|
4777
|
+
}
|
|
4778
|
+
|
|
4421
4779
|
.alert > * {
|
|
4422
4780
|
min-inline-size: 0;
|
|
4423
4781
|
}
|
|
@@ -4442,32 +4800,45 @@ a.badge[aria-disabled="true"] {
|
|
|
4442
4800
|
}
|
|
4443
4801
|
|
|
4444
4802
|
/* Alerts are soft by default. The recipe writes local defaults rather than
|
|
4445
|
-
--ui-* so an explicit shared treatment always wins without exclusions.
|
|
4803
|
+
--ui-* so an explicit shared treatment always wins without exclusions. The
|
|
4804
|
+
fill is the translucent soft tint (see badge.css), with no rim unless a
|
|
4805
|
+
theme sets --soft-border-mix. */
|
|
4446
4806
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
4447
4807
|
.alert {
|
|
4448
|
-
/* @sync soft-
|
|
4808
|
+
/* @sync soft-tint */
|
|
4449
4809
|
--alert-default-bg: color-mix(
|
|
4450
4810
|
in oklab,
|
|
4451
|
-
var(--
|
|
4452
|
-
|
|
4811
|
+
var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
|
|
4812
|
+
transparent
|
|
4453
4813
|
);
|
|
4454
4814
|
--alert-default-border: color-mix(
|
|
4455
4815
|
in oklab,
|
|
4456
|
-
var(--
|
|
4457
|
-
|
|
4816
|
+
var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
|
|
4817
|
+
transparent
|
|
4458
4818
|
);
|
|
4819
|
+
/* @sync soft-tint */
|
|
4820
|
+
/* @sync soft-ink */
|
|
4459
4821
|
--alert-default-fg: var(
|
|
4460
4822
|
--intent-soft-fg,
|
|
4461
4823
|
color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
|
|
4462
4824
|
);
|
|
4463
|
-
/* @sync soft-
|
|
4825
|
+
/* @sync soft-ink */
|
|
4464
4826
|
}
|
|
4465
4827
|
}
|
|
4466
4828
|
|
|
4467
|
-
/*
|
|
4829
|
+
/* Soft is the alert's own default: an explicit .soft must not swap the tint
|
|
4830
|
+
for the shared opaque .soft recipe, so it defers to the default. */
|
|
4831
|
+
.alert.soft {
|
|
4832
|
+
--ui-bg: initial;
|
|
4833
|
+
--ui-fg: initial;
|
|
4834
|
+
--ui-border: initial;
|
|
4835
|
+
}
|
|
4836
|
+
|
|
4837
|
+
/* Decorative leading icon — use .alert-icon on the icon element. display and
|
|
4838
|
+
vertical-align are the no-:has() layout (Degraded tier: the icon sits inline
|
|
4839
|
+
before the text); the grid anatomy below replaces them. */
|
|
4468
4840
|
.alert > .alert-icon {
|
|
4469
4841
|
display: inline-block;
|
|
4470
|
-
flex: none;
|
|
4471
4842
|
inline-size: var(--alert-icon-size);
|
|
4472
4843
|
block-size: var(--alert-icon-size);
|
|
4473
4844
|
font-size: var(--alert-icon-size);
|
|
@@ -4499,45 +4870,44 @@ a.badge[aria-disabled="true"] {
|
|
|
4499
4870
|
|
|
4500
4871
|
/* Grid anatomy — activated by a leading icon, a trailing dismiss button, or
|
|
4501
4872
|
both. Icon and dismiss are pinned to the outer columns; anything else (the
|
|
4502
|
-
message content) flows into the remaining text column.
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
.alert:has(> .alert-icon) {
|
|
4512
|
-
grid-template-columns: auto minmax(0, 1fr);
|
|
4513
|
-
}
|
|
4873
|
+
message content) flows into the remaining text column. Without :has(), the
|
|
4874
|
+
grid rules drop together and the alert keeps ordinary block flow. */
|
|
4875
|
+
.alert:has(> .alert-icon, > .close) {
|
|
4876
|
+
display: grid;
|
|
4877
|
+
/* No grid-template-columns: the icon and close branches below always set
|
|
4878
|
+
it, since one of them is what matched. */
|
|
4879
|
+
align-items: var(--items-align, start);
|
|
4880
|
+
column-gap: var(--space-30);
|
|
4881
|
+
}
|
|
4514
4882
|
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
display: inline-grid;
|
|
4519
|
-
place-items: center;
|
|
4520
|
-
margin-block-start: 0.125em;
|
|
4521
|
-
}
|
|
4883
|
+
.alert:has(> .alert-icon) {
|
|
4884
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
4885
|
+
}
|
|
4522
4886
|
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4887
|
+
.alert:has(> .alert-icon) > .alert-icon {
|
|
4888
|
+
grid-column: 1;
|
|
4889
|
+
grid-row: 1 / -1;
|
|
4890
|
+
display: inline-grid;
|
|
4891
|
+
place-items: center;
|
|
4892
|
+
margin-block-start: 0.125em;
|
|
4893
|
+
}
|
|
4526
4894
|
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4895
|
+
.alert:has(> .alert-icon) > :not(.alert-icon, .close) {
|
|
4896
|
+
grid-column: 2;
|
|
4897
|
+
}
|
|
4530
4898
|
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4899
|
+
.alert:has(> .close) {
|
|
4900
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
4901
|
+
}
|
|
4534
4902
|
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
grid-row: 1;
|
|
4538
|
-
}
|
|
4903
|
+
.alert:has(> .alert-icon):has(> .close) {
|
|
4904
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
4539
4905
|
}
|
|
4540
4906
|
|
|
4907
|
+
.alert:has(> .close) > .close {
|
|
4908
|
+
grid-column: -2 / -1;
|
|
4909
|
+
grid-row: 1;
|
|
4910
|
+
}
|
|
4541
4911
|
/* Links */
|
|
4542
4912
|
.alert :where(a:not(.btn)) {
|
|
4543
4913
|
color: inherit;
|
|
@@ -4562,7 +4932,10 @@ a.badge[aria-disabled="true"] {
|
|
|
4562
4932
|
|
|
4563
4933
|
/* Admonition — structured static box with a tinted title bar and a body
|
|
4564
4934
|
that rests on the page surface. Not collapsible — use .accordion inside
|
|
4565
|
-
the body if the content needs to be expandable.
|
|
4935
|
+
the body if the content needs to be expandable. The title bar is what a
|
|
4936
|
+
shared treatment paints (.solid fills it, .surface/.outline clear it): the
|
|
4937
|
+
root's fill never shows behind the opaque title and body, and the title ink
|
|
4938
|
+
already follows --ui-fg, so a bar left on the soft tint is unreadable. */
|
|
4566
4939
|
.alert.admonition {
|
|
4567
4940
|
padding: 0;
|
|
4568
4941
|
display: block;
|
|
@@ -4576,6 +4949,7 @@ a.badge[aria-disabled="true"] {
|
|
|
4576
4949
|
gap: var(--space-20);
|
|
4577
4950
|
padding-block: var(--alert-pad-block);
|
|
4578
4951
|
padding-inline: var(--alert-pad-inline);
|
|
4952
|
+
font-weight: var(--font-weight-semibold);
|
|
4579
4953
|
border-start-start-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
|
|
4580
4954
|
border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
|
|
4581
4955
|
}
|
|
@@ -4587,18 +4961,17 @@ a.badge[aria-disabled="true"] {
|
|
|
4587
4961
|
|
|
4588
4962
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
4589
4963
|
.alert.admonition > .alert-title {
|
|
4590
|
-
background:
|
|
4591
|
-
|
|
4592
|
-
var(--surface) var(--soft-bg-mix),
|
|
4593
|
-
var(--intent, var(--neutral))
|
|
4964
|
+
background: var(
|
|
4965
|
+
--ui-bg,
|
|
4966
|
+
color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)))
|
|
4594
4967
|
);
|
|
4595
4968
|
}
|
|
4596
4969
|
}
|
|
4597
4970
|
|
|
4598
4971
|
@supports not (color: color-mix(in oklch, red, white)) {
|
|
4599
4972
|
.alert.admonition > .alert-title {
|
|
4600
|
-
background: var(--intent, var(--neutral));
|
|
4601
|
-
color: var(--intent-fg, var(--neutral-fg));
|
|
4973
|
+
background: var(--ui-bg, var(--intent, var(--neutral)));
|
|
4974
|
+
color: var(--ui-fg, var(--intent-fg, var(--neutral-fg)));
|
|
4602
4975
|
}
|
|
4603
4976
|
}
|
|
4604
4977
|
|
|
@@ -4609,6 +4982,32 @@ a.badge[aria-disabled="true"] {
|
|
|
4609
4982
|
color: var(--text);
|
|
4610
4983
|
}
|
|
4611
4984
|
|
|
4985
|
+
/* Solid: the filled title bar already carries the intent, so the frame moves
|
|
4986
|
+
to the body and stays neutral. The root border goes because a transparent
|
|
4987
|
+
one would show the root's intent fill around the body. */
|
|
4988
|
+
.alert.admonition.solid {
|
|
4989
|
+
border: 0;
|
|
4990
|
+
}
|
|
4991
|
+
|
|
4992
|
+
.alert.admonition.solid > .alert-body {
|
|
4993
|
+
border: var(--border-width) solid var(--border);
|
|
4994
|
+
border-block-start: 0;
|
|
4995
|
+
border-end-start-radius: var(--alert-radius, var(--radius-lg));
|
|
4996
|
+
border-end-end-radius: var(--alert-radius, var(--radius-lg));
|
|
4997
|
+
}
|
|
4998
|
+
|
|
4999
|
+
/* Forced colors drop the title fill, so the bar would lose its edge: the
|
|
5000
|
+
frame returns to the root as one uniform system-colored border. */
|
|
5001
|
+
@media (forced-colors: active) {
|
|
5002
|
+
.alert.admonition.solid {
|
|
5003
|
+
border: var(--border-width) solid CanvasText;
|
|
5004
|
+
}
|
|
5005
|
+
|
|
5006
|
+
.alert.admonition.solid > .alert-body {
|
|
5007
|
+
border: 0;
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
5010
|
+
|
|
4612
5011
|
.alert.admonition > .alert-body > :first-child {
|
|
4613
5012
|
margin-block-start: 0;
|
|
4614
5013
|
}
|
|
@@ -4896,6 +5295,12 @@ a.badge[aria-disabled="true"] {
|
|
|
4896
5295
|
background: var(--surface-subtle);
|
|
4897
5296
|
}
|
|
4898
5297
|
|
|
5298
|
+
/* The chrome header is a centered row, so prose margins on a bare title only
|
|
5299
|
+
push the band taller and pull the title off the close axis. */
|
|
5300
|
+
:where(dialog).modal > header > :is(h1, h2, h3, h4, h5, h6) {
|
|
5301
|
+
margin-block: 0;
|
|
5302
|
+
}
|
|
5303
|
+
|
|
4899
5304
|
:where(dialog).modal > header > .media {
|
|
4900
5305
|
flex: 1 1 auto;
|
|
4901
5306
|
min-inline-size: 0;
|
|
@@ -4959,19 +5364,20 @@ a.badge[aria-disabled="true"] {
|
|
|
4959
5364
|
|
|
4960
5365
|
:where(dialog).modal.scrollable > form > :not(:is(header, footer)),
|
|
4961
5366
|
:where(dialog).modal.scrollable > .stack > :not(:is(header, footer)) {
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
);
|
|
5367
|
+
/* Declared here, not as a :root alias: an inherited alias arrives already
|
|
5368
|
+
computed, so a --focus-ring-width override on the dialog would not reach
|
|
5369
|
+
it. */
|
|
5370
|
+
--dialog-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
4966
5371
|
|
|
4967
5372
|
flex: 1 1 auto;
|
|
4968
5373
|
min-block-size: 0;
|
|
4969
|
-
/* Keep focus paint inside the clip
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
5374
|
+
/* Keep focus paint inside the clip, on all four sides (overflow: auto clips
|
|
5375
|
+
both axes), without moving the content. Every outside indicator is
|
|
5376
|
+
--focus-ring-width wide (core/focus.css). On the block axis the reserve
|
|
5377
|
+
reaches into the slot gap, which is wider, so the scrollport never slides
|
|
5378
|
+
under the header or footer. */
|
|
5379
|
+
margin: calc(var(--dialog-focus-bleed) * -1);
|
|
5380
|
+
padding: var(--dialog-focus-bleed);
|
|
4975
5381
|
overflow: auto;
|
|
4976
5382
|
overscroll-behavior: contain;
|
|
4977
5383
|
}
|
|
@@ -5176,6 +5582,7 @@ html.has-modal-open.had-scrollbar {
|
|
|
5176
5582
|
* --drawer-close-reserve (header room for a corner close; zero without a close)
|
|
5177
5583
|
* --drawer-motion-duration (shared by panel and ::backdrop so exit duration
|
|
5178
5584
|
* changes in one place; entry state redefines it longer)
|
|
5585
|
+
* --drawer-motion-ease (panel easing owned by the open/closed state)
|
|
5179
5586
|
*/
|
|
5180
5587
|
:where(dialog).drawer {
|
|
5181
5588
|
--drawer-size: 20rem;
|
|
@@ -5190,6 +5597,7 @@ html.has-modal-open.had-scrollbar {
|
|
|
5190
5597
|
/* Exit is shorter than entry so the panel clears quickly; the scrim reads
|
|
5191
5598
|
the same variable through inheritance and never lags the panel. */
|
|
5192
5599
|
--drawer-motion-duration: var(--duration);
|
|
5600
|
+
--drawer-motion-ease: var(--ease-exit);
|
|
5193
5601
|
|
|
5194
5602
|
/* Keep the surface anchored to the viewport in the top layer. This also
|
|
5195
5603
|
provides the containing block for the absolutely positioned close button.
|
|
@@ -5221,21 +5629,21 @@ html.has-modal-open.had-scrollbar {
|
|
|
5221
5629
|
box-shadow: var(--shadow);
|
|
5222
5630
|
opacity: 0;
|
|
5223
5631
|
transform: translateX(var(--drawer-closed-translate));
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5632
|
+
/* Unsupported discrete behavior drops alone, preserving opacity/transform. */
|
|
5633
|
+
transition-property: opacity, transform, overlay, display;
|
|
5634
|
+
transition-duration: var(--drawer-motion-duration);
|
|
5635
|
+
transition-timing-function: var(--drawer-motion-ease);
|
|
5636
|
+
transition-behavior: allow-discrete;
|
|
5227
5637
|
}
|
|
5228
5638
|
|
|
5229
5639
|
:where(dialog).drawer[open] {
|
|
5230
5640
|
--drawer-motion-duration: var(--duration-slow);
|
|
5641
|
+
--drawer-motion-ease: var(--ease-enter);
|
|
5231
5642
|
display: grid;
|
|
5232
5643
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
5233
5644
|
gap: var(--space-40);
|
|
5234
5645
|
opacity: 1;
|
|
5235
5646
|
transform: translateX(0);
|
|
5236
|
-
transition:
|
|
5237
|
-
opacity var(--drawer-motion-duration) var(--ease-enter),
|
|
5238
|
-
transform var(--drawer-motion-duration) var(--ease-enter);
|
|
5239
5647
|
}
|
|
5240
5648
|
|
|
5241
5649
|
/* A form is the drawer's content wrapper when actions need method="dialog".
|
|
@@ -5255,7 +5663,10 @@ html.has-modal-open.had-scrollbar {
|
|
|
5255
5663
|
opacity: 0;
|
|
5256
5664
|
/* Scrim curve stays neutral `ease`; only the panel's character curves
|
|
5257
5665
|
apply. Duration follows --drawer-motion-duration through inheritance. */
|
|
5258
|
-
transition: opacity
|
|
5666
|
+
transition-property: opacity, overlay, display;
|
|
5667
|
+
transition-duration: var(--drawer-motion-duration);
|
|
5668
|
+
transition-timing-function: ease;
|
|
5669
|
+
transition-behavior: allow-discrete;
|
|
5259
5670
|
}
|
|
5260
5671
|
|
|
5261
5672
|
:where(dialog).drawer[data-side="end"] {
|
|
@@ -5289,47 +5700,23 @@ html.has-modal-open.had-scrollbar {
|
|
|
5289
5700
|
exclusion list is easier to trust than its `position: absolute`. */
|
|
5290
5701
|
:where(dialog).drawer > :not(:is(header, footer, form, .close)),
|
|
5291
5702
|
:where(dialog).drawer > form > :not(:is(header, footer, .close)) {
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
);
|
|
5703
|
+
/* Declared here, not as a :root alias: an inherited alias arrives already
|
|
5704
|
+
computed, so a --focus-ring-width override on the drawer would not reach
|
|
5705
|
+
it. */
|
|
5706
|
+
--drawer-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
5296
5707
|
|
|
5297
5708
|
min-block-size: 0;
|
|
5298
|
-
/* Keep focus paint inside the clip
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5709
|
+
/* Keep focus paint inside the clip, on all four sides (overflow: auto clips
|
|
5710
|
+
both axes), without moving the content. Every outside indicator is
|
|
5711
|
+
--focus-ring-width wide (core/focus.css). On the block axis the reserve
|
|
5712
|
+
reaches into the slot gap, which is wider, so the scrollport never slides
|
|
5713
|
+
under the header or footer. */
|
|
5714
|
+
margin: calc(var(--drawer-focus-bleed) * -1);
|
|
5715
|
+
padding: var(--drawer-focus-bleed);
|
|
5304
5716
|
overflow: auto;
|
|
5305
5717
|
overscroll-behavior: contain;
|
|
5306
5718
|
}
|
|
5307
5719
|
|
|
5308
|
-
@supports (transition-behavior: allow-discrete) {
|
|
5309
|
-
:where(dialog).drawer {
|
|
5310
|
-
transition:
|
|
5311
|
-
opacity var(--drawer-motion-duration) var(--ease-exit),
|
|
5312
|
-
transform var(--drawer-motion-duration) var(--ease-exit),
|
|
5313
|
-
overlay var(--drawer-motion-duration) allow-discrete,
|
|
5314
|
-
display var(--drawer-motion-duration) allow-discrete;
|
|
5315
|
-
}
|
|
5316
|
-
|
|
5317
|
-
:where(dialog).drawer[open] {
|
|
5318
|
-
transition:
|
|
5319
|
-
opacity var(--drawer-motion-duration) var(--ease-enter),
|
|
5320
|
-
transform var(--drawer-motion-duration) var(--ease-enter),
|
|
5321
|
-
overlay var(--drawer-motion-duration) allow-discrete,
|
|
5322
|
-
display var(--drawer-motion-duration) allow-discrete;
|
|
5323
|
-
}
|
|
5324
|
-
|
|
5325
|
-
:where(dialog).drawer::backdrop {
|
|
5326
|
-
transition:
|
|
5327
|
-
opacity var(--drawer-motion-duration) ease,
|
|
5328
|
-
overlay var(--drawer-motion-duration) allow-discrete,
|
|
5329
|
-
display var(--drawer-motion-duration) allow-discrete;
|
|
5330
|
-
}
|
|
5331
|
-
}
|
|
5332
|
-
|
|
5333
5720
|
/* Safari before 16.4 does not support :dir(); keep the ancestor-attribute
|
|
5334
5721
|
path as the Degraded-tier fallback. The :dir() rules are dropped on their
|
|
5335
5722
|
own there, so they need no wrapper. :dir() follows inherited HTML
|
|
@@ -5561,7 +5948,6 @@ picture.bleed > img {
|
|
|
5561
5948
|
.flyout[popover] {
|
|
5562
5949
|
position: fixed;
|
|
5563
5950
|
inset: auto;
|
|
5564
|
-
margin: 0;
|
|
5565
5951
|
}
|
|
5566
5952
|
|
|
5567
5953
|
/* A presentation class may own its *visible* display type, but must not
|
|
@@ -6530,13 +6916,13 @@ picture.bleed > img {
|
|
|
6530
6916
|
* --avatar-size
|
|
6531
6917
|
* --avatar-radius
|
|
6532
6918
|
* --avatar-font-size
|
|
6919
|
+
* --avatar-bg
|
|
6533
6920
|
* --avatar-stack-size
|
|
6534
6921
|
* --avatar-stack-font-size
|
|
6535
6922
|
* --avatar-stack-overlap
|
|
6536
6923
|
* --avatar-stack-ring
|
|
6537
6924
|
*
|
|
6538
6925
|
* Internal:
|
|
6539
|
-
* --avatar-bg
|
|
6540
6926
|
* --avatar-fg
|
|
6541
6927
|
* --avatar-status-translate-x
|
|
6542
6928
|
*/
|
|
@@ -6614,16 +7000,13 @@ picture.bleed > img {
|
|
|
6614
7000
|
|
|
6615
7001
|
.avatar > img,
|
|
6616
7002
|
.avatar > picture > img {
|
|
7003
|
+
display: block;
|
|
6617
7004
|
inline-size: 100%;
|
|
6618
7005
|
block-size: 100%;
|
|
6619
7006
|
object-fit: cover;
|
|
6620
7007
|
border-radius: inherit;
|
|
6621
7008
|
}
|
|
6622
7009
|
|
|
6623
|
-
.avatar > picture > img {
|
|
6624
|
-
display: block;
|
|
6625
|
-
}
|
|
6626
|
-
|
|
6627
7010
|
button.avatar {
|
|
6628
7011
|
appearance: none;
|
|
6629
7012
|
cursor: pointer;
|
|
@@ -6717,19 +7100,6 @@ a.avatar[aria-disabled="true"] {
|
|
|
6717
7100
|
--avatar-stack-font-size: var(--font-size-lg);
|
|
6718
7101
|
}
|
|
6719
7102
|
|
|
6720
|
-
/* Infer the stack scale from its avatars. Progressive: without :has() the
|
|
6721
|
-
explicit .avatar-stack.sm/.lg classes and the default size still size the
|
|
6722
|
-
group, only the inference is lost. */
|
|
6723
|
-
.avatar-stack:has(> .avatar.sm) {
|
|
6724
|
-
--avatar-stack-size: 1.75rem;
|
|
6725
|
-
--avatar-stack-font-size: var(--font-size-xs);
|
|
6726
|
-
}
|
|
6727
|
-
|
|
6728
|
-
.avatar-stack:has(> .avatar.lg) {
|
|
6729
|
-
--avatar-stack-size: 3rem;
|
|
6730
|
-
--avatar-stack-font-size: var(--font-size-lg);
|
|
6731
|
-
}
|
|
6732
|
-
|
|
6733
7103
|
/* Overlapping avatars hide their neighbour: a translucent fill (--surface-subtle
|
|
6734
7104
|
on a glass palette) would show the next avatar's edge through it, most of all
|
|
6735
7105
|
when hover raises one above the other. The fill is layered over the opaque
|
|
@@ -7235,7 +7605,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7235
7605
|
* destinations. Anything else focusable in a step is out of contract.
|
|
7236
7606
|
*
|
|
7237
7607
|
* The framework never hides an authored label to solve a layout problem. A
|
|
7238
|
-
* labelled row
|
|
7608
|
+
* labelled row wraps its labels, then scrolls if its markers or authored item
|
|
7609
|
+
* minimum outgrow the available space. An `actual-container` size
|
|
7239
7610
|
* context buys it one thing: the wide representation, marker beside label.
|
|
7240
7611
|
*
|
|
7241
7612
|
* See docs/design-notes/steps.md for the threshold, the selector shapes, the
|
|
@@ -7346,11 +7717,14 @@ a.avatar[aria-disabled="true"] {
|
|
|
7346
7717
|
|
|
7347
7718
|
/* Complete owns the accented connector that reaches the current step, so the
|
|
7348
7719
|
connector leaving current goes back to neutral. Declared after
|
|
7349
|
-
.step-complete at equal specificity: current wins on every channel.
|
|
7720
|
+
.step-complete at equal specificity: current wins on every channel. The
|
|
7721
|
+
current marker is a regular surface, so its number and ring are selected
|
|
7722
|
+
ink on a surface, not a selected fill; they are held to different ratios
|
|
7723
|
+
(4.5:1 for the number, 3:1 for the ring) by report:theme-contrast. */
|
|
7350
7724
|
.steps > [aria-current="step"] {
|
|
7351
7725
|
--step-marker-bg: var(--surface-opaque);
|
|
7352
|
-
--step-marker-border: var(--state-selected);
|
|
7353
|
-
--step-marker-fg: var(--state-selected);
|
|
7726
|
+
--step-marker-border: var(--state-selected-text, var(--state-selected));
|
|
7727
|
+
--step-marker-fg: var(--state-selected-text, var(--state-selected));
|
|
7354
7728
|
--step-line: var(--border);
|
|
7355
7729
|
|
|
7356
7730
|
color: var(--text);
|
|
@@ -7371,10 +7745,11 @@ a.avatar[aria-disabled="true"] {
|
|
|
7371
7745
|
/* Horizontal ------------------------------------------------------------- */
|
|
7372
7746
|
|
|
7373
7747
|
.steps-horizontal {
|
|
7374
|
-
--step-min:
|
|
7748
|
+
--step-min: var(--step-size);
|
|
7375
7749
|
--step-gap: var(--space-20);
|
|
7376
7750
|
|
|
7377
7751
|
inline-size: 100%;
|
|
7752
|
+
column-gap: var(--step-gap);
|
|
7378
7753
|
overflow-x: auto;
|
|
7379
7754
|
/* Stated, never left to `overflow-x` alone: one scrolling axis computes the
|
|
7380
7755
|
other to `auto`, and a row hugs fractional content closely enough that a
|
|
@@ -7383,11 +7758,22 @@ a.avatar[aria-disabled="true"] {
|
|
|
7383
7758
|
overscroll-behavior-inline: contain;
|
|
7384
7759
|
}
|
|
7385
7760
|
|
|
7761
|
+
/* A row that loads mid-flow starts scrolled to its current step; engines
|
|
7762
|
+
without the property start at the first. */
|
|
7763
|
+
.steps-horizontal > [aria-current="step"] {
|
|
7764
|
+
/* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
|
|
7765
|
+
scroll-initial-target: nearest;
|
|
7766
|
+
}
|
|
7767
|
+
|
|
7386
7768
|
/* A scroll container clips at the padding edge, so a navigable row reserves the
|
|
7387
|
-
focus ring's own width and offset
|
|
7388
|
-
link has a ring to
|
|
7769
|
+
focus ring's own width and offset, and focus scrolls a link in that far from
|
|
7770
|
+
the inline clip. Only this composition scrolls, and only a link has a ring to
|
|
7771
|
+
protect. */
|
|
7389
7772
|
.steps-horizontal:has(a[href]) {
|
|
7773
|
+
/* Wrapped names can fill their cell right up to either inline clip edge. */
|
|
7774
|
+
padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
7390
7775
|
padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
7776
|
+
scroll-padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
7391
7777
|
}
|
|
7392
7778
|
|
|
7393
7779
|
/* One explicit row, the marker's: a `.step-label` is auto-placed into an
|
|
@@ -7399,6 +7785,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7399
7785
|
.steps-horizontal > li {
|
|
7400
7786
|
display: grid;
|
|
7401
7787
|
flex: 1 0 var(--step-min);
|
|
7788
|
+
/* Bound each label to its share so it cannot change the marker pitch. */
|
|
7789
|
+
grid-template-columns: minmax(0, 1fr);
|
|
7402
7790
|
grid-template-rows: var(--step-size);
|
|
7403
7791
|
gap: var(--step-gap);
|
|
7404
7792
|
justify-items: center;
|
|
@@ -7406,9 +7794,42 @@ a.avatar[aria-disabled="true"] {
|
|
|
7406
7794
|
text-align: center;
|
|
7407
7795
|
}
|
|
7408
7796
|
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7797
|
+
.steps-horizontal .step-label {
|
|
7798
|
+
/* Even an unbroken name must fit its share instead of overlapping a peer.
|
|
7799
|
+
Hyphenation, when `lang` allows it, is tried before an arbitrary break. */
|
|
7800
|
+
overflow-wrap: anywhere;
|
|
7801
|
+
hyphens: auto;
|
|
7802
|
+
}
|
|
7803
|
+
|
|
7804
|
+
/* Half end cells put the outer markers on the row's edges without changing
|
|
7805
|
+
their pitch, so the track lines up with the text around it, as the compact
|
|
7806
|
+
and inline forms do. The marker's half-width keeps the first connector equal
|
|
7807
|
+
to its peers.
|
|
7808
|
+
|
|
7809
|
+
Source order is load-bearing: these tie the `:empty` rules below and the
|
|
7810
|
+
inline query on specificity, and both must win over them. */
|
|
7811
|
+
.steps-horizontal > li:is(:first-child, :last-child) {
|
|
7812
|
+
flex: 0.5 0 calc((var(--step-min) + var(--step-size)) / 2);
|
|
7813
|
+
min-inline-size: max(var(--step-size), calc((var(--step-min) + var(--step-size)) / 2));
|
|
7814
|
+
}
|
|
7815
|
+
|
|
7816
|
+
.steps-horizontal > li:first-child {
|
|
7817
|
+
justify-items: start;
|
|
7818
|
+
text-align: start;
|
|
7819
|
+
}
|
|
7820
|
+
|
|
7821
|
+
.steps-horizontal > li:first-child::after {
|
|
7822
|
+
inset-inline-start: var(--step-size);
|
|
7823
|
+
inline-size: calc(200% - var(--step-size) * 2 + var(--step-gap));
|
|
7824
|
+
}
|
|
7825
|
+
|
|
7826
|
+
.steps-horizontal > li:last-child {
|
|
7827
|
+
justify-items: end;
|
|
7828
|
+
text-align: end;
|
|
7829
|
+
}
|
|
7830
|
+
|
|
7831
|
+
/* An empty step ignores any authored reading-width floor and needs only its
|
|
7832
|
+
marker. That lets five of them fit a phone without a size context.
|
|
7412
7833
|
|
|
7413
7834
|
`justify-items` is not an override of the row's centring: the marker is
|
|
7414
7835
|
centred because a label sits under it and the two share a centre, and with no
|
|
@@ -7418,9 +7839,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7418
7839
|
the text around it, the way the inline representation does.
|
|
7419
7840
|
|
|
7420
7841
|
`:empty` says exactly what the markup says, the way `.badge:empty` does, and
|
|
7421
|
-
it reads the item's own content: bare text
|
|
7422
|
-
|
|
7423
|
-
floor and skew the row. `:empty` also keeps the compact form reachable on the
|
|
7842
|
+
it reads the item's own content: bare text keeps the same authored floor as
|
|
7843
|
+
its wrapped peers. `:empty` also keeps the compact form reachable on the
|
|
7424
7844
|
Degraded tier, where only the inline bonus needs `:has()`. */
|
|
7425
7845
|
.steps-horizontal > li:empty {
|
|
7426
7846
|
flex: 1 1 0;
|
|
@@ -7430,6 +7850,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7430
7850
|
|
|
7431
7851
|
.steps-horizontal > li:empty::after {
|
|
7432
7852
|
inset-inline-start: var(--step-size);
|
|
7853
|
+
/* Marker-only end cells keep full shares, unlike labelled end cells. */
|
|
7854
|
+
inline-size: calc(100% - var(--step-size) + var(--step-gap));
|
|
7433
7855
|
}
|
|
7434
7856
|
|
|
7435
7857
|
.steps-horizontal > li:empty:last-child {
|
|
@@ -7437,12 +7859,12 @@ a.avatar[aria-disabled="true"] {
|
|
|
7437
7859
|
}
|
|
7438
7860
|
|
|
7439
7861
|
/* The pseudo-element is marker-height so --step-connector may be any complete
|
|
7440
|
-
background value. */
|
|
7862
|
+
background value; its length also crosses the gap to the next equal track. */
|
|
7441
7863
|
.steps-horizontal > li::after {
|
|
7442
7864
|
position: absolute;
|
|
7443
7865
|
inset-block-start: 0;
|
|
7444
7866
|
inset-inline-start: calc(50% + var(--step-size) / 2);
|
|
7445
|
-
inline-size: calc(100% - var(--step-size));
|
|
7867
|
+
inline-size: calc(100% - var(--step-size) + var(--step-gap));
|
|
7446
7868
|
block-size: var(--step-size);
|
|
7447
7869
|
background: var(
|
|
7448
7870
|
--step-connector,
|
|
@@ -7461,6 +7883,11 @@ a.avatar[aria-disabled="true"] {
|
|
|
7461
7883
|
* written — a row out of contract is not owed a curated fallback.
|
|
7462
7884
|
*/
|
|
7463
7885
|
@container actual-container (inline-size >= 60rem) {
|
|
7886
|
+
.steps-horizontal:has(.step-label) {
|
|
7887
|
+
/* Inline connectors already own the spacing between marker-label groups. */
|
|
7888
|
+
column-gap: 0;
|
|
7889
|
+
}
|
|
7890
|
+
|
|
7464
7891
|
.steps-horizontal:has(.step-label) > li {
|
|
7465
7892
|
display: flex;
|
|
7466
7893
|
flex: 1 1 auto;
|
|
@@ -7598,6 +8025,7 @@ a.avatar[aria-disabled="true"] {
|
|
|
7598
8025
|
}
|
|
7599
8026
|
|
|
7600
8027
|
.app-nav > a {
|
|
8028
|
+
position: relative;
|
|
7601
8029
|
display: grid;
|
|
7602
8030
|
grid-template-rows: auto auto;
|
|
7603
8031
|
place-content: center;
|
|
@@ -7638,20 +8066,27 @@ a.avatar[aria-disabled="true"] {
|
|
|
7638
8066
|
}
|
|
7639
8067
|
|
|
7640
8068
|
/* Current tile is already a full selected surface. Selection never changes
|
|
7641
|
-
text metrics: the shared medium weight is kept
|
|
7642
|
-
|
|
8069
|
+
text metrics: the shared medium weight is kept. The forced-colors cue is a
|
|
8070
|
+
transparent border on a ::before overlay, not on the tile: an element border
|
|
8071
|
+
shrinks the content box, and the side layout starts its content at the inline
|
|
8072
|
+
edge, so the icon and label shifted by --border-width when a tile became
|
|
8073
|
+
current. The overlay costs no layout, yet forced colors still turn its
|
|
8074
|
+
transparent border into a system-color frame once --state-selected flattens
|
|
8075
|
+
to the bar's own Canvas. No forced-colors repaint: a Highlight fill put
|
|
8076
|
+
HighlightText on Chromium's Canvas backplate, which hid the label. The
|
|
8077
|
+
positioning context is on every link so it does not move when one becomes
|
|
8078
|
+
current or an application places its own decoration. */
|
|
7643
8079
|
.app-nav > a[aria-current="page"] {
|
|
7644
8080
|
background: var(--state-selected);
|
|
7645
8081
|
color: var(--state-selected-fg);
|
|
7646
8082
|
}
|
|
7647
8083
|
|
|
7648
|
-
|
|
7649
|
-
|
|
7650
|
-
|
|
7651
|
-
|
|
7652
|
-
|
|
7653
|
-
|
|
7654
|
-
}
|
|
8084
|
+
.app-nav > a[aria-current="page"]::before {
|
|
8085
|
+
content: "";
|
|
8086
|
+
position: absolute;
|
|
8087
|
+
inset: 0;
|
|
8088
|
+
border: var(--border-width) solid transparent;
|
|
8089
|
+
border-radius: inherit;
|
|
7655
8090
|
}
|
|
7656
8091
|
|
|
7657
8092
|
/*
|
|
@@ -7663,6 +8098,9 @@ a.avatar[aria-disabled="true"] {
|
|
|
7663
8098
|
* links are a .cluster using .nav-link items. .nav-list is the vertical nav
|
|
7664
8099
|
* list shared with .drawer, fed by the shared --gap token.
|
|
7665
8100
|
*
|
|
8101
|
+
* Public hooks:
|
|
8102
|
+
* --state-selected-text (fallback-only: current-link ink, see tokens.css)
|
|
8103
|
+
*
|
|
7666
8104
|
* Internal:
|
|
7667
8105
|
* --gap (local value for the shared gap token)
|
|
7668
8106
|
*/
|
|
@@ -7727,8 +8165,7 @@ a.avatar[aria-disabled="true"] {
|
|
|
7727
8165
|
|
|
7728
8166
|
/* nav-link serves two contracts: plain page navigation (aria-current="page")
|
|
7729
8167
|
and scrollspy, which toggles aria-current="location" on the active link.
|
|
7730
|
-
|
|
7731
|
-
removed everywhere else, never serialized as aria-current="false".
|
|
8168
|
+
Those two values are the contract; any other, "false" included, is inert.
|
|
7732
8169
|
Selection never changes text metrics: the shared medium weight is kept and
|
|
7733
8170
|
the current link is carried by the selected accent. */
|
|
7734
8171
|
.nav-link:hover {
|
|
@@ -7739,9 +8176,9 @@ a.avatar[aria-disabled="true"] {
|
|
|
7739
8176
|
/* Current last, equal specificity to :hover (0,2,0): a hovered current link
|
|
7740
8177
|
keeps the selected accent instead of dropping to --text. The surface fill is
|
|
7741
8178
|
shared with the hover rule above. */
|
|
7742
|
-
.nav-link[aria-current] {
|
|
8179
|
+
.nav-link:is([aria-current="page"], [aria-current="location"]) {
|
|
7743
8180
|
background: var(--surface-subtle);
|
|
7744
|
-
color: var(--state-selected);
|
|
8181
|
+
color: var(--state-selected-text, var(--state-selected));
|
|
7745
8182
|
}
|
|
7746
8183
|
|
|
7747
8184
|
/* On a bar with its own --ui-bg/--ui-fg the fill is a tint of the link's own
|
|
@@ -7752,7 +8189,7 @@ a.avatar[aria-disabled="true"] {
|
|
|
7752
8189
|
above keeps the light chip where color-mix is missing, with ink that is
|
|
7753
8190
|
legible on it. */
|
|
7754
8191
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
7755
|
-
.nav-link:is(:hover, [aria-current]) {
|
|
8192
|
+
.nav-link:is(:hover, [aria-current="page"], [aria-current="location"]) {
|
|
7756
8193
|
background: color-mix(in oklch, currentColor 10%, transparent);
|
|
7757
8194
|
}
|
|
7758
8195
|
|
|
@@ -7761,8 +8198,8 @@ a.avatar[aria-disabled="true"] {
|
|
|
7761
8198
|
}
|
|
7762
8199
|
|
|
7763
8200
|
/* After :hover so a hovered current link keeps the selected accent. */
|
|
7764
|
-
.nav-link[aria-current] {
|
|
7765
|
-
color: var(--ui-fg, var(--state-selected));
|
|
8201
|
+
.nav-link:is([aria-current="page"], [aria-current="location"]) {
|
|
8202
|
+
color: var(--ui-fg, var(--state-selected-text, var(--state-selected)));
|
|
7766
8203
|
}
|
|
7767
8204
|
}
|
|
7768
8205
|
|
|
@@ -7795,13 +8232,16 @@ a.avatar[aria-disabled="true"] {
|
|
|
7795
8232
|
follows the forced palette without a forced-colors block and never touches
|
|
7796
8233
|
the row separators. Navbar horizontal links keep color + surface only. The
|
|
7797
8234
|
positioning context lives on every row link, so becoming current never moves
|
|
7798
|
-
an app-positioned decoration to a new containing block.
|
|
7799
|
-
|
|
8235
|
+
an app-positioned decoration to a new containing block.
|
|
8236
|
+
The trait sits centered in the link's inline padding with rounded ends: flush
|
|
8237
|
+
on the edge, a square line read as pasted onto the rounded fill. */
|
|
8238
|
+
.nav-list .nav-link:is([aria-current="page"], [aria-current="location"])::before {
|
|
7800
8239
|
content: "";
|
|
7801
8240
|
position: absolute;
|
|
7802
8241
|
inset-block: var(--space-20);
|
|
7803
|
-
inset-inline-start:
|
|
8242
|
+
inset-inline-start: calc((var(--space-20) - 2px) / 2);
|
|
7804
8243
|
border-inline-start: 2px solid currentColor;
|
|
8244
|
+
border-radius: var(--radius-full);
|
|
7805
8245
|
}
|
|
7806
8246
|
|
|
7807
8247
|
/*
|
|
@@ -7810,14 +8250,21 @@ a.avatar[aria-disabled="true"] {
|
|
|
7810
8250
|
* Docs: docs/pages/components/breadcrumb.md
|
|
7811
8251
|
*
|
|
7812
8252
|
* Inline list separated by "/". Mark the current page with `aria-current="page"`
|
|
7813
|
-
* either on the item or on the current link.
|
|
8253
|
+
* either on the item or on the current link. Ancestor labels sit in an <a> or a
|
|
8254
|
+
* <span>, the box that truncates.
|
|
8255
|
+
*
|
|
8256
|
+
* Breadcrumbs are a single navigation hierarchy. Wrapping makes hierarchy look
|
|
8257
|
+
* like separate rows and causes unstable header height, and scrolling hides the
|
|
8258
|
+
* root behind a scrollbar. A trail that does not fit truncates instead:
|
|
8259
|
+
* ancestors first, the current page last, each label behind an ellipsis.
|
|
7814
8260
|
*/
|
|
8261
|
+
|
|
7815
8262
|
.breadcrumb {
|
|
7816
8263
|
display: flex;
|
|
7817
|
-
flex-
|
|
7818
|
-
flex-wrap: wrap;
|
|
8264
|
+
flex-wrap: nowrap;
|
|
7819
8265
|
align-items: center;
|
|
7820
8266
|
gap: var(--space-10);
|
|
8267
|
+
min-inline-size: 0;
|
|
7821
8268
|
margin: 0;
|
|
7822
8269
|
padding: 0;
|
|
7823
8270
|
color: var(--text-subtle);
|
|
@@ -7825,15 +8272,74 @@ a.avatar[aria-disabled="true"] {
|
|
|
7825
8272
|
list-style: none;
|
|
7826
8273
|
}
|
|
7827
8274
|
|
|
7828
|
-
|
|
7829
|
-
|
|
8275
|
+
/* The landmark around the trail must shrink with it in a flex or grid row, or
|
|
8276
|
+
it keeps the trail's full width and the labels never get to truncate.
|
|
8277
|
+
Without :has() the landmark keeps its width; min-inline-size: 0 on the
|
|
8278
|
+
wrapper is the fallback. */
|
|
8279
|
+
:where(nav:has(> .breadcrumb)) {
|
|
8280
|
+
min-inline-size: 0;
|
|
8281
|
+
}
|
|
8282
|
+
|
|
8283
|
+
/* Grid, not flex: under a min-content constraint a grid track keeps its base
|
|
8284
|
+
size, which is the label's minimum below, so the item's own minimum is
|
|
8285
|
+
separator + label floor without anyone measuring a "/". A flex item would
|
|
8286
|
+
report the full label as its minimum and never shrink.
|
|
8287
|
+
|
|
8288
|
+
Ancestors take the shrink before the current page, so the location is the
|
|
8289
|
+
last thing to lose characters. The ratio is deliberately huge: at 1000 the
|
|
8290
|
+
current page still took a fraction of a pixel, enough to trigger its
|
|
8291
|
+
ellipsis while ancestors had room. Its own factor stays 1, because flex
|
|
8292
|
+
distributes only a share of the deficit when the unfrozen factors sum below
|
|
8293
|
+
one. Keyed on the last item, the current page by convention, so the order
|
|
8294
|
+
holds without :has(). */
|
|
8295
|
+
.breadcrumb > li {
|
|
8296
|
+
display: inline-grid;
|
|
8297
|
+
flex: 0 1000000 auto;
|
|
8298
|
+
grid-auto-flow: column;
|
|
7830
8299
|
align-items: center;
|
|
7831
|
-
|
|
8300
|
+
white-space: nowrap;
|
|
7832
8301
|
}
|
|
7833
8302
|
|
|
7834
|
-
.breadcrumb
|
|
7835
|
-
|
|
8303
|
+
.breadcrumb > li:last-child {
|
|
8304
|
+
flex-shrink: 1;
|
|
8305
|
+
}
|
|
8306
|
+
|
|
8307
|
+
/* A margin rather than a grid gap, so the separator keeps its space when the
|
|
8308
|
+
current page below lays it out inline. The weight stays normal there, where
|
|
8309
|
+
it would inherit the current page's. */
|
|
8310
|
+
.breadcrumb > li + li::before {
|
|
8311
|
+
margin-inline-end: var(--space-10);
|
|
7836
8312
|
color: var(--text-subtle);
|
|
8313
|
+
font-weight: var(--font-weight-normal);
|
|
8314
|
+
content: "/";
|
|
8315
|
+
}
|
|
8316
|
+
|
|
8317
|
+
/* The ellipsis lives on the label, not on the item: an element never clips
|
|
8318
|
+
its own outline, but an overflow-hidden item would clip its link's focus
|
|
8319
|
+
line, which sits outside the link box.
|
|
8320
|
+
|
|
8321
|
+
The floor keeps a truncated label readable as a step ("Po…") instead of
|
|
8322
|
+
collapsing to its separator. calc-size() lets a shorter label keep its own
|
|
8323
|
+
width; without it, the plain 2em floor leaves a label under 2em a little
|
|
8324
|
+
trailing room. */
|
|
8325
|
+
.breadcrumb > li > :is(a, span) {
|
|
8326
|
+
min-inline-size: 2em;
|
|
8327
|
+
min-inline-size: calc-size(max-content, min(size, 2em));
|
|
8328
|
+
overflow: hidden;
|
|
8329
|
+
text-overflow: ellipsis;
|
|
8330
|
+
}
|
|
8331
|
+
|
|
8332
|
+
/* The current page written as bare text has no label box, so its item
|
|
8333
|
+
truncates itself. Only with no element inside: an aria-current item that
|
|
8334
|
+
holds a link would clip that link's focus line, and keeps the label rule
|
|
8335
|
+
above instead. Its own rule, never merged with the one above: a browser
|
|
8336
|
+
without :has() drops it alone, and bare text then keeps its full width. */
|
|
8337
|
+
.breadcrumb > li[aria-current="page"]:not(:has(> *)) {
|
|
8338
|
+
display: block;
|
|
8339
|
+
min-inline-size: 2em;
|
|
8340
|
+
min-inline-size: calc-size(max-content, min(size, 2em));
|
|
8341
|
+
overflow: hidden;
|
|
8342
|
+
text-overflow: ellipsis;
|
|
7837
8343
|
}
|
|
7838
8344
|
|
|
7839
8345
|
.breadcrumb a {
|
|
@@ -7867,6 +8373,11 @@ a.avatar[aria-disabled="true"] {
|
|
|
7867
8373
|
* Truncated ranges are plain text (`.muted`, `aria-hidden="true"`), not .btn
|
|
7868
8374
|
* — they are decorative, not actionable. They still get the same box sizing
|
|
7869
8375
|
* as .btn below so the row stays visually aligned.
|
|
8376
|
+
*
|
|
8377
|
+
* The row wraps when it does not fit: every page stays visible and
|
|
8378
|
+
* clickable. A strip that scrolled hid pages behind a scrollbar that clicking
|
|
8379
|
+
* cannot move, since a click changes the page. Keeping the rendered range
|
|
8380
|
+
* compact is the application's job.
|
|
7870
8381
|
*/
|
|
7871
8382
|
.pagination {
|
|
7872
8383
|
display: flex;
|
|
@@ -7916,8 +8427,18 @@ a.avatar[aria-disabled="true"] {
|
|
|
7916
8427
|
* author class can then adjust a vertical strip without restating the
|
|
7917
8428
|
* attribute to reach its specificity.
|
|
7918
8429
|
*
|
|
8430
|
+
* A horizontal strip is one axis of navigation, so it never wraps: a second
|
|
8431
|
+
* line no longer matches the Left/Right model and reads as a nested level.
|
|
8432
|
+
* When the tabs do not fit, the strip scrolls without a scrollbar; focus
|
|
8433
|
+
* scrolls the active tab into view.
|
|
8434
|
+
*
|
|
7919
8435
|
* Public hooks:
|
|
8436
|
+
* --tab-flex
|
|
7920
8437
|
* --tab-gap
|
|
8438
|
+
*
|
|
8439
|
+
* Internal:
|
|
8440
|
+
* --tab-edge
|
|
8441
|
+
* --tab-fade-dir (read inside @keyframes, so it resolves per element)
|
|
7921
8442
|
*/
|
|
7922
8443
|
/* Intent boundary — inherited intents do not tint this component.
|
|
7923
8444
|
Local intent classes still win because :where() has zero specificity. */
|
|
@@ -7928,27 +8449,129 @@ a.avatar[aria-disabled="true"] {
|
|
|
7928
8449
|
.tabs {
|
|
7929
8450
|
display: flex;
|
|
7930
8451
|
flex-direction: row;
|
|
7931
|
-
flex-wrap:
|
|
8452
|
+
flex-wrap: nowrap;
|
|
7932
8453
|
gap: var(--space-10);
|
|
7933
8454
|
margin: 0;
|
|
7934
8455
|
padding: 0;
|
|
8456
|
+
/* Block overflow is hidden, not auto: overflow-x: auto alone computes the
|
|
8457
|
+
block axis to auto, so a sub-pixel line box would add a vertical
|
|
8458
|
+
scrollbar inside the strip. Nothing paints outside it: the focus outline
|
|
8459
|
+
is inset and the indicator sits inside the tab. */
|
|
8460
|
+
overflow: auto hidden;
|
|
8461
|
+
overscroll-behavior-inline: contain;
|
|
8462
|
+
/* why: a visible bar sits on the indicator and the hairline and reads as a
|
|
8463
|
+
second underline. The strip has its own scroll controls: selecting the
|
|
8464
|
+
clipped tab, or focusing it, brings it into view with the next one
|
|
8465
|
+
hinted by scroll-padding; arrow keys and swipes do the same. */
|
|
8466
|
+
scrollbar-width: none;
|
|
8467
|
+
/* Focus scrolls a tab in with room to spare, hinting at the next one; the
|
|
8468
|
+
edge fade below spans the same inset, so a revealed tab stays clear of it. */
|
|
8469
|
+
--tab-edge: var(--space-60);
|
|
8470
|
+
|
|
8471
|
+
scroll-padding-inline: var(--tab-edge);
|
|
7935
8472
|
border-block-end: var(--border-width) solid var(--border);
|
|
7936
8473
|
list-style: none;
|
|
7937
8474
|
}
|
|
7938
8475
|
|
|
8476
|
+
/* The landmark around link tabs is not a scroll container, so as a flex or
|
|
8477
|
+
grid item it would keep the strip's full width and push the page into
|
|
8478
|
+
scrolling. Letting it shrink hands the overflow back to the strip.
|
|
8479
|
+
Without :has() the landmark keeps its width; min-inline-size: 0 on the
|
|
8480
|
+
wrapper is the fallback. */
|
|
8481
|
+
:where(nav:has(> .tabs)) {
|
|
8482
|
+
min-inline-size: 0;
|
|
8483
|
+
}
|
|
8484
|
+
|
|
8485
|
+
/* Items keep their size and the strip scrolls instead. Covers both
|
|
8486
|
+
anatomies: .tab directly in a tablist, or <li> in a navigation list.
|
|
8487
|
+
--tab-flex: 1 1 0 shares the width equally; an item still never shrinks
|
|
8488
|
+
below its label (flex items keep min-content), so a strip that cannot fit
|
|
8489
|
+
scrolls as before. */
|
|
8490
|
+
.tabs > * {
|
|
8491
|
+
flex: var(--tab-flex, none);
|
|
8492
|
+
}
|
|
8493
|
+
|
|
7939
8494
|
.tabs:where([aria-orientation="vertical"]) {
|
|
7940
8495
|
display: grid;
|
|
8496
|
+
overflow: visible;
|
|
7941
8497
|
align-content: start;
|
|
7942
8498
|
justify-items: stretch;
|
|
7943
8499
|
border-block-end: 0;
|
|
7944
8500
|
border-inline-end: var(--border-width) solid var(--border);
|
|
7945
8501
|
}
|
|
7946
8502
|
|
|
8503
|
+
/* Edge fade: with no scrollbar, the strip fades the edges it can still scroll
|
|
8504
|
+
toward. It runs on the strip's own inline timeline, which is inactive on a
|
|
8505
|
+
strip that fits or a vertical rail, so those stay unmasked. Keep the
|
|
8506
|
+
@supports: without scroll timelines the animation would end at once and pin
|
|
8507
|
+
its last mask. It follows scroll, not time, so reduced motion keeps it. */
|
|
8508
|
+
@supports (animation-timeline: scroll()) {
|
|
8509
|
+
.tabs {
|
|
8510
|
+
--tab-fade-dir: to right;
|
|
8511
|
+
|
|
8512
|
+
animation: actual-tabs-fade linear both;
|
|
8513
|
+
animation-timeline: scroll(self inline);
|
|
8514
|
+
}
|
|
8515
|
+
|
|
8516
|
+
/* Gradient keywords are physical; :dir() follows the strip's own
|
|
8517
|
+
direction, so an LTR island in an RTL page keeps its fade on the right. */
|
|
8518
|
+
.tabs:dir(rtl) {
|
|
8519
|
+
--tab-fade-dir: to left;
|
|
8520
|
+
}
|
|
8521
|
+
}
|
|
8522
|
+
|
|
8523
|
+
/* A strip that loads on a current link tab starts scrolled to it. Horizontal
|
|
8524
|
+
only: the strip is then its nearest scroll container, while a vertical
|
|
8525
|
+
rail would hand the target to the page. Engines without the property start
|
|
8526
|
+
at the first tab, and the fade says there is more. */
|
|
8527
|
+
:where(.tabs:not([aria-orientation="vertical"]))
|
|
8528
|
+
.tab:is([aria-current="page"], [aria-current="true"]) {
|
|
8529
|
+
/* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
|
|
8530
|
+
scroll-initial-target: nearest;
|
|
8531
|
+
}
|
|
8532
|
+
|
|
8533
|
+
/* Forced colors keep every label at full contrast. */
|
|
8534
|
+
@media (forced-colors: active) {
|
|
8535
|
+
.tabs {
|
|
8536
|
+
animation: none;
|
|
8537
|
+
}
|
|
8538
|
+
}
|
|
8539
|
+
|
|
8540
|
+
/* mask-image does not interpolate, so each mask holds until halfway to the
|
|
8541
|
+
next keyframe: the start fade appears within the first pixels of scroll and
|
|
8542
|
+
the end fade drops at the very end. */
|
|
8543
|
+
@keyframes actual-tabs-fade {
|
|
8544
|
+
0% {
|
|
8545
|
+
mask-image: linear-gradient(
|
|
8546
|
+
var(--tab-fade-dir),
|
|
8547
|
+
#000 calc(100% - var(--tab-edge)),
|
|
8548
|
+
transparent
|
|
8549
|
+
);
|
|
8550
|
+
}
|
|
8551
|
+
|
|
8552
|
+
1%,
|
|
8553
|
+
99% {
|
|
8554
|
+
mask-image: linear-gradient(
|
|
8555
|
+
var(--tab-fade-dir),
|
|
8556
|
+
transparent,
|
|
8557
|
+
#000 var(--tab-edge) calc(100% - var(--tab-edge)),
|
|
8558
|
+
transparent
|
|
8559
|
+
);
|
|
8560
|
+
}
|
|
8561
|
+
|
|
8562
|
+
100% {
|
|
8563
|
+
mask-image: linear-gradient(var(--tab-fade-dir), transparent, #000 var(--tab-edge));
|
|
8564
|
+
}
|
|
8565
|
+
}
|
|
8566
|
+
|
|
7947
8567
|
.tab {
|
|
7948
8568
|
/* Positioning context for the selected indicator pseudo. */
|
|
7949
8569
|
position: relative;
|
|
7950
8570
|
display: inline-flex;
|
|
7951
8571
|
align-items: center;
|
|
8572
|
+
/* A tab wider than its label (--tab-flex) centers it; at natural width this
|
|
8573
|
+
changes nothing, and a vertical rail resets it to the start below. */
|
|
8574
|
+
justify-content: center;
|
|
7952
8575
|
gap: var(--tab-gap, 0.375em);
|
|
7953
8576
|
min-block-size: var(--control-size);
|
|
7954
8577
|
padding-inline: var(--space-30);
|
|
@@ -7962,12 +8585,18 @@ a.avatar[aria-disabled="true"] {
|
|
|
7962
8585
|
the strip. The active state is carried by color and the indicator line. */
|
|
7963
8586
|
font-weight: var(--font-weight-semibold);
|
|
7964
8587
|
text-decoration: none;
|
|
8588
|
+
/* A label breaking inside a one-line strip is the same defect as the strip
|
|
8589
|
+
wrapping. */
|
|
8590
|
+
white-space: nowrap;
|
|
7965
8591
|
cursor: pointer;
|
|
7966
8592
|
}
|
|
7967
8593
|
|
|
8594
|
+
/* A vertical strip has no inline axis to protect: long labels wrap within the
|
|
8595
|
+
rail instead of overflowing it. */
|
|
7968
8596
|
.tabs:where([aria-orientation="vertical"]) .tab {
|
|
7969
8597
|
justify-content: flex-start;
|
|
7970
8598
|
padding-inline-end: var(--space-40);
|
|
8599
|
+
white-space: normal;
|
|
7971
8600
|
}
|
|
7972
8601
|
|
|
7973
8602
|
.tab:is(:disabled, [aria-disabled="true"]) {
|
|
@@ -7998,9 +8627,14 @@ a.avatar[aria-disabled="true"] {
|
|
|
7998
8627
|
border-end-end-radius: 0;
|
|
7999
8628
|
}
|
|
8000
8629
|
|
|
8630
|
+
/* A link tab is current on aria-current="page". "true" is for the one place
|
|
8631
|
+
where no page token fits: a navigation flyout trigger whose panel holds the
|
|
8632
|
+
current page. State selectors name their values, never the attribute's
|
|
8633
|
+
presence, so "false" (what React and Vue serialize for a false binding)
|
|
8634
|
+
stays inert. */
|
|
8001
8635
|
.tab[aria-selected="true"],
|
|
8002
|
-
.tab[aria-current="page"] {
|
|
8003
|
-
color: var(--intent, var(--state-selected));
|
|
8636
|
+
.tab:is([aria-current="page"], [aria-current="true"]) {
|
|
8637
|
+
color: var(--intent, var(--state-selected-text, var(--state-selected)));
|
|
8004
8638
|
}
|
|
8005
8639
|
|
|
8006
8640
|
/* The selected indicator lives on a positioned pseudo so that only the active
|
|
@@ -8008,7 +8642,7 @@ a.avatar[aria-disabled="true"] {
|
|
|
8008
8642
|
forced colors and erase the distinction. currentColor follows the palette the
|
|
8009
8643
|
UA forces, so no forced-colors block is needed. */
|
|
8010
8644
|
.tab[aria-selected="true"]::after,
|
|
8011
|
-
.tab[aria-current="page"]::after {
|
|
8645
|
+
.tab:is([aria-current="page"], [aria-current="true"])::after {
|
|
8012
8646
|
content: "";
|
|
8013
8647
|
position: absolute;
|
|
8014
8648
|
inset-inline: 0;
|
|
@@ -8017,13 +8651,207 @@ a.avatar[aria-disabled="true"] {
|
|
|
8017
8651
|
}
|
|
8018
8652
|
|
|
8019
8653
|
.tabs:where([aria-orientation="vertical"]) .tab[aria-selected="true"]::after,
|
|
8020
|
-
.tabs:where([aria-orientation="vertical"])
|
|
8654
|
+
.tabs:where([aria-orientation="vertical"])
|
|
8655
|
+
.tab:is([aria-current="page"], [aria-current="true"])::after {
|
|
8021
8656
|
inset-block: 0;
|
|
8022
8657
|
inset-inline: auto 0;
|
|
8023
8658
|
border-block-end: 0;
|
|
8024
8659
|
border-inline-end: 2px solid currentColor;
|
|
8025
8660
|
}
|
|
8026
8661
|
|
|
8662
|
+
/* A shared width (--tab-flex) lands on the <li> of a link tab; the tab fills
|
|
8663
|
+
it, so the indicator and the hit area span the whole share. */
|
|
8664
|
+
.tabs > li > .tab {
|
|
8665
|
+
inline-size: 100%;
|
|
8666
|
+
}
|
|
8667
|
+
|
|
8668
|
+
/*
|
|
8669
|
+
* Segmented control — one compact choice among a few, on a shared track.
|
|
8670
|
+
*
|
|
8671
|
+
* Docs: docs/pages/components/segmented.md
|
|
8672
|
+
*
|
|
8673
|
+
* .segmented is the track, each .segment a direct child. The look is shared;
|
|
8674
|
+
* the semantics stay with the markup, and the selected state is read from it:
|
|
8675
|
+
* radios <label class="segment"><input type="radio"> :checked
|
|
8676
|
+
* tabs <button class="segment" role="tab"> aria-selected="true"
|
|
8677
|
+
* links <a class="segment"> aria-current="page"
|
|
8678
|
+
* No script of its own: radios bring their arrow keys, a tablist takes
|
|
8679
|
+
* data-enhance="tabs". Without :has() the radio anatomy loses its selected
|
|
8680
|
+
* fill and focus ring, and the native radio stays visible to carry both.
|
|
8681
|
+
*
|
|
8682
|
+
* One row of equal shares (grid-auto-columns: 1fr), at least --control-size
|
|
8683
|
+
* tall so it lines up with .btn and .input. Labels wrap instead of truncating;
|
|
8684
|
+
* a word longer than its share breaks, so the control never overflows its
|
|
8685
|
+
* container. A strip too long for one row is .tabs, which scrolls.
|
|
8686
|
+
*
|
|
8687
|
+
* Focus is drawn inside the segment, unlike other discrete actions
|
|
8688
|
+
* (focus.css): an offset ring would cross the track's padding and cover the
|
|
8689
|
+
* neighbouring segment.
|
|
8690
|
+
*
|
|
8691
|
+
* Public hooks:
|
|
8692
|
+
* --segmented-bg
|
|
8693
|
+
* --segmented-pad
|
|
8694
|
+
* --segmented-radius
|
|
8695
|
+
* --segmented-fg
|
|
8696
|
+
* --segmented-selected-bg
|
|
8697
|
+
* --segmented-selected-border
|
|
8698
|
+
*/
|
|
8699
|
+
.segmented {
|
|
8700
|
+
--segmented-bg: var(--surface-subtle);
|
|
8701
|
+
--segmented-pad: var(--space-10);
|
|
8702
|
+
--segmented-radius: var(--radius);
|
|
8703
|
+
--segmented-fg: var(--text-muted);
|
|
8704
|
+
--segmented-selected-bg: var(--surface-raised);
|
|
8705
|
+
/* The edge is what separates the selected segment where --surface-raised
|
|
8706
|
+
is darker than --surface-subtle (the default dark palette). */
|
|
8707
|
+
--segmented-selected-border: var(--border);
|
|
8708
|
+
|
|
8709
|
+
display: inline-grid;
|
|
8710
|
+
grid-auto-flow: column;
|
|
8711
|
+
grid-auto-columns: 1fr;
|
|
8712
|
+
gap: var(--segmented-pad);
|
|
8713
|
+
/* why: margin and border also reset <fieldset>, the radio anatomy's
|
|
8714
|
+
element. The border is transparent, not 0: forced colors paint it, which
|
|
8715
|
+
outlines the track once its fill is gone. */
|
|
8716
|
+
margin: 0;
|
|
8717
|
+
padding: var(--segmented-pad);
|
|
8718
|
+
border: var(--border-width) solid transparent;
|
|
8719
|
+
border-radius: var(--segmented-radius);
|
|
8720
|
+
background: var(--segmented-bg);
|
|
8721
|
+
font-size: var(--control-font-size);
|
|
8722
|
+
vertical-align: middle;
|
|
8723
|
+
}
|
|
8724
|
+
|
|
8725
|
+
.segment {
|
|
8726
|
+
position: relative;
|
|
8727
|
+
display: flex;
|
|
8728
|
+
align-items: center;
|
|
8729
|
+
justify-content: center;
|
|
8730
|
+
gap: 0.375em;
|
|
8731
|
+
/* The track is --control-size tall through its segments, not through its
|
|
8732
|
+
own min-block-size: a <fieldset> lays its content in an anonymous box
|
|
8733
|
+
sized to the content, so a minimum on the track left the segments short.
|
|
8734
|
+
A minimum, not a height: a wrapped label grows the whole row. */
|
|
8735
|
+
min-block-size: calc(var(--control-size) - 2 * (var(--segmented-pad) + var(--border-width)));
|
|
8736
|
+
padding: calc(var(--space-10) / 2) var(--space-30);
|
|
8737
|
+
border: 0;
|
|
8738
|
+
border-radius: calc(var(--segmented-radius) - var(--segmented-pad));
|
|
8739
|
+
background: transparent;
|
|
8740
|
+
color: var(--segmented-fg);
|
|
8741
|
+
font: inherit;
|
|
8742
|
+
/* Constant across states: a heavier selected label would resize its share. */
|
|
8743
|
+
font-weight: var(--font-weight-medium);
|
|
8744
|
+
line-height: 1.2;
|
|
8745
|
+
text-align: center;
|
|
8746
|
+
text-decoration: none;
|
|
8747
|
+
/* why: also shrinks the min-content contribution, so 1fr keeps equal
|
|
8748
|
+
shares under constraint instead of widening one around a long word. */
|
|
8749
|
+
overflow-wrap: anywhere;
|
|
8750
|
+
cursor: pointer;
|
|
8751
|
+
transition:
|
|
8752
|
+
background-color var(--duration),
|
|
8753
|
+
color var(--duration),
|
|
8754
|
+
box-shadow var(--duration);
|
|
8755
|
+
}
|
|
8756
|
+
|
|
8757
|
+
.segment > input {
|
|
8758
|
+
margin: 0;
|
|
8759
|
+
}
|
|
8760
|
+
|
|
8761
|
+
/* The radio is visually hidden but stays focusable, submitted and arrow-key
|
|
8762
|
+
navigable (sr-only pattern). Guarded: without :has() the segment cannot
|
|
8763
|
+
mirror :checked, so the visible native radio is the fallback. */
|
|
8764
|
+
@supports selector(:has(*)) {
|
|
8765
|
+
.segment > input {
|
|
8766
|
+
position: absolute;
|
|
8767
|
+
inline-size: 1px;
|
|
8768
|
+
block-size: 1px;
|
|
8769
|
+
padding: 0;
|
|
8770
|
+
margin: -1px;
|
|
8771
|
+
overflow: hidden;
|
|
8772
|
+
clip: rect(0, 0, 0, 0);
|
|
8773
|
+
white-space: nowrap;
|
|
8774
|
+
border: 0;
|
|
8775
|
+
}
|
|
8776
|
+
}
|
|
8777
|
+
|
|
8778
|
+
.segment:hover {
|
|
8779
|
+
color: var(--text);
|
|
8780
|
+
}
|
|
8781
|
+
|
|
8782
|
+
/* Separate rules from the :has() forms below: an engine without :has() would
|
|
8783
|
+
discard a shared selector list, ARIA states included. */
|
|
8784
|
+
.segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
|
|
8785
|
+
background: var(--segmented-selected-bg);
|
|
8786
|
+
color: var(--text);
|
|
8787
|
+
box-shadow:
|
|
8788
|
+
0 0 0 var(--border-width) var(--segmented-selected-border),
|
|
8789
|
+
var(--shadow);
|
|
8790
|
+
}
|
|
8791
|
+
|
|
8792
|
+
.segment:has(> input:checked) {
|
|
8793
|
+
background: var(--segmented-selected-bg);
|
|
8794
|
+
color: var(--text);
|
|
8795
|
+
box-shadow:
|
|
8796
|
+
0 0 0 var(--border-width) var(--segmented-selected-border),
|
|
8797
|
+
var(--shadow);
|
|
8798
|
+
}
|
|
8799
|
+
|
|
8800
|
+
.segment:focus-visible {
|
|
8801
|
+
outline: var(--focus-ring-width) solid var(--focus);
|
|
8802
|
+
outline-offset: calc(var(--focus-ring-width) * -1);
|
|
8803
|
+
}
|
|
8804
|
+
|
|
8805
|
+
.segment:has(> input:focus-visible) {
|
|
8806
|
+
outline: var(--focus-ring-width) solid var(--focus);
|
|
8807
|
+
outline-offset: calc(var(--focus-ring-width) * -1);
|
|
8808
|
+
}
|
|
8809
|
+
|
|
8810
|
+
.segment:is(:disabled, [aria-disabled="true"]) {
|
|
8811
|
+
opacity: var(--disabled-opacity);
|
|
8812
|
+
cursor: not-allowed;
|
|
8813
|
+
}
|
|
8814
|
+
|
|
8815
|
+
.segment:has(> input:disabled) {
|
|
8816
|
+
opacity: var(--disabled-opacity);
|
|
8817
|
+
cursor: not-allowed;
|
|
8818
|
+
}
|
|
8819
|
+
|
|
8820
|
+
/* Forced colors drop the raised fill and its edge. The selected segment
|
|
8821
|
+
takes the system highlight — a background, so the outline stays the focus
|
|
8822
|
+
channel alone. forced-color-adjust: none is required, not cosmetic: the UA
|
|
8823
|
+
otherwise paints a Canvas backplate behind the label, which hid the text on
|
|
8824
|
+
the highlight. Opting out also stops the forcing of the rest, so the shadow
|
|
8825
|
+
is dropped here and the focus line takes the highlight's text color. */
|
|
8826
|
+
@media (forced-colors: active) {
|
|
8827
|
+
.segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
|
|
8828
|
+
--focus: HighlightText;
|
|
8829
|
+
|
|
8830
|
+
forced-color-adjust: none;
|
|
8831
|
+
background: Highlight;
|
|
8832
|
+
color: HighlightText;
|
|
8833
|
+
box-shadow: none;
|
|
8834
|
+
}
|
|
8835
|
+
|
|
8836
|
+
.segment:has(> input:checked) {
|
|
8837
|
+
--focus: HighlightText;
|
|
8838
|
+
|
|
8839
|
+
forced-color-adjust: none;
|
|
8840
|
+
background: Highlight;
|
|
8841
|
+
color: HighlightText;
|
|
8842
|
+
box-shadow: none;
|
|
8843
|
+
}
|
|
8844
|
+
|
|
8845
|
+
/* --disabled-opacity is 1 here (tokens.css): the dimming needs a color. */
|
|
8846
|
+
.segment:is(:disabled, [aria-disabled="true"]) {
|
|
8847
|
+
color: GrayText;
|
|
8848
|
+
}
|
|
8849
|
+
|
|
8850
|
+
.segment:has(> input:disabled) {
|
|
8851
|
+
color: GrayText;
|
|
8852
|
+
}
|
|
8853
|
+
}
|
|
8854
|
+
|
|
8027
8855
|
/*
|
|
8028
8856
|
* Scrollspy — behavior hook for navigation that marks the active section
|
|
8029
8857
|
* while the page scrolls.
|
|
@@ -8034,7 +8862,7 @@ a.avatar[aria-disabled="true"] {
|
|
|
8034
8862
|
* <nav class="scrollspy">
|
|
8035
8863
|
* containing <a href="#section"> links and toggles aria-current="location" on
|
|
8036
8864
|
* the link whose target is in view. Pair with .nav-list and .nav-link, or style
|
|
8037
|
-
* [aria-current] yourself.
|
|
8865
|
+
* [aria-current="location"] yourself.
|
|
8038
8866
|
*
|
|
8039
8867
|
* Progressive enhancement: on browsers that support :target-current,
|
|
8040
8868
|
* this file enables the native target group and marks the active link.
|
|
@@ -8149,9 +8977,13 @@ a.avatar[aria-disabled="true"] {
|
|
|
8149
8977
|
.list > :where(.list-item),
|
|
8150
8978
|
.list > li > .list-item {
|
|
8151
8979
|
display: flex;
|
|
8152
|
-
/*
|
|
8153
|
-
|
|
8154
|
-
|
|
8980
|
+
/* A trailing control that no longer fits beside the content's longest word
|
|
8981
|
+
moves to its own line instead of breaking that word. */
|
|
8982
|
+
flex-wrap: wrap;
|
|
8983
|
+
/* Centered fits the short rows lists mostly hold. Read through
|
|
8984
|
+
--items-align so .items-start on the row or the list wins without
|
|
8985
|
+
outranking this selector — the opt-out for long supporting text. */
|
|
8986
|
+
align-items: var(--items-align, center);
|
|
8155
8987
|
gap: var(--list-item-gap);
|
|
8156
8988
|
min-block-size: var(--list-item-min-size);
|
|
8157
8989
|
padding-block: var(--list-item-pad-block);
|
|
@@ -8173,21 +9005,20 @@ a.list-item:hover {
|
|
|
8173
9005
|
background: var(--surface-subtle);
|
|
8174
9006
|
}
|
|
8175
9007
|
|
|
8176
|
-
/* Current row in a navigational list
|
|
8177
|
-
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
…) stays free for assistive technology.
|
|
9008
|
+
/* Current row in a navigational list: aria-current="page" when the row opens
|
|
9009
|
+
a page, "true" when it is the current item of a master/detail view (a
|
|
9010
|
+
conversation, a thread, a record) that is no page of its own. Any other
|
|
9011
|
+
value, "false" included, is inert.
|
|
8181
9012
|
Calm by design: the row keeps its normal text metrics and color on a subtle
|
|
8182
9013
|
surface, and the selected accent lives on an inset inline-start trait only.
|
|
8183
9014
|
The trait is a real border, so it survives forced colors without a repaint
|
|
8184
9015
|
block. The positioning context lives on every row link, so becoming current
|
|
8185
9016
|
never moves an app-positioned decoration to a new containing block. */
|
|
8186
|
-
a.list-item[aria-current] {
|
|
9017
|
+
a.list-item:is([aria-current="page"], [aria-current="true"]) {
|
|
8187
9018
|
background: var(--surface-subtle);
|
|
8188
9019
|
}
|
|
8189
9020
|
|
|
8190
|
-
a.list-item[aria-current]::before {
|
|
9021
|
+
a.list-item:is([aria-current="page"], [aria-current="true"])::before {
|
|
8191
9022
|
content: "";
|
|
8192
9023
|
position: absolute;
|
|
8193
9024
|
inset-block: var(--list-item-pad-block);
|
|
@@ -8195,17 +9026,29 @@ a.list-item[aria-current]::before {
|
|
|
8195
9026
|
border-inline-start: 2px solid var(--state-selected);
|
|
8196
9027
|
}
|
|
8197
9028
|
|
|
8198
|
-
/* The content region is the one that flexes, whichever position it holds.
|
|
9029
|
+
/* The content region is the one that flexes, whichever position it holds.
|
|
9030
|
+
Its automatic minimum is its longest word, which is what wraps the row; the
|
|
9031
|
+
max size caps that minimum at the row, so a long token breaks rather than
|
|
9032
|
+
widening the page. A zero minimum shrank it until words broke mid-way. */
|
|
8199
9033
|
.list-item-content {
|
|
8200
9034
|
flex: 1 1 0;
|
|
8201
|
-
|
|
9035
|
+
max-inline-size: 100%;
|
|
8202
9036
|
}
|
|
8203
9037
|
|
|
9038
|
+
/* break-word, not anywhere: anywhere also lowers the min-content to a single
|
|
9039
|
+
character, and the row would never wrap. */
|
|
8204
9040
|
.list-item-title,
|
|
8205
9041
|
.list-item-text {
|
|
8206
9042
|
display: block;
|
|
8207
9043
|
margin: 0;
|
|
8208
|
-
overflow-wrap:
|
|
9044
|
+
overflow-wrap: break-word;
|
|
9045
|
+
}
|
|
9046
|
+
|
|
9047
|
+
/* Truncated text gives up its width by design: without containment its
|
|
9048
|
+
unbreakable line would become the content's minimum and push the trailing
|
|
9049
|
+
region onto a line of its own. */
|
|
9050
|
+
.list-item-content .truncate {
|
|
9051
|
+
contain: inline-size;
|
|
8209
9052
|
}
|
|
8210
9053
|
|
|
8211
9054
|
.list-item-text {
|
|
@@ -8456,7 +9299,7 @@ a.list-item[aria-current]::before {
|
|
|
8456
9299
|
}
|
|
8457
9300
|
|
|
8458
9301
|
.join-addon {
|
|
8459
|
-
--control-border: var(--border);
|
|
9302
|
+
--control-border: var(--border-control);
|
|
8460
9303
|
--control-bg: var(--surface-subtle);
|
|
8461
9304
|
--control-fg: var(--text-muted);
|
|
8462
9305
|
|
|
@@ -8561,7 +9404,7 @@ a.list-item[aria-current]::before {
|
|
|
8561
9404
|
The second :has() covers a control nested one wrapper level deep. */
|
|
8562
9405
|
.join:has(> :is(.input, .textarea, .select)) > .btn.outline,
|
|
8563
9406
|
.join:has(> * > :is(.input, .textarea, .select)) > .btn.outline {
|
|
8564
|
-
--btn-border-color: var(--intent, var(--border));
|
|
9407
|
+
--btn-border-color: var(--intent, var(--border-control));
|
|
8565
9408
|
}
|
|
8566
9409
|
|
|
8567
9410
|
/* Effects — purely visual enrichments. */
|
|
@@ -9095,9 +9938,9 @@ a.list-item[aria-current]::before {
|
|
|
9095
9938
|
flex-wrap: nowrap;
|
|
9096
9939
|
}
|
|
9097
9940
|
|
|
9098
|
-
/* The property is written directly,
|
|
9099
|
-
a
|
|
9100
|
-
|
|
9941
|
+
/* The property is written directly, so the utility works on any flex row, not
|
|
9942
|
+
only a .cluster. Utilities load last, so the direct declaration beats
|
|
9943
|
+
.cluster at equal specificity. */
|
|
9101
9944
|
.justify-content-start {
|
|
9102
9945
|
justify-content: flex-start;
|
|
9103
9946
|
}
|