Merge branch 'worktree-agent-ab53265ffbd837742'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:21:01 +02:00
23 changed files with 572 additions and 195 deletions
+36 -12
View File
@@ -4,10 +4,11 @@
*
* small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px
* from the start or straight after the navigation icon
* center the same, the title centred
* center the same, the title centred — a three-column grid, so it shrinks against whatever is
* beside it rather than ellipsising underneath it
* medium 112px (136px with a subtitle): the 64px row of icons over a headline-medium title
* large 120px (152px with a subtitle): a display-small title
* search the row holds a search bar
* search the row holds a search bar, full width to 312px and then half of what is left
*
* The container is the surface, and surface-container once content scrolls under it. A medium or
* large bar collapses into the small one without script moving anything: the bar is sticky at a
@@ -118,21 +119,44 @@
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
/* Centred: the headline spans the row and centres its text between whatever is at either end. */
[data-app-bar][data-variant="center"] [data-app-bar-headline] {
position: absolute;
inset-inline: 3.5rem;
top: 0;
bottom: 0;
align-items: center;
text-align: center;
pointer-events: none;
/* Centred: a three-column row — leading, headline, trailing — so the headline is centred in the
window and shrinks against whatever is actually beside it. M3 allows two trailing icon
buttons, which a fixed inset for one would have let a long title ellipsise underneath. */
[data-app-bar][data-variant="center"] [data-app-bar-row] {
display: grid;
grid-template-columns: 1fr auto 1fr;
}
/* The search bar fills the row. */
[data-app-bar][data-variant="center"] [data-app-bar-leading] {
grid-column: 1;
justify-self: start;
}
[data-app-bar][data-variant="center"] [data-app-bar-headline] {
grid-column: 2;
min-width: 0;
align-items: center;
justify-self: center;
padding-inline: 0.75rem;
text-align: center;
}
[data-app-bar][data-variant="center"] [data-app-bar-trailing] {
grid-column: 3;
justify-self: end;
margin-inline-start: 0;
}
/* The search bar fills the space between the leading and trailing elements until it is 312dp
wide, and then grows into only half of what is left
(docs/reference/m3/components-navigation-selection-inputs.md § Top app bar). The percentage
is of the row rather than of that space, which CSS cannot name without measuring it; below
312px the cap never binds, so a phone still gets the whole row. */
[data-app-bar-search] {
min-width: 0;
max-width: calc(19.5rem + (100% - 19.5rem) / 2);
flex: 1 1 0%;
margin-inline: auto;
padding-inline: 0.25rem;
}
+90 -4
View File
@@ -41,6 +41,9 @@
* rail in the layout rather than one that opens over a scrim; 75rem (1200px) is `large`, where the
* rail starts expanded instead of collapsed. The bar's own item layout is a *container* query at
* the same 37.5rem, so a bar in a narrow column lays out by its own width.
*
* Everything here is in `@layer components` except the last block, which restyles a FAB the
* application put in the rail: that one has to beat a utility, and a layer never does.
*/
@custom-variant rail-collapsed {
@@ -56,6 +59,14 @@
@slot;
}
/* Below `medium` a 220360px rail would be most of the screen, so a rail whose width is the
visitor's choice is held collapsed there whatever they chose. */
@media (width < 37.5rem) {
&:where([data-navigation-rail='collapsible'], [data-navigation-rail='collapsible'] *) {
@slot;
}
}
@media (width < 52.5rem) {
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
@slot;
@@ -157,14 +168,16 @@
min-width: max-content;
}
/* The label stays label-medium: NavigationBarTokens.LabelTextFont is the bar's only label
token, and Compose's ShortNavigationBarItem passes it for both icon positions. (The
*rail's* horizontal item is label-large — NavigationRailHorizontalItemTokens — which is
a different component.) */
[data-navigation-bar-item] [data-navigation-pill] {
position: relative;
isolation: isolate;
flex-direction: row;
height: 2.5rem;
padding-inline: 1rem;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-navigation-bar-item] [data-navigation-label] {
@@ -210,16 +223,27 @@
[data-navigation-rail='adaptive'] {
width: 0;
}
/* "Compact → don't use a standard rail, use a navigation bar." A `collapsible` rail takes
the width the visitor chose, which on a 360px screen would be two-thirds of it, so it
is floored at the collapsed 96px. `collapsed` and `expanded` mean what they say and are
left alone: wrap one in a `medium:` element if it must not show on a phone. */
[data-navigation-rail='collapsible'] {
width: 6rem;
}
}
/* `--material-banner` is the height of a bar pinned across the top of the window — what
<x-app-shell>'s `banner` slot holds, if the application made it sticky. The rail sticks
under it rather than behind it; 0 without one. */
[data-navigation-rail-panel] {
position: sticky;
top: 0;
top: var(--material-banner, 0px);
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: 100dvh;
max-height: calc(100dvh - var(--material-banner, 0px));
/* Clip, not hide: no scroll container, so the destinations below can still scroll and
nothing sticky breaks. What only an expanded rail draws — a label, the brand — is drawn
at once when the rail expands, while the width is still growing; the clip keeps it
@@ -233,6 +257,18 @@
background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
/* M3's two optional container treatments: "optional vertical divider separates rail from
content, placed on the content-adjacent edge" — also its answer to a page scrolling under a
fixed rail — and "container fill can be turned off (transparent) as long as items keep ≥3:1
contrast". Neither applies to a rail open over a scrim, which is a surface over the page. */
[data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {
border-inline-end: 1px solid var(--md-sys-color-outline-variant);
}
[data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {
background-color: transparent;
}
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
[data-navigation-rail][data-open] > [data-navigation-rail-panel] {
position: fixed;
@@ -535,6 +571,14 @@
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
/* A navigation item's state layer is on-secondary-container, active or not: the only
state-layer tokens M3 states for one are NavigationRailColorTokens', which give that colour
to all six. The rail follows them two blocks below; the bar follows them here, over its
secondary-container pill. */
[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {
background-color: var(--md-sys-color-on-secondary-container);
}
@container (width < 37.5rem) {
[data-navigation-bar-item] [data-navigation-pill]::before {
display: none;
@@ -601,3 +645,45 @@
}
}
}
/* A FAB in the rail's header rests at elevation 0, not the 3 a standalone FAB has
* (docs/reference/m3/components-navigation-selection-inputs.md § Navigation rail: "when nested
* within another component, such as the navigation rail, the FAB's resting elevation should be
* level 0"). Unlayered on purpose: the FAB draws its shadow with a utility, and a rule in any
* layer loses to a utility — the same reason toolbar.css gives for its vibrant recolouring. */
[data-navigation-rail-header] [data-fab],
[data-navigation-rail-header] [data-fab]:hover {
box-shadow: none;
}
/* "Rail transitions from collapsed to expanded should animate the FAB into and out of an extended
* FAB" — so the header takes one <x-fab label icon> and morphs it, rather than swapping two by
* `display`, which showed the collapsed one at once while the rail's width was still springing.
* The label's width springs on the rail's own spatial spring; with the gap closed and the extended
* FAB's own minimum width off, `aspect-ratio` squares what is left against the height its size
* already sets — 56, 80 or 96px, the three FAB sizes — so no number is repeated here. The label
* stays in the accessibility tree at both widths, so the FAB keeps its name. Unlayered for the
* same reason as the rule above: `gap-2` and `min-w-20` are utilities, and a layer would lose to
* them. The cap is there because a transition needs a length to travel to; a rail FAB's label is a
* word or two. */
[data-navigation-rail-header] [data-fab] > span {
max-width: 16rem;
overflow: hidden;
white-space: nowrap;
transition:
max-width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default),
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
@variant rail-collapsed {
max-width: 0;
opacity: 0;
}
}
[data-navigation-rail-header] [data-fab] {
@variant rail-collapsed {
min-width: 0;
aspect-ratio: 1;
gap: 0;
}
}
+33 -7
View File
@@ -6,20 +6,38 @@
*
* The active indicator is drawn in every tab and shown under the chosen one, so it is right before
* Alpine starts and after a morph: 3px with rounded top corners under the content of a primary tab
* (at least 24px wide), 2px across the whole of a secondary one. When the choice changes, a view
* transition moves it from the old tab to the new (resources/js/tabs.js), as M3's slides.
* (at least 24px wide, inset 2px at each side), 2px across the whole of a secondary one. When the
* choice changes, a view transition moves it from the old tab to the new (resources/js/tabs.js),
* as M3's slides.
*
* The bar scrolls sideways, so it clips whatever leaves it — including the focus ring, which the
* rest of the package draws 3px thick 2px outside the element (tokens/state.css). `--tabs-ring`
* is the room that takes: the bar carries it as padding on every side, and the state layer and the
* indicator reach back out into it, so the divider still sits against the tabs and the indicator
* still sits on the divider. That makes the bar 10px taller than M3's 48px tab.
*/
@layer components {
[data-tabs-bar] {
--tabs-ring: 5px;
position: relative;
display: flex;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: var(--tabs-ring);
scroll-padding-inline: var(--tabs-ring);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
scrollbar-width: thin;
}
/* "Offset the first scrollable tab 52dp from the leading edge so it's clear that more content
is available" (docs/reference/m3/components-navigation-selection-inputs.md § Tabs). Logical,
so a right-to-left page mirrors it. */
[data-tabs-bar][data-scrollable] {
padding-inline-start: 3.25rem;
scroll-padding-inline-start: 3.25rem;
}
/* A section nav's links sit in list items that share the width. */
[data-tabs-bar] > li {
flex: 1 1 0%;
@@ -47,6 +65,7 @@
transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-tabs-bar][data-scrollable] > li,
[data-tabs-bar][data-scrollable] [data-tab] {
flex: none;
}
@@ -55,10 +74,12 @@
height: 4rem;
}
/* The state layer covers the tab and the ring's room around it, so the wash meets the divider. */
[data-tab]::before {
content: "";
position: absolute;
inset: 0;
inset-block: calc(-1 * var(--tabs-ring));
inset-inline: 0;
pointer-events: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@@ -81,10 +102,12 @@
[data-tab]:focus-visible {
color: var(--md-sys-color-on-surface);
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
outline-offset: 2px;
}
[data-tab][aria-selected="true"] {
/* A link-based bar marks the page with aria-current, not aria-selected; both are the chosen
tab, and both take the variant's active colour. */
[data-tab]:is([aria-selected="true"], [aria-current="page"]) {
color: var(--md-sys-color-primary);
}
@@ -116,10 +139,12 @@
gap: 0.125rem;
}
/* Primary indicators are inset 2dp at each side; both variants sit on the divider, which the
ring's room has pushed below the tab. */
[data-tab-indicator] {
position: absolute;
inset-inline: 0;
bottom: 0;
inset-inline: 2px;
bottom: calc(-1 * var(--tabs-ring));
height: 3px;
border-radius: 3px 3px 0 0;
background-color: var(--md-sys-color-primary);
@@ -134,6 +159,7 @@
}
[data-tabs-bar][data-variant="secondary"] [data-tab-indicator] {
inset-inline: 0;
height: 2px;
border-radius: 0;
}
+31 -5
View File
@@ -7,8 +7,12 @@
* floating a 64px pill in surface-container (or primary-container, `vibrant`) at elevation 3,
* 8px at its ends and 4px between controls; `vertical` stands it on end
*
* Unlayered on purpose: a vibrant toolbar recolours the icon buttons inside it, which draw their
* ink as utilities, and a rule in any layer loses to a utility.
* Placed over the page, a horizontal toolbar keeps 16dp from the window's edge and a vertical one
* 24dp, which is M3's minimum for each.
*
* Unlayered on purpose: a toolbar recolours the icon buttons inside it — primary in a standard
* one, on-primary-container in a vibrant one — and they draw their ink as utilities, which a rule
* in any layer loses to.
*/
[data-toolbar] {
@@ -60,6 +64,13 @@
color: var(--md-sys-color-on-surface);
}
/* M3's colour list for a standard toolbar ends "Standard button (Primary)", as the vibrant list
ends "Standard button (On primary container)" — the row above. An icon button carries its own
ink as a utility, so the container's `color` never reaches it and this rule has to. */
[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) {
color: var(--md-sys-color-primary);
}
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
[data-toolbar-group] {
display: inline-flex;
@@ -71,10 +82,17 @@
flex-direction: column;
}
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
/* Placed over the page: centred above the bottom edge, or centred against the end edge.
*
* A toolbar at the bottom shares the screen region with a navigation bar, so it clears the one
* <x-app-shell> publishes as --material-bottom-bar, exactly as the FAB and the snackbar do —
* `max()`, not a sum, because that height already swallows the bottom safe area; without a bar
* the safe area alone applies. A *docked* toolbar and a navigation bar must never be on screen
* together at all (docs/reference/m3/components-navigation-selection-inputs.md § Toolbars); the
* offset is the backstop, not a licence. */
[data-toolbar-place="bottom"] {
position: fixed;
bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);
left: 50%;
z-index: 30;
translate: -50% 0;
@@ -88,9 +106,17 @@
translate: 0 -50%;
}
/* "Horizontal toolbars should have a minimum 16dp margin from the edge of the window. Vertical
toolbars should have a minimum 24dp margin"
(docs/reference/m3/components-navigation-selection-inputs.md § Toolbars). */
[data-toolbar][data-vertical][data-toolbar-place="end"],
[data-toolbar-group][data-vertical][data-toolbar-place="end"] {
inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right)));
}
[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"] {
inset-inline: 0;
bottom: 0;
bottom: var(--material-bottom-bar, 0px);
left: 0;
translate: none;
}