/*! Dashboard-Style 2.1.3 | MIT License | https://github.com/saschazesiger/Dashboard-Style */
/*
 * Dashboard-Style: every stylesheet in cascade order. scripts/build.mjs
 * inlines these imports into dist/ds.css; a bundler can import this file
 * (or single files) directly. Load a project theme after it.
 *
 * Order: tokens, motion, the opt-in page base, then components from the
 * smallest building blocks to the app frame.
 *
 * Cascade layers: every stylesheet starts with the same layer statement
 * (so a single file keeps the order too) and puts its rules into one of
 * ds.tokens, ds.base, ds.components, ds.motion (keyframes) or
 * ds.utilities (the motion utilities). Unlayered project CSS beats every
 * layer, whatever its specificity; a project reset belongs in a layer
 * declared before this file (@layer reset;). @property and @font-face
 * stay outside the layers.
 */

@layer ds.tokens, ds.base, ds.components, ds.motion, ds.utilities;

/* ── tokens.css ── */
/*
 * Dashboard-Style tokens.
 *
 * Every token lives on `.ds-theme`, never on :root: the system can run
 * inside a foreign page (an embedded widget) without touching that page's
 * variables. A full app puts class="ds-theme" on <html>. The exceptions
 * are the @property registrations at the top level: --ds-time-scale, the
 * runtime properties the loading indicators animate (below it) and the
 * share of a chart mark (--ds-share).
 *
 * Three layers:
 *   1. Scales: motion, spacing, state layers, layers (z), radius scale.
 *      The same everywhere, so components move and respond alike. Themes
 *      never set motion tokens: motion is one scheme for every product, and
 *      DS.motion reads the computed tokens.
 *   2. Semantic roles: palette, type roles, radius roles, elevation, focus,
 *      links, layout sizes. The values below are the neutral default theme;
 *      a project theme overrides them (themes/example.css lists them all).
 *   3. Component hooks (--ds-btn-*, --ds-card-pad, ...): read by one
 *      component with a fallback, set by a theme or on an element.
 *
 * Custom properties resolve var() where they are declared. Roles that are
 * built from other tokens (type roles from --ds-font, --ds-link from
 * --ds-primary, --ds-focus-ring from --ds-surface, and the color-mix()
 * roles: --ds-primary-hover and --ds-primary-pressed from --ds-primary,
 * --ds-link-underline from --ds-link, --ds-fill-round, --ds-disabled-fill
 * and --ds-disabled-ink from --ds-text) follow a theme only when the theme
 * sets its values on the .ds-theme element itself, as this file does. Set
 * on a descendant, those derived roles keep the old value.
 *
 * Besides custom properties, one rule sets color-scheme from
 * --ds-color-scheme on :where(.ds-theme), at the end of the layer.
 *
 * Baseline: Safari 16.4, Chrome/Edge 111, Firefox 128; no CSS nesting.
 * Every stylesheet declares the layer order and puts its rules in one of
 * them (this file in ds.tokens), so unlayered project CSS always wins.
 *
 * scripts/motion-curves.mjs writes the linear() values and the block
 * between the "generated: progress-masks" markers; never edit them by hand.
 *
 * Progressive:
 * - linear() (the spring of --ds-ease-spatial and the exact Material
 *   emphasized path, behind @supports): without it the cubic-bezier
 *   fallbacks apply (--ds-ease-out, the emphasized approximation).
 */

/*
 * Slow motion for the styleguide and the browser tests: every duration and
 * delay below is multiplied by it. Registered and inheriting, but never
 * declared on .ds-theme, so a nested .ds-theme cannot reset it; only the
 * styleguide and the tests set it, on the .ds-theme element or above.
 * Components and themes never do (tested).
 */
@property --ds-time-scale {
  syntax: '<number>';
  inherits: true;
  initial-value: 1;
}

/*
 * Runtime properties of the loading indicators, registered so they
 * interpolate: the page sets --ds-progress (a share, 0 ... 1) and it
 * glides; the loop keyframes of motion.css animate the others. Not motion
 * hooks: the progress and skeleton values inherit on purpose, because they
 * are animated on a parent and read by its children and pseudo-elements,
 * which keeps every part in lockstep. The spinner's angles stay on the
 * spinner itself.
 */
@property --ds-progress {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

@property --ds-progress-head-1 {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 0%;
}

@property --ds-progress-head-2 {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 0%;
}

@property --ds-progress-tail-1 {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 0%;
}

@property --ds-progress-tail-2 {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 0%;
}

@property --ds-spinner-arc {
  syntax: '<angle>';
  inherits: false;
  initial-value: 10deg;
}

@property --ds-spinner-start {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

@property --ds-skeleton-x {
  syntax: '<length>';
  inherits: true;
  initial-value: 0px;
}

/*
 * The share of a chart mark (a meter, a column; 0 ... 1), set by the page
 * on the mark (style.setProperty). Registered so it glides: the mark's
 * fill is clipped at it (never scaled, so its round end keeps its shape)
 * and a column's value rides on it. Inherits, so the mark's parts follow
 * the value of the mark. Not --ds-progress, which drives the whole
 * progress choreography.
 */
@property --ds-share {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

@layer ds.tokens {
  .ds-theme {
    /* ── 1. Motion: durations ───────────────────────────
     * Every duration and delay is calc(<value> * var(--ds-time-scale, 1)):
     * read it with DS.motion (the computed calc()), not parseInt.
     * micro    press-in of layers and radius, tiny ticks
     * instant  pressed state, popup exits
     * fast     colour, small exits (the default .ds-leave), focus snap
     * base     toggles, exits of panels and dialogs
     * medium   content entrances, small spatial movement (handle, chip,
     *          check), menu entrances
     * slow     dialogs, FLIP, the indicator cap
     * long     sheets, modal drawer, container transform; never routine UI
     * glide    values that travel: progress, countUp() (js/motion.js)
     */
    --ds-dur-micro: calc(50ms * var(--ds-time-scale, 1));
    --ds-dur-instant: calc(100ms * var(--ds-time-scale, 1));
    --ds-dur-fast: calc(150ms * var(--ds-time-scale, 1));
    --ds-dur-base: calc(200ms * var(--ds-time-scale, 1));
    --ds-dur-medium: calc(250ms * var(--ds-time-scale, 1));
    --ds-dur-slow: calc(350ms * var(--ds-time-scale, 1));
    --ds-dur-long: calc(450ms * var(--ds-time-scale, 1));
    --ds-dur-glide: calc(600ms * var(--ds-time-scale, 1));

    /* ── Motion: easings ────────────────────────────────
     * Effects (opacity, colour) and values: within [0, 1], never decreasing.
     *   in-out      M3 standard: effects, on-screen changes
     *   out         M3 emphasized decelerate: the spring's fallback, and
     *               sizes that push layout (they never overshoot)
     *   in          M3 standard accelerate: small exits, leaving fades
     *   exit        M3 emphasized accelerate: large surfaces leaving
     *               (movement only)
     *   emphasized  movement that starts and ends on screen; the exact M3
     *               path below where supported
     *   value       progress bars and countUp(), so figures and bars land
     *               together
     * Spatial: the spring for movement that floats (translate, scale,
     * clip-path). Its linear() below peaks at most at 1.05 and settles; it is
     * normalised to its settle time, so one curve serves every duration.
     * Sizes that push layout never use it.
     */
    --ds-ease-in-out: cubic-bezier(.2, 0, 0, 1);
    --ds-ease-out: cubic-bezier(.05, .7, .1, 1);
    --ds-ease-in: cubic-bezier(.3, 0, 1, 1);
    --ds-ease-exit: cubic-bezier(.3, 0, .8, .15);
    --ds-ease-emphasized: cubic-bezier(.2, 0, 0, 1);
    --ds-ease-spatial: var(--ds-ease-out);
    --ds-ease-value: var(--ds-ease-in-out);

    /* ── Motion: choreography ───────────────────────────
     * Distances of entering elements (4-12 px, 24 px for side sheets, a full
     * width for a sheet that covers the screen); start scales of content and
     * dialogs (enter), menus and the combobox list (popup), tooltips
     * (tooltip) and small markers such as badges (small); the step between
     * staggered items (at most six, first render only), the delays before
     * loading indicators may show (fast loads show nothing; js/motion.js
     * LOADER_DELAY_MS mirrors --ds-delay-loader), how long a loader that
     * showed stays at least (LOADER_MIN_VISIBLE_MS mirrors --ds-hold-loader),
     * how long a button shows its success face (DS.motion.busy()) and the
     * tooltip delays of DS.overlay.tooltips(): the wait before one shows,
     * the window after a hide in which the next shows at once (warm-up) and
     * the grace before one hides, so the pointer can move onto it
     * (js/overlay.js TOOLTIP_DELAY_MS, TOOLTIP_SKIP_MS, TOOLTIP_HIDE_MS).
     */
    --ds-distance-sm: 4px;
    --ds-distance-md: 8px;
    --ds-distance-lg: 12px;
    --ds-distance-xl: 24px;
    --ds-distance-sheet: 100%;
    --ds-scale-enter: .98;
    --ds-scale-popup: .95;
    --ds-scale-tooltip: .9;
    --ds-scale-small: .6;
    --ds-stagger: calc(30ms * var(--ds-time-scale, 1));
    --ds-delay-loader: calc(200ms * var(--ds-time-scale, 1));
    --ds-delay-boot: calc(400ms * var(--ds-time-scale, 1));
    --ds-hold-loader: calc(450ms * var(--ds-time-scale, 1));
    --ds-hold-success: calc(600ms * var(--ds-time-scale, 1));
    --ds-hold-status: calc(900ms * var(--ds-time-scale, 1));
    --ds-delay-tooltip: calc(500ms * var(--ds-time-scale, 1));
    --ds-delay-tooltip-skip: calc(300ms * var(--ds-time-scale, 1));
    --ds-delay-tooltip-hide: calc(100ms * var(--ds-time-scale, 1));
    /* A backdrop or scrim click this soon after the open does not close
       (shorter than the dialog's --ds-dur-slow entrance; DISMISS_DELAY_MS). */
    --ds-delay-dismiss: calc(300ms * var(--ds-time-scale, 1));

    /* Loops of loading indicators: they always run at their token speed.
     * spin           one turn of the spinner (the M3 rotation period)
     * spin-fast      one turn of the spinner in a status chip
     * arc            one loop of the spinner's arc: four 1333ms cycles of
     *                growing and shrinking (ds-spinner-arc)
     * indeterminate  one cycle of the two-segment indeterminate bar
     * loader         one round of the shape loader: 7 shapes x 650ms
     * skeleton       one pass of the skeleton wave (sweep, then rest)
     * wave           one wavelength of the travelling progress wave
     */
    --ds-loop-spin: calc(1568ms * var(--ds-time-scale, 1));
    --ds-loop-spin-fast: calc(.8s * var(--ds-time-scale, 1));
    --ds-loop-arc: calc(5332ms * var(--ds-time-scale, 1));
    --ds-loop-indeterminate: calc(1800ms * var(--ds-time-scale, 1));
    --ds-loop-loader: calc(4550ms * var(--ds-time-scale, 1));
    --ds-loop-skeleton: calc(2s * var(--ds-time-scale, 1));
    --ds-loop-wave: calc(1.2s * var(--ds-time-scale, 1));

    /* ── Progress waves ─────────────────────────────────
     * The masks that make progress wavy (scripts/motion-curves.mjs writes
     * them): one wavelength of a 4px sine stroke, ±3px, as a tile of
     * --ds-progress-wave-length x 10px for the linear bar, and a closed
     * wavy ring for the 40px determinate spinner.
     */
    /* generated: progress-masks */
    --ds-progress-wave-length: 40px;
    --ds-progress-wave: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='10' viewBox='0 0 40 10'%3E%3Cpath d='M-5 7.12C-3.33 6.57 -1.67 5.79 0 5C1.67 4.21 3.33 3.43 5 2.88C6.67 2.32 8.33 2 10 2C11.67 2 13.33 2.32 15 2.88C16.67 3.43 18.33 4.21 20 5C21.67 5.79 23.33 6.57 25 7.12C26.67 7.68 28.33 8 30 8C31.67 8 33.33 7.68 35 7.12C36.67 6.57 38.33 5.79 40 5C41.67 4.21 43.33 3.43 45 2.88' fill='none' stroke='black' stroke-width='4'/%3E%3C/svg%3E");
    --ds-progress-ring-wave: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cpath d='M20 3.6C21.23 2.76 22.69 2.15 24.01 2.45C25.32 2.75 26.37 3.94 27.12 5.22C27.86 6.51 28.36 7.74 29.23 8.43C30.09 9.12 31.4 9.34 32.82 9.77C34.24 10.21 35.63 10.98 36.22 12.19C36.8 13.4 36.53 14.97 35.99 16.35C35.45 17.73 34.8 18.89 34.8 20C34.8 21.11 35.45 22.27 35.99 23.65C36.53 25.03 36.8 26.6 36.22 27.81C35.63 29.02 34.24 29.79 32.82 30.23C31.4 30.66 30.09 30.88 29.23 31.57C28.36 32.26 27.86 33.49 27.12 34.78C26.37 36.06 25.32 37.25 24.01 37.55C22.69 37.85 21.23 37.24 20 36.4C18.77 35.56 17.79 34.68 16.71 34.43C15.63 34.18 14.35 34.55 12.88 34.78C11.42 35 9.83 34.91 8.78 34.07C7.72 33.23 7.29 31.71 7.18 30.23C7.07 28.74 7.15 27.42 6.67 26.42C6.19 25.42 5.1 24.66 4.01 23.65C2.92 22.64 2 21.35 2 20C2 18.65 2.92 17.36 4.01 16.35C5.1 15.34 6.19 14.58 6.67 13.58C7.15 12.58 7.07 11.26 7.18 9.77C7.29 8.29 7.72 6.77 8.78 5.93C9.83 5.09 11.42 5 12.88 5.22C14.35 5.45 15.63 5.82 16.71 5.57C17.79 5.32 18.77 4.44 20 3.6Z' fill='none' stroke='black' stroke-width='4'/%3E%3C/svg%3E");
    /* end generated: progress-masks */
    /* Short bars and arcs are flat (M3 Expressive flattens the wave at
       small shares): the wave fades in from the share wave-start over the
       next wave-ramp of the share (flat up to 6%, wavy from 11%). */
    --ds-progress-wave-start: .06;
    --ds-progress-wave-ramp: .05;

    /* ── State layers ───────────────────────────────────
     * Opacity of the content colour laid over a control (::before). Large
     * targets (rows, navigation items) take the lighter hover. Keyboard
     * focus lays its own layer under the ring (focus); a press stays on top
     * of it. Disabled content dims to 38%; stale content (about to be
     * replaced) to 55%.
     */
    --ds-state-hover: .08;
    --ds-state-hover-lg: .06;
    --ds-state-focus: .10;
    --ds-state-pressed: .10;
    --ds-state-dragged: .16;
    --ds-state-disabled: .38;
    --ds-state-stale: .55;

    /* ── Spacing (4 px grid) ──────────────────────────── */
    --ds-space-1: 4px;
    --ds-space-2: 8px;
    --ds-space-3: 12px;
    --ds-space-4: 16px;
    --ds-space-5: 20px;
    --ds-space-6: 24px;
    --ds-space-7: 28px;
    --ds-space-8: 32px;
    --ds-space-10: 40px;
    --ds-space-12: 48px;

    /* ── Layers (inside one stacking context) ───────────
     * pinned   a pinned first table column, above the raised controls of
     *          the cells that scroll under it
     * sticky-pinned  the corner cell of a sticky header over a pinned column
     * popover  menus and listboxes
     * drawer   side sheets and the modal navigation drawer
     * dialog   dialogs, above an open side sheet (they may ask from inside one)
     * toast    snackbars, tooltips, the skip link
     */
    --ds-z-raised: 1;
    --ds-z-pinned: calc(var(--ds-z-raised) + 1);
    --ds-z-sticky: 20;
    --ds-z-sticky-pinned: calc(var(--ds-z-sticky) + 1);
    --ds-z-popover: 40;
    --ds-z-drawer: 50;
    --ds-z-dialog: calc(var(--ds-z-drawer) + 1);
    --ds-z-toast: 60;

    /* ── 2. Palette (neutral default) ───────────────────
     * Surfaces: white panels on a cool grey backdrop; hierarchy comes from
     * tonal surfaces, hairlines and type, not shadows.
     *   backdrop         the page behind the panels (top bar, drawer)
     *   surface          panels, cards, dialogs, tables
     *   surface-low      tiles, filled cards, code, read-only fields
     *   surface-high     filled fields (search, toolbar selects), neutral chips
     *   surface-highest  tracks (switch, spinner), skeletons, hovered filled fields
     */
    --ds-backdrop: #f0f4f9;
    --ds-surface: #ffffff;
    --ds-surface-low: #f8fafd;
    --ds-surface-high: #e9eef6;
    --ds-surface-highest: #dde3ea;

    /* Floating surfaces: overlay for the dialog box and the side sheet
       panel, popover for menus, the combobox list and the skip link. The
       same white by default; a dark theme lifts them a tone above the page,
       where a shadow no longer shows. */
    --ds-surface-overlay: var(--ds-surface);
    --ds-surface-popover: var(--ds-surface);

    /* The colour scheme of the theme (light or dark): the browser draws
       scrollbars, native pickers and form controls to match. Set it with
       the palette; :where(.ds-theme) applies it below. */
    --ds-color-scheme: light;

    /* The band of the skeleton wave that passes over the bones. */
    --ds-skeleton-highlight: var(--ds-surface-high);

    /* Text. --ds-text-3 reaches 4.5:1 on white only; on any tinted surface
       the quieter text is --ds-text-2. */
    --ds-text: #1f1f1f;
    --ds-text-2: #444746;
    --ds-text-3: #747775;

    /* Lines: passive structure (cards, dividers, rows), outlines of
       interactive controls (outlined buttons, chips), field borders. */
    --ds-line: #dde3ea;
    --ds-line-2: #c4c7c5;
    --ds-outline: #747775;

    /* Primary: the strongest action, focus, switches, progress, links. The
       hover and pressed values are references for components that cannot
       lay a state layer (a solid button drawn by a project). Derived: they
       follow --ds-primary (hover mixes in 8% of --ds-on-primary, pressed 15%
       black); a theme sets them only to differ. */
    --ds-primary: #0b57d0;
    --ds-on-primary: #ffffff;
    --ds-primary-hover: color-mix(in srgb, var(--ds-primary), var(--ds-on-primary) 8%);
    --ds-primary-pressed: color-mix(in srgb, var(--ds-primary), #000 15%);

    /* The one tint: active navigation, selected chips, the tonal button,
       tracks of progress, text selection. It is too light against white to
       carry a state alone: a selection always adds a check, a weight or an
       aria attribute. */
    --ds-accent: #d3e3fd;
    --ds-on-accent: #041e49;
    --ds-on-accent-icon: var(--ds-on-accent);

    /* Label colour of outlined and text buttons. */
    --ds-action-ink: var(--ds-primary);

    /* Status: text on its container, always with an icon. The warning
       surface is a quieter fill for full-width banners. */
    --ds-error: #b3261e;
    --ds-error-container: #f9dedc;
    --ds-on-error-container: #8c1d18;
    --ds-success: #146c2e;
    --ds-success-container: #c4eed0;
    --ds-on-success-container: #0f5223;
    --ds-warning: #8f4e06;
    --ds-warning-container: #feefc3;
    --ds-on-warning-container: #8f4e06;
    --ds-warning-surface: #fdf6e7;

    /* Inverse surfaces: snackbar, tooltip. */
    --ds-inverse: #303030;
    --ds-on-inverse: #f2f2f2;
    --ds-inverse-primary: #a8c7fa;

    /* Scrim behind dialogs and sheets, never blurred. A dialog over another
       open modal (a sheet, the modal drawer) adds the lighter nested one, so
       the two together come to about 43% instead of 54%. */
    --ds-scrim: rgba(0, 0, 0, .32);
    --ds-scrim-nested: rgba(0, 0, 0, .16);

    /* The disc behind round icon buttons (.ds-btn--disc): 8% of the text
       colour (derived; set it only to differ). */
    --ds-fill-round: color-mix(in srgb, var(--ds-text) 8%, transparent);

    /* Inline code. */
    --ds-code-bg: var(--ds-surface-low);

    /* Charts (meters, columns, legends): four series in a fixed order,
       never cycled (more fold into "Other"), each at 3:1 on the surfaces
       and on the track; the neutral series for the rest of a highlighted
       one; the tones only on single-series marks, always with words.
       Text never takes a series colour. */
    --ds-chart-1: var(--ds-primary);
    --ds-chart-2: #c76a00;
    --ds-chart-3: #00897b;
    --ds-chart-4: #9334e6;
    --ds-chart-track: var(--ds-surface-high);
    --ds-chart-neutral: var(--ds-outline);
    --ds-chart-warning: var(--ds-warning);
    --ds-chart-error: var(--ds-error);

    /* Disabled controls: the neutral Material look (a 12% container and
       38% content) instead of dimming the whole control, so a filled button
       does not fade into a pale brand colour. A theme that prefers dimming
       sets --ds-disabled-opacity: var(--ds-state-disabled) and
       --ds-disabled-fill / --ds-disabled-ink to `initial` (each control then
       keeps its own fill and ink). Fill and ink are derived from --ds-text
       (12% and 38%), so a theme that sets the text colour gets them too. */
    --ds-disabled-opacity: 1;
    --ds-disabled-fill: color-mix(in srgb, var(--ds-text) 12%, transparent);
    --ds-disabled-ink: color-mix(in srgb, var(--ds-text) 38%, transparent);

    /* ── Links ──────────────────────────────────────────
     * Always underlined: colour alone does not tell a link from text. The
     * thickness and offset are the same everywhere (themes recolour only),
     * so links in text, text buttons and tables stay alike. The resting
     * underline is the link colour at 40% (derived; set it only to differ).
     */
    --ds-link: var(--ds-primary);
    --ds-link-underline: color-mix(in srgb, var(--ds-link) 40%, transparent);
    --ds-link-underline-hover: var(--ds-link);
    --ds-link-underline-thickness: 1px;
    --ds-link-underline-offset: 3px;

    /* ── Focus ──────────────────────────────────────────
     * A 2 px ring, solid from the first frame. The outline form for
     * controls: it rests (invisible) at offset + width and snaps in to the
     * offset, a transition of outline-offset on --ds-dur-fast; the
     * box-shadow form for media and floating buttons.
     */
    --ds-focus: var(--ds-primary);
    --ds-focus-width: 2px;
    --ds-focus-offset: 2px;
    --ds-focus-outline: var(--ds-focus-width) solid var(--ds-focus);
    --ds-focus-ring: 0 0 0 2px var(--ds-surface), 0 0 0 4px var(--ds-focus);

    /* ── Radii ──────────────────────────────────────────
     * The scale (xs ... 2xl) and the roles components read. Every action is
     * fully round (--ds-radius-action). Pressed, an action turns squarer
     * (--ds-radius-action-pressed) and springs back on release; cards and
     * radio cards take --ds-radius-card-pressed. A pressed radius is never
     * rounder than the rest radius, so a square theme stays square. A theme
     * that wants square containers sets the scale to 0 and keeps the action
     * radius.
     */
    --ds-radius-xs: 4px;
    --ds-radius-sm: 8px;
    --ds-radius-md: 12px;
    --ds-radius-lg: 16px;
    --ds-radius-xl: 20px;
    --ds-radius-2xl: 28px;
    --ds-radius-action: 9999px;
    --ds-radius-action-pressed: 12px;
    --ds-radius-panel: var(--ds-radius-xl);
    --ds-radius-card: var(--ds-radius-md);
    --ds-radius-card-pressed: var(--ds-radius-sm);
    --ds-radius-dialog: var(--ds-radius-lg);
    --ds-radius-chip: var(--ds-radius-sm);
    --ds-radius-field: var(--ds-radius-sm);
    /* The drawn box of a checkbox (radios are round). */
    --ds-radius-check: 2px;

    /* ── Type ───────────────────────────────────────────
     * Headings are regular weight; the label weight is for buttons, labels
     * and emphasis in UI copy. Sizes in px only: a host page may change the
     * root font size.
     */
    --ds-font: 'Google Sans', Roboto, 'Segoe UI', system-ui, sans-serif;
    --ds-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
    --ds-weight-regular: 400;
    --ds-weight-title: 400;
    --ds-weight-label: 500;
    /* Size of the display role, for a component that caps or scales it
       (key figures shrink with their tile). */
    --ds-size-display: 36px;

    /*
     * Type roles as `font` shorthands: one declaration sets size, line
     * height, weight and family, which also beats host rules that target
     * elements. The shorthand resets font-variant-numeric: figures set
     * tabular-nums after it. Tracking is always 0.
     *   headline  page title
     *   title     dialog, sheet and section titles, product name
     *   subtitle  card titles
     *   reading   running text to read (messages, long notes)
     *   input     typed text on phones (16px: iOS zooms into smaller fields)
     *   body      default UI text, fields
     *   dense     table cells
     *   label     buttons, labels, navigation, column heads
     *   chip      chips
     *   meta      hints, timestamps, notes
     *   caption   status chips, field labels, counters
     *   display   key figures (tabular)
     *   code      code blocks
     */
    --ds-font-headline: var(--ds-weight-title) 24px/32px var(--ds-font);
    --ds-font-title: var(--ds-weight-title) 22px/28px var(--ds-font);
    --ds-font-subtitle: var(--ds-weight-label) 16px/24px var(--ds-font);
    --ds-font-reading: var(--ds-weight-regular) 16px/24px var(--ds-font);
    --ds-font-input: var(--ds-font-reading);
    --ds-font-body: var(--ds-weight-regular) 14px/20px var(--ds-font);
    --ds-font-dense: var(--ds-weight-regular) 14px/20px var(--ds-font);
    --ds-font-label: var(--ds-weight-label) 14px/20px var(--ds-font);
    --ds-font-chip: var(--ds-weight-label) 14px/20px var(--ds-font);
    --ds-font-meta: var(--ds-weight-regular) 12px/16px var(--ds-font);
    --ds-font-caption: var(--ds-weight-label) 12px/16px var(--ds-font);
    --ds-font-display: var(--ds-weight-regular) var(--ds-size-display)/44px var(--ds-font);
    --ds-font-code: var(--ds-weight-regular) 13px/20px var(--ds-font-mono);

    /* ── Elevation ──────────────────────────────────────
     * Only floating layers cast shadows. The three Material levels, and the
     * roles components read:
     *   hover    clickable cards when a theme sets --ds-card-hover-shadow,
     *            and filled buttons on hover (level 1)
     *   popover  menus, listboxes, the skip link (level 2)
     *   overlay  dialogs, side sheets, the modal drawer, snackbars (level 3)
     */
    --ds-elev-1: 0 1px 2px 0 rgba(60, 64, 67, .3), 0 1px 3px 1px rgba(60, 64, 67, .15);
    --ds-elev-2: 0 1px 2px 0 rgba(60, 64, 67, .3), 0 2px 6px 2px rgba(60, 64, 67, .15);
    --ds-elev-3: 0 1px 3px 0 rgba(60, 64, 67, .3), 0 4px 8px 3px rgba(60, 64, 67, .15);
    --ds-elev-hover: var(--ds-elev-1);
    --ds-elev-popover: var(--ds-elev-2);
    --ds-elev-overlay: var(--ds-elev-3);

    /* ── Layout ─────────────────────────────────────────
     * Sizes of the app frame (components/shell.css, components/page.css).
     *   topbar-height  the top app bar
     *   drawer-width   the navigation drawer
     *   drawer-rail-width  the drawer as a rail (icons only, data-drawer-collapsed)
     *   content-max    widest page content
     *   measure        reading measure of running text (leads, notes)
     *   toast-room     room kept free at a page's end for a two-line snackbar
     *   touch-target   the smallest target under .ds-touch (utilities.css)
     */
    --ds-topbar-height: 64px;
    --ds-drawer-width: 256px;
    --ds-drawer-rail-width: 72px;
    --ds-content-max: 1280px;
    --ds-measure: 76ch;
    --ds-toast-room: 64px;
    --ds-touch-target: 48px;
  }

  /*
   * The one property in this file that is not a custom property: the
   * theme's colour scheme, so scrollbars, native pickers and form controls
   * of the browser match the palette. Zero specificity, so a page or a
   * component that sets color-scheme itself still wins.
   */
  :where(.ds-theme) {
    color-scheme: var(--ds-color-scheme);
  }

  /*
   * The sampled curves, written by scripts/motion-curves.mjs (never by hand):
   * the spring of --ds-ease-spatial (damping ratio 0.75, peak about 1.028)
   * and the exact Material 3 emphasized path. Engines without linear()
   * (Safari up to 17.1) keep the cubic-bezier values above.
   */
  @supports (transition-timing-function: linear(0, 1)) {
    .ds-theme {
      --ds-ease-spatial: linear(0, 0.007 1.5%, 0.03 3.3%, 0.077 5.5%, 0.151 8.1%, 0.27 11.7%, 0.506 18.5%, 0.637 22.7%, 0.738 26.5%, 0.817 30.1%, 0.88 33.6%, 0.934 37.6%, 0.975 41.8%, 1.005 46.7%, 1.023 52.6%, 1.028 60.2%, 1.015 77.3%, 1.002 97.2%, 1);
      --ds-ease-emphasized: linear(0, 0.007 3%, 0.03 6%, 0.066 8.6%, 0.112 10.8%, 0.162 12.5%, 0.217 13.9%, 0.273 15%, 0.333 15.9%, 0.393 16.6%, 0.479 17.5%, 0.549 18.4%, 0.602 19.3%, 0.648 20.3%, 0.697 21.7%, 0.743 23.5%, 0.79 26%, 0.835 29.5%, 0.876 34%, 0.913 40.1%, 0.946 48.5%, 0.972 59.4%, 0.991 74.6%, 1 96.7%, 1);
    }
  }
}

/* ── motion.css ── */
/*
 * Motion: the only file with @keyframes (test/css.test.js enforces it).
 * Components either name a keyframe here directly or use the utility
 * classes below; durations, easings, distances and scales always come from
 * tokens.css.
 *
 * Keyframes sit in the ds.motion layer, the utility classes (.ds-enter,
 * .ds-leave, .ds-stagger, .ds-reveal, .ds-reveal--scroll,
 * .ds-collapsible, .ds-chevron, .ds-swap, .ds-swap--fade, .ds-icon-swap,
 * .ds-presence, .ds-presence--keep, .ds-vt-view) and the view-transition
 * rules (.ds-theme::view-transition-*) in ds.utilities. That layer comes after
 * ds.components, so a utility wins over a component's own animation by
 * layer order, not by specificity.
 *
 * The per-element hooks (--ds-motion-*, --ds-clip-from, --ds-draw-length,
 * --ds-enter-*, --ds-leave-*, --ds-i) are registered here, at the top
 * level, and do not inherit: a hook applies to the element that sets it,
 * never to its descendants, so set it on the animating element.
 *
 * Movement keyframes use the individual translate and scale properties, so
 * they compose with an element's own or inline transform (a placed tooltip,
 * the indicator, FLIP) and can be read back mid-flight. Entrances define
 * only `from`: the element animates to whatever its own styles say, so one
 * keyframe works for any final opacity or position. Exits define only `to`
 * and are movement-only; they always pair with ds-fade-out.
 *
 * [data-ds-moving] (set by DS.dom.morphHtml for one style flush on a node
 * it moves with insertBefore, and by DS.indicator.createIndicator on its
 * track) turns transitions off in that subtree, so a keyed reorder never
 * replays an @starting-style entrance and the items under a new pill take
 * their look at once.
 *
 * Progressive:
 * - interpolate-size and ::details-content (.ds-collapsible animates its
 *   height and fades its content, with content-visibility as an
 *   allow-discrete track): without them it simply toggles.
 * - overflow-clip-margin (focus rings at the edge of an open
 *   .ds-collapsible show): without it the content's own padding is the
 *   ring room.
 * - sibling-index() (it numbers the children of .ds-stagger): without it
 *   stagger() in js/motion.js numbers them.
 * - @starting-style, allow-discrete transitions of display and overlay,
 *   and :popover-open (.ds-presence animates toggled elements, stays
 *   rendered and in the top layer while it leaves; :popover-open in a rule
 *   of its own): without them it shows and hides at once.
 * - View Transitions (the ::view-transition-* rules of the "View
 *   transitions" section: fade-through, shared axis, container transform,
 *   the group default that glides named indicators, and .ds-vt-view, a
 *   view outside the app frame; only with .ds-theme on <html>): without
 *   them DS.motion.transition() fades the new view (the first child of a
 *   .ds-vt-view) through with .ds-enter--swap.
 * - scroll-driven animations (animation-timeline: view(), animation-range;
 *   .ds-reveal--scroll ties its fade and rise to the scroll position, and
 *   ds-scroll-fade, ds-scroll-edge-start and ds-scroll-edge-end run on the
 *   scroll timelines of shell.css, sheet.css, table.css and scroller.css):
 *   without them .ds-reveal--scroll stays static, never hidden, and
 *   data-scrolled, the data-ds-overflow-* attributes of
 *   DS.dom.scrollEdges() and the static table fade stay.
 */

/*
 * Per-element motion hooks. No initial value: an unset hook stays
 * guaranteed-invalid, so every var(--hook, fallback) keeps its fallback.
 * inherits: false, so a hook set on a parent (a .ds-reveal block, a
 * .ds-enter--delayed wrapper, a staggered item) never reaches an element
 * that animates inside it.
 */
@property --ds-motion-distance {
  syntax: '*';
  inherits: false;
}

@property --ds-motion-scale {
  syntax: '*';
  inherits: false;
}

@property --ds-motion-origin {
  syntax: '*';
  inherits: false;
}

@property --ds-clip-from {
  syntax: '*';
  inherits: false;
}

@property --ds-draw-length {
  syntax: '*';
  inherits: false;
}

@property --ds-enter-keyframes {
  syntax: '*';
  inherits: false;
}

@property --ds-enter-duration {
  syntax: '*';
  inherits: false;
}

@property --ds-enter-easing {
  syntax: '*';
  inherits: false;
}

@property --ds-enter-delay {
  syntax: '*';
  inherits: false;
}

@property --ds-leave-keyframes {
  syntax: '*';
  inherits: false;
}

@property --ds-leave-duration {
  syntax: '*';
  inherits: false;
}

@property --ds-leave-easing {
  syntax: '*';
  inherits: false;
}

/* The stagger step of an item (stagger() in js/motion.js sets it inline). */
@property --ds-i {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

@layer ds.motion {
  /* ── Keyframes: enter ──────────────────────────── */

  @keyframes ds-fade-in {
    from { opacity: 0; }
  }

  /*
   * Movement only. Opacity must take the standard curve while the movement
   * floats on the spring (an overshooting fade flashes), and one keyframe
   * runs on one curve, so entrances pair ds-fade-in with one of these:
   *
   *   animation: ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) backwards,
   *              ds-lift-in var(--ds-dur-medium) var(--ds-ease-spatial) backwards;
   *
   * ds-lift-in rises (snackbars, a swapped view), ds-grow-in rises from just
   * below full size (dialogs; --ds-motion-scale sets the start scale),
   * ds-shift-in comes from the side (side sheets), ds-pop-in grows small
   * markers (badges). The .ds-enter utilities below always use these pairs.
   */
  @keyframes ds-lift-in {
    from { translate: 0 var(--ds-motion-distance, var(--ds-distance-md)); }
  }

  @keyframes ds-grow-in {
    from {
      translate: 0 var(--ds-motion-distance, var(--ds-distance-md));
      scale: var(--ds-motion-scale, var(--ds-scale-enter));
    }
  }

  @keyframes ds-shift-in {
    from { translate: var(--ds-motion-distance, var(--ds-distance-xl)) 0; }
  }

  /* A rolling line (a status text that changes): the new line comes up from
     below into its slot (--ds-motion-distance: 100%, one line height). */
  @keyframes ds-roll-in {
    from { translate: 0 var(--ds-motion-distance, 100%); }
  }

  /* Small markers that appear (badges, counters): the smallest start scale
     (--ds-scale-small). */
  @keyframes ds-pop-in {
    from { scale: var(--ds-motion-scale, var(--ds-scale-small)); }
  }

  /* A stroke that draws itself (a check after success). The glyph carries
     pathLength="1" and the element sets stroke-dasharray: 1, so the stroke
     starts fully offset (--ds-draw-length, default 1) and draws in. */
  @keyframes ds-draw {
    from { stroke-dashoffset: var(--ds-draw-length, 1); }
  }

  /* A chart mark that fills from zero to its share on its first render
     (.ds-meter, .ds-columns__col): the registered --ds-share runs from 0
     to the mark's own value, so its fill, clipped at the share, keeps its
     round end the whole way. A value that changes later transitions, it
     never replays this. */
  @keyframes ds-fill-share {
    from { --ds-share: 0; }
  }

  /* Deprecated in 2.0 (removed in 3.0 at the earliest): a scaled bar
     squashes its round end; use --ds-share with ds-fill-share. A bar that
     fills from its start: the element sets its own transform (the scaleX
     or scaleY of its share) and transform-origin; the scale property
     multiplies it, so the bar grows from zero to its share. A value that
     changes later jumps or transitions, it never replays this. */
  @keyframes ds-fill-x {
    from { scale: 0 1; }
  }

  @keyframes ds-fill-y {
    from { scale: 1 0; }
  }

  /* ── Keyframes: leave ──────────────────────────── */
  /* Movement only, each paired with ds-fade-out (the fade accelerates on
     its own curve):

       animation: ds-fade-out var(--ds-dur-fast) var(--ds-ease-in) forwards,
                  ds-drop-out var(--ds-dur-fast) var(--ds-ease-in) forwards;
  */

  @keyframes ds-fade-out {
    to { opacity: 0; }
  }

  /* Down (the reverse of ds-lift-in). */
  @keyframes ds-drop-out {
    to { translate: 0 var(--ds-motion-distance, var(--ds-distance-md)); }
  }

  /* Smaller and a little down (the reverse of ds-grow-in). */
  @keyframes ds-shrink-out {
    to {
      translate: 0 var(--ds-motion-distance, var(--ds-distance-sm));
      scale: var(--ds-motion-scale, var(--ds-scale-enter));
    }
  }

  /* Sideways (a side sheet, a drawer). */
  @keyframes ds-shift-out {
    to { translate: var(--ds-motion-distance, var(--ds-distance-lg)) 0; }
  }

  /* A small marker disappears (the reverse of ds-pop-in). */
  @keyframes ds-pop-out {
    to { scale: var(--ds-motion-scale, var(--ds-scale-small)); }
  }

  /* The rolling line leaves upwards (--ds-motion-distance: -100%). */
  @keyframes ds-roll-out {
    to { translate: 0 var(--ds-motion-distance, -100%); }
  }

  /* ── Keyframes: popup reveal ───────────────────── */
  /* A popup (menu, combobox list) uncovers itself from the edge of its
     trigger: --ds-clip-from is the hidden state (DS.motion.originFrom()
     writes it, default: closed towards the top edge). clip-path does not
     interpolate to `none`, so both keyframes name both ends; the open end
     reaches --ds-space-6 beyond the box, the resting clip of .ds-menu and
     .ds-combo__popup, so the popup's shadow stays visible while the
     animation runs and nothing snaps when it ends. */
  @keyframes ds-clip-in {
    from { clip-path: var(--ds-clip-from, inset(0 0 100% 0)); }
    to { clip-path: inset(calc(-1 * var(--ds-space-6))); }
  }

  @keyframes ds-clip-out {
    from { clip-path: inset(calc(-1 * var(--ds-space-6))); }
    to { clip-path: var(--ds-clip-from, inset(0 0 100% 0)); }
  }

  /* ── Keyframes: feedback ───────────────────────── */

  /* The pointer ripple of an action (DS.ripple): a circle grows from the
     pointer. Its fade is a separate track (ds-fade-out). */
  @keyframes ds-press-ripple {
    from { scale: 0; }
  }

  /* A ring that widens and fades once behind a confirmation. No overshoot:
     it only grows and disappears. */
  @keyframes ds-ripple {
    from { opacity: var(--ds-ripple-opacity, .6); scale: 1; }
    to { opacity: 0; scale: var(--ds-ripple-scale, 1.6); }
  }

  /* ── Keyframes: loops (loading indicators) ─────── */
  /* Skeletons: one synchronised, calm diagonal wave across the surface
     (ds-skeleton-wave).

     Loops run `linear` on their --ds-loop-* token: the curves of the
     generated loops are baked into their stops by scripts/motion-curves.mjs
     (never edit a generated block by hand). The registered properties they
     animate are in tokens.css. */

  /* One turn: the spinner turns on top of its arc. The individual rotate
     property composes with an element's own transform, which stays applied
     while it spins. */
  @keyframes ds-spin {
    to { rotate: 1turn; }
  }

  /* The Material 3 arc of .ds-spinner: the sweep (--ds-spinner-arc) grows
     from 10deg to 270deg (the head runs ahead) and shrinks back while the
     start (--ds-spinner-start) advances 270deg (the tail chases the head),
     on the M3 curve. Four such cycles, 3 turns of the start, make one loop
     (--ds-loop-arc), so the loop never jumps. */
  @keyframes ds-spinner-arc {
    /* generated: spinner-arc */
    0% { --ds-spinner-arc: 10deg; --ds-spinner-start: 0deg; }
    0.95% { --ds-spinner-arc: 13.67deg; }
    1.71% { --ds-spinner-arc: 23.79deg; }
    2.34% { --ds-spinner-arc: 39.49deg; }
    2.91% { --ds-spinner-arc: 61.3deg; }
    3.59% { --ds-spinner-arc: 96.43deg; }
    4.56% { --ds-spinner-arc: 149.37deg; }
    5.27% { --ds-spinner-arc: 180.41deg; }
    6.06% { --ds-spinner-arc: 206.48deg; }
    7% { --ds-spinner-arc: 228.76deg; }
    8.14% { --ds-spinner-arc: 247.02deg; }
    9.54% { --ds-spinner-arc: 260.65deg; }
    11.26% { --ds-spinner-arc: 268.56deg; }
    12.5% { --ds-spinner-arc: 270deg; --ds-spinner-start: 0deg; }
    13.43% { --ds-spinner-start: 3.64deg; }
    13.45% { --ds-spinner-arc: 266.33deg; }
    14.18% { --ds-spinner-start: 13.74deg; }
    14.21% { --ds-spinner-arc: 256.21deg; }
    14.8% { --ds-spinner-start: 29.33deg; }
    14.84% { --ds-spinner-arc: 240.51deg; }
    15.36% { --ds-spinner-start: 50.96deg; }
    15.41% { --ds-spinner-arc: 218.7deg; }
    16% { --ds-spinner-start: 84.53deg; }
    16.09% { --ds-spinner-arc: 183.57deg; }
    17.04% { --ds-spinner-start: 143.71deg; }
    17.06% { --ds-spinner-arc: 130.63deg; }
    17.74% { --ds-spinner-start: 175.77deg; }
    17.77% { --ds-spinner-arc: 99.59deg; }
    18.51% { --ds-spinner-start: 202.55deg; }
    18.56% { --ds-spinner-arc: 73.52deg; }
    19.42% { --ds-spinner-start: 225.51deg; }
    19.5% { --ds-spinner-arc: 51.24deg; }
    20.52% { --ds-spinner-start: 244.51deg; }
    20.64% { --ds-spinner-arc: 32.98deg; }
    21.87% { --ds-spinner-start: 258.99deg; }
    22.04% { --ds-spinner-arc: 19.35deg; }
    23.52% { --ds-spinner-start: 267.83deg; }
    23.76% { --ds-spinner-arc: 11.44deg; }
    25% { --ds-spinner-arc: 10deg; --ds-spinner-start: 270deg; }
    25.95% { --ds-spinner-arc: 13.67deg; }
    26.71% { --ds-spinner-arc: 23.79deg; }
    27.34% { --ds-spinner-arc: 39.49deg; }
    27.91% { --ds-spinner-arc: 61.3deg; }
    28.59% { --ds-spinner-arc: 96.43deg; }
    29.56% { --ds-spinner-arc: 149.37deg; }
    30.27% { --ds-spinner-arc: 180.41deg; }
    31.06% { --ds-spinner-arc: 206.48deg; }
    32% { --ds-spinner-arc: 228.76deg; }
    33.14% { --ds-spinner-arc: 247.02deg; }
    34.54% { --ds-spinner-arc: 260.65deg; }
    36.26% { --ds-spinner-arc: 268.56deg; }
    37.5% { --ds-spinner-arc: 270deg; --ds-spinner-start: 270deg; }
    38.43% { --ds-spinner-start: 273.64deg; }
    38.45% { --ds-spinner-arc: 266.33deg; }
    39.18% { --ds-spinner-start: 283.74deg; }
    39.21% { --ds-spinner-arc: 256.21deg; }
    39.8% { --ds-spinner-start: 299.33deg; }
    39.84% { --ds-spinner-arc: 240.51deg; }
    40.36% { --ds-spinner-start: 320.96deg; }
    40.41% { --ds-spinner-arc: 218.7deg; }
    41% { --ds-spinner-start: 354.53deg; }
    41.09% { --ds-spinner-arc: 183.57deg; }
    42.04% { --ds-spinner-start: 413.71deg; }
    42.06% { --ds-spinner-arc: 130.63deg; }
    42.74% { --ds-spinner-start: 445.77deg; }
    42.77% { --ds-spinner-arc: 99.59deg; }
    43.51% { --ds-spinner-start: 472.55deg; }
    43.56% { --ds-spinner-arc: 73.52deg; }
    44.42% { --ds-spinner-start: 495.51deg; }
    44.5% { --ds-spinner-arc: 51.24deg; }
    45.52% { --ds-spinner-start: 514.51deg; }
    45.64% { --ds-spinner-arc: 32.98deg; }
    46.87% { --ds-spinner-start: 528.99deg; }
    47.04% { --ds-spinner-arc: 19.35deg; }
    48.52% { --ds-spinner-start: 537.83deg; }
    48.76% { --ds-spinner-arc: 11.44deg; }
    50% { --ds-spinner-arc: 10deg; --ds-spinner-start: 540deg; }
    50.95% { --ds-spinner-arc: 13.67deg; }
    51.71% { --ds-spinner-arc: 23.79deg; }
    52.34% { --ds-spinner-arc: 39.49deg; }
    52.91% { --ds-spinner-arc: 61.3deg; }
    53.59% { --ds-spinner-arc: 96.43deg; }
    54.56% { --ds-spinner-arc: 149.37deg; }
    55.27% { --ds-spinner-arc: 180.41deg; }
    56.06% { --ds-spinner-arc: 206.48deg; }
    57% { --ds-spinner-arc: 228.76deg; }
    58.14% { --ds-spinner-arc: 247.02deg; }
    59.54% { --ds-spinner-arc: 260.65deg; }
    61.26% { --ds-spinner-arc: 268.56deg; }
    62.5% { --ds-spinner-arc: 270deg; --ds-spinner-start: 540deg; }
    63.43% { --ds-spinner-start: 543.64deg; }
    63.45% { --ds-spinner-arc: 266.33deg; }
    64.18% { --ds-spinner-start: 553.74deg; }
    64.21% { --ds-spinner-arc: 256.21deg; }
    64.8% { --ds-spinner-start: 569.33deg; }
    64.84% { --ds-spinner-arc: 240.51deg; }
    65.36% { --ds-spinner-start: 590.96deg; }
    65.41% { --ds-spinner-arc: 218.7deg; }
    66% { --ds-spinner-start: 624.53deg; }
    66.09% { --ds-spinner-arc: 183.57deg; }
    67.04% { --ds-spinner-start: 683.71deg; }
    67.06% { --ds-spinner-arc: 130.63deg; }
    67.74% { --ds-spinner-start: 715.77deg; }
    67.77% { --ds-spinner-arc: 99.59deg; }
    68.51% { --ds-spinner-start: 742.55deg; }
    68.56% { --ds-spinner-arc: 73.52deg; }
    69.42% { --ds-spinner-start: 765.51deg; }
    69.5% { --ds-spinner-arc: 51.24deg; }
    70.52% { --ds-spinner-start: 784.51deg; }
    70.64% { --ds-spinner-arc: 32.98deg; }
    71.87% { --ds-spinner-start: 798.99deg; }
    72.04% { --ds-spinner-arc: 19.35deg; }
    73.52% { --ds-spinner-start: 807.83deg; }
    73.76% { --ds-spinner-arc: 11.44deg; }
    75% { --ds-spinner-arc: 10deg; --ds-spinner-start: 810deg; }
    75.95% { --ds-spinner-arc: 13.67deg; }
    76.71% { --ds-spinner-arc: 23.79deg; }
    77.34% { --ds-spinner-arc: 39.49deg; }
    77.91% { --ds-spinner-arc: 61.3deg; }
    78.59% { --ds-spinner-arc: 96.43deg; }
    79.56% { --ds-spinner-arc: 149.37deg; }
    80.27% { --ds-spinner-arc: 180.41deg; }
    81.06% { --ds-spinner-arc: 206.48deg; }
    82% { --ds-spinner-arc: 228.76deg; }
    83.14% { --ds-spinner-arc: 247.02deg; }
    84.54% { --ds-spinner-arc: 260.65deg; }
    86.26% { --ds-spinner-arc: 268.56deg; }
    87.5% { --ds-spinner-arc: 270deg; --ds-spinner-start: 810deg; }
    88.43% { --ds-spinner-start: 813.64deg; }
    88.45% { --ds-spinner-arc: 266.33deg; }
    89.18% { --ds-spinner-start: 823.74deg; }
    89.21% { --ds-spinner-arc: 256.21deg; }
    89.8% { --ds-spinner-start: 839.33deg; }
    89.84% { --ds-spinner-arc: 240.51deg; }
    90.36% { --ds-spinner-start: 860.96deg; }
    90.41% { --ds-spinner-arc: 218.7deg; }
    91% { --ds-spinner-start: 894.53deg; }
    91.09% { --ds-spinner-arc: 183.57deg; }
    92.04% { --ds-spinner-start: 953.71deg; }
    92.06% { --ds-spinner-arc: 130.63deg; }
    92.74% { --ds-spinner-start: 985.77deg; }
    92.77% { --ds-spinner-arc: 99.59deg; }
    93.51% { --ds-spinner-start: 1012.55deg; }
    93.56% { --ds-spinner-arc: 73.52deg; }
    94.42% { --ds-spinner-start: 1035.51deg; }
    94.5% { --ds-spinner-arc: 51.24deg; }
    95.52% { --ds-spinner-start: 1054.51deg; }
    95.64% { --ds-spinner-arc: 32.98deg; }
    96.87% { --ds-spinner-start: 1068.99deg; }
    97.04% { --ds-spinner-arc: 19.35deg; }
    98.52% { --ds-spinner-start: 1077.83deg; }
    98.76% { --ds-spinner-arc: 11.44deg; }
    100% { --ds-spinner-arc: 10deg; --ds-spinner-start: 1080deg; }
    /* end generated: spinner-arc */
  }

  /* The Material 3 Expressive loader: the 7 shapes (soft burst, cookie-9,
     pentagon, pill, sunny, cookie-4, oval), each 64 vertices from the top,
     clockwise, so a morph never twists. Run on --ds-ease-spatial over
     --ds-loop-loader: every step lands with the spring. */
  @keyframes ds-loader-morph {
    /* generated: loader-morph */
    0%, 100% {
      clip-path: polygon(
        50% 0%, 54.65% 2.82%, 57.66% 7.66%, 61.14% 12.09%, 66.6% 12.07%, 71.87% 9.9%, 77.32% 8.63%, 81.32% 12.3%,
        81.24% 17.97%, 80.84% 23.66%, 83.57% 28.36%, 89.05% 29.88%, 94.57% 31.29%, 97.78% 35.67%, 95.57% 40.71%, 91.9% 45.07%,
        89.38% 50.01%, 91.94% 54.93%, 95.63% 59.28%, 97.79% 64.34%, 94.53% 68.67%, 89.01% 70.09%, 83.53% 71.64%, 80.86% 76.37%,
        81.29% 82.06%, 81.35% 87.72%, 77.31% 91.34%, 71.86% 90.04%, 66.58% 87.89%, 61.12% 87.93%, 57.67% 92.38%, 54.68% 97.24%,
        50% 100%, 45.35% 97.18%, 42.34% 92.34%, 38.86% 87.91%, 33.4% 87.93%, 28.13% 90.1%, 22.68% 91.37%, 18.68% 87.7%,
        18.76% 82.03%, 19.16% 76.34%, 16.43% 71.64%, 10.95% 70.12%, 5.43% 68.71%, 2.22% 64.33%, 4.43% 59.29%, 8.1% 54.93%,
        10.62% 49.99%, 8.06% 45.07%, 4.37% 40.72%, 2.21% 35.66%, 5.47% 31.33%, 10.99% 29.91%, 16.47% 28.36%, 19.14% 23.63%,
        18.71% 17.94%, 18.65% 12.28%, 22.69% 8.66%, 28.14% 9.96%, 33.42% 12.11%, 38.88% 12.07%, 42.33% 7.62%, 45.32% 2.76%
      );
    }
    14.29% {
      clip-path: polygon(
        50% 1.98%, 54.75% 2.74%, 59.03% 4.93%, 62.87% 7.85%, 67.36% 9.57%, 72.17% 9.89%, 76.9% 10.79%, 81.11% 13.11%,
        84.38% 16.63%, 86.4% 21%, 87.44% 25.71%, 89.62% 30%, 93.02% 33.41%, 96.19% 37.03%, 98.07% 41.45%, 98.49% 46.25%,
        97.39% 50.93%, 95.16% 55.2%, 93.91% 59.85%, 94.17% 64.65%, 94.44% 69.46%, 93.2% 74.1%, 90.58% 78.14%, 86.83% 81.16%,
        82.39% 83.03%, 78.35% 85.64%, 75.33% 89.38%, 72.57% 93.33%, 68.73% 96.23%, 64.18% 97.79%, 59.37% 97.87%, 54.75% 96.51%,
        50% 95.76%, 45.25% 96.51%, 40.63% 97.87%, 35.82% 97.79%, 31.27% 96.23%, 27.43% 93.33%, 24.67% 89.38%, 21.65% 85.64%,
        17.61% 83.03%, 13.17% 81.16%, 9.42% 78.14%, 6.8% 74.1%, 5.56% 69.46%, 5.83% 64.65%, 6.09% 59.85%, 4.84% 55.2%,
        2.61% 50.93%, 1.51% 46.25%, 1.93% 41.45%, 3.81% 37.03%, 6.98% 33.41%, 10.38% 30%, 12.56% 25.71%, 13.6% 21%,
        15.62% 16.63%, 18.89% 13.11%, 23.1% 10.79%, 27.83% 9.89%, 32.64% 9.57%, 37.13% 7.85%, 40.97% 4.93%, 45.25% 2.74%
      );
    }
    28.57% {
      clip-path: polygon(
        50% 5.98%, 54.42% 6.6%, 58.51% 8.41%, 62.19% 10.97%, 65.85% 13.56%, 69.51% 16.14%, 73.17% 18.72%, 76.84% 21.31%,
        80.5% 23.89%, 84.16% 26.48%, 87.82% 29.06%, 91.44% 31.7%, 94.37% 35.07%, 96.21% 39.15%, 96.79% 43.58%, 96.08% 47.98%,
        94.66% 52.24%, 93.24% 56.49%, 91.82% 60.74%, 90.4% 64.99%, 88.98% 69.24%, 87.56% 73.5%, 86.14% 77.75%, 84.72% 82%,
        83% 86.13%, 80.24% 89.64%, 76.61% 92.24%, 72.39% 93.72%, 67.93% 94.02%, 63.45% 94.02%, 58.96% 94.02%, 54.48% 94.02%,
        50% 94.02%, 45.52% 94.02%, 41.04% 94.02%, 36.55% 94.02%, 32.07% 94.02%, 27.61% 93.72%, 23.39% 92.24%, 19.76% 89.64%,
        17% 86.13%, 15.28% 82%, 13.86% 77.75%, 12.44% 73.5%, 11.02% 69.24%, 9.6% 64.99%, 8.18% 60.74%, 6.76% 56.49%,
        5.34% 52.24%, 3.92% 47.98%, 3.21% 43.58%, 3.79% 39.15%, 5.63% 35.07%, 8.56% 31.7%, 12.18% 29.06%, 15.84% 26.48%,
        19.5% 23.89%, 23.16% 21.31%, 26.83% 18.72%, 30.49% 16.14%, 34.15% 13.56%, 37.81% 10.97%, 41.49% 8.41%, 45.58% 6.6%
      );
    }
    42.86% {
      clip-path: polygon(
        50% 9.4%, 53.83% 7.69%, 57.85% 6.53%, 62% 5.94%, 66.2% 5.93%, 70.35% 6.51%, 74.38% 7.66%, 78.21% 9.36%,
        81.76% 11.58%, 84.97% 14.28%, 87.78% 17.39%, 90.13% 20.86%, 91.96% 24.63%, 93.25% 28.62%, 93.98% 32.75%, 94.12% 36.94%,
        93.68% 41.1%, 92.66% 45.17%, 91.08% 49.05%, 88.98% 52.68%, 86.39% 55.98%, 83.45% 58.97%, 80.49% 61.94%, 77.52% 64.91%,
        74.55% 67.87%, 71.59% 70.84%, 68.62% 73.8%, 65.66% 76.77%, 62.69% 79.74%, 59.72% 82.7%, 56.75% 85.66%, 53.55% 88.37%,
        50% 90.6%, 46.17% 92.31%, 42.15% 93.47%, 38% 94.06%, 33.8% 94.07%, 29.65% 93.49%, 25.62% 92.34%, 21.79% 90.64%,
        18.24% 88.42%, 15.03% 85.72%, 12.22% 82.61%, 9.87% 79.14%, 8.04% 75.37%, 6.75% 71.38%, 6.02% 67.25%, 5.88% 63.06%,
        6.32% 58.9%, 7.34% 54.83%, 8.92% 50.95%, 11.02% 47.32%, 13.61% 44.02%, 16.55% 41.03%, 19.51% 38.06%, 22.48% 35.09%,
        25.45% 32.13%, 28.41% 29.16%, 31.38% 26.2%, 34.34% 23.23%, 37.31% 20.26%, 40.28% 17.3%, 43.25% 14.34%, 46.45% 11.63%
      );
    }
    57.14% {
      clip-path: polygon(
        50% 0%, 54.64% 1.52%, 58.44% 4.72%, 62.22% 7.94%, 66.22% 10.85%, 71.1% 11.62%, 76.05% 12.02%, 81% 12.43%,
        85.36% 14.64%, 87.57% 19%, 87.98% 23.95%, 88.38% 28.9%, 89.15% 33.78%, 92.06% 37.78%, 95.28% 41.56%, 98.48% 45.36%,
        100% 50%, 98.48% 54.64%, 95.28% 58.44%, 92.06% 62.22%, 89.15% 66.22%, 88.38% 71.1%, 87.98% 76.05%, 87.57% 81%,
        85.36% 85.36%, 81% 87.57%, 76.05% 87.98%, 71.1% 88.38%, 66.22% 89.15%, 62.22% 92.06%, 58.44% 95.28%, 54.64% 98.48%,
        50% 100%, 45.36% 98.48%, 41.56% 95.28%, 37.78% 92.06%, 33.78% 89.15%, 28.9% 88.38%, 23.95% 87.98%, 19% 87.57%,
        14.64% 85.36%, 12.43% 81%, 12.02% 76.05%, 11.62% 71.1%, 10.85% 66.22%, 7.94% 62.22%, 4.72% 58.44%, 1.52% 54.64%,
        0% 50%, 1.52% 45.36%, 4.72% 41.56%, 7.94% 37.78%, 10.85% 33.78%, 11.62% 28.9%, 12.02% 23.95%, 12.43% 19%,
        14.64% 14.64%, 19% 12.43%, 23.95% 12.02%, 28.9% 11.62%, 33.78% 10.85%, 37.78% 7.94%, 41.56% 4.72%, 45.36% 1.52%
      );
    }
    71.43% {
      clip-path: polygon(
        50% 13.38%, 54.68% 12.8%, 59.12% 11.17%, 63.49% 9.38%, 68.12% 8.43%, 72.84% 8.51%, 77.43% 9.61%, 81.66% 11.69%,
        85.35% 14.64%, 88.3% 18.33%, 90.38% 22.57%, 91.48% 27.16%, 91.55% 31.88%, 90.6% 36.5%, 88.82% 40.88%, 87.19% 45.32%,
        86.62% 50%, 87.2% 54.68%, 88.83% 59.12%, 90.62% 63.49%, 91.57% 68.12%, 91.49% 72.84%, 90.39% 77.43%, 88.31% 81.66%,
        85.36% 85.35%, 81.67% 88.3%, 77.43% 90.38%, 72.84% 91.48%, 68.12% 91.55%, 63.5% 90.6%, 59.12% 88.82%, 54.68% 87.19%,
        50% 86.62%, 45.32% 87.2%, 40.88% 88.83%, 36.51% 90.62%, 31.88% 91.57%, 27.16% 91.49%, 22.57% 90.39%, 18.34% 88.31%,
        14.65% 85.36%, 11.7% 81.67%, 9.62% 77.43%, 8.52% 72.84%, 8.45% 68.12%, 9.4% 63.5%, 11.18% 59.12%, 12.81% 54.68%,
        13.38% 50%, 12.8% 45.32%, 11.17% 40.88%, 9.38% 36.51%, 8.43% 31.88%, 8.51% 27.16%, 9.61% 22.57%, 11.69% 18.34%,
        14.64% 14.65%, 18.33% 11.7%, 22.57% 9.62%, 27.16% 8.52%, 31.88% 8.45%, 36.5% 9.4%, 40.88% 11.18%, 45.32% 12.81%
      );
    }
    85.71% {
      clip-path: polygon(
        50% 11.88%, 53.81% 10.45%, 57.72% 9.3%, 61.71% 8.49%, 65.76% 8.07%, 69.83% 8.1%, 73.86% 8.65%, 77.77% 9.79%,
        81.43% 11.57%, 84.69% 14.01%, 87.4% 17.03%, 89.48% 20.53%, 90.91% 24.34%, 91.72% 28.33%, 91.98% 32.39%, 91.75% 36.45%,
        91.11% 40.48%, 90.11% 44.42%, 88.8% 48.28%, 87.22% 52.03%, 85.39% 55.67%, 83.34% 59.19%, 81.09% 62.59%, 78.65% 65.85%,
        76.05% 68.98%, 73.28% 71.97%, 70.36% 74.81%, 67.29% 77.49%, 64.09% 80.01%, 60.75% 82.34%, 57.29% 84.49%, 53.7% 86.42%,
        50% 88.12%, 46.19% 89.55%, 42.28% 90.7%, 38.29% 91.51%, 34.24% 91.93%, 30.17% 91.9%, 26.14% 91.35%, 22.23% 90.21%,
        18.57% 88.43%, 15.31% 85.99%, 12.6% 82.97%, 10.52% 79.47%, 9.09% 75.66%, 8.28% 71.67%, 8.02% 67.61%, 8.25% 63.55%,
        8.89% 59.52%, 9.89% 55.58%, 11.2% 51.72%, 12.78% 47.97%, 14.61% 44.33%, 16.66% 40.81%, 18.91% 37.41%, 21.35% 34.15%,
        23.95% 31.02%, 26.72% 28.03%, 29.64% 25.19%, 32.71% 22.51%, 35.91% 19.99%, 39.25% 17.66%, 42.71% 15.51%, 46.3% 13.58%
      );
    }
    /* end generated: loader-morph */
  }

  /* The loader turns once per round while it morphs (linear). */
  @keyframes ds-loader-turn {
    to { rotate: 1turn; }
  }

  /* Two-segment indeterminate progress (Material 3, one cycle of
     --ds-loop-indeterminate): the heads and tails of both segments run from
     0% to 100% of the track on their own curves and windows and never go
     back within a cycle. A segment is drawn from its tail to its head, so
     it is empty at both ends of the cycle. */
  @keyframes ds-progress-head {
    /* generated: progress-head */
    0% { --ds-progress-head-1: 0%; --ds-progress-head-2: 0%; }
    1.03% { --ds-progress-head-1: 0.43%; }
    2.34% { --ds-progress-head-1: 1.87%; }
    3.99% { --ds-progress-head-1: 4.57%; }
    6.06% { --ds-progress-head-1: 8.85%; }
    8.67% { --ds-progress-head-1: 15.15%; }
    12.04% { --ds-progress-head-1: 24.2%; }
    16.82% { --ds-progress-head-1: 38.01%; }
    27.08% { --ds-progress-head-1: 68.54%; }
    31.12% { --ds-progress-head-1: 79.91%; }
    34.14% { --ds-progress-head-1: 87.7%; }
    36.51% { --ds-progress-head-1: 93.11%; }
    38.4% { --ds-progress-head-1: 96.71%; }
    39.9% { --ds-progress-head-1: 98.86%; }
    41.09% { --ds-progress-head-1: 99.86%; }
    41.67% { --ds-progress-head-1: 100%; }
    55.56% { --ds-progress-head-2: 0%; }
    58.29% { --ds-progress-head-2: 12.75%; }
    61.53% { --ds-progress-head-2: 27.03%; }
    64.79% { --ds-progress-head-2: 40.61%; }
    67.92% { --ds-progress-head-2: 52.86%; }
    70.86% { --ds-progress-head-2: 63.59%; }
    73.59% { --ds-progress-head-2: 72.8%; }
    76.1% { --ds-progress-head-2: 80.5%; }
    78.39% { --ds-progress-head-2: 86.76%; }
    80.47% { --ds-progress-head-2: 91.69%; }
    82.34% { --ds-progress-head-2: 95.37%; }
    84.01% { --ds-progress-head-2: 97.9%; }
    85.5% { --ds-progress-head-2: 99.4%; }
    86.82% { --ds-progress-head-2: 99.99%; }
    87.06% { --ds-progress-head-2: 100%; }
    100% { --ds-progress-head-1: 100%; --ds-progress-head-2: 100%; }
    /* end generated: progress-head */
  }

  @keyframes ds-progress-tail {
    /* generated: progress-tail */
    0% { --ds-progress-tail-1: 0%; --ds-progress-tail-2: 0%; }
    18.5% { --ds-progress-tail-1: 0%; }
    20.66% { --ds-progress-tail-1: 0.41%; }
    23.02% { --ds-progress-tail-1: 1.69%; }
    25.59% { --ds-progress-tail-1: 3.92%; }
    28.37% { --ds-progress-tail-1: 7.16%; }
    31.37% { --ds-progress-tail-1: 11.49%; }
    34.6% { --ds-progress-tail-1: 16.97%; }
    38.05% { --ds-progress-tail-1: 23.66%; }
    41.72% { --ds-progress-tail-1: 31.59%; }
    45.6% { --ds-progress-tail-1: 40.79%; }
    49.67% { --ds-progress-tail-1: 51.26%; }
    53.88% { --ds-progress-tail-1: 62.9%; }
    58.15% { --ds-progress-tail-1: 75.51%; }
    62.29% { --ds-progress-tail-1: 88.51%; }
    65.69% { --ds-progress-tail-1: 99.89%; }
    65.72% { --ds-progress-tail-1: 99.99%; }
    70.39% { --ds-progress-tail-2: 0%; }
    70.77% { --ds-progress-tail-2: 0.45%; }
    71.29% { --ds-progress-tail-2: 1.99%; }
    72.02% { --ds-progress-tail-2: 5.12%; }
    73.13% { --ds-progress-tail-2: 10.88%; }
    77.65% { --ds-progress-tail-2: 36.58%; }
    79.67% { --ds-progress-tail-2: 47.33%; }
    81.54% { --ds-progress-tail-2: 56.51%; }
    83.35% { --ds-progress-tail-2: 64.61%; }
    85.13% { --ds-progress-tail-2: 71.79%; }
    86.89% { --ds-progress-tail-2: 78.09%; }
    88.65% { --ds-progress-tail-2: 83.6%; }
    90.4% { --ds-progress-tail-2: 88.29%; }
    92.16% { --ds-progress-tail-2: 92.2%; }
    93.92% { --ds-progress-tail-2: 95.32%; }
    95.68% { --ds-progress-tail-2: 97.64%; }
    97.45% { --ds-progress-tail-2: 99.18%; }
    99.22% { --ds-progress-tail-2: 99.92%; }
    100% { --ds-progress-tail-1: 100%; --ds-progress-tail-2: 100%; }
    /* end generated: progress-tail */
  }

  /* The wave of determinate progress travels one wavelength per loop
     (--ds-loop-wave), linear: the mask tile moves, the bar stays. Written
     as a -webkit- and unprefixed pair, like the static masks in
     progress.css: Chrome/Edge before 120 know only the -webkit- form, and
     without it their wave would be drawn but stand still. */
  @keyframes ds-progress-wave {
    to { -webkit-mask-position: var(--ds-progress-wave-length) 0; mask-position: var(--ds-progress-wave-length) 0; }
  }

  /* The skeleton wave: --ds-skeleton-x sweeps once on a sine in-out, then
     rests with the band off screen. Every bone reads the one value (its
     background is fixed to the viewport), so the bones of a surface show a
     single band and background-position itself never animates. */
  @keyframes ds-skeleton-wave {
    /* generated: skeleton-wave */
    0% { --ds-skeleton-x: -150vw; }
    2.41% { --ds-skeleton-x: -149.2vw; }
    4.84% { --ds-skeleton-x: -146.81vw; }
    7.31% { --ds-skeleton-x: -142.76vw; }
    9.85% { --ds-skeleton-x: -136.99vw; }
    12.49% { --ds-skeleton-x: -129.37vw; }
    15.28% { --ds-skeleton-x: -119.67vw; }
    18.3% { --ds-skeleton-x: -107.5vw; }
    21.71% { --ds-skeleton-x: -92.06vw; }
    26% { --ds-skeleton-x: -70.79vw; }
    35.6% { --ds-skeleton-x: -21.1vw; }
    39.49% { --ds-skeleton-x: -2.33vw; }
    42.74% { --ds-skeleton-x: 11.87vw; }
    45.67% { --ds-skeleton-x: 23.15vw; }
    48.4% { --ds-skeleton-x: 32.11vw; }
    51% { --ds-skeleton-x: 39.1vw; }
    53.52% { --ds-skeleton-x: 44.3vw; }
    55.98% { --ds-skeleton-x: 47.79vw; }
    58.4% { --ds-skeleton-x: 49.65vw; }
    60% { --ds-skeleton-x: 50vw; }
    100% { --ds-skeleton-x: 50vw; }
    /* end generated: skeleton-wave */
  }

  /* A scan line over media being processed: a band travels from above the
     frame to below it (percentages of its own height, set by the component). */
  @keyframes ds-scan {
    from { translate: 0 var(--ds-scan-from, -100%); }
    to { translate: 0 var(--ds-scan-to, 100%); }
  }

  /* ── Keyframes: scroll-linked ──────────────────── */

  /* A hairline or a tint that shows with the first pixels of scroll (the
     panel's hairline, the top bar's tint, a sheet head's hairline), on a
     scroll timeline. Both ends are named, so the element's own opacity
     stays 0: a scroller without overflow has an inactive timeline, the
     animation has no effect then, and nothing shows. */
  @keyframes ds-scroll-fade {
    from { opacity: 0; }
    to { opacity: 1; }
  }

  /* The edge fades of a sideways scroller (.ds-scroll-x in scroller.css,
     the table wrappers of table.css; menus and combobox lists reuse them
     on the block axis), each on the scroller's scroll timeline over an
     animation-range at its end: the start fade grows while the first
     --ds-space-8 scroll away, the end fade goes over the last. The numbers (registered in
     scroller.css) scale the fade length, so 0 is no fade. Both ends are
     named (a scroll position, not an entrance); a scroller without
     overflow has an inactive timeline and keeps 0, no fade. */
  @keyframes ds-scroll-edge-start {
    from { --ds-scroll-edge-start: 0; }
    to { --ds-scroll-edge-start: 1; }
  }

  @keyframes ds-scroll-edge-end {
    from { --ds-scroll-edge-end: 1; }
    to { --ds-scroll-edge-end: 0; }
  }

  /* Deprecated in 2.0 (removed in 3.0 at the earliest), unused: the table
     wrappers run ds-scroll-edge-start and ds-scroll-edge-end. The edge
     fades of a table that scrolls sideways (table.css), on the
     wrapper's own scroll timeline, so progress is the scroll position:
     the start fade grows over the first 5% of the travel, the end fade
     goes over the last 5%. Both ends are named (a scroll position, not an
     entrance); the lengths are registered in table.css. */
  @keyframes ds-edge-fade {
    0% { --ds-table-fade-start: 0px; --ds-table-fade-end: var(--ds-card-pad); }
    5% { --ds-table-fade-start: var(--ds-card-pad); }
    95% { --ds-table-fade-end: var(--ds-card-pad); }
    100% { --ds-table-fade-start: var(--ds-card-pad); --ds-table-fade-end: 0px; }
  }
}

@layer ds.utilities {
  /* `hidden` wins inside the theme also without .ds-base (an embedded
     widget): the UA [hidden] rule loses to every component's display, so
     DS.motion.leave(el, v, { then: 'hide' }) on an alert, a button or a chip
     would snap back visible. Zero specificity, first in the last layer: it
     beats every component and loses to project CSS and to the utilities
     below that set display (.ds-swap, .ds-icon-swap). .ds-presence keeps
     its allow-discrete exit, and hidden="until-found" stays searchable. */
  :where(.ds-theme) :where([hidden]:not(.ds-presence, [hidden="until-found"])) {
    display: none;
  }

  /* ── Utilities ─────────────────────────────────── */

  /* Order matters inside this layer, not specificity: the layer beats every
     component's own animation, and within it the later rule wins. The
     first-render utilities come first, .ds-enter after them and .ds-leave
     last, so an exit always wins over an entrance. */

  /* First render of a page only: its children (cards, tiles) fade in and
     rise the smallest distance one after the other, at most six steps
     (stagger() numbers them). Refreshes never animate: drop the class once
     the entrance played. */
  .ds-stagger > * {
    --ds-motion-distance: var(--ds-distance-sm);
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) backwards,
      ds-lift-in var(--ds-dur-medium) var(--ds-ease-spatial) backwards;
    animation-delay: calc(var(--ds-i, 0) * var(--ds-stagger));
  }

  /* Where sibling-index() exists, CSS-only pages stagger too: each child
     numbers itself, capped at the sixth step. An inline --ds-i from
     stagger() still wins. */
  @supports (z-index: sibling-index()) {
    .ds-stagger > * {
      --ds-i: min(sibling-index() - 1, 5);
    }
  }

  /* Freshly loaded content replaces its placeholder: fade-through with the
     smallest rise (opacity on the standard curve, the rise on the spring). */
  .ds-reveal {
    --ds-motion-distance: var(--ds-distance-sm);
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) backwards,
      ds-lift-in var(--ds-dur-medium) var(--ds-ease-spatial) backwards;
  }

  /* Loaders keep their own animation: a skeleton, .ds-loading, .ds-boot
     or a shape loader waits its loader delay (fast loads never flash it)
     and a spinner keeps turning, also as a direct child of .ds-stagger
     (skeleton placeholders in a staggered grid) or with .ds-reveal.
     revert-layer hands them back to ds.components. The selectors weigh one
     class, so they beat the two rules above by order and .ds-enter and
     .ds-leave below still win. */
  .ds-stagger > :where(.ds-skeleton, .ds-loading, .ds-boot, .ds-spinner, .ds-loader),
  .ds-reveal:where(.ds-skeleton, .ds-loading, .ds-boot, .ds-spinner, .ds-loader) {
    animation: revert-layer;
  }

  /*
   * Reveal on scroll (opt-in, long read-only pages: reports, overviews):
   * a block fades in and rises --ds-distance-md while it enters the
   * scrollport, tied to the scroll position (view()), so it never plays on
   * its own and scrolling back reverses it. It is opaque once
   * 4 x --ds-space-6 (96px) of it are in view; a block already passed
   * shows its end state. Never on tables, rows, forms or anything typed
   * into or searched, and not together with .ds-enter or .ds-reveal on the
   * same element. Without view() timelines it does nothing: static, never
   * hidden. The shorthand comes first (it resets animation-timeline).
   */
  @supports (animation-timeline: view()) {
    .ds-reveal--scroll {
      --ds-motion-distance: var(--ds-distance-md);
      animation:
        ds-fade-in linear both,
        ds-lift-in var(--ds-ease-out) both;
      animation-timeline: view();
      animation-range: entry 0% entry calc(4 * var(--ds-space-6));
    }

    /* DS.motion.enter() or leave() on such a block plays in time: the
       one-shot utilities set longhands only, which keep the view()
       timeline, so it goes back to the document's here. */
    .ds-reveal--scroll:is(.ds-enter, .ds-leave) {
      animation-timeline: auto;
      animation-range: normal;
    }
  }

  /* Paper has no scroll position: every block prints at its end state. */
  @media print {
    .ds-reveal--scroll {
      animation: none;
    }
  }

  /*
   * One-shot entrance. Variants pick the keyframes; `--ds-i` (set by
   * stagger() in js/motion.js) delays each item of a list by one
   * --ds-stagger step. Every variant fades on the standard curve: movement
   * runs as a second animation (the lists pair up; duration, delay and fill
   * repeat) and floats on the spring, so the fade never overshoots.
   *
   * It plays once and ends, also on an element with its own loop (the
   * entrance replaces that loop while it runs). Never put .ds-enter on a
   * looping element in static markup: wrap it (spinner.css).
   *
   *   <li class="ds-enter ds-enter--scale">   via JS: DS.motion.enter(el, 'scale')
   */
  .ds-enter {
    animation-name: var(--ds-enter-keyframes, ds-fade-in, ds-lift-in);
    animation-duration: var(--ds-enter-duration, var(--ds-dur-medium));
    animation-timing-function: var(--ds-enter-easing, var(--ds-ease-in-out), var(--ds-ease-spatial));
    animation-delay: calc(var(--ds-i, 0) * var(--ds-stagger) + var(--ds-enter-delay, 0ms));
    animation-iteration-count: 1;
    animation-direction: normal;
    animation-play-state: running;
    /* backwards: hold the start state during the delay, then hand back to the
       element's own styles. */
    animation-fill-mode: backwards;
  }

  .ds-enter--fade { --ds-enter-keyframes: ds-fade-in; --ds-enter-easing: var(--ds-ease-in-out); }
  .ds-enter--scale { --ds-enter-keyframes: ds-fade-in, ds-grow-in; }
  .ds-enter--slide { --ds-enter-keyframes: ds-fade-in, ds-shift-in; --ds-enter-duration: var(--ds-dur-slow); --ds-enter-easing: var(--ds-ease-in-out), var(--ds-ease-spatial); }

  /* Content that replaces a placeholder or another view: fade-through, the
     default pair with the smallest rise. The distance applies to this
     element only (the hooks do not inherit): children that enter inside it
     keep their own distance. */
  .ds-enter--swap { --ds-motion-distance: var(--ds-distance-sm); }

  /* Waits before it shows: fast loads never flash a loading indicator. */
  .ds-enter--delayed { --ds-enter-keyframes: ds-fade-in; --ds-enter-easing: var(--ds-ease-in-out); --ds-enter-delay: var(--ds-delay-loader); }

  /* A small marker appears (a badge, a counter that gets its first value):
     it springs in from --ds-scale-small (the scale settles just past full
     size, about 1%). */
  .ds-enter--pop { --ds-enter-keyframes: ds-fade-in, ds-pop-in; }

  /* A chart mark grows to its share once (first render of a chart): a
     value travelling, so it glides on the value curve like progress (data
     never overshoots). The mark sets --ds-share; the animation runs it
     from 0. */
  .ds-enter--fill { --ds-enter-keyframes: ds-fade-in, ds-fill-share; --ds-enter-duration: var(--ds-dur-glide); --ds-enter-easing: var(--ds-ease-in-out), var(--ds-ease-value); }

  /* Deprecated in 2.0 (removed in 3.0 at the earliest; use .ds-enter--fill
     with --ds-share): a bar or column that scales to its share. */
  .ds-enter--fill-x { --ds-enter-keyframes: ds-fade-in, ds-fill-x; --ds-enter-duration: var(--ds-dur-glide); --ds-enter-easing: var(--ds-ease-in-out), var(--ds-ease-value); }
  .ds-enter--fill-y { --ds-enter-keyframes: ds-fade-in, ds-fill-y; --ds-enter-duration: var(--ds-dur-glide); --ds-enter-easing: var(--ds-ease-in-out), var(--ds-ease-value); }

  /* One-shot exit; leave() in js/motion.js removes or hides the element
     afterwards. It starts at once and plays once, whatever delay (a loader
     that waited to fade in) or loop the element's own animation had. Small
     exits accelerate (--ds-ease-in, the fade included) in --ds-dur-fast,
     0.6 of the default entrance. The variants pair ds-fade-out with a
     movement-only exit. */
  .ds-leave {
    animation-name: var(--ds-leave-keyframes, ds-fade-out);
    animation-duration: var(--ds-leave-duration, var(--ds-dur-fast));
    animation-timing-function: var(--ds-leave-easing, var(--ds-ease-in));
    animation-delay: 0ms;
    animation-iteration-count: 1;
    animation-fill-mode: forwards;
    pointer-events: none;
  }

  .ds-leave--sink { --ds-leave-keyframes: ds-fade-out, ds-drop-out; }
  .ds-leave--scale { --ds-leave-keyframes: ds-fade-out, ds-shrink-out; --ds-leave-duration: var(--ds-dur-base); }
  .ds-leave--slide { --ds-leave-keyframes: ds-fade-out, ds-shift-out; --ds-leave-duration: var(--ds-dur-base); }
  .ds-leave--pop { --ds-leave-keyframes: ds-fade-out, ds-pop-out; }

  /* Loaders whose loop draws them keep it while they leave (a loading bar
     after its data arrived, a skeleton replaced by content): the loop
     names stay in the list, so they run on where they are, and the exit
     takes the positions after them. A leave variant may name one or two
     keyframes, so its timing is listed twice (a list repeats to the
     number of names; surplus entries are ignored). */
  .ds-leave:where(.ds-loadbar) {
    animation-name: ds-progress-head, ds-progress-tail, var(--ds-leave-keyframes, ds-fade-out);
    animation-duration: var(--ds-loop-indeterminate), var(--ds-loop-indeterminate), var(--ds-leave-duration, var(--ds-dur-fast)), var(--ds-leave-duration, var(--ds-dur-fast));
    animation-timing-function: linear, linear, var(--ds-leave-easing, var(--ds-ease-in)), var(--ds-leave-easing, var(--ds-ease-in));
    animation-iteration-count: infinite, infinite, 1, 1;
    animation-fill-mode: none, none, forwards, forwards;
  }

  .ds-leave:where(.ds-skeleton) {
    animation-name: ds-skeleton-wave, var(--ds-leave-keyframes, ds-fade-out);
    animation-duration: var(--ds-loop-skeleton), var(--ds-leave-duration, var(--ds-dur-fast)), var(--ds-leave-duration, var(--ds-dur-fast));
    animation-timing-function: linear, var(--ds-leave-easing, var(--ds-ease-in)), var(--ds-leave-easing, var(--ds-ease-in));
    animation-delay: calc(var(--ds-delay-loader) + var(--ds-dur-medium)), 0ms, 0ms;
    animation-iteration-count: infinite, 1, 1;
    animation-fill-mode: backwards, forwards, forwards;
  }

  /*
   * A slot where outgoing and incoming content cross (roll() and
   * crossfade() in js/motion.js): every child takes the same grid cell, so
   * while both are there the slot keeps the width of the wider one instead
   * of jumping, and a line that rolls in or out is clipped at the slot.
   *
   *   <span class="ds-swap"><span>Saving ...</span></span>   via JS: DS.motion.roll(el, 'Saved')
   */
  .ds-swap {
    display: inline-grid;
    overflow: clip;
  }

  .ds-swap > * {
    grid-area: 1 / 1;
  }

  /*
   * A soft slot edge (2.0): lines appear from and leave behind a soft edge
   * instead of a hard cut. The slot takes --ds-swap-fade (default
   * --ds-space-2) of room above and below, given back by a negative margin
   * so nothing moves, and a static vertical mask fades that room. Nothing
   * in the mask animates.
   *
   *   <span class="ds-swap ds-swap--fade"><span>Syncing 3 of 40</span></span>
   */
  .ds-swap--fade {
    padding-block: var(--ds-swap-fade, var(--ds-space-2));
    margin-block: calc(-1 * var(--ds-swap-fade, var(--ds-space-2)));
    -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--ds-swap-fade, var(--ds-space-2)), #000 calc(100% - var(--ds-swap-fade, var(--ds-space-2))), transparent);
    mask-image: linear-gradient(to bottom, transparent, #000 var(--ds-swap-fade, var(--ds-space-2)), #000 calc(100% - var(--ds-swap-fade, var(--ds-space-2))), transparent);
  }

  /*
   * Icon swap (copy to check, play to pause): two icons in one grid cell.
   * data-swap="b" on the wrapper, or (without data-swap) an ancestor with
   * aria-pressed="true", shows b; otherwise a shows. The incoming icon
   * fades in (--ds-dur-fast) and springs up from --ds-scale-small
   * (--ds-dur-medium); the outgoing one only fades (--ds-dur-instant,
   * accelerating) and takes its small scale once it is gone. A
   * pathLength="1" glyph in b (the check) draws itself as it comes. A
   * crossfade with a scale, not a path morph. Transitions, not keyframes:
   * nothing plays on the first render, and a rapid toggle turns back from
   * where the icons are. DS.motion.crossfade(el, icon, { kind: 'icon' })
   * builds the markup and flips data-swap.
   *
   *   <span class="ds-icon-swap" data-swap="a">
   *     <span class="ds-icon-swap__a">(copy)</span><span class="ds-icon-swap__b">(check)</span>
   *   </span>
   */
  .ds-icon-swap {
    display: inline-grid;
    place-items: center;
    vertical-align: middle;
  }

  .ds-icon-swap > :is(.ds-icon-swap__a, .ds-icon-swap__b) {
    grid-area: 1 / 1;
    display: inline-flex;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      scale var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  /* Hidden: b at rest, a while b shows. b's drawn glyph waits undrawn:
     the face carries stroke-dashoffset and the glyph inherits it (one
     state on the face, so engines that restyle only the face's own
     subtree still draw the glyph). */
  .ds-icon-swap > .ds-icon-swap__b,
  .ds-icon-swap:where([data-swap="b"], [aria-pressed="true"] :not([data-swap])) > .ds-icon-swap__a {
    opacity: 0;
    scale: var(--ds-scale-small);
    stroke-dashoffset: 1;
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in),
      scale 0s var(--ds-dur-instant),
      stroke-dashoffset 0s var(--ds-dur-instant);
  }

  .ds-icon-swap:where([data-swap="b"], [aria-pressed="true"] :not([data-swap])) > .ds-icon-swap__b {
    opacity: 1;
    scale: 1;
    stroke-dashoffset: 0;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      scale var(--ds-dur-medium) var(--ds-ease-spatial),
      stroke-dashoffset var(--ds-dur-medium) var(--ds-ease-out);
  }

  .ds-icon-swap__b [pathLength] {
    stroke-dasharray: 1;
  }

  /*
   * Disclosure: <details class="ds-collapsible"> opens and closes with a
   * height transition where the browser can animate to `auto` (progressive
   * enhancement; elsewhere it simply toggles). A transition takes the
   * timing of the state it moves to, so the closed rule is the close and
   * the [open] rule the open: it opens over --ds-dur-medium on
   * --ds-ease-out (the height pushes the content below, so it never
   * overshoots, D29) and closes faster, accelerating; the content fades
   * with it. content-visibility steps at the end of the close
   * (allow-discrete), so the content stays painted while it goes.
   *
   * The content box clips during the height change, but its clip reaches
   * out by --ds-space-3 (12px): a 2px ring at its snap start (offset +
   * width out, then its own width) plus about --ds-space-1 of room, so the
   * ring of a control flush with the content edge is never cut. One token,
   * not a calc() of the focus tokens: Chromium computes a calc() in
   * overflow-clip-margin to 0. Larger shadows (--ds-elev-2/3) may still be
   * cut a little. Where overflow-clip-margin is missing, give the content
   * that much padding (the ring room) instead.
   */
  @supports (interpolate-size: allow-keywords) {
    .ds-collapsible {
      interpolate-size: allow-keywords;
    }

    .ds-collapsible::details-content {
      height: 0;
      opacity: 0;
      overflow: clip;
      overflow-clip-margin: var(--ds-space-3);
      transition:
        height var(--ds-dur-base) var(--ds-ease-in),
        opacity var(--ds-dur-fast) var(--ds-ease-in),
        content-visibility var(--ds-dur-base) allow-discrete;
    }

    .ds-collapsible[open]::details-content {
      height: auto;
      opacity: 1;
      transition:
        height var(--ds-dur-medium) var(--ds-ease-out),
        opacity var(--ds-dur-medium) var(--ds-ease-in-out),
        content-visibility var(--ds-dur-medium) allow-discrete;
    }
  }

  /*
   * Disclosure chevron (D25): turns 180deg with the expansion on the spring,
   * in a summary of an open <details> or under aria-expanded="true" (a
   * table row's toggle, a menu trigger).
   *
   *   <summary>Advanced <svg class="ds-chevron" ...>(chevronDown)</svg></summary>
   *   <button aria-expanded="false" ...><svg class="ds-chevron" ...>(chevronDown)</svg></button>
   */
  .ds-chevron {
    transition: rotate var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  /* A collapsible's summary with a chevron drops the native marker. */
  .ds-collapsible > summary:has(> .ds-chevron) {
    list-style: none;
  }

  .ds-collapsible > summary:has(> .ds-chevron)::-webkit-details-marker {
    display: none;
  }

  .ds-chevron:where([open] > summary *, [aria-expanded="true"] *) {
    rotate: 180deg;
  }

  /*
   * Presence: a project element that is toggled with the hidden attribute,
   * as a [popover] or as a <dialog> without DS.overlay fades in and grows
   * from --ds-motion-scale (default --ds-scale-popup) on the spring, its
   * clip opening from --ds-clip-from (DS.motion.originFrom() writes it;
   * by default the clip stays open), and leaves faster, accelerating.
   * display and overlay step with allow-discrete, so it stays rendered (and
   * in the top layer) until its exit ends. Plain transitions retarget by
   * themselves: open, close, open continues from where it is. The exit
   * state is three separate rules, so an engine that does not know
   * :popover-open drops only its own rule. Elsewhere: an instant toggle.
   * The lists also carry the element's own state transitions (background,
   * colour, border, shadow on --ds-dur-fast, the focus snap of
   * outline-offset), so a pill inside keeps its hover and focus motion; put
   * .ds-presence on a wrapper, never on a .ds-btn, .ds-chip or .ds-card
   * (their own transition lists would be replaced).
   */
  @supports (transition-behavior: allow-discrete) {
    .ds-presence {
      clip-path: inset(calc(-1 * var(--ds-space-6)));
      transition:
        opacity var(--ds-dur-fast) var(--ds-ease-in-out),
        scale var(--ds-dur-medium) var(--ds-ease-spatial),
        clip-path var(--ds-dur-medium) var(--ds-ease-spatial),
        background-color var(--ds-dur-fast) var(--ds-ease-in-out),
        color var(--ds-dur-fast) var(--ds-ease-in-out),
        border-color var(--ds-dur-fast) var(--ds-ease-in-out),
        box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
        outline-offset var(--ds-dur-fast) var(--ds-ease-out),
        display var(--ds-dur-medium) allow-discrete,
        overlay var(--ds-dur-medium) allow-discrete;
    }

    @starting-style {
      .ds-presence:not(.ds-presence--keep) {
        opacity: 0;
        scale: var(--ds-motion-scale, var(--ds-scale-popup));
        clip-path: var(--ds-clip-from, inset(calc(-1 * var(--ds-space-6))));
      }
    }

    .ds-presence[hidden] {
      opacity: 0;
      scale: var(--ds-motion-scale, var(--ds-scale-popup));
      clip-path: var(--ds-clip-from, inset(calc(-1 * var(--ds-space-6))));
      transition:
        opacity var(--ds-dur-fast) var(--ds-ease-in-out),
        scale var(--ds-dur-fast) var(--ds-ease-in),
        clip-path var(--ds-dur-fast) var(--ds-ease-in),
        background-color var(--ds-dur-fast) var(--ds-ease-in-out),
        color var(--ds-dur-fast) var(--ds-ease-in-out),
        border-color var(--ds-dur-fast) var(--ds-ease-in-out),
        box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
        outline-offset var(--ds-dur-fast) var(--ds-ease-out),
        display var(--ds-dur-fast) allow-discrete,
        overlay var(--ds-dur-fast) allow-discrete;
    }

    dialog.ds-presence:not([open]) {
      opacity: 0;
      scale: var(--ds-motion-scale, var(--ds-scale-popup));
      clip-path: var(--ds-clip-from, inset(calc(-1 * var(--ds-space-6))));
      transition:
        opacity var(--ds-dur-fast) var(--ds-ease-in-out),
        scale var(--ds-dur-fast) var(--ds-ease-in),
        clip-path var(--ds-dur-fast) var(--ds-ease-in),
        background-color var(--ds-dur-fast) var(--ds-ease-in-out),
        color var(--ds-dur-fast) var(--ds-ease-in-out),
        border-color var(--ds-dur-fast) var(--ds-ease-in-out),
        box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
        outline-offset var(--ds-dur-fast) var(--ds-ease-out),
        display var(--ds-dur-fast) allow-discrete,
        overlay var(--ds-dur-fast) allow-discrete;
    }

    .ds-presence[popover]:not(:popover-open) {
      opacity: 0;
      scale: var(--ds-motion-scale, var(--ds-scale-popup));
      clip-path: var(--ds-clip-from, inset(calc(-1 * var(--ds-space-6))));
      transition:
        opacity var(--ds-dur-fast) var(--ds-ease-in-out),
        scale var(--ds-dur-fast) var(--ds-ease-in),
        clip-path var(--ds-dur-fast) var(--ds-ease-in),
        background-color var(--ds-dur-fast) var(--ds-ease-in-out),
        color var(--ds-dur-fast) var(--ds-ease-in-out),
        border-color var(--ds-dur-fast) var(--ds-ease-in-out),
        box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
        outline-offset var(--ds-dur-fast) var(--ds-ease-out),
        display var(--ds-dur-fast) allow-discrete,
        overlay var(--ds-dur-fast) allow-discrete;
    }
  }

  /*
   * Presence that keeps its box (2.0): a pill that shows on hover, a "Saved"
   * note, a jump button whose room stays reserved. The page toggles
   * data-ds-shown; hidden is opacity 0 and visibility hidden (out of the
   * tab order and the accessibility tree), so nothing around it moves. It
   * fades in over --ds-dur-fast and out in --ds-dur-instant (exit 0.67);
   * visibility steps at the end of the exit. Baseline: no @starting-style
   * or allow-discrete needed.
   *
   *   <span class="ds-presence ds-presence--keep" [data-ds-shown]>...</span>
   */
  .ds-presence--keep {
    opacity: 0;
    visibility: hidden;
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in-out),
      visibility 0s linear var(--ds-dur-instant);
  }

  .ds-presence--keep[data-ds-shown] {
    opacity: 1;
    visibility: visible;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      visibility 0s;
  }

  /* A node that DS.dom.morphHtml moves with insertBefore (engines without
     moveBefore) is new to the style engine, so its @starting-style
     entrances (a status icon, an error message's room) would play again on
     every sort. It carries data-ds-moving for one style flush: no
     transitions, so data changes glide and never replay an entrance.
     DS.indicator.createIndicator() sets it on the track for the flush that
     gives its items their look under the new pill. */
  :where(.ds-theme) [data-ds-moving],
  :where(.ds-theme) [data-ds-moving] * {
    transition: none;
  }

  /* ── View transitions ──────────────────────────── */
  /*
   * DS.motion.transition() (js/motion.js) writes data-ds-vt="<type>" on
   * <html> while a View Transition runs; shell.css, sheet.css and
   * toast.css name the view, the panel, the docked drawer, an open sheet's
   * body and a snackbar region with messages only then (and it adds
   * data-ds-vt-morph while the panel changes width), and an
   * indicator created with a `name` carries its own name for good. Every
   * selector starts at .ds-theme, so it only matches when the theme sits on
   * <html> (the pseudo-elements hang off the document element and inherit
   * its tokens) and never restyles a host page. Durations and curves are
   * tokens, so --ds-time-scale slows them too.
   *
   * Old and new are two images of one moment, not a component's entrance
   * and exit: the exit-to-entrance ratio does not apply, and fade-through
   * lets the old view go in --ds-dur-instant by design. No keyframes of
   * their own: the ds-fade-*, ds-grow-in and ds-shift-in/-out pairs.
   */

  /* A view outside the app frame (2.0): a wizard, a sign-in page or a page
     without .ds-app names its .ds-vt-view ds-view while a transition runs,
     so it plays like .ds-view. One per page, never inside .ds-app (the
     frame's view has the name then). Without View Transitions
     DS.motion.transition() enters its first child with .ds-enter--swap.

       <main class="ds-vt-view"><section>(step 1)</section></main> */
  .ds-theme[data-ds-vt] .ds-vt-view {
    view-transition-name: ds-view;
  }

  /* The root (the top bar, the backdrop, everything unnamed): the browser's
     crossfade, short; pixels that did not change stay still under its
     plus-lighter blend. */
  .ds-theme::view-transition-old(root),
  .ds-theme::view-transition-new(root) {
    animation-duration: var(--ds-dur-fast);
    animation-timing-function: var(--ds-ease-in-out);
  }

  /* Every group moves and resizes on the spring, over the indicator's cap
     (--ds-dur-slow): a named indicator glides to its new item this way on
     a page load (css/optional/view-transitions.css). */
  .ds-theme::view-transition-group(*) {
    animation-duration: var(--ds-dur-slow);
    animation-timing-function: var(--ds-ease-spatial);
  }

  /* The docked drawer stays the drawer: its new image is live and covers
     the old one at once (no crossfade), so the pill glides to the new
     destination under its labels and the labels change colour, as on a
     click (DS.motion.transition() lifts an indicator's name meanwhile).
     When the drawer leaves or comes back with the view, the :only-child
     image plays its own motion. */
  .ds-theme::view-transition-new(ds-drawer) {
    animation: none;
  }

  /* Fade-through, the default (without a condition on data-ds-vt, so the
     opt-in stylesheet for page loads reuses it): the old view goes quickly
     and accelerating, then the new one fades in and grows from
     --ds-scale-enter in place. */
  .ds-theme::view-transition-old(ds-view),
  .ds-theme::view-transition-old(ds-sheet-body) {
    animation: ds-fade-out var(--ds-dur-instant) var(--ds-ease-in) both;
  }

  .ds-theme::view-transition-new(ds-view),
  .ds-theme::view-transition-new(ds-sheet-body) {
    --ds-motion-distance: 0px;
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) var(--ds-dur-instant) both,
      ds-grow-in var(--ds-dur-medium) var(--ds-ease-spatial) var(--ds-dur-instant) both;
  }

  /* Shared axis X: forward, the old view leaves to the start while the new
     one comes from the end; back mirrors both. The two images travel on
     one axis on the spring and land together at --ds-dur-slow; the fades
     are those of fade-through. The new image travels with its fade (after
     --ds-dur-instant, over --ds-dur-medium): the spring covers most of
     its way early, so a view that moved while still invisible would only
     settle on screen. */
  .ds-theme[data-ds-vt="forward"]::view-transition-old(ds-view),
  .ds-theme[data-ds-vt="forward"]::view-transition-old(ds-sheet-body) {
    --ds-motion-distance: calc(-1 * var(--ds-distance-xl));
    animation:
      ds-fade-out var(--ds-dur-instant) var(--ds-ease-in) both,
      ds-shift-out var(--ds-dur-slow) var(--ds-ease-spatial) both;
  }

  .ds-theme[data-ds-vt="forward"]::view-transition-new(ds-view),
  .ds-theme[data-ds-vt="forward"]::view-transition-new(ds-sheet-body) {
    --ds-motion-distance: var(--ds-distance-xl);
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) var(--ds-dur-instant) both,
      ds-shift-in var(--ds-dur-medium) var(--ds-ease-spatial) var(--ds-dur-instant) both;
  }

  .ds-theme[data-ds-vt="back"]::view-transition-old(ds-view),
  .ds-theme[data-ds-vt="back"]::view-transition-old(ds-sheet-body) {
    --ds-motion-distance: var(--ds-distance-xl);
    animation:
      ds-fade-out var(--ds-dur-instant) var(--ds-ease-in) both,
      ds-shift-out var(--ds-dur-slow) var(--ds-ease-spatial) both;
  }

  .ds-theme[data-ds-vt="back"]::view-transition-new(ds-view),
  .ds-theme[data-ds-vt="back"]::view-transition-new(ds-sheet-body) {
    --ds-motion-distance: calc(-1 * var(--ds-distance-xl));
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) var(--ds-dur-instant) both,
      ds-shift-in var(--ds-dur-medium) var(--ds-ease-spatial) var(--ds-dur-instant) both;
  }

  /* Container transform: the card or row (ds-container before the update)
     grows into its detail (ds-container after it) on the spring over
     --ds-dur-long, a large surface. One surface grows: the new image is
     live and opaque from the first frame, and the old one lies on top of
     it and fades out within --ds-dur-fast (two images fading at once
     would leave the group see-through halfway, the page showing through
     a card that vanishes and a panel that fades in). */
  .ds-theme[data-ds-vt="container"]::view-transition-group(ds-container) {
    animation-duration: var(--ds-dur-long);
    animation-timing-function: var(--ds-ease-spatial);
  }

  /* Both images keep the browser's sizing (as wide as the group, their own
     aspect, from the top) and the pair clips them to the group: a card is
     never stretched into the shape of its detail, nor magnified to cover a
     much taller panel (cover would scale a card's text several times). */
  .ds-theme[data-ds-vt="container"]::view-transition-image-pair(ds-container) {
    overflow: clip;
  }

  .ds-theme[data-ds-vt="container"]::view-transition-old(ds-container) {
    z-index: var(--ds-z-raised);
    animation: ds-fade-out var(--ds-dur-fast) var(--ds-ease-in-out) both;
  }

  .ds-theme[data-ds-vt="container"]::view-transition-new(ds-container) {
    animation: none;
  }

  /* A view with or without the drawer (data-drawer-hidden toggled inside
     DS.motion.transition()): the panel morphs to its new width instead of
     the grid jumping, a large surface on the spring over --ds-dur-long.
     Its images are only the white surface (the content is the separately
     named view), so the new one fills the group at once and the old one
     is not drawn. The view's group travels with it; its images keep their
     natural size from the top left, so text is never scaled while the
     width changes (the group's edge cuts it). Only then (DS.motion.
     transition() sets data-ds-vt-morph when the panel's width changed):
     a plain swap keeps the --ds-dur-slow groups, so the frozen page holds
     input no longer than the view's own motion. Page loads cannot tell,
     so they keep --ds-dur-slow too. */
  .ds-theme[data-ds-vt-morph]::view-transition-group(ds-panel),
  .ds-theme[data-ds-vt-morph]::view-transition-group(ds-view) {
    animation-duration: var(--ds-dur-long);
  }

  /* Stacking inside the transition (two steps of the z scale): a drawer
     that comes back exists in the new state only, so its group comes last
     and would paint over the panel that makes room for it; the surfaces of
     the page lie above the drawer instead, in their own order. The drawer
     lies above an indicator's group (page loads), so a gliding pill passes
     under its labels (the opt-in stylesheet makes the docked drawer
     transparent; it lies on the same backdrop). */
  .ds-theme::view-transition-group(ds-drawer) {
    z-index: var(--ds-z-raised);
  }

  .ds-theme::view-transition-group(ds-panel),
  .ds-theme::view-transition-group(ds-view),
  .ds-theme::view-transition-group(ds-sheet-body),
  .ds-theme::view-transition-group(ds-container) {
    z-index: var(--ds-z-sticky);
  }

  /* Snackbars stay where they are: a region with messages (toast.css
     names it ds-toasts while a transition runs) lies above every group
     of the page and shows only its live image, so a message never blinks
     out under the panel's opaque image; its group follows the region if
     the update moves it (the drawer shown or hidden). A message that goes
     with the update fades out. */
  .ds-theme::view-transition-group(ds-toasts) {
    z-index: var(--ds-z-toast);
  }

  .ds-theme::view-transition-old(ds-toasts) {
    opacity: 0;
    animation: none;
  }

  .ds-theme::view-transition-new(ds-toasts) {
    animation: none;
  }

  .ds-theme::view-transition-old(ds-toasts):only-child {
    opacity: 1;
    animation: ds-fade-out var(--ds-dur-fast) var(--ds-ease-in) both;
  }

  /* A drawer in both states shows only its live image (see above); a
     transparent one would otherwise show both label sets at once. */
  .ds-theme::view-transition-old(ds-drawer) {
    opacity: 0;
    animation: none;
  }

  .ds-theme::view-transition-old(ds-panel) {
    opacity: 0;
    animation: none;
  }

  .ds-theme::view-transition-new(ds-panel) {
    width: 100%;
    height: 100%;
    animation: none;
  }

  /* A panel on one side only: a page load from a page whose frame lay
     under the modal drawer or an overlay (css/optional/view-transitions.css
     names it only where nothing covers it) crossfades like the root
     around it, so the drawer and its scrim do not vanish at once. */
  .ds-theme::view-transition-new(ds-panel):only-child {
    animation: ds-fade-in var(--ds-dur-fast) var(--ds-ease-in-out) both;
  }

  .ds-theme::view-transition-old(ds-panel):only-child {
    opacity: 1;
    animation: ds-fade-out var(--ds-dur-fast) var(--ds-ease-in-out) both;
  }

  .ds-theme::view-transition-old(ds-view),
  .ds-theme::view-transition-new(ds-view) {
    height: 100%;
    overflow: clip;
    object-fit: none;
    object-position: left top;
  }

  /* The docked drawer leaves to the start, accelerating, faded within
     --ds-dur-fast (the panel grows over it); it comes back from there on
     the spring while the panel makes room. Only one image exists then:
     the drawer is shown on one side of the transition only. */
  .ds-theme::view-transition-old(ds-drawer):only-child {
    --ds-motion-distance: calc(-1 * var(--ds-distance-xl));
    opacity: 1;
    animation:
      ds-fade-out var(--ds-dur-fast) var(--ds-ease-in) both,
      ds-shift-out var(--ds-dur-base) var(--ds-ease-exit) both;
  }

  .ds-theme::view-transition-new(ds-drawer):only-child {
    --ds-motion-distance: calc(-1 * var(--ds-distance-xl));
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) both,
      ds-shift-in var(--ds-dur-long) var(--ds-ease-spatial) both;
  }
}

/* ── base.css ── */
/*
 * Page base for full apps (opt-in): the backdrop, body type, selection,
 * headings on the type roles (h1 headline, h2 subtitle, h3 to h6 the
 * label role, no margins), links (an icon at either end of a plain link
 * keeps its distance), inline code (just text inside a pre or a link), a
 * rule (hr, a hairline like .ds-divider), marked text (mark, on the tint)
 * and the focus ring. Rendered rich text keeps a hierarchy of its own in
 * .ds-prose. Under .ds-base `hidden` always hides; never add a [hidden]
 * rule of your own. (Border-box sizing applies to all of .ds-theme, see
 * utilities.css.)
 *
 *   <html class="ds-theme ds-base">
 *
 * Every rule is scoped to .ds-base through :where(), which adds no
 * specificity: the rules weigh exactly what bare element selectors would,
 * so any class of the page still wins over them. An embedded widget puts
 * .ds-base on its root element: the html/body rules never match there, so
 * the host page stays untouched.
 */

@layer ds.base {
  /* Under .ds-base `hidden` beats everything, page CSS included. Without
     it (a widget that leaves it off), motion.css still hides ds components that
     carry `hidden` inside .ds-theme, in ds.utilities. */
  :where(.ds-base) [hidden] {
    display: none !important;
  }

  html:where(.ds-base),
  :where(.ds-base) body {
    margin: 0;
    height: 100%;
  }

  /* The tinted backdrop of the whole app: white panels sit on it. */
  html:where(.ds-base) {
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
    background: var(--ds-backdrop);
  }

  :where(.ds-base) body {
    font: var(--ds-font-body);
    color: var(--ds-text);
    background: var(--ds-backdrop);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }

  :where(.ds-base) ::selection {
    background: var(--ds-accent);
    color: var(--ds-on-accent);
  }

  /* Set while a modal layer is open (the page behind must not scroll). */
  :where(.ds-base) body.ds-no-scroll {
    overflow: hidden;
  }

  /* Links are told apart from text by the underline, not by colour. */
  :where(.ds-base) a {
    color: var(--ds-link);
    text-decoration-line: underline;
    text-decoration-thickness: var(--ds-link-underline-thickness);
    text-underline-offset: var(--ds-link-underline-offset);
    text-decoration-color: var(--ds-link-underline);
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      text-decoration-color var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  @media (hover: hover) {
    :where(.ds-base) a:hover {
      text-decoration-color: var(--ds-link-underline-hover);
    }
  }

  /* An icon at the end of a plain link (an external link, "Open") or at its
     start keeps a gap to the text and sits on its centre line. */
  :where(.ds-base) :where(a:not([class])) > svg:last-child {
    margin-inline-start: var(--ds-space-1);
    vertical-align: -0.125em;
  }

  :where(.ds-base) :where(a:not([class])) > svg:first-child {
    margin-inline-end: var(--ds-space-1);
    vertical-align: -0.125em;
  }

  /* Bare headings take the type roles, without the browser's margins:
     element weight only, in ds.base, so every component class and every
     unlayered project rule wins. Two weights, as everywhere. */
  :where(.ds-base) :is(h1, h2, h3, h4, h5, h6) {
    margin: 0;
    color: var(--ds-text);
    letter-spacing: 0;
  }

  :where(.ds-base) h1 {
    font: var(--ds-font-headline);
  }

  :where(.ds-base) h2 {
    font: var(--ds-font-subtitle);
  }

  :where(.ds-base) :is(h3, h4, h5, h6) {
    font: var(--ds-font-label);
  }

  /* A rule: a hairline, as .ds-divider. */
  :where(.ds-base) hr {
    margin: 0;
    border: 0;
    border-top: 1px solid var(--ds-line);
  }

  /* Marked text (a search hit): on the tint. Forced colours map it to Mark
     by themselves. */
  :where(.ds-base) mark {
    border-radius: var(--ds-radius-xs);
    background: var(--ds-accent);
    color: var(--ds-on-accent);
  }

  /* Inline code takes the size and colour of the text around it, only the
     typeface changes (a layered reset's code colour does not reach it).
     Its own box is just its glyphs: the monospace font sits higher on the
     baseline and would open up the line by a pixel. */
  :where(.ds-base) code {
    font-family: var(--ds-font-mono);
    font-size: inherit;
    color: inherit;
    line-height: 1;
    background: var(--ds-code-bg);
    border: 1px solid var(--ds-line);
    border-radius: var(--ds-radius-xs);
    padding: 0 var(--ds-space-1);
  }

  /* Code in a block or a link is part of it: no chip per line. */
  :where(.ds-base) :where(pre, a) code {
    padding: 0;
    border: 0;
    background: none;
    line-height: inherit;
  }

  :where(.ds-base) button,
  :where(.ds-base) input,
  :where(.ds-base) select,
  :where(.ds-base) textarea {
    font-family: inherit;
  }

  /* Two weights, 400 and the label weight: emphasis in UI copy is a label,
     not the browser's bold. */
  :where(.ds-base) strong,
  :where(.ds-base) b {
    font-weight: var(--ds-weight-label);
  }

  /* The ring is solid from the first frame and snaps in: links (above),
     disclosure summaries and focusable elements rest one ring width further
     out. Other elements, and programmatic focus targets (tabindex -1, such
     as the drawer, whose forced-colours edge is an outline), show it at
     once. The outline follows the element's radius. */
  :where(.ds-base) :where(summary, [tabindex]:not([tabindex^="-"])) {
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  :where(.ds-base) :focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }
}


/* ── components/utilities.css ── */
/*
 * Utilities: border-box sizing inside .ds-theme, screen-reader text and
 * small text helpers, the press model for a project element, actions that
 * show on demand, keys, dividers, icons that fill and touch targets.
 *
 *   .ds-sr       visually hidden, still read by screen readers
 *   .ds-muted    quieter body text (tabular figures, no margin)
 *   .ds-meta     a meta line: hints, "last saved ..." (tabular figures);
 *                a leading 16px icon (> svg:first-child) sits on its line
 *   .ds-stale    content on its way out, dimmed until it is replaced (set
 *                aria-busy="true" on the container while it is stale)
 *   .ds-num      right-aligned tabular figures (table cells)
 *   .ds-center   centred text
 *   .ds-nowrap   keeps a value and its unit together
 *
 * .ds-stale dims only after --ds-delay-loader, so an update that answers
 * within 200ms never dims; taking it off is instant, so fresh values show
 * at once (docs/motion.md, "Data motion").
 *
 * Every component scroller is positioned (a containing block), so
 * .ds-sr text and absolute parts inside it never escape to the page.
 *
 * A pressable element of your own (a row, a card of a project component):
 *
 *   <div class="x-row ds-pressable" tabindex="0" role="button">...</div>
 *   .ds-pressable--lg     the larger hover layer (--ds-state-hover-lg) of big surfaces
 *   .ds-pressable--inset  the ring inside the edge (rests at 0, snaps to -width)
 *
 * The state layer (::before) in var(--ds-pressable-layer, currentColor):
 * hover --ds-state-hover inside @media (hover: hover), the press
 * --ds-state-pressed in --ds-dur-micro (also while js/ripple.js holds
 * .ds-pressed), the focus layer --ds-state-focus at once with the ring,
 * which rests at offset + width and snaps to the offset (--ds-dur-fast).
 * It is a ripple host (DS.ripple.attach()); under .ds-ripples the press is
 * the ripple's. No morph: pills are .ds-btn. Disabled, aria-disabled and
 * aria-busy show no hover or press layer, no ripple and the not-allowed
 * cursor (aria-disabled keeps the focus layer). Forced colours: no layer,
 * the ring stays.
 *
 * Actions that show on demand (a row's edit button):
 *
 *   <td><span class="ds-hover-actions"><button class="ds-btn ds-btn--quiet ds-btn--icon ds-btn--sm" type="button" aria-label="Edit">...</button></span></td>
 *
 * .ds-hover-actions fades in (opacity, --ds-dur-fast) while its nearest
 * host (a tr, li, .ds-card, .ds-drawer__row or .ds-hover-host: a table in
 * a card answers to its rows, not to the card) is hovered (devices that
 * hover), holds the focus, is current or selected (a drawer row: its item
 * is current) or carries data-ds-shown, or while the actions hold an open
 * menu ([aria-expanded="true"]) or data-ds-shown; it fades out
 * faster (--ds-dur-instant, 0.67). Always shown on touch screens
 * (hover: none) and under .ds-touch. Opacity only: the buttons stay in the
 * tab order and the accessibility tree, and hover moves nothing.
 *
 *   <kbd class="ds-kbd">Ctrl</kbd>    a key (the code font in a hairline box)
 *   <hr class="ds-divider">           a hairline (--inset: from --ds-space-4)
 *   <span class="ds-divider ds-divider--vertical" role="separator" aria-orientation="vertical"></span>
 *   <div class="ds-divider ds-divider--label" role="separator"><span>Today</span></div>
 *
 * Icons that fill when on (js/icons.js, the fillable glyphs):
 * .ds-icon--fillable on the <svg> fills its last element in currentColor
 * while an ancestor is [aria-pressed="true"] or [aria-checked="true"]
 * (fill on --ds-dur-fast); .ds-icon--filled fills it always.
 *
 * Touch screens: .ds-touch (on .ds-theme or any container) sets every
 * target hook to --ds-touch-target (48px) and fields to the input type
 * role. Opt in (docs/theming.md, "Touch screens"); never automatic.
 *
 * Hooks: --ds-pressable-radius (--ds-radius-sm), --ds-pressable-layer
 * (currentColor), --ds-kbd-border (--ds-line-2), --ds-kbd-bg (--ds-surface).
 *
 * Progressive:
 * - text-wrap (a paragraph of .ds-muted avoids a lone last word): without
 *   it the text wraps as usual.
 */

@layer ds.components {
  /* Every component is measured as border-box (a 100% wide field with its
     padding, a dialog of min(400px, 100%)). Zero specificity, so a host page
     that embeds .ds-theme keeps any sizing it sets itself. */
  :where(.ds-theme),
  :where(.ds-theme) *,
  :where(.ds-theme) *::before,
  :where(.ds-theme) *::after {
    box-sizing: border-box;
  }

  /* Placed at the top left of its positioned ancestor: left where it stands
     (the end of a row that scrolls sideways) it would widen the nearest
     positioned scroller, up to a sideways scroll of the page. */
  .ds-sr {
    position: absolute !important;
    top: 0 !important;
    inset-inline-start: 0 !important;
    width: 1px !important;
    height: 1px !important;
    padding: 0 !important;
    margin: -1px !important;
    overflow: hidden !important;
    clip: rect(0, 0, 0, 0) !important;
    white-space: nowrap !important;
    border: 0 !important;
  }

  .ds-muted {
    margin: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-body);
    /* Counts and times beside a title line up. */
    font-variant-numeric: tabular-nums;
  }

  /* A paragraph of notes keeps the reading measure and ends without a lone
     last word. */
  p.ds-muted {
    max-width: var(--ds-measure);
    text-wrap: pretty;
  }

  .ds-meta {
    margin: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
  }

  /* A 16px glyph leading the line fills the meta role's 16px line from
     its top, so it centres on the text without growing the line (on the
     baseline it stood 4px high and the line grew), and keeps the gap of
     .ds-kv-list__icon to the text. */
  .ds-meta > svg:first-child {
    display: inline-block;
    margin-inline-end: var(--ds-space-1);
    vertical-align: top;
  }

  /* Content on its way out (a filter applied, a test running again): it
     stays in place, dimmed, until the new one replaces it in one step, so
     nothing collapses and jumps back meanwhile. It dims after the loader
     delay (a fast answer never dims); the rest state has no transition, so
     removing the class shows the fresh values at once. */
  .ds-stale {
    opacity: var(--ds-state-stale);
    transition: opacity var(--ds-dur-base) var(--ds-ease-in-out) var(--ds-delay-loader);
  }

  .ds-num {
    text-align: end !important;
    font-variant-numeric: tabular-nums;
  }

  .ds-center {
    text-align: center !important;
  }

  .ds-nowrap {
    white-space: nowrap;
  }

  /*
   * A pressable element of the project's own: the state layer of the
   * system, the focus snap and the ripple; never a morph.
   */
  .ds-pressable {
    position: relative;
    /* The layer (z-index -1) stays above the fill, below the content. */
    isolation: isolate;
    border-radius: var(--ds-pressable-radius, var(--ds-radius-sm));
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-pressable::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: var(--ds-pressable-layer, currentColor);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-pressable:hover::before {
      opacity: var(--ds-state-hover);
    }

    .ds-pressable--lg:hover::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  /* Keyboard focus: the layer appears with the ring and fades out on blur. */
  .ds-pressable:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  .ds-pressable:active::before,
  .ds-pressable.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  .ds-pressable:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* An inset ring (a row in a list that clips): it rests on the edge and snaps inward. */
  .ds-pressable--inset {
    outline-offset: 0;
  }

  .ds-pressable--inset:focus-visible {
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  /* Blocked: no hover or press layer and no ripple (js/ripple.js); an
     aria-disabled element stays focusable and keeps its focus layer. */
  .ds-pressable:is(:disabled, [aria-disabled="true"], [aria-busy="true"]) {
    cursor: not-allowed;
  }

  @media (hover: hover) {
    .ds-pressable:is(:disabled, [aria-disabled="true"], [aria-busy="true"]):hover::before {
      opacity: 0;
    }
  }

  .ds-pressable:is(:disabled, [aria-disabled="true"], [aria-busy="true"]):is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  /*
   * Actions that show on demand: hidden by opacity only (still in the tab
   * order and the tree), shown with their nearest host's hover, focus,
   * current or selected state, or their own open menu; they leave faster
   * than they come.
   */
  .ds-hover-actions {
    opacity: 0;
    transition: opacity var(--ds-dur-instant) var(--ds-ease-in-out);
  }

  /* Only the nearest host reveals its actions: a table in a card or a list
     in a list item shows the pointed row's actions, not every row's. A
     hovered host's ancestors are hovered too, so "no host around the
     actions is left unhovered" means the nearest one is hovered. */
  @media (hover: hover) {
    :is(tr, li, .ds-card, .ds-drawer__row, .ds-hover-host):hover .ds-hover-actions:not(:is(tr, li, .ds-card, .ds-drawer__row, .ds-hover-host):not(:hover) .ds-hover-actions) {
      opacity: 1;
      transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
    }
  }

  /* The same for the states: a host in one of them shows its actions
     unless a host nearer the actions is in none of them. An open menu
     counts where it is, in the actions (no :has() on every row and card). */
  :is(tr, li, .ds-card, .ds-drawer__row, .ds-hover-host):is(:focus-within, [aria-current]:not([aria-current="false"]), [aria-selected="true"], .ds-table__row--selected, [data-ds-shown]) .ds-hover-actions:not(:is(tr, li, .ds-card, .ds-drawer__row, .ds-hover-host):is(:focus-within, [aria-current]:not([aria-current="false"]), [aria-selected="true"], .ds-table__row--selected, [data-ds-shown]) :is(tr, li, .ds-card, .ds-drawer__row, .ds-hover-host):not(:focus-within, [aria-current]:not([aria-current="false"]), [aria-selected="true"], .ds-table__row--selected, [data-ds-shown]) .ds-hover-actions),
  .ds-drawer__row:has(> .ds-drawer__item[aria-current]:not([aria-current="false"])) .ds-hover-actions,
  .ds-hover-actions:is(:focus-within, [data-ds-shown], [aria-expanded="true"], :has([aria-expanded="true"])) {
    opacity: 1;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* No hover to reveal them: always there. */
  @media (hover: none) {
    .ds-hover-actions {
      opacity: 1;
    }
  }

  .ds-touch .ds-hover-actions {
    opacity: 1;
  }

  /* A key on the keyboard: the code font in a hairline box. */
  .ds-kbd {
    display: inline-grid;
    place-items: center;
    min-width: var(--ds-space-5);
    padding: 0 var(--ds-space-1);
    border: 1px solid var(--ds-kbd-border, var(--ds-line-2));
    border-radius: var(--ds-radius-xs);
    background: var(--ds-kbd-bg, var(--ds-surface));
    color: var(--ds-text-2);
    font: var(--ds-font-code);
  }

  /* Dividers: a hairline across, inset, upright or around a caption. Borders,
     so they survive forced colours. */
  .ds-divider {
    display: block;
    height: 0;
    margin: 0;
    border: 0;
    border-top: 1px solid var(--ds-line);
  }

  .ds-divider--inset {
    margin-inline-start: var(--ds-space-4);
  }

  .ds-divider--vertical {
    display: inline-block;
    align-self: stretch;
    width: 0;
    height: auto;
    min-height: 1em;
    border-top: 0;
    border-inline-start: 1px solid var(--ds-line);
  }

  .ds-divider--label {
    display: flex;
    align-items: center;
    gap: var(--ds-space-3);
    height: auto;
    border-top: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
  }

  .ds-divider--label::before,
  .ds-divider--label::after {
    content: '';
    flex: 1 1 0;
    border-top: 1px solid var(--ds-line);
  }

  /* Icons that fill when on: the last element of a fillable glyph is a closed outline. */
  .ds-icon--fillable > :last-child {
    fill: transparent;
    transition: fill var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  :is([aria-pressed="true"], [aria-checked="true"]) .ds-icon--fillable > :last-child,
  .ds-icon--filled > :last-child {
    fill: currentColor;
  }

  /*
   * Touch screens (opt-in, docs/theming.md): every target at least
   * --ds-touch-target, fields at the input role (no zoom on focus in iOS).
   */
  .ds-touch {
    --ds-btn-height: var(--ds-touch-target);
    --ds-btn-sm-height: var(--ds-touch-target);
    --ds-btn-icon-size: var(--ds-touch-target);
    --ds-btn-sm-icon-size: var(--ds-touch-target);
    --ds-input-height: var(--ds-touch-target);
    --ds-check-height: var(--ds-touch-target);
    --ds-check-sm-height: var(--ds-touch-target);
    --ds-check-target: var(--ds-touch-target);
    --ds-switch-height: var(--ds-touch-target);
    --ds-switch-halo: var(--ds-touch-target);
    --ds-chip-height: var(--ds-touch-target);
    --ds-combo-option-height: var(--ds-touch-target);
    --ds-menu-item-height: var(--ds-touch-target);
    --ds-drawer-item-height: var(--ds-touch-target);
    --ds-toast-close-size: var(--ds-touch-target);
    --ds-tabs-height: var(--ds-touch-target);
    --ds-disclosure-height: var(--ds-touch-target);
    --ds-input-font: var(--ds-font-input);
  }

  @media (forced-colors: active) {
    .ds-pressable::before {
      display: none;
    }
  }
}

/* ── components/scroller.css ── */
/*
 * Sideways scrollers: a strip wider than its panel (months, a row of
 * cards, a wide chart) that scrolls on its own, with a fade at each end
 * that still has content to show, and the place of a "Latest" pill in a
 * log that sticks to its end.
 *
 *   <div class="ds-scroll-x" tabindex="0" role="region" aria-label="Months">
 *     <div>... content wider than the panel ...</div>
 *   </div>
 *   DS.dom.scrollEdges(scroller)   the attribute fallback (below)
 *
 *   tabindex="0" role="region" aria-label  only when nothing inside can
 *                       take focus: the scroller is then the tab stop that
 *                       arrow keys scroll (WebKit gives a scroller none by
 *                       itself); its ring is inset (it rests at 0 and snaps
 *                       to minus its width) and never masked
 *
 *   <div class="ds-jump"><div class="ds-presence" hidden>
 *     <button class="ds-btn ds-btn--tonal ds-btn--icon" type="button" aria-label="Latest">(arrowDown)</button>
 *   </div></div>
 *                       the last child of a scrolling log: a pill that
 *                       floats --ds-space-4 above the log's bottom edge
 *                       while the log is not at its end (DS.dom.followEnd()
 *                       toggles hidden on the .ds-presence and scrolls to
 *                       the end on a click); the place takes no room
 *
 * The fade: the mask is a gradient whose two ends are the registered
 * numbers --ds-scroll-edge-start and --ds-scroll-edge-end (0 or 1, also
 * in between) times the fade length, so 0 is no fade. Where scroll
 * timelines exist, ds-scroll-edge-start and ds-scroll-edge-end (motion.css)
 * drive them on the scroller's own scroll(self inline) timeline: the start
 * fade grows while the first --ds-space-8 scroll away, the end fade goes
 * over the last --ds-space-8; a strip that fits has an inactive timeline
 * and fades nowhere. Elsewhere DS.dom.scrollEdges() writes
 * data-ds-overflow-start and data-ds-overflow-end (more to scroll at that
 * end), which set the numbers to 1 over --ds-dur-fast (the first
 * measurement at once: the transition waits for data-ds-edges, which it
 * writes after the first edges, so nothing moves on load); without it the
 * strip shows no fade. The mask leaves the scrollbar strip opaque: its height is
 * --ds-scrollbar-height (written by DS.dom.scrollEdges(); 0px without it).
 * The gradient runs left to right (left-to-right pages only).
 * Focus inside lands outside the fades (scroll-padding-inline).
 *
 * The table wrappers of table.css fade on the same two numbers, and the
 * menus (menu.css: shades of their surface) and combobox lists
 * (combobox.css: the same mask) reuse them on the block axis.
 *
 * Hook: --ds-scroll-fade (the fade length, default --ds-space-8; 0px turns
 * the fades of every scroller off: .ds-scroll-x, the card table wrappers
 * and the menu edges).
 *
 * Runtime property (written by DS.dom.scrollEdges(), read with a 0px
 * fallback): --ds-scrollbar-height (its block-axis twin,
 * --ds-scrollbar-width, is read by the combobox list's mask).
 *
 * Print: the strip shows all of its content, unmasked; the jump pill is
 * hidden.
 *
 * Progressive:
 * - scroll-driven animations (animation-timeline: scroll(), animation-range;
 *   the fades follow the scroll position): without them the
 *   data-ds-overflow-* attributes of DS.dom.scrollEdges() set the fades,
 *   or none show.
 * - scrollbar-color and scrollbar-width (a thin, quiet scrollbar): without
 *   them the platform scrollbar shows.
 */

/* The two ends of the fade, as numbers: registered, so they interpolate
   (the scroll-linked keyframes and the attribute transitions); they do not
   inherit, so a scroller inside a scroller keeps its own. */
@property --ds-scroll-edge-start {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

@property --ds-scroll-edge-end {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

@layer ds.components {
  /* Positioned: absolutely placed content (a .ds-sr label) is clipped by
     the strip instead of widening the page. The block padding is the room
     of the focus rings inside; the mask covers the content box and leaves
     the scrollbar strip opaque. */
  .ds-scroll-x {
    position: relative;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
    padding-block: var(--ds-space-1);
    scroll-padding-inline: var(--ds-scroll-fade, var(--ds-space-8));
    -webkit-mask-image:
      linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-scroll-fade, var(--ds-space-8))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-space-8))), transparent),
      linear-gradient(#000, #000);
    mask-image:
      linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-scroll-fade, var(--ds-space-8))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-space-8))), transparent),
      linear-gradient(#000, #000);
    -webkit-mask-size: 100% calc(100% - var(--ds-scrollbar-height, 0px)), 100% var(--ds-scrollbar-height, 0px);
    mask-size: 100% calc(100% - var(--ds-scrollbar-height, 0px)), 100% var(--ds-scrollbar-height, 0px);
    -webkit-mask-position: 0 0, 0 100%;
    mask-position: 0 0, 0 100%;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
  }

  /* The fallback: DS.dom.scrollEdges() says which end has more. Where a
     scroll timeline drives the numbers, the animation wins over these.
     The fades glide only once it has measured (data-ds-edges comes after
     the first edges), so a strip shows its first fades still on load. */
  .ds-scroll-x[data-ds-edges] {
    transition:
      --ds-scroll-edge-start var(--ds-dur-fast) var(--ds-ease-in-out),
      --ds-scroll-edge-end var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-scroll-x[data-ds-overflow-start] {
    --ds-scroll-edge-start: 1;
  }

  .ds-scroll-x[data-ds-overflow-end] {
    --ds-scroll-edge-end: 1;
  }

  /* Scroll-linked: the shorthand first (it resets animation-timeline);
     one timeline for both animations. */
  @supports (animation-timeline: scroll()) {
    .ds-scroll-x {
      animation: ds-scroll-edge-start linear both, ds-scroll-edge-end linear both;
      animation-timeline: scroll(self inline);
      animation-range: 0 var(--ds-space-8), calc(100% - var(--ds-space-8)) 100%;
    }
  }

  /* A scroller that is a tab stop: an inset ring, solid from the first
     frame, that snaps from the edge inward; the mask would cut it, so the
     focused strip shows unmasked. */
  .ds-scroll-x[tabindex] {
    outline-offset: 0;
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-scroll-x[tabindex][data-ds-edges] {
    transition:
      --ds-scroll-edge-start var(--ds-dur-fast) var(--ds-ease-in-out),
      --ds-scroll-edge-end var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-scroll-x[tabindex]:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
    -webkit-mask-image: none;
    mask-image: none;
  }

  /*
   * The place of a "Latest" pill: sticky at the bottom of its log, no room
   * of its own (height 0), so the log's content never moves when the pill
   * comes; only the pill takes the pointer, the place lets clicks through
   * to the log under it. The pill keeps its own height (not stretched to
   * the place's 0) and is lifted by its whole height.
   */
  .ds-jump {
    position: sticky;
    inset-block-end: var(--ds-space-4);
    z-index: var(--ds-z-sticky);
    display: flex;
    align-items: flex-start;
    justify-content: center;
    height: 0;
    overflow: visible;
    pointer-events: none;
  }

  .ds-jump > * {
    pointer-events: auto;
    translate: 0 -100%;
  }

  @media print {
    .ds-scroll-x {
      overflow: visible;
      -webkit-mask-image: none;
      mask-image: none;
    }

    .ds-jump {
      display: none;
    }
  }
}

/* ── components/button.css ── */
/*
 * Button: one component for every button. Every action is a pill
 * (--ds-radius-action).
 *
 *   .ds-btn                  outlined (secondary action)
 *   .ds-btn--outlined        the same, named explicitly
 *   .ds-btn--primary         filled: the main action of a form or dialog
 *   .ds-btn--tonal           the tint: a softer main action
 *   .ds-btn--text            no frame (dialog secondary actions, inline actions)
 *   .ds-btn--quiet           muted until hovered (toolbars, headers)
 *   .ds-btn--icon            round, icon only (needs aria-label)
 *   .ds-btn--disc            hover: a solid disc fades in behind the icon
 *   .ds-btn--sm              compact (tables, card headers, banners); with
 *                            --icon a 32px round button (it reads
 *                            --ds-btn-sm-height and --ds-btn-sm-icon-size)
 *   [aria-pressed="true"]    a toggle that is on: the tint, plus the
 *                            attribute, so it is not told by colour alone;
 *                            in a track with a sliding indicator
 *                            (.ds-has-indicator) the pill paints it: the
 *                            button stays clear and its label changes
 *                            colour on the pill's timing; with
 *                            aria-disabled="true" a choice made once and
 *                            locked: it keeps its on look, no layer
 *   .ds-btn--disclosure      a button that opens a panel; while
 *                            aria-expanded="true" it takes the tint like a
 *                            pressed toggle (so does a button with
 *                            aria-haspopup="dialog"; menu triggers do not)
 *   .ds-btn--adaptive        a labelled button whose label folds away in a
 *                            narrow content panel (600px); anywhere else,
 *                            sheets and dialogs included, in a window of
 *                            600px or less; it leaves a round quiet icon
 *                            button that keeps its name:
 *     <button class="ds-btn ds-btn--adaptive" type="button" data-tip="Export">(download 16)<span class="ds-btn__label">Export</span></button>
 *
 * Link button: .ds-link-btn, a button that reads like a link (an action
 * inside text or a table cell, the subject that opens a detail): the link
 * colour and underline, no padding, border or fill. An inline link: no
 * ripple, no morph; a press darkens its underline (the press feedback on
 * touch screens); keyboard focus a ring solid from the first frame that
 * snaps from offset + width to the offset (outline-offset, --ds-dur-fast).
 *
 *   <button class="ds-link-btn" type="button">LAPTOP-0042</button>
 *
 * Quiet links: .ds-link--quiet (a link) and .ds-link-btn--quiet (with
 * .ds-link-btn): the quieter ink and a hairline underline in --ds-line-2,
 * the link colour and its hover underline on hover; still underlined.
 *
 * Faces: a button whose label changes while it works (DS.motion.busy())
 * stacks every label it can show in one grid cell, so it is as wide as the
 * widest and never changes width:
 *
 *   <button class="ds-btn ds-btn--primary" type="button">
 *     <span class="ds-btn__faces">
 *       <span class="ds-btn__face">Save</span>
 *       <span class="ds-btn__face ds-btn__face--busy" aria-hidden="true"><span class="ds-spinner ds-spinner--sm"></span>Saving</span>
 *       <span class="ds-btn__face ds-btn__face--done" aria-hidden="true">(check 16)Saved</span>
 *     </span>
 *   </button>
 *
 *   [aria-busy="true"]       blocked at once for pointer, keyboard and form
 *                            submission (DS.motion.guard(), which busy()
 *                            and DS.ripple.attach() install); the busy
 *                            face shows only after --ds-delay-loader (a
 *                            fast save shows nothing)
 *   .ds-btn--done            the success face, for --ds-hold-success; its
 *                            check (pathLength="1") draws itself (ds-draw);
 *                            blocked like a busy button
 *
 * The faces roll: a face that leaves rises and fades (--ds-dur-instant,
 * accelerating), the next one rises in from below (--ds-dur-fast); back to
 * rest they roll the other way. Announcing the result (aria-live) stays the
 * page's job.
 *
 * Hover and press lay the content colour over the button at the state-layer
 * opacities (::before), so one rule works on every fill and every palette.
 * Hover never moves anything, and nothing scales on press: the press is the
 * layer (in --ds-dur-micro), the ripple (js/ripple.js) and the shape, which
 * turns squarer (--ds-radius-action-pressed, in --ds-dur-micro) and springs
 * back on release (--ds-ease-spatial, --ds-dur-medium). The rest radius is
 * min(radius, half the height) (--ds-btn-half, internal), never 9999px, so
 * the morph interpolates over its whole run. Disabled, aria-disabled, busy
 * and done buttons neither morph nor show a press layer.
 *
 * Focus: the ring is solid from the first frame and snaps from
 * offset + width to the offset (outline-offset, --ds-dur-fast), with the
 * focus layer (--ds-state-focus) under it; a press stays on top of it.
 *
 * The look comes from --ds-btn-* hooks. A theme may set them on .ds-theme
 * to tune every button; variants set them on the element. Nothing here
 * hard-codes a colour. Hooks: -height, -padding-x, -padding-start, -radius,
 * -radius-pressed, -font, -bg, -color, -border, -state (layer colour),
 * -state-opacity, -state-focus-opacity, -state-pressed-opacity,
 * -hover-color, -hover-border, -hover-shadow, -hover-decoration, -icon-size,
 * -sm-height, -sm-icon-size (read by --sm), -quiet-color, -disabled-bg,
 * -disabled-border.
 *
 * Forced colours: a pressed toggle and an open disclosure button take the
 * system highlight (Highlight / HighlightText, a focused one a CanvasText
 * ring), an aria-disabled button GrayText (a locked choice keeps
 * Highlight).
 *
 * Hooks set on the element by a variant (--ds-btn-padding-start before a
 * leading icon, --ds-btn-quiet-color in the top bar) win over a theme
 * value; change them per element.
 */

@layer ds.components {
  .ds-btn {
    /* Half the height: the largest radius a pill can show. A radius of
       9999px would interpolate almost all of its way off screen and snap
       in the last frame of the morph. */
    --ds-btn-half: calc(var(--ds-btn-height, 36px) / 2);
    position: relative;
    /* The state layer (z-index -1) stays above the fill, below the label. */
    isolation: isolate;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--ds-space-2);
    min-height: var(--ds-btn-height, 36px);
    padding-block: 0;
    padding-inline: var(--ds-btn-padding-start, var(--ds-btn-padding-x, var(--ds-space-4))) var(--ds-btn-padding-x, var(--ds-space-4));
    border: 1px solid var(--ds-btn-border, var(--ds-line-2));
    border-radius: min(var(--ds-btn-radius, var(--ds-radius-action)), var(--ds-btn-half));
    background: var(--ds-btn-bg, transparent);
    color: var(--ds-btn-color, var(--ds-action-ink));
    font: var(--ds-btn-font, var(--ds-font-label));
    letter-spacing: 0;
    text-transform: none;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* State layer */
  .ds-btn::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: var(--ds-btn-state, currentColor);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* Touch devices show no hover; a press shows on any device (below). */
  @media (hover: hover) {
    .ds-btn:hover {
      border-color: var(--ds-btn-hover-border, var(--ds-btn-border, var(--ds-line-2)));
      color: var(--ds-btn-hover-color, var(--ds-btn-color, var(--ds-action-ink)));
      box-shadow: var(--ds-btn-hover-shadow, none);
      text-decoration: var(--ds-btn-hover-decoration, none);
      /* After the shorthand, which resets them: the link underline geometry. */
      text-decoration-thickness: var(--ds-link-underline-thickness);
      text-underline-offset: var(--ds-link-underline-offset);
    }

    .ds-btn:hover::before {
      opacity: var(--ds-btn-state-opacity, var(--ds-state-hover));
    }
  }

  /* Keyboard focus: the layer appears with the ring and fades out on blur
     (the rest transition). After hover, before press: a press stays on top. */
  .ds-btn:focus-visible::before {
    opacity: var(--ds-btn-state-focus-opacity, var(--ds-state-focus));
    transition-duration: 0s;
  }

  /* Press-in in --ds-dur-micro. .ds-pressed (js/ripple.js) holds the press
     for at least --ds-dur-instant, so a quick click shows it whole. */
  .ds-btn:active::before,
  .ds-btn.ds-pressed::before {
    opacity: var(--ds-btn-state-pressed-opacity, var(--ds-state-pressed));
    transition-duration: var(--ds-dur-micro);
  }

  /* Shape morph: squarer while pressed (never rounder than at rest, so a
     square theme stays square), in --ds-dur-micro; the rest transition
     springs it back on release. The state layer follows (inherit). */
  .ds-btn:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"], .ds-btn--done),
  .ds-btn.ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"], .ds-btn--done) {
    border-radius: min(var(--ds-btn-radius-pressed, var(--ds-radius-action-pressed)), var(--ds-btn-radius, var(--ds-radius-action)), var(--ds-btn-half));
    transition:
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* A leading icon sits closer to the edge (Material 3: 12px instead of 16px),
     also the icon of the first face of a button with faces. */
  .ds-btn:is(:has(> svg:first-child), :has(> .ds-btn__faces:first-child > .ds-btn__face:first-child > svg:first-child)) {
    --ds-btn-padding-start: var(--ds-space-3);
  }

  .ds-btn:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  .ds-btn:disabled,
  .ds-btn[aria-busy="true"],
  .ds-btn--done {
    cursor: default;
    pointer-events: none;
  }

  /* aria-disabled without aria-busy: blocked but still focusable and
     hoverable, so a tooltip can say why (the page's script ignores its
     clicks). No state layer and no hover elevation. */
  .ds-btn[aria-disabled="true"]:not([aria-busy="true"]) {
    cursor: not-allowed;
    box-shadow: none;
    text-decoration: none;
  }

  @media (hover: hover) {
    .ds-btn[aria-disabled="true"]:hover::before {
      opacity: 0;
    }
  }

  /* Blocked buttons show no press (a key still makes them :active). They
     keep the focus layer: an aria-disabled button stays focusable for its
     tooltip. */
  .ds-btn:is([aria-disabled="true"], [aria-busy="true"], .ds-btn--done):is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  .ds-btn:is([aria-disabled="true"], [aria-busy="true"], .ds-btn--done):focus-visible::before {
    opacity: var(--ds-btn-state-focus-opacity, var(--ds-state-focus));
  }

  /*
   * Disabled: the neutral Material look by default (--ds-disabled-fill for
   * container and outline, --ds-disabled-ink for the label, full opacity). A
   * theme may dim the whole button instead (--ds-disabled-opacity, see
   * tokens.css). A busy button (aria-busy) is blocked but keeps its colour.
   * Colours and opacity fade on --ds-dur-fast, like every control.
   */
  .ds-btn:disabled,
  .ds-btn[aria-disabled="true"]:not([aria-busy="true"]) {
    opacity: var(--ds-disabled-opacity, var(--ds-state-disabled));
    color: var(--ds-disabled-ink, var(--ds-btn-color, var(--ds-action-ink)));
    background: var(--ds-btn-disabled-bg, var(--ds-btn-bg, transparent));
    border-color: var(--ds-btn-disabled-border, var(--ds-disabled-fill, var(--ds-btn-border, var(--ds-line-2))));
  }

  /* Busy: blocked, but in full colour. Nothing changes on a fast response;
     after the loader delay a spinner and the label say it is working (the
     dimmed disabled look would only flash). */
  .ds-btn[aria-busy="true"] {
    opacity: 1;
  }

  /* Faces: every label of the button in one grid cell, so the widest sets
     the width and nothing jumps when they change. */
  .ds-btn__faces {
    display: inline-grid;
    gap: inherit;
  }

  /* The face that is shown; it enters with a fade on the standard curve and
     rises in, decelerating. */
  .ds-btn__face {
    grid-area: 1 / 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: inherit;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      translate var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* Faces not shown wait below (the ones still to come) or above (the ones
     before the current face), so the leaving face rises out and the next
     one rises in; back to rest they roll down. Leaving accelerates in
     --ds-dur-instant. */
  .ds-btn__face--busy,
  .ds-btn__face--done {
    opacity: 0;
    translate: 0 var(--ds-distance-sm);
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in),
      translate var(--ds-dur-instant) var(--ds-ease-in);
  }

  /* Busy: the label leaves and the spinner face comes only after the loader
     delay, so a fast save changes nothing. */
  .ds-btn[aria-busy="true"] .ds-btn__face:not(.ds-btn__face--busy, .ds-btn__face--done) {
    opacity: 0;
    translate: 0 calc(-1 * var(--ds-distance-sm));
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in) var(--ds-delay-loader),
      translate var(--ds-dur-instant) var(--ds-ease-in) var(--ds-delay-loader);
  }

  .ds-btn[aria-busy="true"] .ds-btn__face--busy {
    opacity: 1;
    translate: none;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out) var(--ds-delay-loader),
      translate var(--ds-dur-fast) var(--ds-ease-out) var(--ds-delay-loader);
  }

  /* The hidden busy face keeps its spinner still: no endless loop per
     button at rest. Paused, not removed, so the arc stays where it is while
     the face fades out. */
  .ds-btn:not([aria-busy="true"]) .ds-btn__face--busy .ds-spinner {
    animation-play-state: paused;
  }

  /* Done: the success face, its check drawing itself. */
  .ds-btn--done:not([aria-busy="true"]) .ds-btn__face:not(.ds-btn__face--done) {
    opacity: 0;
    translate: 0 calc(-1 * var(--ds-distance-sm));
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in),
      translate var(--ds-dur-instant) var(--ds-ease-in);
  }

  .ds-btn--done:not([aria-busy="true"]) .ds-btn__face--done {
    opacity: 1;
    translate: none;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      translate var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-btn__face--done [pathLength] {
    stroke-dasharray: 1;
  }

  /* The check draws on its <svg>; the pathLength="1" path inherits the
     stroke (Chromium can miss a class change above a [pathLength] rule). */
  .ds-btn--done:not([aria-busy="true"]) .ds-btn__face--done > svg {
    animation: ds-draw var(--ds-dur-medium) var(--ds-ease-out) var(--ds-dur-micro) backwards;
  }

  .ds-btn svg {
    flex-shrink: 0;
  }

  /* Filled. Level 1 on hover where the theme has elevation. */
  .ds-btn--primary {
    --ds-btn-border: var(--ds-primary);
    --ds-btn-bg: var(--ds-primary);
    --ds-btn-color: var(--ds-on-primary);
    --ds-btn-hover-shadow: var(--ds-elev-hover);
    --ds-btn-disabled-bg: var(--ds-disabled-fill, var(--ds-btn-bg));
    --ds-btn-disabled-border: var(--ds-disabled-fill, var(--ds-btn-border));
  }

  .ds-btn--tonal {
    --ds-btn-border: var(--ds-accent);
    --ds-btn-bg: var(--ds-accent);
    --ds-btn-color: var(--ds-on-accent);
    --ds-btn-disabled-bg: var(--ds-disabled-fill, var(--ds-btn-bg));
    --ds-btn-disabled-border: var(--ds-disabled-fill, var(--ds-btn-border));
  }

  .ds-btn--outlined {
    --ds-btn-border: var(--ds-line-2);
    --ds-btn-bg: transparent;
    --ds-btn-color: var(--ds-action-ink);
  }

  .ds-btn--text {
    --ds-btn-border: transparent;
    --ds-btn-bg: transparent;
    --ds-btn-color: var(--ds-action-ink);
    --ds-btn-padding-x: var(--ds-space-3);
    --ds-btn-disabled-border: transparent;
  }

  .ds-btn--quiet {
    --ds-btn-border: transparent;
    --ds-btn-bg: transparent;
    --ds-btn-color: var(--ds-btn-quiet-color, var(--ds-text-2));
    --ds-btn-hover-color: var(--ds-text);
    --ds-btn-disabled-border: transparent;
  }

  .ds-btn--icon {
    --ds-btn-half: calc(var(--ds-btn-icon-size, 40px) / 2);
    width: var(--ds-btn-icon-size, 40px);
    height: var(--ds-btn-icon-size, 40px);
    min-height: 0;
    padding: 0;
  }

  /*
   * Disc: a solid --ds-fill-round disc fades in behind the icon instead of
   * a faint layer (round buttons on the backdrop), also under keyboard
   * focus. It only fades, it never grows: in on --ds-dur-base, out on
   * --ds-dur-fast (the state layer's own transition); pressing shows the
   * full disc at once. It morphs and ripples like every pill.
   */
  .ds-btn--disc {
    --ds-btn-state: var(--ds-fill-round);
    --ds-btn-state-opacity: 1;
    --ds-btn-state-focus-opacity: 1;
    --ds-btn-state-pressed-opacity: 1;
  }

  @media (hover: hover) {
    .ds-btn--disc:hover::before {
      transition-duration: var(--ds-dur-base);
    }
  }

  .ds-btn--sm {
    --ds-btn-height: var(--ds-btn-sm-height, 32px);
    --ds-btn-padding-x: var(--ds-space-3);
  }

  .ds-btn--icon.ds-btn--sm {
    --ds-btn-icon-size: var(--ds-btn-sm-icon-size, 32px);
  }

  /* A toggle that is on (a filter shown, "only active"): the tint. So is a
     button whose panel is open (a disclosure, a dialog or side sheet
     opener); a menu trigger stays as it is. */
  .ds-btn[aria-pressed="true"],
  :is(.ds-btn--disclosure, .ds-btn[aria-haspopup="dialog"])[aria-expanded="true"] {
    --ds-btn-border: var(--ds-accent);
    --ds-btn-bg: var(--ds-accent);
    --ds-btn-color: var(--ds-on-accent);
    --ds-btn-hover-color: var(--ds-on-accent);
  }

  /* A choice made once and then locked (aria-disabled while pressed): it
     keeps its on look at full strength and shows no layer; its unpressed
     siblings dim as disabled buttons do. */
  .ds-btn[aria-pressed="true"][aria-disabled="true"]:not([aria-busy="true"]) {
    opacity: 1;
    color: var(--ds-btn-color, var(--ds-action-ink));
    background: var(--ds-btn-bg, transparent);
    border-color: var(--ds-btn-border, var(--ds-line-2));
    cursor: default;
  }

  /*
   * Adaptive label: in a narrow content panel (600px) the label goes
   * visually hidden and the button turns into a round quiet icon button.
   * Everything outside a content panel (the top bar, a page outside the
   * frame) and sheets and dialogs (which may sit inside one but are not
   * its width) follow the window instead; no container on new elements
   * (layout containment would re-anchor popups).
   * Its accessible name stays the label; a data-tip names it to the eye
   * while it is folded. The 1px floor: Firefox can style the button
   * before its container's first layout and read that container as 0px
   * wide; without the floor the button would come folded and unfold on
   * load (its colours and radius transitioning).
   */
  @container ds-content (min-width: 1px) and (max-width: 600px) {
    .ds-btn--adaptive {
      --ds-btn-half: calc(var(--ds-btn-icon-size, 40px) / 2);
      --ds-btn-border: transparent;
      --ds-btn-bg: transparent;
      --ds-btn-color: var(--ds-btn-quiet-color, var(--ds-text-2));
      --ds-btn-hover-color: var(--ds-text);
      width: var(--ds-btn-icon-size, 40px);
      height: var(--ds-btn-icon-size, 40px);
      min-height: 0;
      padding: 0;
    }

    .ds-btn--adaptive.ds-btn--sm {
      --ds-btn-icon-size: var(--ds-btn-sm-icon-size, 32px);
    }

    .ds-btn--adaptive > svg {
      width: 20px;
      height: 20px;
    }

    .ds-btn--adaptive .ds-btn__label {
      position: absolute;
      top: 0;
      inset-inline-start: 0;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }
  }

  @media (max-width: 600px) {
    .ds-btn--adaptive:is(:not(.ds-content *), :is(.ds-sheet, .ds-dialog) *) {
      --ds-btn-half: calc(var(--ds-btn-icon-size, 40px) / 2);
      --ds-btn-border: transparent;
      --ds-btn-bg: transparent;
      --ds-btn-color: var(--ds-btn-quiet-color, var(--ds-text-2));
      --ds-btn-hover-color: var(--ds-text);
      width: var(--ds-btn-icon-size, 40px);
      height: var(--ds-btn-icon-size, 40px);
      min-height: 0;
      padding: 0;
    }

    .ds-btn--adaptive.ds-btn--sm:is(:not(.ds-content *), :is(.ds-sheet, .ds-dialog) *) {
      --ds-btn-icon-size: var(--ds-btn-sm-icon-size, 32px);
    }

    .ds-btn--adaptive:is(:not(.ds-content *), :is(.ds-sheet, .ds-dialog) *) > svg {
      width: 20px;
      height: 20px;
    }

    .ds-btn--adaptive:is(:not(.ds-content *), :is(.ds-sheet, .ds-dialog) *) .ds-btn__label {
      position: absolute;
      top: 0;
      inset-inline-start: 0;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }
  }

  /* A button that reads like a link: an action inside text or a table cell
     (the subject that opens the detail). */
  .ds-link-btn {
    padding: 0;
    border: 0;
    border-radius: var(--ds-radius-xs);
    background: none;
    color: var(--ds-link);
    font: inherit;
    font-weight: var(--ds-weight-label);
    text-align: start;
    text-decoration: underline;
    text-decoration-thickness: var(--ds-link-underline-thickness);
    text-decoration-color: var(--ds-link-underline);
    text-underline-offset: var(--ds-link-underline-offset);
    cursor: pointer;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      text-decoration-color var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  @media (hover: hover) {
    .ds-link-btn:hover {
      text-decoration-color: var(--ds-link-underline-hover);
    }
  }

  /* The press on touch screens, which show no hover: the underline darkens
     like on hover. An inline link has no ripple and no morph. */
  .ds-link-btn:active {
    text-decoration-color: var(--ds-link-underline-hover);
  }

  /* Its own ring, also without .ds-base; it follows the small radius. */
  .ds-link-btn:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* Quiet links: the quieter ink with a hairline underline, the link colour
     on hover; still underlined, so they read as links. */
  .ds-link--quiet,
  .ds-link-btn.ds-link-btn--quiet {
    color: var(--ds-text-2);
    text-decoration-line: underline;
    text-decoration-thickness: var(--ds-link-underline-thickness);
    text-decoration-color: var(--ds-line-2);
    text-underline-offset: var(--ds-link-underline-offset);
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      text-decoration-color var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  @media (hover: hover) {
    .ds-link--quiet:hover,
    .ds-link-btn.ds-link-btn--quiet:hover {
      color: var(--ds-link);
      text-decoration-color: var(--ds-link-underline-hover);
    }
  }

  .ds-link--quiet:active,
  .ds-link-btn.ds-link-btn--quiet:active {
    color: var(--ds-link);
    text-decoration-color: var(--ds-link-underline-hover);
  }

  .ds-link--quiet:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* Toggles in a track with a sliding indicator (js/indicator.js): the pill
     paints the selection. A tint of its own would appear in place while the
     pill still travels (two pills at once), so the button stays clear and
     only its label changes colour, on the pill's timing (--ds-indicator-dur
     on the track). The pressed twin keeps the colour entry: a clicked toggle
     is still pressed (.ds-pressed outlives the click) while its pill
     arrives. Forced colors keep the selection colours below. */
  .ds-has-indicator > .ds-btn[aria-pressed="true"] {
    --ds-btn-border: transparent;
    --ds-btn-bg: transparent;
  }

  .ds-has-indicator > .ds-btn {
    transition:
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-indicator-dur, var(--ds-dur-medium)) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-has-indicator > .ds-btn:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"], .ds-btn--done),
  .ds-has-indicator > .ds-btn.ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"], .ds-btn--done) {
    transition:
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-indicator-dur, var(--ds-dur-medium)) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* Forced colors turn the tint into Canvas, so on would look like off: the
     system's selection colours instead, and a ring that names its colour. */
  @media (forced-colors: active) {
    /* Blocked (aria-disabled): GrayText, like a disabled button. */
    .ds-btn[aria-disabled="true"]:not([aria-busy="true"]) {
      forced-color-adjust: none;
      border-color: GrayText;
      background: Canvas;
      color: GrayText;
    }

    .ds-btn[aria-pressed="true"],
    .ds-btn[aria-pressed="true"][aria-disabled="true"]:not([aria-busy="true"]),
    :is(.ds-btn--disclosure, .ds-btn[aria-haspopup="dialog"])[aria-expanded="true"] {
      forced-color-adjust: none;
      border-color: Highlight;
      background: Highlight;
      color: HighlightText;
    }

    /* Blocked controls stay focusable (their tooltip says why); unforced,
       their ring would keep the brand colour. */
    .ds-btn[aria-pressed="true"]:focus-visible,
    .ds-btn[aria-disabled="true"]:focus-visible,
    :is(.ds-btn--disclosure, .ds-btn[aria-haspopup="dialog"])[aria-expanded="true"]:focus-visible {
      outline-color: CanvasText;
    }
  }
}

/* ── components/spinner.css ── */
/*
 * Spinner: the Material 3 arc, the inline loading indicator (busy buttons,
 * chips, status chips, table cells). The arc grows from 10deg to 270deg and
 * shrinks back while its start advances, so the tail chases the head
 * (ds-spinner-arc, --ds-loop-arc), and the whole ring turns once per
 * --ds-loop-spin (ds-spin). One element, painted by a conic gradient on
 * the two registered angles (--ds-spinner-start, --ds-spinner-arc) through
 * a ring mask; no track by default (M3, 2024). Show it only after
 * --ds-delay-loader, so fast loads never flash it. A block of the page
 * that waits (a section, a sheet, the app start) shows the shape loader
 * instead (loader.css).
 *
 * Never put .ds-enter on the spinner itself in static markup: the entrance
 * replaces the arc and the turn while it runs, and the classes stay. Delay
 * a spinner through .ds-loading (it has its own delay), or wrap it:
 *   <span class="ds-enter ds-enter--delayed"><span class="ds-spinner"></span></span>
 * DS.motion.enter(spinner, 'delayed') is fine: the entrance plays once and
 * enter() removes its classes, so the spinner turns again.
 *
 *   .ds-spinner                on surfaces (16px)
 *   .ds-spinner--sm            inside buttons and chips (12px), takes the
 *                              text colour, on a faint ring of it
 *   .ds-spinner--determinate   circular progress (40px, 4px): the share
 *                              (--ds-progress, 0 ... 1) as an arc, a gap,
 *                              then the remaining track in --ds-accent; the
 *                              arc is wavy (--ds-progress-ring-wave) on a
 *                              plain track, and flat while it is short
 *                              (cross-fading between 6% and 11%). It
 *                              never spins, and its value glides on
 *                              --ds-ease-value over --ds-dur-glide, like
 *                              linear progress and DS.motion.countUp():
 *     <span class="ds-spinner ds-spinner--determinate" role="progressbar" aria-label="Upload"
 *           aria-valuemin="0" aria-valuemax="100" aria-valuenow="40" style="--ds-progress: .4"></span>
 *   .ds-spinner--flat          with --determinate: a plain arc, no wave
 *
 * Inside .ds-progress a determinate spinner reads the share of its
 * .ds-progress (the property inherits) and lands with its bar on the same
 * frame; it has no transition of its own there.
 *
 * Hooks: --ds-spinner-color (the arc), --ds-spinner-track (none, or
 * --ds-accent on the determinate ring), --ds-spinner-width (2px; the
 * determinate ring reads --ds-spinner-determinate-width, 4px). A project that set border-width on a spinner sets
 * --ds-spinner-width instead.
 *
 * The small spinner's faint ring mixes the text colour (color-mix()).
 *
 * Print: the arc and the ring keep their colours (print-color-adjust), so
 * a status chip keeps its glyph and a determinate ring its share.
 */

@layer ds.components {
  .ds-spinner {
    display: inline-block;
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    border: 0;
    border-radius: 50%;
    background: conic-gradient(from var(--ds-spinner-start), var(--ds-spinner-color, var(--ds-primary)) 0 var(--ds-spinner-arc), var(--ds-spinner-track, transparent) 0);
    /* The ring: the inner disc is cut away, half a pixel soft for an even edge. */
    -webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - var(--ds-spinner-width, 2px)), #000 calc(100% - var(--ds-spinner-width, 2px) + .5px));
    mask-image: radial-gradient(farthest-side, transparent calc(100% - var(--ds-spinner-width, 2px)), #000 calc(100% - var(--ds-spinner-width, 2px) + .5px));
    animation:
      ds-spinner-arc var(--ds-loop-arc) linear infinite,
      ds-spin var(--ds-loop-spin) linear infinite;
  }

  .ds-spinner--sm {
    width: 12px;
    height: 12px;
    /* The arc and a faint ring in the text colour. */
    --ds-spinner-color: currentColor;
    --ds-spinner-track: color-mix(in srgb, currentColor 22%, transparent);
  }

  /*
   * Circular progress: the element holds the value; ::before paints the
   * track, ::after the arc, each a ring of its own, so the arc can be wavy
   * on a plain track (M3 Expressive). The arc ends at the share; a gap of
   * about 4px of arc (14deg at the ring's radius) separates it from the
   * track on both ends and opens with the first percent, so an empty ring
   * is a whole track and a full one a whole arc (the colour stops clamp
   * past 360deg).
   */
  .ds-spinner--determinate {
    --ds-spinner-width: var(--ds-spinner-determinate-width, 4px);
    position: relative;
    width: 40px;
    height: 40px;
    background: none;
    -webkit-mask-image: none;
    mask-image: none;
    animation: none;
    transition: --ds-progress var(--ds-dur-glide) var(--ds-ease-value);
  }

  .ds-spinner--determinate::before,
  .ds-spinner--determinate::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    -webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - var(--ds-spinner-width, 2px)), #000 calc(100% - var(--ds-spinner-width, 2px) + .5px));
    mask-image: radial-gradient(farthest-side, transparent calc(100% - var(--ds-spinner-width, 2px)), #000 calc(100% - var(--ds-spinner-width, 2px) + .5px));
  }

  .ds-spinner--determinate::before {
    background: conic-gradient(
      transparent 0 calc(var(--ds-progress) * 360deg + 14deg * min(1, var(--ds-progress) * 100)),
      var(--ds-spinner-track, var(--ds-accent)) 0 calc(360deg - 14deg * min(1, var(--ds-progress) * 100)),
      transparent 0
    );
  }

  .ds-spinner--determinate::after {
    background: conic-gradient(var(--ds-spinner-color, var(--ds-primary)) 0 calc(var(--ds-progress) * 360deg), transparent 0);
  }

  /* The wavy arc (--ds-progress-ring-wave: 7 waves around a 16.4px radius)
     on a plain track at the wave's middle; --flat keeps both plain rings
     at the edge. A short arc would be a loose tick or hook of the wave, so
     up to 6% the arc is flat (a layer of the track's ring, at the wave's
     middle) and the wavy one cross-fades in by 11%, on the gliding share
     (--ds-progress-wave-start, --ds-progress-wave-ramp). */
  .ds-spinner--determinate:not(.ds-spinner--flat)::before {
    inset: 1.6px;
    background:
      conic-gradient(color-mix(in srgb, var(--ds-spinner-color, var(--ds-primary)) clamp(0%, calc(1 - var(--ds-progress) / var(--ds-progress-wave-ramp) + var(--ds-progress-wave-start) / var(--ds-progress-wave-ramp)) * 100%, 100%), transparent) 0 calc(var(--ds-progress) * 360deg), transparent 0),
      conic-gradient(
        transparent 0 calc(var(--ds-progress) * 360deg + 14deg * min(1, var(--ds-progress) * 100)),
        var(--ds-spinner-track, var(--ds-accent)) 0 calc(360deg - 14deg * min(1, var(--ds-progress) * 100)),
        transparent 0
      );
  }

  .ds-spinner--determinate:not(.ds-spinner--flat)::after {
    opacity: calc(var(--ds-progress) / var(--ds-progress-wave-ramp) - var(--ds-progress-wave-start) / var(--ds-progress-wave-ramp));
    -webkit-mask-image: var(--ds-progress-ring-wave);
    mask-image: var(--ds-progress-ring-wave);
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
  }

  /* Nested, the share inherits from .ds-progress, which glides it: a second
     transition here would trail behind the bar. */
  .ds-progress .ds-spinner--determinate {
    transition: none;
  }

  /* Forced colors drop background images: the arc and its track paint in
     system colours, the ring mask stays. */
  @media (forced-colors: active) {
    .ds-spinner {
      forced-color-adjust: none;
      --ds-spinner-color: Highlight;
      --ds-spinner-track: GrayText;
    }
  }

  /* Print: the arc and the ring are backgrounds, which printing drops by
     default; a running status keeps its glyph, a ring its share. */
  @media print {
    .ds-spinner {
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
  }
}

/* ── components/loader.css ── */
/*
 * Shape loader: the Material 3 Expressive loading indicator and the block
 * loader of the system (.ds-boot, .ds-loading). A shape morphs through the
 * seven M3 shapes (soft burst, cookie-9, pentagon, pill, sunny, cookie-4,
 * oval; ds-loader-morph), every step landing on the spatial spring, while
 * it turns once per round (ds-loader-turn, linear); one round takes
 * --ds-loop-loader. Like every loader it fades in only after
 * --ds-delay-loader, so fast loads never show it.
 *
 *   <span class="ds-loader" aria-hidden="true"></span>
 *   <div class="ds-boot" role="status"><span class="ds-loader" aria-hidden="true"></span>Starting ...</div>
 *   <div class="ds-loading" role="status"><span class="ds-loader ds-loader--sm" aria-hidden="true"></span>Loading ...</div>
 *
 *   .ds-loader              48px container, the shape 38px (38/48 of it)
 *   .ds-loader--sm          24px, for the loading line
 *   .ds-loader--contained   the shape on a --ds-accent disc
 *
 * Hooks: --ds-loader-size (48px), --ds-loader-sm-size (24px, read by --sm),
 * --ds-loader-color (--ds-primary).
 *
 * The loader is decoration: aria-hidden, and the surrounding role="status"
 * carries the text. Inside .ds-boot, .ds-loading and .ds-skeleton the
 * container waits its own delay, so the loader drops its fade there (the
 * delays never stack). The loops run on ::before, so .ds-enter on the
 * loader itself replaces only its fade.
 *
 * At most a few per view: the polygon morph repaints every frame. Inline
 * spots (buttons, chips, status chips, table cells) keep the arc spinner
 * (spinner.css).
 *
 * Print: the shape is not printed (its label is).
 */

@layer ds.components {
  .ds-loader {
    display: inline-grid;
    place-items: center;
    flex-shrink: 0;
    width: var(--ds-loader-size, 48px);
    height: var(--ds-loader-size, 48px);
    border-radius: 50%;
    animation: ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) var(--ds-delay-loader) backwards;
  }

  /* The shape: 64-vertex polygons, so every morph is a straight blend. */
  .ds-loader::before {
    content: '';
    width: calc(100% * 38 / 48);
    height: calc(100% * 38 / 48);
    background: var(--ds-loader-color, var(--ds-primary));
    animation:
      ds-loader-morph var(--ds-loop-loader) var(--ds-ease-spatial) infinite,
      ds-loader-turn var(--ds-loop-loader) linear infinite;
  }

  .ds-loader--sm {
    --ds-loader-size: var(--ds-loader-sm-size, 24px);
  }

  .ds-loader--contained {
    background: var(--ds-accent);
  }

  /* The container already waits: no second delay. */
  .ds-boot .ds-loader,
  .ds-loading .ds-loader,
  .ds-skeleton .ds-loader {
    animation: none;
  }

  /* Forced colors drop the fills: the shape in the system highlight, the
     disc of the contained loader as an edge. */
  @media (forced-colors: active) {
    .ds-loader {
      forced-color-adjust: none;
    }

    .ds-loader::before {
      background: Highlight;
    }

    .ds-loader--contained {
      background: Canvas;
      outline: 1px solid CanvasText;
    }
  }

  /* Print: a frozen shape says nothing on paper; the label stays. */
  @media print {
    .ds-loader {
      display: none;
    }
  }
}

/* ── components/indicator.css ── */
/*
 * Sliding indicator (js/indicator.js). The component that uses it gives it
 * a look (background, and its corners through --ds-indicator-radius); this
 * file only moves it.
 *
 * The pill is drawn by clip-path on a box, never by animating width/height:
 * the box (--ds-indicator-x/-y/-w/-h, moved by translate) jumps, the four
 * edges of the clip (--ds-indicator-t/-r/-b/-l, registered lengths) glide.
 * At rest the box is the active item and the clip insets are 0px: a plain
 * rounded pill. A glide makes the box the union of the pill it leaves and
 * the item it goes to, widened on the travel axis by an overshoot margin
 * (data-glide), and moves the edges from the one to the other on the
 * spatial spring. The leading edge leads and the trailing edge follows
 * --ds-indicator-lag later (data-dir), so the pill stretches toward its
 * target; the leading edge passes it by at most 5 % of the travel and
 * settles. The trailing edge runs on --ds-ease-in-out, which never
 * overshoots: the pill never gets shorter than its item and never slides
 * past it (--ds-indicator-ease-t/-r/-b/-l, the curve of each edge, are
 * set by the data-dir rules; component internal). The travel
 * time grows with the distance (from --ds-dur-medium, capped at
 * --ds-dur-slow): js writes it as --ds-indicator-dur on the track, where
 * the labels of the items read it too. The radius springs like a pill's
 * after a press morph, also while the pill rests (the drawer squares the
 * pill under a pressed active item).
 *
 *   <nav class="ds-has-indicator">
 *     <span class="ds-indicator ..." aria-hidden="true" data-ds-keep></span> ...items...
 *   </nav>
 *   (createIndicator() inserts the span and the class.)
 *
 * State on the span, written by js:
 *   data-glide               a glide runs: the box is the union, no own radius
 *   data-dir                 left, right, up or down: the travel; the edge
 *                            on the other side trails
 *   data-empty               no active item: faded out, contracted to the
 *                            centre (it grows from there when one appears)
 *   .ds-indicator--instant   placed without a glide (public)
 *
 * Hooks:
 *   --ds-indicator-radius    the pill's corners (a component class sets it;
 *                            js copies a plain border-radius of a project
 *                            class into it at rest)
 *   --ds-indicator-lag       how long the trailing edge waits (default
 *                            --ds-dur-micro); a motion hook, themes never
 *                            set it
 */

/* The edges and the radius transition as lengths. */
@property --ds-indicator-t {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

@property --ds-indicator-r {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

@property --ds-indicator-b {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

@property --ds-indicator-l {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

@property --ds-indicator-radius {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

@layer ds.components {
  .ds-has-indicator {
    position: relative;
    isolation: isolate;
  }

  /* Box and clip; the order of the transition lists (edges, radius,
     opacity) is the one every rule below keeps. */
  /* Physical on purpose (D9): DS.indicator writes viewport-measured x and
     y, from the top left corner. */
  .ds-indicator {
    position: absolute;
    top: 0;
    left: 0;
    z-index: -1;
    width: var(--ds-indicator-w, 0);
    height: var(--ds-indicator-h, 0);
    translate: var(--ds-indicator-x, 0) var(--ds-indicator-y, 0);
    clip-path: inset(var(--ds-indicator-t) var(--ds-indicator-r) var(--ds-indicator-b) var(--ds-indicator-l) round var(--ds-indicator-radius));
    pointer-events: none;
    transition-property: --ds-indicator-t, --ds-indicator-r, --ds-indicator-b, --ds-indicator-l, --ds-indicator-radius, opacity;
    transition-duration:
      var(--ds-indicator-dur, var(--ds-dur-medium)),
      var(--ds-indicator-dur, var(--ds-dur-medium)),
      var(--ds-indicator-dur, var(--ds-dur-medium)),
      var(--ds-indicator-dur, var(--ds-dur-medium)),
      var(--ds-dur-medium),
      var(--ds-dur-fast);
    transition-timing-function:
      var(--ds-indicator-ease-t, var(--ds-ease-spatial)),
      var(--ds-indicator-ease-r, var(--ds-ease-spatial)),
      var(--ds-indicator-ease-b, var(--ds-ease-spatial)),
      var(--ds-indicator-ease-l, var(--ds-ease-spatial)),
      var(--ds-ease-spatial),
      var(--ds-ease-in-out);
    transition-delay: 0s;
  }

  /* The trailing edge follows: the edge opposite the travel waits, then
     closes in without overshoot (on the spring it would also pass its
     target, and near the end of a long jump the pill would be shorter than
     the item and slide off it). The curve goes through a variable, so a
     rule that restates the curve list (the drawer's pressed pill) keeps it. */
  .ds-indicator[data-dir="right"] {
    --ds-indicator-ease-l: var(--ds-ease-in-out);
    transition-delay: 0s, 0s, 0s, var(--ds-indicator-lag, var(--ds-dur-micro)), 0s, 0s;
  }

  .ds-indicator[data-dir="left"] {
    --ds-indicator-ease-r: var(--ds-ease-in-out);
    transition-delay: 0s, var(--ds-indicator-lag, var(--ds-dur-micro)), 0s, 0s, 0s, 0s;
  }

  .ds-indicator[data-dir="down"] {
    --ds-indicator-ease-t: var(--ds-ease-in-out);
    transition-delay: var(--ds-indicator-lag, var(--ds-dur-micro)), 0s, 0s, 0s, 0s, 0s;
  }

  .ds-indicator[data-dir="up"] {
    --ds-indicator-ease-b: var(--ds-ease-in-out);
    transition-delay: 0s, 0s, var(--ds-indicator-lag, var(--ds-dur-micro)), 0s, 0s, 0s;
  }

  /* While the box is the union of two items, a radius of its own would cut
     into the painted pill (js also sets it inline: unlayered project CSS
     beats this rule). */
  .ds-has-indicator > .ds-indicator[data-glide] {
    border-radius: 0;
  }

  /* Placed without a glide: the edges jump. A radius change (a press morph)
     still springs, and a fade in progress continues. */
  .ds-indicator--instant {
    transition-property: --ds-indicator-radius, opacity;
    transition-duration: var(--ds-dur-medium), var(--ds-dur-fast);
    transition-timing-function: var(--ds-ease-spatial), var(--ds-ease-in-out);
    transition-delay: 0s;
  }

  .ds-indicator[data-empty] {
    opacity: 0;
  }

  /* The active item went away: the pill contracts to its centre, quickly
     and accelerating, while it fades. */
  .ds-indicator[data-glide][data-empty]:not(.ds-indicator--instant) {
    transition-duration: var(--ds-dur-fast), var(--ds-dur-fast), var(--ds-dur-fast), var(--ds-dur-fast), var(--ds-dur-medium), var(--ds-dur-fast);
    transition-timing-function: var(--ds-ease-in), var(--ds-ease-in), var(--ds-ease-in), var(--ds-ease-in), var(--ds-ease-spatial), var(--ds-ease-in);
  }
}

/* ── components/loading.css ── */
/*
 * Loading states. Nothing shows for fast loads: every indicator fades in
 * only after --ds-delay-loader (the same delay as LOADER_DELAY_MS in
 * js/motion.js, whose settleLoader() keeps a visible one up long enough).
 * Their loops always run at their token speed (--ds-loop-* in tokens.css).
 * A block that waits shows the shape loader (loader.css); inline spots
 * keep the arc spinner (spinner.css).
 *
 *   <div class="ds-boot" role="status"><span class="ds-loader" aria-hidden="true"></span>Starting ...</div>
 *                                                     app start, after --ds-delay-boot
 *   <div class="ds-loading" role="status"><span class="ds-loader ds-loader--sm" aria-hidden="true"></span>Loading ...
 *     <p class="ds-loading__detail">812 of 2,030 rows</p></div>   detail lines under the text (2.0)
 *   <div class="ds-skeleton" role="status"> <span class="ds-sr">Loading ...</span>
 *     <span class="ds-loadbar" aria-hidden="true"><span class="ds-loadbar__bar"></span></span>
 *     <span class="ds-skeleton__line ds-skeleton__line--heading"></span> ...
 *     <span class="ds-skeleton__line ds-skeleton__line--lead"></span>
 *     <p class="ds-skeleton__note">Loading 3 of 12 reports</p>      a progress line (2.0); hidden while empty
 *     <div class="ds-skeleton__card"> <span class="ds-skeleton__line ds-skeleton__line--title"></span> ...
 *
 *   .ds-skeleton__line  a body line; --heading (a page title), --lead (its
 *                       lead), --title (a card title), --mid (a middle-length
 *                       line), --short (a last line)
 *
 * The older markup with a .ds-spinner in .ds-boot or .ds-loading keeps
 * working; the spinner is the inline variant (a row in a list, a cell).
 *
 * Hooks: --ds-skeleton-radius (the bones' corners; default
 * --ds-radius-action, the heading line --ds-radius-sm).
 *
 * Skeleton: one synchronised, calm diagonal wave across the whole surface.
 * The surface runs ds-skeleton-wave once its own entrance is over (its
 * delay is --ds-delay-loader plus --ds-dur-medium) and animates one
 * registered value, --ds-skeleton-x; every bone clips itself to its shape
 * (clip-path with its radius) and carries the band as a fixed ::after,
 * placed in the viewport and moved by `translate` to that position (2.0),
 * so the bones show a single continuous band. The band has a compositor
 * layer of its own (will-change: translate): the wave moves it without
 * repainting the bones or the view (before 2.0 a fixed background was
 * repainted with the whole view on every step). A bone outside a
 * .ds-skeleton runs the wave itself. Fallback: under a transformed
 * ancestor (a sheet mid-slide, an element running .ds-enter) the band is
 * placed in that ancestor instead of the viewport; the bones stay in sync
 * in time and share the band within it.
 *
 * Loading bar: the two-segment indeterminate choreography of M3
 * (ds-progress-head, ds-progress-tail on --ds-loop-indeterminate), flat
 * (at the panel edge a wave would be clipped). In a skeleton it shares the
 * skeleton's delay; on its own it fades in after --ds-delay-loader. It
 * reaches the panel edges through the read-only padding properties of
 * .ds-content (--ds-content-pad-top, --ds-content-pad-x; page.css) and,
 * inside a .ds-page, the page's centring (100cqw of .ds-content), and the
 * view's scroll hairline hides while it is there (shell.css). Place it (or
 * its skeleton) directly in .ds-content or .ds-page, with no other size
 * container in between.
 *
 * Refresh pattern: put .ds-stale on the content and insert a loading bar
 * above it; when the data arrives,
 *   await DS.motion.settleLoader(startedAt, { element: loadbar });
 *   await DS.motion.leave(loadbar);       (fades it out and removes it)
 * then render the fresh content and remove .ds-stale. The bar's segments
 * (and a skeleton's wave) keep moving while it fades out: .ds-leave keeps
 * their loops (motion.css).
 *
 * Print: the band and the loading bar are left out.
 *
 * Runtime property --ds-skeleton-x (registered, tokens.css): the band's
 * position; it rests at -150vw (off screen) whenever the wave does not
 * run, so an exit that replaces the wave never shows a band.
 */

@layer ds.components {
  /* App start: appears only when the start takes a moment. */
  .ds-boot {
    min-height: 100vh;
    min-height: 100svh;
    display: grid;
    place-content: center;
    justify-items: center;
    gap: var(--ds-space-5);
    animation: ds-fade-in var(--ds-dur-slow) var(--ds-ease-in-out) var(--ds-delay-boot) backwards;
  }

  /* Small loading line (side sheets, "load more"): the loader, then the
     text, in one row as in 1.x (inline markup in the text or a trailing
     button stays on it). */
  .ds-loading {
    display: flex;
    align-items: center;
    gap: var(--ds-space-3);
    padding: var(--ds-space-4) 0;
    color: var(--ds-text-2);
    font: var(--ds-font-body);
    animation: ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) var(--ds-delay-loader) backwards;
  }

  /* Page placeholder: the outline of the page, one wave passing over it
     after its entrance. The wave holds its start (off screen) during the
     delay. */
  .ds-skeleton {
    /* At rest (an exit or entrance that replaces the wave) the band stays
       off screen, as at the start of the sweep. */
    --ds-skeleton-x: -150vw;
    display: grid;
    gap: var(--ds-space-4);
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) var(--ds-delay-loader) backwards,
      ds-skeleton-wave var(--ds-loop-skeleton) linear calc(var(--ds-delay-loader) + var(--ds-dur-medium)) infinite backwards;
  }

  /* With detail lines (2.0) it becomes two columns: the lines sit under the
     text in its column, aligned with it whatever the loader's size. Keep
     the text one run there (or one element): each piece is a cell. */
  .ds-loading:has(> .ds-loading__detail) {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    row-gap: var(--ds-space-1);
  }

  .ds-loading__detail {
    grid-column: 2;
    margin: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
  }

  /* A progress line under the lead bone (2.0), inside the role="status"
     skeleton, so it is announced. */
  .ds-skeleton__note {
    margin: calc(-1 * var(--ds-space-2)) 0 0;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
  }

  .ds-skeleton__note:empty {
    display: none;
  }

  .ds-skeleton__card {
    display: grid;
    gap: var(--ds-space-3);
    padding: var(--ds-space-6);
    border: 1px solid var(--ds-line);
    border-radius: var(--ds-radius-card);
  }

  /* A bone: the band is two viewports wide and fixed to the viewport, so
     every bone shows its own slice of the same band. The bone clips the
     band to its rounded shape; the band moves on its own layer, so the
     wave repaints nothing. */
  .ds-skeleton__line {
    display: block;
    width: 100%;
    height: 12px;
    border-radius: var(--ds-skeleton-radius, var(--ds-radius-action));
    background-color: var(--ds-surface-highest);
    clip-path: inset(0 round var(--ds-skeleton-radius, var(--ds-radius-action)));
  }

  .ds-skeleton__line::after {
    content: '';
    position: fixed;
    inset-block: 0;
    inset-inline-start: 0;
    width: 200vw;
    background-image: linear-gradient(105deg, transparent 40%, var(--ds-skeleton-highlight) 50%, transparent 60%);
    translate: var(--ds-skeleton-x) 0;
    will-change: translate;
    pointer-events: none;
  }

  .ds-skeleton__line:not(.ds-skeleton *) {
    --ds-skeleton-x: -150vw;
    animation: ds-skeleton-wave var(--ds-loop-skeleton) linear infinite;
  }

  .ds-skeleton__line--heading { width: 28%; height: 28px; border-radius: var(--ds-skeleton-radius, var(--ds-radius-sm)); clip-path: inset(0 round var(--ds-skeleton-radius, var(--ds-radius-sm))); }
  .ds-skeleton__line--lead { width: 56%; margin-bottom: var(--ds-space-2); }
  .ds-skeleton__line--title { width: 24%; height: 16px; }
  .ds-skeleton__line--short { width: 44%; }
  .ds-skeleton__line--mid { width: 70%; }

  /*
   * Page loading bar: 4px at the top edge of the content panel, sticky
   * while the panel scrolls. The track animates the edges of both segments
   * (registered, inheriting); segment 1 is the bar, segment 2 the ::after,
   * each the full width clipped between its tail and its head.
   */
  .ds-loadbar {
    position: sticky;
    top: 0;
    z-index: var(--ds-z-raised);
    display: block;
    height: 4px;
    overflow: hidden;
    margin: calc(-1 * var(--ds-content-pad-top, var(--ds-space-6))) calc(-1 * var(--ds-content-pad-x, var(--ds-space-8))) var(--ds-space-5);
    background: var(--ds-accent);
    animation:
      ds-progress-head var(--ds-loop-indeterminate) linear infinite,
      ds-progress-tail var(--ds-loop-indeterminate) linear infinite;
  }

  /* On its own (a refresh), the bar waits the loader delay; in a skeleton
     the skeleton waits, and only the loops run here. */
  .ds-loadbar:not(.ds-skeleton > .ds-loadbar) {
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) var(--ds-delay-loader) backwards,
      ds-progress-head var(--ds-loop-indeterminate) linear infinite,
      ds-progress-tail var(--ds-loop-indeterminate) linear infinite;
  }

  /* In a page (the skeleton of a loading page, a refresh above its
     content) the bar also spans the page's centring: .ds-page is
     min(100%, --ds-content-max) wide and centred in .ds-content, the
     nearest size container, so 100cqw is the panel's content box. Sides
     only, so the bottom margin of the 720px rule still applies. */
  .ds-page .ds-loadbar {
    margin-inline: calc(-1 * var(--ds-content-pad-x, var(--ds-space-8)) - max(0px, (100cqw - var(--ds-content-max)) / 2));
  }

  @media (max-width: 720px) {
    .ds-loadbar {
      margin-bottom: var(--ds-space-4);
    }
  }

  .ds-loadbar__bar,
  .ds-loadbar::after {
    position: absolute;
    inset: 0;
    display: block;
    background: var(--ds-primary);
  }

  .ds-loadbar__bar {
    clip-path: inset(0 calc(100% - var(--ds-progress-head-1)) 0 var(--ds-progress-tail-1) round var(--ds-radius-action));
  }

  .ds-loadbar::after {
    content: '';
    clip-path: inset(0 calc(100% - var(--ds-progress-head-2)) 0 var(--ds-progress-tail-2) round var(--ds-radius-action));
  }

  /* Forced colors turn every fill into Canvas, which would hide the bones
     and the bar: edges for the bones and the track, the system highlight
     for both segments. */
  @media (forced-colors: active) {
    .ds-skeleton__line {
      outline: 1px solid CanvasText;
      clip-path: none;
    }

    .ds-skeleton__line::after {
      content: none;
    }

    .ds-loadbar {
      outline: 1px solid CanvasText;
    }

    .ds-loadbar__bar,
    .ds-loadbar::after {
      forced-color-adjust: none;
      background: Highlight;
    }
  }

  /* Print: a fixed box repeats on every printed page, so the band goes;
     the moving loading bar says nothing on paper. */
  @media print {
    .ds-skeleton__line::after {
      content: none;
    }

    .ds-loadbar {
      display: none;
    }
  }
}

/* ── components/card.css ── */
/*
 * Card: white, outlined with the passive hairline at --ds-radius-card.
 * Hierarchy comes from type, hairlines and tonal tiles, never shadows.
 *
 *   <section class="ds-card">
 *     <div class="ds-card__row"> <h2 class="ds-card__title"> [button | switch | status chip | badge | tag | chip]
 *     <p class="ds-muted"> ...
 *     <h3 class="ds-card__subtitle"> ...
 *     <div class="ds-subsection"> ...
 *     <nav class="ds-card__links"> <a class="ds-btn ds-btn--text ds-card__link"> ...
 *
 *   .ds-card--filled   a quieter block inside a card or form
 *   .ds-card--elevated a card that floats a little: --ds-card-shadow
 *                      (--ds-elev-1) instead of the hairline
 *   .ds-card--interactive  the whole card opens something:
 *     <a class="ds-card ds-card--interactive" href="/devices/42"> ... </a>
 *     <button class="ds-card ds-card--interactive" type="button"> (spans) </button>
 *     <div class="ds-card ds-card--interactive"> ... <a class="ds-card__action" href="/devices/42">Title</a> ... </div>
 *     The <a> and <button> forms lay their content out as a column from
 *     the top (a button would centre it in a taller grid cell).
 *     The <div> form keeps other controls usable: .ds-card__action's
 *     ::after stretches over the card, the card's other links and controls
 *     stay above it.
 *
 * An interactive card has a state layer in currentColor (hover
 * --ds-state-hover-lg, press --ds-state-pressed in --ds-dur-micro, keyboard
 * focus --ds-state-focus with a ring that is solid from the first frame
 * and snaps from offset + width to the offset; on the <div> form the ring
 * sits on the card through :has(.ds-card__action:focus-visible), so a
 * ripple's clip never cuts it), the press morph (the radius goes to
 * --ds-radius-card-pressed in --ds-dur-micro and springs back over
 * --ds-dur-medium) and the ripple (js/ripple.js); a disabled or
 * aria-disabled card shows no hover or press layer and does not morph (an
 * aria-disabled one keeps its focus layer). Nothing moves on hover:
 * no lift, no scale; a theme may give it a shadow through
 * --ds-card-hover-shadow (default none; for example var(--ds-elev-hover)),
 * in over --ds-dur-medium, out over --ds-dur-base. In forced colours every
 * interactive card keeps a visible edge, an aria-disabled one GrayText.
 *
 * A choice among a few cards is a toggle: aria-pressed, aria-checked or
 * aria-selected on the card (a <button>), or on the .ds-card__action of
 * the <div> form (a <button class="ds-card__action"> loses its own look).
 * Selected it takes --ds-card-selected-bg (--ds-accent) with --ds-on-accent
 * ink and a static inset ring (a border of --ds-focus-width in
 * --ds-selected-ring, --ds-primary, on the card's own ::after), so the
 * focus ring stays free and both read at once. Colours on --ds-dur-fast,
 * the ring at once. Add a check or the label weight: never the tint
 * alone. Forced colours: Highlight / HighlightText.
 *
 * Hooks (on .ds-theme or an element): --ds-card-padding (--ds-space-6),
 * --ds-card-padding-sm (600px and below, --ds-space-4),
 * --ds-card-padding-filled (--ds-space-5), --ds-card-title-line (the
 * title's line height, 24px, which a button, switch or chip in the header
 * row overhangs; a status chip, badge or tag sits on it),
 * --ds-card-hover-shadow (an interactive card's shadow on
 * hover, default none), --ds-card-shadow (--elevated, --ds-elev-1),
 * --ds-card-selected-bg, --ds-selected-ring. --ds-card-pad is the resulting padding of each
 * card, which tables read to bleed to its edges: read it, never set it.
 */

@layer ds.components {
  .ds-card {
    --ds-card-pad: var(--ds-card-padding, var(--ds-space-6));
    min-width: 0;
    padding: var(--ds-card-pad);
    border: 1px solid var(--ds-line);
    border-radius: var(--ds-radius-card);
    background: var(--ds-surface);
    /* Its own ink on its own surface: without .ds-base (a widget in a host
       page) text would inherit the host's colour. */
    color: var(--ds-text);
  }

  /* The rhythm below decides all spacing: no browser margins of paragraphs
     (a last one would add 1em to the bottom padding). Zero specificity, so
     every component's own margin still wins. */
  :where(.ds-card) p {
    margin-block: 0;
  }

  .ds-card > * + * {
    margin-top: var(--ds-space-4);
  }

  /* An empty live region (filled later) takes no room. */
  .ds-card > p:empty {
    display: none;
  }

  @media (max-width: 600px) {
    .ds-card {
      --ds-card-pad: var(--ds-card-padding-sm, var(--ds-space-4));
    }
  }

  .ds-card--filled {
    --ds-card-pad: var(--ds-card-padding-filled, var(--ds-space-5));
    border-color: transparent;
    background: var(--ds-surface-low);
  }

  .ds-card--elevated {
    border-color: transparent;
    box-shadow: var(--ds-card-shadow, var(--ds-elev-1));
  }

  /*
   * Interactive card (D26): a whole card that opens something. The
   * element's own link or button look is reset; the state layer sits
   * between the card's fill and its content (the card is isolated).
   */
  .ds-card--interactive {
    position: relative;
    isolation: isolate;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      box-shadow var(--ds-dur-base) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* A column from the top: a button centres its content in a taller box
     (next to a taller card in a grid row), and its spans stack with the
     card's rhythm like the blocks of the other forms. */
  :is(a, button).ds-card--interactive {
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
    align-items: stretch;
    width: 100%;
    color: var(--ds-text);
    font: inherit;
    text-align: start;
    text-decoration: none;
  }

  .ds-card--interactive::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-card--interactive:hover {
      box-shadow: var(--ds-card-hover-shadow, none);
      transition:
        background-color var(--ds-dur-fast) var(--ds-ease-in-out),
        border-color var(--ds-dur-fast) var(--ds-ease-in-out),
        color var(--ds-dur-fast) var(--ds-ease-in-out),
        border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
        box-shadow var(--ds-dur-medium) var(--ds-ease-in-out),
        outline-offset var(--ds-dur-fast) var(--ds-ease-out);
    }

    .ds-card--interactive:hover::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  /* Keyboard focus: the layer appears with the ring and fades out on blur. */
  .ds-card--interactive:focus-visible::before,
  .ds-card--interactive:has(.ds-card__action:focus-visible)::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  /* Press-in in --ds-dur-micro (.ds-pressed: js/ripple.js holds it); not
     while another control in a <div> card is pressed (it has its own). */
  .ds-card--interactive:active:not(:where(:has(:is(a[href], button, input, select, textarea, label, summary):not(.ds-card__action):active)))::before,
  .ds-card--interactive.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  /* A blocked card (disabled, aria-disabled) shows neither hover nor press,
     like its morph below; an aria-disabled card keeps its focus layer. */
  @media (hover: hover) {
    .ds-card--interactive:is(:disabled, [aria-disabled="true"]):hover::before {
      opacity: 0;
    }
  }

  .ds-card--interactive:is(:disabled, [aria-disabled="true"]):is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  .ds-card--interactive[aria-disabled="true"]:is(:focus-visible, :has(.ds-card__action:focus-visible))::before {
    opacity: var(--ds-state-focus);
  }

  /* The press morph: squarer while pressed (never rounder than at rest),
     springing back on release. */
  .ds-card--interactive:active:not(:disabled, [aria-disabled="true"]):not(:where(:has(:is(a[href], button, input, select, textarea, label, summary):not(.ds-card__action):active))),
  .ds-card--interactive.ds-pressed:not(:disabled, [aria-disabled="true"]) {
    border-radius: min(var(--ds-radius-card-pressed), var(--ds-radius-card));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      box-shadow var(--ds-dur-base) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* The <div> form takes its ring on the card itself, not on the action's
     stretched ::after: a ripple clips the card (overflow: clip) and would
     cut a descendant's ring outside the padding box for the whole press. */
  .ds-card--interactive:focus-visible,
  .ds-card--interactive:has(.ds-card__action:focus-visible) {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* The <div> form: the action's ::after covers the card; the card's other
     links and controls stay above it (zero specificity: a control's own
     position wins). The action reads as the card's text: the whole card is
     the affordance. */
  .ds-card__action {
    color: inherit;
    text-decoration: none;
  }

  /* The button form of the action (a choice card): no look of its own. */
  button.ds-card__action {
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    text-align: start;
    cursor: inherit;
  }

  .ds-card__action::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: var(--ds-radius-card);
  }

  .ds-card--interactive .ds-card__action:focus-visible {
    outline: none;
  }

  :where(.ds-card--interactive) :where(a[href], button, input, select, textarea, label, summary, [tabindex]):where(:not(.ds-card__action)) {
    position: relative;
    z-index: var(--ds-z-raised);
  }

  /* Selected (a choice among a few cards): the tint and a static inset
     ring on the card's own ::after (the state layer is ::before, the
     stretched link of the <div> form is the action's ::after), drawn over
     the hairline, so the focus ring stays free; the ring shows at once. */
  /* The text roles turn to the ink on the card itself, so its title,
     .ds-muted and .ds-meta follow by inheritance: a rule that reached them
     through :has() (card:has(selected action) .ds-muted) put every text
     change on the page through that check (perf.spec, style recalc). */
  .ds-card--interactive:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]),
  .ds-card--interactive:has(.ds-card__action:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"])) {
    --ds-text: var(--ds-on-accent);
    --ds-text-2: var(--ds-on-accent);
    border-color: transparent;
    background: var(--ds-card-selected-bg, var(--ds-accent));
    color: var(--ds-on-accent);
  }

  .ds-card--interactive:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]) :is(.ds-card__title, .ds-card__subtitle, .ds-muted, .ds-meta) {
    color: var(--ds-on-accent);
  }

  .ds-card--interactive:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"])::after,
  .ds-card--interactive:has(.ds-card__action:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]))::after {
    content: '';
    position: absolute;
    inset: -1px;
    border: var(--ds-focus-width) solid var(--ds-selected-ring, var(--ds-primary));
    border-radius: inherit;
    pointer-events: none;
  }

  @media (forced-colors: active) {
    .ds-card--interactive.ds-card--filled,
    .ds-card--elevated {
      border-color: CanvasText;
    }

    .ds-card--interactive:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]),
    .ds-card--interactive:has(.ds-card__action:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"])) {
      forced-color-adjust: none;
      border-color: Highlight;
      background: Highlight;
      color: HighlightText;
    }

    .ds-card--interactive:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]) :is(.ds-card__title, .ds-card__subtitle, .ds-muted, .ds-meta, .ds-card__action),
    .ds-card--interactive:has(.ds-card__action:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"])) :is(.ds-card__title, .ds-card__subtitle, .ds-muted, .ds-meta, .ds-card__action) {
      color: HighlightText;
    }

    /* Other controls inside a <div> card keep the forced system colours. */
    .ds-card--interactive:has(.ds-card__action:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"])) :is(a[href], button, input, select, textarea, label):not(.ds-card__action) {
      forced-color-adjust: auto;
    }

    .ds-card--interactive:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"])::after,
    .ds-card--interactive:has(.ds-card__action:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]))::after {
      border-color: Highlight;
    }

    .ds-card--interactive:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]):is(:focus-visible, :has(.ds-card__action:focus-visible)),
    .ds-card--interactive:has(.ds-card__action:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"])):has(.ds-card__action:focus-visible) {
      outline-color: CanvasText;
    }

    .ds-card--interactive[aria-disabled="true"] {
      forced-color-adjust: none;
      border-color: GrayText;
      background: Canvas;
      color: GrayText;
    }

    .ds-card--interactive[aria-disabled="true"] :is(.ds-card__title, .ds-card__subtitle, .ds-muted, .ds-meta, .ds-card__action) {
      color: GrayText;
    }

    /* It stays focusable; unforced, its ring would keep the brand colour. */
    .ds-card--interactive[aria-disabled="true"]:is(:focus-visible, :has(.ds-card__action:focus-visible)) {
      outline-color: CanvasText;
    }
  }

  /* Cards side by side share one height; their link rows line up at the
     bottom edge. The auto margin replaces the rhythm's 16px, so the block
     before the links keeps it as a floor: the tallest card has no free
     space, and flex margins add up instead of collapsing. */
  .ds-grid-2 > .ds-card:has(> .ds-card__links) {
    display: flex;
    flex-direction: column;
  }

  .ds-grid-2 > .ds-card > .ds-card__links {
    margin-top: auto;
  }

  .ds-grid-2 > .ds-card > :has(+ .ds-card__links) {
    margin-bottom: var(--ds-space-4);
  }

  .ds-card__title {
    min-width: 0;
    margin: 0;
    padding: 0;
    color: var(--ds-text);
    font: var(--ds-font-subtitle);
    letter-spacing: 0;
    overflow-wrap: anywhere;
  }

  /* A title with a leading icon: icon and words on one centre line. */
  .ds-card__title:has(> svg:first-child) {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
  }

  .ds-card__row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--ds-space-3);
    flex-wrap: wrap;
  }

  /* A button (36px) or a switch (40px) next to the title hangs out of the
     title's line (--ds-card-title-line) on both sides instead of making the row taller: every
     card title starts at the same height, whatever its head holds. When the
     row wraps (phones), the row gap makes up for the overhang. A status
     chip, badge or tag fits the title line and sits on it (an overhang
     would hang into the block below a wrapped row); a chip hangs by its
     own height. */
  .ds-card__row {
    row-gap: calc(var(--ds-space-3) + (var(--ds-btn-height, 36px) - var(--ds-card-title-line, 24px)) / 2);
  }

  .ds-card__row > :not(.ds-card__title, :has(> .ds-card__title)) {
    margin-block: calc((var(--ds-card-title-line, 24px) - var(--ds-btn-height, 36px)) / 2);
  }

  .ds-card__row > .ds-switch,
  .ds-card__row > :has(> .ds-switch) {
    margin-block: calc((var(--ds-card-title-line, 24px) - var(--ds-switch-height, 40px)) / 2);
  }

  .ds-card__row > :is(.ds-status, .ds-badge, .ds-tag) {
    margin-block: 0;
  }

  .ds-card__row > .ds-chip {
    margin-block: calc((var(--ds-card-title-line, 24px) - var(--ds-chip-height, 32px)) / 2);
  }

  .ds-card__subtitle {
    margin: var(--ds-space-6) 0 0;
    color: var(--ds-text);
    font: var(--ds-font-label);
  }

  .ds-card > .ds-card__subtitle + * {
    margin-top: var(--ds-space-2);
  }

  /* Links to related pages: text buttons in a row under a hairline. Each one
     hangs its padding into the gutter, so the first of every line lines up
     with the card text (the gap makes up for it between them). */
  .ds-card__links {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ds-space-1) calc(var(--ds-space-2) + var(--ds-space-3));
    padding-top: var(--ds-space-3);
    border-top: 1px solid var(--ds-line);
  }

  /* Only text buttons hang their padding out; an outlined button or a link keeps its place. */
  .ds-card__links > :is(.ds-btn--text, .ds-card__link) {
    margin-inline-start: calc(-1 * var(--ds-space-3));
  }

  .ds-card__link {
    --ds-btn-padding-x: var(--ds-space-3);
  }

  /* At the end of a card's header row the padding hangs out at the end. */
  .ds-card__row > .ds-card__link:last-child {
    margin-inline-end: calc(-1 * var(--ds-space-3));
  }

  /* A part of a card with its own small title: a hairline and room above,
     the content close under its title. */
  .ds-subsection {
    display: grid;
    gap: var(--ds-space-3);
    margin-top: var(--ds-space-2);
    padding-top: var(--ds-space-5);
    border-top: 1px solid var(--ds-line);
  }

  .ds-subsection > .ds-card__subtitle,
  .ds-subsection > p {
    margin: 0;
  }

  /* A centred "load more" row. */
  .ds-more {
    display: flex;
    justify-content: center;
  }

  /* Print: a short card is never split across pages (one that holds a table may). */
  @media print {
    .ds-card:not(:has(.ds-table-wrap, .ds-table)) {
      break-inside: avoid;
    }
  }
}

/* ── components/disclosure.css ── */
/*
 * Disclosure: a styled <details> that opens and closes like any
 * .ds-collapsible (motion.css: the height and the fade, the .ds-chevron
 * turning on the spring), and accordion rows.
 *
 *   <details class="ds-collapsible ds-disclosure">
 *     <summary>Advanced [icon chevronDown 20 .ds-chevron]</summary>
 *     <div class="ds-disclosure__body"> ... </div>
 *   </details>
 *
 *   <div class="ds-disclosure-list">
 *     <details class="ds-collapsible ds-disclosure ds-disclosure--row" name="faq">
 *       <summary><span class="ds-disclosure__title">Sync</span><span class="ds-disclosure__meta">Nightly</span>[icon chevronDown 20 .ds-chevron]</summary>
 *       <div class="ds-disclosure__body"> ... </div>
 *     </details>
 *   </div>
 *
 * The plain summary is a pill of the label font (at least
 * --ds-disclosure-height high) whose state layer hangs --ds-space-2 into
 * the space at its sides, so its text stays in line with the content
 * around it: hover --ds-state-hover, press --ds-state-pressed in
 * --ds-dur-micro, the focus layer and a ring that snaps in from offset +
 * width, the press morph (squarer in --ds-dur-micro, springing back over
 * --ds-dur-medium) and the ripple (js/ripple.js). The body starts
 * --ds-space-3 below the summary.
 *
 * .ds-disclosure--row: a full-width summary (--ds-disclosure-row-height)
 * with a title, a quiet meta and the chevron at the end; rows in a
 * .ds-disclosure-list are divided by hairlines and run to the edges of a
 * card (through --ds-card-pad). A row takes a rectangular layer (hover
 * --ds-state-hover-lg), an inset ring, the ripple and no morph.
 *
 * Rows that share a name (an exclusive accordion) open one at a time.
 * The errors list of alert.css (.ds-errors) keeps its own summary.
 *
 * Hooks: --ds-disclosure-height (40px), --ds-disclosure-row-height (48px).
 *
 * Progressive:
 * - details[name] (an HTML attribute: rows that share a name open one at
 *   a time): without it every row opens and closes on its own.
 */

@layer ds.components {
  .ds-disclosure > summary {
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    box-sizing: border-box;
    width: fit-content;
    max-width: calc(100% + 2 * var(--ds-space-2));
    min-height: var(--ds-disclosure-height, 40px);
    margin-inline: calc(-1 * var(--ds-space-2));
    padding-inline: var(--ds-space-2);
    border-radius: min(var(--ds-radius-action), calc(var(--ds-disclosure-height, 40px) / 2));
    color: var(--ds-text);
    font: var(--ds-font-label);
    list-style: none;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-disclosure > summary::-webkit-details-marker {
    display: none;
  }

  .ds-disclosure > summary::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-disclosure > summary:hover::before {
      opacity: var(--ds-state-hover);
    }

    /* A row's larger hover layer; before the focus and press layers, which
       win at the same specificity. */
    .ds-disclosure--row > summary:hover::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  /* Keyboard focus: the layer appears with the ring and fades out on blur. */
  .ds-disclosure > summary:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  /* Press-in in --ds-dur-micro (.ds-pressed: js/ripple.js holds it). */
  .ds-disclosure > summary:active::before,
  .ds-disclosure > summary.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  /* The press morph: squarer while pressed (never rounder than at rest),
     springing back on release. */
  .ds-disclosure:not(.ds-disclosure--row) > summary:active,
  .ds-disclosure:not(.ds-disclosure--row) > summary.ds-pressed {
    border-radius: min(var(--ds-radius-action-pressed), calc(var(--ds-disclosure-height, 40px) / 2));
    transition:
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-disclosure > summary:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  .ds-disclosure > summary > svg {
    flex: none;
  }

  .ds-disclosure__body {
    padding-top: var(--ds-space-3);
  }

  /*
   * Accordion rows: a full-width summary, rectangular, running to the
   * card's edges inside a .ds-disclosure-list.
   */
  /* In a card the rows run to its edges, their text in line with the card's. */
  .ds-card .ds-disclosure-list {
    margin-inline: calc(-1 * var(--ds-card-pad));
  }

  .ds-disclosure-list > .ds-disclosure + .ds-disclosure {
    border-top: 1px solid var(--ds-line);
  }

  .ds-disclosure--row > summary {
    width: auto;
    max-width: none;
    min-height: var(--ds-disclosure-row-height, 48px);
    margin-inline: 0;
    padding: var(--ds-space-2) var(--ds-space-4);
    border-radius: 0;
    gap: var(--ds-space-3);
    /* An inset ring: it rests on the edge and snaps inward. */
    outline-offset: 0;
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-disclosure--row > summary:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  .ds-disclosure__title {
    flex: 1 1 auto;
    min-width: 0;
    overflow-wrap: anywhere;
  }

  .ds-disclosure__meta {
    flex: none;
    color: var(--ds-text-2);
    font: var(--ds-font-body);
  }

  .ds-disclosure--row > .ds-disclosure__body {
    padding: 0 var(--ds-space-4) var(--ds-space-4);
  }

  .ds-card .ds-disclosure-list > .ds-disclosure--row > summary,
  .ds-card .ds-disclosure-list > .ds-disclosure--row > .ds-disclosure__body {
    padding-inline: var(--ds-card-pad);
  }
}

/* ── components/tile.css ── */
/*
 * Key figures: tonal tiles that fill their row, up to four (.ds-tiles--3:
 * three), at least --ds-tile-min-width (176px) each, in any container (a
 * page, a dialog, a sheet, beside the drawer).
 *
 *   <div class="ds-tiles">
 *     <div class="ds-tile">
 *       <p class="ds-tile__label">[icon] <span>Open items</span></p>
 *       <p class="ds-tile__value">128</p>
 *       <p class="ds-tile__note">+12 this week</p>
 *
 * The value never wraps and never takes a colour: on narrow tiles it gets
 * smaller instead (a broken number reads as another number). Status goes
 * into the label as an icon. A tile that opens something is a <button
 * class="ds-tile"> or <a class="ds-tile">: a 6% state layer, the press
 * in --ds-dur-micro (also while js/ripple.js holds .ds-pressed) with the
 * ripple, and keyboard focus with the focus layer and a ring that is solid
 * from the first frame and snaps from offset + width to the offset
 * (outline-offset, --ds-dur-fast); no lift, no scale. While pressed it
 * turns squarer (16px to --ds-tile-radius-pressed, 12px) in
 * --ds-dur-micro and springs back over --ds-dur-medium, like a card. A
 * disabled or aria-disabled tile shows no hover or press layer and does
 * not morph; an aria-disabled one keeps its focus layer.
 *
 * Values (docs/motion.md, "Data motion"): every new value goes through
 * DS.motion.countUp(el, next), which counts from the figure on screen
 * (the first call from 0) on --ds-ease-value over --ds-dur-glide,
 * retargets mid-count and gives assistive technology the final value. A
 * meter or progress bar set in the same task lands on the same frame. The
 * note changes at once.
 *
 * A tile that filters by its figure is a toggle: aria-pressed (or
 * aria-checked, aria-selected) on the <button class="ds-tile">. Selected it
 * takes --ds-tile-selected-bg (--ds-accent) with --ds-on-accent ink and a
 * static inset ring (a border of --ds-focus-width in --ds-selected-ring,
 * --ds-primary, on its ::after), so the focus ring stays free and both
 * read at once. The colours change on --ds-dur-fast, the ring shows at
 * once. Add a check or the label weight: never the tint alone. Forced
 * colours: Highlight / HighlightText.
 *
 * A value that does not fit ends in an ellipsis (the last resort: shorten
 * figures, the full value in data-tip). .ds-tile__value--empty: a value
 * that is missing ("No data"), in --ds-text-2 with the display font.
 *
 * Hooks: --ds-tile-radius, --ds-tile-radius-pressed, --ds-tile-min-width
 * (176px), --ds-tiles-columns (4), --ds-tile-selected-bg, --ds-selected-ring.
 *
 * On narrow tiles the value shrinks with the tile (container units).
 */

@layer ds.components {
  /* Intrinsic columns: as many tiles per row as fit at their smallest width,
     never more than --ds-tiles-columns, each row filled. */
  .ds-tiles {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--ds-tile-min-width, 176px), (100% - (var(--ds-tiles-columns, 4) - 1) * var(--ds-space-3)) / var(--ds-tiles-columns, 4))), 1fr));
    gap: var(--ds-space-3);
  }

  .ds-tiles--3 {
    grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--ds-tile-min-width, 176px), (100% - 2 * var(--ds-space-3)) / 3)), 1fr));
  }

  .ds-tile {
    /* Its value scales with the tile (cqi, below). */
    container-type: inline-size;
    display: flex;
    flex-direction: column;
    min-width: 0;
    padding: var(--ds-space-4) var(--ds-space-5) var(--ds-space-5);
    border-radius: var(--ds-tile-radius, var(--ds-radius-lg));
    background: var(--ds-surface-low);
  }

  @media (max-width: 600px) {
    .ds-tiles {
      gap: var(--ds-space-2);
    }

    .ds-tile {
      padding: var(--ds-space-3) var(--ds-space-4) var(--ds-space-4);
    }
  }

  .ds-tile__label {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    margin: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-body);
  }

  .ds-tile__label svg {
    flex-shrink: 0;
  }

  .ds-tile__value {
    margin: var(--ds-space-2) 0 0;
    color: var(--ds-text);
    font: var(--ds-font-display);
    /* Tiles of a row are equally wide, so their values keep one size. */
    font-size: min(var(--ds-size-display), 26cqi);
    letter-spacing: 0;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    /* The last resort of a value too wide for its tile. */
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* A value that is missing: quieter, still in the display font (.ds-muted would replace the font). */
  .ds-tile__value--empty {
    color: var(--ds-text-2);
  }

  /* Clickable tile: the element's own button or link look is reset. */
  :is(button, a).ds-tile {
    position: relative;
    isolation: isolate;
    width: 100%;
    margin: 0;
    border: 0;
    color: inherit;
    font: inherit;
    text-align: start;
    text-decoration: none;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* The press morph: squarer while pressed (never rounder than at rest);
     the layer follows through border-radius: inherit. */
  :is(button, a).ds-tile:active:not(:disabled, [aria-disabled="true"]),
  :is(button, a).ds-tile.ds-pressed:not(:disabled, [aria-disabled="true"]) {
    border-radius: min(var(--ds-tile-radius-pressed, var(--ds-radius-md)), var(--ds-tile-radius, var(--ds-radius-lg)));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  :is(button, a).ds-tile::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    :is(button, a).ds-tile:hover::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  :is(button, a).ds-tile:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  :is(button, a).ds-tile:active::before,
  :is(button, a).ds-tile.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  /* A blocked tile (disabled, aria-disabled) shows neither hover nor press,
     like its morph above; an aria-disabled tile keeps its focus layer. */
  @media (hover: hover) {
    :is(button, a).ds-tile:is(:disabled, [aria-disabled="true"]):hover::before {
      opacity: 0;
    }
  }

  :is(button, a).ds-tile:is(:disabled, [aria-disabled="true"]):is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  :is(button, a).ds-tile[aria-disabled="true"]:focus-visible::before {
    opacity: var(--ds-state-focus);
  }

  :is(button, a).ds-tile:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* On the tonal tile the quieter text colour is text-2, never text-3. */
  .ds-tile__note {
    margin: var(--ds-space-1) 0 0;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
  }

  /* Selected (a filter by this figure): the tint and a static inset ring on
     the free ::after (the state layer is ::before), so the focus ring stays
     free; the ring shows at once, the colours glide. */
  :is(button, a).ds-tile:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]) {
    background: var(--ds-tile-selected-bg, var(--ds-accent));
    color: var(--ds-on-accent);
  }

  :is(button, a).ds-tile:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]) :is(.ds-tile__label, .ds-tile__value, .ds-tile__note) {
    color: var(--ds-on-accent);
  }

  :is(button, a).ds-tile:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"])::after {
    content: '';
    position: absolute;
    inset: 0;
    border: var(--ds-focus-width) solid var(--ds-selected-ring, var(--ds-primary));
    border-radius: inherit;
    pointer-events: none;
  }

  @media (forced-colors: active) {
    :is(button, a).ds-tile:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]) {
      forced-color-adjust: none;
      background: Highlight;
      color: HighlightText;
    }

    :is(button, a).ds-tile:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]) :is(.ds-tile__label, .ds-tile__value, .ds-tile__note) {
      color: HighlightText;
    }

    :is(button, a).ds-tile:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"])::after {
      border-color: Highlight;
    }

    :is(button, a).ds-tile:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]):focus-visible {
      outline-color: CanvasText;
    }

    :is(button, a).ds-tile[aria-disabled="true"] {
      forced-color-adjust: none;
      background: Canvas;
      color: GrayText;
    }

    :is(button, a).ds-tile[aria-disabled="true"] :is(.ds-tile__label, .ds-tile__value, .ds-tile__note) {
      color: GrayText;
    }

    /* It stays focusable; unforced, its ring would keep the brand colour. */
    :is(button, a).ds-tile[aria-disabled="true"]:focus-visible {
      outline-color: CanvasText;
    }
  }

  /* Print: a short tile is never split across pages. */
  @media print {
    .ds-tile {
      break-inside: avoid;
    }
  }
}

/* ── components/list.css ── */
/*
 * Lists: an outlined list of rows that open a record, key-value rows
 * inside cards, a plain list, a definition grid of facts and a line of
 * parts separated by «·».
 *
 *   <ul class="ds-list ds-list--interactive" aria-label="Devices">
 *     <li class="ds-list__item" data-ds-key="d42"> [icon .ds-list__icon]
 *       <span class="ds-list__text"><span class="ds-list__title"><a class="ds-list__action" href="/devices/42">LAPTOP-0042</a></span>
 *         <span class="ds-list__meta">Notebook 14 · Building A</span></span>
 *       <span class="ds-status ...">...</span>
 *       <span class="ds-list__actions"><button class="ds-btn ds-btn--quiet ds-btn--icon ds-btn--sm" type="button" aria-label="More for LAPTOP-0042">...</button></span>
 *       [icon chevronRight .ds-list__chevron]</li></ul>
 *   <ul class="ds-kv-list"><li><span>Name</span><strong>Value</strong></li></ul>
 *   <ul class="ds-kv-list ds-kv-list--interactive"><li><a class="ds-kv-list__row" href="/owners/7"><span>Owner</span><strong>Ada Lovelace</strong></a></li></ul>
 *   <ul class="ds-kv-list ds-kv-list--bars"><li><span>Disk</span><strong>78%</strong><span class="ds-meter" aria-hidden="true"></span></li></ul>
 *   <ul class="ds-plain-list"><li>...</li></ul>
 *   <dl class="ds-facts"><div><dt>Label</dt><dd>Value</dd></div></dl>
 *   <dl class="ds-facts ds-facts--rows"><div><dt>Label</dt><dd>Value <span class="ds-lock" role="img" aria-label="Set by the directory sync">[icon lock 16]</span></dd></div></dl>
 *   <span class="ds-parts"><span class="ds-parts__list"><span>3 new</span><span>1 updated</span></span></span>
 *
 * Row list (.ds-list): an outlined surface (never overflow: a row's menu
 * is not cut) with hairlines between rows; a row wraps its trailing parts
 * (status, actions, chevron) to a second line by itself when the text
 * would get narrower than --ds-list-text-min. .ds-list--interactive: one
 * .ds-list__action per row, whose ::after stretches over the row; the
 * row's other links and controls stay above it. The row takes a state
 * layer in currentColor (hover --ds-state-hover-lg, press
 * --ds-state-pressed in --ds-dur-micro, not while another control in the
 * row is pressed), the focus layer and an inset ring at once (through
 * :has(.ds-list__action:focus-visible), the ring snapping from the edge
 * inward), the ripple (js/ripple.js), and no morph. A row whose action is
 * disabled or aria-disabled shows no hover or press layer and takes the
 * not-allowed cursor; it keeps its focus layer. The current row
 * (aria-current on the row or on its action, not "false") takes
 * --ds-list-current-bg over --ds-dur-fast; in forced colours
 * SelectedItem, its other controls and chips on Canvas. Rows that enter through DS.motion.flip fade in over
 * --ds-dur-fast; while rows glide they stand on an opaque backing
 * (data-ds-glide and --ds-flip-backdrop on the list).
 *
 * Key-value rows: .ds-kv-list--interactive rows are links or buttons
 * (.ds-kv-list__row) with the state layer, an outer focus ring that snaps
 * in, the ripple and the disabled model (GrayText in forced colours); a
 * div.ds-kv-list__row is a static row among them. .ds-kv-list__icon
 * (--ok, --warning, --error) marks a value, always with a .ds-sr word.
 * strong.ds-muted is a quiet value (regular weight). .ds-kv-list--bars
 * puts a .ds-meter under each row (aria-hidden: the figure is in the
 * text); the label stays on one line and ends in an ellipsis.
 *
 * Facts take as many 220px columns as fit; exactly four facts get a
 * layout of their own, by the width of the content area. .ds-facts--rows
 * puts each fact on a row of its own, the label in a column of
 * --ds-facts-label-width; under 520px of content (or a 600px window)
 * the label goes above its value. .ds-lock marks a value another system
 * sets (with the lock glyph, a reason in its label and its tooltip): it
 * is not an action.
 *
 * Links in parts take an inset ring for keyboard focus (also without
 * .ds-base): solid from the first frame, it snaps from the edge inward
 * (outline-offset, --ds-dur-fast).
 *
 * Hooks: --ds-list-item-height (56px), --ds-list-text-min (12rem),
 * --ds-list-current-bg (--ds-accent), --ds-facts-label-width (36%).
 */

@layer ds.components {
  .ds-kv-list,
  .ds-plain-list {
    list-style: none;
    /* No top margin of their own: inside a card the card's rhythm spaces them. */
    margin-inline: 0;
    margin-bottom: 0;
    padding: 0;
  }

  .ds-kv-list li {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--ds-space-3);
    padding: var(--ds-space-3) 0;
    border-top: 1px solid var(--ds-line);
    font: var(--ds-font-body);
  }

  .ds-kv-list li:first-child {
    border-top: none;
    padding-top: 0;
  }

  .ds-kv-list li:last-child {
    padding-bottom: 0;
  }

  .ds-kv-list :is(li, .ds-kv-list__row) > span {
    color: var(--ds-text-2);
  }

  /* Long labels and values wrap inside their own part. */
  .ds-kv-list :is(li, .ds-kv-list__row) > :is(span, strong) {
    min-width: 0;
    overflow-wrap: anywhere;
  }

  .ds-kv-list strong {
    font-weight: var(--ds-weight-label);
    text-align: end;
    font-variant-numeric: tabular-nums;
  }

  /* A quiet value ("Not set", "None"): regular weight. */
  .ds-kv-list strong.ds-muted {
    font-weight: var(--ds-weight-regular);
  }

  /* No top margin of the browser's either; zero weight, so the rhythm of
     a card (.ds-card > * + *) still spaces them. */
  :where(.ds-kv-list, .ds-plain-list, .ds-list) {
    margin-top: 0;
  }

  .ds-plain-list li + li {
    margin-top: var(--ds-space-2);
  }

  /* ── Facts: label above value, as many columns as fit ── */

  .ds-facts {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    gap: var(--ds-space-4) var(--ds-space-8);
    margin: 0;
  }

  .ds-facts:empty {
    display: none;
  }

  .ds-card > .ds-facts {
    margin-top: var(--ds-space-5);
  }

  /* Exactly four facts on a page never split three and one: four in a row,
     two and two on a narrower content area, one column on phones. Only
     inside the content area: a side sheet lives outside it and keeps the
     auto-fit columns above. */
  @container ds-content (min-width: 1041px) {
    .ds-facts:has(> :nth-child(4):last-child) {
      grid-template-columns: repeat(4, minmax(0, 1fr));
    }
  }

  @container ds-content (max-width: 1040px) {
    .ds-facts:has(> :nth-child(4):last-child) {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
  }

  @container ds-content (max-width: 520px) {
    .ds-facts:has(> :nth-child(4):last-child) {
      grid-template-columns: minmax(0, 1fr);
    }
  }

  /* Facts above key figures follow the tiles' grid: two equal columns with
     the tiles' gap, so the second fact starts where the third tile does
     (four tiles per row) or the second one does (two per row); phones
     stack them. No per-fact rule: a structural or tag subject under a
     :has() ancestor made Chromium restyle a large part of the page on
     every DOM change inside a card (about 10ms per change on the
     styleguide, every frame of a countUp). */
  .ds-card:has(.ds-tiles) > .ds-facts {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: var(--ds-space-3);
  }

  @media (max-width: 600px) {
    .ds-card:has(.ds-tiles) > .ds-facts {
      grid-template-columns: minmax(0, 1fr);
    }
  }

  .ds-facts dt {
    color: var(--ds-text-2);
    font: var(--ds-font-caption);
  }

  .ds-facts dd {
    margin: var(--ds-space-1) 0 0;
    color: var(--ds-text);
    font: var(--ds-font-body);
    font-variant-numeric: tabular-nums;
    overflow-wrap: anywhere;
  }

  /* A trailing icon in a link (an external target) keeps a little air. Its
     own display: a layered preflight (svg { display: block }) would drop it
     below the text. */
  .ds-facts a svg {
    display: inline-block;
    margin-inline-start: var(--ds-space-1);
    vertical-align: -2px;
  }

  /*
   * Parts: a line of parts that wraps between the parts. A part moves to the
   * next line whole; only a part longer than a whole line wraps inside. Each
   * part brings its «·» in front, in a padding of its own; the list is
   * shifted left by one separator and clipped at the sides only, so the
   * separator of a part that starts a line is never seen (nothing dangles at
   * a line end, nothing leads the next line), while descenders and focus
   * rings above and below stay whole. A link in a part rings inside its own
   * box (an inset ring that snaps inward), so the side clip never cuts it.
   */
  .ds-parts {
    display: block;
    overflow-x: clip;
    overflow-y: visible;
  }

  /* The underline fade of the page base stays in the list. */
  .ds-parts a {
    outline-offset: 0;
    transition:
      text-decoration-color var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-parts a:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  .ds-parts__list {
    display: flex;
    flex-wrap: wrap;
    margin-inline-start: calc(-1 * var(--ds-space-3));
  }

  .ds-parts__list > span {
    position: relative;
    min-width: 0;
    padding-inline-start: var(--ds-space-3);
    overflow-wrap: anywhere;
  }

  .ds-parts__list > span::before {
    content: '·';
    position: absolute;
    top: 0;
    inset-inline-start: 0;
    /* As wide as « · » in running text. */
    width: var(--ds-space-3);
    text-align: center;
  }

  /*
   * Row list: records in rows that open them, on an outlined surface. It
   * never clips (overflow stays visible), so a row's menu is not cut; the
   * outer rows round their own corners for the layer and the ripple.
   */
  .ds-list {
    list-style: none;
    margin-inline: 0;
    margin-bottom: 0;
    padding: 0;
    border: 1px solid var(--ds-line);
    border-radius: var(--ds-radius-card);
    background: var(--ds-surface);
    color: var(--ds-text);
  }

  .ds-list:empty {
    display: none;
  }

  .ds-list__item {
    /* A row that DS.motion.flip() brings in fades in quickly (the hook
       applies to the row itself, never to what is inside). */
    --ds-enter-duration: var(--ds-dur-fast);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ds-space-2) var(--ds-space-3);
    box-sizing: border-box;
    min-height: var(--ds-list-item-height, 56px);
    padding: var(--ds-space-3) var(--ds-space-4);
    border-top: 1px solid var(--ds-line);
    font: var(--ds-font-body);
    transition: background-color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-list__item:first-child {
    border-top: 0;
    border-start-start-radius: calc(var(--ds-radius-card) - 1px);
    border-start-end-radius: calc(var(--ds-radius-card) - 1px);
  }

  .ds-list__item:last-child {
    border-end-start-radius: calc(var(--ds-radius-card) - 1px);
    border-end-end-radius: calc(var(--ds-radius-card) - 1px);
  }

  /* The text takes the room; below its minimum the trailing parts drop to
     a second line by themselves (no container query). */
  .ds-list__text {
    display: grid;
    flex: 1 1 var(--ds-list-text-min, 12rem);
    gap: 2px;
    min-width: 0;
  }

  .ds-list__title {
    color: var(--ds-text);
    font: var(--ds-font-label);
    overflow-wrap: anywhere;
  }

  .ds-list__meta {
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
    overflow-wrap: anywhere;
  }

  .ds-list__icon,
  .ds-list__chevron {
    flex: none;
    color: var(--ds-text-2);
  }

  /* The chevron stays at the end, also on a second line. */
  .ds-list__chevron {
    margin-inline-start: auto;
  }

  .ds-list__actions {
    display: flex;
    flex: none;
    align-items: center;
    gap: var(--ds-space-1);
  }

  /* The current row (aria-current on the row or on its action): the tint,
     with a word or the label weight in the row (never the colour alone). */
  .ds-list__item:is([aria-current]:not([aria-current="false"]), :has(.ds-list__action[aria-current]:not([aria-current="false"]))) {
    background-color: var(--ds-list-current-bg, var(--ds-accent));
  }

  /* While rows glide (DS.motion.flip marks the list data-ds-glide with
     --ds-flip-backdrop), every row stands on an opaque backing, so a row
     that crosses another never shows its text through. */
  .ds-list[data-ds-glide] > .ds-list__item {
    background-image: linear-gradient(var(--ds-flip-backdrop, var(--ds-surface)), var(--ds-flip-backdrop, var(--ds-surface)));
  }

  .ds-list[data-ds-glide] > .ds-list__item:is([aria-current]:not([aria-current="false"]), :has(.ds-list__action[aria-current]:not([aria-current="false"]))) {
    background-image:
      linear-gradient(var(--ds-list-current-bg, var(--ds-accent)), var(--ds-list-current-bg, var(--ds-accent))),
      linear-gradient(var(--ds-flip-backdrop, var(--ds-surface)), var(--ds-flip-backdrop, var(--ds-surface)));
  }

  /*
   * Interactive rows: the row's action stretches over the row (::after
   * against the positioned row); its other links and controls stay above
   * it (zero specificity: a control's own position wins). The state layer
   * sits between the row's colour and its content (the row is isolated).
   */
  .ds-list--interactive > .ds-list__item {
    position: relative;
    isolation: isolate;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* An inset ring: it rests on the row's edge and snaps inward. */
    outline-offset: 0;
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-list--interactive > .ds-list__item::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-list--interactive > .ds-list__item:hover::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  /* Keyboard focus on the action: the layer and the ring at once. */
  .ds-list--interactive > .ds-list__item:has(.ds-list__action:focus-visible)::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  /* Press-in in --ds-dur-micro (.ds-pressed: js/ripple.js holds it); not
     while another control in the row is pressed (it has its own). */
  .ds-list--interactive > .ds-list__item:active:not(:where(:has(:is(a[href], button, input, select, textarea, label, summary):not(.ds-list__action):active)))::before,
  .ds-list--interactive > .ds-list__item.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  .ds-list--interactive > .ds-list__item:has(.ds-list__action:focus-visible) {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  /* A blocked row (its action disabled or aria-disabled) shows neither
     hover nor press; an aria-disabled action keeps its focus layer. */
  .ds-list--interactive > .ds-list__item:has(.ds-list__action:is(:disabled, [aria-disabled="true"])) {
    cursor: not-allowed;
  }

  @media (hover: hover) {
    .ds-list--interactive > .ds-list__item:has(.ds-list__action:is(:disabled, [aria-disabled="true"])):hover::before {
      opacity: 0;
    }
  }

  .ds-list--interactive > .ds-list__item:has(.ds-list__action:is(:disabled, [aria-disabled="true"])):is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  .ds-list--interactive > .ds-list__item:has(.ds-list__action[aria-disabled="true"]:focus-visible)::before {
    opacity: var(--ds-state-focus);
  }

  /* The action reads as the row's title: the whole row is the affordance.
     A <button> action loses its own look. */
  .ds-list--interactive .ds-list__action {
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
    text-align: start;
    text-decoration: none;
    cursor: inherit;
  }

  .ds-list--interactive .ds-list__action::after {
    content: '';
    position: absolute;
    inset: 0;
  }

  .ds-list--interactive .ds-list__action:focus-visible {
    outline: none;
  }

  :where(.ds-list--interactive > .ds-list__item) :where(a[href], button, input, select, textarea, label, summary, [tabindex]):where(:not(.ds-list__action)) {
    position: relative;
    z-index: var(--ds-z-raised);
  }

  /*
   * Key-value rows that open something: the row is a link or a button
   * that hangs --ds-space-2 into the space at its sides, so its text stays
   * in line with the static rows; the first and last rows hang their outer
   * padding too. A div.ds-kv-list__row is a static row among them.
   */
  .ds-kv-list--interactive li {
    padding: 0;
  }

  .ds-kv-list__row {
    display: flex;
    flex: 1 1 auto;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--ds-space-3);
    min-width: 0;
    margin-inline: calc(-1 * var(--ds-space-2));
    padding: var(--ds-space-3) var(--ds-space-2);
    border-radius: var(--ds-radius-sm);
  }

  .ds-kv-list--interactive li:first-child > .ds-kv-list__row {
    margin-top: calc(-1 * var(--ds-space-3));
  }

  .ds-kv-list--interactive li:last-child > .ds-kv-list__row {
    margin-bottom: calc(-1 * var(--ds-space-3));
  }

  :is(a, button).ds-kv-list__row {
    position: relative;
    isolation: isolate;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
    text-align: start;
    text-decoration: none;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  :is(a, button).ds-kv-list__row::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    :is(a, button).ds-kv-list__row:hover::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  :is(a, button).ds-kv-list__row:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  /* Press-in in --ds-dur-micro (.ds-pressed: js/ripple.js holds it). */
  :is(a, button).ds-kv-list__row:active::before,
  :is(a, button).ds-kv-list__row.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  :is(a, button).ds-kv-list__row:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* The disabled model: the neutral ink, no hover or press layer; an
     aria-disabled row keeps its focus layer. */
  :is(a, button).ds-kv-list__row:is(:disabled, [aria-disabled="true"]) {
    color: var(--ds-disabled-ink);
    opacity: var(--ds-disabled-opacity);
    cursor: not-allowed;
  }

  :is(a, button).ds-kv-list__row:is(:disabled, [aria-disabled="true"]) > span {
    color: inherit;
  }

  @media (hover: hover) {
    :is(a, button).ds-kv-list__row:is(:disabled, [aria-disabled="true"]):hover::before {
      opacity: 0;
    }
  }

  :is(a, button).ds-kv-list__row:is(:disabled, [aria-disabled="true"]):is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  :is(a, button).ds-kv-list__row[aria-disabled="true"]:focus-visible::before {
    opacity: var(--ds-state-focus);
  }

  /* An icon before a value, always with a .ds-sr word (never the colour
     alone). */
  .ds-kv-list__icon {
    display: inline-block;
    flex: none;
    margin-inline-end: var(--ds-space-1);
    vertical-align: -3px;
    color: var(--ds-text-2);
  }

  .ds-kv-list__icon--ok {
    color: var(--ds-success);
  }

  .ds-kv-list__icon--warning {
    color: var(--ds-warning);
  }

  .ds-kv-list__icon--error {
    color: var(--ds-error);
  }

  /* Rows with a meter on a line of their own: the label stays on one line
     (the full text shows as a tooltip when it is cut), the figure at the
     end. */
  .ds-kv-list--bars:not(.ds-kv-list--interactive) li,
  .ds-kv-list--bars .ds-kv-list__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: baseline;
    gap: var(--ds-space-2) var(--ds-space-3);
  }

  .ds-kv-list--bars :is(li, .ds-kv-list__row) > span:first-child {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .ds-kv-list--bars .ds-meter {
    grid-column: 1 / -1;
    align-self: center;
  }

  /* ── Facts in rows: the label beside its value ── */

  .ds-facts--rows {
    display: block;
  }

  .ds-facts--rows > div {
    display: grid;
    grid-template-columns: var(--ds-facts-label-width, 36%) minmax(0, 1fr);
    gap: var(--ds-space-4);
    padding-block: var(--ds-space-3);
    border-top: 1px solid var(--ds-line);
  }

  .ds-facts--rows > div:first-child {
    padding-top: 0;
    border-top: 0;
  }

  .ds-facts--rows > div:last-child {
    padding-bottom: 0;
  }

  .ds-facts--rows dt {
    font: var(--ds-font-body);
  }

  .ds-facts--rows dd {
    margin: 0;
  }

  /* Narrow content: the label goes above its value. */
  @container ds-content (max-width: 520px) {
    .ds-facts--rows > div {
      grid-template-columns: minmax(0, 1fr);
      gap: var(--ds-space-1);
    }
  }

  @media (max-width: 600px) {
    .ds-facts--rows > div {
      grid-template-columns: minmax(0, 1fr);
      gap: var(--ds-space-1);
    }
  }

  /* A value another system sets: the lock and its reason (aria-label and
     data-tip), never a disabled field. Not an action. */
  .ds-lock {
    display: inline-flex;
    margin-inline-start: var(--ds-space-1);
    vertical-align: -3px;
    color: var(--ds-text-2);
  }

  @media (forced-colors: active) {
    .ds-list__item:is([aria-current]:not([aria-current="false"]), :has(.ds-list__action[aria-current]:not([aria-current="false"]))) {
      forced-color-adjust: none;
      background: SelectedItem;
      color: SelectedItemText;
    }

    .ds-list__item:is([aria-current]:not([aria-current="false"]), :has(.ds-list__action[aria-current]:not([aria-current="false"]))) :is(.ds-list__title, .ds-list__meta, .ds-list__icon, .ds-list__chevron) {
      color: inherit;
    }

    /* forced-color-adjust inherits: the row's other controls and chips
       take the system colours again, on Canvas, so they stay legible on
       the selection fill (a status chip or a menu button at the row's end). */
    .ds-list__item:is([aria-current]:not([aria-current="false"]), :has(.ds-list__action[aria-current]:not([aria-current="false"]))) :is(a[href], button, input, select, textarea, label, .ds-status, .ds-tag, .ds-chip):not(.ds-list__action) {
      forced-color-adjust: auto;
    }

    .ds-list__item:is([aria-current]:not([aria-current="false"]), :has(.ds-list__action[aria-current]:not([aria-current="false"]))) :is(a[href], button, select, textarea, input:not([type="checkbox"], [type="radio"])):not(.ds-list__action) {
      background-color: Canvas;
    }

    /* A focused current row: a ring of the text colour inside the fill. */
    .ds-list--interactive > .ds-list__item:has(.ds-list__action[aria-current]:not([aria-current="false"]):focus-visible) {
      outline-color: CanvasText;
    }

    /* Blocked key-value rows: one system colour (D8). */
    :is(a, button).ds-kv-list__row:is(:disabled, [aria-disabled="true"]) {
      color: GrayText;
    }
  }
}

/* ── components/timeline.css ── */
/*
 * Timeline: the history of a record, newest first or oldest first as the
 * page orders it. A rail runs down the start edge with a dot (or an icon
 * marker) on the head line of every entry; day headings group entries.
 *
 *   <ol class="ds-timeline" role="list" aria-label="History of LAPTOP-0042">
 *     <li class="ds-timeline__group" role="presentation"><h3 class="ds-timeline__day">12 Oct 2026</h3></li>
 *     <li class="ds-timeline__item" data-ds-key="e3">
 *       <div class="ds-timeline__head"><p class="ds-timeline__title">Issued</p><span class="ds-status ds-status--ok">...</span></div>
 *       <p class="ds-timeline__meta"><time datetime="2026-10-12T14:05">14:05</time> · Ada Lovelace</p>
 *       <p class="ds-timeline__text">Issued to Ada Lovelace.</p>
 *       <div class="ds-timeline__actions"><button class="ds-btn ds-btn--text ds-btn--sm" type="button" aria-label="Edit the entry of 12 Oct 2026, 14:05">Edit</button></div>
 *     </li>
 *     <li class="ds-timeline__item ds-timeline__item--muted"> ... </li>
 *     <li class="ds-timeline__item ds-timeline__item--icon"><span class="ds-timeline__marker" aria-hidden="true">[icon user 16]</span> ... </li>
 *   </ol>
 *
 * An entry is indented by --ds-timeline-indent and divided from the one
 * before by a hairline from its text edge. Its dot (::after) is drawn
 * with borders, so it stays in forced colours, and centred on the head
 * line (--ds-timeline-head-line, the height of a status chip). The rail
 * (::before) runs in segments from dot to dot, with a gap of --ds-space-1
 * around each dot; it passes through day headings (.ds-timeline__group,
 * no dot). --muted: a ring instead of a dot, for an entry that did not
 * take effect (say why in words). --icon with .ds-timeline__marker: a
 * disc with a 16px icon instead of the dot. A 16px icon leading __meta
 * (> svg:first-child) sits on its text line. __text keeps its line breaks
 * and the reading measure; __body holds a form in place; __actions hangs
 * the padding of its text buttons.
 *
 * No motion of its own: entries come and go through DS.motion.flip (a
 * removed entry moves focus to its neighbour, live entries with
 * live: true), and while they glide they stand on an opaque backing
 * (data-ds-glide and --ds-flip-backdrop on the list). Editing in place
 * swaps the text and the form through DS.motion.crossfade.
 *
 * Hooks: --ds-timeline-indent (--ds-space-8), --ds-timeline-dot-size
 * (10px), --ds-timeline-dot-color (--ds-primary), --ds-timeline-muted-color
 * (--ds-outline), --ds-timeline-line-width (2px), --ds-timeline-line-color
 * (--ds-line), --ds-timeline-divider-color (--ds-line),
 * --ds-timeline-marker-size (24px), --ds-timeline-head-line (24px).
 */

@layer ds.components {
  /* No top margin of its own: inside a card the card's rhythm spaces it
     (zero weight). */
  :where(.ds-timeline) {
    margin-top: 0;
  }

  .ds-timeline {
    margin-inline: 0;
    margin-bottom: 0;
    padding: 0;
    list-style: none;
    color: var(--ds-text);
  }

  .ds-timeline:empty {
    display: none;
  }

  .ds-timeline__item,
  .ds-timeline__group {
    position: relative;
    margin-inline-start: var(--ds-timeline-indent, var(--ds-space-8));
  }

  .ds-timeline__item {
    display: grid;
    gap: var(--ds-space-1);
    padding-block: var(--ds-space-3);
  }

  .ds-timeline__item + .ds-timeline__item {
    border-top: 1px solid var(--ds-timeline-divider-color, var(--ds-line));
  }

  /* The dot, centred on the rail and on the head line. */
  .ds-timeline__item::after {
    content: '';
    position: absolute;
    top: calc(var(--ds-space-3) + var(--ds-timeline-head-line, 24px) / 2 - var(--ds-timeline-dot-size, 10px) / 2);
    inset-inline-start: calc(var(--ds-timeline-marker-size, 24px) / 2 - var(--ds-timeline-indent, var(--ds-space-8)) - var(--ds-timeline-dot-size, 10px) / 2);
    box-sizing: border-box;
    width: var(--ds-timeline-dot-size, 10px);
    height: var(--ds-timeline-dot-size, 10px);
    border: calc(var(--ds-timeline-dot-size, 10px) / 2) solid var(--ds-timeline-dot-color, var(--ds-primary));
    border-radius: 50%;
  }

  /* An entry that did not take effect: a ring. */
  .ds-timeline__item--muted::after {
    border-width: var(--ds-timeline-line-width, 2px);
    border-color: var(--ds-timeline-muted-color, var(--ds-outline));
  }

  .ds-timeline__item--icon::after {
    display: none;
  }

  /*
   * The rail: from below this entry's dot (or marker) to above the next
   * one's, in the next entry's top padding. A day heading carries it
   * through its own height; the last entry has none.
   */
  .ds-timeline__item::before,
  .ds-timeline__group::before {
    content: '';
    position: absolute;
    top: calc(var(--ds-space-3) + var(--ds-timeline-head-line, 24px) / 2 + var(--ds-timeline-dot-size, 10px) / 2 + var(--ds-space-1));
    bottom: calc(var(--ds-space-1) + var(--ds-timeline-dot-size, 10px) / 2 - var(--ds-space-3) - var(--ds-timeline-head-line, 24px) / 2);
    inset-inline-start: calc(var(--ds-timeline-marker-size, 24px) / 2 - var(--ds-timeline-indent, var(--ds-space-8)) - var(--ds-timeline-line-width, 2px) / 2);
    border-inline-start: var(--ds-timeline-line-width, 2px) solid var(--ds-timeline-line-color, var(--ds-line));
    pointer-events: none;
  }

  .ds-timeline__item--icon::before {
    top: calc(var(--ds-space-3) + var(--ds-timeline-head-line, 24px) / 2 + var(--ds-timeline-marker-size, 24px) / 2 + var(--ds-space-1));
  }

  .ds-timeline__group::before {
    top: 0;
  }

  .ds-timeline > :last-child::before,
  .ds-timeline > .ds-timeline__group:first-child::before {
    display: none;
  }

  .ds-timeline__marker {
    position: absolute;
    top: calc(var(--ds-space-3) + var(--ds-timeline-head-line, 24px) / 2 - var(--ds-timeline-marker-size, 24px) / 2);
    inset-inline-start: calc(-1 * var(--ds-timeline-indent, var(--ds-space-8)));
    display: grid;
    place-items: center;
    box-sizing: border-box;
    width: var(--ds-timeline-marker-size, 24px);
    height: var(--ds-timeline-marker-size, 24px);
    border: 1px solid transparent;
    border-radius: 50%;
    background: var(--ds-surface-high);
    color: var(--ds-text-2);
  }

  .ds-timeline__marker > svg {
    width: var(--ds-space-4);
    height: var(--ds-space-4);
  }

  .ds-timeline__group {
    padding-block: var(--ds-space-4) var(--ds-space-1);
  }

  .ds-timeline__group:first-child {
    padding-top: 0;
  }

  .ds-timeline__day {
    margin: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-caption);
  }

  .ds-timeline__head {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ds-space-1) var(--ds-space-2);
    min-height: var(--ds-timeline-head-line, 24px);
  }

  .ds-timeline__title {
    margin: 0;
    color: var(--ds-text);
    font: var(--ds-font-label);
    overflow-wrap: anywhere;
  }

  .ds-timeline__meta {
    margin: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
  }

  /* As in .ds-meta: a leading 16px glyph fills the 16px line from its top,
     centred on the text, the line kept. */
  .ds-timeline__meta > svg:first-child {
    display: inline-block;
    margin-inline-end: var(--ds-space-1);
    vertical-align: top;
  }

  .ds-timeline__text {
    max-width: var(--ds-measure);
    margin: 0;
    color: var(--ds-text);
    font: var(--ds-font-body);
    white-space: pre-line;
    overflow-wrap: anywhere;
  }

  .ds-timeline__body {
    padding-top: var(--ds-space-2);
  }

  /* Text buttons hang their padding, so their label lines up with the text. */
  .ds-timeline__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ds-space-1);
    margin-inline-start: calc(-1 * var(--ds-space-3));
  }

  /* While entries glide (DS.motion.flip marks the list data-ds-glide with
     --ds-flip-backdrop), each stands on an opaque backing. */
  .ds-timeline[data-ds-glide] > :is(.ds-timeline__item, .ds-timeline__group) {
    background-image: linear-gradient(var(--ds-flip-backdrop, var(--ds-surface)), var(--ds-flip-backdrop, var(--ds-surface)));
  }
}

/* ── components/tasks.css ── */
/*
 * Tasks: a checklist of the steps of a job (an import, a setup, a
 * migration), each pending, running, done or failed.
 *
 *   <div class="ds-tasks__head"><h3 class="ds-card__title">Set up LAPTOP-0042</h3><p class="ds-meta">3 of 7</p></div>
 *   <ol class="ds-tasks" aria-label="Steps">
 *     <li class="ds-tasks__item" data-status="done"><span class="ds-tasks__mark">[icon checkCircle 20]</span><span class="ds-tasks__text">Copy the image<span class="ds-sr">: done</span></span></li>
 *     <li class="ds-tasks__item" data-status="running"><span class="ds-tasks__mark"><span class="ds-spinner ds-spinner--sm"></span></span><span class="ds-tasks__text">Install updates<span class="ds-sr">: running</span></span></li>
 *     <li class="ds-tasks__item" data-status="failed"><span class="ds-tasks__mark">[icon alertCircle 20]</span><span class="ds-tasks__text">Join the domain: failed, the server did not answer</span></li>
 *     <li class="ds-tasks__item" data-status="pending"><span class="ds-tasks__mark"></span><span class="ds-tasks__text">Restart<span class="ds-sr">: waiting</span></span></li>
 *   </ol>
 *
 * The status shows in the mark's shape and the text's weight, never in
 * colour alone: pending a ring in --ds-outline, running the small spinner
 * and the label weight, done the check in a circle (--ds-success) and
 * the text in --ds-text-2, failed the alert circle in --ds-error with the
 * word in the text. Put the status into the text for screen readers too
 * (a .ds-sr word), and keep the count in the head ("3 of 7") in step. In
 * forced colours the marks take CanvasText.
 *
 * Motion: when a step turns done in place (DS.dom.morphHtml patches the
 * keyed item, so its mark stays), the tick of the new check draws itself:
 * the mark's stroke-dashoffset, which the pathLength="1" tick inherits,
 * runs from 1 to 0 over --ds-dur-medium after --ds-dur-micro. A list
 * rendered with done steps stays still on load (a transition, not a
 * keyframe).
 */

@layer ds.components {
  .ds-tasks__head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--ds-space-1) var(--ds-space-3);
  }

  /* No top margin of its own: inside a card the card's rhythm spaces it
     (zero weight). */
  :where(.ds-tasks) {
    margin-top: 0;
  }

  .ds-tasks {
    display: grid;
    gap: var(--ds-space-2);
    margin-inline: 0;
    margin-bottom: 0;
    padding: 0;
    list-style: none;
  }

  .ds-tasks:empty {
    display: none;
  }

  .ds-tasks__item {
    display: flex;
    align-items: flex-start;
    gap: var(--ds-space-3);
    color: var(--ds-text);
    font: var(--ds-font-body);
  }

  /* The mark sits on the first line of the text (20px). */
  .ds-tasks__mark {
    display: grid;
    flex: none;
    place-items: center;
    width: var(--ds-space-5);
    height: var(--ds-space-5);
    color: var(--ds-text-2);
    /* Inherited by the tick of a done step: undrawn until the step is done. */
    stroke-dashoffset: 1;
    transition: stroke-dashoffset var(--ds-dur-medium) var(--ds-ease-out) var(--ds-dur-micro);
  }

  .ds-tasks__text {
    min-width: 0;
    overflow-wrap: anywhere;
  }

  .ds-tasks__item[data-status="pending"] .ds-tasks__mark::before {
    content: '';
    box-sizing: border-box;
    width: var(--ds-space-4);
    height: var(--ds-space-4);
    border: 2px solid var(--ds-outline);
    border-radius: 50%;
  }

  .ds-tasks__item[data-status="running"] .ds-tasks__mark {
    color: var(--ds-primary);
  }

  .ds-tasks__item[data-status="running"] .ds-tasks__text {
    font-weight: var(--ds-weight-label);
  }

  .ds-tasks__item[data-status="done"] .ds-tasks__mark {
    color: var(--ds-success);
    stroke-dashoffset: 0;
  }

  .ds-tasks__item[data-status="done"] .ds-tasks__text {
    color: var(--ds-text-2);
  }

  .ds-tasks__item[data-status="done"] .ds-tasks__mark [pathLength] {
    stroke-dasharray: 1;
  }

  .ds-tasks__item[data-status="failed"] .ds-tasks__mark {
    color: var(--ds-error);
  }

  @media (forced-colors: active) {
    .ds-tasks__mark,
    .ds-tasks__item[data-status] .ds-tasks__mark {
      color: CanvasText;
    }

    .ds-tasks__item[data-status="pending"] .ds-tasks__mark::before {
      border-color: CanvasText;
    }
  }
}

/* ── components/prose.css ── */
/*
 * Rendered rich text and code: .ds-prose for text the page does not mark
 * up itself (Markdown, a help article, a release note), .ds-code-block for
 * a block of code or a raw request.
 *
 *   <div class="ds-prose"> <h2>...</h2> <p>...</p> <ul><li>...</li></ul> <pre><code>...</code></pre> <table>...</table> </div>
 *   <div class="ds-prose ds-prose--dense"> ... </div>        (the body role: in a card, a bubble, a side sheet)
 *   <pre class="ds-code-block" tabindex="0" aria-label="Request"><code>GET /devices/42</code></pre>
 *   <pre class="ds-code-block ds-code-block--wrap">...</pre> (long lines wrap)
 *
 * .ds-prose styles only bare elements inside it (every rule is under
 * :where(.ds-prose), so a class of the page always wins): a rhythm of
 * --ds-prose-gap between blocks (their margins reset first), headings on
 * the type roles (h1 headline, h2 subtitle, h3 and h4 the label weight at
 * the reading size), lists indented --ds-space-5, strong in
 * --ds-prose-strong-weight, links underlined like every link, a pre as a
 * code block, a table with hairline rows, a --ds-text-2 head and tabular
 * figures that scrolls sideways when it is wider, a quote on
 * --ds-surface-high, images at most as wide as the text. Font:
 * --ds-prose-font (--ds-font-reading); --dense takes --ds-font-body. Long
 * words hyphenate where the browser can, or break.
 *
 * .ds-code-block: the code role on --ds-code-bg in a card's radius, one
 * line per line, scrolling sideways (a thin scrollbar); give it
 * tabindex="0" and a name (aria-label) only when it can scroll, so a
 * keyboard reaches it (an inset ring, solid from the first frame). A code
 * element inside loses the inline chip. --wrap wraps long lines instead.
 *
 * Hooks: --ds-prose-gap (--ds-space-2), --ds-prose-font
 * (--ds-font-reading), --ds-prose-strong-weight (--ds-weight-label).
 *
 * Forced colours: quotes, code blocks and the prose table keep their edges
 * (a transparent outline that the forced colours paint).
 *
 * Print: code blocks and prose tables wrap and unclip.
 *
 * Progressive:
 * - hyphenate-limit-chars (hyphens: auto breaks only words of 18
 *   characters or more, keeping 8 on each side; a string with no
 *   hyphenation point, such as a URL, still breaks): without it long
 *   words break anywhere (overflow-wrap) instead of hyphenating.
 * - text-wrap (headings and paragraphs avoid a lone last word): without it
 *   lines wrap as usual.
 * - scrollbar-color and scrollbar-width (a thin, quiet scrollbar under a
 *   code block or a wide table): without them the platform scrollbar
 *   shows.
 */

@layer ds.components {
  .ds-prose {
    min-width: 0;
    color: var(--ds-text);
    font: var(--ds-prose-font, var(--ds-font-reading));
    overflow-wrap: anywhere;
  }

  .ds-prose--dense {
    --ds-prose-font: var(--ds-font-body);
  }

  @supports (hyphenate-limit-chars: 1) {
    /* Hyphenation points come first; break-word splits only a string with
       no other break (a URL, a hash, a path). */
    .ds-prose {
      overflow-wrap: break-word;
      -webkit-hyphens: auto;
      hyphens: auto;
      hyphenate-limit-chars: 18 8 8;
    }
  }

  /* The rhythm: margins reset, then one gap between blocks. */
  :where(.ds-prose) > * {
    margin-block: 0;
  }

  :where(.ds-prose) > * + * {
    margin-block-start: var(--ds-prose-gap, var(--ds-space-2));
  }

  :where(.ds-prose) :is(h1, h2, h3, h4, h5, h6) {
    margin-block: 0;
    color: var(--ds-text);
    letter-spacing: 0;
    text-wrap: balance;
  }

  /* A heading opens a section: more room above it than between paragraphs. */
  :where(.ds-prose) > * + :is(h1, h2, h3, h4, h5, h6) {
    margin-block-start: var(--ds-space-5);
  }

  :where(.ds-prose) h1 {
    font: var(--ds-font-headline);
  }

  :where(.ds-prose) h2 {
    font: var(--ds-font-subtitle);
  }

  :where(.ds-prose) :is(h3, h4, h5, h6) {
    font: inherit;
    font-weight: var(--ds-weight-label);
  }

  :where(.ds-prose) p {
    text-wrap: pretty;
  }

  :where(.ds-prose) :is(ul, ol) {
    padding-inline-start: var(--ds-space-5);
  }

  :where(.ds-prose) li > :is(ul, ol) {
    margin-block-start: var(--ds-space-1);
  }

  :where(.ds-prose) li + li {
    margin-block-start: var(--ds-space-1);
  }

  :where(.ds-prose) :is(strong, b) {
    font-weight: var(--ds-prose-strong-weight, var(--ds-weight-label));
  }

  :where(.ds-prose) a {
    color: var(--ds-link);
    text-decoration-line: underline;
    text-decoration-thickness: var(--ds-link-underline-thickness);
    text-underline-offset: var(--ds-link-underline-offset);
    text-decoration-color: var(--ds-link-underline);
  }

  :where(.ds-prose) img {
    max-width: 100%;
    height: auto;
  }

  :where(.ds-prose) blockquote {
    margin-inline: 0;
    padding: var(--ds-space-3) var(--ds-space-4);
    border-radius: var(--ds-radius-card);
    background: var(--ds-surface-high);
    color: var(--ds-text);
  }

  :where(.ds-prose) hr {
    border: 0;
    border-top: 1px solid var(--ds-line);
  }

  /* A table of rendered text: hairline rows, a quiet head, tabular figures;
     wider than the text, it scrolls on its own. */
  :where(.ds-prose) table {
    position: relative;
    display: block;
    max-width: 100%;
    overflow-x: auto;
    border-collapse: collapse;
    font: var(--ds-font-body);
    font-variant-numeric: tabular-nums;
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
  }

  :where(.ds-prose) :is(th, td) {
    padding: var(--ds-space-2) var(--ds-space-3);
    border-bottom: 1px solid var(--ds-line);
    text-align: start;
    vertical-align: top;
  }

  :where(.ds-prose) th {
    color: var(--ds-text-2);
    font: var(--ds-font-label);
  }

  /* A pre in prose is a code block. */
  :where(.ds-prose) pre,
  .ds-code-block {
    position: relative;
    margin: 0;
    padding: var(--ds-space-4);
    overflow-x: auto;
    border-radius: var(--ds-radius-card);
    background: var(--ds-code-bg);
    color: var(--ds-text);
    font: var(--ds-font-code);
    white-space: pre;
    tab-size: 2;
    -webkit-hyphens: manual;
    hyphens: manual;
    overflow-wrap: normal;
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
    /* An inset ring (the block clips anything outside): it rests on the
       edge and snaps inward. */
    outline-offset: 0;
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  :where(.ds-prose) > * + pre {
    margin-block-start: var(--ds-prose-gap, var(--ds-space-2));
  }

  .ds-code-block:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  .ds-code-block--wrap {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }

  /* Code inside a block (or a link) is just text: no inline chip per line. */
  :where(.ds-prose, .ds-code-block) :where(pre, a) code,
  .ds-code-block code {
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
  }

  /* Inline code in prose takes the size of the text around it. */
  :where(.ds-prose) :not(pre) > code {
    padding: 0 var(--ds-space-1);
    border: 1px solid var(--ds-line);
    border-radius: var(--ds-radius-xs);
    background: var(--ds-code-bg);
    font-family: var(--ds-font-mono);
    font-size: inherit;
  }

  @media (forced-colors: active) {
    :where(.ds-prose) blockquote,
    :where(.ds-prose) pre,
    .ds-code-block {
      outline: 1px solid transparent;
    }
  }

  @media print {
    :where(.ds-prose) pre,
    .ds-code-block,
    :where(.ds-prose) table {
      overflow: visible;
      white-space: pre-wrap;
    }
  }
}

/* ── components/empty.css ── */
/*
 * Empty state: centred, at most 400px. A tinted disc with the icon (24px),
 * a title, at most one line of what to do next and at most one action.
 *
 *   <div class="ds-empty">
 *     <span class="ds-empty__disc" aria-hidden="true"><svg ...></span>
 *     <p class="ds-empty__title">No devices yet</p>
 *     <p class="ds-empty__text">Add the first one to start the inventory.</p>
 *     <button class="ds-btn ds-btn--tonal" type="button">Add device</button>
 *
 * No motion of its own; the page picks it by what brought the empty state
 * (docs/motion.md, "Data motion"):
 *   replacing a skeleton           .ds-reveal
 *   a filter, search or live update the rows leave through DS.motion.flip
 *                                  (they fade out in place), then
 *                                  DS.motion.enter(empty, 'fade'), no rise
 *   the filter cleared             DS.motion.leave(empty) while the rows
 *                                  enter through flip
 *   the user deleted the last item DS.motion.enter(disc, 'pop') on the disc
 *
 * A confirmation (a request sent, a job done): .ds-empty--done with a
 * pathLength="1" check in the disc:
 *
 *   <div class="ds-empty ds-empty--done" role="status">
 *     <span class="ds-empty__disc" aria-hidden="true"><svg ...>(check, 24)</svg></span>
 *     <p class="ds-empty__title">Request sent</p>
 *     <p class="ds-empty__text">We will answer within a day.</p>
 *
 * Its own entrance: the disc fades in and pops up on the spring
 * (ds-fade-in, ds-pop-in, --ds-dur-slow) after --ds-dur-base; a ring
 * widens and fades once behind it (ds-ripple on ::after) after
 * --ds-dur-slow; the check draws itself (ds-draw, --ds-dur-glide,
 * --ds-ease-out). Keyframes, so they play when the element is inserted or
 * shown (hidden removed), never again while a re-render keeps the node.
 * Forced colours: the disc keeps an outline (its fill is dropped).
 *
 * Hooks: --ds-empty-disc-size (48px): the disc's width and height (a
 * confirmation page may want a larger one); the ring follows, the glyph
 * keeps the size of its markup.
 *
 * Progressive:
 * - text-wrap (the text avoids a lone last word): without it the line
 *   wraps as usual.
 */

@layer ds.components {
  .ds-empty {
    display: grid;
    justify-items: center;
    gap: var(--ds-space-2);
    max-width: 400px;
    margin: 0 auto;
    padding: var(--ds-space-8) var(--ds-space-4);
    text-align: center;
  }

  .ds-empty__disc {
    display: grid;
    place-items: center;
    width: var(--ds-empty-disc-size, 48px);
    height: var(--ds-empty-disc-size, 48px);
    margin-bottom: var(--ds-space-2);
    border-radius: var(--ds-radius-action);
    background: var(--ds-accent);
    color: var(--ds-on-accent-icon);
  }

  .ds-empty__title {
    margin: 0;
    color: var(--ds-text);
    font: var(--ds-font-subtitle);
  }

  .ds-empty__text {
    margin: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-body);
    text-wrap: pretty;
  }

  .ds-empty > .ds-btn {
    margin-top: var(--ds-space-3);
  }

  /* The confirmation: the disc pops in, a ring widens behind it once, the
     check draws itself. */
  .ds-empty--done .ds-empty__disc {
    position: relative;
    /* The ring (z-index -1) widens behind the check, above the disc's fill. */
    isolation: isolate;
    animation:
      ds-fade-in var(--ds-dur-slow) var(--ds-ease-in-out) var(--ds-dur-base) backwards,
      ds-pop-in var(--ds-dur-slow) var(--ds-ease-spatial) var(--ds-dur-base) backwards;
  }

  .ds-empty--done .ds-empty__disc::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: var(--ds-accent);
    opacity: 0;
    pointer-events: none;
    animation: ds-ripple var(--ds-dur-slow) var(--ds-ease-out) var(--ds-dur-slow);
  }

  .ds-empty--done .ds-empty__disc [pathLength] {
    stroke-dasharray: 1;
  }

  /* The check draws on its <svg>; the pathLength="1" path inherits the
     stroke (Chromium can miss a change above a [pathLength] rule). */
  .ds-empty--done .ds-empty__disc > svg {
    animation: ds-draw var(--ds-dur-glide) var(--ds-ease-out) var(--ds-dur-slow) backwards;
  }

  @media (forced-colors: active) {
    .ds-empty--done .ds-empty__disc {
      outline: 1px solid CanvasText;
    }
  }
}

/* ── components/status.css ── */
/*
 * Status chip: icon and text on the tonal container of its status, never
 * colour alone. A running state turns the small spinner instead of an
 * icon; keep that node when the label changes, so it never restarts.
 *
 *   <span class="ds-status ds-status--ok"><svg ...>(check, 16)</svg><span>Synced</span></span>
 *   <span class="ds-status ds-status--running"><span class="ds-spinner ds-status__spinner" aria-hidden="true"></span><span>Running</span></span>
 *
 *   --ok  --error  --warning  --running   (none: neutral, e.g. idle or off, told apart by the icon)
 *
 * Fully round at one line (radius = half the height); a long label that
 * wraps keeps soft corners instead of turning into a stadium.
 * Hooks: --ds-status-radius, --ds-status-spinner-width (2px, the ring of
 * the running spinner).
 *
 * Status changes glide, for a user action and a poll alike: colour and
 * icon change smoothly for every status change, the text at once. The
 * container and its icon change colour over --ds-dur-medium; render with
 * DS.dom.morphHtml, which patches the kept span in place, so the class
 * change transitions instead of replacing the chip. An icon or spinner
 * that comes in new (running to ok) fades in and springs up from
 * --ds-scale-small, like the incoming face of .ds-icon-swap; a page that
 * swaps the icon itself can use DS.motion.crossfade(chip, icon, { kind:
 * 'icon' }). What glides and what changes at once: docs/motion.md, "Data
 * motion".
 *
 * Tag: a label of words ("Preview", "Beta", "External"), not a state and
 * not a count; not interactive.
 *
 *   <span class="ds-tag">Preview</span>
 *   <span class="ds-tag ds-tag--neutral">(tag 16)External</span>
 *
 * The caption type on the tint (--ds-tag-bg / --ds-tag-color, from
 * --ds-accent / --ds-on-accent; --neutral: --ds-surface-high / --ds-text),
 * an optional leading 16px icon in --ds-on-accent-icon, the pill radius
 * min(--ds-radius-action, half its height). Forced colours: a CanvasText
 * outline. Tag = words, badge (.ds-badge) = counts, status chip = a state
 * with an icon. Hooks: --ds-tag-bg, --ds-tag-color, --ds-tag-height (24px).
 *
 * Progressive:
 * - @starting-style (the incoming icon's fade and scale; it also plays on
 *   the first render of a chip, never on a keyed move: where moveBefore is
 *   missing, morphHtml marks a moved row data-ds-moving for one style
 *   flush, motion.css): without it the icon swaps at once while the
 *   colours still glide.
 */

@layer ds.components {
  .ds-status {
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-1);
    min-height: 24px;
    max-width: 100%;
    padding-block: var(--ds-space-1);
    padding-inline: 6px var(--ds-space-2);
    border-radius: var(--ds-status-radius, var(--ds-radius-md));
    background: var(--ds-surface-high);
    color: var(--ds-text-2);
    font: var(--ds-font-caption);
    vertical-align: middle;
    transition:
      background-color var(--ds-dur-medium) var(--ds-ease-in-out),
      color var(--ds-dur-medium) var(--ds-ease-in-out);
  }

  .ds-status > svg {
    flex-shrink: 0;
  }

  /* The icon's colour follows the modifier on the container's timing; an
     icon or spinner that comes in new fades in and springs up. */
  .ds-status > :is(svg, .ds-spinner) {
    transition:
      color var(--ds-dur-medium) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      scale var(--ds-dur-fast) var(--ds-ease-spatial);
  }

  @starting-style {
    .ds-status > :is(svg, .ds-spinner) {
      opacity: 0;
      scale: var(--ds-scale-small);
    }
  }

  .ds-status--ok {
    background: var(--ds-success-container);
    color: var(--ds-on-success-container);
  }

  .ds-status--ok > svg {
    color: var(--ds-success);
  }

  .ds-status--error {
    background: var(--ds-error-container);
    color: var(--ds-on-error-container);
  }

  .ds-status--warning {
    background: var(--ds-warning-container);
    color: var(--ds-on-warning-container);
  }

  /* Running: a neutral chip with the primary spinner. */
  .ds-status--running {
    color: var(--ds-text);
  }

  /* The arc at its own pace, the turn a little faster than on a surface. */
  .ds-status__spinner {
    --ds-spinner-width: var(--ds-status-spinner-width, 2px);
    width: 14px;
    height: 14px;
    margin: 1px;
    animation-duration: var(--ds-loop-arc), var(--ds-loop-spin-fast);
  }

  /* Tag: words on the tint, a pill at one line. */
  .ds-tag {
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-1);
    min-height: var(--ds-tag-height, 24px);
    max-width: 100%;
    padding: 0 var(--ds-space-2);
    border-radius: min(var(--ds-radius-action), calc(var(--ds-tag-height, 24px) / 2));
    background: var(--ds-tag-bg, var(--ds-accent));
    color: var(--ds-tag-color, var(--ds-on-accent));
    font: var(--ds-font-caption);
    white-space: nowrap;
    vertical-align: middle;
  }

  .ds-tag > svg {
    flex-shrink: 0;
    width: 16px;
    height: 16px;
    color: var(--ds-on-accent-icon);
  }

  .ds-tag:has(> svg:first-child) {
    padding-inline-start: 6px;
  }

  .ds-tag--neutral {
    --ds-tag-bg: var(--ds-surface-high);
    --ds-tag-color: var(--ds-text);
  }

  .ds-tag--neutral > svg {
    color: var(--ds-text-2);
  }

  @media (forced-colors: active) {
    .ds-tag {
      outline: 1px solid CanvasText;
      outline-offset: -1px;
    }
  }
}

/* ── components/alert.css ── */
/*
 * Alerts: notices on tonal containers with an icon, never colour alone.
 * The neutral alert has no colour of its own: the quiet surface and the
 * icon in the primary colour.
 *
 *   <div class="ds-alert ds-alert--warning" role="status">
 *     <svg ...>(alert, 20)</svg>
 *     <span class="ds-alert__text">The certificate expires in 5 days.</span>
 *     <button class="ds-btn ds-btn--text ds-btn--sm" type="button">Renew</button>
 *   </div>
 *
 *   (none: neutral info)  --success  --warning  --error
 *   .ds-alert--plain      a finding in a card: no fill, no edge, no inline
 *                         padding, no radius; hairlines between consecutive
 *                         plain alerts (a preset of the hooks below)
 *
 * A title, more paragraphs and actions:
 *
 *   <div class="ds-alert ds-alert--warning">
 *     <svg ...>(alert, 20)</svg>
 *     <div class="ds-alert__text"><p class="ds-alert__title">Certificate expires soon</p><p>Renew it before 1 May.</p></div>
 *     <div class="ds-alert__actions"><button class="ds-btn ds-btn--text ds-btn--sm" type="button">Renew</button></div>
 *   </div>
 *
 * With .ds-alert__actions the alert wraps: the text takes at least
 * --ds-alert-text-basis (256px) and the actions drop under it when the row
 * is narrow. Static findings carry no live role.
 *
 * Hooks: --ds-alert-bg (--ds-surface-low), --ds-alert-color (--ds-text),
 * --ds-alert-icon (--ds-primary; also the edge colour), --ds-alert-edge
 * (the width of a leading edge, 0), and per kind (success, warning, error)
 * --ds-alert-<kind>-bg, --ds-alert-<kind>-color, --ds-alert-<kind>-icon
 * with today's values as fallbacks; .ds-alert--error buttons take
 * --ds-alert-error-color; --ds-alert-text-basis. Forced colours: a
 * transparent outline becomes the edge.
 *
 * The summary of .ds-errors is a pressable part: a state layer in its
 * colour (hover, press in --ds-dur-micro, focus with --ds-state-focus) in
 * a 4px padding that hangs into the space around it, the ripple
 * (js/ripple.js), and its own ring for keyboard focus (also without
 * .ds-base): solid from the first frame, it snaps from offset + width to
 * the offset (outline-offset, --ds-dur-fast). It is not a pill and does
 * not morph. Its chevron (.ds-errors__chevron) turns 180deg with the list
 * on the spring, like .ds-chevron; <details class="ds-errors
 * ds-collapsible"> opens the list with the collapsible timing.
 *
 * Inserting and dismissing (docs/motion.md, "Data motion"): through
 * DS.motion.flip on the stack, for a user action and live data alike.
 * Dismiss: flip(stack, () => alert.remove()): the alert fades out in place,
 * out of flow, and the alerts below glide up on the spring (in any flex or
 * grid gap, no height animated). Insert: flip(stack, () =>
 * stack.prepend(alert)): it fades in while the others glide down.
 */

@layer ds.components {
  /* No entrance of their own: an alert re-rendered with fresh data is
     patched in place and never fades in again. An alert that comes or goes
     (a user action or live data) does it through DS.motion.flip on its
     stack (the header above); one that replaces a placeholder takes
     .ds-reveal, never per update. */

  .ds-alert {
    display: flex;
    align-items: flex-start;
    gap: var(--ds-space-3);
    padding: var(--ds-space-3) var(--ds-space-4);
    border-inline-start: var(--ds-alert-edge, 0px) solid var(--ds-alert-icon, var(--ds-primary));
    border-radius: var(--ds-radius-card);
    background: var(--ds-alert-bg, var(--ds-surface-low));
    color: var(--ds-alert-color, var(--ds-text));
    font: var(--ds-font-body);
  }

  .ds-alert > svg {
    flex-shrink: 0;
    color: var(--ds-alert-icon, var(--ds-primary));
  }

  /* The 20px icon and a 20px line share one baseline. */
  .ds-alert__text {
    flex: 1;
    min-width: 0;
    overflow-wrap: anywhere;
  }

  /* The button hangs out of the 20px text line by the same amount above and
     below, so its label sits on the text's centre line and the banner keeps
     its even padding. */
  .ds-alert .ds-btn {
    flex-shrink: 0;
    margin-block: calc((var(--ds-space-5) - var(--ds-btn-height, 36px)) / 2);
    margin-inline: auto calc(-1 * var(--ds-space-2));
  }

  /* A title and more paragraphs in the text. */
  .ds-alert__title {
    display: block;
    color: inherit;
    font: var(--ds-font-label);
  }

  .ds-alert__text > p {
    margin: 0;
  }

  .ds-alert__text > p + p {
    margin-top: var(--ds-space-2);
  }

  /* Actions in a row after the text: the row hangs out of the text line as
     a single button would, its buttons sit side by side. */
  .ds-alert__actions {
    display: flex;
    flex-wrap: wrap;
    flex-shrink: 0;
    gap: var(--ds-space-1);
    margin-block: calc((var(--ds-space-5) - var(--ds-btn-height, 36px)) / 2);
    margin-inline: auto calc(-1 * var(--ds-space-2));
  }

  .ds-alert__actions > .ds-btn {
    margin: 0;
  }

  .ds-alert:has(> .ds-alert__actions) {
    flex-wrap: wrap;
  }

  .ds-alert:has(> .ds-alert__actions) > .ds-alert__text {
    flex: 1 1 var(--ds-alert-text-basis, 256px);
  }

  .ds-alert a {
    color: inherit;
  }

  /* The kinds set the alert's hooks from their own, with today's colours as fallbacks. */
  .ds-alert--success {
    --ds-alert-bg: var(--ds-alert-success-bg, var(--ds-success-container));
    --ds-alert-color: var(--ds-alert-success-color, var(--ds-on-success-container));
    --ds-alert-icon: var(--ds-alert-success-icon, var(--ds-success));
  }

  .ds-alert--warning {
    --ds-alert-bg: var(--ds-alert-warning-bg, var(--ds-warning-surface));
    --ds-alert-color: var(--ds-alert-warning-color, var(--ds-text));
    --ds-alert-icon: var(--ds-alert-warning-icon, var(--ds-warning));
  }

  .ds-alert--error {
    --ds-alert-bg: var(--ds-alert-error-bg, var(--ds-error-container));
    --ds-alert-color: var(--ds-alert-error-color, var(--ds-on-error-container));
    --ds-alert-icon: var(--ds-alert-error-icon, var(--ds-error));
  }

  .ds-alert--error:not(.ds-alert--plain) .ds-btn {
    --ds-btn-color: var(--ds-alert-error-color, var(--ds-on-error-container));
  }

  /* Plain: a finding in a card, on the card's own surface. A preset of the
     hooks (after the kinds: their icon stays), hairlines between
     consecutive ones. */
  .ds-alert--plain {
    --ds-alert-bg: transparent;
    --ds-alert-color: var(--ds-text);
    --ds-alert-edge: 0;
    padding-inline: 0;
    border-radius: 0;
  }

  .ds-alert--plain + .ds-alert--plain {
    border-top: 1px solid var(--ds-line);
  }

  /* Forced colours drop the fill: a transparent outline becomes the edge. */
  @media (forced-colors: active) {
    .ds-alert:not(.ds-alert--plain) {
      outline: 1px solid transparent;
      outline-offset: -1px;
    }
  }

  /* Code in a tonal alert is told apart by the typeface alone. */
  .ds-alert code {
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
  }

  /* Collapsible list of messages: icon and text in the summary.

     <details class="ds-errors ds-collapsible"><summary><svg ...>(alertCircle)</svg>2 errors<svg class="ds-errors__chevron" ...>(chevronDown)</svg></summary><ul><li>...</li></ul></details> */
  .ds-errors {
    font: var(--ds-font-body);
  }

  /* Its 4px padding (the room of its state layer) hangs into the space
     around it, like the table's sort button, so the text keeps its place. */
  .ds-errors summary {
    position: relative;
    isolation: isolate;
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-2);
    margin: calc(-1 * var(--ds-space-1));
    padding: var(--ds-space-1);
    border-radius: var(--ds-radius-xs);
    color: var(--ds-on-error-container);
    font: var(--ds-font-label);
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* State layer in the content colour. */
  .ds-errors summary::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-errors summary:hover::before {
      opacity: var(--ds-state-hover);
    }
  }

  /* Keyboard focus: the layer appears with the ring and fades out on blur. */
  .ds-errors summary:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  /* Press-in in --ds-dur-micro (.ds-pressed: js/ripple.js holds it). */
  .ds-errors summary:active::before,
  .ds-errors summary.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  .ds-errors summary:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  .ds-errors summary svg {
    flex-shrink: 0;
    color: var(--ds-error);
  }

  /* Its own bullets: a layered reset (list-style: none on ul) would leave
     the indent without its markers. */
  .ds-errors ul {
    list-style-type: disc;
    margin: var(--ds-space-2) 0 0;
    padding-inline-start: var(--ds-space-5);
    color: var(--ds-text-2);
    overflow-wrap: anywhere;
  }

  .ds-errors li + li {
    margin-top: var(--ds-space-1);
  }

  .ds-errors summary::-webkit-details-marker {
    display: none;
  }

  /* The disclosure chevron turns 180deg with the expansion (D25), on the
     timing of .ds-chevron. */
  .ds-errors .ds-errors__chevron {
    color: inherit;
    transition: rotate var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  .ds-errors[open] .ds-errors__chevron {
    rotate: 180deg;
  }
}

/* ── components/table.css ── */
/*
 * Data tables (Material 3): a 48px header in the label style, 52px rows
 * divided by the passive hairline, a quiet hover, no zebra. Numbers
 * right-aligned and tabular. Inside a card the table runs to the card's
 * edges, so the row hover spans the whole width and the first column lines
 * up with the card text. The wrapper scrolls sideways on narrow screens; a
 * sticky header needs a wrapper that also scrolls down: the full-page
 * table (.ds-table-wrap--fill).
 *
 *   <div class="ds-table-wrap">
 *     <table class="ds-table [ds-table--wide]">
 *       <thead><tr><th>Name</th><th class="ds-num">Count</th><th class="ds-col--secondary">Owner</th></tr></thead>
 *       <tbody><tr><td class="ds-col--text">...</td><td class="ds-num">12</td><td class="ds-col--secondary">...</td></tr></tbody>
 *       <tfoot><tr><th scope="row">Total</th><td class="ds-num">128</td><td class="ds-col--secondary"></td></tr></tfoot>
 *
 *   .ds-col--text       a text column that keeps a readable width
 *   .ds-col--secondary  dropped on phones (and in .ds-table--wide when the
 *                       content area is narrow); in a card the last
 *                       column still shown keeps the card's gutter
 *   .ds-link-btn        a button that reads like a link (opens a detail;
 *                       button.css)
 *   .ds-table--dense    40px rows (long lists)
 *   <th aria-sort="ascending"><button class="ds-table__sort">Name (arrowUp .ds-table__sort-arrow)</button></th>
 *                       a sortable column; the arrow at the sorted column
 *                       says it, not the colour; every sort button keeps
 *                       the arrow's room, so moving the sort never shifts
 *                       the columns
 *   .ds-table__sort-arrow  on the arrow's svg: one up arrow for both
 *                       directions, which turns to point down for
 *                       aria-sort="descending" (opt-in: an unmarked svg
 *                       is never turned, so a page that renders arrowDown
 *                       for descending keeps its direction)
 *   <td class="ds-table__actions"><button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="Remove source 1">(close)</button></td>
 *                       the row's own actions at its end: end-aligned, as
 *                       narrow as its buttons, which hang into the cell's
 *                       padding (the row keeps its height); in an inner
 *                       .ds-hover-actions they show on demand
 *   tfoot               a summary row: label type, a hairline above, no
 *                       hover, press or row link
 *   <div class="ds-selection-bar" role="toolbar" aria-label="Selected devices">
 *     <button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="Clear selection">(close)</button>
 *     <p class="ds-selection-bar__count" aria-live="polite">3 selected</p>
 *     <div class="ds-selection-bar__actions">...</div>
 *   </div>              the bar of a bulk selection, in --ds-accent: it
 *                       shares a .ds-swap slot with the toolbar
 *                       (DS.motion.crossfade() when the count goes from 0
 *                       to 1 and back), so the table never moves
 *
 * Stacked rows (a row of fields on a phone or in a side sheet):
 *   <table class="ds-table ds-table--stack">   (or ds-table--stack-wide)
 *     <tbody><tr><td data-label="Name"><input class="ds-input" aria-label="Name"></td> ...
 *   Each row becomes a block with a hairline border, each cell a label
 *   (its data-label, in the field label's type) above its content, a
 *   .ds-table__actions cell at the block's top end; the header stays for
 *   screen readers only. Always inside .ds-sheet__body and .ds-dialog,
 *   and in a view whose content area is at most 600px wide (--stack) or
 *   840px (--stack-wide, five or more field columns). No row hover and no
 *   fade; not for interactive rows, expandable rows or sort buttons, and
 *   a full-page wrapper or a pinned column steps aside while the rows
 *   stack. The three copies of the stacked block (sheet and dialog, 600px,
 *   840px) are kept identical by a test.
 *
 * Full-page tables and pinned columns:
 *   <div class="ds-table-wrap ds-table-wrap--fill"><table class="ds-table ds-table--pin-first">...</table></div>
 *   .ds-table-wrap--fill  the wrapper is its own card (hairline border,
 *                       card radius), fills what is left of a bounded
 *                       parent (.ds-view--fill) and scrolls both ways; its
 *                       header row is sticky, and long cells are cut with
 *                       an ellipsis at --ds-table-cell-max-width (a
 *                       tooltip shows a cut cell's text)
 *   .ds-table--pin-first  the first column stays while the others scroll
 *                       under it, on an opaque base; the row's layers lie
 *                       over it (registered colours, so they still fade);
 *                       a hairline at its end shows once the table has
 *                       scrolled (the wrapper drops its start
 *                       fade). In an interactive table
 *                       DS.ripple.attach() forwards a click on the row to
 *                       its row link (the pinned cell confines the link's
 *                       stretched area) and ripples the row in two parts
 *
 * Wrappers that scroll sideways fade at the end that still has columns
 * (in a card: .ds-table--wide when the content area is narrow, every table
 * on phones; .ds-table-wrap--fill always). The fades are the two numbers
 * of scroller.css (--ds-scroll-edge-start, --ds-scroll-edge-end): where
 * scroll timelines exist ds-scroll-edge-start and ds-scroll-edge-end drive
 * them on the wrapper's own scroll; elsewhere DS.dom.scrollEdges() writes
 * data-ds-overflow-start and -end (a card wrapper without it keeps the
 * static fade at its right edge). A wrapper with stacked rows never fades.
 * A wrapper whose cells hold nothing focusable is a tab stop
 * (tabindex="0" role="region" aria-label="..."): an inset ring that snaps
 * from its edge, unmasked while focused.
 *
 * Hooks: --ds-table-row-hover, --ds-table-row-selected, --ds-table-head-height
 * (48px), --ds-table-row-height (52px), --ds-table-dense-row-height
 * (40px; the cell padding follows the heights), --ds-table-sticky-bg (the
 * opaque base of sticky and pinned cells and of the fill wrapper, default
 * --ds-surface), --ds-table-cell-max-width (280px), --ds-table-fill-min-height
 * (240px), --ds-table-fill-max-height (none), --ds-scroll-fade (the fade
 * length; in a card --ds-card-pad, 0px turns the fades off).
 *
 * Runtime properties (written by DS.dom.scrollEdges(), read with a
 * fallback): --ds-scrollbar-height (the scrollbar strip stays unmasked),
 * --ds-table-pin-width (focus never lands under a pinned column).
 *
 * Internal (never set it): --ds-table-start-fade, 1 on every wrapper and
 * 0 on one that holds a .ds-table--pin-first (no start fade over the
 * pinned column; the end fade stays).
 *
 * Internal (each body row sets them, its cells read them; never set
 * them): --ds-table-row-layer, --ds-table-row-pace, --ds-table-row-tint,
 * --ds-table-row-line. The row decides, the cells paint: no cell rule
 * sits behind a row's :has() (Chromium would restyle every table cell
 * of the page on each change anywhere in it).
 *
 * The sort button presses in --ds-dur-micro (also while js/ripple.js holds
 * .ds-pressed) and ripples; it is not a pill and does not morph. Keyboard
 * focus on the sort button: a ring that is solid from the first frame and
 * snaps from offset + width to the offset (outline-offset, --ds-dur-fast),
 * plus the focus layer. The link button (.ds-link-btn, button.css) is an
 * inline link: no ripple, no morph.
 *
 * Rows:
 *   hover                  a state layer on the cells (currentColor at
 *                          --ds-state-hover-lg), so it adds to a selected
 *                          row's colour; hook --ds-table-row-hover
 *   .ds-table--interactive every body row with a row link opens something:
 *                          its primary link or button carries
 *                          .ds-table__row-link, whose
 *                          ::after stretches over the row (the focus ring
 *                          sits on it, inset, so the scroller never clips
 *                          it); the row is positioned and every body row
 *                          has an identity transform, so it is the box of
 *                          the stretched area also in WebKit, which lays
 *                          rows out static (a row is then a stacking
 *                          context and the box of fixed descendants; not
 *                          while it holds a menu or combobox list that the
 *                          script places); WebKit makes the area as tall
 *                          as the table below the row's top, the rows
 *                          below, painted above it, keep their own
 *                          clicks, and a focused row clips its ring to
 *                          itself; the press is the cell layer at
 *                          --ds-state-pressed in --ds-dur-micro, or the
 *                          ripple (js/ripple.js, the row is a host); other
 *                          controls in the row stay above the link and
 *                          keep their own press (no row layer, no ripple);
 *                          the table draws separate borders (a positioned
 *                          row would cover collapsed hairlines in Firefox);
 *                          a row without a row link stays plain (no
 *                          pointer, no press, no ripple)
 *   .ds-table__row--selected, or a checked checkbox directly in a cell: the
 *                          row takes --ds-table-row-selected (default
 *                          --ds-accent) over --ds-dur-fast; the checkbox is
 *                          the cue that does not rely on colour, also in
 *                          the header cell (select all, indeterminate for
 *                          some); select all changes every row in one frame
 *   expandable             the trigger row holds
 *                          <button class="ds-btn ds-btn--quiet ds-btn--icon ds-table__toggle" aria-expanded="false" aria-controls="d1">(chevronDown .ds-chevron)</button>
 *                          and is followed by
 *                          <tr class="ds-table__detail" id="d1" inert><td colspan="N"><div class="ds-table__expand"><div>...</div></div></td></tr>
 *                          its header cell is named for screen readers:
 *                          <th scope="col"><span class="ds-sr">Details</span></th>
 *                          The detail opens by grid-template-rows (0fr to
 *                          1fr) over --ds-dur-medium on --ds-ease-out and
 *                          closes over --ds-dur-base on --ds-ease-in: it
 *                          pushes the rows below, so it never overshoots;
 *                          its content fades over --ds-dur-fast. One
 *                          hairline, below the detail. The page toggles
 *                          aria-expanded, sets inert on the collapsed
 *                          detail row and keys both rows (data-ds-key), so
 *                          flip() and morphHtml keep them together.
 *
 * Sorting: with .ds-table__sort-arrow the page renders the same up arrow
 * for both directions; aria-sort="descending" turns it 180deg on the
 * spring (--ds-dur-medium), when the header is patched in place
 * (DS.dom.morphHtml). Without the marker the arrow is what the page
 * renders (arrowUp or arrowDown), as before 1.4.
 *
 * Data motion (docs/motion.md, "Data motion"): sort, filter (also while
 * typing) and live data glide through DS.motion.flip with keyed rows and
 * DS.dom.morphHtml; figures in cells change at once and never count up.
 * A removed row goes at once while the rows below glide up. While rows
 * glide, flip() marks the tbody data-ds-glide and sets --ds-flip-backdrop
 * (the colour behind the table): every row gets an opaque backing in that
 * colour (the selection colour over it), so crossing rows never show
 * through each other.
 *
 * Forced colours: sticky and pinned cells are Canvas (nothing shows through
 * them), the fill wrapper's border CanvasText, the selection bar keeps an
 * outline. Print: wrappers show every column, unmasked and unclipped; the
 * full-page table is unbounded and unsticky, header text wraps, rows do not
 * break across pages, closed detail rows and the selection bar are hidden.
 *
 * :has() and container queries are baseline.
 * Progressive:
 * - scroll-driven edge fades (animation-timeline, the registered
 *   --ds-scroll-edge-* numbers of scroller.css; the pinned column's
 *   hairline): without them DS.dom.scrollEdges() sets them through its
 *   attributes, or the static fade at the right edge stays (the hairline
 *   then shows with data-ds-overflow-start).
 * - alt text for generated content (content: attr(data-label) / "", the
 *   label of a stacked cell is not read twice): without it Safari 16.4
 *   reads the label once more.
 * - scrollbar-color and scrollbar-width (a thin scrollbar on the full-page
 *   table): without them the platform scrollbar shows.
 */

/*
 * Deprecated in 2.0 (removed in 3.0 at the earliest), unused: the widths
 * of the old scroll-linked edge fades (ds-edge-fade in motion.css). The
 * wrappers fade on --ds-scroll-edge-start and --ds-scroll-edge-end
 * (scroller.css) now.
 */
@property --ds-table-fade-start {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

@property --ds-table-fade-end {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

/*
 * The state layers of a pinned cell: its base is opaque (the columns that
 * scroll under it never show through), so hover, focus, press and the
 * selection lie over it as background images of these two colours.
 * Registered, so they fade like the other cells' background colour; they do
 * not inherit.
 */
@property --ds-table-sticky-layer {
  syntax: '<color>';
  inherits: false;
  initial-value: transparent;
}

@property --ds-table-sticky-tint {
  syntax: '<color>';
  inherits: false;
  initial-value: transparent;
}

@layer ds.components {
  /* Positioned, so absolutely placed descendants (the visually hidden header
     text .ds-sr) are clipped by the scroller instead of widening the page. */
  .ds-table-wrap {
    position: relative;
    overflow-x: auto;
    /* Focus moved by the keyboard never lands under a pinned column
       (DS.dom.scrollEdges() writes its width), in a card as in a fill
       wrapper. */
    scroll-padding-inline-start: var(--ds-table-pin-width, 0px);
    /* The start fade, on (1) or off (0): a pinned column stays opaque. */
    --ds-table-start-fade: 1;
  }

  /* A pinned first column stays at the start edge, so the start fade would
     only wash out its text; its hairline tells that the table has
     scrolled. The end fade stays. */
  .ds-table-wrap:has(> .ds-table--pin-first) {
    --ds-table-start-fade: 0;
  }

  /* The edge numbers of scroller.css, from DS.dom.scrollEdges() where no
     scroll timeline drives them (the animations win where one does). They
     glide only once it has measured (data-ds-edges comes after the first
     edges): the first fades show still on load. */
  .ds-table-wrap[data-ds-edges] {
    transition:
      --ds-scroll-edge-start var(--ds-dur-fast) var(--ds-ease-in-out),
      --ds-scroll-edge-end var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-table-wrap[data-ds-overflow-start] {
    --ds-scroll-edge-start: 1;
  }

  .ds-table-wrap[data-ds-overflow-end] {
    --ds-scroll-edge-end: 1;
  }

  /* A wrapper that is a tab stop (nothing focusable in its cells): an inset
     ring, solid from the first frame, that snaps inward from the edge; the
     fades would cut it, so the focused wrapper shows unmasked. */
  .ds-table-wrap[tabindex] {
    outline-offset: 0;
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-table-wrap[tabindex][data-ds-edges] {
    transition:
      --ds-scroll-edge-start var(--ds-dur-fast) var(--ds-ease-in-out),
      --ds-scroll-edge-end var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-table-wrap[tabindex]:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
    -webkit-mask-image: none;
    mask-image: none;
  }

  .ds-card .ds-table-wrap {
    margin-inline: calc(-1 * var(--ds-card-pad));
  }

  .ds-table {
    width: 100%;
    border-collapse: collapse;
    /* Its own ink (a widget without .ds-base would take the host's). */
    color: var(--ds-text);
    font: var(--ds-font-dense);
    /* Dates, times and counts line up from row to row. */
    font-variant-numeric: tabular-nums;
  }

  .ds-table th,
  .ds-table td {
    padding: var(--ds-space-4);
    border-bottom: 1px solid var(--ds-line);
    text-align: start;
    vertical-align: middle;
  }

  .ds-card .ds-table-wrap :is(th, td):first-child {
    padding-inline-start: var(--ds-card-pad);
  }

  .ds-card .ds-table-wrap :is(th, td):last-child {
    padding-inline-end: var(--ds-card-pad);
  }

  .ds-table caption {
    text-align: start;
  }

  /* The heights are hooks (a compact theme); the block padding follows
     them, never more than --ds-space-3: a 20px line in a 48px head, a 24px
     status chip and the hairline in a 52px row (40px rows: 7px). */
  .ds-table th {
    height: var(--ds-table-head-height, 48px);
    padding-block: min(var(--ds-space-3), calc((var(--ds-table-head-height, 48px) - var(--ds-space-5) - 1px) / 2));
    color: var(--ds-text-2);
    font: var(--ds-font-label);
    white-space: nowrap;
  }

  .ds-table td {
    height: var(--ds-table-row-height, 52px);
    padding-block: min(var(--ds-space-3), calc((var(--ds-table-row-height, 52px) - var(--ds-space-6) - 2px) / 2));
  }

  /*
   * The row holds the selection colour, its cells the state layer: the
   * layer adds to the selection instead of replacing it. The row decides
   * and its cells paint: each body row sets its state layer
   * (--ds-table-row-layer) and how fast it changes (--ds-table-row-pace),
   * the selection a pinned cell lays over its base (--ds-table-row-tint)
   * and the style of its hairline (--ds-table-row-line); its cells read
   * them. A cell rule behind a row's :has() would put every cell of the
   * page into Chromium's one :has() invalidation set, so each change
   * anywhere in the page restyled every table cell. Every body row starts
   * from these values, so a nested table never takes its outer row's
   * layer. Internal: a page never sets them.
   */
  .ds-table tbody tr {
    --ds-table-row-layer: transparent;
    --ds-table-row-pace: var(--ds-dur-fast);
    --ds-table-row-tint: transparent;
    --ds-table-row-line: solid;
    transition: background-color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-table tbody tr > :is(td, th) {
    border-bottom-style: var(--ds-table-row-line);
    background-color: var(--ds-table-row-layer);
    transition: background-color var(--ds-table-row-pace) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-table tbody tr:not(.ds-table__detail):hover {
      --ds-table-row-layer: var(--ds-table-row-hover, color-mix(in srgb, currentColor calc(var(--ds-state-hover-lg) * 100%), transparent));
    }
  }

  /* Selected rows (bulk selection): the checkbox in the row is the cue
     that does not rely on colour. Two rules: the class marks a row on its
     own (set by the page), and the :has() rule picks up a row whose
     checkbox is checked. */
  .ds-table tbody tr.ds-table__row--selected {
    --ds-table-row-tint: var(--ds-table-row-selected, var(--ds-accent));
    background-color: var(--ds-table-row-selected, var(--ds-accent));
  }

  .ds-table tbody tr:has(> :is(td, th) > input[type="checkbox"]:checked) {
    --ds-table-row-tint: var(--ds-table-row-selected, var(--ds-accent));
    background-color: var(--ds-table-row-selected, var(--ds-accent));
  }

  /* While rows glide (DS.motion.flip marks the tbody data-ds-glide with
     --ds-flip-backdrop, the colour behind the table), every row is opaque,
     so a row that crosses another never shows its text through. A
     background image: it switches at once (no colour transition), and the
     selection colour lies over the backing; the cells' state layer stays
     above both. At rest it matches what is behind the table. */
  .ds-table tbody[data-ds-glide] > tr {
    background-image: linear-gradient(var(--ds-flip-backdrop, var(--ds-surface)), var(--ds-flip-backdrop, var(--ds-surface)));
  }

  .ds-table tbody[data-ds-glide] > tr.ds-table__row--selected {
    background-image:
      linear-gradient(var(--ds-table-row-selected, var(--ds-accent)), var(--ds-table-row-selected, var(--ds-accent))),
      linear-gradient(var(--ds-flip-backdrop, var(--ds-surface)), var(--ds-flip-backdrop, var(--ds-surface)));
  }

  .ds-table tbody[data-ds-glide] > tr:has(> :is(td, th) > input[type="checkbox"]:checked) {
    background-image:
      linear-gradient(var(--ds-table-row-selected, var(--ds-accent)), var(--ds-table-row-selected, var(--ds-accent))),
      linear-gradient(var(--ds-flip-backdrop, var(--ds-surface)), var(--ds-flip-backdrop, var(--ds-surface)));
  }

  /*
   * Interactive rows: the row's primary link or button stretches over the
   * row (::after against the row, positioned and transformed). Other
   * controls in the row stay above it (zero specificity: a control's own
   * position wins). A row without a row link opens nothing, so it stays
   * plain.
   */
  /* Positioned rows paint after the table's collapsed borders (Firefox
     then covers a selected row's hairlines with its colour): separate
     borders, which each cell paints over its row. Only bottom borders are
     drawn, so the lines look the same. */
  .ds-table--interactive {
    border-collapse: separate;
    border-spacing: 0;
  }

  .ds-table--interactive tbody tr:not(.ds-table__detail):has(.ds-table__row-link) {
    position: relative;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
  }

  /* WebKit lays a table row out static whatever its position, so the row
     link's ::after (and the row's ripple box) would stretch over the
     nearest positioned box around the table: every row's link over the
     whole table, the last on top, taking hover, clicks and the status bar
     URL of every row. An identity transform makes the row their containing
     block in every engine (it is also a stacking context, which the
     raised controls and the pinned cell stay inside). Every body row gets
     it: WebKit makes the area as tall as the table
     below the row's top, and a later row, painted above, keeps its own
     clicks also when it has no row link (or is a detail row). */
  .ds-table--interactive tbody tr {
    transform: translateX(0);
  }

  /* A menu or combobox list that the script places inside a row (no top
     layer: no popover or anchor positioning) is fixed or absolute against
     the viewport or its field: the transform would make the row its box,
     clipped by the scrolling wrapper and painted under the rows below. */
  .ds-table--interactive tbody tr:has(:is(.ds-menu, .ds-combo__popup):not([hidden], [data-anchored])) {
    transform: none;
  }

  :where(.ds-table--interactive tbody tr) :where(a[href], button, input, select, textarea, label, summary, [tabindex]):where(:not(.ds-table__row-link)) {
    position: relative;
    z-index: var(--ds-z-raised);
  }

  .ds-table__row-link::after {
    content: '';
    position: absolute;
    inset: 0;
    /* The ring rests at the row's edge and snaps inward. */
    outline-offset: 0;
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-table--interactive .ds-table__row-link:focus-visible {
    outline: none;
  }

  /* Inset by the ring's width, so the scroller never clips it. */
  .ds-table__row-link:focus-visible::after {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  /* Keyboard focus: the focus layer on the cells, at once. WebKit gives a
     transformed row's absolute boxes the row's top and width but the
     table's height, so the link's ring would run on down to the table's
     end: the focused row clips what it holds to its own box (the inset
     ring and the cells' content lie inside it in every engine). Anchor
     positioning would size the area itself, but WebKit then restyles the
     link from its initial values on every load, so its underline and
     ring offset transition. */
  .ds-table--interactive tbody tr:not(.ds-table__detail):where(:has(.ds-table__row-link:focus-visible)):focus-within {
    --ds-table-row-layer: color-mix(in srgb, currentColor calc(var(--ds-state-focus) * 100%), transparent);
    --ds-table-row-pace: 0s;
    clip-path: inset(0);
  }

  /* Press-in in --ds-dur-micro (.ds-pressed: js/ripple.js holds it); not
     while a control inside the row is pressed (it has its own press). */
  .ds-table--interactive tbody tr:not(.ds-table__detail):has(.ds-table__row-link):is(:active, .ds-pressed):not(:where(:has(:is(a[href], button, input, select, textarea, label, summary):not(.ds-table__row-link):active))) {
    --ds-table-row-layer: color-mix(in srgb, currentColor calc(var(--ds-state-pressed) * 100%), transparent);
    --ds-table-row-pace: var(--ds-dur-micro);
  }

  /*
   * Expandable rows: the detail row has no padding, height or hairline of
   * its own until it opens; the trigger row gives its hairline to the
   * detail, so there is one line, below the detail, which travels down as
   * it opens.
   */
  .ds-table tbody tr:has(+ .ds-table__detail) {
    --ds-table-row-line: none;
  }

  .ds-table .ds-table__detail > td,
  .ds-card .ds-table-wrap .ds-table__detail > td {
    height: auto;
    padding: 0;
  }

  .ds-table__expand {
    display: grid;
    grid-template-rows: 0fr;
    transition: grid-template-rows var(--ds-dur-base) var(--ds-ease-in);
  }

  /* The content: horizontal room as the cells', vertical room as spacers
     inside the clip (a padding would stay as the collapsed height). */
  .ds-table__expand > * {
    min-height: 0;
    overflow: hidden;
    padding-inline: var(--ds-space-4);
    opacity: 0;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in);
  }

  .ds-table__expand > *::before,
  .ds-table__expand > *::after {
    content: '';
    display: block;
    height: var(--ds-space-4);
  }

  .ds-table__expand > *::before {
    height: var(--ds-space-1);
  }

  .ds-card .ds-table-wrap .ds-table__expand > * {
    padding-inline: var(--ds-card-pad);
  }

  tr:has(.ds-table__toggle[aria-expanded="true"]) + .ds-table__detail .ds-table__expand {
    grid-template-rows: 1fr;
    transition: grid-template-rows var(--ds-dur-medium) var(--ds-ease-out);
  }

  tr:has(.ds-table__toggle[aria-expanded="true"]) + .ds-table__detail .ds-table__expand > * {
    opacity: 1;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* The 40px round toggle hangs out of the 20px text line, so the row
     keeps its height; its icon lines up with the column's text. Its margin
     box is the text line: aligned to the line's top (not its baseline),
     its centre is the line's centre. */
  .ds-table .ds-table__toggle {
    vertical-align: top;
    margin-block: calc((var(--ds-space-5) - var(--ds-btn-icon-size, 40px)) / 2);
    margin-inline: calc((var(--ds-space-5) - var(--ds-btn-icon-size, 40px)) / 2) 0;
  }

  .ds-table tbody tr:last-child td {
    border-bottom: none;
  }

  /* Dense rows: 40px, the text stays 14/20. The hairline counts: 7 + 24 (a
     status chip) + 7 + 1 = 39, within the 40px. */
  .ds-table--dense :is(th, td) {
    height: var(--ds-table-dense-row-height, 40px);
    padding-block: min(var(--ds-space-3), calc((var(--ds-table-dense-row-height, 40px) - var(--ds-space-6) - 2px) / 2));
  }

  /*
   * Row actions: the last cell holds the row's own buttons, as narrow as
   * they are, at the row's end. The buttons hang into the cell's padding
   * (their margin box is the 20px text line), so the row keeps its height
   * and the last icon lines up with the column edge.
   */
  .ds-table td.ds-table__actions {
    width: 1%;
    white-space: nowrap;
    text-align: end;
  }

  .ds-table__actions .ds-btn {
    margin-block: calc((var(--ds-space-5) - var(--ds-btn-height, 36px)) / 2);
    vertical-align: top;
  }

  .ds-table__actions .ds-btn--icon {
    margin-block: calc((var(--ds-space-5) - var(--ds-btn-icon-size, 40px)) / 2);
  }

  .ds-table__actions .ds-btn--icon:last-child {
    margin-inline-end: calc((var(--ds-space-5) - var(--ds-btn-icon-size, 40px)) / 2);
  }

  /* A summary row: label type, one hairline above it, none below; no row
     layers (tbody rules never reach it) and no row link. */
  .ds-table tfoot :is(th, td) {
    border-top: 1px solid var(--ds-line-2);
    border-bottom: 0;
    color: var(--ds-text);
    font: var(--ds-font-label);
    font-variant-numeric: tabular-nums;
  }

  /* Sort button in a header cell. Its 4px padding (the room of its state
     layer) hangs into the cell's padding, so the header keeps its height
     and the label lines up with the cells below. */
  .ds-table__sort {
    position: relative;
    isolation: isolate;
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-1);
    margin: calc(-1 * var(--ds-space-1));
    padding: var(--ds-space-1);
    border: 0;
    border-radius: var(--ds-radius-xs);
    background: transparent;
    color: inherit;
    font: inherit;
    text-align: start;
    white-space: inherit;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-table__sort::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-table__sort:hover::before {
      opacity: var(--ds-state-hover);
    }
  }

  .ds-table__sort:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  .ds-table__sort:active::before,
  .ds-table__sort.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  .ds-table__sort:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  .ds-table__sort > svg {
    flex-shrink: 0;
  }

  /* A sortable column without its arrow keeps the arrow's room (the gap
     and the documented 16px arrow) in its padding, so a sort that moves to
     another column never resizes the columns. Padding, not an empty flex
     item: that would become the inline-flex button's baseline and move it
     down in its row. In a .ds-num column the room leads, as the arrow does. */
  .ds-table__sort:not(:has(> svg)) {
    padding-inline-end: calc(2 * var(--ds-space-1) + 16px);
  }

  /* Opt-in: one up arrow for both directions, which descending turns on
     the spring. An unmarked svg keeps the direction the page drew. */
  .ds-table__sort-arrow {
    transition: rotate var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  .ds-table th[aria-sort="descending"] .ds-table__sort-arrow {
    rotate: 180deg;
  }

  /* A number column sorts with the arrow in front, so the figures keep
     their right edge. */
  .ds-table th.ds-num .ds-table__sort {
    flex-direction: row-reverse;
  }

  .ds-table th.ds-num .ds-table__sort:not(:has(> svg)) {
    padding-inline: calc(2 * var(--ds-space-1) + 16px) var(--ds-space-1);
  }

  .ds-table th:is([aria-sort="ascending"], [aria-sort="descending"]) {
    color: var(--ds-text);
  }

  /*
   * The full-page table: the wrapper is its own card and scrolls both
   * ways, so the header row sticks to its top. Separate borders: a sticky
   * cell keeps its own hairline. Its fades leave the border and the
   * scrollbars opaque: three mask layers, the fade over the padding box
   * (the box inside the border and the scrollbars) added to the ring
   * between the border box and the padding box.
   *
   * Every fade of a table wrapper runs left to right with the scroll
   * (LTR only, D9): the gradients keep their physical directions.
   */
  .ds-table-wrap--fill {
    display: block;
    flex: 1 1 auto;
    min-height: var(--ds-table-fill-min-height, 240px);
    max-height: var(--ds-table-fill-max-height, none);
    overflow: auto;
    overscroll-behavior: contain;
    isolation: isolate;
    border: 1px solid var(--ds-line);
    border-radius: var(--ds-radius-card);
    background: var(--ds-table-sticky-bg, var(--ds-surface));
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
    /* Focus moved by the keyboard never lands under the header (nor, as
       in every wrapper, under the pinned column). */
    scroll-padding-block-start: var(--ds-table-head-height, 48px);
    -webkit-mask-image:
      linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-space-8))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-space-8))), transparent),
      linear-gradient(#000, #000),
      linear-gradient(#000, #000);
    mask-image:
      linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-space-8))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-space-8))), transparent),
      linear-gradient(#000, #000),
      linear-gradient(#000, #000);
    -webkit-mask-origin: padding-box, padding-box, border-box;
    mask-origin: padding-box, padding-box, border-box;
    -webkit-mask-clip: padding-box, padding-box, border-box;
    mask-clip: padding-box, padding-box, border-box;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-composite: source-over, xor;
    mask-composite: add, exclude;
  }

  @supports (animation-timeline: scroll()) {
    .ds-table-wrap--fill {
      animation: ds-scroll-edge-start linear both, ds-scroll-edge-end linear both;
      animation-timeline: scroll(self inline);
      animation-range: 0 var(--ds-space-8), calc(100% - var(--ds-space-8)) 100%;
    }
  }

  .ds-table-wrap--fill:has(> .ds-table--dense) {
    scroll-padding-block-start: var(--ds-table-dense-row-height, 40px);
  }

  .ds-table-wrap--fill > .ds-table {
    border-collapse: separate;
    border-spacing: 0;
  }

  .ds-table-wrap--fill > .ds-table > thead th {
    position: sticky;
    inset-block-start: 0;
    z-index: var(--ds-z-sticky);
    background-color: var(--ds-table-sticky-bg, var(--ds-surface));
  }

  /* Long cells stay on one line and end in an ellipsis (a tooltip shows a
     cut cell's text). The rings of controls in a cell stay inside its
     padding; the row link's stretched area and the row's ripple belong to
     the row, so the cell does not clip them. */
  .ds-table-wrap--fill td {
    max-width: var(--ds-table-cell-max-width, 280px);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /*
   * A pinned first column: sticky at the start, above the raised controls
   * of the cells that scroll under it (--ds-z-pinned), on an opaque base.
   * The row's layers would show the scrolled columns through a translucent
   * background colour, so they lie over the base as images of two
   * registered colours: --ds-table-sticky-tint (the selection) and
   * --ds-table-sticky-layer (hover, focus, press) above it.
   * The first cell is named :is(td, th):first-child, never a bare
   * :first-child: a bare one is tried on every element of the page and
   * marks every parent as depending on its first child, so each inserted
   * node restyles its siblings (Chromium: four times the style work of a
   * page load).
   */
  .ds-table--pin-first tbody tr:not(.ds-table__detail) > :is(td, th):first-child,
  .ds-table--pin-first thead th:first-child,
  .ds-table--pin-first tfoot tr > :is(td, th):first-child {
    position: sticky;
    inset-inline-start: 0;
    z-index: var(--ds-z-pinned);
    background-image:
      linear-gradient(var(--ds-table-sticky-layer), var(--ds-table-sticky-layer)),
      linear-gradient(var(--ds-table-sticky-tint), var(--ds-table-sticky-tint)),
      linear-gradient(var(--ds-table-sticky-bg, var(--ds-surface)), var(--ds-table-sticky-bg, var(--ds-surface)));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      --ds-table-sticky-layer var(--ds-dur-fast) var(--ds-ease-in-out),
      --ds-table-sticky-tint var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* A body row's pinned cell lays the row's layer and selection over its
     base (the row decides them, as for the other cells). */
  .ds-table--pin-first tbody tr:not(.ds-table__detail) > :is(td, th):first-child {
    --ds-table-sticky-layer: var(--ds-table-row-layer);
    --ds-table-sticky-tint: var(--ds-table-row-tint);
    transition:
      background-color var(--ds-table-row-pace) var(--ds-ease-in-out),
      --ds-table-sticky-layer var(--ds-table-row-pace) var(--ds-ease-in-out),
      --ds-table-sticky-tint var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* The corner over the sticky header and the pinned column. */
  .ds-table-wrap--fill > .ds-table--pin-first > thead th:first-child {
    z-index: var(--ds-z-sticky-pinned);
  }

  /* The hairline at the pinned column's end: it shows once the table has
     scrolled away from its start (the cells under it are cut there).
     Without scroll timelines data-ds-overflow-start shows it. */
  .ds-table--pin-first tbody tr:not(.ds-table__detail) > :is(td, th):first-child::after,
  .ds-table--pin-first thead th:first-child::after,
  .ds-table--pin-first tfoot tr > :is(td, th):first-child::after {
    content: '';
    position: absolute;
    inset-block: 0;
    inset-inline-end: 0;
    width: 1px;
    background: var(--ds-line);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-table-wrap[data-ds-overflow-start] > .ds-table--pin-first tbody tr:not(.ds-table__detail) > :is(td, th):first-child::after,
  .ds-table-wrap[data-ds-overflow-start] > .ds-table--pin-first thead th:first-child::after,
  .ds-table-wrap[data-ds-overflow-start] > .ds-table--pin-first tfoot tr > :is(td, th):first-child::after {
    opacity: 1;
  }

  @supports (animation-timeline: scroll()) {
    .ds-table--pin-first tbody tr:not(.ds-table__detail) > :is(td, th):first-child::after,
    .ds-table--pin-first thead th:first-child::after,
    .ds-table--pin-first tfoot tr > :is(td, th):first-child::after {
      animation: ds-scroll-fade linear both;
      animation-timeline: scroll(nearest inline);
      animation-range: 0 var(--ds-space-2);
    }
  }

  /*
   * The bar of a bulk selection: it stands where the toolbar stood (one
   * .ds-swap slot, DS.motion.crossfade()), so the table never moves. The
   * count says the state, not the tint; it changes at once.
   */
  .ds-selection-bar {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    min-height: var(--ds-space-12);
    padding-inline: var(--ds-space-1) var(--ds-space-2);
    border-radius: var(--ds-radius-md);
    background: var(--ds-accent);
    color: var(--ds-on-accent);
  }

  .ds-selection-bar__count {
    margin: 0;
    font: var(--ds-font-label);
    font-variant-numeric: tabular-nums;
  }

  .ds-selection-bar__actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ds-space-2);
    margin-inline-start: auto;
  }

  /*
   * Short cells (numbers, status chips, dates with .ds-nowrap) stay on one
   * line, and text cells keep a readable width instead of breaking word by
   * word. A wide table (.ds-table--wide) drops its secondary columns
   * whenever the content area is narrow, also next to the persistent drawer;
   * on phones every table does. A table that still does not fit scrolls
   * sideways, and a fade at the edge of its card tells that more columns
   * follow (statically: at the right edge, over the card's padding).
   */
  .ds-table td.ds-num,
  .ds-table .ds-status {
    white-space: nowrap;
  }

  .ds-table .ds-col--text {
    min-width: 168px;
  }

  @container ds-content (max-width: 1040px) {
    .ds-table--wide th {
      white-space: normal;
    }

    .ds-table--wide .ds-col--secondary {
      display: none;
    }

    /* In a card the last column still shown takes the card's gutter, as the
       last cell does while every column shows. The sibling :has() stays in
       the cell's own compound; the :where() keeps the rule at the weight of
       a plain card cell, so detail rows and stacked rows keep theirs. */
    .ds-card .ds-table-wrap :where(.ds-table--wide) :is(th, td):where(:not(.ds-col--secondary):not(:has(~ :not(.ds-col--secondary)))) {
      padding-inline-end: var(--ds-card-pad);
    }

    /* The static fade (no scroll timeline, no DS.dom.scrollEdges()); the
       scrollbar strip stays opaque. */
    .ds-card .ds-table-wrap:has(> .ds-table--wide) {
      -webkit-mask-image: linear-gradient(to left, transparent, #000 var(--ds-scroll-fade, var(--ds-card-pad))), linear-gradient(#000, #000);
      mask-image: linear-gradient(to left, transparent, #000 var(--ds-scroll-fade, var(--ds-card-pad))), linear-gradient(#000, #000);
      -webkit-mask-size: 100% calc(100% - var(--ds-scrollbar-height, 0px)), 100% var(--ds-scrollbar-height, 0px);
      mask-size: 100% calc(100% - var(--ds-scrollbar-height, 0px)), 100% var(--ds-scrollbar-height, 0px);
      -webkit-mask-position: 0 0, 0 100%;
      mask-position: 0 0, 0 100%;
      -webkit-mask-repeat: no-repeat;
      mask-repeat: no-repeat;
    }

    /* DS.dom.scrollEdges() attached: a fade on each side that has more. */
    .ds-card .ds-table-wrap:where([data-ds-edges]):has(> .ds-table--wide) {
      -webkit-mask-image: linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-card-pad))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-card-pad))), transparent), linear-gradient(#000, #000);
      mask-image: linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-card-pad))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-card-pad))), transparent), linear-gradient(#000, #000);
    }

    /* Scroll-linked: a fade on each side that still has columns to show
       (the timeline is the wrapper's own inline scroll; the shorthand
       comes first, it resets animation-timeline). */
    @supports (animation-timeline: scroll()) {
      .ds-card .ds-table-wrap:has(> .ds-table--wide) {
        -webkit-mask-image: linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-card-pad))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-card-pad))), transparent), linear-gradient(#000, #000);
        mask-image: linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-card-pad))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-card-pad))), transparent), linear-gradient(#000, #000);
        animation: ds-scroll-edge-start linear both, ds-scroll-edge-end linear both;
        animation-timeline: scroll(self inline);
        animation-range: 0 var(--ds-space-8), calc(100% - var(--ds-space-8)) 100%;
      }
    }
  }

  @media (max-width: 600px) {
    .ds-table th {
      white-space: normal;
    }

    .ds-table .ds-col--secondary {
      display: none;
    }

    .ds-card .ds-table-wrap :is(th, td):where(:not(.ds-col--secondary):not(:has(~ :not(.ds-col--secondary)))) {
      padding-inline-end: var(--ds-card-pad);
    }

    /* A 360px phone has no room for a 168px text column next to three
       others: it takes what is left, and the cells keep closer together. */
    .ds-table .ds-col--text {
      min-width: 0;
    }

    .ds-table :is(th, td) {
      padding-inline: var(--ds-space-3);
    }

    .ds-table .ds-table__expand > * {
      padding-inline: var(--ds-space-3);
    }

    .ds-card .ds-table-wrap {
      -webkit-mask-image: linear-gradient(to left, transparent, #000 var(--ds-scroll-fade, var(--ds-card-pad))), linear-gradient(#000, #000);
      mask-image: linear-gradient(to left, transparent, #000 var(--ds-scroll-fade, var(--ds-card-pad))), linear-gradient(#000, #000);
      -webkit-mask-size: 100% calc(100% - var(--ds-scrollbar-height, 0px)), 100% var(--ds-scrollbar-height, 0px);
      mask-size: 100% calc(100% - var(--ds-scrollbar-height, 0px)), 100% var(--ds-scrollbar-height, 0px);
      -webkit-mask-position: 0 0, 0 100%;
      mask-position: 0 0, 0 100%;
      -webkit-mask-repeat: no-repeat;
      mask-repeat: no-repeat;
    }

    .ds-card .ds-table-wrap:where([data-ds-edges]) {
      -webkit-mask-image: linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-card-pad))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-card-pad))), transparent), linear-gradient(#000, #000);
      mask-image: linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-card-pad))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-card-pad))), transparent), linear-gradient(#000, #000);
    }

    @supports (animation-timeline: scroll()) {
      .ds-card .ds-table-wrap {
        -webkit-mask-image: linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-card-pad))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-card-pad))), transparent), linear-gradient(#000, #000);
        mask-image: linear-gradient(to right, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-table-start-fade) * var(--ds-scroll-fade, var(--ds-card-pad))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-card-pad))), transparent), linear-gradient(#000, #000);
        animation: ds-scroll-edge-start linear both, ds-scroll-edge-end linear both;
        animation-timeline: scroll(self inline);
        animation-range: 0 var(--ds-space-8), calc(100% - var(--ds-space-8)) 100%;
      }
    }
  }

  /* 320px (400% zoom): closer still, so three columns fit without a sideways
     scroll. */
  @media (max-width: 359px) {
    .ds-table :is(th, td) {
      padding-inline: var(--ds-space-2);
    }

    .ds-table .ds-table__expand > * {
      padding-inline: var(--ds-space-2);
    }
  }

  /* Stacked rows never scroll sideways, so their wrapper never fades. */
  .ds-card .ds-table-wrap:has(> :is(.ds-table--stack, .ds-table--stack-wide)) {
    -webkit-mask-image: none;
    mask-image: none;
    animation: none;
  }

  /*
   * Stacked rows: three copies of one block, kept identical by a test
   * (test/css.test.js): always in a side sheet body or a dialog (both
   * follow the window, outside the ds-content container), and in a view
   * whose content area is narrow. No container query on the wrapper: its
   * layout containment would re-anchor script-placed popups.
   */
  :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) {
    display: block;
  }

  :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) > thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) > tbody {
    display: grid;
    gap: var(--ds-space-3);
  }

  :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) > tbody > tr {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    gap: var(--ds-space-3);
    padding: var(--ds-space-4);
    border: 1px solid var(--ds-line);
    border-radius: var(--ds-radius-card);
  }

  :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) > tbody > tr > td {
    display: grid;
    grid-column: 1;
    gap: var(--ds-space-1);
    max-width: none;
    height: auto;
    padding: 0;
    overflow: visible;
    border: 0;
    white-space: normal;
  }

  :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) > tbody > tr > td.ds-table__actions {
    grid-column: 2;
    grid-row: 1;
    align-self: start;
    width: auto;
  }

  :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) > tbody > tr > td[data-label]::before {
    content: attr(data-label);
    content: attr(data-label) / "";
    color: var(--ds-text-2);
    font: var(--ds-font-label);
  }

  :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) > tbody > tr:not(.ds-table__detail) > :is(td, th):first-child {
    position: static;
    background-image: none;
  }

  :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) > tbody > tr:not(.ds-table__detail) > :is(td, th):first-child::after {
    display: none;
  }

  @media (hover: hover) {
    :is(.ds-sheet__body, .ds-dialog) :is(.ds-table--stack, .ds-table--stack-wide) > tbody > tr:not(.ds-table__detail):hover {
      --ds-table-row-layer: transparent;
    }
  }

  :is(.ds-sheet__body, .ds-dialog) .ds-card .ds-table-wrap > :is(.ds-table--stack, .ds-table--stack-wide) > tbody {
    padding-inline: var(--ds-card-pad);
  }

  :is(.ds-sheet__body, .ds-dialog) .ds-card .ds-table-wrap > :is(.ds-table--stack, .ds-table--stack-wide) > tbody > tr > td {
    padding: 0;
  }

  @container ds-content (max-width: 600px) {
    .ds-table--stack {
      display: block;
    }

    .ds-table--stack > thead {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }

    .ds-table--stack > tbody {
      display: grid;
      gap: var(--ds-space-3);
    }

    .ds-table--stack > tbody > tr {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: var(--ds-space-3);
      padding: var(--ds-space-4);
      border: 1px solid var(--ds-line);
      border-radius: var(--ds-radius-card);
    }

    .ds-table--stack > tbody > tr > td {
      display: grid;
      grid-column: 1;
      gap: var(--ds-space-1);
      max-width: none;
      height: auto;
      padding: 0;
      overflow: visible;
      border: 0;
      white-space: normal;
    }

    .ds-table--stack > tbody > tr > td.ds-table__actions {
      grid-column: 2;
      grid-row: 1;
      align-self: start;
      width: auto;
    }

    .ds-table--stack > tbody > tr > td[data-label]::before {
      content: attr(data-label);
      content: attr(data-label) / "";
      color: var(--ds-text-2);
      font: var(--ds-font-label);
    }

    .ds-table--stack > tbody > tr:not(.ds-table__detail) > :is(td, th):first-child {
      position: static;
      background-image: none;
    }

    .ds-table--stack > tbody > tr:not(.ds-table__detail) > :is(td, th):first-child::after {
      display: none;
    }

    @media (hover: hover) {
      .ds-table--stack > tbody > tr:not(.ds-table__detail):hover {
        --ds-table-row-layer: transparent;
      }
    }

    .ds-card .ds-table-wrap > .ds-table--stack > tbody {
      padding-inline: var(--ds-card-pad);
    }

    .ds-card .ds-table-wrap > .ds-table--stack > tbody > tr > td {
      padding: 0;
    }
  }

  @container ds-content (max-width: 840px) {
    .ds-table--stack-wide {
      display: block;
    }

    .ds-table--stack-wide > thead {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }

    .ds-table--stack-wide > tbody {
      display: grid;
      gap: var(--ds-space-3);
    }

    .ds-table--stack-wide > tbody > tr {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: var(--ds-space-3);
      padding: var(--ds-space-4);
      border: 1px solid var(--ds-line);
      border-radius: var(--ds-radius-card);
    }

    .ds-table--stack-wide > tbody > tr > td {
      display: grid;
      grid-column: 1;
      gap: var(--ds-space-1);
      max-width: none;
      height: auto;
      padding: 0;
      overflow: visible;
      border: 0;
      white-space: normal;
    }

    .ds-table--stack-wide > tbody > tr > td.ds-table__actions {
      grid-column: 2;
      grid-row: 1;
      align-self: start;
      width: auto;
    }

    .ds-table--stack-wide > tbody > tr > td[data-label]::before {
      content: attr(data-label);
      content: attr(data-label) / "";
      color: var(--ds-text-2);
      font: var(--ds-font-label);
    }

    .ds-table--stack-wide > tbody > tr:not(.ds-table__detail) > :is(td, th):first-child {
      position: static;
      background-image: none;
    }

    .ds-table--stack-wide > tbody > tr:not(.ds-table__detail) > :is(td, th):first-child::after {
      display: none;
    }

    @media (hover: hover) {
      .ds-table--stack-wide > tbody > tr:not(.ds-table__detail):hover {
        --ds-table-row-layer: transparent;
      }
    }

    .ds-card .ds-table-wrap > .ds-table--stack-wide > tbody {
      padding-inline: var(--ds-card-pad);
    }

    .ds-card .ds-table-wrap > .ds-table--stack-wide > tbody > tr > td {
      padding: 0;
    }
  }

  /* A link that leaves the app carries the external icon. Its own display:
     a layered preflight (svg { display: block }) would drop it below the
     text. */
  .ds-table a > svg {
    display: inline-block;
    margin-inline-start: var(--ds-space-1);
    vertical-align: -2px;
  }

  /* Small notes inside a cell ("(default)", "was: ...") stay in one piece:
     a note broken over two lines reads as two notes. */
  .ds-table small {
    font: var(--ds-font-meta);
    white-space: nowrap;
  }

  /* Forced colours: sticky and pinned cells are opaque in the system
     colours, so nothing shows through them; the fill wrapper keeps its
     edge and the selection bar an outline (its tint is gone). */
  @media (forced-colors: active) {
    .ds-table-wrap--fill {
      border-color: CanvasText;
    }

    .ds-table-wrap--fill > .ds-table > thead th,
    .ds-table--pin-first tbody tr:not(.ds-table__detail) > :is(td, th):first-child,
    .ds-table--pin-first thead th:first-child,
    .ds-table--pin-first tfoot tr > :is(td, th):first-child {
      background-color: Canvas;
      background-image: none;
    }

    .ds-selection-bar {
      outline: 1px solid CanvasText;
    }
  }

  /* Paper: every column, unmasked and unclipped; the full-page table runs
     on over the pages with its header repeated (not sticky); rows stay in
     one piece. */
  @media print {
    .ds-table-wrap,
    .ds-card .ds-table-wrap,
    .ds-card .ds-table-wrap:has(> .ds-table--wide) {
      overflow: visible;
      -webkit-mask-image: none;
      mask-image: none;
      animation: none;
    }

    .ds-table-wrap--fill {
      max-height: none;
      overflow: visible;
      -webkit-mask-image: none;
      mask-image: none;
    }

    .ds-table th {
      white-space: normal;
    }

    .ds-table-wrap--fill > .ds-table > thead th,
    .ds-table--pin-first tbody tr:not(.ds-table__detail) > :is(td, th):first-child,
    .ds-table--pin-first thead th:first-child,
    .ds-table--pin-first tfoot tr > :is(td, th):first-child {
      position: static;
    }

    .ds-table-wrap--fill td {
      max-width: none;
      white-space: normal;
    }

    .ds-table tr {
      break-inside: avoid;
    }

    /* A closed detail row stays off paper: paged layout gives its 0fr
       room the height of its content (an open one prints in full). */
    .ds-table tr:not(:has(.ds-table__toggle[aria-expanded="true"])) + .ds-table__detail {
      display: none;
    }

    .ds-selection-bar {
      display: none;
    }
  }
}

/* ── components/pagination.css ── */
/*
 * Pagination: the footer of a table (or a list) whose records come in
 * pages, as in Material data tables: rows per page, the range shown, and
 * buttons to the first, previous, next and last page. No numbered pages.
 *
 *   <nav class="ds-pagination [ds-pagination--simple]" aria-label="Pages of devices">
 *     <div class="ds-pagination__size">
 *       <label class="ds-pagination__label" for="pp">Rows per page</label>
 *       <select class="ds-input ds-input--filled" id="pp"><option>25</option><option selected>50</option><option>100</option></select>
 *     </div>
 *     <p class="ds-pagination__range" aria-live="polite">1–50 of 128</p>
 *     <button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="First page" aria-disabled="true" data-tip="First page">(chevronFirst 20)</button>
 *     <button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="Previous page" aria-disabled="true" data-tip="Previous page">(chevronLeft 20)</button>
 *     <button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="Next page" data-tip="Next page">(chevronRight 20)</button>
 *     <button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="Last page" data-tip="Last page">(chevronLast 20)</button>
 *   </nav>
 *
 *   .ds-pagination--simple  previous and next only (leave out first and last)
 *
 * End-aligned. At the end of a table card (after its .ds-table-wrap) it
 * runs to the card's edges like the table, with a hairline above. The
 * range is meta text with tabular figures; when the content area is at
 * most 600px wide the size field drops and the range leads. A button that
 * leads nowhere (first and previous on the first page) is aria-disabled,
 * not disabled: focus stays on it, the click is blocked, and its tooltip
 * still names it. The page holds the page number; a page change dims the
 * rows (.ds-stale with the loading bar), then renders them through
 * DS.motion.flip() and DS.dom.morphHtml() (docs/motion.md, "Data motion").
 *
 * Print: hidden.
 */

@layer ds.components {
  .ds-pagination {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-end;
    gap: var(--ds-space-1);
    color: var(--ds-text-2);
  }

  /* After the table of a card: right under it (no card rhythm), with a
     hairline, out to the card's edges like the table. */
  .ds-card > .ds-table-wrap + .ds-pagination {
    margin-top: 0;
    margin-inline: calc(-1 * var(--ds-card-pad));
    padding-block-start: var(--ds-space-2);
    padding-inline: var(--ds-card-pad);
    border-top: 1px solid var(--ds-line);
  }

  .ds-pagination__size {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    margin-inline-end: var(--ds-space-4);
  }

  .ds-pagination__label {
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
  }

  /* The select keeps its own width (a field elsewhere fills its column). */
  .ds-pagination__size .ds-input {
    width: auto;
  }

  .ds-pagination__range {
    margin: 0;
    margin-inline-end: var(--ds-space-4);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }

  @container ds-content (max-width: 600px) {
    .ds-pagination__size {
      display: none;
    }

    .ds-pagination__range {
      margin-inline-end: auto;
    }
  }

  @media print {
    .ds-pagination {
      display: none;
    }
  }
}

/* ── components/form.css ── */
/*
 * Forms (Material 3, dense): 40px outlined fields with the label above,
 * filled toolbar fields (search pill, select), checkboxes and radio cards.
 * Focus shows as a 2px ring drawn inside the field (border plus inset
 * shadow), so nothing shifts; it appears at once and never snaps: typing
 * needs to see the field at once. Checkboxes and radios are drawn on the
 * native input (appearance: none, no wrapper; the section below) with a
 * halo for hover, press and focus; they and radio cards take the outline
 * ring of every other control, solid from the first frame and snapping
 * from offset + width to the offset (outline-offset, --ds-dur-fast). A
 * radio card's input has no halo: the card's own layer is its hover,
 * press and (with the ring, at once) focus layer (--ds-state-focus).
 * Chips and the switch have files of their own.
 *
 *   <label class="ds-check"><input type="checkbox" name="managed"> Managed</label>
 *   <input class="ds-checkbox" type="checkbox" aria-label="Select all">   (a checkbox anywhere)
 *   <input class="ds-radio-input" type="radio" name="plan" aria-label="Standard">
 *   <label class="ds-radio"><input type="radio" name="plan"><span><strong>Standard</strong><small>...</small></span></label>
 *
 *   <div class="ds-radios ds-radios--tiles" role="radiogroup" aria-label="Kind">   (radio tiles)
 *     <label class="ds-radio"><input type="radio" name="kind"><span class="ds-radio__icon" aria-hidden="true">(icon)</span>
 *       <span><strong>Laptop</strong><small>...</small></span><span class="ds-radio__mark" aria-hidden="true">(check)</span></label>
 *   <fieldset class="ds-field"><legend class="ds-field__label">Status</legend>
 *     <div class="ds-check-list"> <label class="ds-check"><input type="checkbox"><span>Active</span><span class="ds-check__count">47</span></label> ...
 *   <div class="ds-check-group"> <label class="ds-check">(parent)</label>
 *     <div class="ds-check-group__children" role="group" aria-label="Sites"> <label class="ds-check">...</label> ...
 *
 * Hooks: --ds-check-size (18px box), --ds-check-target (40px halo),
 * --ds-check-height (40px row; --sm: --ds-check-sm-height, 32px),
 * --ds-check-list-max-height (264px), --ds-form-actions-bleed (the inline
 * padding of the container a sticky action bar runs through; default
 * --ds-content-pad-x, in a .ds-card --ds-card-pad).
 *
 *   <form class="ds-form">         (--rows: editing in rows, hairlines between the fields)
 *     <div class="ds-form-grid">  (3 columns, the first wider; --2: two; --3: three equal)
 *       <div class="ds-field">     (--changed: a dot after the label; --inset: the filled field)
 *         <label class="ds-field__label" for="name">Name <span class="ds-field__optional">(optional)</span></label>
 *         <input class="ds-input" id="name" aria-describedby="name-hint">
 *         <small class="ds-field__hint" id="name-hint">Shown on the label.</small>
 *           (--error, --success: an icon first, the message fades in)
 *       <div class="ds-field">
 *         <label class="ds-field__label" for="room">Room <span class="ds-field__changed ds-sr">(changed)</span></label>
 *         <div class="ds-field__row"> <input class="ds-input" id="room"> <button class="ds-btn ...">...</button>   (--top: beside a textarea)
 *         <div class="ds-input-group"> <input class="ds-input" id="quota"> <span class="ds-input-group__affix" aria-hidden="true">GB</span>
 *         <div class="ds-field__foot"> <small class="ds-field__hint">...</small> <span class="ds-field__counter" aria-hidden="true">12 / 80</span>
 *       <div class="ds-field" role="group" aria-labelledby="serial-l">
 *         <span class="ds-field__label" id="serial-l">Serial</span> <span class="ds-field__value">SN-0042</span>
 *     <div class="ds-form-actions"> <button class="ds-btn ds-btn--primary">Save</button> ...   (--sticky: stays at the bottom on phones)
 *
 *   <label class="ds-field__label" for="a">Name <span class="ds-field__required" aria-hidden="true">*</span></label>  (with required on the input)
 *   <div class="ds-search">(search icon)<input class="ds-input" type="search" aria-keyshortcuts="/" placeholder="Search">
 *     <kbd class="ds-kbd ds-search__key" aria-hidden="true">/</kbd></div>
 *   <textarea class="ds-input ds-input--grow" rows="1"></textarea>   (grows with its text)
 *   <div class="ds-composer"><textarea class="ds-composer__input ds-input--grow" rows="1" aria-label="Message"></textarea>
 *     <button class="ds-btn ds-btn--primary ds-btn--icon" type="submit" aria-label="Send">(send)</button></div>
 *
 * Field hooks: --ds-input-height (40px), --ds-input-bg, --ds-input-border,
 * --ds-input-radius, --ds-input-focus-color (the ring and the label tint),
 * --ds-input-font, --ds-input-grow-max (50vh), --ds-field-error-color,
 * --ds-field-success-color, --ds-field-changed-color,
 * --ds-form-rows-control-max (480px), --ds-composer-radius,
 * --ds-composer-max-height (220px).
 *
 * Field text is --ds-font-body and switches to --ds-font-input (16px) on
 * screens up to 600px, where iOS would zoom into smaller text. A theme
 * that wants one size everywhere sets --ds-input-font.
 *
 * Grids narrow by container queries on "ds-content" (and by the window
 * width everywhere, also in sheets and dialogs).
 *
 * The label takes the primary colour with the ring (at once) and the
 * error ink on an invalid field; an error or success message fades in
 * when it appears. A changed field's dot fades in on --ds-dur-fast and out
 * on --ds-dur-instant (the word "(changed)" joins the label's name only
 * while it shows). The search key fades back on --ds-dur-fast and goes on
 * --ds-dur-instant once the field has the focus or a value. A radio
 * tile's check pops from --ds-scale-small on the spring.
 *
 * aria-disabled="true" on a checkbox or radio looks like disabled (no
 * hover or press halo, the disabled ink; a radio card with a blocked input
 * takes the quiet look of .ds-radio--disabled) but keeps its focus halo
 * and its place in the tab order; DS.motion's action guard blocks its
 * click.
 *
 * Forced colours: focused fields, input groups and the composer show a
 * Highlight outline (their inset ring is a shadow, which forced colours
 * drop); the filled field and the composer keep a transparent border,
 * their edge there. Print: a check list shows every row, sticky actions
 * stay in the flow, drawn checkboxes and radios keep their colours
 * (print-color-adjust).
 *
 * Progressive:
 * - text-wrap (hints and radio card descriptions avoid a lone last word):
 *   without it they wrap as usual.
 * - interpolate-size and @starting-style (the room of an error or success
 *   message that appears while focus is in its form or field opens from
 *   height 0 on --ds-ease-out, never on load): without them the message
 *   only fades in.
 * - scrollbar-color (a thin, quiet scrollbar in .ds-check-list): without it
 *   the platform scrollbar shows.
 * - field-sizing (.ds-input--grow grows with its text): without it
 *   DS.dom.autosize(textarea) sets the height, and without that the field
 *   keeps its height and scrolls.
 */

@layer ds.components {
  .ds-form {
    display: grid;
    gap: var(--ds-space-5);
  }

  /* Editing in rows keeps its own rhythm (.ds-form--rows, below). */
  .ds-card.ds-form:not(.ds-form--rows) {
    gap: var(--ds-space-4);
  }

  .ds-card.ds-form > * + * {
    margin-top: 0;
  }

  .ds-form-grid {
    display: grid;
    grid-template-columns: minmax(0, 2fr) minmax(0, 1.3fr) minmax(0, 1fr);
    gap: var(--ds-space-4);
  }

  .ds-form-grid--2 {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .ds-form-grid--3 {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  /* By the content area's width (page.css): next to the persistent drawer
     three fields per row would put their labels on two lines. Side sheets
     and dialogs lie outside that container: they follow the window. */
  @container ds-content (max-width: 760px) {
    .ds-form-grid,
    .ds-form-grid--2,
    .ds-form-grid--3 {
      grid-template-columns: minmax(0, 1fr);
    }
  }

  @media (max-width: 760px) {
    .ds-form-grid,
    .ds-form-grid--2,
    .ds-form-grid--3 {
      grid-template-columns: minmax(0, 1fr);
    }
  }

  .ds-form-actions {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2) var(--ds-space-3);
    flex-wrap: wrap;
  }

  /* On a phone the actions of a long form stay at the bottom of its
     scroller: a surface bar with a static hairline that bleeds to the edges
     of its page (or card) and keeps clear of the home indicator. In a
     padded container of the page's own (a project card, a sheet section)
     the page names that padding in --ds-form-actions-bleed. */
  @media (max-width: 600px) {
    .ds-form-actions--sticky {
      position: sticky;
      inset-block-end: 0;
      z-index: var(--ds-z-sticky);
      margin-inline: calc(-1 * var(--ds-form-actions-bleed, var(--ds-content-pad-x, 0px)));
      padding-block: var(--ds-space-3) calc(var(--ds-space-3) + env(safe-area-inset-bottom));
      padding-inline: var(--ds-form-actions-bleed, var(--ds-content-pad-x, 0px));
      border-block-start: 1px solid var(--ds-line);
      background: var(--ds-surface);
    }

    .ds-card .ds-form-actions--sticky {
      margin-inline: calc(-1 * var(--ds-form-actions-bleed, var(--ds-card-pad)));
      padding-inline: var(--ds-form-actions-bleed, var(--ds-card-pad));
    }
  }

  /*
   * Editing in rows (a record's fields one under the other): the label
   * stays above its control (never a label column), the fields sit on
   * hairlines with row padding instead of the form's gap, and controls stop
   * at a readable width.
   */
  .ds-form--rows {
    gap: 0;
  }

  .ds-form--rows > .ds-form-grid {
    grid-template-columns: minmax(0, 1fr);
    gap: 0;
  }

  .ds-form--rows > .ds-field,
  .ds-form--rows > .ds-form-grid > .ds-field {
    padding-block: var(--ds-space-3);
    border-block-start: 1px solid var(--ds-line);
  }

  .ds-form--rows > .ds-field:first-child,
  .ds-form--rows > .ds-form-grid:first-child > .ds-field:first-child {
    border-block-start: 0;
  }

  .ds-form--rows .ds-field > :is(.ds-input, .ds-input-group, .ds-combo, .ds-field__row) {
    max-width: var(--ds-form-rows-control-max, 480px);
  }

  .ds-form--rows > .ds-form-actions {
    margin-block-start: var(--ds-space-4);
  }

  /* Fields next to each other that wrap (filters, a lookup). */
  .ds-inline-form,
  .ds-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    gap: var(--ds-space-3);
  }

  .ds-inline-form .ds-field {
    width: min(100%, 220px);
  }

  /* The button is lower than the 40px field: centred on it, while flex-end
     keeps both under the label. */
  .ds-inline-form > .ds-btn {
    margin-bottom: calc((var(--ds-space-10) - var(--ds-btn-height, 36px)) / 2);
  }

  .ds-filters .ds-field:not(.ds-field--grow) {
    width: 160px;
  }

  @media (max-width: 720px) {
    .ds-filters .ds-field:not(.ds-field--grow) {
      width: calc(50% - var(--ds-space-2));
    }

    /* Two dates side by side at 320px: less padding, so the value is not
       cut off early. Only where fields stand side by side: a date alone in
       its row keeps the field's padding. */
    :is(.ds-filters, .ds-inline-form, .ds-form-grid--2, .ds-form-grid--3) .ds-input:is([type="date"], [type="datetime-local"], [type="month"], [type="week"], [type="time"]) {
      padding-inline: var(--ds-space-2);
    }
  }

  /* A toolbar of filters: the fields in one row, the chips in the next. */
  .ds-toolbar {
    display: grid;
    gap: var(--ds-space-3);
  }

  /* An action bar above a list: search, buttons and a count in one row that
     wraps; the search takes the room that is left, up to a readable width. */
  .ds-action-bar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ds-space-2) var(--ds-space-3);
    min-width: 0;
  }

  .ds-action-bar > .ds-search {
    flex: 1 1 240px;
    max-width: 420px;
  }

  /* A narrow page: the search takes a row of its own, the buttons and the
     count follow together (as .ds-filters does at 980px). */
  @container ds-content (max-width: 600px) {
    .ds-action-bar > .ds-search {
      flex-basis: 100%;
      max-width: none;
    }
  }

  /* ── Field: label, control, hint ──────────────── */

  .ds-field {
    display: grid;
    gap: var(--ds-space-1);
    align-content: start;
    min-width: 0;
  }

  .ds-field--full {
    grid-column: 1 / -1;
  }

  .ds-field--grow {
    flex: 1 1 240px;
  }

  /* The search keeps room for a placeholder of about 224px between the icon
     and the end padding. When the row cannot hold that and the 160px
     filters, it takes a row of its own and the filters follow together,
     instead of a lone field wrapping under them. */
  .ds-filters .ds-field--grow {
    flex-basis: calc(224px + var(--ds-space-4) * 2 + 20px + var(--ds-space-3) + 2px);
  }

  @container ds-content (max-width: 980px) {
    .ds-filters .ds-field--grow {
      flex-basis: 100%;
    }
  }

  .ds-field__label {
    color: var(--ds-text-2);
    font: var(--ds-font-caption);
    transition: color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* The label takes the ring's colour with the ring (at once) and fades
     back on blur; an invalid field keeps its label in the error ink, also
     while focused. A fieldset tints its legend while focus is inside. */
  .ds-field:focus-within > .ds-field__label {
    color: var(--ds-input-focus-color, var(--ds-primary));
    transition-duration: 0s;
  }

  /* Also through an input group, a field row or a combobox; bounded to
     them, so an invalid field never reaches a field it sits in. */
  .ds-field:has(> [aria-invalid="true"], > :is(.ds-input-group, .ds-field__row, .ds-combo) > [aria-invalid="true"]) > .ds-field__label {
    color: var(--ds-on-error-container);
  }

  /* A group of checkboxes or radio cards is a fieldset whose legend is the
     label: without the browser's frame and indent. */
  fieldset.ds-field {
    margin: 0;
    padding: 0;
    border: 0;
  }

  /* A layered reset (Bootstrap's reboot) floats a legend, widens it and
     gives it a margin; ds sets all three, so a group label sits as close to
     its first option as a field's label to its field. */
  legend.ds-field__label {
    float: none;
    width: auto;
    margin: 0;
    padding: 0;
  }

  .ds-field__hint {
    margin: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    text-wrap: pretty;
  }

  .ds-field__hint:empty {
    display: none;
  }

  /* A hint led by an icon (a live check, a note): the icon beside the
     first line, the text wrapping beside it. */
  .ds-field__hint:has(> svg:first-child) {
    display: flex;
    align-items: flex-start;
    gap: var(--ds-space-1);
  }

  .ds-field__hint > svg {
    flex-shrink: 0;
    color: var(--ds-text-2);
  }

  /* Markers in the label: "(optional)" on the few optional fields (the
     recommended way), or an asterisk where most fields are optional; the
     input carries required, so the asterisk is decoration. */
  .ds-field__optional {
    color: var(--ds-text-2);
    font-weight: var(--ds-weight-regular);
  }

  .ds-field__required {
    color: var(--ds-on-error-container);
  }

  /* A hint and a counter in one row under the field: the counter at the
     end, in tabular figures (aria-hidden; the page announces near the
     limit). */
  .ds-field__foot {
    display: flex;
    align-items: flex-start;
    gap: var(--ds-space-1) var(--ds-space-3);
    min-width: 0;
  }

  .ds-field__foot > .ds-field__hint {
    flex: 1 1 auto;
    min-width: 0;
  }

  .ds-field__counter {
    flex: none;
    margin-inline-start: auto;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
  }

  /*
   * A changed field: a dot after its label and the word "(changed)" for
   * screen readers (.ds-field__changed.ds-sr, shown only while the field
   * carries --changed). The dot always takes its room, so the label never
   * shifts: only its opacity changes, in on --ds-dur-fast and out on
   * --ds-dur-instant.
   */
  .ds-field__label::after {
    content: '';
    display: inline-block;
    width: 6px;
    height: 6px;
    margin-inline-start: var(--ds-space-1);
    vertical-align: middle;
    border-radius: 50%;
    background: var(--ds-field-changed-color, var(--ds-primary));
    opacity: 0;
    transition: opacity var(--ds-dur-instant) var(--ds-ease-in);
  }

  .ds-field--changed > .ds-field__label::after {
    opacity: 1;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-field:not(.ds-field--changed) .ds-field__changed {
    display: none;
  }

  /* A value that is read, not edited (set by another system): as tall as a
     field's text row, without a box that would look like a field. A value
     that must be focusable or submitted is an <input class="ds-input"
     readonly> instead. */
  .ds-field__value {
    margin: 0;
    padding-block: var(--ds-space-2);
    color: var(--ds-text);
    font: var(--ds-font-body);
    font-variant-numeric: tabular-nums;
    overflow-wrap: anywhere;
  }

  /* A field and its buttons in one row (copy, reveal, look up): the field
     takes the room, the buttons keep their size. --top aligns them to the
     start, beside a textarea. */
  .ds-field__row {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    min-width: 0;
  }

  .ds-field__row > :first-child {
    flex: 1 1 auto;
    min-width: 0;
  }

  .ds-field__row > .ds-btn {
    flex: none;
  }

  .ds-field__row--top {
    align-items: flex-start;
  }

  /* ── Text input, select, textarea ─────────────── */

  .ds-input {
    width: 100%;
    min-height: var(--ds-input-height, 40px);
    margin: 0;
    padding: var(--ds-space-2) var(--ds-space-3);
    border: 1px solid var(--ds-input-border, var(--ds-outline));
    border-radius: var(--ds-input-radius, var(--ds-radius-field));
    background: var(--ds-input-bg, var(--ds-surface));
    color: var(--ds-text);
    font: var(--ds-input-font, var(--ds-font-body));
    /* On blur border, inset shadow and text leave together, so the ring
       never fades as half a ring (a primary border without its inner
       pixel); focus sets 0s below, so the ring itself appears at once. */
    transition:
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* A multi-line field: about three lines tall, taller only by hand. */
  textarea.ds-input {
    display: block;
    min-height: 88px;
    resize: vertical;
  }

  /* A field that grows with its text (a message, a note): one row at
     first, up to --ds-input-grow-max, then it scrolls. Where field-sizing
     is missing, DS.dom.autosize(textarea) sets the height (never
     animated). */
  textarea.ds-input--grow {
    min-height: var(--ds-input-height, 40px);
    max-height: var(--ds-input-grow-max, 50vh);
    overflow-y: auto;
    resize: none;
  }

  @supports (field-sizing: content) {
    textarea.ds-input--grow {
      field-sizing: content;
    }
  }

  /* 16px on phones: iOS Safari zooms the page into any smaller field. */
  @media (max-width: 600px) {
    .ds-input {
      font: var(--ds-input-font, var(--ds-font-input));
    }
  }

  /* Zero specificity beyond .ds-input itself: the filled toolbar fields
     (search pill, selects, dates) keep their own hover without an outline. */
  @media (hover: hover) {
    .ds-input:where(:hover:not(:focus-within, :disabled, [readonly], [aria-invalid="true"])) {
      border-color: var(--ds-text);
    }
  }

  .ds-input::placeholder {
    color: var(--ds-text-3);
    opacity: 1;
  }

  /* The ring is the field's own 1px border plus 1px inside: 2px, no shift.
     It appears at once: the hover fade of the border must not carry over.
     In Chromium the calendar button of a date field is a tab stop of its
     own inside the field: the field is not :focus then, but keeps the ring. */
  .ds-input:focus,
  .ds-input[type="date"]:focus-within,
  .ds-input:is([type="datetime-local"], [type="month"], [type="week"], [type="time"]):focus-within {
    outline: none;
    border-color: var(--ds-input-focus-color, var(--ds-primary));
    box-shadow: inset 0 0 0 1px var(--ds-input-focus-color, var(--ds-primary));
    transition-duration: 0s;
  }

  /* Error: the 1px border in the error colour at rest and the 2px ring when
     focused (Material 3), so the focused field still stands out; the
     message sits under the field (.ds-field__hint--error), never the colour
     alone. */
  .ds-input[aria-invalid="true"] {
    border-color: var(--ds-error);
    box-shadow: none;
  }

  @media (hover: hover) {
    .ds-input[aria-invalid="true"]:hover:not(:focus-within) {
      border-color: var(--ds-on-error-container);
    }
  }

  .ds-input[aria-invalid="true"]:focus,
  .ds-input[aria-invalid="true"]:focus-within {
    border-color: var(--ds-error);
    box-shadow: inset 0 0 0 1px var(--ds-error);
  }

  /* The message fades in when it appears (inserted, or filled from
     :empty), not when its text changes; it never lifts. Where height can
     animate to auto its room opens too (below); removal is instant. A
     confirmation (--success: "Available") enters the same way; a hint that
     turns from error to success keeps its animation (the same name), so
     only its text and icon change. The error keeps its own layout, also
     without an icon and outside a .ds-field. */
  .ds-field__hint--error {
    display: flex;
    align-items: flex-start;
    gap: var(--ds-space-1);
    color: var(--ds-field-error-color, var(--ds-on-error-container));
  }

  .ds-field__hint--success {
    color: var(--ds-field-success-color, var(--ds-text-2));
  }

  .ds-field__hint--error,
  .ds-field__hint--success {
    animation: ds-fade-in var(--ds-dur-fast) var(--ds-ease-in-out) backwards;
  }

  .ds-field__hint--success > svg {
    color: var(--ds-success);
  }

  /* The room opens from 0 to the message's height on a curve that never
     overshoots (a layout size, D29), but only for a message that appears
     while someone works in the form (focus in its form or field: validation
     on blur or on submit). @starting-style also matches the first render,
     so a message already in the markup would push the page down on load;
     without focus it only fades in. */
  @supports (interpolate-size: allow-keywords) {
    .ds-field__hint--error {
      interpolate-size: allow-keywords;
      overflow: clip;
      transition: height var(--ds-dur-base) var(--ds-ease-out);
    }

    .ds-field__hint--success {
      interpolate-size: allow-keywords;
      overflow: clip;
      transition: height var(--ds-dur-base) var(--ds-ease-out);
    }

    @starting-style {
      :where(form, .ds-form, .ds-field):focus-within .ds-field__hint--error,
      :where(form, .ds-form, .ds-field):focus-within .ds-field__hint--success {
        height: 0;
      }
    }
  }

  .ds-field__hint--error > svg {
    flex-shrink: 0;
    color: var(--ds-error);
  }

  /* Under an inline form the message follows the row closely. */
  .ds-card > .ds-inline-form + .ds-field__hint--error {
    margin-top: var(--ds-space-1);
  }

  /* Disabled and read-only: the quiet surface and the quieter text. */
  .ds-input:disabled,
  .ds-input[readonly] {
    border-color: var(--ds-line);
    background: var(--ds-surface-low);
    color: var(--ds-text-2);
  }

  .ds-input:disabled {
    cursor: not-allowed;
  }

  .ds-input:disabled::placeholder {
    color: var(--ds-text-2);
  }

  /*
   * Select: the native control without its arrow, and M3's drop-down triangle
   * drawn by two gradients in the quieter text colour (no image file). A
   * field with suggestions (datalist) shows the same triangle at
   * rest; Chromium's own arrow, which only appears on hover, stays on top of
   * it invisibly, so a click there still opens the list.
   */
  /* Physical on purpose (D9): the arrow's background-position is
     physical, so its padding and the invisible picker over it stay on the
     right with it. */
  select.ds-input,
  input.ds-input[list] {
    padding-right: var(--ds-space-8);
    background-image:
      linear-gradient(45deg, transparent 50%, var(--ds-text-2) 50%),
      linear-gradient(-45deg, transparent 50%, var(--ds-text-2) 50%);
    background-repeat: no-repeat;
    background-position:
      right 17px top 50%,
      right 12px top 50%;
    background-size: 5px 5px;
  }

  select.ds-input {
    appearance: none;
    -webkit-appearance: none;
    cursor: pointer;
  }

  /* Laid over the triangle (in the field's right padding), so a click on
     the triangle opens the list. */
  input.ds-input[list]::-webkit-calendar-picker-indicator {
    width: var(--ds-space-8);
    margin: 0 calc(-1 * var(--ds-space-8)) 0 0;
    padding: 0;
    opacity: 0;
    cursor: pointer;
  }

  /* Date fields: the native picker button, drawn as the line set's calendar
     in the quieter text colour (like the select's triangle and the search
     icon) instead of Chromium's black filled glyph. Without a minimum width
     of its own, the field fits its filter column (two per row at 320px).
     The other date and time types take the same button (a time field the
     line set's clock). */
  .ds-input[type="date"],
  .ds-input:is([type="datetime-local"], [type="month"], [type="week"], [type="time"]) {
    min-width: 0;
  }

  .ds-input[type="date"]::-webkit-calendar-picker-indicator,
  .ds-input:is([type="datetime-local"], [type="month"], [type="week"], [type="time"])::-webkit-calendar-picker-indicator {
    width: 20px;
    height: 20px;
    padding: 0;
    background: var(--ds-text-2);
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M16 2v4'/%3E%3Crect width='18' height='18' x='3' y='4' rx='2'/%3E%3Cpath d='M3 10h18'/%3E%3C/svg%3E") center / 20px no-repeat;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M16 2v4'/%3E%3Crect width='18' height='18' x='3' y='4' rx='2'/%3E%3Cpath d='M3 10h18'/%3E%3C/svg%3E") center / 20px no-repeat;
    opacity: 1;
    cursor: pointer;
  }

  .ds-input[type="time"]::-webkit-calendar-picker-indicator {
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 6v6l4 2'/%3E%3C/svg%3E") center / 20px no-repeat;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 6v6l4 2'/%3E%3C/svg%3E") center / 20px no-repeat;
  }

  /* Firefox: no dotted inner focus line. */
  select.ds-input:-moz-focusring {
    color: transparent;
    text-shadow: 0 0 0 var(--ds-text);
  }

  /* Filled toolbar variant: the select sits on the tonal fill, no outline. */
  .ds-input--filled {
    border-color: transparent;
    background-color: var(--ds-surface-high);
  }

  /* On the tonal fill text-3 falls short of 4.5:1; text-2 keeps it. */
  .ds-input--filled::placeholder,
  .ds-search .ds-input::placeholder {
    color: var(--ds-text-2);
  }

  @media (hover: hover) {
    .ds-input--filled:hover:not(:focus-within):not(:disabled) {
      border-color: transparent;
      background-color: var(--ds-surface-highest);
    }
  }

  /* Search: a filled pill with a leading icon (toolbars). */
  .ds-search {
    position: relative;
    display: flex;
    align-items: center;
    min-width: 0;
  }

  .ds-search > svg {
    position: absolute;
    inset-inline-start: var(--ds-space-4);
    color: var(--ds-text-2);
    pointer-events: none;
  }

  .ds-search .ds-input {
    padding-inline: calc(var(--ds-space-4) + 20px + var(--ds-space-3)) var(--ds-space-4);
    border-color: transparent;
    border-radius: var(--ds-radius-action);
    background: var(--ds-surface-high);
  }

  @media (hover: hover) {
    .ds-search .ds-input:hover:not(:focus) {
      border-color: transparent;
      background: var(--ds-surface-highest);
    }
  }

  .ds-search .ds-input:focus {
    border-color: var(--ds-input-focus-color, var(--ds-primary));
    background: var(--ds-surface);
  }

  /*
   * The search key: a .ds-kbd ("/") in the field's end, shown while the
   * field rests empty. The field keeps room for it, so the caret is never
   * under it. It goes once the field has the focus, a value or is
   * disabled (in --ds-dur-instant, then hidden) and comes back on
   * --ds-dur-fast; touch screens have no key to press, so it is never
   * shown there. aria-keyshortcuts on the input names the shortcut; the
   * key itself is aria-hidden.
   */
  .ds-search__key {
    position: absolute;
    inset-inline-end: var(--ds-space-3);
    pointer-events: none;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      visibility 0s;
  }

  .ds-search > .ds-input:has(~ .ds-search__key) {
    padding-inline-end: calc(var(--ds-space-3) + var(--ds-space-5) + var(--ds-space-2));
  }

  .ds-search .ds-input:is(:focus, :not(:placeholder-shown), :disabled) ~ .ds-search__key {
    opacity: 0;
    visibility: hidden;
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in-out),
      visibility 0s linear var(--ds-dur-instant);
  }

  @media (hover: none) {
    .ds-search__key {
      display: none;
    }
  }

  /* Chromium's clear button is a blue glyph of its own: the icon set's close icon in the quieter text colour instead, a
     mask like the date fields' calendar. A click still clears the field. */
  .ds-search .ds-input::-webkit-search-decoration {
    -webkit-appearance: none;
  }

  .ds-search .ds-input::-webkit-search-cancel-button {
    -webkit-appearance: none;
    appearance: none;
    width: 20px;
    height: 20px;
    margin-block: 0;
    margin-inline: var(--ds-space-2) 0;
    background: var(--ds-text-2);
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") center / 20px no-repeat;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") center / 20px no-repeat;
    cursor: pointer;
  }

  /* Number fields are typed in like every other
     field: no native spinner showing up on hover. Arrow keys still step. */
  input.ds-input[type="number"] {
    appearance: textfield;
    -moz-appearance: textfield;
  }

  input.ds-input[type="number"]::-webkit-inner-spin-button,
  input.ds-input[type="number"]::-webkit-outer-spin-button {
    -webkit-appearance: none;
    margin: 0;
  }

  /*
   * Input group: a unit or prefix inside the field (an aria-hidden
   * .ds-input-group__affix; the unit is in the label too) or a trailing
   * quiet icon button (a password reveal with aria-pressed). The group
   * draws the field (border, radius, background, height); the input inside
   * loses its box. The ring moves to the group while the input has the
   * focus, appearing at once and fading back on blur like a field's; a
   * focused trailing button shows its own ring instead. Invalid and
   * disabled (or read-only) follow the input.
   */
  .ds-input-group {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    min-width: 0;
    min-height: var(--ds-input-height, 40px);
    padding-inline: var(--ds-space-3);
    border: 1px solid var(--ds-input-border, var(--ds-outline));
    border-radius: var(--ds-input-radius, var(--ds-radius-field));
    background: var(--ds-input-bg, var(--ds-surface));
    color: var(--ds-text);
    transition:
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-input-group:has(> .ds-btn:last-child) {
    padding-inline-end: var(--ds-space-1);
  }

  .ds-input-group > .ds-input,
  .ds-input-group > .ds-input:focus,
  .ds-input-group > .ds-input:focus-within {
    flex: 1 1 auto;
    align-self: stretch;
    min-width: 0;
    min-height: 0;
    padding-inline: 0;
    border: 0;
    border-radius: 0;
    background: transparent;
    box-shadow: none;
  }

  .ds-input-group > .ds-btn {
    flex: none;
  }

  .ds-input-group__affix {
    flex: none;
    color: var(--ds-text-2);
    font: var(--ds-input-font, var(--ds-font-body));
    user-select: none;
  }

  @media (max-width: 600px) {
    .ds-input-group__affix {
      font: var(--ds-input-font, var(--ds-font-input));
    }
  }

  @media (hover: hover) {
    .ds-input-group:hover:not(:focus-within, :has(> .ds-input:is(:disabled, [readonly], [aria-invalid="true"]))) {
      border-color: var(--ds-text);
    }
  }

  .ds-input-group:has(> .ds-input:focus) {
    border-color: var(--ds-input-focus-color, var(--ds-primary));
    box-shadow: inset 0 0 0 1px var(--ds-input-focus-color, var(--ds-primary));
    transition-duration: 0s;
  }

  .ds-input-group:has(> [aria-invalid="true"]) {
    border-color: var(--ds-error);
  }

  .ds-input-group:has(> [aria-invalid="true"]:focus) {
    border-color: var(--ds-error);
    box-shadow: inset 0 0 0 1px var(--ds-error);
  }

  .ds-input-group:has(> .ds-input:is(:disabled, [readonly])) {
    border-color: var(--ds-line);
    background: var(--ds-surface-low);
    color: var(--ds-text-2);
  }

  /*
   * The filled field (Material 3): the label sits inside a tonal box above
   * the value (still a real <label>), a hairline closes the box at the
   * bottom and turns into a 2px line in --ds-input-focus-color while the
   * control has the focus (at once, back on blur). The box is the field's
   * ::before over the label's and the control's rows; hints follow below
   * it. In forced colours the fill is gone, so the box keeps a transparent
   * border (its edge there) and the focused control the outline below.
   */
  .ds-field--inset {
    row-gap: 0;
  }

  .ds-field--inset::before {
    content: '';
    grid-area: 1 / 1 / 3 / 2;
    border: 1px solid transparent;
    border-start-start-radius: var(--ds-input-radius, var(--ds-radius-field));
    border-start-end-radius: var(--ds-input-radius, var(--ds-radius-field));
    background: var(--ds-surface-high);
    box-shadow: inset 0 -1px 0 0 var(--ds-input-border, var(--ds-outline));
    pointer-events: none;
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-field--inset > .ds-field__label {
    grid-area: 1 / 1;
    padding: var(--ds-space-2) var(--ds-space-3) 0;
  }

  .ds-field--inset > .ds-input,
  .ds-field--inset > .ds-input:focus,
  .ds-field--inset > .ds-input:focus-within {
    grid-area: 2 / 1;
    min-height: 0;
    padding-block: var(--ds-space-1) var(--ds-space-2);
    border: 0;
    border-radius: 0;
    background-color: transparent;
    box-shadow: none;
  }

  .ds-field--inset > .ds-field__hint {
    margin-block-start: var(--ds-space-1);
    padding-inline: var(--ds-space-3);
  }

  .ds-field--inset > .ds-field__hint:empty {
    margin-block-start: 0;
  }

  @media (hover: hover) {
    .ds-field--inset:hover:not(:focus-within, :has(> .ds-input:is(:disabled, [readonly])))::before {
      background-color: var(--ds-surface-highest);
    }
  }

  .ds-field--inset:has(> .ds-input:focus)::before {
    box-shadow: inset 0 -2px 0 0 var(--ds-input-focus-color, var(--ds-primary));
    transition-duration: 0s;
  }

  .ds-field--inset:has(> [aria-invalid="true"])::before {
    box-shadow: inset 0 -1px 0 0 var(--ds-error);
  }

  .ds-field--inset:has(> [aria-invalid="true"]:focus)::before {
    box-shadow: inset 0 -2px 0 0 var(--ds-error);
  }

  .ds-field--inset:has(> .ds-input:is(:disabled, [readonly]))::before {
    background-color: var(--ds-surface-low);
    box-shadow: inset 0 -1px 0 0 var(--ds-line);
  }

  /*
   * Composer: a message field with its actions (send, attach) in one
   * filled shell. The textarea grows with its text (.ds-input--grow) up to
   * --ds-composer-max-height; the actions stay at the bottom. The ring
   * follows the field model on the shell: at once while focus is inside,
   * fading back on blur.
   */
  .ds-composer {
    display: flex;
    align-items: flex-end;
    gap: var(--ds-space-1);
    min-width: 0;
    padding-block: var(--ds-space-1);
    padding-inline: var(--ds-space-4) var(--ds-space-1);
    border: 1px solid transparent;
    border-radius: var(--ds-composer-radius, var(--ds-radius-2xl));
    background: var(--ds-surface-high);
    color: var(--ds-text);
    transition:
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-composer:focus-within {
    border-color: var(--ds-input-focus-color, var(--ds-primary));
    background: var(--ds-surface);
    box-shadow: inset 0 0 0 1px var(--ds-input-focus-color, var(--ds-primary));
    transition-duration: 0s;
  }

  .ds-composer > .ds-composer__input {
    flex: 1 1 auto;
    min-width: 0;
    min-height: var(--ds-input-height, 40px);
    max-height: var(--ds-composer-max-height, 220px);
    margin: 0;
    padding: calc((var(--ds-input-height, 40px) - 20px) / 2) 0;
    border: 0;
    background: transparent;
    color: var(--ds-text);
    font: var(--ds-input-font, var(--ds-font-body));
    resize: none;
  }

  .ds-composer__input:focus {
    outline: none;
  }

  /* On the tonal fill text-3 falls short of 4.5:1; text-2 keeps it. */
  .ds-composer__input::placeholder {
    color: var(--ds-text-2);
    opacity: 1;
  }

  .ds-composer > .ds-btn {
    flex: none;
  }

  @media (max-width: 600px) {
    .ds-composer > .ds-composer__input {
      font: var(--ds-input-font, var(--ds-font-input));
    }
  }

  /* ── Checkbox and radio cards ─────────────────── */

  /* A long label wraps (also a bare text node: overflow-wrap: anywhere
     lowers its min-content size) and the box stays beside its first line:
     the row pads to --ds-check-height around one 20px line, the box centres
     on that line. A single line looks as with align-items: center. */
  .ds-check {
    display: inline-flex;
    align-items: flex-start;
    gap: var(--ds-space-3);
    max-width: 100%;
    min-height: var(--ds-check-height, 40px);
    padding-block: calc((var(--ds-check-height, 40px) - 20px) / 2);
    color: var(--ds-text);
    font: var(--ds-font-body);
    overflow-wrap: anywhere;
    cursor: pointer;
    /* The label fades to the quieter ink with a disabled box. */
    transition: color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /*
   * Checkbox and radio, drawn on the native input itself (appearance:
   * none, no wrapper), so forms, labels and keyboard stay native. The
   * input is the box (2px border, --ds-radius-check) or the ring;
   * ::before is the check (a mask of the icon set's check at stroke 3,
   * 2.25px at 18px), ::after the indeterminate dash or the radio dot.
   *
   * The check wipes in from the short arm (clip-path, after
   * --ds-dur-micro, decelerating) and fades out; the dash grows from the
   * centre while the check collapses into it (indeterminate, both ways);
   * the radio dot grows on the spatial spring. Transitions only, so
   * boxes rendered checked are still on load.
   *
   * The halo (box-shadow, 40px across: a circle on radios, a square with
   * 13px corners on checkboxes) is the state layer and the press of the
   * input: hover (also on the .ds-check row), press in --ds-dur-micro,
   * keyboard focus with the ring. On touch screens it rests at 0 and a tap
   * grows it from the centre (the inputs' ripple; js/ripple.js skips
   * them). Radio cards (.ds-radio) keep their card layer instead.
   *
   * A table draws its bare checkboxes too, but not the hidden inputs of a
   * switch or a chip in a cell (:where() keeps the specificity).
   *
   * Hooks: --ds-check-size (18px), --ds-check-target (40px).
   */
  .ds-check input,
  .ds-radio input,
  .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)),
  .ds-checkbox,
  .ds-radio-input {
    appearance: none;
    -webkit-appearance: none;
    position: relative;
    box-sizing: border-box;
    flex-shrink: 0;
    width: var(--ds-check-size, 18px);
    height: var(--ds-check-size, 18px);
    /* An empty box would sit on the baseline; inline (a table cell) it
       centres on the text like the native control did. */
    vertical-align: middle;
    border: 2px solid var(--ds-text-2);
    border-radius: var(--ds-radius-check);
    background: transparent;
    color: var(--ds-text);
    cursor: pointer;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-check input:focus-visible,
  .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)):focus-visible,
  .ds-checkbox:focus-visible,
  .ds-radio-input:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* The box adds no margin of its own: rows and table cells place it, the
     same in every engine (a UA margin would make a table head 49px). */
  .ds-check input,
  .ds-radio input,
  .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)),
  .ds-checkbox,
  .ds-radio-input {
    margin: 0;
  }

  .ds-check > input {
    margin-block: calc((20px - var(--ds-check-size, 18px)) / 2);
  }

  :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input) {
    border-radius: 50%;
  }

  /* Checked (or mixed): the box fills in the primary colour; the halo
     follows it. */
  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:checked, :indeterminate),
  :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input):checked {
    border-color: var(--ds-primary);
    color: var(--ds-primary);
  }

  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:checked, :indeterminate) {
    background: var(--ds-primary);
  }

  /* The check: the glyph runs left to right, so the wipe reads as drawing.
     It leaves by fading, then the wipe resets unseen. */
  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)::before {
    content: '';
    position: absolute;
    inset: -2px;
    background: var(--ds-on-primary);
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath pathLength='1' d='M4 12l5 5L20 6'/%3E%3C/svg%3E") center / 100% no-repeat;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath pathLength='1' d='M4 12l5 5L20 6'/%3E%3C/svg%3E") center / 100% no-repeat;
    clip-path: inset(0 100% 0 0);
    opacity: 0;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in),
      clip-path 0s linear var(--ds-dur-fast);
  }

  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):checked::before {
    clip-path: inset(0);
    opacity: 1;
    transition:
      opacity 0s,
      clip-path var(--ds-dur-medium) var(--ds-ease-out) var(--ds-dur-micro);
  }

  /* Mixed: the check collapses to the centre while the dash grows out of
     it (and back), so one mark morphs into the other. */
  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):indeterminate::before {
    clip-path: inset(0 50%);
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in),
      clip-path var(--ds-dur-fast) var(--ds-ease-in);
  }

  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)::after {
    content: '';
    position: absolute;
    top: calc(50% - 1px);
    inset-inline: 2px;
    height: 2px;
    background: var(--ds-on-primary);
    clip-path: inset(0 50%);
    transition: clip-path var(--ds-dur-fast) var(--ds-ease-in);
  }

  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):indeterminate::after {
    clip-path: inset(0);
    transition: clip-path var(--ds-dur-medium) var(--ds-ease-out);
  }

  /* The radio dot grows on the spring and shrinks away accelerating. */
  :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input)::after {
    content: '';
    position: absolute;
    inset: 0;
    width: var(--ds-space-2);
    height: var(--ds-space-2);
    margin: auto;
    border-radius: 50%;
    background: var(--ds-primary);
    scale: 0;
    transition: scale var(--ds-dur-fast) var(--ds-ease-in);
  }

  :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input):checked::after {
    scale: 1;
    transition: scale var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  /* The halo of the inputs that are their own control (not radio cards). */
  :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input) {
    box-shadow: 0 0 0 calc((var(--ds-check-target, 40px) - var(--ds-check-size, 18px)) / 2) transparent;
  }

  @media (hover: hover) {
    :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):not(:disabled, [aria-disabled="true"]):hover,
    .ds-check:hover input:not(:disabled, [aria-disabled="true"]) {
      box-shadow: 0 0 0 calc((var(--ds-check-target, 40px) - var(--ds-check-size, 18px)) / 2) color-mix(in srgb, currentColor calc(var(--ds-state-hover) * 100%), transparent);
    }
  }

  /* Keyboard focus: the halo appears with the ring (and fades on blur). */
  :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):focus-visible:not(:disabled) {
    box-shadow: 0 0 0 calc((var(--ds-check-target, 40px) - var(--ds-check-size, 18px)) / 2) color-mix(in srgb, currentColor calc(var(--ds-state-focus) * 100%), transparent);
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow 0s,
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):not(:disabled, [aria-disabled="true"]):active,
  .ds-check:active input:not(:disabled, [aria-disabled="true"]) {
    box-shadow: 0 0 0 calc((var(--ds-check-target, 40px) - var(--ds-check-size, 18px)) / 2) color-mix(in srgb, currentColor calc(var(--ds-state-pressed) * 100%), transparent);
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-micro) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* Touch: no hover, so the halo rests at 0 and a tap grows it from the
     centre, the press feedback of an input (it cannot hold a ripple). */
  @media (hover: none) {
    :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input) {
      box-shadow: 0 0 0 0 transparent;
    }

    :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):not(:disabled, [aria-disabled="true"]):active,
    .ds-check:active input:not(:disabled, [aria-disabled="true"]) {
      transition:
        background-color var(--ds-dur-fast) var(--ds-ease-in-out),
        border-color var(--ds-dur-fast) var(--ds-ease-in-out),
        box-shadow var(--ds-dur-medium) var(--ds-ease-out),
        opacity var(--ds-dur-fast) var(--ds-ease-in-out),
        outline-offset var(--ds-dur-fast) var(--ds-ease-out);
    }
  }

  /* Error: the border, the fill and the dot in the error colour, the mark
     on it in the surface colour. */
  :is(.ds-check input, .ds-radio input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"]) {
    border-color: var(--ds-error);
  }

  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"]):is(:checked, :indeterminate) {
    background: var(--ds-error);
  }

  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"])::before,
  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"])::after {
    background: var(--ds-surface);
  }

  :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"])::after {
    background: var(--ds-error);
  }

  /* Disabled: the neutral ink for border, fill and dot (a theme that dims
     instead keeps the control's colours), the mark in the surface colour,
     no halo. aria-disabled="true" looks the same; it keeps its focus halo
     (it stays in the tab order, so its reason in a tooltip can be read). */
  :is(.ds-check input, .ds-radio input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):is(:disabled, [aria-disabled="true"]) {
    border-color: var(--ds-disabled-ink, var(--ds-text-2));
    opacity: var(--ds-disabled-opacity, var(--ds-state-disabled));
    cursor: not-allowed;
  }

  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:disabled, [aria-disabled="true"]):is(:checked, :indeterminate) {
    background: var(--ds-disabled-ink, var(--ds-primary));
  }

  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:disabled, [aria-disabled="true"])::before,
  :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:disabled, [aria-disabled="true"])::after {
    background: var(--ds-surface);
  }

  :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input):is(:disabled, [aria-disabled="true"])::after {
    background: var(--ds-disabled-ink, var(--ds-primary));
  }

  .ds-check:has(input:is(:disabled, [aria-disabled="true"])) {
    color: var(--ds-text-2);
    cursor: not-allowed;
  }

  /* A hint under a checkbox lines up with the label text, not the box
     (the box plus the gap). */
  .ds-check + .ds-field__hint {
    padding-inline-start: calc(var(--ds-check-size, 18px) + var(--ds-space-3));
  }

  /* Reads its own hook, so .ds-touch (and a theme) can size it too. */
  .ds-check--sm {
    --ds-check-height: var(--ds-check-sm-height, 32px);
    color: var(--ds-text-2);
  }

  /*
   * Check list: many values from a long set (a filter sheet's statuses):
   * an outlined box of rows on hairlines that scrolls on its own after
   * about six rows (--ds-check-list-max-height; none shows them all). The
   * row padding keeps the halo and the ring of every box inside the box,
   * also in the first and the last row. A count sits at the end of a row;
   * a checked row's label takes the label weight (at once, never
   * animated), so the selection shows by more than the check.
   */
  .ds-check-list {
    position: relative;
    display: grid;
    /* Rows keep their content's height in a box shorter than they are (an
       auto row would shrink to the row's min-height there). */
    grid-auto-rows: max-content;
    max-height: var(--ds-check-list-max-height, 264px);
    overflow-y: auto;
    border: 1px solid var(--ds-line);
    border-radius: var(--ds-radius-card);
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
  }

  .ds-check-list > .ds-check {
    display: flex;
    padding-inline: var(--ds-space-4);
  }

  .ds-check-list > .ds-check + .ds-check {
    border-block-start: 1px solid var(--ds-line);
  }

  /* On the row itself (the count sets its own font): a :has() before a
     combinator would restyle far more on every change in the page. */
  .ds-check-list > .ds-check:has(> input:checked) {
    font-weight: var(--ds-weight-label);
  }

  .ds-check__count {
    flex: none;
    align-self: center;
    margin-inline-start: auto;
    padding-inline-start: var(--ds-space-3);
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
  }

  /* A parent checkbox and its children (role="group"): the children's
     boxes line up under the parent's label (box plus gap). The page keeps
     the parent in step (checked, or indeterminate while only some are). */
  .ds-check-group {
    display: grid;
    justify-items: start;
  }

  .ds-check-group__children {
    display: grid;
    justify-items: start;
    padding-inline-start: calc(var(--ds-check-size, 18px) + var(--ds-space-3));
  }

  .ds-radios {
    display: grid;
    gap: var(--ds-space-2);
  }

  /*
   * Radio tiles: a few big options side by side (two columns, one below
   * 480px of the content or the window). The radio itself is hidden: a
   * leading icon disc and a trailing check circle show the choice (the card
   * keeps its ring and tint), and keyboard focus rings the tile. The check
   * pops from --ds-scale-small on the spring and fades in; it leaves faster,
   * accelerating. With the documented indicator
   * (createIndicator(group, { active: '.ds-radio:has(input:checked)',
   * className: 'ds-radios__indicator' }), and its update() on the group's
   * change event) the tint is a pill that glides from tile to tile; the
   * ring and the check change at once.
   */
  .ds-radios--tiles {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  @container ds-content (max-width: 480px) {
    .ds-radios--tiles {
      grid-template-columns: minmax(0, 1fr);
    }
  }

  @media (max-width: 480px) {
    .ds-radios--tiles {
      grid-template-columns: minmax(0, 1fr);
    }
  }

  .ds-radios--tiles > .ds-radio {
    align-items: center;
  }

  .ds-radios--tiles > .ds-radio > input {
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 0;
    margin: 0;
    clip-path: inset(50%);
    pointer-events: none;
  }

  .ds-radios--tiles > .ds-radio > span:not(.ds-radio__icon, .ds-radio__mark) {
    flex: 1 1 auto;
    min-width: 0;
  }

  .ds-radio > .ds-radio__icon {
    display: inline-grid;
    flex: none;
    place-items: center;
    width: var(--ds-space-10);
    height: var(--ds-space-10);
    border-radius: 50%;
    background: var(--ds-fill-round);
    color: var(--ds-text-2);
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-radio > input:checked ~ .ds-radio__icon {
    background: var(--ds-primary);
    color: var(--ds-on-primary);
  }

  .ds-radio > .ds-radio__mark {
    display: inline-grid;
    flex: none;
    place-items: center;
    width: var(--ds-space-5);
    height: var(--ds-space-5);
    margin-inline-start: auto;
    border: 2px solid var(--ds-text-2);
    border-radius: 50%;
    color: var(--ds-on-primary);
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-radio__mark > svg {
    opacity: 0;
    scale: var(--ds-scale-small);
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in),
      scale var(--ds-dur-fast) var(--ds-ease-in);
  }

  .ds-radio > input:checked ~ .ds-radio__mark {
    border-color: var(--ds-primary);
    background: var(--ds-primary);
  }

  .ds-radio > input:checked ~ .ds-radio__mark > svg {
    opacity: 1;
    scale: 1;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      scale var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  .ds-radio--disabled > :is(.ds-radio__icon, .ds-radio__mark),
  .ds-radio > input:is(:disabled, [aria-disabled="true"]) ~ :is(.ds-radio__icon, .ds-radio__mark) {
    opacity: var(--ds-disabled-opacity, var(--ds-state-disabled));
  }

  /* The pill of the indicator is the tint: the tiles let it show through
     and keep their ring. */
  .ds-radios__indicator {
    --ds-indicator-radius: var(--ds-radius-card);
    background: var(--ds-surface-low);
  }

  .ds-radios.ds-has-indicator > .ds-radio:not(.ds-radio--disabled) {
    background: transparent;
  }

  /* The selected card takes a 2px primary outline (its border plus 1px
     inside, no shift) besides the filled radio: never the tint alone.
     Pressed, the card turns squarer (--ds-radius-card-pressed, in
     --ds-dur-micro) and springs back on release; its state layer and the
     ripple (js/ripple.js) are the press, also when its radio is pressed. */
  .ds-radio {
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: flex-start;
    gap: var(--ds-space-3);
    padding: var(--ds-space-4);
    border: 1px solid var(--ds-line-2);
    border-radius: var(--ds-radius-card);
    background: var(--ds-surface);
    cursor: pointer;
    /* The ring (on keyboard focus of its input) rests one width further out
       and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* State layer in the content colour, over the border like a chip's. */
  .ds-radio::before {
    content: '';
    position: absolute;
    inset: -1px;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-radio:hover:not(.ds-radio--disabled, :has(input:is(:disabled, [aria-disabled="true"]))) {
      border-color: var(--ds-outline);
    }

    .ds-radio:hover:not(.ds-radio--disabled, :has(input:is(:disabled, [aria-disabled="true"])))::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  /* Keyboard focus lays the focus layer under the card's ring (its input
   * has no halo inside a card). :where(input) keeps the weight of the hover
   * and press rules, so source order puts the press on top. */
  .ds-radio:has(:where(input):focus-visible):not(.ds-radio--disabled, :has(input:disabled))::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  .ds-radio:active:not(.ds-radio--disabled, :has(input:is(:disabled, [aria-disabled="true"]))),
  .ds-radio.ds-pressed:not(.ds-radio--disabled, :has(input:is(:disabled, [aria-disabled="true"]))) {
    border-radius: min(var(--ds-radius-card-pressed), var(--ds-radius-card));
    transition:
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-radio:active:not(.ds-radio--disabled, :has(input:is(:disabled, [aria-disabled="true"])))::before,
  .ds-radio.ds-pressed:not(.ds-radio--disabled, :has(input:is(:disabled, [aria-disabled="true"])))::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  .ds-radio:has(input:checked) {
    border-color: var(--ds-primary);
    background: var(--ds-surface-low);
    box-shadow: inset 0 0 0 1px var(--ds-primary);
  }

  .ds-radio input {
    margin-top: 1px;
  }

  /* Keyboard focus rings the whole card, not the round control inside. */
  .ds-radio input:focus-visible {
    outline: none;
  }

  .ds-radio:has(input:focus-visible) {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  .ds-radio span {
    display: grid;
    gap: var(--ds-space-1);
  }

  .ds-radio strong {
    color: var(--ds-text);
    font: var(--ds-font-label);
  }

  .ds-radio small {
    color: var(--ds-text-2);
    font: var(--ds-font-body);
    text-wrap: pretty;
  }

  /* An option that cannot be chosen says why (icon and text, not only greyed out). */
  .ds-radio--disabled {
    border-color: var(--ds-line);
    background: var(--ds-surface-low);
    cursor: not-allowed;
  }

  .ds-radio:has(input:is(:disabled, [aria-disabled="true"])) {
    cursor: not-allowed;
  }

  /* A card whose input is blocked (disabled, aria-disabled) looks like
     .ds-radio--disabled; a checked one keeps its selected look, like a
     checked .ds-radio--disabled. */
  .ds-radio:has(input:is(:disabled, [aria-disabled="true"])):not(:has(input:checked)) {
    border-color: var(--ds-line);
    background: var(--ds-surface-low);
  }

  .ds-radio--disabled strong,
  .ds-radio:has(input:is(:disabled, [aria-disabled="true"])) strong {
    color: var(--ds-text-2);
  }

  .ds-radio small.ds-radio__note {
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-2);
    margin-top: var(--ds-space-1);
    color: var(--ds-on-warning-container);
    font: var(--ds-font-meta);
  }

  .ds-radio__note svg {
    flex-shrink: 0;
    color: var(--ds-warning);
  }

  /* "Add a row" under an editable table: a text button whose padding hangs
     into the gutter, so its icon lines up with the table's first column. */
  .ds-add-row {
    justify-self: start;
    margin-inline-start: calc(-1 * var(--ds-space-3));
  }

  /*
   * Forced colors drop background images and fills: the select's triangle is
   * gone there, so the native controls come back. The date field's calendar
   * and the search's clear button are masks over a fill, which forced colors
   * would paint in the field's own background: they keep the mask, filled in
   * the system text colour.
   */
  @media (forced-colors: active) {
    select.ds-input {
      appearance: auto;
      -webkit-appearance: menulist;
      background-image: none;
    }

    input.ds-input[list] {
      background-image: none;
    }

    input.ds-input[list]::-webkit-calendar-picker-indicator {
      opacity: 1;
    }

    .ds-input[type="date"]::-webkit-calendar-picker-indicator,
    .ds-input:is([type="datetime-local"], [type="month"], [type="week"], [type="time"])::-webkit-calendar-picker-indicator,
    .ds-search .ds-input::-webkit-search-cancel-button {
      forced-color-adjust: none;
      background: CanvasText;
    }

    /* The inset ring of a focused field is a shadow, which forced colours
       drop (and outline: none took the rest): an outline in its place, on
       the box that draws the field. */
    .ds-input:focus,
    .ds-input[type="date"]:focus-within,
    .ds-input:is([type="datetime-local"], [type="month"], [type="week"], [type="time"]):focus-within,
    .ds-input-group:has(> .ds-input:focus),
    .ds-field--inset:has(> .ds-input:focus)::before,
    .ds-composer:focus-within {
      outline: 2px solid Highlight;
      outline-offset: -2px;
    }

    .ds-input-group > .ds-input:focus,
    .ds-field--inset > .ds-input:focus,
    .ds-field--inset > .ds-input:focus-within {
      outline: none;
    }

    /* The changed dot is a fill: the text colour keeps it (the word is the
       cue that needs no colour at all). */
    .ds-field__label::after {
      forced-color-adjust: none;
      background: CanvasText;
    }

    /* Radio tiles hide their radio: the check circle carries the choice. */
    .ds-radio > .ds-radio__mark {
      forced-color-adjust: none;
      border-color: CanvasText;
      background: Canvas;
      color: HighlightText;
    }

    .ds-radio > input:checked ~ .ds-radio__mark {
      border-color: Highlight;
      background: Highlight;
    }

    .ds-radio--disabled > .ds-radio__mark,
    .ds-radio > input:is(:disabled, [aria-disabled="true"]) ~ .ds-radio__mark {
      border-color: GrayText;
    }

    .ds-radio > input:is(:disabled, [aria-disabled="true"]):checked ~ .ds-radio__mark {
      background: GrayText;
    }

    /* The drawn checkbox and radio in system colours (the box, the mark
       and the dot are fills); the halo is a tint: none. */
    :is(.ds-check input, .ds-radio input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input),
    :is(.ds-check input, .ds-radio input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"]) {
      forced-color-adjust: none;
      border-color: CanvasText;
      background: Canvas;
    }

    :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input),
    :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):not(:disabled):hover,
    .ds-check:hover input:not(:disabled),
    :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):focus-visible:not(:disabled),
    :is(.ds-check input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):not(:disabled):active,
    .ds-check:active input:not(:disabled) {
      box-shadow: none;
    }

    :is(.ds-check input, .ds-radio input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):focus-visible {
      outline-color: CanvasText;
    }

    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:checked, :indeterminate),
    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"]):is(:checked, :indeterminate),
    :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input):checked {
      border-color: Highlight;
    }

    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:checked, :indeterminate),
    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"]):is(:checked, :indeterminate) {
      background: Highlight;
    }

    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)::before,
    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)::after,
    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"])::before,
    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"])::after {
      background: HighlightText;
    }

    :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input)::after,
    :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input)[aria-invalid="true"]:not(:disabled, [aria-disabled="true"])::after {
      background: Highlight;
    }

    :is(.ds-check input, .ds-radio input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input):is(:disabled, [aria-disabled="true"]) {
      border-color: GrayText;
    }

    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:disabled, [aria-disabled="true"]):is(:checked, :indeterminate) {
      background: GrayText;
    }

    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:disabled, [aria-disabled="true"])::before,
    :is(.ds-check input[type="checkbox"], .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox):is(:disabled, [aria-disabled="true"])::after {
      background: Canvas;
    }

    :is(.ds-check input[type="radio"], .ds-radio input, .ds-radio-input):is(:disabled, [aria-disabled="true"])::after {
      background: GrayText;
    }

    .ds-check:has(input:is(:disabled, [aria-disabled="true"])) {
      color: GrayText;
    }
  }

  /* Print: a check list shows every row, and the actions stay in the flow.
     The drawn checkbox and radio (and a radio tile's check circle) are
     backgrounds, which printing drops by default: they keep their colours,
     so a checked box prints checked. */
  @media print {
    .ds-check-list {
      max-height: none;
      overflow: visible;
    }

    .ds-form-actions--sticky {
      position: static;
    }

    :is(.ds-check input, .ds-radio input, .ds-table input[type="checkbox"]:where(:not(.ds-switch *, .ds-chip *)), .ds-checkbox, .ds-radio-input),
    .ds-radio > :is(.ds-radio__icon, .ds-radio__mark) {
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
  }
}

/* ── components/drag.css ── */
/*
 * Drag, drop and files (2.0): the grip and the dragged row of a list that
 * DS.motion.sortable() reorders, the file field, the drop zone and the
 * drop state of a page's own drop target (DS.dom.dropzone()).
 *
 *   <li class="ex-row">
 *     <button class="ds-btn ds-btn--quiet ds-btn--icon ds-grip" type="button" aria-label="Reorder Monday">(grip 20)</button>
 *     ...
 *   </li>                                   DS.motion.sortable(list, { onChange })
 *
 *   [data-ds-dragging]        the row being moved (written by sortable()):
 *                             raised (--ds-z-raised, --ds-elev-2) with the
 *                             dragged state layer (currentColor at
 *                             --ds-state-dragged) on --ds-dur-fast; nothing
 *                             scales. ="end" while it settles: the look
 *                             leaves in --ds-dur-instant
 *
 *   <input class="ds-input" type="file" accept=".csv">
 *                             the field box; the chosen file's name in
 *                             --ds-text-2; the browser's button is a small
 *                             tonal pill that morphs on press
 *
 *   <label class="ds-dropzone">
 *     <input class="ds-dropzone__input" type="file" accept=".csv" multiple>
 *     <span class="ds-dropzone__disc" aria-hidden="true">(upload 24)</span>
 *     <span class="ds-dropzone__title">
 *       <span class="ds-dropzone__idle">Drop CSV files here or <span class="ds-dropzone__browse">browse</span></span>
 *       <span class="ds-dropzone__over" aria-hidden="true">Release to add the files</span>
 *     </span>
 *     <span class="ds-dropzone__hint">Up to 10 MB each</span>
 *   </label>                                DS.dom.dropzone(zone, { onFiles })
 *
 *   [data-ds-dragover]        files are dragged over it (written by
 *                             DS.dom.dropzone()): the zone takes the accent
 *                             with an inset primary edge (no layout shift)
 *                             and says so in words (__over crossfades with
 *                             __idle in one cell); any other element of a
 *                             page (a pane, a chat) gets a dashed primary
 *                             outline inside its edge
 *
 * The zone is a label: a click or Enter on its input opens the file
 * dialog, and its input stays in the tab order (visually hidden) with the
 * ring on the zone. It is a ripple host and morphs to
 * --ds-radius-card-pressed like a radio card; disabled through its input
 * (:has(input:disabled)). The list of chosen files is the page's (.ds-list
 * rows with a .ds-progress and a remove button, rendered through flip()).
 *
 * Hooks: --ds-dropzone-border (1px dashed --ds-outline).
 *
 * Forced colours: the dragged row and a drop target are outlined in
 * Highlight; the zone's dashed edge is ButtonText (Highlight while dragged
 * over); the file button is ButtonFace / ButtonText with a ButtonText
 * border; a disabled zone or file field is GrayText.
 */

@layer ds.components {
  /* ── The grip and the dragged row ─────────────── */

  .ds-grip {
    cursor: grab;
    touch-action: none;
  }

  .ds-grip:active {
    cursor: grabbing;
  }

  :where(.ds-theme) [data-ds-dragging] {
    position: relative;
    z-index: var(--ds-z-raised);
    box-shadow: var(--ds-elev-2);
    transition: box-shadow var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  :where(.ds-theme) [data-ds-dragging]::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: currentColor;
    opacity: var(--ds-state-dragged);
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* Settling into its place: the raised look leaves (exit 100/150). */
  :where(.ds-theme) [data-ds-dragging="end"] {
    box-shadow: none;
    transition: box-shadow var(--ds-dur-instant) var(--ds-ease-in-out);
  }

  :where(.ds-theme) [data-ds-dragging="end"]::after {
    opacity: 0;
    transition: opacity var(--ds-dur-instant) var(--ds-ease-in-out);
  }

  /* ── The file field ───────────────────────────── */

  .ds-input[type="file"] {
    display: flex;
    align-items: center;
    padding-block: var(--ds-space-1);
    padding-inline-start: var(--ds-space-1);
    color: var(--ds-text-2);
    cursor: pointer;
  }

  /* A pseudo-element has no ::before of its own: its hover and press mix
     the state layer into its fill. */
  .ds-input[type="file"]::file-selector-button {
    block-size: 32px;
    margin-inline-end: var(--ds-space-3);
    padding-inline: var(--ds-space-3);
    border: 0;
    border-radius: min(var(--ds-radius-action), var(--ds-space-4));
    background: var(--ds-accent);
    color: var(--ds-on-accent);
    font: var(--ds-font-label);
    cursor: pointer;
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  @media (hover: hover) {
    .ds-input[type="file"]:not(:disabled):hover::file-selector-button {
      background: color-mix(in srgb, var(--ds-on-accent) calc(var(--ds-state-hover) * 100%), var(--ds-accent));
    }
  }

  .ds-input[type="file"]:not(:disabled):active::file-selector-button {
    border-radius: min(var(--ds-radius-action-pressed), var(--ds-radius-action), var(--ds-space-4));
    background: color-mix(in srgb, var(--ds-on-accent) calc(var(--ds-state-pressed) * 100%), var(--ds-accent));
    transition:
      background-color var(--ds-dur-micro) var(--ds-ease-out),
      border-radius var(--ds-dur-micro) var(--ds-ease-out);
  }

  .ds-input[type="file"]:disabled {
    cursor: not-allowed;
  }

  .ds-input[type="file"]:disabled::file-selector-button {
    background: var(--ds-disabled-fill);
    color: var(--ds-disabled-ink);
    cursor: not-allowed;
  }

  /* ── The drop zone ────────────────────────────── */

  .ds-dropzone {
    position: relative;
    isolation: isolate;
    display: grid;
    justify-items: center;
    gap: var(--ds-space-2);
    padding: var(--ds-space-6) var(--ds-space-5);
    border: var(--ds-dropzone-border, 1px dashed var(--ds-outline));
    border-radius: var(--ds-radius-card);
    background: var(--ds-surface);
    color: var(--ds-text);
    text-align: center;
    cursor: pointer;
    /* The ring (on keyboard focus of its input) rests one width further out
       and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* State layer in the content colour, over the border. */
  .ds-dropzone::before {
    content: '';
    position: absolute;
    inset: -1px;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-dropzone:hover:not(:has(input:disabled))::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  .ds-dropzone:has(.ds-dropzone__input:focus-visible):not(:has(input:disabled))::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  .ds-dropzone:active:not(:has(input:disabled)),
  .ds-dropzone.ds-pressed:not(:has(input:disabled)) {
    border-radius: min(var(--ds-radius-card-pressed), var(--ds-radius-card));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-dropzone:active:not(:has(input:disabled))::before,
  .ds-dropzone.ds-pressed:not(:has(input:disabled))::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  .ds-dropzone:has(.ds-dropzone__input:focus-visible) {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* Visually hidden, still in the tab order: the zone shows its focus. */
  .ds-dropzone__input {
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 0;
    width: 1px;
    height: 1px;
    margin: 0;
    opacity: 0;
    clip-path: inset(50%);
  }

  .ds-dropzone__input:focus-visible {
    outline: none;
  }

  .ds-dropzone__disc {
    display: grid;
    place-items: center;
    width: 48px;
    height: 48px;
    border-radius: 50%;
    background: var(--ds-accent);
    color: var(--ds-on-accent);
  }

  .ds-dropzone__title {
    display: grid;
    font: var(--ds-font-label);
  }

  /* The two titles share one cell, so the zone never changes its size. */
  .ds-dropzone__idle,
  .ds-dropzone__over {
    grid-area: 1 / 1;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-dropzone__over {
    opacity: 0;
    transition-duration: var(--ds-dur-instant);
  }

  .ds-dropzone__browse {
    color: var(--ds-link);
    text-decoration: underline;
    text-decoration-thickness: var(--ds-link-underline-thickness);
    text-decoration-color: var(--ds-link-underline);
    text-underline-offset: var(--ds-link-underline-offset);
  }

  .ds-dropzone__hint {
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
  }

  .ds-dropzone[data-ds-dragover] {
    background: var(--ds-accent);
    box-shadow: inset 0 0 0 var(--ds-focus-width) var(--ds-primary);
  }

  .ds-dropzone[data-ds-dragover] .ds-dropzone__idle {
    opacity: 0;
    transition-duration: var(--ds-dur-instant);
  }

  .ds-dropzone[data-ds-dragover] .ds-dropzone__over {
    opacity: 1;
    transition-duration: var(--ds-dur-fast);
  }

  .ds-dropzone:has(input:disabled) {
    border-color: var(--ds-disabled-fill);
    color: var(--ds-disabled-ink);
    cursor: not-allowed;
    transition:
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-dropzone:has(input:disabled) .ds-dropzone__disc {
    background: var(--ds-disabled-fill);
    color: var(--ds-disabled-ink);
  }

  /* A page's own drop target (a pane, a chat) while files are over it. */
  :where(.ds-theme) [data-ds-dragover]:not(.ds-dropzone) {
    outline: var(--ds-focus-width) dashed var(--ds-primary);
    outline-offset: calc(-1 * var(--ds-space-2));
  }

  @media (forced-colors: active) {
    :where(.ds-theme) [data-ds-dragging] {
      outline: var(--ds-focus-width) solid Highlight;
    }

    :where(.ds-theme) [data-ds-dragover]:not(.ds-dropzone) {
      outline-color: Highlight;
    }

    .ds-dropzone {
      border: 1px dashed ButtonText;
    }

    .ds-dropzone[data-ds-dragover] {
      border-color: Highlight;
      box-shadow: inset 0 0 0 var(--ds-focus-width) Highlight;
    }

    .ds-input[type="file"]::file-selector-button {
      border: 1px solid ButtonText;
      background: ButtonFace;
      color: ButtonText;
    }

    /* Blocked is one system colour (D8). */
    .ds-dropzone:has(input:disabled),
    .ds-dropzone:has(input:disabled) .ds-dropzone__disc {
      border-color: GrayText;
      color: GrayText;
    }

    .ds-input[type="file"]:disabled,
    .ds-input[type="file"]:disabled::file-selector-button {
      border-color: GrayText;
      color: GrayText;
    }
  }
}

/* ── components/chip.css ── */
/*
 * Filter chips on native checkboxes or radios: the input stays in the form
 * and in the tab order, visually hidden; selection shows the tint and a
 * check, never the tint alone.
 *
 *   <fieldset class="ds-chips">
 *     <legend class="ds-sr">Status</legend>
 *     <label class="ds-chip">
 *       <input class="ds-chip__input" type="checkbox" name="status" value="open">
 *       <span class="ds-chip__body"><svg class="ds-chip__check" ...>(check, 18px)</svg><span>Open</span></span>
 *     </label>
 *     <span class="ds-chips__divider" aria-hidden="true"></span>
 *   </fieldset>
 *
 * Selecting opens the room of the check: an empty slot before the label
 * (.ds-chip__body::after, the only property that moves is its width)
 * grows to the check's size on --ds-ease-out (it pushes layout, so it
 * never overshoots) and the neighbours on the line follow the reflow
 * every frame. The check lies over the slot (never a flex item) and draws
 * itself from the short arm after --ds-dur-micro (stroke-dashoffset, the
 * glyph needs pathLength="1" like the icon set's check). Deselecting
 * reverses it: the check leaves first, the slot closes after
 * --ds-dur-micro; radio chips grow and shrink at once, so a pair keeps
 * its width. Transitions only: a chip rendered selected stays still.
 * Across a wrap the chips glide by FLIP when DS.ripple.attach() wires the
 * row (a programmatic `checked` change, or a page without the script,
 * still jumps to the new line); meanwhile the row carries data-ds-glide
 * and every chip an opaque backing (--ds-chips-backdrop, the colour behind
 * the row, written by the script), so crossing chips never show through;
 * the toggled chip opens with its neighbour (clip-path on the body, its
 * label translated, both by the script), so the two never overlap.
 *
 * Press: the state layer in --ds-dur-micro (also while js/ripple.js holds
 * .ds-pressed) and the ripple; chips are not pills, so they do not morph.
 * A disabled chip shows no hover, press or ripple and takes the button's
 * disabled model (--ds-disabled-fill outline or container, --ds-disabled-ink
 * label and check, --ds-disabled-opacity), fading on --ds-dur-fast.
 * Keyboard focus: the ring is solid from the first frame and snaps from
 * offset + width to the offset (outline-offset, --ds-dur-fast), with the
 * focus layer (--ds-state-focus).
 *
 * aria-disabled="true" on the input looks and acts like disabled (the
 * click is blocked by the action guard); its focus layer stays.
 *
 * Action and input chips are buttons that act at once (a suggestion, an
 * active filter that removes itself), in a role="group" row:
 *
 *   <div class="ds-chips" role="group" aria-label="Active filters">
 *     <button class="ds-chip ds-chip--input" type="button" data-tip="Building C, second floor">
 *       <span class="ds-chip__body"><span class="ds-chip__label"><span class="ds-sr">Remove filter </span>Building C, second floor</span>(close 18 .ds-chip__trailing)</span></button>
 *     <button class="ds-chip" type="button"><span class="ds-chip__body">(user 18 .ds-chip__icon)<span class="ds-chip__label">Only my devices</span></span></button>
 *   </div>
 *
 *   .ds-chip--input   the tint (--ds-chip-input-bg, -color, -border); the
 *                     whole chip removes its value
 *   .ds-chips--wrap   on the row: labels wrap inside the chip (long
 *                     suggestions); a one-line chip keeps its height
 *
 * The body keeps every visual; no check slot; 16px padding, 8px next to a
 * leading icon or a trailing glyph (--ds-chip-check-size); the label ends
 * in an ellipsis once the body reaches --ds-chip-max-width (240px). Hover,
 * the focus ring (on the chip, snapping), the press in --ds-dur-micro and
 * the disabled model like a filter chip; no morph. A chip that stays on or
 * off is a filter chip, never a button with aria-pressed. A row whose chips
 * all went (.ds-chips:empty) takes no room.
 *
 * Hooks: --ds-chip-height (32px), --ds-chip-check-size (18px),
 * --ds-chip-disabled-border, --ds-chip-disabled-bg (selected),
 * --ds-chip-input-bg, --ds-chip-input-color, --ds-chip-input-border
 * (--ds-accent, --ds-on-accent), --ds-chip-max-width (240px). The glide
 * backing reads --ds-chips-backdrop (the script's), else --ds-flip-backdrop.
 */

@layer ds.components {
  .ds-chips {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ds-space-2);
    margin: 0;
    padding: 0;
    border: 0;
    min-width: 0;
  }

  /* A row whose chips all went (the last filter removed) takes no room. */
  .ds-chips:empty {
    display: none;
  }

  .ds-chips__divider {
    width: 1px;
    height: 20px;
    margin: 0 var(--ds-space-2);
    background: var(--ds-line);
  }

  /* Narrow screens wrap the chips; a divider would dangle at a line end. */
  @media (max-width: 720px) {
    .ds-chips__divider {
      display: none;
    }
  }

  .ds-chip {
    position: relative;
    display: inline-flex;
    cursor: pointer;
  }

  /* The native input stays in the form and in the tab order, visually hidden. */
  .ds-chip__input {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    opacity: 0;
    pointer-events: none;
  }

  .ds-chip__body {
    position: relative;
    isolation: isolate;
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-2);
    height: var(--ds-chip-height, 32px);
    /* The same in both states: the gap after the empty slot gives the
       label its 16px; selected the slot adds the check's room. */
    padding-block: 0;
    padding-inline: var(--ds-space-2) var(--ds-space-4);
    border: 1px solid var(--ds-line-2);
    border-radius: var(--ds-radius-chip);
    color: var(--ds-text-2);
    font: var(--ds-font-chip);
    white-space: nowrap;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      border-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* State layer in the content colour. */
  .ds-chip__body::before {
    content: '';
    position: absolute;
    inset: -1px;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* Hover and press only for an input that can change (a disabled chip
     shows neither; js/ripple.js gives it no ripple). */
  @media (hover: hover) {
    .ds-chip:hover .ds-chip__input:not(:disabled, [aria-disabled="true"]) + .ds-chip__body::before {
      opacity: var(--ds-state-hover);
    }
  }

  /* Keyboard focus: the layer appears with the ring and fades out on blur. */
  .ds-chip__input:focus-visible + .ds-chip__body::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  .ds-chip:active .ds-chip__input:not(:disabled, [aria-disabled="true"]) + .ds-chip__body::before,
  .ds-chip.ds-pressed .ds-chip__input:not(:disabled, [aria-disabled="true"]) + .ds-chip__body::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  .ds-chip:has(.ds-chip__input:is(:disabled, [aria-disabled="true"])) {
    cursor: not-allowed;
  }

  /* The slot: the room of the check, first in the row (order), 0 wide at
     rest. Its width is the only thing that moves when the chip toggles;
     it closes after the check has begun to leave. */
  .ds-chip__body::after {
    content: '';
    order: -1;
    flex-shrink: 0;
    width: 0;
    transition: width var(--ds-dur-medium) var(--ds-ease-out) var(--ds-dur-micro);
  }

  .ds-chip__input:checked + .ds-chip__body::after {
    width: var(--ds-chip-check-size, 18px);
    transition-delay: 0s;
  }

  /* Radio chips trade the selection: one slot opens while the other
     closes, at once, so the pair keeps its width. */
  .ds-chip__input[type="radio"] + .ds-chip__body::after {
    transition-delay: 0s;
  }

  /* DS.ripple.attach() measures the final layout of a toggle (a wrap?)
     with the slots at their end width for one frame. */
  .ds-chips[data-ds-snap] .ds-chip__body::after {
    transition: none;
  }

  /* While chips glide across a wrap (DS.ripple.attach()), each chip rests on
     an opaque backing in the colour behind the row, under its body: a chip
     that crosses another, transparent or still fading to its fill, never
     shows the other one through it. At once on and off; at rest it matches
     what is behind the row. */
  .ds-chips[data-ds-glide] .ds-chip {
    border-radius: var(--ds-radius-chip);
    background-color: var(--ds-chips-backdrop, var(--ds-flip-backdrop, var(--ds-surface)));
  }

  /* The check lies over the slot, never a flex item, and fades: in at once
     (--ds-dur-micro, before the stroke starts), out with the stroke. The
     drawn stroke (pathLength="1") runs on the <svg>, and its path inherits
     it: from the short arm, after the check's fade-in; out faster and
     accelerating. Set on the path itself, Chromium missed the change of
     :checked (its style invalidation does not reach a [pathLength]
     selector on the camelCase SVG attribute), so the check drew late. */
  .ds-chip__check {
    position: absolute;
    top: calc(50% - var(--ds-chip-check-size, 18px) / 2);
    inset-inline-start: var(--ds-space-2);
    width: var(--ds-chip-check-size, 18px);
    height: var(--ds-chip-check-size, 18px);
    opacity: 0;
    stroke-dashoffset: 1;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in),
      stroke-dashoffset var(--ds-dur-fast) var(--ds-ease-in);
  }

  .ds-chip__check [pathLength] {
    stroke-dasharray: 1;
  }

  .ds-chip__input:checked + .ds-chip__body {
    border-color: var(--ds-accent);
    background: var(--ds-accent);
    color: var(--ds-on-accent);
  }

  .ds-chip__input:checked + .ds-chip__body .ds-chip__check {
    opacity: 1;
    stroke-dashoffset: 0;
    transition:
      opacity var(--ds-dur-micro) var(--ds-ease-in-out),
      stroke-dashoffset var(--ds-dur-medium) var(--ds-ease-out) var(--ds-dur-micro);
  }

  .ds-chip__input:focus-visible + .ds-chip__body {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* Disabled: the button model. The outline (or, selected, the container)
     takes the neutral fill and the label and check the disabled ink; a
     theme that dims instead (--ds-disabled-opacity, the fill and ink set
     to `initial`) keeps the chip's own colours. */
  .ds-chip__input:is(:disabled, [aria-disabled="true"]) + .ds-chip__body {
    border-color: var(--ds-chip-disabled-border, var(--ds-disabled-fill, var(--ds-line-2)));
    color: var(--ds-disabled-ink, var(--ds-text-2));
    opacity: var(--ds-disabled-opacity, var(--ds-state-disabled));
  }

  .ds-chip__input:checked:is(:disabled, [aria-disabled="true"]) + .ds-chip__body {
    border-color: var(--ds-chip-disabled-bg, var(--ds-disabled-fill, var(--ds-accent)));
    background: var(--ds-chip-disabled-bg, var(--ds-disabled-fill, var(--ds-accent)));
    color: var(--ds-disabled-ink, var(--ds-on-accent));
  }

  /*
   * Action and input chips: a <button> (or a link) that acts at once. The
   * element's own look is reset; the body keeps every visual. No check
   * slot; the padding follows a leading icon or a trailing glyph. Not a
   * pill, so no morph; a chip that stays on or off is a filter chip.
   */
  :is(button, a).ds-chip {
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: var(--ds-radius-chip);
    background: none;
    color: inherit;
    font: inherit;
    text-align: start;
    text-decoration: none;
    -webkit-tap-highlight-color: transparent;
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  :is(button, a).ds-chip > .ds-chip__body {
    max-width: var(--ds-chip-max-width, 240px);
    padding-inline: var(--ds-space-4);
  }

  :is(button, a).ds-chip > .ds-chip__body:has(> .ds-chip__icon:first-child) {
    padding-inline-start: var(--ds-space-2);
  }

  :is(button, a).ds-chip > .ds-chip__body:has(> .ds-chip__trailing:last-child) {
    padding-inline-end: var(--ds-space-2);
  }

  :is(button, a).ds-chip > .ds-chip__body::after {
    content: none;
  }

  .ds-chip__icon,
  .ds-chip__trailing {
    flex-shrink: 0;
    width: var(--ds-chip-check-size, 18px);
    height: var(--ds-chip-check-size, 18px);
  }

  .ds-chip__label {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  @media (hover: hover) {
    :is(button, a).ds-chip:not(:disabled, [aria-disabled="true"]):hover > .ds-chip__body::before {
      opacity: var(--ds-state-hover);
    }
  }

  :is(button, a).ds-chip:focus-visible > .ds-chip__body::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  :is(button, a).ds-chip:not(:disabled, [aria-disabled="true"]):is(:active, .ds-pressed) > .ds-chip__body::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  :is(button, a).ds-chip:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  :is(button, a).ds-chip:is(:disabled, [aria-disabled="true"]) {
    cursor: not-allowed;
  }

  :is(button, a).ds-chip:is(:disabled, [aria-disabled="true"]) > .ds-chip__body {
    border-color: var(--ds-chip-disabled-border, var(--ds-disabled-fill, var(--ds-line-2)));
    color: var(--ds-disabled-ink, var(--ds-text-2));
    opacity: var(--ds-disabled-opacity, var(--ds-state-disabled));
  }

  /* An input chip: an active filter or a picked value; the whole chip removes it. */
  .ds-chip--input > .ds-chip__body {
    border-color: var(--ds-chip-input-border, var(--ds-accent));
    background: var(--ds-chip-input-bg, var(--ds-accent));
    color: var(--ds-chip-input-color, var(--ds-on-accent));
  }

  .ds-chip--input:is(:disabled, [aria-disabled="true"]) > .ds-chip__body {
    border-color: var(--ds-chip-disabled-bg, var(--ds-disabled-fill, var(--ds-chip-input-border, var(--ds-accent))));
    background: var(--ds-chip-disabled-bg, var(--ds-disabled-fill, var(--ds-chip-input-bg, var(--ds-accent))));
    color: var(--ds-disabled-ink, var(--ds-chip-input-color, var(--ds-on-accent)));
  }

  /* Wrapping chips (long suggestions): the label wraps inside the chip; a
     one-line chip keeps its height, and the corners stay soft. */
  .ds-chips--wrap .ds-chip__body {
    height: auto;
    min-height: var(--ds-chip-height, 32px);
    padding-block: calc((var(--ds-chip-height, 32px) - 1lh) / 2 - 1px);
    border-radius: min(var(--ds-radius-chip), calc(var(--ds-chip-height, 32px) / 2));
    white-space: normal;
  }

  .ds-chips--wrap .ds-chip__label {
    overflow: visible;
    overflow-wrap: anywhere;
  }

  .ds-chips--wrap :is(button, a).ds-chip {
    border-radius: min(var(--ds-radius-chip), calc(var(--ds-chip-height, 32px) / 2));
  }

  /* Forced colors: the selected fill would be dropped, so a selected chip
     takes the system highlight; the check (stroke currentColor) follows
     in HighlightText. */
  @media (forced-colors: active) {
    .ds-chip__input:checked + .ds-chip__body {
      forced-color-adjust: none;
      border-color: Highlight;
      background: Highlight;
      color: HighlightText;
    }

    .ds-chip__input:checked:focus-visible + .ds-chip__body,
    .ds-chip__input[aria-disabled="true"]:focus-visible + .ds-chip__body {
      outline-color: CanvasText;
    }

    /* Disabled in GrayText; selected, a GrayText container with the check
       and label in Canvas (like the disabled switch). */
    .ds-chip__input:is(:disabled, [aria-disabled="true"]) + .ds-chip__body {
      forced-color-adjust: none;
      border-color: GrayText;
      background: Canvas;
      color: GrayText;
    }

    .ds-chip__input:checked:is(:disabled, [aria-disabled="true"]) + .ds-chip__body {
      border-color: GrayText;
      background: GrayText;
      color: Canvas;
    }

    /* An input chip keeps a real edge (its fill is dropped); a blocked
       action or input chip turns GrayText. */
    .ds-chip--input > .ds-chip__body {
      border-color: CanvasText;
    }

    :is(button, a).ds-chip:is(:disabled, [aria-disabled="true"]) > .ds-chip__body {
      forced-color-adjust: none;
      border-color: GrayText;
      background: Canvas;
      color: GrayText;
    }
  }
}

/* ── components/badge.css ── */
/*
 * Badge: a count on a button, chip, tab or drawer item ("3 new"). Counts
 * only: a label of words is a tag (.ds-tag, status.css), a state is a
 * status chip (.ds-status).
 *
 *   <button class="ds-btn" type="button">(mail 16)Inbox <span class="ds-badge">3</span></button>
 *   <span class="ds-badge ds-badge--error">2</span>
 *
 *   (none: the tint)  --neutral  --ok  --warning  --error
 *
 * A pill of at least --ds-badge-size (20px) in the caption type with
 * tabular figures, on --ds-badge-bg / --ds-badge-color (the tint,
 * --ds-accent / --ds-on-accent); the tones take their container and
 * on-container pair, and a toned badge needs its word in the row (colour
 * is never the only signal: "2 failed"). An empty badge (:empty) takes no
 * room. On a tinted host (a pressed toggle, a tonal button, an open
 * disclosure button, a checked chip, an input chip, a selected table row)
 * the default badge turns to --ds-primary / --ds-on-primary and the
 * neutral one to --ds-surface / --ds-text-2, so it never melts into the
 * tint. In a blocked host (a disabled or aria-disabled button, chip, tab
 * or drawer item) it takes the disabled model: --ds-disabled-fill and
 * --ds-disabled-ink over any tone (GrayText in forced colours).
 *
 * Motion: colours glide on --ds-dur-fast. A count that appears pops in
 * (DS.motion.enter(badge, 'pop')), one that goes pops out
 * (DS.motion.leave(badge, 'pop', { then: 'hide' })); the number itself
 * changes at once, and nothing plays on load.
 *
 * Forced colours: Canvas / CanvasText with a CanvasText outline (GrayText
 * in a blocked host).
 *
 * Hooks: --ds-badge-size (20px), --ds-badge-radius (--ds-radius-action,
 * never above half the size), --ds-badge-bg, --ds-badge-color.
 */

@layer ds.components {
  .ds-badge {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    min-width: var(--ds-badge-size, 20px);
    height: var(--ds-badge-size, 20px);
    padding-inline: var(--ds-space-1);
    border-radius: min(var(--ds-badge-radius, var(--ds-radius-action)), calc(var(--ds-badge-size, 20px) / 2));
    background: var(--ds-badge-bg, var(--ds-accent));
    color: var(--ds-badge-color, var(--ds-on-accent));
    font: var(--ds-font-caption);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-badge:empty {
    display: none;
  }

  .ds-badge--neutral {
    --ds-badge-bg: var(--ds-surface-high);
    --ds-badge-color: var(--ds-text-2);
  }

  .ds-badge--ok {
    --ds-badge-bg: var(--ds-success-container);
    --ds-badge-color: var(--ds-on-success-container);
  }

  .ds-badge--warning {
    --ds-badge-bg: var(--ds-warning-container);
    --ds-badge-color: var(--ds-on-warning-container);
  }

  .ds-badge--error {
    --ds-badge-bg: var(--ds-error-container);
    --ds-badge-color: var(--ds-on-error-container);
  }

  /* On a tinted host the tint badge would vanish: the strong pair instead. */
  :is(.ds-btn[aria-pressed="true"], .ds-btn--tonal, .ds-btn--disclosure[aria-expanded="true"], .ds-btn[aria-haspopup="dialog"][aria-expanded="true"], .ds-chip__input:checked + .ds-chip__body, .ds-chip--input, .ds-table__row--selected, tr:has(> td input[type="checkbox"]:checked)) .ds-badge:not(.ds-badge--neutral, .ds-badge--ok, .ds-badge--warning, .ds-badge--error) {
    --ds-badge-bg: var(--ds-primary);
    --ds-badge-color: var(--ds-on-primary);
  }

  :is(.ds-btn[aria-pressed="true"], .ds-btn--tonal, .ds-btn--disclosure[aria-expanded="true"], .ds-btn[aria-haspopup="dialog"][aria-expanded="true"], .ds-chip__input:checked + .ds-chip__body, .ds-chip--input, .ds-table__row--selected, tr:has(> td input[type="checkbox"]:checked)) .ds-badge--neutral {
    --ds-badge-bg: var(--ds-surface);
    --ds-badge-color: var(--ds-text-2);
  }

  /* In a blocked host (disabled, aria-disabled) the disabled model, over
     any tone: the 12% container and the 38% ink. A theme that dims instead
     (--ds-disabled-fill and -ink set to initial) keeps the badge's colours
     and the host's opacity fades it. */
  :is(.ds-btn:disabled, .ds-btn[aria-disabled="true"]:not([aria-busy="true"]), .ds-chip__input:is(:disabled, [aria-disabled="true"]) + .ds-chip__body, :is(button, a).ds-chip:is(:disabled, [aria-disabled="true"]), .ds-tabs__tab:is(:disabled, [aria-disabled="true"]), .ds-drawer__item:is(:disabled, [aria-disabled="true"])) .ds-badge {
    background: var(--ds-disabled-fill, var(--ds-badge-bg, var(--ds-accent)));
    color: var(--ds-disabled-ink, var(--ds-badge-color, var(--ds-on-accent)));
  }

  @media (forced-colors: active) {
    .ds-badge {
      forced-color-adjust: none;
      outline: 1px solid CanvasText;
      outline-offset: -1px;
      background: Canvas;
      color: CanvasText;
    }

    :is(.ds-btn:disabled, .ds-btn[aria-disabled="true"]:not([aria-busy="true"]), .ds-chip__input:is(:disabled, [aria-disabled="true"]) + .ds-chip__body, :is(button, a).ds-chip:is(:disabled, [aria-disabled="true"]), .ds-tabs__tab:is(:disabled, [aria-disabled="true"]), .ds-drawer__item:is(:disabled, [aria-disabled="true"])) .ds-badge {
      outline-color: GrayText;
      color: GrayText;
    }
  }
}

/* ── components/switch.css ── */
/*
 * Switch (Material 3, 52 x 32) with its name and a state word next to it,
 * so the state never shows through the handle position and colour alone.
 * The word is hidden from screen readers, which announce the switch state
 * itself. With both words in the markup the CSS shows the right one and
 * crossfades them (the leaving word fades, the other rises in); a single
 * word still works, and then the page keeps it in step.
 *
 *   <label class="ds-switch">
 *     <input type="checkbox" role="switch" name="sync" checked>
 *     <span class="ds-switch__track" aria-hidden="true"></span>
 *     <span class="ds-switch__label">Nightly sync</span>
 *     <span class="ds-switch__state" aria-hidden="true"><span class="ds-switch__on">On</span><span class="ds-switch__off">Off</span></span>
 *   </label>
 *
 *   single word:  <span class="ds-switch__state" aria-hidden="true">On</span>
 *
 * The handle travels and grows on the spatial spring (translate and scale,
 * --ds-dur-medium), also when the page sets `checked`; it never renders
 * above its 28px pressed size. Pressed it grows to 28px (--ds-dur-instant),
 * and a halo around it (the switch's press feedback, no ripple) shows
 * hover, press and keyboard focus. Keyboard focus also rings the track:
 * solid from the first frame, it snaps from offset + width to the offset
 * (outline-offset, --ds-dur-fast). Disabled: the neutral Material look
 * (--ds-disabled-fill track, --ds-disabled-ink handle), no hover, halo or
 * grow; it fades in on --ds-dur-fast (opacity, the name and the word) with
 * the colours of the track.
 *
 * The input covers the track (a native hit target, also for a tooltip:
 * data-tip goes on the input); the track takes no pointer.
 *
 * Blocked with a reason: aria-disabled="true" on the input looks and acts
 * like disabled (no hover, halo, grow; the click is blocked by the action
 * guard) but stays focusable, so its tooltip can say why; the ring and the
 * focus halo stay.
 *
 * Locked (a value another system sets): .ds-switch--locked, always with
 * aria-disabled="true" and the reason in words. Its on and off looks keep
 * full strength; the lock glyph rides the handle (the track's last child):
 *
 *   <label class="ds-switch ds-switch--locked">
 *     <input type="checkbox" role="switch" checked aria-disabled="true" aria-describedby="sync-why">
 *     <span class="ds-switch__track" aria-hidden="true"><svg class="ds-switch__icon" ...>(lock, 16)</svg></span>
 *     <span class="ds-switch__label">Nightly sync</span>
 *     <small class="ds-switch__hint" id="sync-why">Set by the device policy</small>
 *   </label>
 *
 * The off handle grows to 24px (6/7) so the lock fits; the lock travels
 * with it (translate, --ds-dur-medium on the spring), in
 * --ds-surface-highest on the off handle and --ds-primary on the on handle,
 * and fades in on --ds-dur-fast, out on --ds-dur-instant.
 *
 * Hint and extras: small.ds-switch__hint (a line under the name, the meta
 * type), span.ds-switch__meta (non-interactive extras before the state
 * word: a count, a tag); the label wraps long words. A switch with a hint
 * lays its parts on a small grid, the hint under the name; the name and
 * its hint stand together and the text centres on the track.
 *
 * Many settings, each a switch: .ds-switch-list holds .ds-switch rows on a
 * grid (track, name over hint, meta, state), at least
 * --ds-switch-list-row-height (56px), hairlines between; the text centres
 * on the track with or without a hint; at 560px and below the meta wraps
 * under the name. Name each input with
 * aria-labelledby (its .ds-switch__label) and describe it with
 * aria-describedby (its hint), so the extras stay out of its name.
 *
 * Forced colours: system colours for on and off (Highlight, CanvasText),
 * GrayText when blocked; a locked switch keeps on and off; a focused
 * checked switch rings in CanvasText.
 *
 * Hooks: --ds-switch-height (40px), --ds-switch-halo (40px),
 * --ds-switch-disabled-track, --ds-switch-disabled-handle,
 * --ds-switch-list-row-height (56px).
 *
 * Print: track and handle keep their colours (print-color-adjust), so on
 * and off show on paper next to the state word.
 */

@layer ds.components {
  .ds-switch {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-3);
    min-height: var(--ds-switch-height, 40px);
    color: var(--ds-text);
    font: var(--ds-font-label);
    cursor: pointer;
  }

  /* The input covers the track: the native hit target (and a tooltip's
     trigger); the track below takes no pointer. */
  .ds-switch input {
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    width: 52px;
    height: 32px;
    margin: auto 0;
    -webkit-appearance: none;
    appearance: none;
    opacity: 0;
    cursor: inherit;
  }

  .ds-switch__track {
    position: relative;
    /* The lock (z-index) stays inside the track. */
    isolation: isolate;
    flex-shrink: 0;
    width: 52px;
    height: 32px;
    border: 2px solid var(--ds-outline);
    border-radius: var(--ds-radius-action);
    background: var(--ds-surface-highest);
    /* The ring rests (invisible) one width further out and snaps in. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    pointer-events: none;
    transition:
      background-color var(--ds-dur-base) var(--ds-ease-in-out),
      border-color var(--ds-dur-base) var(--ds-ease-in-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* The handle is the 28px pressed box at the start of the padding box
     (48 x 28, centre 14, 14), scaled down: 16px off, 24px on, 28px while
     pressed, so it never renders above 1. It travels and grows on the
     spatial spring (translate and scale; the spring from 6/7 peaks below
     1), the press grows it fast and decelerating, the colour changes on
     the standard curve. */
  /* Physical on purpose (D9): the handle and its halo start at the left
     and travel on translate (mirrored motion is not part of 2.0). */
  .ds-switch__track::after {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: 28px;
    height: 28px;
    border-radius: var(--ds-radius-action);
    background: var(--ds-outline);
    scale: calc(4 / 7);
    transition:
      translate var(--ds-dur-medium) var(--ds-ease-spatial),
      scale var(--ds-dur-medium) var(--ds-ease-spatial),
      background-color var(--ds-dur-base) var(--ds-ease-in-out);
  }

  /* The halo: a circle centred on the handle box that moves with it on
     the same spring; the switch's state layer (hover, press, focus) and
     its press feedback (no ripple). */
  .ds-switch__track::before {
    content: '';
    position: absolute;
    top: calc(14px - var(--ds-switch-halo, 40px) / 2);
    left: calc(14px - var(--ds-switch-halo, 40px) / 2);
    width: var(--ds-switch-halo, 40px);
    height: var(--ds-switch-halo, 40px);
    border-radius: 50%;
    background: currentColor;
    color: var(--ds-text);
    opacity: 0;
    pointer-events: none;
    transition:
      translate var(--ds-dur-medium) var(--ds-ease-spatial),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* Hover darkens the resting handle (Material 3: on-surface-variant) and
     lights the halo; only on a switch that can change. */
  @media (hover: hover) {
    .ds-switch:hover input:not(:checked):not(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
      background: var(--ds-text-2);
    }

    .ds-switch:hover input:not(:disabled, [aria-disabled="true"]) + .ds-switch__track::before {
      opacity: var(--ds-state-hover);
    }
  }

  .ds-switch input:checked + .ds-switch__track {
    border-color: var(--ds-primary);
    background: var(--ds-primary);
  }

  .ds-switch input:checked + .ds-switch__track::after {
    background: var(--ds-on-primary);
    translate: 20px 0;
    scale: calc(6 / 7);
  }

  .ds-switch input:checked + .ds-switch__track::before {
    color: var(--ds-primary);
    translate: 20px 0;
  }

  .ds-switch input:focus-visible + .ds-switch__track {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  /* Keyboard focus: the halo appears with the ring (it fades out on blur);
     a toggle meanwhile still glides. */
  .ds-switch input:focus-visible + .ds-switch__track::before {
    opacity: var(--ds-state-focus);
    transition:
      translate var(--ds-dur-medium) var(--ds-ease-spatial),
      opacity 0s,
      color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* Pressed (also a static .ds-pressed): the handle grows to its full 28px
     (Material 3) and the off handle darkens; the halo takes the press. The
     release springs back with the travel. */
  .ds-switch:active input:not(:disabled, [aria-disabled="true"]) + .ds-switch__track::after,
  .ds-switch.ds-pressed input:not(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
    scale: 1;
    transition:
      translate var(--ds-dur-medium) var(--ds-ease-spatial),
      scale var(--ds-dur-instant) var(--ds-ease-out),
      background-color var(--ds-dur-base) var(--ds-ease-in-out);
  }

  .ds-switch:active input:not(:checked):not(:disabled, [aria-disabled="true"]) + .ds-switch__track::after,
  .ds-switch.ds-pressed input:not(:checked):not(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
    background: var(--ds-text);
  }

  .ds-switch:active input:not(:disabled, [aria-disabled="true"]) + .ds-switch__track::before,
  .ds-switch.ds-pressed input:not(:disabled, [aria-disabled="true"]) + .ds-switch__track::before {
    opacity: var(--ds-state-pressed);
    transition:
      translate var(--ds-dur-medium) var(--ds-ease-spatial),
      opacity var(--ds-dur-micro) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* Disabled: the button model. The track takes the neutral fill (a theme
     that dims instead keeps the switch's own colours), the off handle the
     disabled ink, the on handle the surface; no hover, halo or grow. */
  .ds-switch:has(input:is(:disabled, [aria-disabled="true"])) {
    cursor: not-allowed;
  }

  /* The name and the word fade to the quieter ink with the track. */
  .ds-switch__label,
  .ds-switch__state {
    transition: color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-switch input:is(:disabled, [aria-disabled="true"]) ~ .ds-switch__label,
  .ds-switch input:is(:disabled, [aria-disabled="true"]) ~ .ds-switch__state {
    color: var(--ds-text-2);
  }

  .ds-switch input:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
    border-color: var(--ds-switch-disabled-track, var(--ds-disabled-fill, var(--ds-outline)));
    background: var(--ds-switch-disabled-track, var(--ds-disabled-fill, var(--ds-surface-highest)));
    opacity: var(--ds-disabled-opacity, var(--ds-state-disabled));
  }

  .ds-switch input:checked:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
    border-color: var(--ds-switch-disabled-track, var(--ds-disabled-fill, var(--ds-primary)));
    background: var(--ds-switch-disabled-track, var(--ds-disabled-fill, var(--ds-primary)));
  }

  .ds-switch input:is(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
    background: var(--ds-switch-disabled-handle, var(--ds-disabled-ink, var(--ds-outline)));
  }

  .ds-switch input:checked:is(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
    background: var(--ds-surface);
  }

  /*
   * Locked: a value another system sets. Its on and off looks keep full
   * strength (after the disabled rules, with their weight); the off handle
   * grows to 24px to hold the lock.
   */
  .ds-switch--locked input:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
    border-color: var(--ds-outline);
    background: var(--ds-surface-highest);
    opacity: 1;
  }

  .ds-switch--locked input:checked:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
    border-color: var(--ds-primary);
    background: var(--ds-primary);
  }

  .ds-switch--locked input:is(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
    background: var(--ds-outline);
    scale: calc(6 / 7);
  }

  .ds-switch--locked input:checked:is(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
    background: var(--ds-on-primary);
  }

  .ds-switch--locked input:is(:disabled, [aria-disabled="true"]) ~ :is(.ds-switch__label, .ds-switch__state) {
    color: var(--ds-text);
  }

  /* The lock rides the handle: its centre is the handle's (14px, 14px of
     the padding box), above it; it fades out faster than it comes. */
  .ds-switch__icon {
    position: absolute;
    inset-block-start: 6px;
    inset-inline-start: 6px;
    z-index: var(--ds-z-raised);
    width: 16px;
    height: 16px;
    color: var(--ds-surface-highest);
    opacity: 0;
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in-out),
      translate var(--ds-dur-medium) var(--ds-ease-spatial),
      color var(--ds-dur-base) var(--ds-ease-in-out);
  }

  .ds-switch--locked .ds-switch__icon {
    opacity: 1;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      translate var(--ds-dur-medium) var(--ds-ease-spatial),
      color var(--ds-dur-base) var(--ds-ease-in-out);
  }

  .ds-switch input:checked + .ds-switch__track .ds-switch__icon {
    color: var(--ds-primary);
    translate: 20px 0;
  }

  /* Hint and extras: a line under the name, and quiet parts before the word. */
  .ds-switch__label {
    min-width: 0;
    overflow-wrap: anywhere;
  }

  small.ds-switch__hint {
    display: block;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
  }

  span.ds-switch__meta {
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-2);
    color: var(--ds-text-2);
    font: var(--ds-font-body);
    font-variant-numeric: tabular-nums;
  }

  /* The state in words next to the name ("On" / "Off"). It keeps the width
     of the longer word, so the switch does not move when it flips. */
  .ds-switch__state {
    display: inline-grid;
    min-width: 3.5em;
    margin-inline-start: calc(-1 * var(--ds-space-1));
    color: var(--ds-text-2);
    font: var(--ds-font-body);
  }

  /* Both words in one cell. The hidden one waits below; the leaving word
     only fades (then drops back to wait), the arriving word rises in once
     the other has begun to go. */
  .ds-switch__on,
  .ds-switch__off {
    grid-area: 1 / 1;
    opacity: 0;
    translate: 0 var(--ds-distance-sm);
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in),
      translate 0s linear var(--ds-dur-instant);
  }

  .ds-switch input:checked ~ .ds-switch__state .ds-switch__on,
  .ds-switch input:not(:checked) ~ .ds-switch__state .ds-switch__off {
    opacity: 1;
    translate: none;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out) var(--ds-dur-micro),
      translate var(--ds-dur-fast) var(--ds-ease-out) var(--ds-dur-micro);
  }

  /* Forced colors: the handle and the on state are fills only; in system
     colours, so on and off still differ. The halo is a tint: hidden. */
  @media (forced-colors: active) {
    .ds-switch__track::after,
    .ds-switch:hover input:not(:checked):not(:disabled, [aria-disabled="true"]) + .ds-switch__track::after,
    .ds-switch:active input:not(:checked):not(:disabled, [aria-disabled="true"]) + .ds-switch__track::after,
    .ds-switch.ds-pressed input:not(:checked):not(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
      forced-color-adjust: none;
      background: CanvasText;
    }

    .ds-switch input:checked + .ds-switch__track {
      forced-color-adjust: none;
      border-color: Highlight;
      background: Highlight;
    }

    .ds-switch input:checked + .ds-switch__track::after {
      background: Canvas;
    }

    .ds-switch__track::before {
      display: none;
    }

    .ds-switch input:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
      forced-color-adjust: none;
      border-color: GrayText;
      background: Canvas;
    }

    .ds-switch input:checked:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
      background: GrayText;
    }

    .ds-switch input:is(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
      background: GrayText;
    }

    .ds-switch input:checked:is(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
      background: Canvas;
    }

    .ds-switch input:is(:disabled, [aria-disabled="true"]) ~ .ds-switch__label,
    .ds-switch input:is(:disabled, [aria-disabled="true"]) ~ .ds-switch__state {
      color: GrayText;
    }

    /* A checked switch keeps its ring in CanvasText (the track is Highlight),
       and so does a blocked one (its track is not forced). */
    .ds-switch input:checked:focus-visible + .ds-switch__track,
    .ds-switch input[aria-disabled="true"]:focus-visible + .ds-switch__track {
      outline-color: CanvasText;
    }

    /* Locked: the normal on and off system colours, the lock in Canvas on
       the off handle and in Highlight on the on handle. */
    .ds-switch--locked input:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
      border-color: CanvasText;
      background: Canvas;
    }

    .ds-switch--locked input:checked:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
      border-color: Highlight;
      background: Highlight;
    }

    .ds-switch--locked input:is(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
      background: CanvasText;
    }

    .ds-switch--locked input:checked:is(:disabled, [aria-disabled="true"]) + .ds-switch__track::after {
      background: Canvas;
    }

    .ds-switch--locked input:is(:disabled, [aria-disabled="true"]) ~ :is(.ds-switch__label, .ds-switch__state) {
      color: CanvasText;
    }

    /* The page's forced svg rule (shell.css) makes every icon inherit its
       colour, so the track carries it: the lock takes the track's colour. */
    .ds-switch--locked input:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
      color: Canvas;
    }

    .ds-switch--locked input:checked:is(:disabled, [aria-disabled="true"]) + .ds-switch__track {
      color: Highlight;
    }

    .ds-switch__icon {
      forced-color-adjust: none;
      color: inherit;
    }
  }

  /*
   * Many settings, each a switch: rows on one grid (track, name over hint,
   * meta, state word), hairlines between. At 560px and below the meta
   * wraps under the name.
   */
  .ds-switch-list {
    display: grid;
    container: ds-switch-list / inline-size;
    margin: 0;
    padding: 0;
  }

  /* A switch with a hint stacks the hint under its name (the same areas).
     The name and the hint stand together, no gap between the text rows;
     the two flexible rows around them take the free height, so the text
     centres on the track whichever rows are empty (no hint, no meta, a
     wrapped name). */
  .ds-switch:has(> .ds-switch__hint) {
    display: inline-grid;
    grid-template-columns: auto minmax(0, 1fr) auto auto;
    grid-template-rows: 1fr auto auto 1fr;
    grid-template-areas:
      "track . meta state"
      "track label meta state"
      "track hint meta state"
      "track . meta state";
    align-items: center;
    column-gap: var(--ds-space-3);
    row-gap: 0;
  }

  .ds-switch-list > .ds-switch {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto auto;
    grid-template-rows: 1fr auto auto 1fr;
    grid-template-areas:
      "track . meta state"
      "track label meta state"
      "track hint meta state"
      "track . meta state";
    align-items: center;
    column-gap: var(--ds-space-3);
    row-gap: 0;
    min-height: var(--ds-switch-list-row-height, 56px);
    padding-block: var(--ds-space-2);
    border-bottom: 1px solid var(--ds-line);
  }

  .ds-switch-list > .ds-switch:last-child {
    border-bottom: 0;
  }

  .ds-switch > .ds-switch__track {
    grid-area: track;
  }

  .ds-switch > .ds-switch__label {
    grid-area: label;
  }

  .ds-switch > .ds-switch__hint {
    grid-area: hint;
  }

  .ds-switch > .ds-switch__meta {
    grid-area: meta;
  }

  .ds-switch > .ds-switch__state {
    grid-area: state;
  }

  @container ds-switch-list (max-width: 560px) {
    .ds-switch-list > .ds-switch {
      grid-template-columns: auto minmax(0, 1fr) auto;
      grid-template-rows: 1fr auto auto auto 1fr;
      grid-template-areas:
        "track . state"
        "track label state"
        "track hint state"
        "track meta state"
        "track . state";
    }

    /* Under the hint (or the name) the meta keeps a little air; a tag
       directly on the text line reads as part of it. */
    .ds-switch > .ds-switch__meta {
      justify-self: start;
      margin-top: var(--ds-space-1);
    }
  }

  /* Print: track and handle are backgrounds, which printing drops by
     default; on and off stay visible. */
  @media print {
    .ds-switch {
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
  }
}

/* ── components/slider.css ── */
/*
 * Slider (Material 3): a value on a scale (a threshold, days of history),
 * drawn on the native range input, so keyboard, forms and labels stay
 * native. A 4px track in the tonal accent, its active part in the primary
 * colour, and a 20px round handle with a halo (--ds-slider-halo, 40px)
 * for hover, press and keyboard focus, like the switch's: the halo is the
 * press (no ripple), and the handle never scales. Keyboard focus also
 * rings the handle: solid from the first frame, it snaps from offset +
 * width to the offset (outline-offset, --ds-dur-fast). The value never
 * glides while it is dragged (direct manipulation): nothing transitions
 * it.
 *
 *   <label class="ds-field__label" for="keep">Keep history for</label>
 *   <div class="ds-field__row">
 *     <input class="ds-slider" id="keep" type="range" min="7" max="365" value="90" aria-describedby="keep-out">
 *     <output class="ds-slider__value" id="keep-out" for="keep">90 days</output>
 *   </div>
 *
 * Firefox draws the active part itself (::-moz-range-progress). Elsewhere
 * the page sets --ds-share (0 to 1, the value's share of the range) on the
 * input when it renders and on every input event; without it the track
 * shows no active part and the slider still works.
 *
 * Disabled (and aria-disabled="true"): the neutral ink for the handle and
 * the active part, no halo; the handle lays the ink over --ds-surface, so
 * it stays solid; it fades on --ds-dur-fast. Forced colours: the
 * track in ButtonText, the handle in Highlight (GrayText when disabled).
 *
 * Hooks: --ds-slider-halo (40px).
 *
 * Print: track and handle keep their colours (print-color-adjust).
 */

@layer ds.components {
  .ds-slider {
    appearance: none;
    -webkit-appearance: none;
    display: block;
    width: 100%;
    min-width: 0;
    height: var(--ds-slider-halo, 40px);
    margin: 0;
    padding: 0;
    background: transparent;
    color: var(--ds-primary);
    cursor: pointer;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* The ring sits on the handle, not around the whole input. */
  .ds-slider:focus-visible {
    outline: none;
  }

  /* The gradient runs left to right: the active part first (D9, LTR). */
  .ds-slider::-webkit-slider-runnable-track {
    height: 4px;
    border-radius: var(--ds-radius-xs);
    background: linear-gradient(to right, currentColor calc(var(--ds-share, 0) * 100%), var(--ds-accent) 0);
  }

  .ds-slider::-moz-range-track {
    height: 4px;
    border-radius: var(--ds-radius-xs);
    background: var(--ds-accent);
  }

  .ds-slider::-moz-range-progress {
    height: 4px;
    border-radius: var(--ds-radius-xs);
    background: currentColor;
  }

  /* The handle and its halo (the halo rests transparent at its full size,
     so only its colour fades). The ring rests one width further out. */
  .ds-slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    box-sizing: border-box;
    width: 20px;
    height: 20px;
    margin-top: -8px;
    border: 0;
    border-radius: 50%;
    background: currentColor;
    box-shadow: 0 0 0 calc((var(--ds-slider-halo, 40px) - 20px) / 2) transparent;
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-slider::-moz-range-thumb {
    box-sizing: border-box;
    width: 20px;
    height: 20px;
    border: 0;
    border-radius: 50%;
    background: currentColor;
    box-shadow: 0 0 0 calc((var(--ds-slider-halo, 40px) - 20px) / 2) transparent;
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  @media (hover: hover) {
    .ds-slider:not(:disabled, [aria-disabled="true"]):hover::-webkit-slider-thumb {
      box-shadow: 0 0 0 calc((var(--ds-slider-halo, 40px) - 20px) / 2) color-mix(in srgb, currentColor calc(var(--ds-state-hover) * 100%), transparent);
    }

    .ds-slider:not(:disabled, [aria-disabled="true"]):hover::-moz-range-thumb {
      box-shadow: 0 0 0 calc((var(--ds-slider-halo, 40px) - 20px) / 2) color-mix(in srgb, currentColor calc(var(--ds-state-hover) * 100%), transparent);
    }
  }

  /* Keyboard focus: the ring snaps in and the halo appears with it (at
     once), also on an aria-disabled slider, which stays in the tab order. */
  .ds-slider:focus-visible::-webkit-slider-thumb {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
    box-shadow: 0 0 0 calc((var(--ds-slider-halo, 40px) - 20px) / 2) color-mix(in srgb, currentColor calc(var(--ds-state-focus) * 100%), transparent);
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow 0s,
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-slider:focus-visible::-moz-range-thumb {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
    box-shadow: 0 0 0 calc((var(--ds-slider-halo, 40px) - 20px) / 2) color-mix(in srgb, currentColor calc(var(--ds-state-focus) * 100%), transparent);
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow 0s,
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-slider:not(:disabled, [aria-disabled="true"]):active::-webkit-slider-thumb {
    box-shadow: 0 0 0 calc((var(--ds-slider-halo, 40px) - 20px) / 2) color-mix(in srgb, currentColor calc(var(--ds-state-pressed) * 100%), transparent);
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-micro) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-slider:not(:disabled, [aria-disabled="true"]):active::-moz-range-thumb {
    box-shadow: 0 0 0 calc((var(--ds-slider-halo, 40px) - 20px) / 2) color-mix(in srgb, currentColor calc(var(--ds-state-pressed) * 100%), transparent);
    transition:
      background-color var(--ds-dur-fast) var(--ds-ease-in-out),
      box-shadow var(--ds-dur-micro) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* Disabled: the neutral ink for the handle and the active part (a theme
     that dims instead keeps the slider's colours). */
  .ds-slider:is(:disabled, [aria-disabled="true"]) {
    color: var(--ds-disabled-ink, var(--ds-primary));
    opacity: var(--ds-disabled-opacity, var(--ds-state-disabled));
    cursor: not-allowed;
  }

  /* The ink is translucent: laid over the surface, the handle stays solid
     and the track does not show through it. One engine's pseudo-element
     per rule. */
  .ds-slider:is(:disabled, [aria-disabled="true"])::-webkit-slider-thumb {
    background: linear-gradient(currentColor, currentColor) var(--ds-surface);
  }

  .ds-slider:is(:disabled, [aria-disabled="true"])::-moz-range-thumb {
    background: linear-gradient(currentColor, currentColor) var(--ds-surface);
  }

  /* The value beside the slider: tabular, so the row keeps its width. */
  .ds-slider__value {
    flex: none;
    min-width: var(--ds-space-10);
    color: var(--ds-text);
    font: var(--ds-font-label);
    font-variant-numeric: tabular-nums;
    text-align: end;
  }

  @media (forced-colors: active) {
    .ds-slider {
      forced-color-adjust: none;
      color: Highlight;
    }

    .ds-slider::-webkit-slider-runnable-track {
      background: ButtonText;
    }

    .ds-slider::-moz-range-track,
    .ds-slider::-moz-range-progress {
      background: ButtonText;
    }

    .ds-slider::-webkit-slider-thumb,
    .ds-slider:not(:disabled, [aria-disabled="true"]):hover::-webkit-slider-thumb,
    .ds-slider:focus-visible::-webkit-slider-thumb,
    .ds-slider:not(:disabled, [aria-disabled="true"]):active::-webkit-slider-thumb {
      box-shadow: none;
    }

    .ds-slider::-moz-range-thumb,
    .ds-slider:not(:disabled, [aria-disabled="true"]):hover::-moz-range-thumb,
    .ds-slider:focus-visible::-moz-range-thumb,
    .ds-slider:not(:disabled, [aria-disabled="true"]):active::-moz-range-thumb {
      box-shadow: none;
    }

    /* One engine's pseudo-element per rule: a selector list with a pseudo-element
       an engine does not know is dropped as a whole. */
    .ds-slider:focus-visible::-webkit-slider-thumb {
      outline-color: CanvasText;
    }

    .ds-slider:focus-visible::-moz-range-thumb {
      outline-color: CanvasText;
    }

    .ds-slider:is(:disabled, [aria-disabled="true"]) {
      color: GrayText;
    }
  }

  /* Print: track and handle are backgrounds, which printing drops by
     default; the value stays visible. */
  @media print {
    .ds-slider {
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
  }
}

/* ── components/combobox.css ── */
/*
 * Combobox: a searchable list under an outlined field (ARIA combobox
 * pattern; the page filters and chooses, DS.overlay opens and closes). The
 * field keeps its look, a chevron at the end says there is a list, and the
 * list opens as a white surface on the popover elevation. Each entry shows
 * a name and, quieter, a line of meta. The active entry (keyboard or
 * pointer, data-active) gets the hover layer, the chosen one
 * (aria-selected) the tint and the label weight, never colour alone.
 *
 *   <div class="ds-combo">
 *     <input class="ds-input ds-combo__input" role="combobox" aria-expanded="false" aria-controls="list" aria-autocomplete="list" aria-activedescendant="opt-2">
 *     <button class="ds-combo__toggle" type="button" tabindex="-1" aria-label="Show list">(chevronDown)</button>
 *     <div class="ds-combo__popup" hidden data-state="open | closed" [data-placement="top"] [data-anchored]>
 *       <ul class="ds-combo__list" id="list" role="listbox">
 *         <li class="ds-combo__option" id="opt-2" role="option" aria-selected="true" data-active>
 *           <span class="ds-combo__name">Name</span><span class="ds-combo__meta">Meta</span>
 *       <p class="ds-combo__status" role="status">12 of 240</p>
 *
 * Select-only (.ds-combo--select: pick one of a few rich options, no
 * typing): the field is a button that shows the choice, its value and an
 * optional meta in one row (start-aligned, ellipsis); options may hold any
 * content. The keys (Enter, Space and the arrows open; type-ahead) are the
 * page's script, as for every combobox.
 *
 *   <div class="ds-combo ds-combo--select">
 *     <button class="ds-input ds-combo__input" type="button" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="plan-list" aria-labelledby="plan-label plan-value">
 *       <span class="ds-combo__value" id="plan-value">Standard</span><span class="ds-combo__meta">4 devices</span>
 *     </button>
 *     <button class="ds-combo__toggle" type="button" tabindex="-1" aria-label="Show list">(chevronDown)</button>
 *     <div class="ds-combo__popup" hidden> ... the same list ... </div>
 *
 * aria-disabled="true" on an option: the disabled ink, no active layer.
 * Without a toggle the field keeps only its own end padding.
 *
 *   DS.overlay.open(popup, { opener: input });   DS.overlay.close(popup);
 *
 * Motion (D17): the list grows out of the field. It fades in over
 * --ds-dur-fast while its clip opens from the field's edge (clip-path from
 * --ds-clip-from) and it grows from --ds-scale-popup at the field
 * (--ds-motion-origin), both over --ds-dur-medium on the spring;
 * DS.motion.originFrom() writes both hooks. The resting clip reaches
 * --ds-space-6 beyond the box, so the shadow is there from the first
 * frame. It leaves with a fade only (--ds-dur-instant). DS.overlay drives
 * data-state ("closed", flushed, "open"; "closed" to hide), so a list
 * closed mid-way turns back from where it is. Without data-state (a page
 * that only toggles hidden) the list plays ds-fade-in and ds-clip-in once
 * when it shows. Closed, the list takes no pointer.
 *
 * The chevron follows aria-expanded on the field (DS.overlay sets it) and
 * turns 180deg on the spring with the list (the disclosure rotation of
 * the motion rules).
 * The active entry's layer fades in over --ds-dur-micro and goes at once,
 * so a held arrow key leaves no trail; pointer and keyboard share
 * data-active, there is no separate hover layer.
 *
 * Placement: below the field. When it does not fit below and there is
 * more room above (in the viewport, narrowed by every clipping box around
 * the field), DS.overlay sets data-placement="top" and the list opens
 * above it, revealed from its bottom edge. It is never taller than the
 * room on its side (the list scrolls inside, the status line stays), so
 * it never covers the field being typed into. Inside a scrolling box (a
 * dialog's box, a sheet's body) the absolute list is clipped by that box
 * where the progressive path below is missing: there the room is that
 * box's, so keep a combobox off the very end of a dialog.
 *
 * Cut edges: a list that scrolls fades at each edge with more to show, a
 * mask --ds-scroll-fade long (default --ds-space-8; 0px turns it off) on
 * the registered numbers --ds-scroll-edge-start and --ds-scroll-edge-end
 * of scroller.css (the .ds-scroll-x model on the block axis; the
 * scrollbar strip, --ds-scrollbar-width, stays opaque). Where scroll
 * timelines exist ds-scroll-edge-start and ds-scroll-edge-end drive them
 * on the list's own scroll; elsewhere DS.overlay attaches
 * DS.dom.scrollEdges(list, { axis: 'block' }) while the list is open, and
 * data-ds-overflow-start / -end set them over --ds-dur-fast. The active
 * option, scrolled into view, lands clear of them (scroll-padding).
 *
 * Sizes: the field reads --ds-input-height (40px); the toggle is that size
 * and the field keeps room for it at its end; options are at least
 * --ds-combo-option-height (48px) tall. A .ds-progress on the chosen option
 * takes --ds-surface as its track (the tint behind it).
 *
 * The toggle is a round 40px target: a state layer in its colour (hover,
 * press in --ds-dur-micro), the ripple (js/ripple.js) and the press morph
 * of every pill (squarer to --ds-radius-action-pressed in --ds-dur-micro,
 * back on the spatial spring). It stays tabindex="-1", so it has no ring.
 * Options have no ripple: a press selects and closes the list.
 *
 * Forced colours: the list keeps an edge (its transparent outline), the
 * chosen option is SelectedItem / SelectedItemText, the active one has a
 * Highlight outline (CanvasText on the chosen one), a blocked one GrayText.
 *
 * Print: an open list is not printed.
 *
 * Progressive:
 * - scrollbar-color and scrollbar-width (a thin, quiet scrollbar in the
 *   list): without them the platform scrollbar shows.
 * - popover and CSS anchor positioning (anchor-name on the field,
 *   position-anchor, position-area, position-try-fallbacks and anchor-size()
 *   on the list, behind @supports; DS.overlay opens the list as
 *   popover="manual" in the top layer with data-anchored, as wide as the
 *   field, below it or flipped above, so a scrolling dialog never clips
 *   it): without them the list is absolute, and the script flips it up.
 * - scroll-driven animations (animation-timeline: scroll(), animation-range;
 *   the edge fades follow the scroll position): without them the
 *   data-ds-overflow-* attributes of DS.dom.scrollEdges() (attached by
 *   DS.overlay) set the fades, or none show.
 */

@layer ds.components {
  .ds-combo {
    position: relative;
  }

  .ds-combo__input {
    padding-inline-end: calc(var(--ds-space-1) + var(--ds-input-height, 40px));
    text-overflow: ellipsis;
  }

  /* Without a toggle there is nothing to keep room for. */
  .ds-combo:not(:has(.ds-combo__toggle)) > .ds-combo__input {
    padding-inline-end: var(--ds-space-3);
  }

  /* Select-only: a button that reads like the field, value and meta in one
     row. A field, not an action: no ripple, no press morph (like a select). */
  /* Its row never widens the field: what does not fit ends in an ellipsis. */
  .ds-combo--select {
    min-width: 0;
  }

  .ds-combo--select .ds-combo__input {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    min-width: 0;
    overflow: hidden;
    text-align: start;
    cursor: default;
    -webkit-tap-highlight-color: transparent;
  }

  .ds-combo__value {
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    color: var(--ds-text);
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .ds-combo__input > .ds-combo__meta {
    flex: 0 1 auto;
    min-width: 0;
  }

  /* A round 40px target in the field's end. Pressed it turns squarer and
     springs back like every pill; it stays out of the tab order (the field
     opens the list from the keyboard), so it has no ring. */
  .ds-combo__toggle {
    position: absolute;
    top: 0;
    inset-inline-end: var(--ds-space-1);
    isolation: isolate;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--ds-input-height, 40px);
    height: var(--ds-input-height, 40px);
    padding: 0;
    border: 0;
    border-radius: min(var(--ds-radius-action), calc(var(--ds-input-height, 40px) / 2));
    background: transparent;
    color: var(--ds-text-2);
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: border-radius var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  /* State layer in the content colour. */
  .ds-combo__toggle::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-combo__toggle:hover::before {
      opacity: var(--ds-state-hover);
    }
  }

  /* Press-in in --ds-dur-micro (.ds-pressed: js/ripple.js holds it). */
  .ds-combo__toggle:active::before,
  .ds-combo__toggle.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  .ds-combo__toggle:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"]),
  .ds-combo__toggle.ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {
    border-radius: min(var(--ds-radius-action-pressed), var(--ds-radius-action), calc(var(--ds-input-height, 40px) / 2));
    transition: border-radius var(--ds-dur-micro) var(--ds-ease-out);
  }

  /* The chevron turns over with the list (a disclosure rotation, D25):
     it follows aria-expanded on the field, on the spatial spring. */
  .ds-combo__toggle svg {
    transition: rotate var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  .ds-combo__input[aria-expanded="true"] ~ .ds-combo__toggle svg {
    rotate: 180deg;
  }

  .ds-combo__popup {
    position: absolute;
    top: calc(100% + var(--ds-space-1));
    inset-inline: 0;
    z-index: var(--ds-z-popover);
    /* A column: under a cap (the room on its side) the list scrolls inside
       it and the status line stays. */
    display: flex;
    flex-direction: column;
    overflow: hidden;
    border-radius: var(--ds-radius-xs);
    background: var(--ds-surface-popover);
    box-shadow: var(--ds-elev-popover);
    /* Invisible; forced colors paint it, so the list keeps an edge. */
    outline: 1px solid transparent;
    /* Grows at the field (originFrom() writes the origin); its clip rests
       --ds-space-6 beyond the box, room for the shadow. */
    transform-origin: var(--ds-motion-origin, 50% 0%);
    clip-path: inset(calc(-1 * var(--ds-space-6)));
  }

  /* Above the field: it is revealed from its bottom edge. */
  .ds-combo__popup[data-placement="top"] {
    top: auto;
    bottom: calc(100% + var(--ds-space-1));
    transform-origin: var(--ds-motion-origin, 50% 100%);
  }

  /* Shown by hand (no data-state): the reveal plays once. */
  .ds-combo__popup:not([data-state]) {
    animation:
      ds-fade-in var(--ds-dur-fast) var(--ds-ease-in-out) backwards,
      ds-clip-in var(--ds-dur-medium) var(--ds-ease-spatial) backwards;
  }

  /* Driven by DS.overlay: transitions keyed on data-state, so a list
     closed mid-way continues from where it is. */
  .ds-combo__popup[data-state] {
    animation: none;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      scale var(--ds-dur-medium) var(--ds-ease-spatial),
      clip-path var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  /* The exit only fades, in --ds-dur-instant; scale and clip go back to
     their start once the list is invisible, ready for the next entrance. */
  .ds-combo__popup[data-state="closed"] {
    opacity: 0;
    scale: var(--ds-motion-scale, var(--ds-scale-popup));
    clip-path: var(--ds-clip-from, inset(0 0 100% 0));
    pointer-events: none;
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in-out),
      scale 0ms var(--ds-ease-in-out) var(--ds-dur-instant),
      clip-path 0ms var(--ds-ease-in-out) var(--ds-dur-instant);
  }

  .ds-combo__popup[hidden] {
    display: none;
  }

  /* Anchor positioning: in the top layer (popover="manual"), as wide as
     the field, below it or above it (data-placement="top", the side
     DS.overlay chose; the margins flip with it), never taller than the
     room on that side: its position-area is its containing block, so the
     cap follows the field while a dialog scrolls. The UA box of a popover
     (inset 0, auto margins, a border, padding, its own colour) goes. */
  @supports (position-area: block-end) and (width: anchor-size(width)) {
    .ds-combo__popup[data-anchored] {
      position: fixed;
      inset: auto;
      width: anchor-size(width);
      height: auto;
      max-height: calc(100% - 2 * var(--ds-space-1));
      margin: var(--ds-space-1) 0;
      padding: 0;
      border: 0;
      color: inherit;
      position-area: block-end span-inline-end;
      position-try-fallbacks: flip-block;
    }

    .ds-combo__popup[data-anchored][data-placement="top"] {
      position-area: block-start span-inline-end;
    }
  }

  .ds-combo__list {
    /* The offset parent of the options (scrolling to the active one). */
    position: relative;
    flex: 0 1 auto;
    min-height: 0;
    max-height: 320px;
    margin: 0;
    padding: var(--ds-space-2) 0;
    overflow-y: auto;
    list-style: none;
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
    /* The active option, scrolled into view, lands clear of the fades. */
    scroll-padding-block: var(--ds-scroll-fade, var(--ds-space-8));
    /* The cut edges fade (scroller.css, on the block axis): a gradient
       whose ends are the registered numbers times --ds-scroll-fade, over
       the content; the scrollbar strip at the end (--ds-scrollbar-width)
       stays opaque. */
    -webkit-mask-image:
      linear-gradient(to bottom, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-scroll-fade, var(--ds-space-8))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-space-8))), transparent),
      linear-gradient(#000, #000);
    mask-image:
      linear-gradient(to bottom, transparent, #000 calc(var(--ds-scroll-edge-start) * var(--ds-scroll-fade, var(--ds-space-8))), #000 calc(100% - var(--ds-scroll-edge-end) * var(--ds-scroll-fade, var(--ds-space-8))), transparent),
      linear-gradient(#000, #000);
    -webkit-mask-size: calc(100% - var(--ds-scrollbar-width, 0px)) 100%, var(--ds-scrollbar-width, 0px) 100%;
    mask-size: calc(100% - var(--ds-scrollbar-width, 0px)) 100%, var(--ds-scrollbar-width, 0px) 100%;
    -webkit-mask-position: 0 0, 100% 0;
    mask-position: 0 0, 100% 0;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    transition:
      --ds-scroll-edge-start var(--ds-dur-fast) var(--ds-ease-in-out),
      --ds-scroll-edge-end var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* The fallback: DS.dom.scrollEdges() (attached by DS.overlay while the
     list is open) says which end has more. Where a scroll timeline drives
     the numbers, the animation wins over these. */
  .ds-combo__list[data-ds-overflow-start] {
    --ds-scroll-edge-start: 1;
  }

  .ds-combo__list[data-ds-overflow-end] {
    --ds-scroll-edge-end: 1;
  }

  /* Scroll-linked: the shorthand first (it resets animation-timeline);
     one timeline for both animations. */
  @supports (animation-timeline: scroll()) {
    .ds-combo__list {
      animation: ds-scroll-edge-start linear both, ds-scroll-edge-end linear both;
      animation-timeline: scroll(self block);
      animation-range: 0 var(--ds-space-8), calc(100% - var(--ds-space-8)) 100%;
    }
  }

  .ds-combo__option {
    position: relative;
    display: grid;
    align-content: center;
    gap: 2px;
    min-height: var(--ds-combo-option-height, 48px);
    padding: var(--ds-space-2) var(--ds-space-4);
    cursor: pointer;
  }

  /* Cannot be chosen: the disabled ink, and no active layer. */
  .ds-combo__option[aria-disabled="true"] {
    color: var(--ds-disabled-ink, var(--ds-text));
    cursor: default;
  }

  .ds-combo__option[aria-disabled="true"] :is(.ds-combo__name, .ds-combo__meta) {
    color: inherit;
  }

  .ds-combo__option[aria-disabled="true"][data-active]::before {
    opacity: 0;
  }

  /* A share on the chosen option keeps a track that shows on the tint. */
  .ds-combo__option[aria-selected="true"] .ds-progress {
    --ds-progress-track: var(--ds-surface);
  }

  /* State layer of the active entry, as in the drawer: it fades in over
     --ds-dur-micro and goes at once, so a held arrow key leaves no trail
     (keyboard and pointer share data-active; no separate hover layer). */
  .ds-combo__option::before {
    content: '';
    position: absolute;
    inset: 0;
    background: var(--ds-text);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-micro) var(--ds-ease-in-out);
  }

  .ds-combo__option:not([data-active])::before {
    transition-duration: 0ms;
  }

  .ds-combo__option[data-active]::before {
    opacity: var(--ds-state-hover);
  }

  .ds-combo__option[aria-selected="true"] {
    background: var(--ds-accent);
  }

  .ds-combo__name {
    position: relative;
    overflow: hidden;
    color: var(--ds-text);
    font: var(--ds-font-body);
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .ds-combo__option[aria-selected="true"] .ds-combo__name {
    color: var(--ds-on-accent);
    font: var(--ds-font-label);
  }

  .ds-combo__meta {
    position: relative;
    overflow: hidden;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .ds-combo__option[aria-selected="true"] .ds-combo__meta {
    color: var(--ds-on-accent);
  }

  .ds-combo__status {
    flex: none;
    margin: 0;
    padding: var(--ds-space-2) var(--ds-space-4);
    border-top: 1px solid var(--ds-line);
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
  }

  .ds-combo__list[hidden] + .ds-combo__status {
    border-top: 0;
  }

  @media (forced-colors: active) {
    .ds-combo__option[data-active] {
      outline: 2px solid Highlight;
      outline-offset: -2px;
    }

    /* The chosen option in the system's selection colours (the tint is
       gone); name and meta follow it. */
    .ds-combo__option[aria-selected="true"] {
      forced-color-adjust: none;
      background: SelectedItem;
      color: SelectedItemText;
    }

    .ds-combo__option[aria-selected="true"] .ds-combo__name,
    .ds-combo__option[aria-selected="true"] .ds-combo__meta {
      color: inherit;
    }

    /* The active option on the chosen one: a ring of the text colour
       inside the fill (Highlight would vanish on SelectedItem). */
    .ds-combo__option[aria-selected="true"][data-active] {
      outline-color: CanvasText;
    }

    /* Blocked is one system colour (D8). */
    .ds-combo__option[aria-disabled="true"] {
      color: GrayText;
    }
  }

  @media print {
    .ds-combo__popup {
      display: none;
    }
  }
}

/* ── components/menu.css ── */
/*
 * Menu (Material 3): actions for one item or the overflow of a toolbar, on
 * the popover elevation, opened from a button. Each item is an icon and a
 * label; a checkable item shows a check when checked (aria-checked, never
 * colour alone); a divider separates groups.
 *
 *   <button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="More actions"
 *           aria-haspopup="menu" aria-expanded="false" aria-controls="row-menu">(moreVertical)</button>
 *   <div class="ds-menu" role="menu" id="row-menu" aria-label="Actions" hidden data-state="open | closed" [data-placement="top"] [data-anchored]>
 *     <button class="ds-menu__item" role="menuitem" type="button">(pencil) <span class="ds-menu__label">Edit</span></button>
 *     <button class="ds-menu__item" role="menuitemcheckbox" type="button" aria-checked="true">(check, .ds-menu__check) <span class="ds-menu__label">Pinned</span></button>
 *     <div class="ds-menu__divider" role="separator"></div>
 *     <button class="ds-menu__item" role="menuitem" type="button" aria-disabled="true">(trash) <span class="ds-menu__label">Delete</span></button>
 *   </div>
 *
 * Groups of radio items under a heading, and a meta at an item's end (a
 * shortcut in a .ds-kbd, or "On" / "Off" of a menuitemcheckbox):
 *
 *   <div role="group" aria-labelledby="sort-h">
 *     <p class="ds-menu__heading" id="sort-h">Sort by</p>
 *     <button class="ds-menu__item" role="menuitemradio" type="button" aria-checked="true">(check, .ds-menu__check) <span class="ds-menu__label">Name</span></button>
 *     <button class="ds-menu__item" role="menuitemradio" type="button" aria-checked="false">(check, .ds-menu__check) <span class="ds-menu__label">Last seen</span></button>
 *   </div>
 *   <button class="ds-menu__item" role="menuitem" type="button">(copy) <span class="ds-menu__label">Copy link</span><span class="ds-menu__meta">Ctrl C</span></button>
 *
 *   DS.overlay.open(menu, { opener: button });   DS.overlay.close(menu);
 *
 * DS.overlay (js/overlay.js) places it below the trigger, aligned with its
 * start edge (towards the start edge without room; above it when it does
 * not fit below and there is more room above; never taller than the room
 * on its side, so it never covers its trigger: it scrolls inside),
 * focuses the first enabled item (the last one when opened from ArrowUp),
 * moves focus with ArrowUp, ArrowDown, Home and End (wrapping; a heading is
 * no item, so they skip it) and to the next item whose label starts with
 * the typed letters (type-ahead), closes on
 * Escape (focus back on the trigger), on Tab, after an item's click
 * handlers ran, and on a pointerdown outside (focus stays where the pointer
 * put it), and sets aria-expanded on the trigger.
 *
 * Placement API: --ds-menu-x and --ds-menu-y (left and top, px, written by
 * DS.overlay where anchor positioning is missing).
 *
 * Motion (D17): as the combobox list, it grows out of its trigger. It fades
 * in over --ds-dur-fast while its clip opens from the trigger's edge
 * (--ds-clip-from) and it grows from --ds-scale-popup at the trigger
 * (--ds-motion-origin), both over --ds-dur-medium on the spring; the
 * resting clip reaches --ds-space-6 beyond the box, so the shadow is there
 * from the first frame. It leaves with a fade only (--ds-dur-instant).
 * Transitions keyed on data-state (DS.overlay), so a menu closed mid-way
 * turns back from where it is; without data-state (a page that toggles
 * hidden by hand) it plays ds-fade-in and ds-clip-in once.
 *
 * Items: a 48px row (--ds-menu-item-height) in the body role, a state layer in the content colour
 * (hover, press in --ds-dur-micro, the focus layer and an inset ring that
 * snaps in), the ripple (js/ripple.js) and no press morph (a menu row is
 * not a pill). aria-disabled items stay focusable (their name is read) but
 * show the disabled ink and take no press.
 *
 * menuitemradio and menuitemcheckbox items show .ds-menu__check only when
 * aria-checked="true" (the page flips it; one radio of a group at a time).
 * .ds-menu__heading names a group (caption type, not focusable);
 * .ds-menu__meta sits at an item's end (meta type, tabular).
 *
 * Cut edges: a menu capped by the room on its side scrolls, and each edge
 * with more to show fades into the menu's surface: a sticky shade
 * (::before, ::after) in --ds-surface-popover, --ds-scroll-fade long
 * (default --ds-space-8; 0px turns it off), whose opacity is the
 * registered number --ds-scroll-edge-start or --ds-scroll-edge-end of
 * scroller.css. Not a mask: the menu is its own surface, and a mask would
 * cut its shadow. Where scroll timelines exist ds-scroll-edge-start and
 * ds-scroll-edge-end drive the numbers on the menu's scroll; elsewhere
 * DS.overlay attaches DS.dom.scrollEdges(menu, { axis: 'block' }) while it
 * is open, and data-ds-overflow-start / -end set them over --ds-dur-fast.
 * Focus moved by the keys stays clear of the shades (scroll-padding).
 *
 * Forced colours: the menu keeps an edge (its transparent outline), the
 * focused item its ring, a blocked item GrayText; the edge shades go.
 *
 * Print: an open menu is not printed.
 *
 * Progressive:
 * - popover and CSS anchor positioning (anchor-name on the trigger,
 *   position-anchor, position-area and position-try-fallbacks on the menu,
 *   behind @supports; DS.overlay opens the menu as popover="manual" in the
 *   top layer with data-anchored, below the trigger, flipped above or
 *   towards the start edge when there is no room): without them the
 *   script writes --ds-menu-x/-y and sets data-placement="top" above.
 * - scrollbar-color and scrollbar-width (a thin scrollbar in a long menu):
 *   without them the platform scrollbar shows.
 * - scroll-driven animations (animation-timeline: scroll(), animation-range;
 *   the edge shades follow the scroll position): without them the
 *   data-ds-overflow-* attributes of DS.dom.scrollEdges() (attached by
 *   DS.overlay) set the shades, or none show.
 */

@layer ds.components {
  /* Fixed, so no scrolling or clipping box cuts it off. inset, margin and
     border double as the reset of the UA box of a popover. Physical on
     purpose (D9): --ds-menu-x is a script coordinate from the left. */
  .ds-menu {
    position: fixed;
    inset: var(--ds-menu-y, 0px) auto auto var(--ds-menu-x, 0px);
    z-index: var(--ds-z-popover);
    display: flex;
    flex-direction: column;
    box-sizing: border-box;
    min-width: 112px;
    max-width: min(280px, calc(100vw - 2 * var(--ds-space-2)));
    max-height: calc(100vh - 2 * var(--ds-space-2));
    margin: 0;
    padding: var(--ds-space-2) 0;
    overflow-y: auto;
    border: 0;
    border-radius: var(--ds-radius-xs);
    background: var(--ds-surface-popover);
    color: var(--ds-text);
    box-shadow: var(--ds-elev-popover);
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
    /* Focus moved by the keys lands clear of the edge shades. */
    scroll-padding-block: var(--ds-scroll-fade, var(--ds-space-8));
    /* Invisible; forced colors paint it, so the menu keeps an edge. */
    outline: 1px solid transparent;
    /* Grows at its trigger (originFrom() writes the origin); its clip rests
       --ds-space-6 beyond the box, room for the shadow. */
    transform-origin: var(--ds-motion-origin, 0% 0%);
    clip-path: inset(calc(-1 * var(--ds-space-6)));
  }

  /* Above the trigger: it is revealed from its bottom edge. */
  .ds-menu[data-placement="top"] {
    transform-origin: var(--ds-motion-origin, 0% 100%);
  }

  /* Shown by hand (no data-state): the reveal plays once. */
  .ds-menu:not([data-state]) {
    animation:
      ds-fade-in var(--ds-dur-fast) var(--ds-ease-in-out) backwards,
      ds-clip-in var(--ds-dur-medium) var(--ds-ease-spatial) backwards;
  }

  /* Driven by DS.overlay: transitions keyed on data-state, so a menu closed
     mid-way continues from where it is. */
  .ds-menu[data-state] {
    animation: none;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      scale var(--ds-dur-medium) var(--ds-ease-spatial),
      clip-path var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  /* The exit only fades, in --ds-dur-instant; scale and clip go back to
     their start once the menu is invisible. Closed, it takes no pointer. */
  .ds-menu[data-state="closed"] {
    opacity: 0;
    scale: var(--ds-motion-scale, var(--ds-scale-popup));
    clip-path: var(--ds-clip-from, inset(0 0 100% 0));
    pointer-events: none;
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in-out),
      scale 0ms var(--ds-ease-in-out) var(--ds-dur-instant),
      clip-path 0ms var(--ds-ease-in-out) var(--ds-dur-instant);
  }

  .ds-menu[hidden] {
    display: none;
  }

  /* Anchor positioning: in the top layer (popover="manual"), below the
     trigger from its start edge or above it (data-placement="top", the
     side DS.overlay chose), towards the start edge without room (the
     margins flip with it); never taller than the room on its side, its
     position-area being its containing block. */
  @supports (position-area: block-end) {
    .ds-menu[data-anchored] {
      inset: auto;
      max-height: calc(100% - 2 * var(--ds-space-1));
      margin: var(--ds-space-1) 0;
      position-area: block-end span-inline-end;
      position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
    }

    .ds-menu[data-anchored][data-placement="top"] {
      position-area: block-start span-inline-end;
    }
  }

  .ds-menu__item {
    position: relative;
    display: flex;
    flex-shrink: 0;
    align-items: center;
    gap: var(--ds-space-3);
    width: 100%;
    min-height: var(--ds-menu-item-height, 48px);
    margin: 0;
    padding: 0 var(--ds-space-3);
    border: 0;
    border-radius: 0;
    background: transparent;
    color: var(--ds-text);
    font: var(--ds-font-body);
    text-align: start;
    text-decoration: none;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* An inset ring (the scrolling menu clips anything outside): it rests
       on the edge and snaps inward. */
    outline-offset: 0;
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-menu__item svg {
    flex-shrink: 0;
    color: var(--ds-text-2);
  }

  .ds-menu__label {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* The check of a checkable item keeps its room when unchecked, so the
     labels line up; it shows only with aria-checked="true". */
  .ds-menu__item:not([aria-checked="true"]) .ds-menu__check {
    visibility: hidden;
  }

  /* State layer in the content colour. */
  .ds-menu__item::before {
    content: '';
    position: absolute;
    inset: 0;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-menu__item:hover::before {
      opacity: var(--ds-state-hover);
    }
  }

  .ds-menu__item:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  .ds-menu__item:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  /* Press-in in --ds-dur-micro (.ds-pressed: js/ripple.js holds it). */
  .ds-menu__item:active::before,
  .ds-menu__item.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  /* Cannot be chosen: the disabled ink, no hover and no press (no ripple
     either); the focus layer stays, so keyboard users find it. */
  .ds-menu__item[aria-disabled="true"],
  .ds-menu__item:disabled {
    opacity: var(--ds-disabled-opacity, var(--ds-state-disabled));
    color: var(--ds-disabled-ink, var(--ds-text));
    cursor: default;
  }

  .ds-menu__item[aria-disabled="true"] svg,
  .ds-menu__item:disabled svg {
    color: inherit;
  }

  @media (hover: hover) {
    .ds-menu__item[aria-disabled="true"]:hover::before {
      opacity: 0;
    }
  }

  .ds-menu__item[aria-disabled="true"]:is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  .ds-menu__item[aria-disabled="true"]:focus-visible::before {
    opacity: var(--ds-state-focus);
  }

  .ds-menu__divider {
    flex-shrink: 0;
    height: 1px;
    margin: var(--ds-space-2) 0;
    background: var(--ds-line);
  }

  /* A group of items (role="group") is a column like the menu itself. */
  .ds-menu [role="group"] {
    display: flex;
    flex-direction: column;
    flex-shrink: 0;
  }

  /* The name of a group: not focusable, so the arrow keys pass it by. */
  .ds-menu__heading {
    flex-shrink: 0;
    margin: 0;
    padding: var(--ds-space-2) var(--ds-space-4) var(--ds-space-1);
    color: var(--ds-text-2);
    font: var(--ds-font-caption);
  }

  /* At an item's end: a shortcut (.ds-kbd) or the state word of a checkbox item. */
  .ds-menu__meta {
    flex-shrink: 0;
    margin-inline-start: auto;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
  }

  .ds-menu__label + .ds-menu__meta {
    margin-inline-start: var(--ds-space-3);
  }

  .ds-menu__item[aria-disabled="true"] .ds-menu__meta,
  .ds-menu__item:disabled .ds-menu__meta {
    color: inherit;
  }

  /* The cut edges of a long menu. The menu is its own surface (a mask
     would cut its shadow away), so a shade of that surface lies over each
     edge that has more to show, --ds-scroll-fade long, above the items:
     sticky at the edge, taking no room (the negative margin). Its number
     (--ds-scroll-edge-start or -end, registered in scroller.css) is 0 at
     rest; the menu's scroll timeline drives it where it exists, else the
     data-ds-overflow-* attributes of DS.dom.scrollEdges(), which
     DS.overlay attaches while the menu is open. */
  .ds-menu::before,
  .ds-menu::after {
    content: '';
    position: sticky;
    z-index: var(--ds-z-raised);
    flex: none;
    height: var(--ds-scroll-fade, var(--ds-space-8));
    pointer-events: none;
    transition:
      --ds-scroll-edge-start var(--ds-dur-fast) var(--ds-ease-in-out),
      --ds-scroll-edge-end var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-menu::before {
    inset-block-start: 0;
    margin-block-end: calc(-1 * var(--ds-scroll-fade, var(--ds-space-8)));
    background: linear-gradient(var(--ds-surface-popover), transparent);
    opacity: var(--ds-scroll-edge-start);
  }

  .ds-menu::after {
    inset-block-end: 0;
    margin-block-start: calc(-1 * var(--ds-scroll-fade, var(--ds-space-8)));
    background: linear-gradient(transparent, var(--ds-surface-popover));
    opacity: var(--ds-scroll-edge-end);
  }

  .ds-menu[data-ds-overflow-start]::before {
    --ds-scroll-edge-start: 1;
  }

  .ds-menu[data-ds-overflow-end]::after {
    --ds-scroll-edge-end: 1;
  }

  /* Scroll-linked, on the menu's scroll (the nearest scroller of its own
     shades): the shorthand first (it resets animation-timeline). */
  @supports (animation-timeline: scroll()) {
    .ds-menu::before {
      animation: ds-scroll-edge-start linear both;
      animation-timeline: scroll(nearest block);
      animation-range: 0 var(--ds-space-8);
    }

    .ds-menu::after {
      animation: ds-scroll-edge-end linear both;
      animation-timeline: scroll(nearest block);
      animation-range: calc(100% - var(--ds-space-8)) 100%;
    }
  }

  /* Blocked is one system colour (D8). The shades go: forced colours
     would keep their gradient in the theme's surface colour. */
  @media (forced-colors: active) {
    .ds-menu__item[aria-disabled="true"],
    .ds-menu__item:disabled {
      color: GrayText;
    }

    .ds-menu::before,
    .ds-menu::after {
      display: none;
    }
  }

  @media print {
    .ds-menu {
      display: none;
    }
  }
}

/* ── components/progress.css ── */
/*
 * Linear progress (Material 3, 2024 look): a primary bar that ends at the
 * share, a gap, then the remaining track in --ds-accent with a stop dot at
 * its end. The bar is wavy and its wave travels (M3 Expressive); it turns
 * flat once the job is complete. An unknown share runs the two-segment
 * choreography of M3 across the track.
 *
 *   <div class="ds-progress [ds-progress--indeterminate | --waiting | --flat]" style="--ds-progress: .4">
 *     <div class="ds-progress__track" role="progressbar" aria-valuenow="40" aria-valuemin="0" aria-valuemax="100" aria-label="Import">
 *       <div class="ds-progress__bar"></div>
 *     </div>
 *     <p class="ds-progress__label">40%</p>
 *     <p class="ds-progress__detail">812 of 2,030 rows</p>
 *
 *   .ds-progress--indeterminate  two segments run across (ds-progress-head,
 *                                ds-progress-tail over --ds-loop-indeterminate)
 *   .ds-progress--waiting        hides the track (only the label shows);
 *                                the track keeps its 4px, so the label
 *                                stays put, and fades in when the
 *                                modifier drops
 *   .ds-progress--flat           the classic look: no gap, no stop dot, no
 *                                wave. Use it in dense table cells: the wave
 *                                reaches 3px above and below the 4px track,
 *                                and an ancestor with overflow: hidden closer
 *                                than that clips it.
 *
 * The share: set --ds-progress (0 ... 1; registered as a number) on the
 * .ds-progress element, with el.style.setProperty (a strict CSP allows it).
 * It glides there on --ds-ease-value over --ds-dur-glide, and the bar, the
 * gap, the remaining track and a nested .ds-spinner--determinate read the
 * inherited value, so they land together, and on the same frame as a
 * DS.motion.countUp() figure set in the same task. The bar is cut by
 * clip-path at the share (never scaled), so its round cap keeps its radius
 * at any share; the clip repaints every frame of a glide, which is fine for
 * a 4px bar.
 *
 * Layers: the track paints the remaining track and the stop dot (background
 * layers that start after the share and the gap); .ds-progress__bar is the
 * box of the bar, 3px taller than the track on both sides, cut at the share
 * (the clip's round end is the cap); its ::before is the wave (the primary
 * colour through the --ds-progress-wave mask, which travels one
 * --ds-progress-wave-length per --ds-loop-wave and stops at completion) and
 * its ::after the flat 4px fill, which cross-fades in over --ds-dur-medium
 * when aria-valuenow reaches 100 (the documented aria-valuemax="100") and
 * in --flat. The track's ::before and ::after are the two indeterminate
 * segments, on the same wave.
 *
 * Short bars are flat (M3 Expressive flattens the wave at small shares): a
 * bar shorter than a wavelength would show a loose piece of the sine that
 * bobs above and below the track as the wave travels. The wave and the
 * flat fill cross-fade on the (gliding) share itself: flat up to 6%, wavy
 * from 11% (--ds-progress-wave-start, --ds-progress-wave-ramp), so the
 * crossover follows the glide on its own.
 *
 * Handoff: when --indeterminate drops, the segments stop where they are and
 * fade out (--ds-dur-fast, --ds-ease-in), the bar fades in (--ds-dur-fast,
 * --ds-ease-in-out) and --ds-progress glides from its previous value (0
 * when it was never set): the bar grows to its share and never sweeps
 * across or shrinks from 100%. Set the first share in the same task that
 * drops the modifier; a share set earlier, while indeterminate, simply
 * appears at that share.
 *
 * Accessibility: while --indeterminate or --waiting is on, remove
 * aria-valuenow and set aria-valuetext ("Preparing", "Waiting"); with the
 * first share, set aria-valuenow again and remove aria-valuetext.
 *
 * Hooks: --ds-progress-gap (4px between the bar and the remaining track),
 * --ds-progress-stop (the stop dot, --ds-primary), --ds-progress-track (the
 * remaining track, --ds-accent).
 *
 * Print: the bar keeps its colours (print-color-adjust), so the share
 * shows on paper.
 */

@layer ds.components {
  .ds-progress {
    transition: --ds-progress var(--ds-dur-glide) var(--ds-ease-value);
  }

  /*
   * The remaining track starts the gap after the share. The gap opens with
   * the first percent (an empty bar shows the whole track); a disc of the
   * track's height rounds its start. The stop dot is the top layer, under
   * the bar, which paints over it once it arrives. The loops of the
   * segments are declared here, paused, so dropping --indeterminate stops
   * them where they are while they fade out.
   */
  /* The gradients run left to right, like the bar's clip (LTR only, D9). */
  .ds-progress__track {
    position: relative;
    height: 4px;
    overflow: visible;
    isolation: isolate;
    border-radius: var(--ds-radius-action);
    background:
      radial-gradient(circle 2px at calc(100% - 2px) 50%, var(--ds-progress-stop, var(--ds-primary)) calc(100% - .5px), transparent),
      radial-gradient(circle 2px at calc(var(--ds-progress) * 100% + var(--ds-progress-gap, 4px) * min(1, var(--ds-progress) * 100) + 2px) 50%, var(--ds-progress-track, var(--ds-accent)) calc(100% - .5px), transparent),
      linear-gradient(to right, transparent calc(var(--ds-progress) * 100% + var(--ds-progress-gap, 4px) * min(1, var(--ds-progress) * 100) + 2px), var(--ds-progress-track, var(--ds-accent)) 0);
    animation:
      ds-progress-head var(--ds-loop-indeterminate) linear infinite paused,
      ds-progress-tail var(--ds-loop-indeterminate) linear infinite paused;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* The bar's box: the track's width, 3px taller on both sides for the
     wave, cut at the share with a round end. */
  .ds-progress__bar {
    position: absolute;
    inset: -3px 0;
    clip-path: inset(0 calc((1 - var(--ds-progress)) * 100%) 0 0 round var(--ds-radius-action));
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-progress__bar::before,
  .ds-progress__bar::after {
    content: '';
    position: absolute;
    inset-inline: 0;
    background: var(--ds-primary);
    transition: opacity var(--ds-dur-medium) var(--ds-ease-in-out);
  }

  /* The wave, travelling; it fades in between 6% and 11% (short bars are
     flat). Opacity clamps the ramp to 0 ... 1 by itself. */
  .ds-progress__bar::before {
    top: 0;
    bottom: 0;
    opacity: calc(var(--ds-progress) / var(--ds-progress-wave-ramp) - var(--ds-progress-wave-start) / var(--ds-progress-wave-ramp));
    -webkit-mask-image: var(--ds-progress-wave);
    mask-image: var(--ds-progress-wave);
    -webkit-mask-size: var(--ds-progress-wave-length) 100%;
    mask-size: var(--ds-progress-wave-length) 100%;
    -webkit-mask-repeat: repeat-x;
    mask-repeat: repeat-x;
    animation: ds-progress-wave var(--ds-loop-wave) linear infinite;
  }

  /* The flat fill, with its own clip: the box's clip rounds 5px, the 4px
     fill needs its own 2px cap. */
  .ds-progress__bar::after {
    top: 3px;
    height: 4px;
    border-radius: var(--ds-radius-action);
    clip-path: inset(0 calc((1 - var(--ds-progress)) * 100%) 0 0 round var(--ds-radius-action));
    opacity: calc(1 - var(--ds-progress) / var(--ds-progress-wave-ramp) + var(--ds-progress-wave-start) / var(--ds-progress-wave-ramp));
  }

  /* Complete: the wave stops where it is and fades, the flat fill takes
     over. */
  .ds-progress:has(> .ds-progress__track[aria-valuenow="100"]) .ds-progress__bar::before {
    opacity: 0;
    animation-play-state: paused;
  }

  .ds-progress:has(> .ds-progress__track[aria-valuenow="100"]) .ds-progress__bar::after {
    opacity: 1;
  }

  /* The classic look: the whole track behind a flat bar. */
  .ds-progress--flat .ds-progress__track {
    background: var(--ds-progress-track, var(--ds-accent));
  }

  .ds-progress--flat .ds-progress__bar::before {
    display: none;
  }

  .ds-progress--flat .ds-progress__bar::after {
    opacity: 1;
    transition: none;
  }

  /*
   * The two indeterminate segments: each the full width of the track, 3px
   * taller for the wave, cut between its tail and its head (the track's
   * loops animate the four edges; they inherit). Out of that state they
   * fade out on the way (the handoff) and stay at opacity 0, their wave
   * paused.
   */
  .ds-progress__track::before,
  .ds-progress__track::after {
    content: '';
    position: absolute;
    inset: -3px 0;
    z-index: -1;
    background: var(--ds-primary);
    -webkit-mask-image: var(--ds-progress-wave);
    mask-image: var(--ds-progress-wave);
    -webkit-mask-size: var(--ds-progress-wave-length) 100%;
    mask-size: var(--ds-progress-wave-length) 100%;
    -webkit-mask-repeat: repeat-x;
    mask-repeat: repeat-x;
    opacity: 0;
    animation: ds-progress-wave var(--ds-loop-wave) linear infinite paused;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in);
  }

  .ds-progress__track::before {
    clip-path: inset(0 calc(100% - var(--ds-progress-head-1)) 0 var(--ds-progress-tail-1) round var(--ds-radius-action));
  }

  .ds-progress__track::after {
    clip-path: inset(0 calc(100% - var(--ds-progress-head-2)) 0 var(--ds-progress-tail-2) round var(--ds-radius-action));
  }

  /* Indeterminate: the whole track, no stop dot, the segments running;
     the bar waits unseen and still. */
  .ds-progress--indeterminate .ds-progress__track {
    background: var(--ds-progress-track, var(--ds-accent));
    animation-play-state: running;
  }

  .ds-progress--indeterminate .ds-progress__track::before,
  .ds-progress--indeterminate .ds-progress__track::after {
    opacity: 1;
    animation-play-state: running;
    transition: none;
  }

  .ds-progress--indeterminate .ds-progress__bar {
    opacity: 0;
    transition: none;
  }

  .ds-progress--indeterminate .ds-progress__bar::before {
    animation-play-state: paused;
  }

  .ds-progress--flat.ds-progress--indeterminate .ds-progress__track::before,
  .ds-progress--flat.ds-progress--indeterminate .ds-progress__track::after {
    -webkit-mask-image: none;
    mask-image: none;
    inset: 0;
    animation: none;
  }

  /* Waiting: the track keeps its 4px (so the label never jumps when the
     wait ends) but is hidden, its loops held; it fades in when the
     modifier drops. */
  .ds-progress--waiting .ds-progress__track {
    visibility: hidden;
    opacity: 0;
    animation-play-state: paused;
  }

  .ds-progress--waiting .ds-progress__track::before,
  .ds-progress--waiting .ds-progress__track::after,
  .ds-progress--waiting .ds-progress__bar::before {
    animation-play-state: paused;
  }

  .ds-progress__label {
    margin: var(--ds-space-2) 0 0;
    color: var(--ds-text);
    font: var(--ds-font-body);
    font-variant-numeric: tabular-nums;
  }

  .ds-progress__detail {
    margin: 0;
    overflow: hidden;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* Forced colors drop the fills and the background images: the parts
     paint in system colours (the bar, its segments and the dot Highlight,
     the remaining track GrayText), and the track keeps an edge. */
  @media (forced-colors: active) {
    .ds-progress {
      --ds-progress-stop: Highlight;
      --ds-progress-track: GrayText;
    }

    .ds-progress__track {
      forced-color-adjust: none;
      outline: 1px solid CanvasText;
    }

    .ds-progress__bar::before,
    .ds-progress__bar::after,
    .ds-progress__track::before,
    .ds-progress__track::after {
      background: Highlight;
    }
  }

  /* Print: the bar and the track are backgrounds, which printing drops by
     default; the share stays visible. */
  @media print {
    .ds-progress {
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
  }
}

/* ── components/chart.css ── */
/*
 * Charts: a meter (a share as a thin bar), columns (counts per category,
 * values over time) and a legend, drawn from the chart roles
 * (--ds-chart-1 ... --ds-chart-4, --ds-chart-neutral, --ds-chart-track,
 * --ds-chart-warning, --ds-chart-error). Lines, areas, scatter plots and
 * stacked bars are the project's own, with the same roles and --ds-share.
 *
 *   <span class="ds-meter" role="meter" aria-label="Disk" aria-valuemin="0" aria-valuemax="100"
 *         aria-valuenow="78" aria-valuetext="78% used"></span>     (the page sets --ds-share: .78)
 *
 *   <figure class="ds-columns">
 *     <figcaption class="ds-sr">Devices that checked in, per day</figcaption>
 *     <ol class="ds-columns__plot">
 *       <li class="ds-columns__col">                               (the page sets --ds-share on each column)
 *         <span class="ds-columns__value">42</span>
 *         <span class="ds-columns__fill" aria-hidden="true"></span>
 *         <span class="ds-columns__label">Mon</span>
 *       </li>
 *     </ol>
 *   </figure>
 *
 *   <ul class="ds-legend"><li><span class="ds-legend__key"></span>Opened</li><li><span class="ds-legend__key ds-legend__key--s2"></span>Closed</li></ul>
 *
 * Share: --ds-share (0 ... 1, registered in tokens.css), set by the page
 * on the mark (the meter, each column) with style.setProperty. A fill is
 * clipped at the share, never scaled, so its round data end keeps its
 * shape; a later value glides there on --ds-ease-value over
 * --ds-dur-glide, and a column's value rides on the top of its fill. The
 * first render fills from zero with .ds-enter--fill
 * (DS.motion.enterAll(marks, 'fill', { stagger: false })).
 *
 *   .ds-meter--neutral, --warning, --error     the tone of a meter (always
 *                                              with words next to it)
 *   .ds-columns__col--s2, --s3, --s4           a column of series 2 to 4
 *   .ds-columns__col--warning, --error         a column's tone
 *   .ds-columns--mini                          a 32px sparkline of columns:
 *     no labels, the columns aria-hidden, the figure role="img" with a
 *     summary in its aria-label; .ds-columns__col--highlight takes
 *     --ds-chart-1, the rest --ds-chart-neutral
 *   .ds-columns--grouped                       groups of columns side by
 *     side: .ds-columns__groups > .ds-columns__group (a .ds-columns__plot
 *     and a .ds-columns__label), columns 2px apart, the current group
 *     (.ds-columns__group--current, with a .ds-sr word) on a band of
 *     --ds-surface-low with its label in the label weight; put it in a
 *     .ds-scroll-x when it may be wider than its card
 *   :is(button, a).ds-columns__col             a column that opens its
 *     category: the state layer, an inset ring, the ripple, no morph
 *   .ds-legend__key--s2, --s3, --s4, --neutral, --warning, --error
 *
 * Every chart has a text equivalent: its caption plus a table or a
 * key-value list, or the values as text. In forced colours the fills
 * take CanvasText, series 2 to 4 a hatching of their own (45, 135 and 90
 * degrees), as do the legend keys; a meter's track becomes Canvas with a
 * CanvasText outline. Printed, the fills keep their colours.
 *
 * Hooks: --ds-meter-height (4px), --ds-meter-track (--ds-chart-track),
 * --ds-meter-color (--ds-chart-1), --ds-columns-height (120px),
 * --ds-columns-radius (--ds-radius-xs), --ds-columns-group-min (56px),
 * --ds-chart-color (the series of a column or a legend key, set by the
 * modifiers), --ds-legend-key-size (12px).
 */

@layer ds.components {
  /* ── Meter ── */

  .ds-meter {
    display: block;
    position: relative;
    height: var(--ds-meter-height, 4px);
    border-radius: min(var(--ds-radius-action), calc(var(--ds-meter-height, 4px) / 2));
    background: var(--ds-meter-track, var(--ds-chart-track));
    transition: --ds-share var(--ds-dur-glide) var(--ds-ease-value);
  }

  .ds-meter::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: var(--ds-meter-color, var(--ds-chart-1));
    clip-path: inset(0 calc((1 - var(--ds-share)) * 100%) 0 0 round var(--ds-radius-action));
  }

  .ds-meter--neutral {
    --ds-meter-color: var(--ds-chart-neutral);
  }

  .ds-meter--warning {
    --ds-meter-color: var(--ds-chart-warning);
  }

  .ds-meter--error {
    --ds-meter-color: var(--ds-chart-error);
  }

  /* ── Columns ── */

  /* No top margin of their own: inside a card the card's rhythm spaces
     them (zero weight). */
  :where(.ds-columns, .ds-legend) {
    margin-top: 0;
  }

  .ds-columns {
    min-width: 0;
    margin-inline: 0;
    margin-bottom: 0;
  }

  .ds-columns__plot,
  .ds-columns__groups {
    display: grid;
    grid-auto-columns: minmax(0, 1fr);
    grid-auto-flow: column;
    gap: var(--ds-space-3);
    margin: 0;
    /* Room above a full column for its value. */
    padding: var(--ds-space-6) 0 0;
    list-style: none;
  }

  /* An interactive column sits in a plain <li>, which holds its place. */
  .ds-columns__plot > li:not(.ds-columns__col) {
    display: grid;
    min-width: 0;
  }

  .ds-columns__col {
    position: relative;
    display: grid;
    grid-template-rows: var(--ds-columns-height, 120px) auto;
    justify-items: center;
    row-gap: var(--ds-space-2);
    min-width: 0;
    transition: --ds-share var(--ds-dur-glide) var(--ds-ease-value);
  }

  .ds-columns__fill,
  .ds-columns__value {
    grid-row: 1;
    grid-column: 1;
  }

  .ds-columns__fill {
    width: 100%;
    max-width: var(--ds-space-10);
    height: 100%;
    background: var(--ds-chart-color, var(--ds-chart-1));
    clip-path: inset(calc((1 - var(--ds-share)) * 100%) 0 0 0 round var(--ds-columns-radius, var(--ds-radius-xs)) var(--ds-columns-radius, var(--ds-radius-xs)) 0 0);
  }

  /* The value rides on the top of the fill: only the registered share
     transitions, the position follows it. */
  .ds-columns__value {
    align-self: end;
    translate: 0 calc(-1 * var(--ds-share) * var(--ds-columns-height, 120px) - var(--ds-space-1));
    color: var(--ds-text);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }

  .ds-columns__label {
    max-width: 100%;
    overflow: hidden;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .ds-columns__col--s2 {
    --ds-chart-color: var(--ds-chart-2);
  }

  .ds-columns__col--s3 {
    --ds-chart-color: var(--ds-chart-3);
  }

  .ds-columns__col--s4 {
    --ds-chart-color: var(--ds-chart-4);
  }

  .ds-columns__col--warning {
    --ds-chart-color: var(--ds-chart-warning);
  }

  .ds-columns__col--error {
    --ds-chart-color: var(--ds-chart-error);
  }

  /* A sparkline of columns: no labels or values, one highlighted column. */
  .ds-columns--mini .ds-columns__plot {
    gap: 2px;
    padding: 0;
  }

  .ds-columns--mini .ds-columns__col {
    --ds-chart-color: var(--ds-chart-neutral);
    grid-template-rows: var(--ds-space-8);
    row-gap: 0;
  }

  .ds-columns--mini .ds-columns__col--highlight {
    --ds-chart-color: var(--ds-chart-1);
  }

  .ds-columns--mini :is(.ds-columns__value, .ds-columns__label) {
    display: none;
  }

  .ds-columns--mini .ds-columns__fill {
    max-width: none;
  }

  /* Groups of columns side by side, the label under each group. */
  .ds-columns__group {
    position: relative;
    display: grid;
    grid-template-rows: auto auto;
    justify-items: center;
    row-gap: var(--ds-space-2);
    min-width: var(--ds-columns-group-min, 56px);
    padding-inline: var(--ds-space-1);
  }

  .ds-columns--grouped .ds-columns__groups {
    grid-auto-columns: minmax(var(--ds-columns-group-min, 56px), 1fr);
    gap: var(--ds-space-2);
  }

  .ds-columns__group > .ds-columns__plot {
    gap: 2px;
    justify-self: stretch;
    padding: 0;
  }

  .ds-columns__group .ds-columns__col {
    grid-template-rows: var(--ds-columns-height, 120px);
    row-gap: 0;
  }

  .ds-columns__group .ds-columns__value {
    display: none;
  }

  .ds-columns__group--current {
    border-radius: var(--ds-radius-xs);
    background: var(--ds-surface-low);
  }

  .ds-columns__group--current > .ds-columns__label {
    color: var(--ds-text);
    font-weight: var(--ds-weight-label);
  }

  /* A column that opens its category: a state layer over the column,
     an inset ring, the ripple, no morph. */
  :is(button, a).ds-columns__col {
    isolation: isolate;
    width: 100%;
    padding: 0 0 var(--ds-space-1);
    border: 0;
    border-radius: var(--ds-radius-xs);
    background: none;
    color: inherit;
    font: inherit;
    text-decoration: none;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    outline-offset: 0;
    transition:
      --ds-share var(--ds-dur-glide) var(--ds-ease-value),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  :is(button, a).ds-columns__col::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    :is(button, a).ds-columns__col:hover::before {
      opacity: var(--ds-state-hover);
    }
  }

  :is(button, a).ds-columns__col:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  /* Press-in in --ds-dur-micro (.ds-pressed: js/ripple.js holds it). */
  :is(button, a).ds-columns__col:active::before,
  :is(button, a).ds-columns__col.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  :is(button, a).ds-columns__col:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  /* ── Legend ── */

  .ds-legend {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ds-space-1) var(--ds-space-4);
    margin-inline: 0;
    margin-bottom: 0;
    padding: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    list-style: none;
  }

  .ds-legend > li {
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-2);
  }

  .ds-legend__key {
    flex: none;
    width: var(--ds-legend-key-size, 12px);
    height: var(--ds-legend-key-size, 12px);
    border-radius: var(--ds-radius-xs);
    background: var(--ds-chart-color, var(--ds-chart-1));
  }

  .ds-legend__key--s2 {
    --ds-chart-color: var(--ds-chart-2);
  }

  .ds-legend__key--s3 {
    --ds-chart-color: var(--ds-chart-3);
  }

  .ds-legend__key--s4 {
    --ds-chart-color: var(--ds-chart-4);
  }

  .ds-legend__key--neutral {
    --ds-chart-color: var(--ds-chart-neutral);
  }

  .ds-legend__key--warning {
    --ds-chart-color: var(--ds-chart-warning);
  }

  .ds-legend__key--error {
    --ds-chart-color: var(--ds-chart-error);
  }

  /* Forced colours drop the series colours: fills in the text colour,
     series 2 to 4 hatched, so they stay apart without colour. */
  @media (forced-colors: active) {
    .ds-meter {
      forced-color-adjust: none;
      background: Canvas;
      outline: 1px solid CanvasText;
    }

    .ds-meter::before {
      background: CanvasText;
    }

    .ds-columns__fill,
    .ds-legend__key {
      forced-color-adjust: none;
      background: CanvasText;
    }

    .ds-legend__key--s2,
    .ds-columns__col--s2 > .ds-columns__fill {
      background: repeating-linear-gradient(45deg, CanvasText 0 2px, Canvas 2px 4px);
    }

    .ds-legend__key--s3,
    .ds-columns__col--s3 > .ds-columns__fill {
      background: repeating-linear-gradient(135deg, CanvasText 0 2px, Canvas 2px 4px);
    }

    .ds-legend__key--s4,
    .ds-columns__col--s4 > .ds-columns__fill {
      background: repeating-linear-gradient(90deg, CanvasText 0 2px, Canvas 2px 4px);
    }

    .ds-legend__key {
      outline: 1px solid CanvasText;
    }
  }

  /* Printed, the fills keep their colours (browsers drop backgrounds by
     default, which would print empty charts). */
  @media print {
    .ds-meter,
    .ds-meter::before,
    .ds-columns__fill,
    .ds-legend__key {
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
  }
}

/* ── components/sheet.css ── */
/*
 * Modal side sheet (Material 3): white, rounded on its leading corners, on
 * the overlay elevation, over a plain scrim (never blurred). The panel is
 * opaque throughout: it slides its full width in from the edge on the
 * spatial spring while the scrim fades (D16), and leaves faster than it
 * came, back to the edge, accelerating, without a fade; the scrim fades out
 * with it. At 600px and below it rises from the bottom and falls back down;
 * there it fills the screen, so it rises on --ds-ease-out: a spring would
 * carry its head past the top edge. A strip of its own background past its
 * outer edge (the bleed) covers the spring's overshoot, so the scrim never
 * shows at the screen edge. A close taken back mid-way continues from
 * where the panel is.
 *
 * Native markup (preferred): showModal() makes the page inert and gives
 * the sheet its modal semantics.
 *
 *   <dialog class="ds-sheet" aria-labelledby="sheet-title">
 *     <div class="ds-sheet__backdrop" data-close></div>
 *     <section class="ds-sheet__panel"> ... (as below)
 *
 * Div markup (fallback), hidden until it opens:
 *
 *   <div class="ds-sheet" role="dialog" aria-modal="true" aria-labelledby="sheet-title" hidden>
 *     <div class="ds-sheet__backdrop" data-close></div>
 *     <section class="ds-sheet__panel [ds-sheet__panel--wide]">
 *       <div class="ds-sheet__head">
 *         <div class="ds-sheet__heading">
 *           <h2 class="ds-sheet__title" id="sheet-title">Details</h2>
 *           <p class="ds-sheet__subtitle" aria-live="polite">12 of 240 devices</p>   (optional)
 *         </div>
 *         <button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="Close" data-close>(close)</button>
 *       </div>
 *       <div class="ds-sheet__body" tabindex="0"> ... </div>
 *       <div class="ds-sheet__foot">                                    (optional)
 *         <p class="ds-meta">3 filters</p>
 *         <button class="ds-btn ds-btn--text" type="button">Reset</button>
 *         <button class="ds-btn ds-btn--primary" type="button" data-close>Show 12 devices</button>
 *       </div>
 *
 * Open and close both with DS.overlay.open(sheet, { opener }) and
 * DS.overlay.close(sheet) (js/overlay.js): focus, trap, Escape, the
 * scroll lock and data-state ("closed", then "open"; "closed" again to
 * leave). The motion runs on transitions keyed on data-state, so a
 * reversal continues from the current value in every engine.
 *
 *   .ds-sheet--closing   the exit by hand, without data-state (keyframes):
 *                        release the trap and focus the opener first, add
 *                        the class, and after DS.motion.settled(panel) hide
 *                        the sheet and remove the class in the same task.
 *                        On a native sheet it keeps the element shown and
 *                        stacked after close() took it out of the top
 *                        layer. During the exit the panel ignores the
 *                        pointer and the backdrop swallows clicks, so
 *                        nothing reaches the page under it.
 *
 * The hidden attribute hides the div sheet, also without .ds-base.
 *
 * Head: the title wraps (a long record name stays readable); in
 * .ds-sheet__heading a .ds-sheet__subtitle under it (meta type, one line,
 * ellipsis; empty, it takes no room). The head's buttons never shrink.
 * Foot: the sheet's actions, at the end, wrapping, above a hairline
 * (--ds-sheet-foot-border); a leading .ds-meta takes the start; it holds
 * the safe area, so the body above it ends with the normal gap; empty, it
 * takes no room. A lone .ds-btn directly in the body keeps its own width.
 *
 * Another record in an open sheet (previous/next, another row of the
 * table): keep the sheet open and swap only the body, inside
 * DS.motion.transition() with type 'forward' or 'back' for previous and
 * next, 'fade-through' for another row:
 *
 *   DS.motion.transition(() => { DS.dom.morphHtml(body, html); return body; }, { type: 'forward' });
 *
 * The body then takes the view's motion (ds-sheet-body, motion.css) while
 * head, panel and the page under the scrim stay still; without View
 * Transitions the body enters with .ds-enter--swap. A refresh of the same
 * record never calls transition(): a plain DS.dom.morphHtml(body, html),
 * since data never replays an entrance.
 *
 * Container transform (opt-in): DS.overlay.open(sheet, { opener: row,
 * container: true }) grows the row into the panel (js/overlay.js).
 *
 * data-scrolled on the panel while its body is scrolled: the head's
 * hairline shows then (where scroll timelines exist it follows the body's
 * scroll by itself). DS.overlay.open() sets it through
 * DS.dom.watchScrolled(body) (a 1px sentinel at the top of the body, never
 * a scroll listener); a sheet shown without DS.overlay calls it once. Do
 * not use <header> in the head: outside main content it would be a second
 * banner landmark.
 *
 * The on-screen keyboard: with DS.dom.followViewport() on the page, the
 * panel ends above it (--ds-keyboard-inset, 0px without it) while the
 * scrim covers the whole window.
 *
 * Hook: --ds-sheet-width (560px), --ds-sheet-width-wide (720px),
 * --ds-sheet-foot-border (1px solid --ds-line).
 *
 * Print: an open sheet is not printed (it floats over the page).
 *
 * Progressive:
 * - @starting-style, allow-discrete transitions and the overlay property
 *   (a native sheet driven by hand, showModal() and close() of the page
 *   without DS.overlay, plays the same transitions, also on close();
 *   Chromium keeps a sheet in the top layer through every exit, DS.overlay's
 *   included): without them it enters with the keyframes and closes at
 *   once.
 * - View Transitions (while DS.motion.transition() runs, data-ds-vt on
 *   <html>, the body of an open modal sheet is named ds-sheet-body, so a
 *   record swap moves only the body; a sheet shown inline, without
 *   data-state, [open] or aria-modal, is never named): without them the
 *   body enters with .ds-enter--swap.
 * - scroll-driven animations (animation-timeline, animation-range,
 *   scroll-timeline, timeline-scope; the head's hairline fades in over the
 *   first --ds-space-2 of the body's scroll, on the named timeline
 *   --ds-sheet-scroll scoped on the panel): without them data-scrolled
 *   shows it over --ds-dur-fast.
 * - scrollbar-color and scrollbar-width (a thin, quiet scrollbar on the
 *   body): without them the platform scrollbar shows.
 */

@layer ds.components {
  .ds-sheet {
    position: fixed;
    inset: 0;
    z-index: var(--ds-z-drawer);
    /* Out of flow: no rhythm margin from a parent card (.ds-card > * + *). */
    margin: 0;
    display: flex;
    justify-content: flex-end;
    /* The panel ends above the on-screen keyboard (DS.dom.followViewport()
       writes its height); the scrim still covers the whole window. */
    padding-bottom: var(--ds-keyboard-inset, 0px);
    /* The bleed past the panel's outer edge never makes anything scroll. */
    overflow: clip;
  }

  /* A native <dialog> loses the UA box: it is the same full-window frame
     as the div. */
  dialog.ds-sheet {
    width: auto;
    height: auto;
    max-width: none;
    max-height: none;
    margin: 0;
    padding: 0 0 var(--ds-keyboard-inset, 0px);
    border: 0;
    background: transparent;
    color: inherit;
    overflow: clip;
  }

  dialog.ds-sheet::backdrop {
    background: transparent;
  }

  /* display: flex above outranks the UA rule that hides a closed dialog.
     --closing keeps it shown while its exit plays outside the top layer;
     data-state="open" keeps it shown when the page closes it natively
     (close(), a <form method="dialog">), until the close event, a task
     later, starts the exit from where it stands. */
  dialog.ds-sheet:not([open]):not(.ds-sheet--closing):not([data-state="open"]) {
    display: none;
  }

  .ds-sheet__backdrop {
    position: absolute;
    inset: 0;
    background: var(--ds-scrim);
  }

  /* With viewport-fit=cover the sheet keeps clear of the notch, the status
     bar and the home indicator, like the app frame (shell.css). Physical
     on purpose (D9): the panel slides in from the right edge, so its
     rounded corners and its bleed stay on the left. */
  .ds-sheet__panel {
    position: relative;
    width: min(100%, var(--ds-sheet-width, 560px));
    height: 100%;
    display: flex;
    flex-direction: column;
    padding-top: env(safe-area-inset-top);
    border-radius: var(--ds-radius-dialog) 0 0 var(--ds-radius-dialog);
    background: var(--ds-surface-overlay);
    /* Its own ink on its own surface (a widget without .ds-base). */
    color: var(--ds-text);
    box-shadow: var(--ds-elev-overlay);
    /* Invisible; forced colors paint it, so the sheet keeps its edge. */
    outline: 1px solid transparent;
  }

  /* Bleed: the spring carries the panel a little past its resting edge
     (about 3 % of the travel, at most 5 %: 36px of a 720px panel); this
     strip of its own background beyond the outer edge keeps the scrim from
     showing at the screen edge. Off screen at rest, clipped by the sheet. */
  .ds-sheet__panel::after {
    content: '';
    position: absolute;
    top: 0;
    bottom: 0;
    left: 100%;
    width: var(--ds-space-12);
    background: inherit;
    pointer-events: none;
  }

  .ds-sheet__panel--wide {
    width: min(100%, var(--ds-sheet-width-wide, 720px));
  }

  .ds-sheet[hidden] {
    display: none;
  }

  /* Only the panel lets clicks through while it leaves; the backdrop
     swallows them until the sheet is hidden or removed. */
  .ds-sheet--closing .ds-sheet__panel,
  .ds-sheet[data-state="closed"] .ds-sheet__panel {
    pointer-events: none;
  }

  /* State-driven (DS.overlay): transitions, so a reversal continues from
     the current value. A transition takes the timing of the state it moves
     to. The panel only slides (opaque, no fade); the scrim fades out over
     the panel's exit time, so both end together. */
  .ds-sheet[data-state] .ds-sheet__backdrop {
    animation: none;
    transition: opacity var(--ds-dur-medium) var(--ds-ease-in-out);
  }

  .ds-sheet[data-state] .ds-sheet__panel {
    animation: none;
    transition: translate var(--ds-dur-long) var(--ds-ease-spatial);
  }

  .ds-sheet[data-state="closed"] .ds-sheet__backdrop {
    opacity: 0;
    transition: opacity var(--ds-dur-base) var(--ds-ease-in-out);
  }

  .ds-sheet[data-state="closed"] .ds-sheet__panel {
    translate: var(--ds-distance-sheet) 0;
    transition: translate var(--ds-dur-base) var(--ds-ease-exit);
  }

  /* Keyframes: markup driven by hand, without data-state. The full-width
     slide of the opaque panel is the one exit without ds-fade-out: it
     leaves the screen. */
  .ds-sheet:not([data-state]) .ds-sheet__backdrop {
    animation: ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) backwards;
  }

  .ds-sheet:not([data-state]) .ds-sheet__panel {
    --ds-motion-distance: var(--ds-distance-sheet);
    animation: ds-shift-in var(--ds-dur-long) var(--ds-ease-spatial) backwards;
  }

  .ds-sheet--closing:not([data-state]) .ds-sheet__backdrop {
    animation: ds-fade-out var(--ds-dur-base) var(--ds-ease-in-out) forwards;
  }

  .ds-sheet--closing:not([data-state]) .ds-sheet__panel {
    --ds-motion-distance: var(--ds-distance-sheet);
    animation: ds-shift-out var(--ds-dur-base) var(--ds-ease-exit) forwards;
  }

  /* A native sheet driven by hand: the same transitions, keyed on [open].
     display and overlay step at the end of the exit, so close() animates
     and Chromium keeps the sheet in the top layer meanwhile. Under
     DS.overlay only overlay steps: --closing keeps display, and the sheet
     must not linger, invisible, once it is done. */
  @supports (transition-behavior: allow-discrete) {
    dialog.ds-sheet:not([data-state]) {
      transition:
        display var(--ds-dur-base) allow-discrete,
        overlay var(--ds-dur-base) allow-discrete;
    }

    dialog.ds-sheet[data-state] {
      transition: overlay var(--ds-dur-base) allow-discrete;
    }

    dialog.ds-sheet:not([data-state]) .ds-sheet__backdrop {
      animation: none;
      transition: opacity var(--ds-dur-medium) var(--ds-ease-in-out);
    }

    dialog.ds-sheet:not([data-state]) .ds-sheet__panel {
      animation: none;
      transition: translate var(--ds-dur-long) var(--ds-ease-spatial);
    }

    dialog.ds-sheet:not([data-state]):not([open]) .ds-sheet__backdrop {
      opacity: 0;
      transition: opacity var(--ds-dur-base) var(--ds-ease-in-out);
    }

    dialog.ds-sheet:not([data-state]):not([open]) .ds-sheet__panel {
      translate: var(--ds-distance-sheet) 0;
      transition: translate var(--ds-dur-base) var(--ds-ease-exit);
    }
  }

  /* Where the sheet enters from when showModal() shows it (a rule of its
     own, not nested; engines without it play the keyframes). */
  @starting-style {
    dialog.ds-sheet[open]:not([data-state]) .ds-sheet__backdrop {
      opacity: 0;
    }

    dialog.ds-sheet[open]:not([data-state]) .ds-sheet__panel {
      translate: var(--ds-distance-sheet) 0;
    }
  }

  /* Header: 64px; its hairline shows only while the body scrolls under it. */
  .ds-sheet__head {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--ds-space-3);
    flex-shrink: 0;
    min-height: 64px;
    padding: var(--ds-space-3) max(var(--ds-space-3), env(safe-area-inset-right)) var(--ds-space-3) var(--ds-space-6);
  }

  .ds-sheet__head::after {
    content: '';
    position: absolute;
    inset-inline: 0;
    bottom: 0;
    height: 1px;
    background: var(--ds-line);
    opacity: 0;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-sheet__panel[data-scrolled] .ds-sheet__head::after {
    opacity: 1;
  }

  /* Where scroll timelines exist, the hairline follows the body's scroll
     position itself (over the first --ds-space-2), and data-scrolled, the
     fallback, changes nothing. The head is the body's sibling, so the body
     names its timeline and the panel widens the scope to the head.
     ds-scroll-fade names both ends, so a body without overflow (an
     inactive timeline) keeps the hairline's own opacity 0;
     animation-timeline comes after the shorthand, which resets it. */
  @supports (timeline-scope: none) {
    .ds-sheet__panel {
      timeline-scope: --ds-sheet-scroll;
    }

    .ds-sheet__body {
      scroll-timeline: --ds-sheet-scroll block;
    }

    .ds-sheet__head::after {
      animation: ds-scroll-fade linear both;
      animation-timeline: --ds-sheet-scroll;
      animation-range: 0 var(--ds-space-2);
    }
  }

  /* The title wraps: a long record name stays readable (2.0). */
  .ds-sheet__title {
    margin: 0;
    min-width: 0;
    color: var(--ds-text);
    font: var(--ds-font-title);
    letter-spacing: 0;
    overflow-wrap: anywhere;
  }

  .ds-sheet__head > .ds-btn {
    flex-shrink: 0;
  }

  /* Title and subtitle as one block that takes the head's free width. */
  .ds-sheet__heading {
    display: grid;
    flex: 1;
    min-width: 0;
  }

  .ds-sheet__subtitle {
    margin: 0;
    overflow: hidden;
    color: var(--ds-text-2);
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .ds-sheet__subtitle:empty {
    display: none;
  }

  .ds-sheet__body {
    position: relative;
    flex: 1;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
    padding:
      var(--ds-space-2)
      max(var(--ds-space-6), env(safe-area-inset-right))
      calc(var(--ds-space-8) + env(safe-area-inset-bottom))
      var(--ds-space-6);
    display: flex;
    flex-direction: column;
    gap: var(--ds-space-3);
    outline-offset: 0;
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* A snackbar shown inside the sheet (toast.css) that stays until closed:
     the body keeps room for it at its end on top of its own padding
     (data-toast-reserve and --ds-toast-reserve, set by toast(), or by the
     page with reserve: false), so the sheet's actions can scroll clear of
     it. */
  .ds-sheet__body[data-toast-reserve] {
    padding-bottom: calc(var(--ds-space-8) + env(safe-area-inset-bottom) + var(--ds-toast-reserve, 0px));
    scroll-padding-bottom: var(--ds-toast-reserve, 0px);
  }

  /* A lone button keeps its own width (a column would stretch it). */
  .ds-sheet__body > .ds-btn {
    align-self: flex-start;
  }

  /* The sheet's actions: at the end, wrapping, above a hairline. The foot
     holds the safe area, so the body above it ends with its normal gap. */
  .ds-sheet__foot {
    display: flex;
    flex-shrink: 0;
    flex-wrap: wrap;
    justify-content: flex-end;
    align-items: center;
    gap: var(--ds-space-2);
    padding:
      var(--ds-space-3)
      max(var(--ds-space-6), env(safe-area-inset-right))
      calc(var(--ds-space-3) + env(safe-area-inset-bottom))
      var(--ds-space-6);
    border-top: var(--ds-sheet-foot-border, 1px solid var(--ds-line));
  }

  .ds-sheet__foot > .ds-meta:first-child {
    margin-inline-end: auto;
  }

  .ds-sheet__foot:empty {
    display: none;
  }

  .ds-sheet__body:has(+ .ds-sheet__foot:not(:empty)) {
    padding-bottom: var(--ds-space-3);
  }

  .ds-sheet__body[data-toast-reserve]:has(+ .ds-sheet__foot:not(:empty)) {
    padding-bottom: calc(var(--ds-space-3) + var(--ds-toast-reserve, 0px));
  }

  /* A tab stop for scrolling: the ring only for the keyboard, inside the
     edge so the panel does not clip it; it rests on the edge and snaps
     inward like every inset ring (.ds-sheet__body above). */
  .ds-sheet__body:focus {
    outline: none;
  }

  .ds-sheet__body:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  /* Another record in an open sheet (DS.motion.transition() writes
     data-ds-vt on <html> while it runs): the body is a group of its own and
     takes the view's fade-through or shared axis (motion.css); head and
     panel stay still. Named only while a transition runs, and only in an
     open modal sheet (DS.overlay's data-state, a native sheet shown by
     hand, a div sheet shown by hand), never while it leaves: a group
     escapes the clip of the scroller around it, so a sheet shown inline
     in a page would paint over the top bar, and two named bodies would
     abort the transition. */
  .ds-theme[data-ds-vt] .ds-sheet[data-state="open"] .ds-sheet__body,
  .ds-theme[data-ds-vt] dialog.ds-sheet[open]:not([data-state], .ds-sheet--closing) .ds-sheet__body,
  .ds-theme[data-ds-vt] .ds-sheet[aria-modal="true"]:not([hidden], [data-state], .ds-sheet--closing) .ds-sheet__body {
    view-transition-name: ds-sheet-body;
  }

  @media (max-width: 600px) {
    .ds-sheet__panel,
    .ds-sheet__panel--wide {
      width: 100%;
      border-radius: 0;
    }

    /* Phones: the sheet rises from the bottom and falls back down; the
       bleed sits below it. As tall as the screen, it rises on
       --ds-ease-out (every path), so the spring's overshoot never cuts
       off its head at the top edge; the exit keeps its tokens. */
    .ds-sheet__panel::after {
      top: 100%;
      inset-inline: 0;
      bottom: auto;
      width: auto;
      height: var(--ds-space-12);
    }

    .ds-sheet[data-state="open"] .ds-sheet__panel {
      transition: translate var(--ds-dur-long) var(--ds-ease-out);
    }

    .ds-sheet[data-state="closed"] .ds-sheet__panel {
      translate: 0 var(--ds-distance-sheet);
    }

    .ds-sheet:not([data-state]) .ds-sheet__panel {
      --ds-motion-distance: var(--ds-distance-sheet);
      animation: ds-lift-in var(--ds-dur-long) var(--ds-ease-out) backwards;
    }

    .ds-sheet--closing:not([data-state]) .ds-sheet__panel {
      --ds-motion-distance: var(--ds-distance-sheet);
      animation: ds-drop-out var(--ds-dur-base) var(--ds-ease-exit) forwards;
    }

    .ds-sheet__head {
      padding-left: max(var(--ds-space-4), env(safe-area-inset-left));
    }

    .ds-sheet__body {
      padding-inline: max(var(--ds-space-4), env(safe-area-inset-left)) max(var(--ds-space-4), env(safe-area-inset-right));
    }

    .ds-sheet__foot {
      padding-inline: max(var(--ds-space-4), env(safe-area-inset-left)) max(var(--ds-space-4), env(safe-area-inset-right));
    }
  }

  @supports (transition-behavior: allow-discrete) {
    @media (max-width: 600px) {
      dialog.ds-sheet[open]:not([data-state]) .ds-sheet__panel {
        transition: translate var(--ds-dur-long) var(--ds-ease-out);
      }

      dialog.ds-sheet:not([data-state]):not([open]) .ds-sheet__panel {
        translate: 0 var(--ds-distance-sheet);
      }
    }
  }

  @media (max-width: 600px) {
    @starting-style {
      dialog.ds-sheet[open]:not([data-state]) .ds-sheet__panel {
        translate: 0 var(--ds-distance-sheet);
      }
    }
  }

  @media print {
    .ds-sheet {
      display: none;
    }
  }
}

/* ── components/dialog.css ── */
/*
 * Dialog (confirmation): a modal box over the whole window, above an open
 * side sheet (it may ask from inside one) and below snackbars. The scrim
 * fades in while the box fades, grows from just below full size and rises
 * a little on the spatial spring, from an origin towards its opener. It
 * leaves faster than it came, the same way back, accelerating. A close
 * taken back mid-way continues from where the box is.
 *
 * Native markup (preferred): showModal() makes the page inert and gives
 * the dialog its modal semantics, so the box carries no role.
 *
 *   <dialog class="ds-dialog" role="alertdialog" aria-labelledby="d-title" aria-describedby="d-text">
 *     <div class="ds-dialog__backdrop" data-close></div>
 *     <div class="ds-dialog__box">
 *       <h2 class="ds-dialog__title" id="d-title">Delete 3 devices?</h2>
 *       <p class="ds-dialog__text" id="d-text">They are removed for everyone.</p>
 *       <div class="ds-dialog__actions">
 *         <button class="ds-btn ds-btn--text" type="button" data-close autofocus>Cancel</button>
 *         <button class="ds-btn ds-btn--primary" type="button">Delete devices</button>
 *
 * Content taller than the window (a form, a long list): the body scrolls,
 * title and actions stay. A body with nothing focusable that scrolls is a
 * tab stop with a name.
 *
 *   <dialog class="ds-dialog ds-dialog--wide" aria-labelledby="p-title">
 *     <div class="ds-dialog__backdrop" data-close></div>
 *     <div class="ds-dialog__box">
 *       <h2 class="ds-dialog__title" id="p-title">Choose an owner</h2>
 *       <div class="ds-dialog__body" [tabindex="0" role="region" aria-label="Owners"]> ... </div>
 *       <div class="ds-dialog__actions"> ... </div>
 *
 * Full screen (a task that needs the whole phone screen): a head with a
 * close icon button, the title and an optional text button at the end.
 *
 *   <dialog class="ds-dialog ds-dialog--full-narrow" aria-labelledby="f-title">
 *     <div class="ds-dialog__backdrop" data-close></div>
 *     <div class="ds-dialog__box">
 *       <div class="ds-dialog__head">
 *         <button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="Close" data-close>(close)</button>
 *         <h2 class="ds-dialog__title" id="f-title">New ticket</h2>
 *         <button class="ds-btn ds-btn--text" type="button">Save</button>
 *       </div>
 *       <div class="ds-dialog__body"> ... </div>
 *
 * Action sheet (the actions of a row on a phone): the menu's items, full
 * bleed, in a box at the bottom edge.
 *
 *   <dialog class="ds-dialog ds-dialog--bottom" aria-labelledby="a-title">
 *     <div class="ds-dialog__backdrop" data-close></div>
 *     <div class="ds-dialog__box">
 *       <h2 class="ds-dialog__title" id="a-title">LAPTOP-0042</h2>
 *       <div class="ds-dialog__list">
 *         <button class="ds-menu__item" type="button">(pencil) <span class="ds-menu__label">Edit</span></button>
 *
 * Div markup (fallback): the same tree in a div; the box takes the role.
 *
 *   <div class="ds-dialog" hidden>
 *     <div class="ds-dialog__backdrop" data-close></div>
 *     <div class="ds-dialog__box" role="alertdialog" aria-modal="true" aria-labelledby="d-title" aria-describedby="d-text">
 *
 * Open and close both with DS.overlay.open(dialog, { opener }) and
 * DS.overlay.close(dialog) (js/overlay.js): focus, trap, Escape, the
 * scroll lock and data-state. The motion runs on transitions keyed on
 * data-state ("closed", then "open"; "closed" again to leave), so a
 * reversal continues from the current value in every engine. The scrim is
 * the __backdrop child; a native dialog's ::backdrop stays transparent.
 *
 *   .ds-dialog--hidden     not shown (or use the hidden attribute; it works
 *                          without .ds-base)
 *   .ds-dialog--closing    the exit by hand, without data-state (keyframes):
 *                          release the trap and focus the opener first, add
 *                          the class, and after DS.motion.settled(box) hide
 *                          the dialog and remove the class in the same task.
 *                          On a native dialog it keeps the element shown and
 *                          stacked (position: fixed, --ds-z-dialog) after
 *                          close() took it out of the top layer. The box
 *                          ignores the pointer during the exit, the backdrop
 *                          still catches clicks, so nothing reaches the page
 *                          under it.
 *   .ds-dialog--nested     over another open modal (a sheet, the modal
 *                          drawer): the lighter --ds-scrim-nested, so the
 *                          scrims add up to about 43%. DS.overlay sets it.
 *   .ds-dialog--contained  covers its positioned container instead of the
 *                          window (div markup only: a dialog inside an
 *                          embedded panel); in a container taller than
 *                          the window the box stays in the visible part
 *                          (position: sticky)
 *   .ds-dialog--wide       a wider box (--ds-dialog-width-wide, 560px): a
 *                          list, a form, a confirmation with a field
 *   .ds-dialog--full       the box fills the window (no padding around it,
 *                          radius --ds-dialog-full-radius, safe areas on
 *                          head and actions); it rises by
 *                          --ds-distance-lg on the spring over
 *                          --ds-dur-long with the fade, no scale, and
 *                          leaves dropping back in --ds-dur-base
 *   .ds-dialog--full-narrow  the same at 600px and below, a normal dialog
 *                          above
 *   .ds-dialog--bottom     the action sheet: the box sits at the bottom edge
 *                          and rises its full height (--ds-distance-sheet)
 *                          on the spring over --ds-dur-long with the fade;
 *                          it drops back in --ds-dur-base
 *
 * Parts: .ds-dialog__body (with a title above and actions below, it is the
 * one part that scrolls: the box turns into a flex column; it shows a top
 * hairline while scrolled, data-scrolled on the box, which DS.overlay.open()
 * keeps through DS.dom.watchScrolled(); at a window 320px high or less the
 * box scrolls instead, WCAG 1.4.10; empty, its sentinel aside, it takes no
 * room, nor do empty actions), .ds-dialog__head (the full-screen top bar:
 * a close icon button with data-close, the title, an optional text button)
 * and .ds-dialog__list (.ds-menu__item rows full bleed in the box;
 * DS.overlay moves focus through them with the arrow keys). A lone .ds-btn
 * directly in the body keeps its own width.
 *
 * The on-screen keyboard: with DS.dom.followViewport() on the page, every
 * dialog keeps clear of it (--ds-keyboard-inset, 0px without it): a
 * centred box centres in the window above the keyboard, a full-screen box
 * (--full, --full-narrow on phones) ends at its top edge, the action sheet
 * (--bottom) sits on it, a contained box sticks above it; the scrim keeps
 * covering the whole window. A box taller than the room scrolls its body.
 *
 * Fallback focus: a box that took focus because nothing inside could
 * (tabindex="-1", set by DS.overlay) shows no ring.
 *
 * At 600px and below the box moves on --ds-ease-out: a box nearly as wide
 * as the window never overshoots past it.
 *
 * There are no red buttons: the main action names the consequence.
 *
 * Hooks: --ds-dialog-width (400px), --ds-dialog-width-wide (560px),
 * --ds-dialog-height (auto; set it so a filtered list does not make the
 * box jump; capped by the window), --ds-dialog-full-radius (0),
 * --ds-dialog-border (an invisible hairline that forced colors paint, so
 * the dialog keeps its edge once shadow and scrim are gone; the full
 * screen dialog needs none), --ds-dialog-shadow, --ds-dialog-scrim
 * (--ds-scrim).
 *
 * Print: an open dialog is not printed (it is not the page).
 *
 * Progressive:
 * - @starting-style, allow-discrete transitions and the overlay property
 *   (a native dialog driven by hand, showModal() and close() of the page
 *   without DS.overlay, plays the same transitions, also on close();
 *   Chromium keeps a dialog in the top layer through every exit, DS.overlay's
 *   included): without them it enters with the keyframes and closes at
 *   once.
 * - scrollbar-color and scrollbar-width (a thin, quiet scrollbar on the
 *   body): without them the platform scrollbar shows.
 */

@layer ds.components {
  .ds-dialog {
    position: fixed;
    inset: 0;
    z-index: var(--ds-z-dialog);
    display: grid;
    place-items: center;
    /* Out of flow: no rhythm margin from a parent card (.ds-card > * + *)
       pushes the cover off its container or the window. */
    margin: 0;
    padding: var(--ds-space-4);
    /* The on-screen keyboard (DS.dom.followViewport() writes its height)
       covers the bottom of the window, not of the layout: the box centres
       in what is left above it, and the scrim still covers it all. */
    padding-bottom: calc(var(--ds-space-4) + var(--ds-keyboard-inset, 0px));
  }

  /* A native <dialog> loses the UA box: it is the same full-window grid
     as the div, and the box inside is the surface. */
  dialog.ds-dialog {
    width: auto;
    height: auto;
    max-width: none;
    max-height: none;
    margin: 0;
    border: 0;
    background: transparent;
    color: inherit;
    overflow: visible;
  }

  dialog.ds-dialog::backdrop {
    background: transparent;
  }

  /* display: grid above outranks the UA rule that hides a closed dialog.
     --closing keeps it shown while its exit plays outside the top layer;
     data-state="open" keeps it shown when the page closes it natively
     (close(), a <form method="dialog">), until the close event, a task
     later, starts the exit from where it stands. */
  dialog.ds-dialog:not([open]):not(.ds-dialog--closing):not([data-state="open"]) {
    display: none;
  }

  .ds-dialog--contained {
    position: absolute;
    z-index: var(--ds-z-popover);
    /* It covers its container, not the window: the keyboard is the
       sticky box's concern (below). */
    padding-bottom: var(--ds-space-4);
  }

  /* The container may be taller than the window (a long list, a grid cell
     the host stretches): the box sticks inside the visible part instead of
     centring below the fold, and stays centred when the container fits. A
     box taller than the window scrolls inside it. */
  .ds-dialog--contained .ds-dialog__box {
    position: sticky;
    top: var(--ds-space-4);
    bottom: calc(var(--ds-space-4) + var(--ds-keyboard-inset, 0px));
    max-height: min(100%, calc(100dvh - 2 * var(--ds-space-4) - var(--ds-keyboard-inset, 0px)));
  }

  .ds-dialog--hidden,
  .ds-dialog[hidden] {
    display: none;
  }

  .ds-dialog--nested {
    --ds-dialog-scrim: var(--ds-scrim-nested);
  }

  .ds-dialog__backdrop {
    position: absolute;
    inset: 0;
    background: var(--ds-dialog-scrim, var(--ds-scrim));
  }

  .ds-dialog__box {
    position: relative;
    width: min(var(--ds-dialog-width, 400px), 100%);
    max-height: 100%;
    overflow-y: auto;
    padding: var(--ds-space-6);
    display: grid;
    gap: var(--ds-space-4);
    border: var(--ds-dialog-border, 1px solid transparent);
    border-radius: var(--ds-radius-dialog);
    background: var(--ds-surface-overlay);
    box-shadow: var(--ds-dialog-shadow, var(--ds-elev-overlay));
    /* Its own ink on its own surface (a widget without .ds-base). */
    color: var(--ds-text);
    text-align: start;
  }

  /* Only the box lets clicks through while it leaves; the backdrop swallows
     them until the dialog is hidden or removed. */
  .ds-dialog--closing .ds-dialog__box,
  .ds-dialog[data-state="closed"] .ds-dialog__box {
    pointer-events: none;
  }

  /* State-driven (DS.overlay): transitions, so a reversal continues from
     the current value. A transition takes the timing of the state it moves
     to: the entrance from these rules, the exit from the closed ones. The
     opacity is opaque within --ds-dur-fast, before the spring has settled. */
  .ds-dialog[data-state] .ds-dialog__backdrop {
    animation: none;
    transition: opacity var(--ds-dur-medium) var(--ds-ease-in-out);
  }

  .ds-dialog[data-state] .ds-dialog__box {
    animation: none;
    transform-origin: var(--ds-motion-origin, 50% 50%);
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      scale var(--ds-dur-slow) var(--ds-ease-spatial),
      translate var(--ds-dur-slow) var(--ds-ease-spatial);
  }

  .ds-dialog[data-state="closed"] .ds-dialog__backdrop {
    opacity: 0;
    transition: opacity var(--ds-dur-base) var(--ds-ease-in-out);
  }

  .ds-dialog[data-state="closed"] .ds-dialog__box {
    opacity: 0;
    scale: var(--ds-scale-enter);
    translate: 0 var(--ds-distance-sm);
    transition:
      opacity var(--ds-dur-base) var(--ds-ease-in-out),
      scale var(--ds-dur-base) var(--ds-ease-exit),
      translate var(--ds-dur-base) var(--ds-ease-exit);
  }

  /* Keyframes: markup driven by hand, without data-state. The exit mirrors
     the entrance, movement-only plus the fade. The box rises the same
     --ds-distance-sm as on the state path; the keyframes keep the
     --ds-dur-medium of the hand path (docs/motion.md rule 3). */
  .ds-dialog:not([data-state]) .ds-dialog__backdrop {
    animation: ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) backwards;
  }

  .ds-dialog:not([data-state]) .ds-dialog__box {
    --ds-motion-distance: var(--ds-distance-sm);
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) backwards,
      ds-grow-in var(--ds-dur-medium) var(--ds-ease-spatial) backwards;
  }

  .ds-dialog--closing:not([data-state]) .ds-dialog__backdrop {
    animation: ds-fade-out var(--ds-dur-base) var(--ds-ease-in-out) forwards;
  }

  .ds-dialog--closing:not([data-state]) .ds-dialog__box {
    --ds-motion-distance: var(--ds-distance-sm);
    animation:
      ds-fade-out var(--ds-dur-base) var(--ds-ease-in-out) forwards,
      ds-shrink-out var(--ds-dur-base) var(--ds-ease-exit) forwards;
  }

  /* A native dialog driven by hand: the same transitions, keyed on [open].
     display and overlay step at the end of the exit, so close() animates
     and Chromium keeps the dialog in the top layer meanwhile. Under
     DS.overlay only overlay steps: --closing keeps display, and the dialog
     must not linger, invisible, once it is done. */
  @supports (transition-behavior: allow-discrete) {
    dialog.ds-dialog:not([data-state]) {
      transition:
        display var(--ds-dur-base) allow-discrete,
        overlay var(--ds-dur-base) allow-discrete;
    }

    dialog.ds-dialog[data-state] {
      transition: overlay var(--ds-dur-base) allow-discrete;
    }

    dialog.ds-dialog:not([data-state]) .ds-dialog__backdrop {
      animation: none;
      transition: opacity var(--ds-dur-medium) var(--ds-ease-in-out);
    }

    dialog.ds-dialog:not([data-state]) .ds-dialog__box {
      animation: none;
      transition:
        opacity var(--ds-dur-fast) var(--ds-ease-in-out),
        scale var(--ds-dur-slow) var(--ds-ease-spatial),
        translate var(--ds-dur-slow) var(--ds-ease-spatial);
    }

    dialog.ds-dialog:not([data-state]):not([open]) .ds-dialog__backdrop {
      opacity: 0;
      transition: opacity var(--ds-dur-base) var(--ds-ease-in-out);
    }

    dialog.ds-dialog:not([data-state]):not([open]) .ds-dialog__box {
      opacity: 0;
      scale: var(--ds-scale-enter);
      translate: 0 var(--ds-distance-sm);
      transition:
        opacity var(--ds-dur-base) var(--ds-ease-in-out),
        scale var(--ds-dur-base) var(--ds-ease-exit),
        translate var(--ds-dur-base) var(--ds-ease-exit);
    }
  }

  /* Where the dialog enters from when showModal() shows it (a rule of its
     own, not nested; engines without it play the keyframes). */
  @starting-style {
    dialog.ds-dialog[open]:not([data-state]) .ds-dialog__backdrop {
      opacity: 0;
    }

    dialog.ds-dialog[open]:not([data-state]) .ds-dialog__box {
      opacity: 0;
      scale: var(--ds-scale-enter);
      translate: 0 var(--ds-distance-sm);
    }
  }

  .ds-dialog__title {
    margin: 0;
    color: var(--ds-text);
    font: var(--ds-font-title);
    letter-spacing: 0;
    overflow-wrap: anywhere;
  }

  .ds-dialog__text {
    margin: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-body);
  }

  /* The title and its text belong together. */
  .ds-dialog__title + .ds-dialog__text {
    margin-top: calc(-1 * var(--ds-space-2));
  }

  .ds-dialog__actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    align-items: center;
    gap: var(--ds-space-2);
    margin-top: var(--ds-space-2);
  }

  /* Fallback focus (DS.overlay focuses the box when nothing inside can
     take it): focus is in the trap, but there is nothing to point at. */
  .ds-dialog__box[tabindex="-1"]:focus {
    outline: none;
  }

  /* As tall as the hook asks (a list that filters keeps its height),
     never taller than the window. */
  .ds-dialog__box {
    height: var(--ds-dialog-height, auto);
  }

  .ds-dialog--wide .ds-dialog__box {
    width: min(var(--ds-dialog-width-wide, 560px), 100%);
  }

  /* With a body, the box is a column whose only scrolling part is the
     body: title, head and actions stay in view. */
  .ds-dialog__box:has(> .ds-dialog__body) {
    display: flex;
    flex-direction: column;
  }

  /* Only the column's items read it (without a body the box is a grid). A
     :has() before a combinator would make Chromium restyle the whole page
     on any text change, so this rule has none. */
  .ds-dialog__box > * {
    flex-shrink: 0;
  }

  /* The 12px around its content give room to focus rings and the halos of
     checks, which the scroller would clip. The hairline at its top shows
     while it is scrolled (data-scrolled on the box); a border, so forced
     colours keep it. */
  .ds-dialog__box > .ds-dialog__body {
    flex: 1 1 auto;
  }

  .ds-dialog__body {
    position: relative;
    min-height: 0;
    display: grid;
    align-content: start;
    gap: var(--ds-space-4);
    margin: calc(-1 * var(--ds-space-3));
    padding: var(--ds-space-3);
    overflow-y: auto;
    overscroll-behavior: contain;
    scroll-padding-block: var(--ds-space-3);
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
    border-top: 1px solid transparent;
    outline-offset: 0;
    transition:
      outline-offset var(--ds-dur-fast) var(--ds-ease-out),
      border-color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-dialog__box[data-scrolled] > .ds-dialog__body {
    border-top-color: var(--ds-line);
  }

  /* A scrolling body with nothing focusable is a tab stop (tabindex="0",
     role="region" and a name): an inset ring that rests on the edge and
     snaps inward, never clipped by the scroller. */
  .ds-dialog__body:focus {
    outline: none;
  }

  .ds-dialog__body:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  /* The sentinel of DS.dom.watchScrolled() (DS.overlay.open() adds it) comes first. */
  .ds-dialog__title + .ds-dialog__body > .ds-dialog__text:first-child,
  .ds-dialog__title + .ds-dialog__body > [data-ds-sentinel]:first-child + .ds-dialog__text {
    margin-top: calc(-1 * var(--ds-space-2));
  }

  /* A lone button keeps its own width (a grid item would stretch). */
  .ds-dialog__body > .ds-btn {
    justify-self: start;
  }

  /* An empty body, its sentinel aside, takes no room. "No child but the
     sentinel" rather than :only-child inside :has(): Firefox does not
     restyle that when rows are added after the sentinel. Text nodes are
     no children to a selector, so a body of bare text carries
     data-ds-text (DS.dom.watchScrolled()) and stays. */
  .ds-dialog__body:empty,
  .ds-dialog__body:not([data-ds-text]):has(> [data-ds-sentinel]):not(:has(> :not([data-ds-sentinel]))),
  .ds-dialog__actions:empty {
    display: none;
  }

  /* The full-screen top bar (M3): close, title, an optional text action.
     It reaches into the box's padding, so the icon button lines up with
     the content edge. */
  .ds-dialog__head {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    min-height: 56px;
    margin: calc(-1 * var(--ds-space-3)) calc(-1 * var(--ds-space-3)) 0;
  }

  .ds-dialog__head > .ds-dialog__title {
    flex: 1;
    min-width: 0;
  }

  .ds-dialog__head > .ds-btn {
    flex-shrink: 0;
  }

  /* An action sheet's rows: menu items, full bleed in the box. */
  .ds-dialog__list {
    display: flex;
    flex-direction: column;
    margin: 0 calc(-1 * var(--ds-space-6));
    padding: 0;
  }

  .ds-dialog__list + .ds-dialog__actions {
    margin-top: 0;
  }

  /* The whole window: no padding around the box, safe areas inside it;
     it ends above the on-screen keyboard. */
  .ds-dialog--full {
    padding: 0 0 var(--ds-keyboard-inset, 0px);
  }

  .ds-dialog--full .ds-dialog__box {
    width: 100%;
    height: 100%;
    max-height: none;
    border-radius: var(--ds-dialog-full-radius, 0);
    padding:
      0
      max(var(--ds-space-6), env(safe-area-inset-right))
      max(var(--ds-space-6), env(safe-area-inset-bottom))
      max(var(--ds-space-6), env(safe-area-inset-left));
  }

  .ds-dialog--full .ds-dialog__head {
    margin: 0 calc(var(--ds-space-2) - var(--ds-space-6));
    padding-top: env(safe-area-inset-top);
  }

  /* The action sheet: at the bottom edge, above the home indicator, or
     above the on-screen keyboard while it is up. */
  .ds-dialog--bottom {
    place-items: end center;
    padding-bottom: max(env(safe-area-inset-bottom), calc(var(--ds-space-4) + var(--ds-keyboard-inset, 0px)));
  }

  /* A window 320px high or less (a phone on its side with the keyboard):
     the box scrolls as one, so every part stays reachable (WCAG 1.4.10). */
  @media (max-height: 320px) {
    .ds-dialog__box:has(> .ds-dialog__body) {
      display: grid;
    }

    .ds-dialog__body {
      min-height: auto;
      overflow: visible;
    }
  }

  /* Phones: a box nearly as wide as the window never overshoots past it;
     the movement decelerates instead of springing (every path). */
  @media (max-width: 600px) {
    .ds-dialog[data-state="open"] .ds-dialog__box {
      transition:
        opacity var(--ds-dur-fast) var(--ds-ease-in-out),
        scale var(--ds-dur-slow) var(--ds-ease-out),
        translate var(--ds-dur-slow) var(--ds-ease-out);
    }

    .ds-dialog:not([data-state]) .ds-dialog__box {
      animation:
        ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) backwards,
        ds-grow-in var(--ds-dur-medium) var(--ds-ease-out) backwards;
    }

    /* The rule above matches a closing dialog too; the exit stays the
       exit (it accelerates and cannot overshoot). */
    .ds-dialog--closing:not([data-state]) .ds-dialog__box {
      animation:
        ds-fade-out var(--ds-dur-base) var(--ds-ease-in-out) forwards,
        ds-shrink-out var(--ds-dur-base) var(--ds-ease-exit) forwards;
    }

    /* Full screen on phones only, above the on-screen keyboard. */
    .ds-dialog--full-narrow {
      padding: 0 0 var(--ds-keyboard-inset, 0px);
    }

    .ds-dialog--full-narrow .ds-dialog__box {
      width: 100%;
      height: 100%;
      max-height: none;
      border-radius: var(--ds-dialog-full-radius, 0);
      padding:
        0
        max(var(--ds-space-6), env(safe-area-inset-right))
        max(var(--ds-space-6), env(safe-area-inset-bottom))
        max(var(--ds-space-6), env(safe-area-inset-left));
    }

    .ds-dialog--full-narrow .ds-dialog__head {
      margin: 0 calc(var(--ds-space-2) - var(--ds-space-6));
      padding-top: env(safe-area-inset-top);
    }
  }

  @supports (transition-behavior: allow-discrete) {
    @media (max-width: 600px) {
      dialog.ds-dialog[open]:not([data-state]) .ds-dialog__box {
        transition:
          opacity var(--ds-dur-fast) var(--ds-ease-in-out),
          scale var(--ds-dur-slow) var(--ds-ease-out),
          translate var(--ds-dur-slow) var(--ds-ease-out);
      }
    }
  }

  /* ── Full screen and action sheet: they rise, no scale ──
     After the phone rules, which they override: the full-screen box
     always rises on the spring (a 12px rise has nothing to overshoot past),
     the action sheet rises its full height from the bottom edge. Both
     fade in over --ds-dur-fast and leave in --ds-dur-base, dropping back,
     accelerating. */
  .ds-dialog--full[data-state] .ds-dialog__box,
  .ds-dialog--bottom[data-state] .ds-dialog__box {
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      translate var(--ds-dur-long) var(--ds-ease-spatial);
  }

  .ds-dialog--full[data-state="closed"] .ds-dialog__box,
  .ds-dialog--bottom[data-state="closed"] .ds-dialog__box {
    scale: none;
    transition:
      opacity var(--ds-dur-base) var(--ds-ease-in-out),
      translate var(--ds-dur-base) var(--ds-ease-exit);
  }

  .ds-dialog--full[data-state="closed"] .ds-dialog__box {
    translate: 0 var(--ds-distance-lg);
  }

  .ds-dialog--bottom[data-state="closed"] .ds-dialog__box {
    translate: 0 var(--ds-distance-sheet);
  }

  /* By hand (no data-state): the same rise as keyframes. */
  .ds-dialog--full:not([data-state]) .ds-dialog__box,
  .ds-dialog--bottom:not([data-state]) .ds-dialog__box {
    --ds-motion-distance: var(--ds-distance-lg);
    animation:
      ds-fade-in var(--ds-dur-fast) var(--ds-ease-in-out) backwards,
      ds-lift-in var(--ds-dur-long) var(--ds-ease-spatial) backwards;
  }

  .ds-dialog--bottom:not([data-state]) .ds-dialog__box {
    --ds-motion-distance: var(--ds-distance-sheet);
  }

  .ds-dialog--full.ds-dialog--closing:not([data-state]) .ds-dialog__box,
  .ds-dialog--bottom.ds-dialog--closing:not([data-state]) .ds-dialog__box {
    animation:
      ds-fade-out var(--ds-dur-base) var(--ds-ease-in-out) forwards,
      ds-drop-out var(--ds-dur-base) var(--ds-ease-exit) forwards;
  }

  @media (max-width: 600px) {
    .ds-dialog--full-narrow[data-state] .ds-dialog__box {
      transition:
        opacity var(--ds-dur-fast) var(--ds-ease-in-out),
        translate var(--ds-dur-long) var(--ds-ease-spatial);
    }

    .ds-dialog--full-narrow[data-state="closed"] .ds-dialog__box {
      scale: none;
      translate: 0 var(--ds-distance-lg);
      transition:
        opacity var(--ds-dur-base) var(--ds-ease-in-out),
        translate var(--ds-dur-base) var(--ds-ease-exit);
    }

    .ds-dialog--full-narrow:not([data-state]) .ds-dialog__box {
      --ds-motion-distance: var(--ds-distance-lg);
      animation:
        ds-fade-in var(--ds-dur-fast) var(--ds-ease-in-out) backwards,
        ds-lift-in var(--ds-dur-long) var(--ds-ease-spatial) backwards;
    }

    .ds-dialog--full-narrow.ds-dialog--closing:not([data-state]) .ds-dialog__box {
      animation:
        ds-fade-out var(--ds-dur-base) var(--ds-ease-in-out) forwards,
        ds-drop-out var(--ds-dur-base) var(--ds-ease-exit) forwards;
    }
  }

  /* A native full-screen dialog or action sheet driven by hand: the same
     rise on transitions, keyed on [open]. The open state names [open], so
     it outweighs the phone dialog's ease-out (dialog.ds-dialog[open]
     above). */
  @supports (transition-behavior: allow-discrete) {
    dialog.ds-dialog--full:not([data-state]) .ds-dialog__box,
    dialog.ds-dialog--full[open]:not([data-state]) .ds-dialog__box,
    dialog.ds-dialog--bottom:not([data-state]) .ds-dialog__box,
    dialog.ds-dialog--bottom[open]:not([data-state]) .ds-dialog__box {
      animation: none;
      transition:
        opacity var(--ds-dur-fast) var(--ds-ease-in-out),
        translate var(--ds-dur-long) var(--ds-ease-spatial);
    }

    dialog.ds-dialog--full:not([data-state]):not([open]) .ds-dialog__box,
    dialog.ds-dialog--bottom:not([data-state]):not([open]) .ds-dialog__box {
      scale: none;
      transition:
        opacity var(--ds-dur-base) var(--ds-ease-in-out),
        translate var(--ds-dur-base) var(--ds-ease-exit);
    }

    dialog.ds-dialog--full:not([data-state]):not([open]) .ds-dialog__box {
      translate: 0 var(--ds-distance-lg);
    }

    dialog.ds-dialog--bottom:not([data-state]):not([open]) .ds-dialog__box {
      translate: 0 var(--ds-distance-sheet);
    }

    /* --full-narrow is a full-screen dialog at 600px and below: the same
       rise. */
    @media (max-width: 600px) {
      dialog.ds-dialog--full-narrow:not([data-state]) .ds-dialog__box,
      dialog.ds-dialog--full-narrow[open]:not([data-state]) .ds-dialog__box {
        animation: none;
        transition:
          opacity var(--ds-dur-fast) var(--ds-ease-in-out),
          translate var(--ds-dur-long) var(--ds-ease-spatial);
      }

      dialog.ds-dialog--full-narrow:not([data-state]):not([open]) .ds-dialog__box {
        scale: none;
        translate: 0 var(--ds-distance-lg);
        transition:
          opacity var(--ds-dur-base) var(--ds-ease-in-out),
          translate var(--ds-dur-base) var(--ds-ease-exit);
      }
    }
  }

  @starting-style {
    dialog.ds-dialog--full[open]:not([data-state]) .ds-dialog__box {
      scale: none;
      translate: 0 var(--ds-distance-lg);
    }

    dialog.ds-dialog--bottom[open]:not([data-state]) .ds-dialog__box {
      scale: none;
      translate: 0 var(--ds-distance-sheet);
    }
  }

  @media (max-width: 600px) {
    @starting-style {
      dialog.ds-dialog--full-narrow[open]:not([data-state]) .ds-dialog__box {
        scale: none;
        translate: 0 var(--ds-distance-lg);
      }
    }
  }

  @media print {
    .ds-dialog {
      display: none;
    }
  }
}

/* ── components/toast.css ── */
/*
 * Snackbar (Material 3): the inverse surface on the overlay elevation, at
 * the bottom left of the content panel. Icon and text, never colour alone.
 * Place the region right after .ds-app: next to a persistent drawer (or
 * its rail) it then starts at the panel's edge by itself.
 * Enters with a fade and a short rise on the spatial spring, leaves with a
 * quick fade in place while the messages around it close the gap.
 *
 *   <div class="ds-toasts"></div>
 *   DS.overlay.toast(region, 'Saved.');                      // or { kind: 'error' }, { persistent: true }
 *   DS.overlay.toast(region, '3 devices deleted.', { action: { label: 'Undo', onClick } });
 *
 *   <div class="ds-toast [ds-toast--error | ds-toast--info]">  what toast() builds from a string
 *     <svg ...>(checkCircle | alertCircle, 20)</svg>
 *     <span class="ds-toast__text">Saved.</span>
 *     <button class="ds-btn ds-btn--text ds-btn--sm ds-toast__action" type="button">Undo</button>   with `action`
 *     <button class="ds-btn ds-btn--quiet ds-btn--icon ds-toast__close" type="button" aria-label="Close" data-tip="Close">(close)</button>
 *
 *   .ds-toast--shown  runtime: toast() marks a message whose entrance has
 *                     played, so moving its region never replays it
 *
 * Any text button in a message (.ds-toast__action, or one of a message
 * built by hand) takes the inverse primary ink (--ds-toast-action-color)
 * and sits at the end, before the close button.
 *
 * DS.overlay.toast() (js/overlay.js) stacks at most three with FLIP, evicts
 * the oldest message that would close by itself or queues one behind three
 * that stay, restarts the same message instead of stacking it, replaces a
 * message by its key, pauses the timers on hover, focus and a hidden tab,
 * announces the text through two .ds-sr live regions next to the region
 * (the region and the messages are not live, so their buttons stay
 * reachable; toast() takes role and aria-live off an old region), keeps
 * focus off <body> when a focused message leaves, and moves the region
 * into an open dialog (in place) or side sheet (with the panel's edge and
 * above its foot, data-follow), so messages stay above its scrim and
 * usable.
 *
 * Hooks: --ds-toast-action-color (the text button's ink, default
 * --ds-inverse-primary), --ds-toast-close-size (the close button, 36px).
 *
 * Runtime properties: --ds-toast-left (the region's left edge; a page sets
 * it when the panel starts elsewhere, DS.overlay in a side sheet),
 * --ds-toast-bottom (its distance from the bottom edge, page-set),
 * --ds-toast-lift (written by DS.overlay only: the room above a sheet's
 * foot, the modal drawer's foot on phones, a shown save bar),
 * --ds-keyboard-inset (the on-screen keyboard, DS.dom.followViewport() on
 * <html>), --ds-toast-shift and --ds-toast-shift-y (written by DS.overlay
 * only: the glide from where the region stood), and --ds-toast-reserve with
 * data-toast-reserve (the room kept for messages that stay: toast() sets
 * them on .ds-view, a hosting .ds-sheet__body or .ds-dialog; the page only
 * with `reserve: false`). data-ds-toast-room="off" (page-set, 2.1) on one
 * of those layers: it keeps no room, the others still do.
 *
 * Progressive:
 * - View Transitions (while DS.motion.transition() runs, data-ds-vt on
 *   <html>, a region with messages is named ds-toasts, so motion.css keeps
 *   it live and still above the page's groups; in the root snapshot the
 *   panel's opaque image would cover it until the end. One region with
 *   messages per page: transition() lifts the name of any further one):
 *   without them nothing is named.
 *
 * Print: the region is not printed (a message is about the screen, now).
 */

@layer ds.components {
  /* Physical on purpose (D9): --ds-toast-left is a script coordinate (the
     left edge of the content panel), and DS.overlay reads the computed
     left back. */
  .ds-toasts {
    position: fixed;
    left: var(--ds-toast-left, var(--ds-space-6));
    bottom: calc(var(--ds-toast-bottom, var(--ds-space-6)) + var(--ds-toast-lift, 0px) + var(--ds-keyboard-inset, 0px));
    /* Above the home indicator (viewport-fit=cover); a margin, so `bottom`
       stays the offset a script may mirror. */
    margin-bottom: env(safe-area-inset-bottom);
    z-index: var(--ds-z-toast);
    display: grid;
    justify-items: start;
    /* The stack keeps to the bottom edge: while a leaving message holds the
       region's height (DS.motion.flip()), the others stay where they are. */
    align-content: end;
    gap: var(--ds-space-2);
    max-width: min(560px, calc(100vw - var(--ds-toast-left, var(--ds-space-6)) - var(--ds-space-6)));
    pointer-events: none;
    /* Moving into or out of a side sheet, above a foot or a save bar, the
       messages on screen start where they stood and glide to the new place
       on both axes (DS.overlay sets the shift for one style flush). */
    translate: var(--ds-toast-shift, 0px) var(--ds-toast-shift-y, 0px);
    transition: translate var(--ds-dur-medium) var(--ds-ease-in-out);
  }

  /* Following a side sheet (DS.overlay.open / close) or a save bar: the
     messages arrive with the panel's edge, on the sheet's own timing. */
  .ds-toasts[data-follow="enter"] {
    transition-duration: var(--ds-dur-long);
    transition-timing-function: var(--ds-ease-spatial);
  }

  .ds-toasts[data-follow="exit"] {
    transition-duration: var(--ds-dur-base);
    transition-timing-function: var(--ds-ease-exit);
  }

  /* Beside the persistent drawer (above 900px, shell.css): the panel's
     left edge plus the usual 24px. */
  @media (min-width: 901px) {
    .ds-app--drawer:not([data-drawer-hidden]) ~ .ds-toasts {
      --ds-toast-left: calc(var(--ds-drawer-width) + env(safe-area-inset-left) + var(--ds-space-6));
    }

    /* Beside the rail (data-drawer-collapsed): its narrower column. */
    .ds-app--drawer[data-drawer-collapsed]:not([data-drawer-hidden]) ~ .ds-toasts {
      --ds-toast-left: calc(var(--ds-drawer-rail-width) + env(safe-area-inset-left) + var(--ds-space-6));
    }
  }

  /* Beside the open modal drawer (601px to 900px): next to it, never under
     it. On phones the region spans the width; DS.overlay lifts it above
     the drawer's foot instead. */
  @media (min-width: 601px) and (max-width: 900px) {
    .ds-app[data-drawer-open] ~ .ds-toasts {
      --ds-toast-left: calc(var(--ds-drawer-width) + env(safe-area-inset-left) + var(--ds-space-6));
    }
  }

  .ds-toast {
    display: flex;
    align-items: center;
    gap: var(--ds-space-3);
    min-height: 48px;
    max-width: 100%;
    padding: var(--ds-space-3) var(--ds-space-4);
    border-radius: var(--ds-radius-sm);
    background: var(--ds-inverse);
    color: var(--ds-on-inverse);
    font: var(--ds-font-body);
    box-shadow: var(--ds-elev-overlay);
    /* Invisible; forced colors paint it, so the message keeps an edge. */
    outline: 1px solid transparent;
    pointer-events: auto;
    --ds-motion-distance: var(--ds-distance-md);
    --ds-leave-duration: var(--ds-dur-fast);
    animation:
      ds-fade-in var(--ds-dur-medium) var(--ds-ease-in-out) backwards,
      ds-lift-in var(--ds-dur-medium) var(--ds-ease-spatial) backwards;
  }

  /* Played once (mark it .ds-toast--shown; toast() does after the
     entrance): moving the region into or out of a side sheet re-inserts
     the message where moveBefore() is missing, which would restart it, so
     a message already on screen would blink out and rise again. enter() and
     .ds-leave still play: the motion utilities sit in the ds.utilities
     layer, which beats this rule whatever its specificity. */
  .ds-toast--shown:not(.ds-leave) {
    animation: none;
  }

  .ds-toast > svg {
    flex-shrink: 0;
    color: var(--ds-inverse-primary);
  }

  .ds-toast__text {
    min-width: 0;
    overflow-wrap: anywhere;
  }

  .ds-toast--error > svg,
  .ds-toast--info > svg {
    color: var(--ds-on-inverse);
  }

  /* A text button in a message (the action of toast(), or Undo in a message
     built by hand): the inverse primary ink, readable on the inverse
     surface, at the end of the line; its pill overhangs the padding, so the
     message keeps its height. */
  .ds-toast > .ds-btn--text {
    --ds-btn-color: var(--ds-toast-action-color, var(--ds-inverse-primary));
    --ds-btn-hover-color: var(--ds-toast-action-color, var(--ds-inverse-primary));
    flex-shrink: 0;
    margin-block: calc(-1 * var(--ds-space-2));
    margin-inline-start: auto;
  }

  .ds-toast > .ds-btn--text:focus-visible {
    outline-color: var(--ds-on-inverse);
  }

  /* The shared quiet icon button (state layers for hover and press), in the
     inverse text colour; toast.css loads after button.css, so these win. */
  .ds-toast__close {
    --ds-btn-icon-size: var(--ds-toast-close-size, 36px);
    --ds-btn-quiet-color: var(--ds-on-inverse);
    --ds-btn-hover-color: var(--ds-on-inverse);
    flex-shrink: 0;
    margin-block: calc(-1 * var(--ds-space-2));
    margin-inline: auto calc(-1 * var(--ds-space-2));
  }

  /* After an action the close button keeps close to it. */
  .ds-toast > .ds-btn--text + .ds-toast__close {
    margin-inline-start: calc(-1 * var(--ds-space-1));
  }

  .ds-toast__close:focus-visible {
    outline-color: var(--ds-on-inverse);
  }

  /* While a message that stays shows (toast() measures and sets both, or the
     page with `reserve: false`), the content panel keeps room for it at its
     end: the last form actions can scroll clear of it. The panel's own
     padding is the safe area (shell.css). A side sheet's body does the
     same while the message shows in it (sheet.css). */
  .ds-view[data-toast-reserve] {
    padding-bottom: calc(env(safe-area-inset-bottom) + var(--ds-toast-reserve, 0px));
    scroll-padding-bottom: var(--ds-toast-reserve, 0px);
  }

  /* In a dialog that hosts the region: while the box would overlap the
     stack, the dialog keeps the stack's room at its bottom, so the box
     re-centres above it (no motion: the room comes with the message). */
  .ds-dialog[data-toast-reserve] {
    padding-bottom: var(--ds-toast-reserve, var(--ds-space-4));
  }

  /* A region with messages, only while a transition runs (see the header). */
  .ds-theme[data-ds-vt] .ds-toasts:has(> .ds-toast) {
    view-transition-name: ds-toasts;
  }

  /* Phones: across the width, above the bottom edge. */
  @media (max-width: 600px) {
    .ds-toasts {
      inset-inline: var(--ds-space-4);
      bottom: calc(var(--ds-toast-bottom, var(--ds-space-4)) + var(--ds-toast-lift, 0px) + var(--ds-keyboard-inset, 0px));
      max-width: none;
      justify-items: stretch;
    }
  }

  /* Print: messages stay on the screen. */
  @media print {
    .ds-toasts {
      display: none;
    }
  }
}

/* ── components/tooltip.css ── */
/*
 * Plain tooltip (Material 3): the inverse surface, the meta role, below its
 * control (above it when there is no room). It fades in and grows from
 * --ds-scale-tooltip at the side of its anchor (no rise: it is anchored to
 * the control), and leaves with a quick fade only. The pointer may move
 * onto it (WCAG 1.4.13: a magnifier user can read it there); it stays open
 * meanwhile.
 *
 * DS.overlay.tooltips(root) (js/overlay.js) does everything for
 * [data-tip] triggers: it creates the one tooltip of the theme, shows it
 * after --ds-delay-tooltip on hover (mouse and pen) and at once on keyboard
 * focus, on touch after a tap on an image or a blocked control and a long
 * press on the other triggers (no timeout), warms up along a toolbar
 * (--ds-delay-tooltip-skip), hides after the --ds-delay-tooltip-hide
 * grace, on Escape, on any pointerdown and when its trigger leaves the
 * document, shows a tip that repeats the visible text only while that text
 * is cut off (a name in a narrow column), and places it:
 *
 *   <button class="ds-btn ds-btn--quiet ds-btn--icon" type="button" aria-label="Copy link" data-tip="Copy link">(link)</button>
 *   <td><a href="#device-42" data-tip="LAPTOP-0042-FINANCE-BACKUP">LAPTOP-0042-FINANCE-BACKUP</a></td>   only while cut off
 *   DS.overlay.tooltips(document);
 *
 *   <div class="ds-tooltip" role="tooltip" id="..." hidden data-state="open | closed" [data-warm] [data-placement="top"] [data-anchored]>Copy link</div>
 *
 * Placement API: --ds-tooltip-x and --ds-tooltip-y (left and top, px) on
 * the tooltip. Inline top / left and a transform of the page still work:
 * the motion animates the separate scale property, so it composes. With a
 * transform, set transform-origin with it, or the scale shrinks the offset
 * too and the tooltip drifts in.
 *
 * Motion: tooltips() drives data-state ("closed", flushed, then "open";
 * "closed" again to hide), so a show taken back mid-way continues from
 * where it is (transitions retarget). The origin is --ds-motion-origin,
 * which DS.motion.originFrom() puts at the anchor side; data-warm (a show
 * during the warm-up) keeps the scale at rest, so the next tooltip only
 * fades in. Without data-state (a page that drives it by hand: remove
 * hidden, leave with DS.motion.leave(tip, '', { then: 'hide' })) the
 * keyframe entrance and .ds-leave still play.
 *
 * Progressive:
 * - popover and CSS anchor positioning (popover="manual" puts the tooltip
 *   into the top layer, over a modal dialog; anchor-name on the trigger,
 *   position-anchor, position-area and position-try-fallbacks on the
 *   tooltip, behind @supports (position-area: block-end); data-anchored
 *   marks that path): without them the script writes --ds-tooltip-x/-y and
 *   flips the tooltip above the trigger (data-placement="top").
 *
 * Print: a tooltip is not printed.
 */

@layer ds.components {
  /* Physical on purpose (D9): --ds-tooltip-x is a script coordinate from
     the left, and the anchored placement (below) clears it. */
  .ds-tooltip {
    position: fixed;
    left: var(--ds-tooltip-x, 0px);
    top: var(--ds-tooltip-y, 0px);
    z-index: var(--ds-z-toast);
    /* Its own width wherever it was last placed (measure it before moving
       it), wrapping only past the maximum. */
    width: max-content;
    max-width: min(320px, calc(100vw - 2 * var(--ds-space-2)));
    padding: var(--ds-space-1) var(--ds-space-2);
    border-radius: var(--ds-radius-xs);
    background: var(--ds-inverse);
    color: var(--ds-on-inverse);
    font: var(--ds-font-meta);
    /* Line breaks in the text are kept (a name and an email on two lines). */
    white-space: pre-line;
    overflow-wrap: anywhere;
    /* Invisible; forced colors paint it, so the tooltip keeps an edge. */
    outline: 1px solid transparent;
    /* Grows at the side of its anchor: originFrom() writes the origin;
       below the trigger that is its top edge. */
    --ds-motion-scale: var(--ds-scale-tooltip);
    transform-origin: var(--ds-motion-origin, 50% 0%);
  }

  /* Above the trigger: it grows from its bottom edge. */
  .ds-tooltip[data-placement="top"] {
    transform-origin: var(--ds-motion-origin, 50% 100%);
  }

  /* Driven by hand (no data-state): the one-shot entrance; leave with
     .ds-leave, a quick fade. */
  .ds-tooltip:not([data-state]) {
    --ds-motion-distance: 0px;
    --ds-leave-duration: var(--ds-dur-instant);
    animation:
      ds-fade-in var(--ds-dur-fast) var(--ds-ease-in-out) backwards,
      ds-grow-in var(--ds-dur-fast) var(--ds-ease-out) backwards;
  }

  /* Driven by DS.overlay.tooltips(): transitions keyed on data-state, so a
     hide taken back continues from the current opacity. The entrance fades
     in over --ds-dur-fast and springs to full size over --ds-dur-medium. */
  .ds-tooltip[data-state] {
    animation: none;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      scale var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  /* The exit only fades, in --ds-dur-instant; the scale goes back to its
     start once the tooltip is invisible, ready for the next entrance. It
     takes no pointer while it fades or rests hidden. */
  .ds-tooltip[data-state="closed"] {
    opacity: 0;
    scale: var(--ds-motion-scale, var(--ds-scale-tooltip));
    pointer-events: none;
    transition:
      opacity var(--ds-dur-instant) var(--ds-ease-in-out),
      scale 0ms var(--ds-ease-in-out) var(--ds-dur-instant);
  }

  /* Warm-up: the next tooltip of a toolbar only fades in. */
  .ds-tooltip[data-state][data-warm] {
    scale: none;
  }

  .ds-tooltip[hidden] {
    display: none;
  }

  /* The UA box of a popover (centred by auto margins over the whole
     window, a border, its own scrolling) goes: it is the same tooltip. */
  .ds-tooltip[popover] {
    inset: var(--ds-tooltip-y, 0px) auto auto var(--ds-tooltip-x, 0px);
    width: max-content;
    height: auto;
    margin: 0;
    border: 0;
    overflow: visible;
  }

  /* Anchor positioning: below the trigger, centred on it, above it when
     there is no room (the margin flips with it); the side margins keep it
     8px from the viewport edges, like the script's placement. */
  @supports (position-area: block-end) {
    .ds-tooltip[data-anchored] {
      position-area: block-end;
      position-try-fallbacks: flip-block;
      margin: var(--ds-space-2) var(--ds-space-2) 0;
      left: auto;
      top: auto;
    }
  }

  @media print {
    .ds-tooltip {
      display: none;
    }
  }
}

/* ── components/savebar.css ── */
/*
 * Save bar: the unsaved edits of a view, saved or discarded together. A
 * floating bar at the bottom of the view, centred in the page column,
 * sticky while the view scrolls; it takes no room of its own (height 0),
 * the view keeps its end clear of it while it shows.
 *
 *   <main class="ds-view" id="main" tabindex="-1">
 *     <div class="ds-content"> ... </div>
 *     <div class="ds-savebar">
 *       <div class="ds-savebar__bar ds-presence" role="region" aria-label="Unsaved changes" hidden>
 *         <p class="ds-savebar__text">2 unsaved changes</p>
 *         <div class="ds-savebar__actions">
 *           <button class="ds-btn ds-btn--text" type="button">Discard</button>
 *           <button class="ds-btn ds-btn--primary" type="button"><span class="ds-btn__faces">...</span></button>
 *         </div>
 *       </div>
 *     </div>
 *   </main>
 *
 *   const bar = DS.overlay.savebar(document.querySelector('.ds-savebar'), { focusTarget: () => form });
 *   bar.show();  bar.hide();  bar.update(() => { text.textContent = ... });
 *
 * Motion: .ds-presence (motion.css) with --ds-motion-scale at
 * --ds-scale-enter from the bottom edge: it fades in and grows on the
 * spring, leaves faster; a toggle mid-way continues from where it is.
 *
 * Runtime (written by DS.overlay.savebar(), owned): data-ds-savebar on the
 * view while the bar shows, data-ds-savebar-rest after a hide at the end of
 * the page (the room stays until the page scrolls away from the end), and
 * --ds-savebar-height (the bar's height, px). The view's scroll padding and
 * the content's bottom padding keep the bar's room: its height, its offset
 * and --ds-space-2; the room of snackbars that stay (--ds-toast-reserve)
 * moves into the content's padding meanwhile (a padding of the view would
 * lift the sticky bar).
 *
 * Hooks: --ds-savebar-offset (distance from the view's bottom edge,
 * --ds-space-4), --ds-savebar-inset (from the view's sides, the page pad
 * --ds-content-pad-x), --ds-savebar-min-height (56px),
 * --ds-savebar-radius (--ds-radius-card), --ds-savebar-bg
 * (--ds-surface-popover, the floating surface: the dark theme lifts it like
 * a menu), --ds-savebar-shadow (--ds-elev-overlay).
 *
 * Progressive:
 * - @starting-style and transition-behavior: allow-discrete (the
 *   .ds-presence entrance and exit): without them the bar shows and hides
 *   at once.
 */

@layer ds.components {
  .ds-savebar {
    position: sticky;
    bottom: 0;
    z-index: var(--ds-z-sticky);
    flex-shrink: 0;
    height: 0;
    /* Under short content it still sits at the view's bottom (the view is
       a flex column, shell.css). */
    margin-top: auto;
  }

  .ds-savebar__bar {
    position: absolute;
    bottom: var(--ds-savebar-offset, var(--ds-space-4));
    /* The page column: the view's pad on both sides, at most as wide as
       the page, centred. */
    inset-inline: var(--ds-savebar-inset, var(--ds-content-pad-x, var(--ds-space-8)));
    max-width: var(--ds-content-max);
    margin-inline: auto;
    box-sizing: border-box;
    min-height: var(--ds-savebar-min-height, 56px);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ds-space-2) var(--ds-space-4);
    padding-block: var(--ds-space-2);
    padding-inline: var(--ds-space-5) var(--ds-space-3);
    border-radius: var(--ds-savebar-radius, var(--ds-radius-card));
    background: var(--ds-savebar-bg, var(--ds-surface-popover));
    color: var(--ds-text);
    box-shadow: var(--ds-savebar-shadow, var(--ds-elev-overlay));
    /* Invisible; forced colors paint it, so the bar keeps an edge. */
    outline: 1px solid transparent;
    --ds-motion-scale: var(--ds-scale-enter);
    transform-origin: 50% 100%;
  }

  /* Hidden also without .ds-base; .ds-presence keeps it displayed through
     its exit (display steps with allow-discrete). */
  .ds-savebar__bar[hidden] {
    display: none;
  }

  .ds-savebar__text {
    flex: 1 1 12em;
    min-width: 0;
    margin: 0;
    font: var(--ds-font-body);
    color: var(--ds-text);
  }

  .ds-savebar__actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--ds-space-2);
    margin-inline-start: auto;
  }

  /* While it shows (or rests after a hide at the end of the page), the view
     keeps its end clear of it: scrolling into view stops above the bar, and
     the content's last element can scroll above it. */
  .ds-view[data-ds-savebar] {
    scroll-padding-bottom: max(calc(var(--ds-savebar-height, 0px) + var(--ds-savebar-offset, var(--ds-space-4)) + var(--ds-space-2)), var(--ds-toast-reserve, 0px));
  }

  /* The room of snackbars that stay (data-toast-reserve, toast.css) moves
     from the view's padding to the content's: a padding of the view would
     lift the sticky bar off the view's bottom, and the messages above it
     with it. */
  .ds-view[data-ds-savebar][data-toast-reserve] {
    padding-bottom: env(safe-area-inset-bottom);
  }

  /* --ds-content-pad-bottom is the content's own (page.css), phones
     included. */
  .ds-view:is([data-ds-savebar], [data-ds-savebar-rest]) > .ds-content {
    padding-bottom: max(var(--ds-content-pad-bottom), calc(var(--ds-savebar-height, 0px) + var(--ds-savebar-offset, var(--ds-space-4)) + var(--ds-space-2)), var(--ds-toast-reserve, 0px));
  }

  /* Outside a .ds-view (no page pad to read) the phone pad. */
  @media (max-width: 720px) {
    .ds-savebar__bar {
      inset-inline: var(--ds-savebar-inset, var(--ds-content-pad-x, var(--ds-space-4)));
    }
  }

  @media (max-width: 600px) {
    .ds-savebar__bar {
      padding-inline-start: var(--ds-space-4);
    }
  }

  @media print {
    .ds-savebar {
      display: none;
    }
  }
}

/* ── components/shell.css ── */
/*
 * App frame (Google console layout): the frame fills the window on the
 * tinted backdrop; a top app bar (--ds-topbar-height) and an optional
 * navigation drawer (--ds-drawer-width), both without lines or shadows,
 * and one white content panel, the only scroller of the page. At 900px
 * and below the drawer becomes modal (a sheet from the left over a scrim).
 *
 *   <body>
 *   <div class="ds-app ds-app--drawer" [data-drawer-open] [data-drawer-hidden] [data-drawer-collapsed] [data-scrolled]>
 *     <a class="ds-skip" href="#main">Skip to content</a>
 *     <div class="ds-topbar">
 *       <button class="ds-btn ds-btn--quiet ds-btn--icon ds-topbar__menu" type="button" aria-label="Menu" aria-expanded="false">(menu)</button>
 *       <a class="ds-brand" href="/">[mark] <span class="ds-brand__name">Product</span></a>
 *       [<h1 class="ds-topbar__title">Page</h1>]   (instead of a page head on small screens)
 *       [<div class="ds-topbar__search"> <div class="ds-search"> ... </div> </div>]   (hidden below 720px)
 *       [<div class="ds-topbar__actions"> icon buttons, each with data-tip </div>]
 *       <div class="ds-account"> <div class="ds-account__text"> <div class="ds-account__name"> <div class="ds-account__email"> </div>
 *         <span class="ds-account__avatar" role="img" aria-label="..." tabindex="0" data-tip="...">A</span> <button class="ds-btn ds-btn--text" type="button">Sign out</button>
 *       (.ds-account--capsule: avatar, text aria-hidden, <span class="ds-account__divider"></span>, the sign-out button)
 *     </div>
 *     <div class="ds-scrim"></div>     (before the drawer, so it covers the page only)
 *     <nav class="ds-drawer" aria-label="Main" tabindex="-1">
 *       <div class="ds-drawer__head" aria-hidden="true"> (brand again, shown in the modal drawer) </div>
 *       [<div class="ds-drawer__back"> <a class="ds-drawer__item" href="/">(arrowLeft 20) <span class="ds-drawer__label">All devices</span></a> </div>]
 *       [<div class="ds-drawer__actions"> a primary action </div>]
 *       <div class="ds-drawer__list">
 *         <div class="ds-drawer__group"> <p class="ds-drawer__heading">Group</p>
 *           <a class="ds-drawer__item" href="/a" aria-current="page">(icon 20) <span class="ds-drawer__label">Overview</span> [<span class="ds-drawer__count"><span class="ds-sr">, </span>3</span>]</a>
 *           (or <button class="ds-drawer__item"> that shows a section in place; also aria-current="page" when active)
 *         <details class="ds-drawer__group ds-collapsible" open>   (a group that closes)
 *           <summary class="ds-drawer__heading ds-drawer__toggle">(chevronDown .ds-chevron) <span>Group</span> <span class="ds-drawer__count">3</span></summary> items ...
 *         <div class="ds-drawer__row"> <a class="ds-drawer__item" ...> <button class="ds-btn ds-btn--quiet ds-btn--icon ds-btn--sm ds-drawer__action ds-hover-actions" type="button" aria-label="..." data-tip="...">(moreVertical)</button> </div>
 *       </div>
 *       [<div class="ds-drawer__foot [ds-drawer__foot--rail]"> drawer items (settings, help) </div>]
 *     </nav>
 *     <div class="ds-panel">
 *       [<div class="ds-banner"> <div class="ds-alert ds-alert--warning" role="status"> ... </div> </div>]
 *       <main class="ds-view [ds-view--fill | ds-view--fixed]" id="main" tabindex="-1"> <div class="ds-content"> <div class="ds-page"> ...
 *
 *   An avatar elsewhere: <span class="ds-avatar [ds-avatar--sm | ds-avatar--lg]" role="img" aria-label="Ada Lovelace">A</span>
 *   or <img class="ds-avatar" src="..." alt=""> next to the name.
 *
 * State attributes on .ds-app, set by the page's script:
 *   data-drawer-open    the modal drawer is open (at 900px and below)
 *   data-drawer-hidden  this view has no drawer (the panel takes the width)
 *   data-drawer-collapsed  above 900px the drawer is a rail (--ds-drawer-rail-width):
 *                       icons only, labels and headings visually hidden but read,
 *                       each item's data-tip names it; the page sets it (and
 *                       aria-pressed on the menu button, which then shows above
 *                       900px) and keeps it across loads
 *   data-scrolled       the view is scrolled: the panel's top hairline shows
 *                       and the top bar tints (--ds-topbar-scrolled-bg);
 *                       filled fields in it step up to --ds-surface-highest.
 *                       Where scroll timelines exist, hairline and tint
 *                       follow the scroll position themselves (below); set
 *                       the attribute anyway: DS.dom.watchScrolled(view)
 *                       writes it (a 1px sentinel, docs/components.md)
 *
 * Under .ds-touch the brand link is at least --ds-touch-target on both
 * sides (the targets of the drawer and the buttons follow their hooks).
 *
 * .ds-drawer--instant on the drawer: no slide and no scrim fade while the
 * window crosses 900px (the page adds it on the matchMedia change and
 * removes it two frames later; docs/components.md, App frame).
 *
 * Motion: the modal drawer is an opaque sheet. It opens on the spatial
 * spring (--ds-dur-long) and closes accelerating (--ds-dur-base,
 * --ds-ease-exit); a bleed on its left covers the spring's overshoot, so
 * the scrim never shows at the screen edge. A state change mid-flight
 * continues from where the sheet is. The docked drawer coming back
 * (DS.motion.enter(drawer)) slides in from the left like .ds-enter--slide.
 * data-drawer-hidden toggled inside DS.motion.transition() morphs the
 * panel to its new width while the drawer leaves or slides back (the
 * transition plays it; enter(drawer) stays quiet meanwhile).
 *
 * A segmented switch (.ds-segmented) may follow the brand in the top bar
 * (it takes its start margin only there) or sit in a page toolbar, flush.
 * Its options are links (aria-current="true") or buttons (role="radio"
 * with aria-checked, or aria-pressed); a button option loses the browser's
 * button look, and a :disabled or aria-disabled option takes the disabled
 * model (no hover, press or morph; aria-disabled keeps its focus layer).
 * The active drawer item glides with js/indicator.js
 * (createIndicator(list, { active: '[aria-current="page"]', className: 'ds-drawer__indicator' })),
 * which follows aria-current by itself. While the pill glides, the labels
 * change colour on its timing (--ds-indicator-dur); the weight switches at
 * once, never animated. Without the indicator an item paints its own tint
 * and label colour in one frame.
 *
 * Drawer items and segmented options press in --ds-dur-micro (also while
 * js/ripple.js holds .ds-pressed) and ripple, and turn squarer while
 * pressed (--ds-radius-action-pressed, in --ds-dur-micro, springing back
 * on release): a drawer item morphs itself and, when it is the active one,
 * the pill under it; a segmented option morphs the clip of its state layer
 * (the pill around the options clips them). Nothing moves. An
 * aria-disabled drawer item shows no hover, press or morph. Their
 * keyboard rings are inset (the list and the pill would clip anything
 * outside): solid from the first frame, they rest on the edge and snap
 * inward (outline-offset, --ds-dur-fast), with the focus layer
 * (--ds-state-focus). The skip link's ring snaps in from outside.
 *
 * Hooks: --ds-drawer-item-height (48px; the rest radius is half of it),
 * --ds-topbar-scrolled-bg (the top bar while the view is scrolled;
 * --ds-surface-high, transparent keeps a flat bar),
 * --ds-view-scrollbar-gutter (stable; stable both-edges, or auto),
 * --ds-segmented-padding-x (--ds-space-4: the inline padding of an
 * option), --ds-topbar-search-max (720px), --ds-account-text-max
 * (240px), --ds-avatar-size (32px). Layout role: --ds-drawer-rail-width.
 *
 * Drawer parts: .ds-drawer__back (a way up, between head and list, with a
 * hairline under it; above 900px the docked drawer's back item hides the
 * page head's back link), .ds-drawer__actions (above the list),
 * .ds-drawer__foot (below it; --rail lays its items out icon-only in a
 * row), .ds-drawer__count (a count at the end of an item or a group
 * heading; a group heading shows it only while closed), collapsible
 * groups (<details class="ds-drawer__group ds-collapsible"> with a
 * pressable .ds-drawer__toggle summary: state layer, ring snap, ripple),
 * .ds-drawer__row with a .ds-drawer__action (a quiet icon button at the
 * end of an item, shown on demand through .ds-hover-actions; the item
 * keeps room for it at its size, --ds-btn-sm-icon-size). The pill
 * follows the current item across groups; an item inside a closed group
 * has no pill. The rail (data-drawer-collapsed) switches its column at
 * once; the labels fade back in on --ds-dur-fast when it widens again
 * (--ds-dur-instant out); nothing else animates.
 *
 * Top bar parts: .ds-topbar__title (a page title that takes the room
 * between brand and account; the label role in two lines at 600px and
 * below, where the brand name gives way), .ds-topbar__search (centred,
 * at most --ds-topbar-search-max, hidden below 720px) and
 * .ds-topbar__actions (icon buttons with data-tip). A brand of only a name
 * aligns its text with the drawer's icon edge and keeps it on phones.
 * .ds-account--capsule: an outlined pill with the avatar first at every
 * width (the only carrier of the name: role="img", aria-label, data-tip),
 * the text aria-hidden (at most --ds-account-text-max, hidden below
 * 720px), .ds-account__divider and a text button; without the button
 * (none, or hidden) the capsule ends with its start's room. .ds-avatar
 * (32px, --sm 24px, --lg 40px, --ds-avatar-size) is the same disc
 * anywhere.
 *
 * .ds-banner: a message for the whole app, the first child of .ds-panel
 * above the view, holding one .ds-alert (role="status", or role="alert"
 * when urgent). It comes and goes through DS.motion.flip(panel, ..., {
 * items: ':scope > *' }): the view glides, the banner fades in place; no
 * height is animated.
 *
 * Views: .ds-view and .ds-drawer__list are positioned (position:
 * relative, no stacking context), so an absolute element inside stays in
 * its scroller. The view defines the page pad properties too
 * (--ds-content-pad-top, --ds-content-pad-x, --ds-content-pad-bottom; read
 * them, never set them). .ds-view--fill: from 601px wide and 720px tall
 * the view stops scrolling and the page's last shown child fills the rest
 * (a full-page table); below, it scrolls as usual. .ds-view--fixed: the
 * view never scrolls and has no gutter; a child of the page scrolls (a
 * chat, a terminal). The frame is as tall as --ds-viewport-height
 * (DS.dom.followViewport() writes it on <html>; else 100dvh), and
 * .ds-app--keyboard-hides-bar hides the top bar while
 * html[data-ds-keyboard] says the on-screen keyboard is up.
 *
 * Forced colours: a current drawer item and a selected segmented option
 * are a choice in a set (SelectedItem / SelectedItemText, a CanvasText
 * ring inside the fill while focused), blocked items GrayText.
 *
 * Print: only the page content, without the frame and its scroller (the
 * page's own padding and width come off at the end of page.css); the
 * banner stays off paper; short cards, tiles, alerts, key-value rows and
 * facts do not break across pages.
 *
 * Progressive:
 * - scrollbar-color and scrollbar-width (thin, quiet scrollbars in the
 *   drawer and the panel): without them the platform scrollbar shows.
 * - scrollbar-gutter (the view keeps room for a classic scrollbar, so
 *   content never shifts when it starts to scroll): ignored before Safari
 *   18.2, where overlay scrollbars need no room.
 * - View Transitions (while DS.motion.transition() runs, data-ds-vt on
 *   <html>, the view, the panel and the docked drawer carry
 *   view-transition-names ds-view, ds-panel and ds-drawer, the top bar
 *   none, so it stays still): without them the new view enters with
 *   .ds-enter--swap, and a view without the drawer makes the grid jump.
 * - scroll-driven animations (animation-timeline, animation-range,
 *   scroll-timeline, timeline-scope; the panel hairline fades in over the
 *   first --ds-space-2 of scroll on scroll(nearest block), the top bar
 *   tint on the view's named timeline --ds-view-scroll, whose scope the
 *   frame widens to the bar, both with ds-scroll-fade; a view without
 *   overflow shows neither): without them data-scrolled shows both over
 *   --ds-dur-fast.
 * - scroll-driven animations (animation-timeline: scroll(), animation-range;
 *   the edge shades of a long .ds-drawer__list follow its scroll position):
 *   without them the data-ds-overflow-* attributes of
 *   DS.dom.scrollEdges(list, { axis: 'block' }) (attached by the page) set
 *   the shades (at once at its first measure, then over --ds-dur-fast), or
 *   none show.
 */

@layer ds.components {
  /* As tall as the visual viewport where DS.dom.followViewport() writes
     it (the on-screen keyboard shrinks it), else the dynamic viewport. */
  .ds-app {
    height: 100vh;
    height: var(--ds-viewport-height, 100dvh);
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: calc(var(--ds-topbar-height) + env(safe-area-inset-top)) minmax(0, 1fr);
    grid-template-areas:
      "top"
      "main";
    background: var(--ds-backdrop);
  }

  .ds-app--drawer {
    grid-template-columns: calc(var(--ds-drawer-width) + env(safe-area-inset-left)) minmax(0, 1fr);
    grid-template-areas:
      "top top"
      "nav main";
  }

  /* ── Top app bar ──────────────────────────────── */

  .ds-topbar {
    grid-area: top;
    position: relative;
    z-index: var(--ds-z-sticky);
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    min-width: 0;
    /* viewport-fit=cover: keep clear of notches and the status bar of an
       installed app. */
    padding:
      env(safe-area-inset-top)
      max(var(--ds-space-4), env(safe-area-inset-right))
      0
      max(var(--ds-space-6), env(safe-area-inset-left));
  }

  /* Content scrolls under the bar: it takes a surface container colour
     while the panel's hairline shows (D33). The tint is a layer of its own
     behind the bar's content (the bar is a stacking context), so a scroll
     timeline can drive its opacity (below); elsewhere data-scrolled fades
     it in on the hairline's timing, so they show together. */
  .ds-topbar::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    background: var(--ds-topbar-scrolled-bg, var(--ds-surface-high));
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-app[data-scrolled] .ds-topbar::before {
    opacity: 1;
  }

  /* Filled toolbar fields share that fill (form.css): they step up one
     surface while the bar is tinted, so they keep their container. Their
     focus look stays (a focused search turns --ds-surface). */
  .ds-app[data-scrolled] .ds-topbar .ds-input--filled:not(:focus-within, :disabled),
  .ds-app[data-scrolled] .ds-topbar .ds-search .ds-input:not(:focus, :disabled) {
    background-color: var(--ds-surface-highest);
  }

  /* With the drawer, a 32px brand mark centres on the axis of the drawer's
     icons (the list's inset, the item's padding, half the 20px icon), so the
     bar and the destinations under it share one line. */
  .ds-app--drawer {
    --ds-drawer-icon-axis: calc(var(--ds-space-3) + var(--ds-space-4) + 10px);
  }

  .ds-app--drawer .ds-topbar {
    padding-left: max(calc(var(--ds-drawer-icon-axis) - 16px), env(safe-area-inset-left));
  }

  /* Opt-in: while the on-screen keyboard is up (DS.dom.followViewport()
     sets data-ds-keyboard on <html>), the bar gives its row to the view, so
     a chat or a form keeps the field and its context in sight. */
  [data-ds-keyboard] .ds-app--keyboard-hides-bar {
    grid-template-rows: 0 minmax(0, 1fr);
  }

  [data-ds-keyboard] .ds-app--keyboard-hides-bar > .ds-topbar {
    display: none;
  }

  /* Top bar parts: a title that takes the room between the brand and the
     account, a centred search, and a row of icon actions. The account and
     the actions sit at the end: the first of them takes the auto margin,
     none after a search, which centres itself. */
  .ds-topbar__title {
    flex: 1 1 auto;
    min-width: 0;
    margin: 0;
    overflow: hidden;
    color: var(--ds-text);
    font: var(--ds-font-subtitle);
    letter-spacing: 0;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .ds-topbar__search {
    flex: 1 1 auto;
    min-width: 0;
    max-width: var(--ds-topbar-search-max, 720px);
    margin-inline: auto;
  }

  .ds-topbar__actions {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    gap: var(--ds-space-1);
    margin-inline-start: auto;
  }

  .ds-topbar__search ~ .ds-topbar__actions,
  .ds-topbar__search ~ .ds-account,
  .ds-topbar__actions ~ .ds-account {
    margin-inline-start: 0;
  }

  /*
   * Skip link: the drawer puts up to 13 tab stops before the content. Hidden
   * until focused, then a pill on the backdrop over the top bar.
   */
  .ds-skip {
    position: fixed;
    top: calc(var(--ds-space-3) + env(safe-area-inset-top));
    left: max(var(--ds-space-4), env(safe-area-inset-left));
    z-index: var(--ds-z-toast);
    display: inline-flex;
    align-items: center;
    min-height: 40px;
    padding: 0 var(--ds-space-5);
    border-radius: var(--ds-radius-action);
    background: var(--ds-surface-popover);
    box-shadow: var(--ds-elev-popover);
    color: var(--ds-text);
    font: var(--ds-font-label);
    text-decoration: none;
    /* The ring rests one width further out and snaps in, like every ring. */
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-skip:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  .ds-skip:not(:focus) {
    position: fixed;
    width: 1px;
    height: 1px;
    min-height: 0;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    box-shadow: none;
  }

  /*
   * Segmented switch: a compact pill of two or three options after the brand
   * (a mode switch). The tint glides between them (js/indicator.js); the
   * current option is also set in the label weight, never colour alone.
   *
   *   <nav class="ds-segmented" aria-label="Mode">
   *     <a class="ds-segmented__option" href="/a" aria-current="true" data-label="Live">Live</a><a class="ds-segmented__option" href="/b" data-label="Admin">Admin</a>
   *   </nav>
   *   createIndicator(nav, { active: '[aria-current="true"]', className: 'ds-segmented__indicator' })
   *
   *   A mode of the same page (buttons):
   *   <div class="ds-segmented" role="radiogroup" aria-label="View">
   *     <button class="ds-segmented__option" type="button" role="radio" aria-checked="true" data-label="List">List</button>
   *     <button class="ds-segmented__option" type="button" role="radio" aria-checked="false" tabindex="-1" data-label="Map">Map</button>
   *   </div>
   *   createIndicator(group, { active: '[aria-checked="true"]', className: 'ds-segmented__indicator' })
   *   DS.dom.roving(group, { items: '.ds-segmented__option' })   (arrow keys)
   *
   * Selected: aria-current="true" (links), aria-checked="true" (radios) or
   * aria-pressed="true" (toggle buttons). Blocked: :disabled or
   * aria-disabled="true".
   *
   * data-label (optional, the option's text again) reserves the width of the
   * heavier current weight, so the switch keeps its width when the current
   * option changes. It is for an option whose content is its text (or one
   * wrapper): the option stacks its content and the reserved copy in a
   * column. Options without it lay out their content in a row, as before.
   */
  .ds-segmented {
    position: relative;
    display: inline-grid;
    grid-auto-flow: column;
    grid-auto-columns: 1fr;
    flex-shrink: 0;
    overflow: hidden;
    border: 1px solid var(--ds-outline);
    border-radius: var(--ds-radius-action);
  }

  /* In the top bar the switch keeps its distance from the brand; in a page
     toolbar it sits flush. */
  .ds-topbar .ds-segmented {
    margin-inline-start: var(--ds-space-4);
  }

  .ds-segmented__indicator {
    background: var(--ds-accent);
  }

  .ds-segmented__option {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 30px;
    padding: 0 var(--ds-segmented-padding-x, var(--ds-space-4));
    color: var(--ds-text-2);
    font: var(--ds-font-body);
    text-decoration: none;
    white-space: nowrap;
    -webkit-tap-highlight-color: transparent;
    /* An inset ring (the pill clips anything outside): it rests on the edge
       and snaps inward. The opacity fades with the disabled model. */
    outline-offset: 0;
    transition:
      outline-offset var(--ds-dur-fast) var(--ds-ease-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* An option may be a <button> (a mode of the same page): without the
     browser's button look. The divider rule below puts the divider back. */
  button.ds-segmented__option {
    margin: 0;
    border: 0;
    background: none;
    cursor: pointer;
  }

  /* With the gliding pill, the label changes colour while the pill arrives
     (on its timing, --ds-indicator-dur on the track); without it the tint
     and the label change in one frame. */
  .ds-has-indicator > .ds-segmented__option {
    transition:
      outline-offset var(--ds-dur-fast) var(--ds-ease-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-indicator-dur, var(--ds-dur-medium)) var(--ds-ease-in-out);
  }

  /* The label once more in the current weight, without height and unseen
     (hidden content is not read out): the option is always as wide as its
     heavier label, so a selection change moves nothing. ::before is the
     state layer. Only these options turn into a column (still centred both
     ways), so the copy takes the width without taking a line. */
  .ds-segmented__option[data-label] {
    flex-direction: column;
  }

  .ds-segmented__option[data-label]::after {
    content: attr(data-label);
    display: block;
    height: 0;
    overflow: hidden;
    visibility: hidden;
    pointer-events: none;
    font: var(--ds-font-label);
  }

  .ds-segmented__option + .ds-segmented__option {
    border-inline-start: 1px solid var(--ds-outline);
  }

  /* State layer. The pill clips the options, so a pressed option cannot
     change its own shape: its layer morphs its clip instead (squarer
     corners in --ds-dur-micro, back on the spatial spring). */
  .ds-segmented__option::before {
    content: '';
    position: absolute;
    inset: 0;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    clip-path: inset(0 round 0);
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      clip-path var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  @media (hover: hover) {
    .ds-segmented__option:hover::before {
      opacity: var(--ds-state-hover);
    }
  }

  /* The layer appears with the ring; the clip keeps its spring back. */
  .ds-segmented__option:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s, var(--ds-dur-medium);
  }

  .ds-segmented__option:active::before,
  .ds-segmented__option.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    clip-path: inset(0 round var(--ds-radius-action-pressed));
    transition:
      opacity var(--ds-dur-micro) var(--ds-ease-in-out),
      clip-path var(--ds-dur-micro) var(--ds-ease-out);
  }

  .ds-segmented__option:first-of-type {
    border-radius: 0;
    border-start-start-radius: var(--ds-radius-action);
    border-end-start-radius: var(--ds-radius-action);
  }

  .ds-segmented__option:last-of-type {
    border-radius: 0;
    border-start-end-radius: var(--ds-radius-action);
    border-end-end-radius: var(--ds-radius-action);
  }

  .ds-segmented__option:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  .ds-segmented__option:is([aria-current="true"], [aria-checked="true"], [aria-pressed="true"]) {
    background: var(--ds-accent);
    color: var(--ds-on-accent);
    font: var(--ds-font-label);
  }

  .ds-has-indicator > .ds-segmented__option:is([aria-current="true"], [aria-checked="true"], [aria-pressed="true"]) {
    background: transparent;
  }

  /* Blocked: the disabled model (ink and opacity, faded on --ds-dur-fast),
     no hover, press or clip morph; an aria-disabled option stays focusable
     and keeps its focus layer. */
  .ds-segmented__option:is(:disabled, [aria-disabled="true"]) {
    color: var(--ds-disabled-ink);
    opacity: var(--ds-disabled-opacity);
    cursor: default;
    transition:
      outline-offset var(--ds-dur-fast) var(--ds-ease-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-segmented__option:is(:disabled, [aria-disabled="true"]):hover::before {
      opacity: 0;
    }
  }

  .ds-segmented__option:is(:disabled, [aria-disabled="true"]):is(:active, .ds-pressed)::before {
    opacity: 0;
    clip-path: inset(0 round 0);
  }

  .ds-segmented__option[aria-disabled="true"]:focus-visible::before {
    opacity: var(--ds-state-focus);
  }

  /* A view without the drawer: the panel takes the whole width. Toggled
     inside DS.motion.transition() (View Transitions, .ds-theme on <html>),
     the panel morphs to its new width while the drawer leaves or comes
     back (motion.css); elsewhere the grid jumps, and the drawer coming
     back slides in through DS.motion.enter(drawer) (below). */
  .ds-app--drawer[data-drawer-hidden] {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "top"
      "main";
  }

  .ds-app--drawer[data-drawer-hidden] .ds-drawer,
  .ds-app--drawer[data-drawer-hidden] .ds-scrim,
  .ds-app--drawer[data-drawer-hidden] .ds-topbar__menu {
    display: none;
  }

  .ds-app--drawer[data-drawer-hidden] .ds-panel {
    margin-left: max(var(--ds-space-4), env(safe-area-inset-left));
  }

  .ds-app--drawer[data-drawer-hidden] .ds-topbar {
    padding-left: max(var(--ds-space-6), env(safe-area-inset-left));
  }

  /* When the drawer comes back (a view that has it), it slides in from the
     left: DS.motion.enter(drawer). Self-contained: the same keyframes,
     duration and curves as .ds-enter--slide (a 24px shift, not a
     full-width slide), so enter(drawer) and enter(drawer, 'slide') look the
     same; only the side differs from the default. */
  .ds-drawer.ds-enter {
    --ds-motion-distance: calc(-1 * var(--ds-distance-xl));
    --ds-enter-keyframes: ds-fade-in, ds-shift-in;
    --ds-enter-duration: var(--ds-dur-slow);
    --ds-enter-easing: var(--ds-ease-in-out), var(--ds-ease-spatial);
  }

  /* Inside a View Transition the transition plays the drawer's return (its
     new image slides in), so enter(drawer) stays quiet: the live drawer
     would slide a second time inside its image. One recipe for both
     paths: remove data-drawer-hidden, then enter(drawer). */
  .ds-theme[data-ds-vt] .ds-drawer.ds-enter {
    --ds-enter-keyframes: none;
  }

  /* The menu button shows with the modal drawer only. */
  .ds-topbar__menu {
    display: none;
    flex-shrink: 0;
    --ds-btn-quiet-color: var(--ds-text-2);
  }

  /* Never squeezed: the name would run under the avatar on narrow windows
     (the account shrinks instead, see the 359px rule). */
  .ds-brand {
    display: inline-flex;
    align-items: center;
    gap: var(--ds-space-3);
    flex-shrink: 0;
    border-radius: var(--ds-radius-action);
    color: var(--ds-text);
    text-decoration: none;
  }

  /* Under .ds-touch the home link is a finger's target too, also when it
     is a small mark only. */
  .ds-touch .ds-brand {
    min-width: var(--ds-touch-target);
    min-height: var(--ds-touch-target);
  }

  .ds-brand__name {
    font: var(--ds-font-title);
    color: var(--ds-text);
    white-space: nowrap;
  }

  .ds-account {
    display: flex;
    align-items: center;
    gap: var(--ds-space-3);
    min-width: 0;
    margin-inline-start: auto;
  }

  .ds-account__text {
    min-width: 0;
    text-align: end;
  }

  .ds-account__name,
  .ds-account__email {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .ds-account__name {
    font: var(--ds-font-label);
    color: var(--ds-text);
  }

  .ds-account__email {
    font: var(--ds-font-meta);
    color: var(--ds-text-2);
  }

  /* An account without an email shows only the name. */
  .ds-account__email:empty {
    display: none;
  }

  /*
   * Avatar: the initial (or a picture) on the tint, a disc. The account's
   * avatar is the same disc (both selectors), shown only where it stands in
   * for the text. Narrow windows show the initial instead of name and
   * email: the avatar then names them itself (aria-label) and shows them in
   * its tooltip on hover, keyboard focus or a tap (data-tip). No status dot.
   */
  .ds-avatar,
  .ds-account__avatar {
    flex-shrink: 0;
    width: var(--ds-avatar-size, 32px);
    height: var(--ds-avatar-size, 32px);
    place-items: center;
    overflow: hidden;
    border-radius: 50%;
    background: var(--ds-accent);
    color: var(--ds-on-accent);
    font: var(--ds-font-label);
    object-fit: cover;
    user-select: none;
  }

  .ds-avatar {
    display: inline-grid;
    vertical-align: middle;
  }

  .ds-account__avatar {
    display: none;
  }

  /* Sizes from the spacing scale; the initial scales with the disc. */
  .ds-avatar--sm {
    --ds-avatar-size: var(--ds-space-6);
    font: var(--ds-font-caption);
  }

  .ds-avatar--lg {
    --ds-avatar-size: var(--ds-space-10);
    font: var(--ds-font-subtitle);
  }

  /*
   * Account capsule: one outlined pill, the avatar first at every width and
   * the only carrier of the name (role="img", aria-label, data-tip); the
   * text beside it is aria-hidden and gives way below 720px. A divider
   * goes with a hidden sign-out button, and so does the end padding.
   */
  .ds-account--capsule {
    gap: var(--ds-space-2);
    min-height: var(--ds-space-12);
    padding-block: 0;
    padding-inline: var(--ds-space-2) var(--ds-space-1);
    border: 1px solid var(--ds-line);
    border-radius: var(--ds-radius-action);
    background: var(--ds-surface);
  }

  .ds-account--capsule .ds-account__avatar {
    display: grid;
    order: -1;
  }

  .ds-account--capsule .ds-account__text {
    max-width: var(--ds-account-text-max, 240px);
    text-align: start;
  }

  .ds-account__divider {
    align-self: stretch;
    margin-block: var(--ds-space-3);
    border-inline-start: 1px solid var(--ds-line);
  }

  .ds-account__divider:has(+ [hidden]) {
    display: none;
  }

  /* Without a shown sign-out button (none, or a hidden one) the capsule
     ends with the room it starts with: the start padding counts on the
     text button's own padding at the end. */
  .ds-account--capsule:not(:has(> .ds-btn:not([hidden]))) {
    padding-inline-end: var(--ds-space-4);
  }

  /* ── Navigation drawer ────────────────────────── */

  .ds-drawer {
    grid-area: nav;
    min-height: 0;
    display: flex;
    flex-direction: column;
    padding-left: env(safe-area-inset-left);
    background: var(--ds-backdrop);
  }

  /* Focusable (tabindex -1) only so a click on a heading or the padding keeps
     focus inside the modal drawer, where Tab and Escape are handled. Only
     the colour goes: the modal drawer's transparent edge (below) stays. */
  .ds-drawer:focus {
    outline-color: transparent;
  }

  /* The brand repeats in the modal drawer only, where it covers the top bar. */
  .ds-drawer__head {
    display: none;
  }

  /* A way up (back to a list from one of its records), between the head
     and the list: an item that is never current, outside the pill's track,
     with a hairline under it inset to the text edge. */
  .ds-drawer__back {
    flex-shrink: 0;
    padding: var(--ds-space-1) var(--ds-space-3) 0;
  }

  .ds-drawer__back::after {
    content: '';
    display: block;
    margin: var(--ds-space-1) var(--ds-space-4) 0;
    border-bottom: 1px solid var(--ds-line);
  }

  .ds-drawer__back:empty {
    display: none;
  }

  /* A primary action above the list (compose, new record). */
  .ds-drawer__actions {
    flex-shrink: 0;
    display: flex;
    padding: var(--ds-space-2) var(--ds-space-3);
  }

  /* Destinations below the list (settings, help), clear of the home
     indicator of an installed app. */
  .ds-drawer__foot {
    flex-shrink: 0;
    display: grid;
    padding: var(--ds-space-2) var(--ds-space-3) max(var(--ds-space-3), env(safe-area-inset-bottom));
  }

  /* Icon-only items in a row; each names itself (a .ds-sr label and
     data-tip). */
  .ds-drawer__foot--rail {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ds-space-1);
  }

  .ds-drawer__foot--rail .ds-drawer__item {
    width: var(--ds-drawer-item-height, 48px);
    padding: 0;
    justify-content: center;
  }

  .ds-drawer__foot--rail .ds-drawer__label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* Items inset 12px; the list is the indicator's track and scrolls itself
     when the window is low. Positioned (no stacking context), so an
     absolute element inside scrolls with the list. */
  .ds-drawer__list {
    position: relative;
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    /* The block padding (--ds-space-1 above, --ds-space-6 below) lives in
       the edge shades (below), so the end shade reaches the cut edge. */
    padding: 0 var(--ds-space-3);
    /* An item focused by the keys or scrolled into view lands clear of the
       edge shades, focus ring and all. */
    scroll-padding-block: var(--ds-scroll-fade, var(--ds-space-8));
    /* Classic scrollbars (Chrome on Windows) would draw a grey rail between
       drawer and panel, a divider the drawer must not have: the thumb only
       shows while the pointer or focus is in the list. */
    scrollbar-width: thin;
    scrollbar-color: transparent transparent;
  }

  .ds-drawer__list:hover,
  .ds-drawer__list:focus-within {
    scrollbar-color: var(--ds-line-2) transparent;
  }

  /* Taller than the window (the page sets the class): the thumb stays, the only sign
     that more destinations lie below the fold. */
  .ds-drawer__list--overflow {
    scrollbar-color: var(--ds-line-2) transparent;
  }

  /* The cut edges of a long list, as a menu's (menu.css): a mask would
     fade the pill and the focus rings, so a shade of the drawer's surface
     lies over each edge that has more to show, --ds-scroll-fade long,
     above the items, across the item inset: sticky at the edge, taking no
     room. Each holds the list's padding at its end (the negative margin
     gives back only the rest), so the list keeps --ds-space-1 above and
     --ds-space-6 below its items, and the end shade sticks flush to the
     scroller's edge down to the last pixel of scroll. Its number
     (--ds-scroll-edge-start or -end, registered in scroller.css) is 0 at
     rest; the list's scroll timeline drives it where it exists, else the
     data-ds-overflow-* attributes of DS.dom.scrollEdges(list, { axis:
     'block' }), which the page attaches. */
  .ds-drawer__list::before,
  .ds-drawer__list::after {
    content: '';
    position: sticky;
    z-index: var(--ds-z-raised);
    display: block;
    height: var(--ds-scroll-fade, var(--ds-space-8));
    margin-inline: calc(-1 * var(--ds-space-3));
    pointer-events: none;
  }

  /* The shades glide only once it has measured (data-ds-edges comes after
     the first edges), so a drawer that loads with a long list shows its end
     shade at once instead of fading it in on load. */
  .ds-drawer__list[data-ds-edges]::before,
  .ds-drawer__list[data-ds-edges]::after {
    transition:
      --ds-scroll-edge-start var(--ds-dur-fast) var(--ds-ease-in-out),
      --ds-scroll-edge-end var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-drawer__list::before {
    inset-block-start: 0;
    margin-block-end: calc(var(--ds-space-1) - var(--ds-scroll-fade, var(--ds-space-8)));
    background: linear-gradient(var(--ds-backdrop), transparent);
    opacity: var(--ds-scroll-edge-start);
  }

  .ds-drawer__list::after {
    inset-block-end: 0;
    margin-block-start: calc(var(--ds-space-6) - var(--ds-scroll-fade, var(--ds-space-8)));
    background: linear-gradient(transparent, var(--ds-backdrop));
    opacity: var(--ds-scroll-edge-end);
  }

  .ds-drawer__list[data-ds-overflow-start]::before {
    --ds-scroll-edge-start: 1;
  }

  .ds-drawer__list[data-ds-overflow-end]::after {
    --ds-scroll-edge-end: 1;
  }

  /* Scroll-linked, on the list's own scroll: the shorthand first (it
     resets animation-timeline). */
  @supports (animation-timeline: scroll()) {
    .ds-drawer__list::before {
      animation: ds-scroll-edge-start linear both;
      animation-timeline: scroll(nearest block);
      animation-range: 0 var(--ds-space-8);
    }

    .ds-drawer__list::after {
      animation: ds-scroll-edge-end linear both;
      animation-timeline: scroll(nearest block);
      animation-range: calc(100% - var(--ds-space-8)) 100%;
    }
  }

  .ds-drawer__group {
    display: grid;
  }

  /* Sentence case, the label style of the items, a step quieter. No eyebrow. */
  .ds-drawer__heading {
    margin: 0;
    padding: var(--ds-space-4) var(--ds-space-4) var(--ds-space-2);
    font: var(--ds-font-label);
    color: var(--ds-text-2);
  }

  /* A group that closes: its heading is a pressable row of the item's
     height and shape (state layer, ring snap, press morph, ripple), the
     chevron on the icons' axis. The count shows only while it is closed. */
  .ds-drawer__toggle {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--ds-space-3);
    min-height: var(--ds-drawer-item-height, 48px);
    padding-block: 0;
    padding-inline: var(--ds-space-4) var(--ds-space-6);
    border-radius: min(var(--ds-radius-action), calc(var(--ds-drawer-item-height, 48px) / 2));
    list-style: none;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    outline-offset: 0;
    transition:
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-drawer__toggle::-webkit-details-marker {
    display: none;
  }

  .ds-drawer__toggle svg {
    flex-shrink: 0;
  }

  .ds-drawer__toggle::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-drawer__toggle:hover::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  .ds-drawer__toggle:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  .ds-drawer__toggle:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  .ds-drawer__toggle:active::before,
  .ds-drawer__toggle.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  .ds-drawer__toggle:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"]),
  .ds-drawer__toggle.ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {
    border-radius: min(var(--ds-radius-action-pressed), var(--ds-radius-action), calc(var(--ds-drawer-item-height, 48px) / 2));
    transition:
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-drawer__group[open] > .ds-drawer__toggle .ds-drawer__count {
    display: none;
  }

  /* A count at the end of an item or a closed group (unread, open
     tickets); a .ds-sr separator inside reads it with the label. */
  .ds-drawer__count {
    flex: none;
    margin-inline-start: auto;
    color: inherit;
    font: var(--ds-font-meta);
    font-variant-numeric: tabular-nums;
  }

  /* An item with an action of its own at its end (a quiet icon button,
     .ds-hover-actions: shown on hover, focus, the current item and an open
     menu, always on touch). The item keeps room for it at the action's
     own size (the hook the small icon button reads: 48px under
     .ds-touch) plus --ds-space-2 (its margin and as much air). */
  .ds-drawer__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
  }

  .ds-drawer__row > .ds-drawer__item {
    grid-area: 1 / 1 / 2 / 3;
    padding-inline-end: calc(var(--ds-btn-sm-icon-size, var(--ds-space-8)) + var(--ds-space-2));
  }

  .ds-drawer__action {
    grid-area: 1 / 2;
    margin-inline-end: var(--ds-space-1);
  }

  .ds-drawer__item {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--ds-space-3);
    min-height: var(--ds-drawer-item-height, 48px);
    padding-block: 0;
    padding-inline: var(--ds-space-4) var(--ds-space-6);
    /* Half the height, never 9999px, so the press morph interpolates over
       its whole run. */
    border-radius: min(var(--ds-radius-action), calc(var(--ds-drawer-item-height, 48px) / 2));
    color: var(--ds-text-2);
    font: var(--ds-font-body);
    text-decoration: none;
    -webkit-tap-highlight-color: transparent;
    /* An inset ring (the scrolling list clips anything outside): it rests
       on the edge and snaps inward. No colour here: without the gliding
       pill the item paints its own tint, and tint and label change in one
       frame (the colour joins under .ds-has-indicator, below). */
    outline-offset: 0;
    transition:
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* An item may be a <button> (it switches a section in place instead of
     loading a page): without the browser's button look, full width. */
  button.ds-drawer__item {
    width: 100%;
    margin: 0;
    border: 0;
    background: transparent;
    text-align: start;
    cursor: pointer;
  }

  .ds-drawer__item svg {
    flex-shrink: 0;
    color: var(--ds-text-2);
  }

  /* The labels fade back in when the rail widens again (in on
     --ds-dur-fast, out on --ds-dur-instant, below). */
  .ds-drawer__label {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* State layer (6% on large targets), the colour of the content. */
  .ds-drawer__item::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  @media (hover: hover) {
    .ds-drawer__item:hover::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  .ds-drawer__item:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  .ds-drawer__item:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s;
  }

  .ds-drawer__item:active::before,
  .ds-drawer__item.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    transition-duration: var(--ds-dur-micro);
  }

  /* Pressed, the item turns squarer (in --ds-dur-micro, never rounder than
     at rest) and springs back on release; its layer follows (inherit).
     Nothing moves: no transform. */
  .ds-drawer__item:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"]),
  .ds-drawer__item.ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {
    border-radius: min(var(--ds-radius-action-pressed), var(--ds-radius-action), calc(var(--ds-drawer-item-height, 48px) / 2));
    transition:
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  /* With the gliding pill, the label (and its icon) change colour while the
     pill arrives, on its timing (--ds-indicator-dur on the track), and the
     old label fades back on the same timing. The weight switches at once,
     never animated. The pressed twin keeps the colour entry: the item a
     click has just made current is still pressed (.ds-pressed outlives the
     click) while its pill arrives. */
  .ds-has-indicator .ds-drawer__item {
    transition:
      color var(--ds-indicator-dur, var(--ds-dur-medium)) var(--ds-ease-in-out),
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-has-indicator .ds-drawer__item:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"]),
  .ds-has-indicator .ds-drawer__item.ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {
    transition:
      color var(--ds-indicator-dur, var(--ds-dur-medium)) var(--ds-ease-in-out),
      border-radius var(--ds-dur-micro) var(--ds-ease-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-has-indicator .ds-drawer__item svg {
    transition: color var(--ds-indicator-dur, var(--ds-dur-medium)) var(--ds-ease-in-out);
  }

  /* An item that cannot be chosen (aria-disabled) shows no hover and no
     press (js/ripple.js gives it no ripple); it keeps the focus layer. */
  @media (hover: hover) {
    .ds-drawer__item[aria-disabled="true"]:hover::before {
      opacity: 0;
    }
  }

  .ds-drawer__item[aria-disabled="true"]:is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  .ds-drawer__item[aria-disabled="true"]:focus-visible::before {
    opacity: var(--ds-state-focus);
  }

  /* The active pill: the tint glides between the items (js/indicator.js);
     without it the active item paints the tint itself. The tint is only
     1.3:1 against the backdrop, so the active label also turns medium (500,
     the others are 400) and darker: the state never rests on colour alone.
     The pill spans the full item width, so the weight change moves nothing. */
  .ds-drawer__indicator {
    --ds-indicator-radius: min(var(--ds-radius-action), calc(var(--ds-drawer-item-height, 48px) / 2));
    background: var(--ds-accent);
  }

  /* The pill under a pressed active item morphs with it: the same pressed
     radius in --ds-dur-micro, back on the indicator's own spring
     (indicator.css). Only the radius entry changes: the item a click has
     just made active is still pressed (.ds-pressed outlives the click), and
     the pill's glide to it keeps its own timing (the trailing edge's delay
     is a list of its own, untouched here, and its curve comes through
     --ds-indicator-ease-*). Keep the lists in step with .ds-indicator. */
  .ds-has-indicator:has(.ds-drawer__item[aria-current="page"]:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"])) > .ds-drawer__indicator,
  .ds-has-indicator:has(.ds-drawer__item[aria-current="page"].ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"])) > .ds-drawer__indicator {
    --ds-indicator-radius: min(var(--ds-radius-action-pressed), var(--ds-radius-action), calc(var(--ds-drawer-item-height, 48px) / 2));
    transition-duration:
      var(--ds-indicator-dur, var(--ds-dur-medium)),
      var(--ds-indicator-dur, var(--ds-dur-medium)),
      var(--ds-indicator-dur, var(--ds-dur-medium)),
      var(--ds-indicator-dur, var(--ds-dur-medium)),
      var(--ds-dur-micro),
      var(--ds-dur-fast);
    transition-timing-function:
      var(--ds-indicator-ease-t, var(--ds-ease-spatial)),
      var(--ds-indicator-ease-r, var(--ds-ease-spatial)),
      var(--ds-indicator-ease-b, var(--ds-ease-spatial)),
      var(--ds-indicator-ease-l, var(--ds-ease-spatial)),
      var(--ds-ease-out),
      var(--ds-ease-in-out);
  }

  /* A pill placed without a glide (.ds-indicator--instant) keeps it so. */
  .ds-has-indicator:has(.ds-drawer__item[aria-current="page"]:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"])) > .ds-drawer__indicator.ds-indicator--instant,
  .ds-has-indicator:has(.ds-drawer__item[aria-current="page"].ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"])) > .ds-drawer__indicator.ds-indicator--instant {
    transition-duration: var(--ds-dur-micro), var(--ds-dur-fast);
    transition-timing-function: var(--ds-ease-out), var(--ds-ease-in-out);
  }

  .ds-drawer__item[aria-current="page"] {
    background: var(--ds-accent);
  }

  .ds-has-indicator .ds-drawer__item[aria-current="page"] {
    background: transparent;
  }

  .ds-drawer__item[aria-current="page"] {
    font: var(--ds-font-label);
  }

  .ds-drawer__item[aria-current="page"],
  .ds-drawer__item[aria-current="page"] svg {
    color: var(--ds-on-accent);
  }

  .ds-scrim {
    display: none;
  }

  /* ── Content panel ────────────────────────────── */

  /* The rounded frame. It does not scroll itself: a classic scrollbar
     (Chrome on Windows) ignores the radius of its own box and would square
     off the top-right corner, but the frame's rounded clip trims the
     scroller inside. No clip-path: overflow leaves the fixed scrim and
     dialogs of the views alone. */
  .ds-panel {
    grid-area: main;
    min-height: 0;
    margin: 0 max(var(--ds-space-4), env(safe-area-inset-right)) 0 max(var(--ds-space-4), env(safe-area-inset-left));
    display: flex;
    flex-direction: column;
    overflow: hidden;
    border-radius: var(--ds-radius-panel) var(--ds-radius-panel) 0 0;
    background: var(--ds-surface);
  }

  /* A message for the whole app (offline, maintenance), above the view and
     outside its scroll. The alert runs to the panel's edges; the panel's
     rounded clip gives it its top corners. It comes and goes through
     DS.motion.flip() on the panel: the view glides, nothing animates a
     height. */
  .ds-banner {
    flex-shrink: 0;
  }

  .ds-banner > .ds-alert {
    border-radius: var(--ds-radius-panel) var(--ds-radius-panel) 0 0;
  }

  /* The only scroller of the app; quiet thumb on a clear track. The gutter
     stays reserved, so a classic scrollbar that appears (content revealed,
     a skeleton replaced, a list gliding) never shifts the content.
     Positioned (no stacking context: sticky parts and the hairline keep
     their place), so an absolute element of the page, a .ds-sr label
     included, stays inside the scroller. The page pad properties are
     defined here too (as on .ds-content), for the parts of the view outside
     the content: read them, never set them. */
  .ds-view {
    --ds-content-pad-top: var(--ds-space-6);
    --ds-content-pad-x: var(--ds-space-8);
    --ds-content-pad-bottom: calc(var(--ds-space-6) + var(--ds-toast-room) + var(--ds-space-2));
    position: relative;
    flex: 1;
    min-height: 0;
    padding-bottom: env(safe-area-inset-bottom);
    overflow-y: auto;
    scrollbar-gutter: var(--ds-view-scrollbar-gutter, stable);
    display: flex;
    flex-direction: column;
    scrollbar-width: thin;
    scrollbar-color: var(--ds-line-2) transparent;
  }

  /* Programmatic focus target (skip link, page change); the panel itself
     needs no ring. */
  .ds-view:focus,
  .ds-view :is(h1, h2)[tabindex="-1"]:focus {
    outline: none;
  }

  /*
   * Scroll edge: a hairline at the top of the panel, only while content
   * scrolls under it. Inside the panel it keeps to the panel's width and its
   * rounded clip; the negative margin keeps it from taking up space.
   */
  .ds-view::before {
    content: '';
    position: sticky;
    top: 0;
    z-index: var(--ds-z-sticky);
    flex-shrink: 0;
    height: 1px;
    margin-bottom: -1px;
    background: var(--ds-line);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  .ds-app[data-scrolled] .ds-view::before {
    opacity: 1;
  }

  /* Where scroll timelines exist, the hairline follows the scroll position
     itself: it fades in over the first --ds-space-2 of scroll, and
     data-scrolled (the fallback) changes nothing. ds-scroll-fade names both
     ends, so a view without overflow (an inactive timeline) keeps the
     hairline's own opacity 0. animation-timeline comes after the
     shorthand, which resets it. */
  @supports (animation-timeline: scroll()) {
    .ds-view::before {
      animation: ds-scroll-fade linear both;
      animation-timeline: scroll(nearest block);
      animation-range: 0 var(--ds-space-2);
    }
  }

  /* A loading bar at the top of the view takes the hairline's place (also
     its scroll-driven fade). */
  .ds-view:has(.ds-loadbar)::before,
  .ds-app[data-scrolled] .ds-view:has(.ds-loadbar)::before {
    opacity: 0;
    animation: none;
  }

  /* The top bar's tint on the same scroll: the view names its timeline and
     the frame widens its scope to the bar (a sibling of the panel). Guarded
     by timeline-scope, not scroll(): an engine with scroll timelines but
     without timeline-scope would leave the named timeline unresolved, and
     the bar would never tint; it keeps data-scrolled there. */
  @supports (timeline-scope: none) {
    .ds-app {
      timeline-scope: --ds-view-scroll;
    }

    .ds-view {
      scroll-timeline: --ds-view-scroll block;
    }

    .ds-topbar::before {
      animation: ds-scroll-fade linear both;
      animation-timeline: --ds-view-scroll;
      animation-range: 0 var(--ds-space-2);
    }
  }

  /* The drawer brings its own inset on the left (physical, like the
     safe-area margin it replaces, D9). */
  .ds-app--drawer .ds-panel {
    margin-left: 0;
  }

  /*
   * A view that never scrolls (a chat, a terminal, a map): no gutter; the
   * content and the page are flex columns and the page's last shown child
   * takes the rest of the height. That child scrolls itself (a scroller, or
   * a flex column of its own with min-height: 0). Its pages keep a short
   * bottom padding.
   */
  .ds-view--fixed {
    --ds-content-pad-bottom: var(--ds-space-6);
    overflow-y: hidden;
    scrollbar-gutter: auto;
  }

  .ds-view--fixed > .ds-content,
  .ds-view--fixed > .ds-content > .ds-page {
    --ds-content-pad-bottom: var(--ds-space-6);
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
  }

  .ds-view--fixed > .ds-content > .ds-page > :not([hidden]):not(:has(~ :not([hidden]))) {
    flex: 1 1 auto;
  }

  /*
   * A view that fills the window (a full-page table): where there is room
   * (from 601px wide and 720px tall) it stops scrolling as .ds-view--fixed
   * does, and the page's last shown child (a .ds-table-wrap--fill) fills
   * it. Below that the view scrolls as usual and a fill table is capped
   * a little under the window's height, so its own header stays in sight.
   */
  @media (min-width: 601px) and (min-height: 720px) {
    .ds-view--fill {
      --ds-content-pad-bottom: var(--ds-space-6);
      overflow-y: hidden;
    }

    .ds-view--fill > .ds-content,
    .ds-view--fill > .ds-content > .ds-page {
      --ds-content-pad-bottom: var(--ds-space-6);
      display: flex;
      flex-direction: column;
      flex: 1 1 auto;
      min-height: 0;
    }

    .ds-view--fill > .ds-content > .ds-page > :not([hidden]):not(:has(~ :not([hidden]))) {
      flex: 1 1 auto;
    }
  }

  @media (max-width: 600px), (max-height: 719px) {
    .ds-view--fill .ds-table-wrap--fill {
      max-height: calc(100dvh - var(--ds-topbar-height) - env(safe-area-inset-top) - env(safe-area-inset-bottom) - var(--ds-content-pad-bottom) - var(--ds-space-2));
    }
  }

  /*
   * Docked drawer only (above 900px; the modal drawer below is unaffected).
   *
   * The rail (data-drawer-collapsed, set by the page): the column narrows
   * to --ds-drawer-rail-width at once, the items become squares (circles
   * by the pill rule), labels, headings and counts are visually hidden but
   * read (each item's data-tip names it then), row actions go. The labels
   * fade back in when it widens (in --ds-dur-fast, out --ds-dur-instant).
   * The menu button toggles it: with aria-pressed it shows above 900px,
   * on the drawer icons' axis.
   */
  @media (min-width: 901px) {
    .ds-app--drawer[data-drawer-collapsed]:not([data-drawer-hidden]) {
      --ds-drawer-icon-axis: calc(var(--ds-drawer-rail-width) / 2);
      grid-template-columns: calc(var(--ds-drawer-rail-width) + env(safe-area-inset-left)) minmax(0, 1fr);
    }

    .ds-app--drawer[data-drawer-collapsed] .ds-drawer .ds-drawer__item,
    .ds-app--drawer[data-drawer-collapsed] .ds-drawer .ds-drawer__toggle {
      width: var(--ds-drawer-item-height, 48px);
      padding: 0;
      justify-content: center;
      gap: 0;
    }

    /* Hidden in place (1px less a 1px margin takes no room), not taken out
       of the flow: Firefox drops the fade of an element whose position
       changes in the same style change. */
    .ds-app--drawer[data-drawer-collapsed] .ds-drawer :is(.ds-drawer__label, .ds-drawer__heading:not(.ds-drawer__toggle), .ds-drawer__toggle > span, .ds-drawer__count) {
      flex: none;
      width: 1px;
      min-width: 0;
      height: 1px;
      margin-block: 0 -1px;
      margin-inline: 0 -1px;
      padding: 0;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
      opacity: 0;
      transition: opacity var(--ds-dur-instant) var(--ds-ease-in-out);
    }

    .ds-app--drawer[data-drawer-collapsed] .ds-drawer__action {
      display: none;
    }

    .ds-app--drawer[data-drawer-collapsed] .ds-drawer__actions {
      justify-content: center;
    }

    .ds-app--drawer[data-drawer-collapsed] .ds-drawer__back::after {
      margin-inline: var(--ds-space-2);
    }

    .ds-app--drawer:not([data-drawer-hidden]) .ds-topbar__menu[aria-pressed] {
      display: inline-flex;
    }

    .ds-app--drawer:not([data-drawer-hidden]) .ds-topbar:has(> .ds-topbar__menu[aria-pressed]) {
      padding-left: max(calc(var(--ds-drawer-icon-axis) - 20px), env(safe-area-inset-left));
    }

    /* A brand of only a name: its text starts on the icons' edge (the
       axis less half a 20px icon) instead of a 32px mark centring there.
       The name as the brand's only child, not a :has() inside the :has()
       (no engine allows that nesting; it would drop the rule). */
    .ds-app--drawer:not([data-drawer-hidden]) .ds-topbar:has(> .ds-brand:first-child > .ds-brand__name:only-child, > .ds-topbar__menu:not([aria-pressed]):first-child + .ds-brand > .ds-brand__name:only-child) {
      padding-left: max(calc(var(--ds-drawer-icon-axis) - 10px), env(safe-area-inset-left));
    }
  }

  /* The docked drawer's way up stands in for the page head's back link:
     the page shows one of them (the exact complement of the modal query). */
  @media (width > 900px) {
    .ds-app--drawer:not([data-drawer-hidden]):has(> .ds-drawer .ds-drawer__back .ds-drawer__item) .ds-page-head__back {
      display: none;
    }
  }

  /* Between phone and docked drawer, a bar without the drawer starts where
     the panel does. */
  @media (min-width: 601px) and (max-width: 900px) {
    .ds-app:not(.ds-app--drawer) .ds-topbar,
    .ds-app--drawer[data-drawer-hidden] .ds-topbar {
      padding-left: max(var(--ds-space-4), env(safe-area-inset-left));
    }
  }

  /* Fade-through, outgoing half: quick and accelerating. Leaving the state
     snaps back without a transition, the incoming content enters itself.
     Deprecated in 2.0 (removed in 3.0 at the earliest) with its manual swap
     recipe: DS.motion.transition() replaces both. */
  .ds-view--leaving > * {
    opacity: 0;
    transition: opacity var(--ds-dur-instant) var(--ds-ease-in);
  }

  /*
   * View transitions (DS.motion.transition() writes data-ds-vt on <html>
   * while one runs; motion.css plays them). Only then are these named, so
   * a page without a transition carries no names. The view is its own
   * group and the top bar has none: it stays in the root snapshot, whose
   * crossfade leaves unchanged pixels still. The panel's name serves the
   * morph of a view without the drawer; the docked drawer is named for its
   * exit and return there. The modal drawer is never named: while it is
   * open or leaving, DS.motion.transition() lifts these names, so the view
   * crossfades in the root under it (close it inside the update).
   */
  .ds-theme[data-ds-vt] .ds-view {
    view-transition-name: ds-view;
  }

  .ds-theme[data-ds-vt] .ds-panel {
    view-transition-name: ds-panel;
  }

  @media (min-width: 901px) {
    .ds-theme[data-ds-vt] .ds-drawer {
      view-transition-name: ds-drawer;
    }
  }

  /* ── Modal drawer ─────────────────────────────── */

  /* A script that moves focus into the modal drawer uses the same query:
     (max-width: 900px). */
  @media (max-width: 900px) {
    .ds-app--drawer {
      grid-template-columns: minmax(0, 1fr);
      grid-template-areas:
        "top"
        "main";
    }

    .ds-app--drawer .ds-panel {
      margin-left: max(var(--ds-space-4), env(safe-area-inset-left));
    }

    .ds-app--drawer .ds-topbar {
      padding-left: max(var(--ds-space-2), env(safe-area-inset-left));
    }

    .ds-topbar__menu {
      display: inline-flex;
    }

    /* Physical on purpose (D9): the modal drawer slides in from the left
       edge (mirrored motion is not part of 2.0), so its corners, its bleed
       and its safe-area inset stay on that side. */
    .ds-drawer {
      position: fixed;
      top: 0;
      bottom: 0;
      left: 0;
      z-index: var(--ds-z-drawer);
      width: min(calc(var(--ds-drawer-width) + env(safe-area-inset-left)), calc(100vw - 56px));
      padding-top: env(safe-area-inset-top);
      border-radius: 0 var(--ds-radius-dialog) var(--ds-radius-dialog) 0;
      box-shadow: var(--ds-elev-overlay);
      /* Invisible, but forced colors drop fill, shadow and scrim and paint
         it: the sheet keeps an edge against the page, on its own edge
         whatever offset a page base gives focusable elements. */
      outline: 1px solid transparent;
      outline-offset: 0;
      /* An opaque sheet: it only slides on the spring, the scrim does the
         fading; the bleed covers the overshoot. Closing accelerates out in
         --ds-dur-base (0.44 of the entrance). A state change mid-flight
         starts the other transition from where the sheet is, on the curve
         of the new direction. */
      visibility: hidden;
      transform: translateX(calc(-1 * var(--ds-distance-sheet)));
      transition:
        transform var(--ds-dur-base) var(--ds-ease-exit),
        visibility 0ms linear var(--ds-dur-base);
    }

    .ds-app[data-drawer-open] .ds-drawer {
      visibility: visible;
      transform: none;
      transition:
        transform var(--ds-dur-long) var(--ds-ease-spatial),
        visibility 0ms;
    }

    /* Bleed: the spring carries the sheet a little past x = 0 (about 3 % of
       its width, at most 5 %); this strip of its own background left of it
       keeps the scrim from showing at the screen edge. The fixed drawer is
       its containing block, so it is off screen at rest and hidden with the
       drawer. It reaches 1px under the drawer's edge, so no antialiased seam
       lets the shadow through while the edge sits between pixels. */
    .ds-drawer::before {
      content: '';
      position: absolute;
      top: 0;
      bottom: 0;
      right: calc(100% - 1px);
      width: calc(var(--ds-space-8) + 1px);
      background: inherit;
      pointer-events: none;
    }

    /* The modal drawer is closed whenever the active item changes: the pill
       only has to be in place when it opens again, not glide while it leaves. */
    .ds-drawer .ds-drawer__indicator {
      transition: none;
    }

    /* Its labels change colour at once with it: the lists of the items
       without the colour entry. */
    .ds-drawer .ds-drawer__item {
      transition:
        border-radius var(--ds-dur-medium) var(--ds-ease-spatial),
        outline-offset var(--ds-dur-fast) var(--ds-ease-out);
    }

    .ds-drawer .ds-has-indicator .ds-drawer__item:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"]),
    .ds-drawer .ds-has-indicator .ds-drawer__item.ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {
      transition:
        border-radius var(--ds-dur-micro) var(--ds-ease-out),
        outline-offset var(--ds-dur-fast) var(--ds-ease-out);
    }

    .ds-drawer .ds-drawer__item svg {
      transition: none;
    }

    /* Also while the item a click has just made active is still pressed;
       the press morph of the pill stays. */
    .ds-drawer .ds-has-indicator:has(.ds-drawer__item[aria-current="page"]:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"])) > .ds-drawer__indicator,
    .ds-drawer .ds-has-indicator:has(.ds-drawer__item[aria-current="page"].ds-pressed:not(:disabled, [aria-disabled="true"], [aria-busy="true"])) > .ds-drawer__indicator {
      transition: --ds-indicator-radius var(--ds-dur-micro) var(--ds-ease-out);
    }

    /* The lock-up stays where the top bar shows it (menu button inset,
       40px button, gap), so it does not jump while the drawer slides over. */
    .ds-drawer__head {
      display: flex;
      align-items: center;
      gap: var(--ds-space-3);
      flex-shrink: 0;
      height: var(--ds-topbar-height);
      padding-block: 0;
      padding-inline: calc(var(--ds-space-2) + var(--ds-space-10) + var(--ds-space-2)) var(--ds-space-6);
    }

    .ds-scrim {
      display: block;
      position: fixed;
      inset: 0;
      z-index: var(--ds-z-drawer);
      background: var(--ds-scrim);
      visibility: hidden;
      opacity: 0;
      transition:
        opacity var(--ds-dur-base) var(--ds-ease-in),
        visibility 0ms linear var(--ds-dur-base);
    }

    /* The scrim fades in the same frame as the sheet starts. */
    .ds-app[data-drawer-open] .ds-scrim {
      visibility: visible;
      opacity: 1;
      transition:
        opacity var(--ds-dur-medium) var(--ds-ease-in-out),
        visibility 0ms;
    }

    /* The window crosses 900px: the drawer turns modal (or back) in place,
       without a slide or a scrim fade. The page sets the class for two
       frames (docs/components.md); the scrim comes before the drawer, so
       :has() reaches it. As specific as the open rules above, and later. */
    .ds-app .ds-drawer.ds-drawer--instant,
    .ds-app:has(> .ds-drawer--instant) > .ds-scrim {
      transition: none;
    }
  }

  /* The avatar names the person, so the text goes entirely (screen readers
     would hear the name twice). */
  @media (max-width: 720px) {
    .ds-account__text {
      display: none;
    }

    .ds-account__avatar {
      display: grid;
    }

    /* Only the avatar shows: without a button the capsule is round. */
    .ds-account--capsule:not(:has(> .ds-btn:not([hidden]))) {
      padding-inline-end: var(--ds-space-2);
    }

    /* The page offers its search in the view instead. */
    .ds-topbar__search {
      display: none;
    }

    .ds-view {
      --ds-content-pad-top: var(--ds-space-5);
      --ds-content-pad-x: var(--ds-space-4);
    }
  }

  /* 320px (400% zoom): no room for brand, avatar and sign-out. Without the
     avatar, name and email stay for screen readers, visually hidden. */
  @media (max-width: 359px) {
    .ds-account__avatar {
      display: none;
    }

    .ds-account__text {
      display: block;
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
  }

  /* Phones: the panel spans the width, its rounded top stays. */
  @media (max-width: 600px) {
    .ds-panel,
    .ds-app--drawer .ds-panel,
    .ds-app--drawer[data-drawer-hidden] .ds-panel {
      margin: 0;
    }

    .ds-view {
      --ds-content-pad-bottom: calc(var(--ds-space-4) + var(--ds-toast-room) + var(--ds-space-2));
      padding-left: env(safe-area-inset-left);
      padding-right: env(safe-area-inset-right);
    }

    .ds-topbar,
    .ds-app--drawer[data-drawer-hidden] .ds-topbar {
      padding-left: max(var(--ds-space-4), env(safe-area-inset-left));
    }

    /* The brand gives way to the mode switch: the mark stays. */
    .ds-segmented ~ .ds-account .ds-account__text,
    .ds-app--drawer .ds-brand__name {
      display: none;
    }

    /* A brand of only a name keeps it: it has nothing else to show. */
    .ds-app--drawer .ds-brand:not(:has(> :not(.ds-brand__name))) .ds-brand__name {
      display: inline;
    }

    .ds-topbar .ds-segmented {
      margin-inline-start: var(--ds-space-2);
    }

    /* A title in the bar takes the brand's room: two lines of the label
       role; the brand keeps its mark (a brand of only a name goes). */
    .ds-topbar__title {
      display: -webkit-box;
      font: var(--ds-font-label);
      white-space: normal;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 2;
    }

    .ds-topbar:has(.ds-topbar__title) .ds-brand__name,
    .ds-topbar:has(.ds-topbar__title) .ds-brand:not(:has(> :not(.ds-brand__name))) {
      display: none;
    }
  }

  /*
   * Forced colors (Windows contrast themes) drop the pill's fill: the
   * current destination and the selected option paint the selection
   * themselves, a choice in a set (SelectedItem, D8); the outline stays for
   * focus, a CanvasText ring inside that fill. Icons coloured on the <svg>
   * itself keep their authored colour there (dark grey on black), so every
   * icon follows the forced text colour of its control; status stays
   * readable through the text that always goes with it.
   */
  @media (forced-colors: active) {
    /* The forced canvas would paint the tint over nothing: the bar stays
       flat (the hairline still marks the scrolled view). */
    .ds-topbar::before {
      display: none;
    }

    /* The shades would keep their gradient in the theme's surface colour:
       their opacity goes, the room they hold stays. */
    .ds-drawer__list::before,
    .ds-drawer__list::after {
      visibility: hidden;
    }

    .ds-drawer__indicator,
    .ds-segmented__indicator {
      display: none;
    }

    .ds-drawer__item[aria-current="page"],
    .ds-has-indicator .ds-drawer__item[aria-current="page"],
    .ds-segmented__option:is([aria-current="true"], [aria-checked="true"], [aria-pressed="true"]),
    .ds-has-indicator > .ds-segmented__option:is([aria-current="true"], [aria-checked="true"], [aria-pressed="true"]) {
      forced-color-adjust: none;
      background: SelectedItem;
      color: SelectedItemText;
    }

    .ds-drawer__item[aria-current="page"]:focus-visible,
    .ds-segmented__option:is([aria-current="true"], [aria-checked="true"], [aria-pressed="true"]):focus-visible {
      outline-color: CanvasText;
    }

    .ds-drawer__item[aria-disabled="true"],
    .ds-segmented__option:is(:disabled, [aria-disabled="true"]) {
      color: GrayText;
    }

    /* The disc keeps its edge where its fill is gone. */
    .ds-avatar,
    .ds-account__avatar {
      outline: 1px solid transparent;
    }

    .ds-theme svg {
      color: inherit !important;
    }
  }

  @media print {
    .ds-app {
      display: block;
      height: auto;
      background: var(--ds-surface);
    }

    .ds-topbar,
    .ds-drawer,
    .ds-scrim,
    .ds-skip,
    .ds-banner {
      display: none;
    }

    .ds-panel,
    .ds-view {
      display: block;
      height: auto;
      margin: 0;
      overflow: visible;
      border-radius: 0;
    }

    .ds-view::before {
      display: none;
    }

    /* Paper has no "on its way out": what is shown prints in full. */
    .ds-stale {
      opacity: 1;
    }

    /* A short card, a tile, an alert, a key-value row or a fact stays on
       one page. A card that holds a table may break (card.css): pushed
       whole to the next page inside a grid, it would print over the
       blocks after it. */
    .ds-card:not(:has(.ds-table-wrap, .ds-table)),
    .ds-tile,
    .ds-alert,
    .ds-kv-list li,
    .ds-facts > div {
      break-inside: avoid;
    }
  }
}

/* ── components/tabs.css ── */
/*
 * Tabs: sections of one page or record, one row of labels over a hairline.
 * The selected tab takes the primary ink, the label weight and a bar under
 * it that glides between the tabs (js/indicator.js); selected is bar,
 * weight and ARIA, never colour alone.
 *
 *   <div class="ds-tabs" role="tablist" aria-label="Device">
 *     <button class="ds-tabs__tab" role="tab" type="button" id="t-overview" aria-selected="true" aria-controls="p-overview" data-label="Overview">Overview</button>
 *     <button class="ds-tabs__tab" role="tab" type="button" id="t-activity" aria-selected="false" aria-controls="p-activity" tabindex="-1" data-label="Activity">Activity<span class="ds-badge">3</span></button>
 *   </div>
 *   <div class="ds-tabs__panel" role="tabpanel" id="p-overview" aria-labelledby="t-overview" tabindex="0"> ... </div>
 *   <div class="ds-tabs__panel" role="tabpanel" id="p-activity" aria-labelledby="t-activity" tabindex="0" hidden> ... </div>
 *
 *   Link tabs (navigation between pages of a record):
 *   <nav class="ds-tabs" aria-label="Device">
 *     <a class="ds-tabs__tab" href="/devices/42" aria-current="page" data-label="Overview">Overview</a> ...
 *   </nav>
 *
 *   createIndicator(tabs, { active: '[aria-selected="true"], [aria-current="page"]', className: 'ds-tabs__indicator' })
 *   DS.dom.roving(tabs)   (arrow keys, Home, End; the page's click handler selects)
 *
 * data-label (the tab's text again) reserves the width of the heavier
 * selected weight, so a selection change moves nothing (as on the
 * segmented switch); a badge after the text keeps its place.
 *
 * Modifiers: --fill (equal widths across the row), --flush (no hairline).
 * Blocked: :disabled or aria-disabled="true" (the disabled model; no
 * hover, press or morph, aria-disabled keeps its focus layer).
 *
 * Press: the state layer is a pill inside the tab (--ds-space-1 from its
 * top and bottom edge) that turns squarer while pressed
 * (--ds-radius-action-pressed in --ds-dur-micro, back on the spring) and
 * ripples (js/ripple.js); nothing moves. The keyboard ring is inset and
 * snaps inward (outline-offset, --ds-dur-fast) with the focus layer.
 *
 * Switching: ARIA tabs toggle `hidden` on the panels and enter the new one
 * (DS.motion.enter(panel, 'swap')); link tabs are navigation
 * (DS.motion.transition(update, { type: 'fade-through' })). The labels
 * change colour on the bar's timing (--ds-indicator-dur).
 *
 * Overflow: the row scrolls sideways without a scrollbar; DS.dom.roving()
 * scrolls a focused tab into view, and createIndicator(..., { reveal:
 * true }) keeps the selected one in view.
 *
 * Hooks: --ds-tabs-height (48px), --ds-tabs-indicator-height (3px),
 * --ds-tabs-indicator-color (--ds-primary).
 *
 * Forced colours: the selected tab is a choice in a set (SelectedItem /
 * SelectedItemText, the bar hidden), a focused one a CanvasText ring
 * inside; blocked tabs GrayText.
 *
 * Print: the row wraps instead of scrolling.
 *
 * Progressive:
 * - scrollbar-color and scrollbar-width (the row hides its scrollbar while
 *   it scrolls sideways): without them the platform scrollbar shows under
 *   the row.
 */

@layer ds.components {
  .ds-tabs {
    position: relative;
    display: flex;
    min-width: 0;
    overflow-x: auto;
    overflow-y: hidden;
    border-bottom: 1px solid var(--ds-line);
    scrollbar-width: none;
  }

  .ds-tabs::-webkit-scrollbar {
    display: none;
  }

  .ds-tabs--flush {
    border-bottom: 0;
  }

  .ds-tabs--fill > .ds-tabs__tab {
    flex: 1 1 0;
  }

  /* The bar: the indicator's box is the selected tab; only its bottom edge
     is painted, in the hook colour (square: --ds-indicator-radius keeps its
     initial 0px). */
  .ds-tabs__indicator {
    background: linear-gradient(var(--ds-tabs-indicator-color, var(--ds-primary)), var(--ds-tabs-indicator-color, var(--ds-primary))) bottom / 100% var(--ds-tabs-indicator-height, 3px) no-repeat;
  }

  .ds-tabs__tab {
    position: relative;
    display: inline-grid;
    grid-auto-flow: column;
    align-items: center;
    /* The rows pack in the middle: the 0px row of data-label takes none of
       the free height, so the label sits on the tab's centre. */
    align-content: center;
    justify-content: center;
    column-gap: var(--ds-space-2);
    flex: none;
    min-height: var(--ds-tabs-height, 48px);
    margin: 0;
    padding: 0 var(--ds-space-4);
    border: 0;
    /* The ring follows this radius; the layer inside is a pill too. */
    border-radius: min(var(--ds-radius-action), calc(var(--ds-tabs-height, 48px) / 2));
    background: none;
    color: var(--ds-text-2);
    font: var(--ds-font-label);
    font-weight: var(--ds-weight-regular);
    text-decoration: none;
    white-space: nowrap;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    /* An inset ring (the scrolling row clips anything outside): it rests on
       the edge and snaps inward. */
    outline-offset: 0;
    transition:
      outline-offset var(--ds-dur-fast) var(--ds-ease-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out);
  }

  /* With the gliding bar, the label changes colour while the bar arrives
     (on its timing, --ds-indicator-dur on the row). */
  .ds-has-indicator > .ds-tabs__tab {
    transition:
      outline-offset var(--ds-dur-fast) var(--ds-ease-out),
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      color var(--ds-indicator-dur, var(--ds-dur-medium)) var(--ds-ease-in-out);
  }

  /* The label once more in the selected weight, without height and unseen,
     under the text (a badge keeps its column). */
  .ds-tabs__tab[data-label]::after {
    content: attr(data-label);
    grid-row: 2;
    grid-column: 1;
    height: 0;
    overflow: hidden;
    visibility: hidden;
    pointer-events: none;
    font-weight: var(--ds-weight-label);
  }

  .ds-tabs__tab:is([aria-selected="true"], [aria-current="page"]) {
    color: var(--ds-primary);
    font-weight: var(--ds-weight-label);
  }

  /* State layer: a pill inside the tab, the colour of the label. */
  .ds-tabs__tab::before {
    content: '';
    position: absolute;
    inset: var(--ds-space-1) 0;
    border-radius: min(var(--ds-radius-action), calc(var(--ds-tabs-height, 48px) / 2 - var(--ds-space-1)));
    background: currentColor;
    opacity: 0;
    pointer-events: none;
    transition:
      opacity var(--ds-dur-fast) var(--ds-ease-in-out),
      border-radius var(--ds-dur-medium) var(--ds-ease-spatial);
  }

  @media (hover: hover) {
    .ds-tabs__tab:hover::before {
      opacity: var(--ds-state-hover);
    }
  }

  .ds-tabs__tab:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: calc(-1 * var(--ds-focus-width));
  }

  .ds-tabs__tab:focus-visible::before {
    opacity: var(--ds-state-focus);
    transition-duration: 0s, var(--ds-dur-medium);
  }

  .ds-tabs__tab:active::before,
  .ds-tabs__tab.ds-pressed::before {
    opacity: var(--ds-state-pressed);
    border-radius: min(var(--ds-radius-action-pressed), var(--ds-radius-action), calc(var(--ds-tabs-height, 48px) / 2 - var(--ds-space-1)));
    transition:
      opacity var(--ds-dur-micro) var(--ds-ease-in-out),
      border-radius var(--ds-dur-micro) var(--ds-ease-out);
  }

  /* Blocked: the disabled model, no hover, press or morph; an
     aria-disabled tab stays focusable and keeps its focus layer. */
  .ds-tabs__tab:is(:disabled, [aria-disabled="true"]) {
    color: var(--ds-disabled-ink);
    opacity: var(--ds-disabled-opacity);
    cursor: default;
  }

  @media (hover: hover) {
    .ds-tabs__tab:is(:disabled, [aria-disabled="true"]):hover::before {
      opacity: 0;
    }
  }

  .ds-tabs__tab:is(:disabled, [aria-disabled="true"]):is(:active, .ds-pressed)::before {
    opacity: 0;
    border-radius: min(var(--ds-radius-action), calc(var(--ds-tabs-height, 48px) / 2 - var(--ds-space-1)));
  }

  .ds-tabs__tab[aria-disabled="true"]:focus-visible::before {
    opacity: var(--ds-state-focus);
  }

  /* A panel takes focus as a whole (tabindex="0") when it holds nothing
     focusable; its ring snaps in like every ring. */
  .ds-tabs__panel {
    min-width: 0;
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition: outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  .ds-tabs__panel:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  @media (forced-colors: active) {
    .ds-tabs__indicator {
      display: none;
    }

    .ds-tabs__tab:is([aria-selected="true"], [aria-current="page"]) {
      forced-color-adjust: none;
      background: SelectedItem;
      color: SelectedItemText;
    }

    .ds-tabs__tab:is([aria-selected="true"], [aria-current="page"]):focus-visible {
      outline-color: CanvasText;
    }

    .ds-tabs__tab:is(:disabled, [aria-disabled="true"]) {
      color: GrayText;
    }
  }

  @media print {
    .ds-tabs {
      flex-wrap: wrap;
      overflow: visible;
    }
  }
}

/* ── components/steps.css ── */
/*
 * Steps of a form (a wizard): where the user is in a short sequence. Steps
 * are not buttons; going back is a link or a button of the page.
 *
 *   <ol class="ds-steps" aria-label="Report a problem">
 *     <li class="ds-steps__step" data-status="done">
 *       <span class="ds-steps__mark">(check 16, its path with pathLength="1")</span>
 *       <span class="ds-steps__label">Device<span class="ds-sr">, done</span></span>
 *     </li>
 *     <li class="ds-steps__step" aria-current="step"><span class="ds-steps__mark">2</span><span class="ds-steps__label">Problem</span></li>
 *     <li class="ds-steps__step"><span class="ds-steps__mark">3</span><span class="ds-steps__label">Contact</span></li>
 *   </ol>
 *
 *   A bar instead of marks: <ol class="ds-steps ds-steps--bar" aria-label="Step 2 of 3"> (the same items)
 *
 * State, set by the page: data-status="done" on a finished step (as on
 * .ds-tasks; never data-state, which DS.overlay writes on overlays) and
 * aria-current="step" on the current one; the others are upcoming.
 *
 * Marks: a 24px disc. Done: --ds-primary with a check that draws itself
 * when the step turns done in place (re-render with DS.dom.morphHtml, so
 * the mark stays): the mark's stroke-dashoffset, which the pathLength="1"
 * check inherits, runs from 1 to 0 over --ds-dur-medium after
 * --ds-dur-micro; steps rendered with done ones stay still on load (a
 * transition, not a keyframe). Current: --ds-primary
 * with --ds-on-primary figures and the label weight on its label;
 * upcoming: an --ds-outline ring in --ds-text-2. The connector after a
 * done step turns --ds-primary (colours on --ds-dur-medium). Status is
 * never colour alone: the check, the weight and aria-current. On narrow
 * pages (600px of the content and below) only the current label shows;
 * the others stay readable.
 *
 * --bar: one segment per step, --ds-steps-bar-height high; done and
 * current segments fill with --ds-primary (a clip that glides on
 * --ds-ease-value over --ds-dur-glide), the rest is --ds-accent. Marks and
 * labels are visually hidden: the list's aria-label says where the user is.
 *
 * Moving between steps: DS.motion.transition(update, { type: 'forward' })
 * (or 'back').
 *
 * Hooks: --ds-steps-bar-height (4px).
 *
 * Forced colours: done and current marks and segments Highlight,
 * upcoming ones a CanvasText outline.
 *
 * Print: marks and segments keep their colours (print-color-adjust): the
 * check of a done step is drawn on its filled mark.
 */

@layer ds.components {
  .ds-steps {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    min-width: 0;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .ds-steps__step {
    display: flex;
    align-items: center;
    gap: var(--ds-space-2);
    flex: 0 1 auto;
    min-width: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-body);
  }

  /* The connector: a hairline that takes the room between two steps. */
  .ds-steps__step + .ds-steps__step {
    flex: 1 1 0;
  }

  .ds-steps__step + .ds-steps__step::before {
    content: '';
    flex: 1 1 var(--ds-space-6);
    min-width: var(--ds-space-4);
    height: 1px;
    background: var(--ds-line);
    transition: background-color var(--ds-dur-medium) var(--ds-ease-in-out);
  }

  .ds-steps__step[data-status="done"] + .ds-steps__step::before {
    background: var(--ds-primary);
  }

  .ds-steps__mark {
    display: inline-grid;
    flex: none;
    place-items: center;
    width: var(--ds-space-6);
    height: var(--ds-space-6);
    border: 1px solid var(--ds-outline);
    border-radius: 50%;
    color: var(--ds-text-2);
    font: var(--ds-font-caption);
    font-variant-numeric: tabular-nums;
    /* Inherited by the check of a done step: undrawn until the step is
       done. A transition, not a keyframe, so steps rendered with done ones
       stay still on load. */
    stroke-dashoffset: 1;
    transition:
      background-color var(--ds-dur-medium) var(--ds-ease-in-out),
      border-color var(--ds-dur-medium) var(--ds-ease-in-out),
      color var(--ds-dur-medium) var(--ds-ease-in-out),
      stroke-dashoffset var(--ds-dur-medium) var(--ds-ease-out) var(--ds-dur-micro);
  }

  .ds-steps__label {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .ds-steps__step:is([data-status="done"], [aria-current="step"]) {
    color: var(--ds-text);
  }

  .ds-steps__step:is([data-status="done"], [aria-current="step"]) > .ds-steps__mark {
    border-color: var(--ds-primary);
    background: var(--ds-primary);
    color: var(--ds-on-primary);
  }

  .ds-steps__step[aria-current="step"] > .ds-steps__label {
    font: var(--ds-font-label);
  }

  .ds-steps__mark [pathLength] {
    stroke-dasharray: 1;
  }

  /* The mark stays when a step turns done in place (DS.dom.morphHtml
     patches it), so its offset glides to 0 and the new check draws itself;
     the pathLength="1" path inherits it. */
  .ds-steps__step[data-status="done"] > .ds-steps__mark {
    stroke-dashoffset: 0;
  }

  /* Narrow pages: the current label only, the others read but unseen. */
  @container ds-content (max-width: 600px) {
    .ds-steps__step:not([aria-current="step"]) > .ds-steps__label {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
  }

  /* The bar: equal segments; done and current ones fill. */
  .ds-steps--bar {
    gap: var(--ds-space-1);
  }

  .ds-steps--bar > .ds-steps__step {
    position: relative;
    flex: 1 1 0;
    height: var(--ds-steps-bar-height, 4px);
    overflow: hidden;
    border-radius: var(--ds-radius-xs);
    background: var(--ds-accent);
  }

  .ds-steps--bar > .ds-steps__step + .ds-steps__step::before {
    content: none;
  }

  .ds-steps--bar > .ds-steps__step::after {
    content: '';
    position: absolute;
    inset: 0;
    background: var(--ds-primary);
    clip-path: inset(0 100% 0 0);
    transition: clip-path var(--ds-dur-glide) var(--ds-ease-value);
  }

  .ds-steps--bar > .ds-steps__step:is([data-status="done"], [aria-current="step"])::after {
    clip-path: inset(0);
  }

  .ds-steps--bar .ds-steps__mark {
    display: none;
  }

  .ds-steps--bar .ds-steps__label {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  @media (forced-colors: active) {
    .ds-steps__mark {
      border-color: CanvasText;
    }

    .ds-steps__step:is([data-status="done"], [aria-current="step"]) > .ds-steps__mark {
      forced-color-adjust: none;
      border-color: Highlight;
      background: Highlight;
      color: HighlightText;
    }

    .ds-steps--bar > .ds-steps__step {
      outline: 1px solid CanvasText;
      outline-offset: -1px;
    }

    .ds-steps--bar > .ds-steps__step::after {
      forced-color-adjust: none;
      background: Highlight;
    }
  }

  /* Print: the filled marks and segments are backgrounds, which printing
     drops by default (the check of a done step would vanish with them). */
  @media print {
    .ds-steps {
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
  }
}

/* ── components/ripple.css ── */
/*
 * Pointer ripple (js/ripple.js): a press grows a circle of the content
 * colour from the point that was pressed (Enter or Space: from the centre)
 * and fades it when the press ends. It is an effect, so it never
 * overshoots. Everything here applies only under .ds-ripples, which
 * DS.ripple.attach(root) puts on the root; pages without it keep the flat
 * state layer as their press feedback.
 *
 *   DS.ripple.attach(document);     <html class="... ds-ripples">
 *   <button class="ds-btn">         a host
 *     ... <span class="ds-press-ripple" aria-hidden="true" data-ds-keep
 *              style="--ds-ripple-x: 31px; --ds-ripple-y: 18px; --ds-ripple-r: 52px"></span>
 *   </button>
 *
 * Hosts: .ds-btn, .ds-chip (the ripple goes into .ds-chip__body),
 * .ds-segmented__option, .ds-drawer__item, :is(button, a).ds-tile,
 * .ds-card--interactive, .ds-table__sort, the body rows of
 * .ds-table--interactive (a press on another link or control inside a
 * row or a <div> card is that control's own: no ripple, no .ds-pressed),
 * .ds-radio (radio cards), .ds-combo__toggle,
 * .ds-menu__item, .ds-errors summary, .ds-pressable (a project element
 * with the system's state layer, utilities.css) and [data-ds-ripple] (any element,
 * made positioned here); inline links, the switch, checkbox and radio
 * inputs (their halo is the press), combobox options and native field
 * parts have none; data-ds-ripple="off" on a host or an ancestor opts out.
 * Each host gets position: relative, and while a ripple is inside also
 * overflow: clip and isolation: isolate, all with zero specificity: a
 * ripple never escapes its host, a host's own position still wins, and a
 * host at rest looks as without .ds-ripples.
 *
 * .ds-press-ripple--out   the release: the ripple fades, then the script removes it
 *
 * Runtime properties (set by the script on the ripple): --ds-ripple-x and
 * --ds-ripple-y (its centre, from the host's padding box), --ds-ripple-r
 * (the distance to the farthest corner).
 *
 * Hook: --ds-ripple-color (default currentColor, the content colour).
 *
 * The flat state layer and the ripple never stack: under .ds-ripples a
 * pressed host keeps its hover layer (devices that hover) or none (touch),
 * or its focus layer when a key pressed it, and the ripple adds the press
 * (--ds-state-pressed). A button's layer also keeps what its
 * --ds-btn-state-pressed-opacity hook asks for beyond the ripple (the
 * solid disc of .ds-btn--disc). Forced colours show no ripple.
 *
 * A table row cannot hold the ripple and does not clip: the ripple goes
 * into its first cell (a <td>, or a <th scope="row"> row header) as a box
 * over the whole row (against the positioned row; the script gives it the
 * row's height, which WebKit would take from the table) that clips the circle,
 * its ::before. The row is isolated while it ripples, so the ripple
 * paints over the row's colour and under the cells' content and layers.
 * A row of a pinned table (.ds-table--pin-first) ripples in two parts with
 * one centre: the box over the row goes into its second cell (the pinned
 * first cell paints over it), a second box into the pinned cell, which
 * places and clips it; the pinned cell lays the row's layer over its
 * opaque base, so it follows the same rule as the other cells.
 *
 * Progressive:
 * - overflow-clip-margin (a chip's ripple covers the chip's 1px border
 *   like its state layer does, and a selected interactive card keeps the
 *   outer pixel of its 2px ring while a ripple is inside): without it the
 *   ripple stops at the padding box and the card's ring thins to 1px for
 *   the press.
 */

@layer ds.components {
  :where(.ds-ripples :is(.ds-btn, .ds-chip__body, .ds-segmented__option, .ds-drawer__item, button.ds-tile, a.ds-tile, .ds-card--interactive, .ds-table__sort, .ds-table--interactive tbody tr:has(.ds-table__row-link), .ds-radio, .ds-combo__toggle, .ds-menu__item, .ds-errors summary, [data-ds-ripple]:not([data-ds-ripple="off"]))) {
    position: relative;
  }

  /* Clipped and isolated only while a ripple is inside: at rest nothing of
     the host changes (no clipped overflow, no other raster path). */
  :where(.ds-ripples :has(> .ds-press-ripple)) {
    overflow: clip;
    isolation: isolate;
  }

  /* A selected interactive card draws its ring over its 1px border (its
     ::after at inset -1px): the clip keeps that pixel, so the ring does not
     thin while pressed (its border is transparent, so the ripple there is
     under the ring). */
  @supports (overflow-clip-margin: 1px) {
    :where(.ds-ripples .ds-chip__body:has(> .ds-press-ripple)),
    :where(.ds-ripples .ds-card--interactive:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"], :has(.ds-card__action:is([aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]))):has(> .ds-press-ripple)) {
      overflow-clip-margin: 1px;
    }
  }

  /* Above the host's fill, below its content (the host is isolated).
     Physical on purpose (D9): --ds-ripple-x and -y are pointer
     coordinates from the host's top left corner. */
  .ds-press-ripple {
    position: absolute;
    top: calc(var(--ds-ripple-y) - var(--ds-ripple-r));
    left: calc(var(--ds-ripple-x) - var(--ds-ripple-r));
    z-index: -1;
    width: calc(var(--ds-ripple-r) * 2);
    height: calc(var(--ds-ripple-r) * 2);
    border-radius: 50%;
    background: var(--ds-ripple-color, currentColor);
    opacity: var(--ds-state-pressed);
    pointer-events: none;
    animation: ds-press-ripple var(--ds-dur-medium) var(--ds-ease-out) backwards;
  }

  .ds-press-ripple--out {
    opacity: 0;
    transition: opacity var(--ds-dur-base) var(--ds-ease-in-out);
  }

  /* A row's ripple: a box over the row (its first cell, <td> or a <th>
     row header, is static, so the row places it: positioned, and in an
     interactive table transformed, which WebKit needs) that clips
     the circle. The cell neither
     clips nor isolates; the row is isolated instead. */
  :where(.ds-ripples tr:has(> :is(td, th) > .ds-press-ripple)) {
    isolation: isolate;
  }

  :where(.ds-ripples tr > :is(td, th):has(> .ds-press-ripple)) {
    overflow: visible;
    isolation: auto;
  }

  .ds-ripples tr > :is(td, th) > .ds-press-ripple {
    inset: 0;
    width: auto;
    height: auto;
    overflow: clip;
    border-radius: 0;
    background: none;
    animation: none;
  }

  .ds-ripples tr > :is(td, th) > .ds-press-ripple::before {
    content: '';
    position: absolute;
    top: calc(var(--ds-ripple-y) - var(--ds-ripple-r));
    left: calc(var(--ds-ripple-x) - var(--ds-ripple-r));
    width: calc(var(--ds-ripple-r) * 2);
    height: calc(var(--ds-ripple-r) * 2);
    border-radius: 50%;
    background: var(--ds-ripple-color, currentColor);
    animation: ds-press-ripple var(--ds-dur-medium) var(--ds-ease-out) backwards;
  }

  /*
   * One press feedback, not two: the ripple brings --ds-state-pressed, so
   * the flat layer of a pressed host (:active, or .ds-pressed while
   * js/ripple.js holds the press) stays at its hover value (0 on touch,
   * which shows no hover). A button keeps its press hook: its layer shows
   * what --ds-btn-state-pressed-opacity asks for beyond the ripple's
   * --ds-state-pressed (so .ds-btn--disc keeps its disc on touch, too; a
   * negative difference computes to 0). A key press on a focused host keeps
   * its focus layer (--ds-state-focus) under the ripple.
   * :where() keeps the specificity of each component's own press rule, so
   * a disabled host's rule still wins; a component whose press rule weighs
   * more needs a rule of the same weight here.
   */
  :where(.ds-ripples) .ds-btn:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
    opacity: calc(var(--ds-btn-state-pressed-opacity, var(--ds-state-pressed)) - var(--ds-state-pressed));
  }

  :where(.ds-ripples) .ds-chip:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed) .ds-chip__input:not(:disabled, [aria-disabled="true"]) + .ds-chip__body::before,
  :where(.ds-ripples) .ds-chip:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(button, a):not(:disabled, [aria-disabled="true"]):is(:active, .ds-pressed) > .ds-chip__body::before,
  :where(.ds-ripples) .ds-segmented__option:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-drawer__item:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) :is(button, a).ds-tile:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-card--interactive:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-table__sort:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-radio:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed):not(.ds-radio--disabled, :has(input:is(:disabled, [aria-disabled="true"])))::before,
  :where(.ds-ripples) .ds-combo__toggle:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-menu__item:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-errors summary:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-pressable:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  /* A row's layer is the one its cells paint (table.css). */
  :where(.ds-ripples) .ds-table--interactive tbody tr:not(.ds-table__detail):has(.ds-table__row-link):where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed) {
    --ds-table-row-layer: transparent;
  }

  @media (hover: hover) {
    :where(.ds-ripples) .ds-btn:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
      opacity: max(var(--ds-btn-state-opacity, var(--ds-state-hover)), calc(var(--ds-btn-state-pressed-opacity, var(--ds-state-pressed)) - var(--ds-state-pressed)));
    }

    :where(.ds-ripples) .ds-chip:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed) .ds-chip__input:not(:disabled, [aria-disabled="true"]) + .ds-chip__body::before,
    :where(.ds-ripples) .ds-chip:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(button, a):not(:disabled, [aria-disabled="true"]):is(:active, .ds-pressed) > .ds-chip__body::before,
    :where(.ds-ripples) .ds-segmented__option:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) .ds-table__sort:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) .ds-combo__toggle:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) .ds-menu__item:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) .ds-errors summary:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) .ds-pressable:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
      opacity: var(--ds-state-hover);
    }

    :where(.ds-ripples) .ds-drawer__item:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) :is(button, a).ds-tile:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) .ds-card--interactive:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) .ds-radio:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed):not(.ds-radio--disabled, :has(input:is(:disabled, [aria-disabled="true"])))::before,
    :where(.ds-ripples) .ds-pressable.ds-pressable--lg:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
      opacity: var(--ds-state-hover-lg);
    }

    :where(.ds-ripples) .ds-table--interactive tbody tr:not(.ds-table__detail):has(.ds-table__row-link):where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed) {
      --ds-table-row-layer: var(--ds-table-row-hover, color-mix(in srgb, currentColor calc(var(--ds-state-hover-lg) * 100%), transparent));
    }
  }

  :where(.ds-ripples) .ds-btn:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before {
    opacity: max(var(--ds-btn-state-focus-opacity, var(--ds-state-focus)), calc(var(--ds-btn-state-pressed-opacity, var(--ds-state-pressed)) - var(--ds-state-pressed)));
  }

  :where(.ds-ripples) .ds-chip:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed) .ds-chip__input:focus-visible + .ds-chip__body::before,
  :where(.ds-ripples) .ds-chip:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(button, a):focus-visible:is(:active, .ds-pressed) > .ds-chip__body::before,
  :where(.ds-ripples) .ds-segmented__option:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-drawer__item:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before,
  :where(.ds-ripples) :is(button, a).ds-tile:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-card--interactive:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:focus-visible, :has(.ds-card__action:focus-visible)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-table__sort:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-menu__item:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-radio:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):has(:where(input):focus-visible):is(:active, .ds-pressed):not(.ds-radio--disabled, :has(input:is(:disabled, [aria-disabled="true"])))::before,
  :where(.ds-ripples) .ds-errors summary:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-pressable:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before {
    opacity: var(--ds-state-focus);
  }

  :where(.ds-ripples) .ds-table--interactive tbody tr:not(.ds-table__detail):has(.ds-table__row-link):where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):has(.ds-table__row-link:focus-visible):is(:active, .ds-pressed) {
    --ds-table-row-layer: color-mix(in srgb, currentColor calc(var(--ds-state-focus) * 100%), transparent);
  }

  /* The drop zone (drag.css, 2.0): positioned by its own rule; its flat
     layer stays at the hover value while the ripple brings the press. */
  :where(.ds-ripples) .ds-dropzone:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed):not(:has(input:disabled))::before {
    opacity: 0;
  }

  @media (hover: hover) {
    :where(.ds-ripples) .ds-dropzone:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed):not(:has(input:disabled))::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  :where(.ds-ripples) .ds-dropzone:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):has(.ds-dropzone__input:focus-visible):is(:active, .ds-pressed):not(:has(input:disabled))::before {
    opacity: var(--ds-state-focus);
  }

  /* Row lists, key-value rows, disclosures and chart columns (2.0): the
     same model, in blocks of their own. */
  :where(.ds-ripples) .ds-list--interactive > .ds-list__item:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) :is(a, button).ds-kv-list__row:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-disclosure > summary:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) :is(button, a).ds-columns__col:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  @media (hover: hover) {
    :where(.ds-ripples) .ds-disclosure > summary:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) :is(button, a).ds-columns__col:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
      opacity: var(--ds-state-hover);
    }

    :where(.ds-ripples) .ds-list--interactive > .ds-list__item:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) :is(a, button).ds-kv-list__row:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
    :where(.ds-ripples) .ds-disclosure--row > summary:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
      opacity: var(--ds-state-hover-lg);
    }
  }

  :where(.ds-ripples) .ds-list--interactive > .ds-list__item:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):has(.ds-list__action:focus-visible):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) :is(a, button).ds-kv-list__row:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-disclosure > summary:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before,
  :where(.ds-ripples) :is(button, a).ds-columns__col:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before {
    opacity: var(--ds-state-focus);
  }

  /*
   * The drawer's group toggles (a large row, like its items) and the tabs
   * (2.0): the same one-feedback rule. A tab's layer is a pill inside the
   * tab (tabs.css), so its ripple is a box over that pill that clips the
   * circle, its ::before (like a table row's).
   */
  :where(.ds-ripples :is(.ds-drawer__toggle, .ds-tabs__tab)) {
    position: relative;
  }

  .ds-ripples .ds-tabs__tab > .ds-press-ripple {
    top: var(--ds-space-1);
    inset-inline: 0;
    bottom: var(--ds-space-1);
    width: auto;
    height: auto;
    overflow: clip;
    border-radius: min(var(--ds-radius-action), calc(var(--ds-tabs-height, 48px) / 2 - var(--ds-space-1)));
    background: none;
    animation: none;
  }

  .ds-ripples .ds-tabs__tab > .ds-press-ripple::before {
    content: '';
    position: absolute;
    top: calc(var(--ds-ripple-y) - var(--ds-ripple-r) - var(--ds-space-1));
    left: calc(var(--ds-ripple-x) - var(--ds-ripple-r));
    width: calc(var(--ds-ripple-r) * 2);
    height: calc(var(--ds-ripple-r) * 2);
    border-radius: 50%;
    background: var(--ds-ripple-color, currentColor);
    animation: ds-press-ripple var(--ds-dur-medium) var(--ds-ease-out) backwards;
  }

  :where(.ds-ripples) .ds-drawer__toggle:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-tabs__tab:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
    opacity: 0;
  }

  @media (hover: hover) {
    :where(.ds-ripples) .ds-drawer__toggle:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
      opacity: var(--ds-state-hover-lg);
    }

    :where(.ds-ripples) .ds-tabs__tab:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):is(:active, .ds-pressed)::before {
      opacity: var(--ds-state-hover);
    }
  }

  :where(.ds-ripples) .ds-drawer__toggle:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before,
  :where(.ds-ripples) .ds-tabs__tab:where(:not([data-ds-ripple="off"], [data-ds-ripple="off"] *)):focus-visible:is(:active, .ds-pressed)::before {
    opacity: var(--ds-state-focus);
  }

  @media (forced-colors: active) {
    .ds-press-ripple {
      display: none;
    }
  }
}

/* ── components/page.css ── */
/*
 * Page content inside the content panel (shell.css): the padded content
 * area, one page at most --ds-content-max wide, the page header with its
 * back link or breadcrumbs, the layout helpers for cards, and the sign-in
 * page.
 *
 *   <div class="ds-content">            padding, container "ds-content"
 *     <div class="ds-page">             one page, gap 20px
 *       <header class="ds-page-head">
 *         <a class="ds-btn ds-btn--text ds-btn--sm ds-page-head__back" href="/devices">(arrowLeft 18) <span class="ds-sr">Back to </span>Devices</a>
 *         (or <nav class="ds-breadcrumbs" aria-label="Breadcrumb"> <ol> <li><a href="/">Home</a></li> <li><a href="/devices">Devices</a></li> <li><a href="/devices/42" aria-current="page">LAPTOP-0042</a></li> </ol> </nav>)
 *         <div class="ds-page-head__row">
 *           <div class="ds-page-head__text"> <h1 class="ds-page-head__title" tabindex="-1">LAPTOP-0042</h1> <p class="ds-page-head__lead">...</p>
 *             <div class="ds-page-head__status"> status chips </div> </div>
 *           <div class="ds-page-head__actions"> buttons </div>
 *         </div>
 *       </header>
 *       (a plain head: <header class="ds-page-head"> <h1 class="ds-page-head__title"> <p class="ds-page-head__lead"> </header>)
 *       <div class="ds-stack"> | <div class="ds-grid-2"> cards ...
 *
 *   Sign-in: <body class="ds-auth"> (or a <main class="ds-auth">)
 *     <div class="ds-auth__alert"> an alert, when there is one </div>
 *     <section class="ds-card ds-card--elevated ds-auth__card"> <h1>...</h1> <form class="ds-form"> ... <div class="ds-form-actions"> <button class="ds-btn ds-btn--primary" type="submit">Sign in</button> </div> </form> </section>
 *
 * The panel's width, not the window's, decides when tables and form grids
 * get narrow: the persistent drawer takes --ds-drawer-width of it. Those
 * rules are container queries on "ds-content". The end of a page stays
 * clear of a one- or two-line snackbar (--ds-toast-room), so a success
 * message never covers the actions just pressed.
 *
 * Page head: the back link (a small text button) or the breadcrumbs take
 * the first row (one or the other); the title row wraps: the text takes
 * --ds-page-head-text-basis, the actions sit at its end on the title's
 * centre line (--ds-page-head-title-line, the headline's line height) and
 * start under the title at 600px of the content. A text button hangs its
 * padding into the gutter, as in a card's link row: the last one ends on
 * the content edge, and under the title the first one starts on the
 * title's edge (outlined and filled buttons, the buttons after the first
 * under the title and a folded .ds-btn--adaptive keep their place and the
 * 8px gap). Empty parts (and an empty head) take no room. Breadcrumbs
 * are inline links (underlined, no ripple, the link ring) with a chevron
 * between them; at 600px of the content only the parent shows. A docked
 * drawer with a back item (.ds-drawer__back) hides the page head's back
 * link (shell.css).
 *
 * A lone .ds-btn directly in .ds-page or .ds-stack keeps its own width.
 *
 * Read-only resulting properties of .ds-content (read them, never set
 * them; set the spacing tokens instead): --ds-content-pad-top,
 * --ds-content-pad-x and --ds-content-pad-bottom, the top, side and bottom
 * padding of the panel at the current width (.ds-view defines the same,
 * shell.css). The loading bar (loading.css) reads them, and inside
 * .ds-page the page's centring (100cqw of the "ds-content" container), to
 * reach the panel edges.
 *
 * .ds-auth: a page that is one centred card on the backdrop (a sign-in, a
 * password reset), within the safe areas; its alert slot sits above the
 * card, and the primary action takes the card's width.
 *
 * Hooks: --ds-stack-gap (--ds-space-4), --ds-stack-gap-page (--ds-space-5:
 * a stack of cards directly in the page, or .ds-stack--page anywhere),
 * --ds-page-head-text-basis (320px), --ds-page-head-title-line (32px),
 * --ds-breadcrumbs-item-max (240px), --ds-auth-width (400px).
 *
 * Forced colours: the breadcrumb chevrons paint in CanvasText.
 *
 * Print: the page fills the paper; the back link and the head's actions
 * stay off it.
 *
 * Progressive:
 * - text-wrap (the lead avoids a lone last word): without it the line
 *   wraps as usual.
 */

@layer ds.components {
  .ds-content {
    --ds-content-pad-top: var(--ds-space-6);
    --ds-content-pad-x: var(--ds-space-8);
    --ds-content-pad-bottom: calc(var(--ds-space-6) + var(--ds-toast-room) + var(--ds-space-2));
    container: ds-content / inline-size;
    padding: var(--ds-content-pad-top) var(--ds-content-pad-x) var(--ds-content-pad-bottom);
  }

  .ds-page {
    width: min(100%, var(--ds-content-max));
    margin: 0 auto;
    display: grid;
    gap: var(--ds-space-5);
    align-content: start;
  }

  /* The page's gap is the rhythm: a closing note has no browser margin. */
  .ds-page > p {
    margin: 0;
  }

  /* A lone button in the page or a stack keeps its own width (a grid item
     would stretch across the column). */
  .ds-page > .ds-btn,
  .ds-stack > .ds-btn {
    justify-self: start;
  }

  @media (max-width: 720px) {
    .ds-content {
      --ds-content-pad-top: var(--ds-space-5);
      --ds-content-pad-x: var(--ds-space-4);
    }
  }

  /* Phones: the snackbar sits closer to the bottom edge (toast.css). */
  @media (max-width: 600px) {
    .ds-content {
      --ds-content-pad-bottom: calc(var(--ds-space-4) + var(--ds-toast-room) + var(--ds-space-2));
    }
  }

  /* ── Page header ──────────────────────────────── */

  .ds-page-head {
    display: grid;
    gap: var(--ds-space-1);
    min-width: 0;
    margin-bottom: var(--ds-space-1);
  }

  .ds-page-head:empty {
    display: none;
  }

  /* A long name without spaces (a serial, a URL) breaks instead of
     widening the page. */
  .ds-page-head__title {
    margin: 0;
    color: var(--ds-text);
    font: var(--ds-font-headline);
    letter-spacing: 0;
    overflow-wrap: anywhere;
  }

  .ds-page-head__lead {
    margin: 0;
    max-width: var(--ds-measure);
    color: var(--ds-text-2);
    font: var(--ds-font-body);
    font-variant-numeric: tabular-nums;
    text-wrap: pretty;
  }

  /* The way back: a small text button whose label lines up with the title
     (it hangs out by its own start padding). */
  .ds-page-head__back {
    justify-self: start;
    margin-inline-start: calc(-1 * var(--ds-btn-padding-start, var(--ds-space-3)));
  }

  /* Title and actions: the text takes its basis, the actions sit at the
     end of the row and wrap under it when the row is full. */
  .ds-page-head__row {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    gap: var(--ds-space-3) var(--ds-space-4);
    min-width: 0;
  }

  .ds-page-head__text {
    display: grid;
    flex: 1 1 var(--ds-page-head-text-basis, 320px);
    gap: var(--ds-space-1);
    min-width: 0;
  }

  .ds-page-head__status {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ds-space-2);
  }

  /* On the title's centre line: the buttons hang out of the headline's
     line by the same amount above and below. */
  .ds-page-head__actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    align-items: center;
    gap: var(--ds-space-2);
    margin-block: calc((var(--ds-page-head-title-line, 32px) - var(--ds-btn-height, 36px)) / 2);
    margin-inline-start: auto;
  }

  /* A trailing text button hangs its padding into the gutter, so its text
     ends on the edge the title, the cards and the banners share (as in a
     card's link row). Outlined and filled buttons keep their place. */
  .ds-page-head__actions > .ds-btn--text:last-child {
    margin-inline-end: calc(-1 * var(--ds-space-3));
  }

  .ds-page-head__lead:empty,
  .ds-page-head__status:empty,
  .ds-page-head__actions:empty {
    display: none;
  }

  /* A folded .ds-btn--adaptive (button.css: outside a content panel, by
     the window) is a round icon button: it keeps its place. */
  @media (max-width: 600px) {
    .ds-page-head__actions > .ds-btn--adaptive:is(:not(.ds-content *), :is(.ds-sheet, .ds-dialog) *) {
      margin-inline: 0;
    }
  }

  /* Under the title the actions start on its edge: a text button that
     comes first hangs its start padding out instead, so its text lines up
     with the title; the buttons after it keep the 8px gap and their place.
     A folded .ds-btn--adaptive (a round icon button at this width) keeps
     its place too. */
  @container ds-content (max-width: 600px) {
    .ds-page-head__actions {
      flex-basis: 100%;
      justify-content: flex-start;
      margin: 0;
    }

    .ds-page-head__actions > .ds-btn--text:first-child:not(.ds-btn--adaptive) {
      margin-inline-start: calc(-1 * var(--ds-space-3));
    }

    .ds-page-head__actions > .ds-btn--text:last-child {
      margin-inline-end: 0;
    }
  }

  /*
   * Breadcrumbs: where the page sits, in place of the back link. Inline
   * links in the label role, a chevron mask between them; the last item is
   * the page (aria-current="page"), in the text colour, not underlined.
   */
  .ds-breadcrumbs {
    min-width: 0;
    color: var(--ds-text-2);
    font: var(--ds-font-label);
  }

  .ds-breadcrumbs ol {
    display: flex;
    align-items: center;
    min-width: 0;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .ds-breadcrumbs li {
    display: flex;
    align-items: center;
    min-width: 0;
  }

  .ds-breadcrumbs li + li::before {
    content: '';
    flex: none;
    width: var(--ds-space-4);
    height: var(--ds-space-4);
    margin-inline: var(--ds-space-1);
    background: currentColor;
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E") center / 100% no-repeat;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E") center / 100% no-repeat;
  }

  .ds-breadcrumbs a {
    max-width: var(--ds-breadcrumbs-item-max, 240px);
    overflow: hidden;
    color: inherit;
    text-overflow: ellipsis;
    white-space: nowrap;
    text-decoration-line: underline;
    text-decoration-thickness: var(--ds-link-underline-thickness);
    text-underline-offset: var(--ds-link-underline-offset);
    text-decoration-color: var(--ds-link-underline);
    outline-offset: calc(var(--ds-focus-offset) + var(--ds-focus-width));
    transition:
      text-decoration-color var(--ds-dur-fast) var(--ds-ease-in-out),
      outline-offset var(--ds-dur-fast) var(--ds-ease-out);
  }

  @media (hover: hover) {
    .ds-breadcrumbs a:hover {
      text-decoration-color: var(--ds-link-underline-hover);
    }
  }

  .ds-breadcrumbs a:focus-visible {
    outline: var(--ds-focus-outline);
    outline-offset: var(--ds-focus-offset);
  }

  .ds-breadcrumbs [aria-current="page"] {
    color: var(--ds-text);
    text-decoration-line: none;
  }

  /* Narrow pages: only the way up (the parent) shows. */
  @container ds-content (max-width: 600px) {
    .ds-breadcrumbs li:not(:nth-last-child(2)) {
      display: none;
    }

    .ds-breadcrumbs li:nth-last-child(2)::before {
      content: none;
    }
  }

  /* ── Layout helpers ───────────────────────────── */

  /* One column that never grows wider than the page: a wide table inside
     scrolls in its own wrapper instead of scrolling the page sideways. */
  .ds-stack {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--ds-stack-gap, var(--ds-space-4));
    min-width: 0;
  }

  .ds-grid-2 {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    min-width: 0;
  }

  /* Cards in a list or a grid of the page keep the page's own rhythm. A
     stack of fields inside a card keeps its 16px. */
  .ds-page > .ds-stack,
  .ds-stack--page {
    gap: var(--ds-stack-gap-page, var(--ds-space-5));
  }

  .ds-grid-2 {
    gap: var(--ds-space-5);
  }

  @media (max-width: 900px) {
    .ds-grid-2 {
      grid-template-columns: minmax(0, 1fr);
    }
  }

  /* ── Sign-in page ─────────────────────────────── */

  .ds-auth {
    box-sizing: border-box;
    min-height: 100vh;
    min-height: 100dvh;
    margin: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--ds-space-4);
    padding:
      max(var(--ds-space-6), env(safe-area-inset-top))
      max(var(--ds-space-4), env(safe-area-inset-right))
      max(var(--ds-space-6), env(safe-area-inset-bottom))
      max(var(--ds-space-4), env(safe-area-inset-left));
    background: var(--ds-backdrop);
  }

  .ds-auth__card,
  .ds-auth__alert {
    width: min(100%, var(--ds-auth-width, 400px));
  }

  .ds-auth__alert:empty {
    display: none;
  }

  .ds-auth .ds-form-actions > .ds-btn--primary {
    flex: 1;
  }

  @media (forced-colors: active) {
    .ds-breadcrumbs li + li::before {
      forced-color-adjust: none;
      background: CanvasText;
    }
  }

  /* Print: the page fills the paper, without the panel's padding and width
     cap. Last in the file, so it beats the rules above, the narrow-window
     ones included, at the same specificity. */
  @media print {
    .ds-content {
      padding: 0;
    }

    .ds-page {
      width: auto;
    }

    .ds-page-head__back,
    .ds-page-head__actions {
      display: none;
    }
  }
}
