/*! Pulse v1.0.0-alpha.9 — 99d37d793fad
 * Generated by `pulse bundle` from pulse/layers/. Do not edit.
 *
 * The layer order is declared up front, so any CSS a project keeps
 * outside these layers still wins. That is what makes conversion
 * incremental: adopt Pulse first, delete your own rules after.
 */
@layer pulse.reset, pulse.theme, pulse.base, pulse.elements, pulse.icons, pulse.patterns, pulse.utilities;

/* Pulse — reset
 * Minimal and boring on purpose. Only removes browser defaults that would
 * fight the element styles in layer `pulse.elements`.
 */
@layer pulse.reset {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
  }

  html {
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
    tab-size: 2;
  }

  body,
  h1, h2, h3, h4, h5, h6,
  p, figure, blockquote, dl, dd, pre {
    margin: 0;
  }

  ul[class], ol[class] {
    margin: 0;
    padding: 0;
    list-style: none;
  }

  img, svg, video, canvas, audio, iframe, embed, object {
    display: block;
    max-width: 100%;
  }

  img, video {
    height: auto;
  }

  /* Inherit the type system rather than the UA's 13px Arial. */
  button, input, select, textarea, optgroup {
    font: inherit;
    letter-spacing: inherit;
    color: inherit;
  }

  button, select {
    text-transform: none;
  }

  table {
    border-collapse: collapse;
    border-spacing: 0;
  }

  fieldset {
    margin: 0;
    padding: 0;
    border: 0;
    min-inline-size: 0;
  }

  legend {
    padding: 0;
  }

  hr {
    border: 0;
    margin: 0;
  }

  dialog {
    padding: 0;
    border: 0;
    color: inherit;
    background: none;
    max-width: none;
    max-height: none;
  }

  summary {
    display: block;
    cursor: pointer;
  }

  summary::-webkit-details-marker {
    display: none;
  }

  [hidden] {
    display: none !important;
  }
}
/* Pulse — theme
 *
 * THIS FILE IS THE GENERATOR. There is no build step and no frozen output.
 * Every token below derives, live in the browser, from a small set of scalar
 * inputs using oklch() + calc(). Change an input and the whole system moves.
 *
 * Inputs a theme may set (and the only ones the theme builder writes):
 *   --p-accent-l / --p-accent-c / --p-accent-h   accent, in OKLCH
 *   --p-round                                    0 = square .. 1 = pill
 *   --p-cap                                      measured cap-height ratio
 *   --p-font / --p-font-mono                     family stacks
 *   [data-pulse-mode]  = dark | light | system
 *
 * Everything else is derived. Do not hand-write a hex value into a project.
 *
 * The per-mode lightness constants are not eyeballed: they are solutions to
 * stated contrast targets, produced by tools/pulsekit/calibrate.py.
 * `pulse verify` re-solves them and fails if this file has drifted.
 */
/* spec: contrast-modes */
/* spec: texture */
/* spec: texture-direction */
/* spec: the-scale */
/* spec: shape */
/* spec: elevation */
/* spec: motion */
@layer pulse.theme {
  :root {
    /* ---------------------------------------------------------------
     * Theme inputs
     * ------------------------------------------------------------- */
    --p-accent-l: 0.6569;
    --p-accent-c: 0.1758;
    --p-accent-h: 300;

    /* THE SURFACE TINT. The neutral ramp can take a hue — warm paper,
     * cool slate — without any of it becoming a colour you have to think
     * about. Two inputs and nothing else:
     *
     *   --p-surf-t   how much, 0 to 1. Zero means chroma exactly zero
     *                and the neutral ramp the system had before this
     *                existed; the shipped default is a quarter of the
     *                way up, which is a page you would call grey until
     *                you put it beside a real one.
     *   --p-surf-h   which hue, from the same twenty-five the meanings
     *                are picked from. Defaults to the accent's, so the
     *                page can be a whisper of the theme's own colour for
     *                the cost of one number.
     *
     * WHAT MAKES THIS SAFE is not that the tint is small. It is that the
     * ramp's LIGHTNESS never moves: a tint changes chroma only, and
     * contrast is very nearly all lightness. Measured across all
     * twenty-five hues at full strength the worst ratio moves 1.3%, and
     * upward. `pulse verify` re-checks every floor against every tinted
     * surface rather than taking that on trust. */
    --p-surf-t: 0.25;
    --p-surf-h: var(--p-accent-h);

    --p-round: 0.5;

    /* THE READER'S SCALE. Everything with a size — type, controls,
     * spacing, corners — is this number times its base, so one slider
     * moves the whole interface and none of it drifts out of proportion
     * with the rest. The theme builder offers half to one and a half.
     *
     * The default is 1.1, not 1. The numbers throughout this file are
     * still written at 1, which is the baseline they were designed
     * against; the system simply read a shade small at arm's length, and
     * a multiplier is a smaller lie than rewriting forty constants. */
    --p-scale: 1.1;

    --p-cap: 0.7;
    --p-cap-ref: 0.7;
    --p-ascent: 0.75;
    --p-descent: 0.25;

    /* Optical vertical centring.
     *
     * Centring a line of text in a control centres the *em box*, but the eye
     * centres on the capitals. The gap between those two is
     * (cap + descent - ascent) / 2, measured after font load.
     *
     * For a normal font this is ~0 and nothing moves. For Josefin Sans the
     * ascender is the cap height — no room above the capitals, a full
     * descender below — so text sits 0.115em high and needs pushing down.
     * One formula, no per-font table. */
    --p-optical-text: calc((var(--p-cap) + var(--p-descent) - var(--p-ascent)) / 2 * 1em);

    /* How much of that shift the current element actually applied. Zero
     * everywhere by default; the block-padding rule in 05-patterns.css
     * raises it on the controls that carry the correction, so a
     * baseline-less child can subtract exactly what its host added. */
    --p-optical-applied: 0px;

    --p-font: "Josefin Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
    --p-font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;

    /* ---------------------------------------------------------------
     * The palette — the colours a theme may assign to a meaning.
     *
     * A meaning's colour is oklch(<derived L> <derived C x trim> <hue>).
     * The lightness is solved per mode against contrast targets and is
     * never chosen by anybody, so a hue CANNOT make a pairing
     * unreadable: contrast is almost entirely lightness. That is what
     * lets a reader put any colour on any meaning and keep a legible
     * interface — structural, not lucky, and `pulse palette` proves it
     * exhaustively across all three sets.
     *
     * TWENTY-FIVE HUES, at named colour centres taken from real named
     * colours rather than invented. Placed by NAME rather than by an
     * even spread through a distance metric, because oklab hue is not
     * perceptually uniform: an even spread put three yellow-ish entries
     * within thirty-six degrees and left a sixty-two degree hole where
     * deep cyan and sky should have been.
     *
     * THREE SETS over those same hues. Only the colourfulness differs —
     * the hue of `danger` is 27 in all three, so switching sets restyles
     * the interface without renaming anything:
     *
     *   vibrant   every hue as vivid as sRGB allows        cx / cxl
     *   normal    one chroma for all, per mode             the two below
     *   pastel    every hue in the lifted band, soft       px / pxl
     *
     * TWO BANDS, and `-hi` says which one a hue uses in vibrant and
     * normal. Gold, yellow, citron, jade and cyan cannot hold a colour
     * at the standard lightness — trimmed to fit they come back olive
     * and slate — so they sit higher and take dark ink. That is a fact
     * about those hues, not about the meaning wearing them. Pastel puts
     * EVERY hue in that band, which is what makes it pastel.
     *
     * A TRIM PER MODE throughout. sRGB has different headroom in each: a
     * colour can be fully saturated on a dark page and overflow on a
     * light one, and one number for both dulls the mode that had room.
     * ------------------------------------------------------------- */
    --p-hue-1: 12;  --p-hue-1-hi: 0;  --p-hue-1-cx: 1;  --p-hue-1-cxl: 1;  --p-hue-1-px: 0.64;  --p-hue-1-pxl: 0.77;  /* rose */
    --p-hue-2: 27;  --p-hue-2-hi: 0;  --p-hue-2-cx: 1;  --p-hue-2-cxl: 1;  --p-hue-2-px: 0.63;  --p-hue-2-pxl: 0.76;  /* red — danger */
    --p-hue-3: 40;  --p-hue-3-hi: 0;  --p-hue-3-cx: 1;  --p-hue-3-cxl: 1;  --p-hue-3-px: 0.65;  --p-hue-3-pxl: 0.79;  /* vermilion */
    --p-hue-4: 53;  --p-hue-4-hi: 0;  --p-hue-4-cx: 1;  --p-hue-4-cxl: 0.84;  --p-hue-4-px: 0.71;  --p-hue-4-pxl: 0.86;  /* orange */
    --p-hue-5: 67;  --p-hue-5-hi: 0;  --p-hue-5-cx: 0.96;  --p-hue-5-cxl: 0.73;  --p-hue-5-px: 0.85;  --p-hue-5-pxl: 0.81;  /* amber */
    --p-hue-6: 80;  --p-hue-6-hi: 1;  --p-hue-6-cx: 1;  --p-hue-6-cxl: 0.76;  --p-hue-6-px: 1;  --p-hue-6-pxl: 0.76;  /* gold — caution */
    --p-hue-7: 94;  --p-hue-7-hi: 1;  --p-hue-7-cx: 1;  --p-hue-7-cxl: 0.75;  --p-hue-7-px: 1;  --p-hue-7-pxl: 0.75;  /* yellow */
    --p-hue-8: 107;  --p-hue-8-hi: 1;  --p-hue-8-cx: 1;  --p-hue-8-cxl: 0.78;  --p-hue-8-px: 1;  --p-hue-8-pxl: 0.78;  /* citron */
    --p-hue-9: 121;  --p-hue-9-hi: 0;  --p-hue-9-cx: 1;  --p-hue-9-cxl: 0.8;  --p-hue-9-px: 1;  --p-hue-9-pxl: 0.88;  /* lime */
    --p-hue-10: 134;  --p-hue-10-hi: 0;  --p-hue-10-cx: 1;  --p-hue-10-cxl: 0.95;  --p-hue-10-px: 1;  --p-hue-10-pxl: 1;  /* leaf */
    --p-hue-11: 148;  --p-hue-11-hi: 0;  --p-hue-11-cx: 1;  --p-hue-11-cxl: 0.96;  --p-hue-11-px: 1;  --p-hue-11-pxl: 1;  /* green — success */
    --p-hue-12: 163;  --p-hue-12-hi: 0;  --p-hue-12-cx: 0.94;  --p-hue-12-cxl: 0.72;  --p-hue-12-px: 1;  --p-hue-12-pxl: 0.79;  /* emerald */
    --p-hue-13: 178;  --p-hue-13-hi: 1;  --p-hue-13-cx: 0.94;  --p-hue-13-cxl: 0.67;  --p-hue-13-px: 0.94;  --p-hue-13-pxl: 0.67;  /* jade */
    --p-hue-14: 194;  --p-hue-14-hi: 0;  --p-hue-14-cx: 0.74;  --p-hue-14-cxl: 0.57;  --p-hue-14-px: 0.87;  --p-hue-14-pxl: 0.62;  /* teal */
    --p-hue-15: 209;  --p-hue-15-hi: 1;  --p-hue-15-cx: 0.88;  --p-hue-15-cxl: 0.63;  --p-hue-15-px: 0.88;  --p-hue-15-pxl: 0.63;  /* cyan */
    --p-hue-16: 225;  --p-hue-16-hi: 0;  --p-hue-16-cx: 0.82;  --p-hue-16-cxl: 0.63;  --p-hue-16-px: 0.75;  --p-hue-16-pxl: 0.69;  /* sky */
    --p-hue-17: 240;  --p-hue-17-hi: 0;  --p-hue-17-cx: 1;  --p-hue-17-cxl: 0.76;  --p-hue-17-px: 0.62;  --p-hue-17-pxl: 0.75;  /* azure — utility */
    --p-hue-18: 255;  --p-hue-18-hi: 0;  --p-hue-18-cx: 0.93;  --p-hue-18-cxl: 1;  --p-hue-18-px: 0.57;  --p-hue-18-pxl: 0.68;  /* cerulean */
    --p-hue-19: 271;  --p-hue-19-hi: 0;  --p-hue-19-cx: 0.91;  --p-hue-19-cxl: 1;  --p-hue-19-px: 0.55;  --p-hue-19-pxl: 0.66;  /* blue */
    --p-hue-20: 286;  --p-hue-20-hi: 0;  --p-hue-20-cx: 0.96;  --p-hue-20-cxl: 1;  --p-hue-20-px: 0.58;  --p-hue-20-pxl: 0.7;  /* indigo — accent */
    --p-hue-21: 300;  --p-hue-21-hi: 0;  --p-hue-21-cx: 1;  --p-hue-21-cxl: 1;  --p-hue-21-px: 0.66;  --p-hue-21-pxl: 0.79;  /* violet */
    --p-hue-22: 315;  --p-hue-22-hi: 0;  --p-hue-22-cx: 1;  --p-hue-22-cxl: 1;  --p-hue-22-px: 0.82;  --p-hue-22-pxl: 0.98;  /* purple */
    --p-hue-23: 329;  --p-hue-23-hi: 0;  --p-hue-23-cx: 1;  --p-hue-23-cxl: 1;  --p-hue-23-px: 1;  --p-hue-23-pxl: 1;  /* orchid */
    --p-hue-24: 344;  --p-hue-24-hi: 0;  --p-hue-24-cx: 1;  --p-hue-24-cxl: 1;  --p-hue-24-px: 0.8;  --p-hue-24-pxl: 0.98;  /* plum */
    --p-hue-25: 358;  --p-hue-25-hi: 0;  --p-hue-25-cx: 1;  --p-hue-25-cxl: 1;  --p-hue-25-px: 0.69;  --p-hue-25-pxl: 0.84;  /* magenta */

    /* ---------------------------------------------------------------
     * Semantic hues — stable across modes. Lightness and chroma are
     * regenerated per mode; the hue never moves, so "danger" is the same
     * red idea in both themes.
     * ------------------------------------------------------------- */
    --p-good-h: 148;  /* green — success */
    --p-bad-h: 12;    /* rose — danger */
    --p-warn-h: 80;   /* gold — caution */
    --p-info-h: 225;  /* sky — utility */

    /* Per-hue chroma trim. Yellow reaches its gamut ceiling far below blue,
     * so a single chroma would make warn look muddy next to info. */
    --p-good-cx: 1;
    --p-good-cxl: 0.96;
    --p-bad-cx: 1;
    --p-bad-cxl: 1;
    --p-warn-cx: 1;
    --p-warn-cxl: 0.76;
    --p-info-cx: 0.82;
    --p-info-cxl: 0.63;

    /* Which band each meaning's hue belongs to: 0 standard, 1 lifted. It
     * travels with the HUE, not the meaning — assign citron to `good` and
     * good lifts; assign leaf to `warn` and warn comes back down. A 0/1
     * flag interpolated between two values is how CSS picks one of two
     * numbers without a second selector. */
    /* The trim in force, resolved from the two by the mode's own 0/1
     * switch — the same interpolation the band uses. */
    /* The one chroma the `normal` set gives every hue, per mode. */
    --p-norm-cx: 0.74;
    --p-norm-cxl: 0.57;

    --p-good-cxm: calc(
      var(--p-good-cx) + var(--p-lt) * (var(--p-good-cxl) - var(--p-good-cx))
    );
    --p-bad-cxm: calc(
      var(--p-bad-cx) + var(--p-lt) * (var(--p-bad-cxl) - var(--p-bad-cx))
    );
    --p-warn-cxm: calc(
      var(--p-warn-cx) + var(--p-lt) * (var(--p-warn-cxl) - var(--p-warn-cx))
    );
    --p-info-cxm: calc(
      var(--p-info-cx) + var(--p-lt) * (var(--p-info-cxl) - var(--p-info-cx))
    );

    --p-good-hi: 0;
    --p-bad-hi: 0;
    --p-warn-hi: 1;
    --p-info-hi: 0;

    /* Yellow is an intrinsically light hue. Forcing it to the shared
     * semantic lightness so it can carry pale ink drags it down to a dark
     * ochre. Warn instead takes dark ink in *both* modes and sits high and
     * bright, which is what a caution colour is supposed to look like. */
    --p-lift-fg-l: 0.16;

    /* ---------------------------------------------------------------
     * Spacing — compact 2 + 4.
     * Unit is 2px. Four-unit steps (2, 4, 8, 12...) carry the rhythm;
     * two-unit half-steps (1, 3) tune dense controls. Token number x 2px.
     * ------------------------------------------------------------- */
    --p-space-0: 0px;
    --p-space-px: 1px;
    --p-space-1: calc(2px * var(--p-scale));
    --p-space-2: calc(4px * var(--p-scale));
    --p-space-3: calc(6px * var(--p-scale));
    --p-space-4: calc(8px * var(--p-scale));
    --p-space-5: calc(10px * var(--p-scale));
    --p-space-6: calc(12px * var(--p-scale));
    --p-space-8: calc(16px * var(--p-scale));
    --p-space-10: calc(20px * var(--p-scale));
    --p-space-12: calc(24px * var(--p-scale));
    --p-space-16: calc(32px * var(--p-scale));
    --p-space-20: calc(40px * var(--p-scale));
    --p-space-24: calc(48px * var(--p-scale));
    --p-space-32: calc(64px * var(--p-scale));
    --p-space-40: calc(80px * var(--p-scale));

    /* Clustered grouping: tight inside a group, a clear step between them. */
    --p-gap-tight: var(--p-space-2);
    --p-gap: var(--p-space-4);
    --p-gap-group: var(--p-space-8);
    --p-gap-section: var(--p-space-16);

    /* The dim behind anything modal. Fixed rather than derived: a scrim
     * is not a surface, it is an absence of one, and it has to read the
     * same over a photograph as over a panel. */
    --p-scrim: oklch(0 0 0 / 0.55);

    /* Media cards. The artwork width is one number for the whole feed,
     * so a poster and a book cover line up even though their ratios do
     * not — the height is the card's, and the picture is cropped to it.
     * The clamp is what keeps a list of them the same height. */
    --p-media-art: 7rem;
    --p-media-clamp: 3;

    /* Icons are sized in em so they track the text they sit beside at
     * every control size, and are drawn a shade larger than the cap
     * height because a 24px line drawing with 2px of internal margin
     * reads smaller than a letter of the same box. */
    --p-icon-size: 1.15em;

    /* The disclosure caret lives in a SQUARE box and the triangle is
     * clipped inside it, so rotating from shut to open changes nothing
     * about the layout. A bare CSS-border triangle is 5px wide and 8px
     * tall; rotate that and the row it sits in gets 3px wider, which
     * reads as the whole header twitching every time you open a folder. */
    --p-caret-size: 0.65em;

    /* ---------------------------------------------------------------
     * Type — one measured number does the calibration.
     *
     * --p-cap is the active font's cap-height / em, measured after load.
     * Sizes scale so every font lands the same *cap height* on screen
     * (Josefin Sans has a famously small x-height; without this it reads
     * two steps smaller than Inter at the same px). Leading counter-scales
     * at half strength so absolute leading stays roughly put.
     * ------------------------------------------------------------- */
    /* The font's own correction, kept separate from the reader's. A
     * family with a small cap height needs its TYPE lifted, but lifting
     * its controls by the same amount makes buttons that look inflated —
     * hence the half-strength form for --p-fit. The reader's scale gets
     * no such damping: asked for bigger, they meant bigger. */
    --p-cap-fit: calc(var(--p-cap-ref) / var(--p-cap));
    --p-size-scale: calc(var(--p-cap-fit) * var(--p-scale));
    --p-lead-scale: calc(0.5 + 0.5 * var(--p-cap) / var(--p-cap-ref));
    --p-fit: calc((0.5 + 0.5 * var(--p-cap-fit)) * var(--p-scale));

    /* The scale tapers: the smallest steps are lifted most and the display
     * sizes are untouched, which tightens the range between a caption and a
     * major heading without letting the headings grow. */
    --p-text-2xs: calc(11.5px * var(--p-size-scale));
    --p-text-xs: calc(12.5px * var(--p-size-scale));
    --p-text-sm: calc(13.5px * var(--p-size-scale));
    --p-text-md: calc(14.5px * var(--p-size-scale));
    --p-text-lg: calc(16px * var(--p-size-scale));
    --p-text-xl: calc(18px * var(--p-size-scale));
    --p-text-2xl: calc(21px * var(--p-size-scale));
    --p-text-3xl: calc(25px * var(--p-size-scale));
    --p-text-4xl: calc(30px * var(--p-size-scale));
    --p-text-5xl: calc(38px * var(--p-size-scale));

    --p-leading-tight: calc(1.14 * var(--p-lead-scale));
    --p-leading-snug: calc(1.32 * var(--p-lead-scale));
    --p-leading-normal: calc(1.5 * var(--p-lead-scale));

    --p-weight-regular: 400;
    --p-weight-medium: 500;
    --p-weight-semibold: 600;
    --p-weight-bold: 700;

    /* Natural family spacing. Corrections only where an optical problem was
     * demonstrated — small caps-heavy labels tighten, nothing else. */
    --p-tracking: 0em;
    --p-tracking-label: 0.01em;

    /* ---------------------------------------------------------------
     * Controls — visually compact, with a transparent hit expansion so
     * touch targets clear 40px without the control looking chunky.
     * ------------------------------------------------------------- */
    --p-control-h-sm: calc(24px * var(--p-fit));
    --p-control-h: calc(30px * var(--p-fit));
    --p-control-h-lg: calc(38px * var(--p-fit));
    --p-hit: 40px;

    /* Corner inset. A control sitting in a rounded corner needs at least
     * the corner's own radius of clearance, or the curve eats the gap on
     * the diagonal and the control looks closer to the corner than to the
     * flat edges. Containers use this instead of a bare space token. */
    --p-inset: max(var(--p-space-8), var(--p-radius-lg));
    --p-inset-sm: max(var(--p-space-4), var(--p-radius-md));

    /* A split view's panel. Defined here rather than left to a fallback
     * inside the pattern, because the resize grip names it in script:
     * an undefined token is one rename away from a grip that silently
     * drags nothing, and `pulse lint` is watching for exactly that. */
    --p-split-w: 17rem;

    /* ---------------------------------------------------------------
     * Radius — one continuous setting.
     * Controls resolve against their own height, so --p-round: 1 makes
     * every single-line control an exact pill, and containers pick up a
     * matching roundness at the same time.
     * ------------------------------------------------------------- */
    --p-radius-xs: calc(4px * var(--p-round) * var(--p-scale));
    --p-radius-sm: calc(8px * var(--p-round) * var(--p-scale));
    --p-radius-md: calc(14px * var(--p-round) * var(--p-scale));
    --p-radius-lg: calc(22px * var(--p-round) * var(--p-scale));
    --p-radius-xl: calc(32px * var(--p-round) * var(--p-scale));
    --p-radius-full: 9999px;
    --p-radius-control-sm: calc(var(--p-control-h-sm) * 0.5 * var(--p-round));
    --p-radius-control: calc(var(--p-control-h) * 0.5 * var(--p-round));
    --p-radius-control-lg: calc(var(--p-control-h-lg) * 0.5 * var(--p-round));

    /* ---------------------------------------------------------------
     * Motion — short, direct, responsive.
     * ------------------------------------------------------------- */
    --p-dur-1: 90ms;
    --p-dur-2: 140ms;
    --p-dur-3: 220ms;
    /* Not a transition duration: how long a completed result stays long
     * enough to be read before its transient slot clears. */
    --p-dwell: 2s;

    /* The focus ring breathes so it can be found on a busy screen, and
     * leaves a trail so the eye can follow it when it moves. Both are
     * locators layered on top of the static ring, never the indicator
     * itself. Set the count to a number if a permanent pulse is too much
     * while typing. */
    --p-dur-ping: 520ms;

    /* Waiting. Slow enough that a spinner reads as patient rather than
     * frantic, fast enough that it is obviously running. */
    --p-dur-spin: 1.05s;
    --p-dur-slide: 1.9s;
    --p-dur-shimmer: 1.4s;

    /* Three dots under a name. Slower than a spinner on purpose: this
     * one is reporting somebody else's hands, and a fast cycle reads as
     * urgency about a message that has not been written yet. */
    --p-dur-typing: 1.6s;

    /* A streaming caret blinks at the rate a text caret does, because it
     * is claiming to be one. Anything faster reads as an alarm. */
    --p-dur-caret: 1.06s;

    /* Linear, and it is not a placeholder. A loop that wraps without a
     * seam needs the same velocity at both ends of the cycle, and every
     * ease that is gentle at the ends pays for it by flinging the band
     * across the middle — which is the part anyone actually watches. */
    --p-ease-slide: linear;

    /* A mild surge and settle once per turn, so the spinner has a pulse
     * instead of the dead constant rate of a loading GIF. */
    --p-ease-spin: cubic-bezier(0.5, 0.12, 0.5, 0.88);

    /* The spinner tracks its text the way an icon does, so a busy button
     * does not change size when the wait starts. */
    --p-spinner-size: 1.2em;
    --p-spinner-stroke: max(2px, 0.13em);

    /* ---------------------------------------------------------------
     * Gauge — how full something is, not how far along it is.
     *
     * The thresholds are the whole component. Below the first there is
     * nothing to say; above the last the thing is about to run out. Any
     * project can move them, and a project whose resource is not scarce
     * until 95% should.
     * ------------------------------------------------------------- */
    --p-gauge-value: 0;
    --p-gauge-t1: 0.5;
    --p-gauge-t2: 0.75;
    --p-gauge-t3: 0.9;
    --p-gauge-size: 4rem;
    --p-gauge-stroke: 7px;
    --p-gauge-bar: 6px;
    /* Taller than a gauge bar. A level is read at a glance while
     * somebody is talking, and it carries a peak marker inside it. */
    --p-level-bar: 10px;
    /* Bigger than a browser's default radio, which is drawn for a
     * form and disappears at the end of a dense row. */
    --p-choice-size: 1.15rem;
    --p-dur-focus-travel: 190ms;

    /* The static ring. None of this animates, ever — see below. */
    --p-ring-width: 2px;
    --p-ring-offset: 2px;
    --p-ring-sep: 2px;

    /* The breath.
     *
     * The outline does not move. Animating outline geometry cannot be
     * made smooth: outline-offset and outline-width rasterise on whole
     * pixels, so a 2px -> 5px swell is four hard cuts rather than a
     * glide. That is measured, not assumed — rendered at 0.25px steps,
     * offsets 2, 2.25, 2.5 and 2.75 produce byte-identical pixels.
     * getComputedStyle interpolates perfectly throughout and tells you
     * nothing about it.
     *
     * So the breath is a blurred glow instead. A blurred edge is a
     * gradient rather than a boundary, so it does move at sub-pixel
     * amounts: the same test shows every 0.25px of spread producing a
     * different render.
     *
     * Fading the glow is safe where fading the ring is not. The outline
     * stays fully opaque and carries the whole contrast requirement by
     * itself — in light mode the accent is dark on near-white, and a
     * translucent ring bottoms out near 1.4:1 against a 3:1 floor.
     *
     * The alpha floor is above zero so the glow thins rather than
     * disappearing; a glow that reaches nothing reads as a blink.
     *
     * Only the alpha moves — the spread is fixed. Two reasons. Firefox
     * caches blurred shadow masks by integer geometry, so animating
     * spread recomputes the mask every frame and can step, while the
     * colour is applied to the finished mask and cannot. And a single
     * frozen geometry means one mechanism covers buttons and text fields
     * alike, where a pseudo-element glow would not: Firefox gives no
     * pseudo-elements to <input>.
     *
     * Both keyframes must express the colour the SAME way. Firefox will
     * not interpolate a color-mix() against a plain oklch() and silently
     * falls back to discrete animation — a hard flip at the halfway
     * point, which looks exactly like no animation at all. Measured in
     * the bake-off: an outline-color breath written that way snapped,
     * while the identical breath with color-mix() at both ends was
     * smooth. If you touch these keyframes, keep the forms matching.
     *
     * Sine in and out. A cubic ease-in-out hangs at both extremes, and a
     * hold at the top and bottom of a breath reads as two states rather
     * than one motion. */
    --p-dur-breathe: 3.4s;
    --p-ring-glow-blur: 12px;
    --p-ring-glow: 4px;
    --p-ring-glow-alpha-min: 0.06;
    --p-ring-glow-alpha-max: 0.6;
    --p-ease-breathe: cubic-bezier(0.37, 0, 0.63, 1);
    --p-focus-breathe-count: infinite;
    --p-ease: cubic-bezier(0.2, 0.75, 0.3, 1);

    /* Overshoots slightly past its target and settles back — the only
     * easing in the system that does, and it is for things that model a
     * physical release: an elastic scroller springing back to its end.
     * Nothing that carries information should use it, because the
     * overshoot means the value is briefly wrong. */
    --p-ease-spring: cubic-bezier(0.2, 1.5, 0.4, 1);

    /* Runtime state, not a design token: pulse-scroll.js writes the
     * current elastic pull here and clears it on release. Declared with
     * its resting value so the property exists whether or not the script
     * is loaded — the alternative is a var() fallback repeated at every
     * use site, which is the same default written more than once. */
    --p-overscroll: 0px;

    /* ---------------------------------------------------------------
     * Layering
     * ------------------------------------------------------------- */
    --p-z-sticky: 100;
    --p-z-overlay: 200;
    --p-z-popover: 300;
    --p-z-toast: 400;
    --p-z-modal: 500;
  }

  /* =================================================================
   * MODE: dark (authored)
   *
   * Surface lightnesses are authored here as deltas around a near-black
   * base with restrained tonal variation. Text and border lightnesses are
   * NOT authored — they are solved against contrast targets:
   *   text-strong 16:1  text 12:1  text-muted 7:1  text-subtle 3.9:1
   *   border-strong 3:1  border 2.2:1  line 1.7:1
   * ================================================================= */
  :root,
  [data-pulse-mode="dark"] {
    color-scheme: dark;

    --p-l-inset: 0.15;
    --p-l-sunken: 0.165;
    --p-l-base: 0.195;
    --p-l-raised: 0.235;
    --p-l-overlay: 0.27;

    --p-l-line: 0.362;
    --p-l-border: 0.424;
    --p-l-border-strong: 0.496;

    --p-l-text-strong: 0.954;
    --p-l-text: 0.861;
    --p-l-text-muted: 0.706;
    --p-l-text-subtle: 0.558;

    /* Tonal direction: accent tints sit *above* a dark base. */
    --p-dir: 1;
    --p-texture-cap: 1;
    --p-texture-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='t'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='1' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0.33 0.33 0.33 0 0'/%3E%3C/filter%3E%3Crect width='128' height='128' filter='url(%23t)' opacity='0.22'/%3E%3C/svg%3E");
    --p-texture-paper: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='t'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.05 0.72' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0.33 0.33 0.33 0 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23t)' opacity='0.20'/%3E%3C/svg%3E");
    --p-lt: 0;
    --p-tint-k: 1;

    /* How much chroma a tinted surface may carry here, and per elevation
     * step. sRGB is the whole constraint: a dark ramp has room at every
     * step, so every step takes the full amount. The ceiling is the
     * worst hue's headroom at the darkest step, less a margin. */
    --p-surf-cmax: 0.024;
    --p-surf-k-inset: 1;
    --p-surf-k-sunken: 1;
    --p-surf-k-base: 1;
    --p-surf-k-raised: 1;
    --p-surf-k-overlay: 1;

    /* Accent safe band. Any accent is clamped into it, so the paired
     * foreground clears 4.5:1 for every hue and chroma in sRGB. */
    --p-accent-lo: 0.64;
    --p-accent-hi: 0.86;
    --p-l-accent-fg: 0.16;

    /* Accent used as text/icon on a neutral surface needs a different
     * band than accent used as a filled control. */
    --p-accent-lo-on-surface: 0.7;
    --p-accent-hi-on-surface: 0.92;

    --p-l-sem: 0.7;
    --p-l-sem-lift: 0.82;
    --p-c-sem: 0.16;
    --p-l-sem-text: 0.8;
    --p-c-sem-text: 0.11;

    /* A CHART MARK IS NOT A BADGE. The semantic and palette steps above
     * are solved for text and fills that must stay legible at small
     * sizes on a surface; a two-pixel line on a plot has a different
     * job and, measured against the data-visualisation floors, the
     * shipped steps sit ABOVE the usable band in dark mode — L 0.70
     * where a mark wants 0.48–0.67. So the ramp gets its own two
     * numbers rather than borrowing. */
    --p-l-series: 0.62;
    --p-c-series: 0.15;

    --p-shadow-a: 0.78;
  }

  /* =================================================================
   * MODE: light (derived)
   *
   * Not an inversion. Each role keeps its intent and is re-resolved:
   *   - elevation roles keep their authored delta from base, re-anchored
   *     at 0.975 and compressed by 0.55 (light surfaces need less spread
   *     to read as separated);
   *   - text and border roles are re-solved against the light base for
   *     the same contrast intents, with reading-comfort targets rather
   *     than the dark theme's crisper ones;
   *   - accent keeps hue and chroma exactly, and moves to the band that
   *     is legible against a light surface.
   * ================================================================= */
  [data-pulse-mode="light"] {
    color-scheme: light;

    --p-l-inset: 0.95;
    --p-l-sunken: 0.959;
    --p-l-base: 0.975;
    --p-l-raised: 0.997;
    --p-l-overlay: 1;

    --p-l-line: 0.807;
    --p-l-border: 0.733;
    --p-l-border-strong: 0.651;

    --p-l-text-strong: 0.223;
    --p-l-text: 0.363;
    --p-l-text-muted: 0.526;
    --p-l-text-subtle: 0.615;

    --p-dir: -1;
    --p-texture-cap: 0.14;
    --p-texture-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='t'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='1' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0.33 0.33 0.33 0 0'/%3E%3C/filter%3E%3Crect width='128' height='128' filter='url(%23t)' opacity='0.024'/%3E%3C/svg%3E");
    --p-texture-paper: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='t'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.05 0.72' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0.33 0.33 0.33 0 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23t)' opacity='0.022'/%3E%3C/svg%3E");
    --p-lt: 1;
    --p-tint-k: 0.75;

    /* Light mode's ramp ends at white, and white holds no chroma at all.
     * So the tint fades out as the ramp rises: the page and the wells
     * take it, a raised surface takes a trace of it, and an overlay
     * stays the white it has to be. That reads the way tinted light
     * themes are drawn anyway — toned paper, clean cards — and it is a
     * consequence of the gamut rather than a taste applied on top.
     *
     * The ceiling is a third of dark's for the same reason: at L 0.975
     * there is barely a hundredth of chroma to spend before the worst
     * hue leaves sRGB. */
    --p-surf-cmax: 0.0105;
    --p-surf-k-inset: 1;
    --p-surf-k-sunken: 1;
    --p-surf-k-base: 1;
    --p-surf-k-raised: 0.12;
    --p-surf-k-overlay: 0;

    --p-accent-lo: 0.4;
    --p-accent-hi: 0.52;
    --p-l-accent-fg: 0.99;

    --p-accent-lo-on-surface: 0.36;
    --p-accent-hi-on-surface: 0.5;

    --p-l-sem: 0.5;
    --p-l-sem-lift: 0.8;
    --p-c-sem: 0.15;
    --p-l-sem-text: 0.5;
    --p-c-sem-text: 0.12;

    --p-l-series: 0.55;
    --p-c-series: 0.16;

    --p-shadow-a: 0.34;
  }

  @media (prefers-color-scheme: light) {
    [data-pulse-mode="system"] {
      color-scheme: light;

      --p-l-inset: 0.95;
      --p-l-sunken: 0.959;
      --p-l-base: 0.975;
      --p-l-raised: 0.997;
      --p-l-overlay: 1;

      --p-l-line: 0.807;
      --p-l-border: 0.733;
      --p-l-border-strong: 0.651;

      --p-l-text-strong: 0.223;
      --p-l-text: 0.363;
      --p-l-text-muted: 0.526;
      --p-l-text-subtle: 0.615;

      --p-dir: -1;
      --p-texture-cap: 0.14;
      --p-texture-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='t'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='1' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0.33 0.33 0.33 0 0'/%3E%3C/filter%3E%3Crect width='128' height='128' filter='url(%23t)' opacity='0.024'/%3E%3C/svg%3E");
      --p-texture-paper: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='t'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.05 0.72' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0.33 0.33 0.33 0 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23t)' opacity='0.022'/%3E%3C/svg%3E");
        --p-lt: 1;
      --p-tint-k: 0.75;

      /* Light mode's ramp ends at white, and white holds no chroma at all.
       * So the tint fades out as the ramp rises: the page and the wells
       * take it, a raised surface takes a trace of it, and an overlay
       * stays the white it has to be. That reads the way tinted light
       * themes are drawn anyway — toned paper, clean cards — and it is a
       * consequence of the gamut rather than a taste applied on top.
       *
       * The ceiling is a third of dark's for the same reason: at L 0.975
       * there is barely a hundredth of chroma to spend before the worst
       * hue leaves sRGB. */
      --p-surf-cmax: 0.0105;
      --p-surf-k-inset: 1;
      --p-surf-k-sunken: 1;
      --p-surf-k-base: 1;
      --p-surf-k-raised: 0.12;
      --p-surf-k-overlay: 0;

      --p-accent-lo: 0.4;
      --p-accent-hi: 0.52;
      --p-l-accent-fg: 0.99;

      --p-accent-lo-on-surface: 0.36;
      --p-accent-hi-on-surface: 0.5;

      --p-l-sem: 0.5;
      --p-l-sem-lift: 0.8;
      --p-c-sem: 0.15;
      --p-l-sem-text: 0.5;
      --p-c-sem-text: 0.12;

      --p-l-series: 0.55;
      --p-c-series: 0.16;

      --p-shadow-a: 0.34;
    }
  }

  /* =================================================================
   * Derivation — runs in the browser, wherever a scope hook appears.
   *
   * IMPORTANT: a derived token is substituted at the element that declares
   * it and then *inherits as a finished value*. So any element that
   * overrides an input (a --p-l-* / --p-accent-* scalar) must also re-run
   * this block, which means it must match one of the selectors below.
   * Adding a new scope hook means adding it here too. `pulse check`
   * enforces that: overriding an input outside a known hook is an error.
   * ================================================================= */
  :root,
  [data-pulse-mode],
  [data-pulse-contrast],
  [data-pulse-texture],
  [data-pulse-accent] {
    /* Clamped accent lightness. Every accent-derived state clamps into the
     * same band, so hover/active can never walk out of contrast safety. */
    --p-accent-l-safe: clamp(var(--p-accent-lo), var(--p-accent-l), var(--p-accent-hi));
    --p-accent-l-hover: clamp(
      var(--p-accent-lo),
      calc(var(--p-accent-l) + var(--p-dir) * 0.05),
      var(--p-accent-hi)
    );
    --p-accent-l-active: clamp(
      var(--p-accent-lo),
      calc(var(--p-accent-l) - var(--p-dir) * 0.035),
      var(--p-accent-hi)
    );
    --p-accent-l-on-surface: clamp(
      var(--p-accent-lo-on-surface),
      var(--p-accent-l),
      var(--p-accent-hi-on-surface)
    );

    /* --- The base ramp. Neutral by default — --p-surf-t is 0, so every
     * chroma below is exactly zero and these are the same greys they
     * always were. Raise it and the ramp takes a hue.
     *
     * ONLY CHROMA MOVES. The lightnesses are the calibrator's solutions
     * to contrast targets and a tint does not touch them, which is the
     * whole reason a reader can pick any of the twenty-five for the page
     * and not have to be told which ones are safe. --- */
    --p-surf-c: calc(var(--p-surf-t) * var(--p-surf-cmax));
    --p-surface-inset: oklch(var(--p-l-inset) calc(var(--p-surf-c) * var(--p-surf-k-inset)) var(--p-surf-h));
    --p-surface-sunken: oklch(var(--p-l-sunken) calc(var(--p-surf-c) * var(--p-surf-k-sunken)) var(--p-surf-h));
    --p-surface: oklch(var(--p-l-base) calc(var(--p-surf-c) * var(--p-surf-k-base)) var(--p-surf-h));
    --p-surface-raised: oklch(var(--p-l-raised) calc(var(--p-surf-c) * var(--p-surf-k-raised)) var(--p-surf-h));
    --p-surface-overlay: oklch(var(--p-l-overlay) calc(var(--p-surf-c) * var(--p-surf-k-overlay)) var(--p-surf-h));

    /* ===============================================================
     * TEXTURE
     *
     * A surface can be a material rather than a fill. The whole thing is
     * three background layers, and the trick is the order:
     *
     *     background: <veil>, <texture>, <colour>
     *
     * The veil sits ON TOP of the texture, painted in that surface's own
     * colour at alpha `1 - k`. At k = 0 it is opaque and the texture is
     * completely hidden; at k = 1 it vanishes and the texture is at full
     * strength. So intensity is continuous, needs no second copy of any
     * texture, and — the point — costs no JavaScript. A slider writes one
     * number and the stylesheet does the rest, which is the same bargain
     * as every other input.
     *
     * WHY NOT `opacity` ON A PSEUDO-ELEMENT, which is how this is usually
     * done: it needs a spare `::before` on every surface, and the ones
     * that matter here — cards, panels, bars — have theirs spoken for.
     * Firefox also gives `<input>` no pseudo-elements at all, so the
     * mechanism would have a hole in it from the start.
     *
     * The texture never carries colour of its own. It is black and white
     * and the veil is the surface, so a texture inherits whatever hue
     * `--p-surf-h` is currently on and cannot drift away from it.
     *
     * ---------------------------------------------------------------
     * IT ONLY EVER PUSHES ONE WAY, AND THAT IS WHAT KEEPS THE CONTRAST
     * PROOF TRUE.
     *
     * The per-mode lightness constants are solutions to contrast
     * targets, so several floors sit almost exactly ON their target —
     * light-mode muted text clears 4.5:1 by 0.26. There is no headroom
     * to spend, and a texture that moves a surface both ways spends it
     * immediately: measured against the shipped floors, a two-way grain
     * strong enough to see needed to be eleven times weaker to stay
     * legal, which is another way of saying invisible.
     *
     * So it does not move both ways. In dark mode the texture may only
     * darken a surface; in light mode it may only lighten one. Both are
     * the direction that moves text contrast UP, so the flat surface
     * stays the worst case and every ratio already proven still holds,
     * at any strength, with nothing re-solved.
     *
     * THE NOISE IS IN THE ALPHA CHANNEL, not in the colour. Each SVG
     * forces its RGB to one ink — black on a dark page, white on a
     * light one — and takes only its alpha from the turbulence. So the
     * ink is the single colour a surface can be dragged toward, and it
     * is always the safe one.
     *
     * The first attempt kept greyscale noise and clamped it with
     * `background-blend-mode: darken`. That is correct and useless: a
     * dark surface is already near the bottom of the range, so `darken`
     * finds almost no noise pixel darker than the surface and the
     * texture disappears entirely. Clamping a distribution at its own
     * extreme leaves nothing. Moving the noise into alpha keeps every
     * pixel useful and needs no blend mode at all.
     *
     * Gradient textures do the same thing with `--p-texture-ink`. The
     * mean shift all this leaves behind is real but small: at the
     * shipped default the page moves by about a percent of its own
     * lightness, in the direction that helps.
     * --- */
    /* SHIPPED ON. A flat fill is a screen; a surface with a little tooth
     * is a thing. The default is fine grain at a little over half
     * strength, which on a large dark region also does the job a dither
     * does — eight-bit gradients band into visible steps, and grain
     * breaks the step up before the eye finds it.
     *
     * Set `data-pulse-texture="none"` for the flat surfaces Pulse had
     * before this existed. */
    --p-texture-k: 0.5;
    /* One token, and each texture supplies its whole background layer —
     * image, position and size together. A shared tile variable looked
     * tidier and could not express `wash`, which is one large gradient
     * rather than a repeat, or `grid`, which is two layers and needs no
     * size at all. `none` is a transparent layer rather than the `none`
     * keyword, because a well-formed layer keeps the composed shorthand
     * valid whatever changes around it. */
    --p-texture-layer: linear-gradient(transparent, transparent);
    /* Black on a dark page, white on a light one — the safe direction in
     * each. One expression rather than a per-mode override, so a mode
     * cannot be added without it. */
    --p-texture-ink: oklch(0 0 0);

    /* The veil, per surface. `calc((1 - k) * 100%)` is the whole control. */
    --p-veil-sunken: color-mix(in oklab, var(--p-surface-sunken) calc((1 - var(--p-texture-k)) * 100%), transparent);
    --p-veil-base: color-mix(in oklab, var(--p-surface) calc((1 - var(--p-texture-k)) * 100%), transparent);
    --p-veil-raised: color-mix(in oklab, var(--p-surface-raised) calc((1 - var(--p-texture-k)) * 100%), transparent);

    /* The composed values. A rule that wants a textured surface sets
     * `background: var(--p-fill-…)`; one that wants the flat colour —
     * a badge, a form field, anything small — keeps using the surface
     * token directly and is unaffected by any of this. */
    --p-fill-sunken:
      linear-gradient(var(--p-veil-sunken), var(--p-veil-sunken)) 0 0 / cover,
      var(--p-texture-layer), var(--p-surface-sunken);
    --p-fill-base:
      linear-gradient(var(--p-veil-base), var(--p-veil-base)) 0 0 / cover,
      var(--p-texture-layer), var(--p-surface);
    --p-fill-raised:
      linear-gradient(var(--p-veil-raised), var(--p-veil-raised)) 0 0 / cover,
      var(--p-texture-layer), var(--p-surface-raised);

    /* --- Accent-tonal surfaces. A separate family at three contrast-safe
     * strengths. Accent enters the UI here; it never contaminates the
     * neutral ramp above. --- */
    --p-tint-subtle: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.022 * var(--p-tint-k))
      calc(0.014 * var(--p-tint-k)) var(--p-accent-h)
    );
    --p-tint-soft: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.05 * var(--p-tint-k))
      calc(0.028 * var(--p-tint-k)) var(--p-accent-h)
    );
    --p-tint-medium: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.09 * var(--p-tint-k))
      calc(0.048 * var(--p-tint-k)) var(--p-accent-h)
    );

    /* --- Borders. Routine separators stay neutral; important boundaries
     * take a low-chroma accent cast, always weaker than an active control
     * so the eye still ranks the control first. --- */
    --p-line: oklch(var(--p-l-line) 0 0);
    --p-border: oklch(var(--p-l-border) 0.016 var(--p-accent-h));
    --p-border-strong: oklch(calc(var(--p-l-border-strong) + var(--p-dir) * 0.02) 0.034 var(--p-accent-h));
    --p-border-accent: oklch(var(--p-accent-l-safe) calc(var(--p-accent-c) * 0.55) var(--p-accent-h));

    /* --- Foregrounds, per surface. --- */
    --p-text-strong: oklch(var(--p-l-text-strong) 0 0);
    --p-text: oklch(var(--p-l-text) 0 0);
    --p-text-muted: oklch(var(--p-l-text-muted) 0 0);
    --p-text-subtle: oklch(var(--p-l-text-subtle) 0 0);
    --p-text-accent: oklch(var(--p-accent-l-on-surface) var(--p-accent-c) var(--p-accent-h));

    /* --- Accent as a filled control. --- */
    --p-accent: oklch(var(--p-accent-l-safe) var(--p-accent-c) var(--p-accent-h));
    --p-accent-hover: oklch(var(--p-accent-l-hover) var(--p-accent-c) var(--p-accent-h));
    --p-accent-active: oklch(var(--p-accent-l-active) var(--p-accent-c) var(--p-accent-h));
    --p-accent-fg: oklch(var(--p-l-accent-fg) 0 0);
    --p-focus: oklch(var(--p-accent-l-on-surface) var(--p-accent-c) var(--p-accent-h));

    /* spec: series-ramp */
    /* --- THE SERIES RAMP -------------------------------------------
     *
     * Five identities for a chart, in this fixed order, and never
     * cycled: a sixth series folds into "other", becomes small
     * multiples, or the chart is asking one question too many.
     *
     * Every one is a hue this system had not already spoken for — red
     * is danger, gold is caution, green is success, azure is utility,
     * indigo is the accent — so a line on a plot can never be mistaken
     * for a status. Chosen against the data-visualisation floors rather
     * than by eye, in both modes: lightness band, chroma floor,
     * colour-blind separation between neighbours, and contrast against
     * the surface.
     *
     * ONE OF THE PAIRS IS AT THE FLOOR. Jade and orchid separate by
     * ΔE 6.4 under deuteranopia, against a target of 8 — which is legal
     * only where something other than colour also carries identity. So
     * a legend is not optional on a Pulse chart, and neither are direct
     * labels at four series or fewer. Four series would have cleared it
     * outright; five was chosen knowing the cost. --- */
    --p-series-1: oklch(var(--p-l-series) var(--p-c-series) var(--p-hue-18));
    --p-series-2: oklch(var(--p-l-series) var(--p-c-series) var(--p-hue-4));
    --p-series-3: oklch(var(--p-l-series) var(--p-c-series) var(--p-hue-13));
    --p-series-4: oklch(var(--p-l-series) var(--p-c-series) var(--p-hue-23));
    --p-series-5: oklch(var(--p-l-series) var(--p-c-series) var(--p-hue-9));

    /* --- Semantic roles. Hue fixed, lightness and chroma per mode.
     * Meaning must never rest on hue alone: every pattern that uses these
     * also carries a label, an icon, or a structural cue. --- */
    --p-good: oklch(
      calc(var(--p-l-sem) + var(--p-good-hi) * (var(--p-l-sem-lift) - var(--p-l-sem)))
      calc(var(--p-c-sem) * var(--p-good-cxm)) var(--p-good-h)
    );
    --p-good-fg: oklch(
      calc(var(--p-l-accent-fg) + var(--p-good-hi) * (var(--p-lift-fg-l) - var(--p-l-accent-fg))) 0 0
    );
    --p-good-text: oklch(var(--p-l-sem-text) calc(var(--p-c-sem-text) * var(--p-good-cxm)) var(--p-good-h));
    /* TWO TINT STRENGTHS PER MEANING, the same pair the accent has had
     * all along as --p-tint-subtle and --p-tint-soft. The soft one is a
     * panel a message lives in; the subtle one is the fill under a
     * control that has to look like a control without looking like the
     * loudest one on the screen, and it is the one that leaves a
     * hovered state somewhere to go. */
    --p-good-surface: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.06 * var(--p-tint-k))
      calc(0.04 * var(--p-tint-k) * var(--p-good-cxm)) var(--p-good-h)
    );
    --p-good-surface-subtle: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.028 * var(--p-tint-k))
      calc(0.019 * var(--p-tint-k) * var(--p-good-cxm)) var(--p-good-h)
    );
    --p-good-border: oklch(calc(var(--p-l-border-strong) + var(--p-dir) * 0.035) calc(0.10 * var(--p-good-cxm)) var(--p-good-h));

    --p-bad: oklch(
      calc(var(--p-l-sem) + var(--p-bad-hi) * (var(--p-l-sem-lift) - var(--p-l-sem)))
      calc(var(--p-c-sem) * var(--p-bad-cxm)) var(--p-bad-h)
    );
    --p-bad-fg: oklch(
      calc(var(--p-l-accent-fg) + var(--p-bad-hi) * (var(--p-lift-fg-l) - var(--p-l-accent-fg))) 0 0
    );
    --p-bad-text: oklch(var(--p-l-sem-text) calc(var(--p-c-sem-text) * var(--p-bad-cxm)) var(--p-bad-h));
    --p-bad-surface: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.06 * var(--p-tint-k))
      calc(0.04 * var(--p-tint-k) * var(--p-bad-cxm)) var(--p-bad-h)
    );
    --p-bad-surface-subtle: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.028 * var(--p-tint-k))
      calc(0.019 * var(--p-tint-k) * var(--p-bad-cxm)) var(--p-bad-h)
    );
    --p-bad-border: oklch(calc(var(--p-l-border-strong) + var(--p-dir) * 0.035) calc(0.10 * var(--p-bad-cxm)) var(--p-bad-h));

    --p-warn: oklch(
      calc(var(--p-l-sem) + var(--p-warn-hi) * (var(--p-l-sem-lift) - var(--p-l-sem)))
      calc(var(--p-c-sem) * var(--p-warn-cxm)) var(--p-warn-h)
    );
    --p-warn-fg: oklch(
      calc(var(--p-l-accent-fg) + var(--p-warn-hi) * (var(--p-lift-fg-l) - var(--p-l-accent-fg))) 0 0
    );
    --p-warn-text: oklch(var(--p-l-sem-text) calc(var(--p-c-sem-text) * var(--p-warn-cxm)) var(--p-warn-h));
    --p-warn-surface: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.06 * var(--p-tint-k))
      calc(0.04 * var(--p-tint-k) * var(--p-warn-cxm)) var(--p-warn-h)
    );
    --p-warn-surface-subtle: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.028 * var(--p-tint-k))
      calc(0.019 * var(--p-tint-k) * var(--p-warn-cxm)) var(--p-warn-h)
    );
    --p-warn-border: oklch(calc(var(--p-l-border-strong) + var(--p-dir) * 0.035) calc(0.10 * var(--p-warn-cxm)) var(--p-warn-h));

    --p-info: oklch(
      calc(var(--p-l-sem) + var(--p-info-hi) * (var(--p-l-sem-lift) - var(--p-l-sem)))
      calc(var(--p-c-sem) * var(--p-info-cxm)) var(--p-info-h)
    );
    --p-info-fg: oklch(
      calc(var(--p-l-accent-fg) + var(--p-info-hi) * (var(--p-lift-fg-l) - var(--p-l-accent-fg))) 0 0
    );
    --p-info-text: oklch(var(--p-l-sem-text) calc(var(--p-c-sem-text) * var(--p-info-cxm)) var(--p-info-h));
    --p-info-surface: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.06 * var(--p-tint-k))
      calc(0.04 * var(--p-tint-k) * var(--p-info-cxm)) var(--p-info-h)
    );
    --p-info-surface-subtle: oklch(
      calc(var(--p-l-base) + var(--p-dir) * 0.028 * var(--p-tint-k))
      calc(0.019 * var(--p-tint-k) * var(--p-info-cxm)) var(--p-info-h)
    );
    --p-info-border: oklch(calc(var(--p-l-border-strong) + var(--p-dir) * 0.035) calc(0.10 * var(--p-info-cxm)) var(--p-info-h));

    /* --- Interaction washes. Neutral so they read the same on any tone.
     * Two of them, deliberately: selected rows use --p-tint-soft and
     * disabled controls use opacity, so a -selected or -disabled wash
     * here would be a knob wired to nothing — both shipped once, and a
     * theme author turning them saw no effect anywhere. --- */
    --p-wash-hover: color-mix(in oklab, var(--p-text) 8%, transparent);
    --p-wash-active: color-mix(in oklab, var(--p-text) 14%, transparent);

    /* --- Shadow. Crisp and tight, not atmospheric. --- */
    --p-shadow-1: 0 1px 1px oklch(0 0 0 / calc(var(--p-shadow-a) * 0.45));
    --p-shadow-2:
      0 1px 2px oklch(0 0 0 / calc(var(--p-shadow-a) * 0.5)),
      0 2px 4px -2px oklch(0 0 0 / calc(var(--p-shadow-a) * 0.45));
    --p-shadow-3:
      0 2px 4px -1px oklch(0 0 0 / calc(var(--p-shadow-a) * 0.5)),
      0 6px 12px -5px oklch(0 0 0 / calc(var(--p-shadow-a) * 0.65));
    --p-shadow-4:
      0 4px 8px -2px oklch(0 0 0 / calc(var(--p-shadow-a) * 0.5)),
      0 12px 28px -10px oklch(0 0 0 / calc(var(--p-shadow-a) * 0.7));
  }

  /* Context-adaptive content contrast. Tools stay crisp; reading and media
   * surfaces calibrate down so long-form text is not a glare source.
   *
   * Expressed relative to the muted role and --p-dir so one rule serves
   * both modes: dark lands ~12:1 / ~9.5:1, light ~10:1 / ~7:1. Still well
   * clear of AA in both — softer, never weak. */
  [data-pulse-contrast="reading"] {
    --p-l-text-strong: calc(var(--p-l-text-muted) + var(--p-dir) * 0.16);
    --p-l-text: calc(var(--p-l-text-muted) + var(--p-dir) * 0.08);
  }

  /* ---- the textures ------------------------------------------------
   *
   * Two families, for a reason worth knowing before adding a seventh.
   *
   * GRAIN AND PAPER ARE FILTERED SVG, inline as a data URI. Random is
   * the one thing gradients cannot do: every CSS pattern repeats on some
   * lattice, and at grain frequencies a lattice is exactly what the eye
   * finds. `stitchTiles="stitch"` makes the turbulence wrap, so a 128px
   * tile has no seam.
   *
   * EVERYTHING ELSE IS GRADIENTS, because they are resolution
   * independent, cost nothing, and a regular pattern is the point of
   * them rather than a defect.
   *
   * All six are grey. Colour comes from the veil above, which is the
   * surface itself, so a texture cannot pull a theme off its hue.
   *
   * The baked opacities are the ceiling — full slider — and they are low
   * on purpose: `pulse verify` proves text contrast against a surface
   * that may be this much lighter or darker at any pixel, and every
   * value here is inside that proof. Raising one means re-running it. */
  /* `:root` as well, so grain is what a page gets with no attribute at
   * all — the same shape as the mode blocks, and it keeps the data URI
   * written once rather than copied into a default. */
  :root,
  [data-pulse-texture="grain"] {
    --p-texture-layer: var(--p-texture-grain) 0 0 / 128px 128px;
  }

  /* Lower frequency across the inline axis than down the block one, which
   * is what makes it read as fibre with a direction rather than as dust. */
  [data-pulse-texture="paper"] {
    --p-texture-layer: var(--p-texture-paper) 0 0 / 160px 160px;
  }

  [data-pulse-texture="dots"] {
    --p-texture-layer: radial-gradient(
      circle at 50% 50%,
      color-mix(in oklab, var(--p-texture-ink) calc(16% * var(--p-texture-cap)), transparent) 0 0.7px, transparent 0.8px
    ) 0 0 / 6px 6px;
  }

  /* Repeating gradients carry their own geometry, so these need no size. */
  [data-pulse-texture="grid"] {
    --p-texture-layer:
      repeating-linear-gradient(
        to right, color-mix(in oklab, var(--p-texture-ink) calc(10% * var(--p-texture-cap)), transparent) 0 1px, transparent 1px 16px
      ),
      repeating-linear-gradient(
        to bottom, color-mix(in oklab, var(--p-texture-ink) calc(10% * var(--p-texture-cap)), transparent) 0 1px, transparent 1px 16px
      );
  }

  [data-pulse-texture="hatch"] {
    --p-texture-layer: repeating-linear-gradient(
      45deg, color-mix(in oklab, var(--p-texture-ink) calc(11% * var(--p-texture-cap)), transparent) 0 1px, transparent 1px 8px
    );
  }

  /* Not a texture so much as a drift: one very large gradient, no repeat. */
  [data-pulse-texture="wash"] {
    --p-texture-layer: radial-gradient(
      120% 90% at 50% 0%,
      color-mix(in oklab, var(--p-texture-ink) calc(10% * var(--p-texture-cap)), transparent) 0%, transparent 70%
    ) 0 0 / cover;
  }

  /* Both halves: the veil alone would hide the texture, but the image
     * would still be fetched, decoded and composited every paint for a
     * surface that has decided not to have one. */
  [data-pulse-texture="none"] {
    --p-texture-k: 0;
    --p-texture-layer: linear-gradient(transparent, transparent);
  }

  @media (prefers-reduced-motion: reduce) {
    :root {
      --p-dur-1: 1ms;
      --p-dur-2: 1ms;
      --p-dur-3: 1ms;
    }
  }

  /* Fallback for engines without relative-colour-capable oklch(). Every
   * browser Pulse targets has shipped it since 2024; this only keeps an
   * ancient one legible rather than unstyled. */
  @supports not (color: oklch(0.5 0.1 250)) {
    :root {
      --p-surface: #151515;
      --p-surface-sunken: #0e0e0e;
      --p-surface-raised: #1e1e1e;
      --p-surface-overlay: #262626;
      --p-text: #d1d1d1;
      --p-text-strong: #f0f0f0;
      --p-text-muted: #a0a0a0;
      --p-line: #3e3e3e;
      --p-border: #4e4e4e;
      --p-accent: #7aa2f7;
      --p-accent-fg: #101010;
    }
  }
}
/* Pulse — base
 * Document-level type and colour. Everything here is a default that an
 * element or pattern rule can override.
 */
/* spec: prose */
/* spec: focus */
@layer pulse.base {
  html {
    font-family: var(--p-font);
    font-size: var(--p-text-md);
    line-height: var(--p-leading-snug);
    letter-spacing: var(--p-tracking);
    color: var(--p-text);
    background: var(--p-fill-base);
    accent-color: var(--p-accent);
    scrollbar-color: var(--p-border-strong) transparent;
    /* Reserve the scrollbar's lane whether or not there is one, so that
     * taking the page's scrollbar away below does not shift the whole
     * layout sideways and put it back on close. It also stops a page
     * jumping when its content grows past one screen. */
    scrollbar-gutter: stable;
  }

  /* THE PAGE BEHIND AN OVERLAY DOES NOT SCROLL.
   *
   * Two things go wrong when it does, and the visible one is the giveaway:
   * the page's own scrollbar keeps being drawn down the right of the
   * screen, over or beside a Screen Modal, where it reads as the
   * modal's — while the modal's real scrollbar is inside its body. The
   * other is that a wheel over the scrim scrolls the thing you cannot
   * see and did not mean to touch.
   *
   * `:modal` covers any dialog opened with showModal(); the drawer half
   * is Pulse's own flag. Both need it, because both cover the page. */
  html:has(dialog:modal),
  html:has(:is([data-drawer][data-open],
               .p-app[data-side-open] > .p-side,
               .p-app[data-side-end-open] > .p-side-end)) {
    overflow: hidden;
  }

  /* Release the reserved lane only for a modal, which covers the page.
   * Engines disagree about a stable gutter under overflow:hidden:
   * Chrome drops it and Firefox keeps it. Against a kept gutter, a
   * full-screen modal is wider than its containing block and its trailing
   * edge is clipped. A drawer leaves much of the page visible, so it keeps
   * the stable lane above instead — otherwise that visible page jumps by
   * half a scrollbar width when the drawer opens. */
  html:has(dialog:modal) {
    scrollbar-gutter: auto;
  }

  /* AN APP SHELL IS NOT A DOCUMENT, so it does not want a document's
   * scrollbar lane. `.p-app` is exactly one viewport tall and hides its
   * own overflow — the document behind it can never scroll, so the lane
   * reserved above is for a scrollbar that cannot exist. It is not
   * invisible: it ends the shell 15px short of the viewport, so the
   * trailing rail of an edge-to-edge shell stops before the edge and the
   * last strip of screen has no hit target in it.
   *
   * That contradicts the app-shell's own edge-to-edge contract, and it
   * was silent for as long as it did, because a 15px band of background
   * beside a sidebar looks like a margin somebody chose. SpoofySort
   * measured it: innerWidth 1280, every box inside ending at 1265.
   *
   * IT WAS WRITTEN `body > .p-app` AND THAT MATCHED NOTHING REAL.
   * The reasoning was that the shell must BE the page rather than a
   * picture of one — but every framework mounts into a container first.
   * Vite writes `<div id="app">`, Preact and React are rendered into
   * `#root`, SvelteKit has its own wrapper: the shell is always a
   * grandchild at best. So the release never fired in a single
   * converted project, and every one of them shipped the strip this
   * rule exists to remove. Measured on three at once — Anima at
   * `body > #root > #app`, ImmichJudge at `body > #root > div`, AIUI at
   * `body > #app` — all three 1265px inside a 1280px window.
   *
   * A descendant match instead. The demo case it was guarding against
   * costs nothing anyway: a page long enough to show a shell inline is
   * a page that scrolls, and a scrolling page's scrollbar occupies the
   * lane whether it was reserved or not, so `auto` and `stable` render
   * identically there. The guard was protecting a case that did not
   * need protecting, at the price of the one that did. */
  html:has(.p-app) {
    scrollbar-gutter: auto;
  }

  body {
    min-height: 100svh;
    font-synthesis-weight: none;
    text-rendering: optimizeLegibility;
    -webkit-font-smoothing: antialiased;
  }

  /* ---- headings -------------------------------------------------
   * Hierarchy is carried by size + weight + spacing + tone together, so no
   * single variable has to do all the work. Title case for major headings
   * is an authoring convention, not a text-transform: CSS must never
   * rewrite stored or accessible text.
   * -------------------------------------------------------------- */
  h1, h2, h3, h4, h5, h6 {
    font-weight: var(--p-weight-semibold);
    line-height: var(--p-leading-tight);
    color: var(--p-text-strong);
    text-wrap: balance;
  }

  /* A heading owes its body copy some air — spacing is part of how the
   * hierarchy reads, not decoration. Zero specificity, so the gap-owning
   * patterns below can drop it without a fight. */
  :where(h1, h2, h3, h4, h5, h6):not(:last-child) {
    margin-block-end: var(--p-space-3);
  }

  h1 { font-size: var(--p-text-4xl); letter-spacing: -0.012em; }
  h2 { font-size: var(--p-text-3xl); letter-spacing: -0.008em; }
  h3 { font-size: var(--p-text-2xl); letter-spacing: -0.004em; }
  h4 { font-size: var(--p-text-xl); }
  h5 { font-size: var(--p-text-lg); }
  h6 {
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-semibold);
    letter-spacing: var(--p-tracking-label);
    color: var(--p-text-muted);
  }

  p {
    text-wrap: pretty;
  }

  small {
    font-size: var(--p-text-sm);
    color: var(--p-text-muted);
  }

  strong, b {
    font-weight: var(--p-weight-semibold);
    color: var(--p-text-strong);
  }

  em, i {
    font-style: italic;
  }

  a {
    color: var(--p-text-accent);
    text-decoration-color: color-mix(in oklab, currentColor 40%, transparent);
    text-underline-offset: 0.18em;
    text-decoration-thickness: from-font;
    border-radius: var(--p-radius-xs);
    transition: color var(--p-dur-1) var(--p-ease);
  }

  a:hover {
    text-decoration-color: currentColor;
  }

  code, kbd, samp, pre {
    font-family: var(--p-font-mono);
    font-size: 0.92em;
    font-feature-settings: "calt" 0;
  }

  /* Aligned numerals wherever numbers are compared vertically. */
  table, [data-numeric], .p-numeric {
    font-variant-numeric: tabular-nums;
  }

  ::selection {
    background: color-mix(in oklab, var(--p-accent) 40%, transparent);
    color: var(--p-text-strong);
  }

  /* ---- focus ----------------------------------------------------
   * One visible ring everywhere. Never removed, only relocated.
   * -------------------------------------------------------------- */
  /* Two rings, not one.
   *
   * The focus ring is accent-coloured and plenty of controls are
   * accent-filled exactly when they are worth focusing — a checked
   * switch, a pressed toggle, a primary button, a range thumb. An accent
   * ring against an accent fill merges into a single blob, which is the
   * one moment the ring has to be unmistakable.
   *
   * So a neutral separator sits in the offset gap. The outline is what
   * carries the meaning; the separator only has to break the merge, and
   * it is a box-shadow so that if an overflow ancestor clips it the
   * outline still shows. */
  /* [data-pulse-focus-preview] paints the ring on an element that is not
   * focused. It exists because focus states are close to untestable from
   * automation — a headless or unfocused window never matches
   * :focus-visible — and because tuning the breath by tabbing and
   * squinting is miserable. Same declarations, two triggers, no
   * duplicated definition to drift. */
  :focus-visible,
  [data-pulse-focus-preview] {
    outline: var(--p-ring-width) solid var(--p-focus);
    outline-offset: var(--p-ring-offset);
    /* The glow starts at the outline's outer edge, so the separator and
     * the outline sit on top of its inner end and only the soft part
     * shows. This is the resting end of the breath; the keyframes below
     * restate the same shadow rather than reaching in through a custom
     * property, for the reason given there. */
    box-shadow:
      0 0 0 var(--p-ring-sep) var(--p-surface),
      0 0 var(--p-ring-glow-blur)
        calc(var(--p-ring-offset) + var(--p-ring-width) + var(--p-ring-glow))
        color-mix(in oklab, var(--p-focus)
          calc(var(--p-ring-glow-alpha-min) * 100%), transparent);
  }

  /* ---- ring styles -------------------------------------------------
   * Set data-pulse-focus on <html>. The outline and separator never
   * change; only the glow does, so every option keeps the same legible
   * indicator underneath.
   *
   *   breathe  default. A continuous swell, so the ring is findable at
   *            any moment rather than only just after it moves. Spread
   *            and alpha rise together — a glow swelling, not a ripple.
   *   ping     one expanding ring as focus lands, then perfectly still.
   *   none     the static ring alone.
   * ------------------------------------------------------------------ */
  /* The breath is OPT-IN on the modality: it needs data-pulse-nav="key"
   * on <html>, which pulse-focus.js sets and only for keys that actually
   * move focus.
   *
   * It used to be the other way round — breathe by default, switched off
   * when the last input was a pointer — and that inverted default was
   * wrong in the case that matters most. A project that has not loaded
   * pulse-focus.js never gets the attribute at all, so EVERY focus
   * breathed: click into a chat box, start typing, and the whole textarea
   * pulses at you while you write in it. Failing to the calm state is the
   * right way round; the ring itself is never conditional on any of this.
   *
   * data-pulse-focus-preview is exempt because it exists to show the
   * animation to someone tuning it, with nothing focused at all. */
  [data-pulse-focus-preview],
  [data-pulse-nav="key"] :focus-visible,
  [data-pulse-nav="key"][data-pulse-focus="breathe"] :focus-visible {
    animation: p-focus-breathe var(--p-dur-breathe) var(--p-ease-breathe)
      var(--p-focus-breathe-count);
  }

  /* Only the glow moves. The outline is untouched on purpose: it is the
   * indicator, it holds the contrast, and it is the one part of the ring
   * that physically cannot animate smoothly.
   *
   * These keyframes restate the whole box-shadow rather than animating a
   * custom property that the static rule reads. That is deliberate and
   * it is the load-bearing decision here. box-shadow has interpolated
   * natively in every engine for over a decade. A custom property only
   * interpolates where @property is supported AND honoured in the
   * context it is declared in — and where it is not, the fallback is not
   * an error, it is *discrete* animation: the value flips at the halfway
   * mark. That is indistinguishable from a two-state snap, which is
   * precisely the symptom this animation was rebuilt four times to chase.
   * Restating the shadow costs a few lines and removes the entire class
   * of failure.
   *
   * The var()s below are substituted, not interpolated, and substitution
   * works everywhere — which is also what lets one keyframe set serve
   * controls with different ring geometry. A rule that wants a different
   * ring sets --p-ring-offset / --p-ring-sep on the element and these
   * keyframes follow it. */
  @keyframes p-focus-breathe {
    0%, 100% {
      box-shadow:
        0 0 0 var(--p-ring-sep) var(--p-surface),
        0 0 var(--p-ring-glow-blur)
          calc(var(--p-ring-offset) + var(--p-ring-width) + var(--p-ring-glow))
          color-mix(in oklab, var(--p-focus)
            calc(var(--p-ring-glow-alpha-min) * 100%), transparent);
    }
    50% {
      box-shadow:
        0 0 0 var(--p-ring-sep) var(--p-surface),
        0 0 var(--p-ring-glow-blur)
          calc(var(--p-ring-offset) + var(--p-ring-width) + var(--p-ring-glow))
          color-mix(in oklab, var(--p-focus)
            calc(var(--p-ring-glow-alpha-max) * 100%), transparent);
    }
  }

  /* Pointer-driven focus keeps the ring and drops the breath. The breath
   * is a locator for someone travelling the page by keyboard; somebody
   * who has just clicked a control already knows where it is, and a glow
   * swelling under their cursor is noise. The static outline stays
   * either way — it is the indicator, and it is never conditional.
   *
   * Redundant now that the breath is opt-in, and kept anyway: it is the
   * rule that states the intent, and it is what stops a project's own
   * `animation` on a focused control from leaking in. */
  [data-pulse-nav="pointer"] :focus-visible {
    animation: none;
  }

  [data-pulse-nav="key"][data-pulse-focus="ping"] :focus-visible {
    animation: p-focus-ping var(--p-dur-ping) var(--p-ease) 1;
  }

  /* The end colour is written as a 0% mix rather than `transparent` for
   * the reason recorded against the breath tokens: Firefox will not
   * interpolate a color-mix() against a differently-expressed colour and
   * falls back to a discrete flip. The two ends must match in form. */
  @keyframes p-focus-ping {
    from {
      box-shadow:
        0 0 0 var(--p-ring-sep) var(--p-surface),
        0 0 var(--p-ring-glow-blur)
          calc(var(--p-ring-offset) + var(--p-ring-width))
          color-mix(in oklab, var(--p-focus) 55%, transparent);
    }
    to {
      box-shadow:
        0 0 0 var(--p-ring-sep) var(--p-surface),
        0 0 var(--p-ring-glow-blur)
          calc(var(--p-ring-offset) + var(--p-ring-width) + 12px)
          color-mix(in oklab, var(--p-focus) 0%, transparent);
    }
  }

  [data-pulse-focus="none"] :focus-visible {
    animation: none;
  }

  /* While the ring is in flight the destination ring is held back so the
   * travelling one reads as the same object moving. pulse-focus.js
   * guarantees this attribute is cleared — on completion, on the next
   * focus change, and on a timeout — because a stuck one would leave a
   * keyboard user with no visible focus. */
  [data-pulse-focus-flying] :focus-visible {
    outline-color: transparent;
    box-shadow: none;
    animation: none;
  }

  :focus:not(:focus-visible) {
    outline: none;
  }

  /* ---- scrollbars ------------------------------------------------ */
  ::-webkit-scrollbar {
    width: 10px;
    height: 10px;
  }

  ::-webkit-scrollbar-track {
    background: transparent;
  }

  ::-webkit-scrollbar-thumb {
    background: var(--p-border);
    border: 3px solid transparent;
    background-clip: content-box;
    border-radius: var(--p-radius-full);
  }

  ::-webkit-scrollbar-thumb:hover {
    background: var(--p-border-strong);
    background-clip: content-box;
  }
}
/* Pulse — elements
 *
 * Native HTML, styled directly. A converted project should be able to
 * delete its own control CSS and keep its markup.
 *
 * Variants ride on data-* attributes so they work identically in Jinja,
 * Svelte and React with no class-name conventions to agree on.
 */
/* spec: intents */
/* spec: outline */
@layer pulse.elements {
  /* =================================================================
   * Tone map
   * One place. Adding a tone is additive: define these five custom
   * properties and every variant below picks it up.
   *
   * The default (accent) tone repeats the scope-hook selector list from
   * 02-theme.css for the same reason that block does: a custom property
   * is substituted where it is declared and then inherits as a finished
   * value. Declared only on :root, --p-tone would carry the root mode's
   * accent into a nested [data-pulse-mode="light"] subtree.
   * ================================================================= */
  :root,
  [data-pulse-mode],
  [data-pulse-contrast],
  [data-pulse-texture],
  [data-pulse-accent],
  [data-tone="accent"] {
    --p-tone: var(--p-accent);
    --p-tone-hover: var(--p-accent-hover);
    --p-tone-active: var(--p-accent-active);
    --p-tone-fg: var(--p-accent-fg);
    --p-tone-text: var(--p-text-accent);
    --p-tone-surface: var(--p-tint-soft);
    --p-tone-surface-subtle: var(--p-tint-subtle);
    --p-tone-border: var(--p-border-accent);
  }

  [data-tone="neutral"] {
    --p-tone: var(--p-surface-raised);
    --p-tone-hover: var(--p-wash-hover);
    --p-tone-active: var(--p-wash-active);
    --p-tone-fg: var(--p-text-strong);
    --p-tone-text: var(--p-text-muted);
    --p-tone-surface: var(--p-surface-raised);
    --p-tone-surface-subtle: var(--p-surface-raised);
    --p-tone-border: var(--p-border);
  }

  [data-tone="good"] {
    --p-tone: var(--p-good);
    --p-tone-fg: var(--p-good-fg);
    --p-tone-text: var(--p-good-text);
    --p-tone-surface: var(--p-good-surface);
    --p-tone-surface-subtle: var(--p-good-surface-subtle);
    --p-tone-border: var(--p-good-border);
  }

  [data-tone="bad"] {
    --p-tone: var(--p-bad);
    --p-tone-fg: var(--p-bad-fg);
    --p-tone-text: var(--p-bad-text);
    --p-tone-surface: var(--p-bad-surface);
    --p-tone-surface-subtle: var(--p-bad-surface-subtle);
    --p-tone-border: var(--p-bad-border);
  }

  [data-tone="warn"] {
    --p-tone: var(--p-warn);
    --p-tone-fg: var(--p-warn-fg);
    --p-tone-text: var(--p-warn-text);
    --p-tone-surface: var(--p-warn-surface);
    --p-tone-surface-subtle: var(--p-warn-surface-subtle);
    --p-tone-border: var(--p-warn-border);
  }

  [data-tone="info"] {
    --p-tone: var(--p-info);
    --p-tone-fg: var(--p-info-fg);
    --p-tone-text: var(--p-info-text);
    --p-tone-surface: var(--p-info-surface);
    --p-tone-surface-subtle: var(--p-info-surface-subtle);
    --p-tone-border: var(--p-info-border);
  }

  /* Semantic tones get their interactive states by mixing toward the
   * strongest foreground, which moves the right way in both modes:
   * lighter on dark, darker on light — and always toward more contrast
   * against the paired --p-tone-fg, never less. */
  [data-tone="good"],
  [data-tone="bad"],
  [data-tone="warn"],
  [data-tone="info"] {
    --p-tone-hover: color-mix(in oklab, var(--p-tone) 90%, var(--p-text-strong));
    --p-tone-active: color-mix(in oklab, var(--p-tone) 82%, var(--p-text-strong));
  }

  /* =================================================================
   * Intents
   *
   * A tone says what an element should look like. An intent says what
   * the action *does*, and the look follows from that. The distinction
   * earns its keep because a colour choice made per-screen drifts — one
   * page's Delete is red, the next page's is a grey ghost — while an
   * intent is decidable from the action alone and gives the same answer
   * every time.
   *
   *   danger    destroys data, or cannot be undone
   *   caution   bypasses a safeguard, but is recoverable
   *   transfer  moves data across the app's boundary
   *   commit    writes the change the screen exists to make
   *   proceed   advances without committing anything yet
   *   cancel    backs out, changing nothing
   *   neutral   everything else
   *
   * The full decision procedure — ordered, first match wins — is in
   * AGENT-BRIEF.md, and `pulse check` enforces it against the label.
   *
   * Intent also drives placement; see .p-actions in the patterns layer.
   *
   * These sit after the tone map on purpose. An element carrying both
   * data-intent and data-tone takes the intent, because the intent is
   * the statement of fact and the tone is only a preference.
   * ================================================================= */
  [data-intent="danger"] {
    --p-tone: var(--p-bad);
    --p-tone-fg: var(--p-bad-fg);
    --p-tone-text: var(--p-bad-text);
    --p-tone-surface: var(--p-bad-surface);
    --p-tone-surface-subtle: var(--p-bad-surface-subtle);
    --p-tone-border: var(--p-bad-border);
  }

  [data-intent="caution"] {
    --p-tone: var(--p-warn);
    --p-tone-fg: var(--p-warn-fg);
    --p-tone-text: var(--p-warn-text);
    --p-tone-surface: var(--p-warn-surface);
    --p-tone-surface-subtle: var(--p-warn-surface-subtle);
    --p-tone-border: var(--p-warn-border);
  }

  [data-intent="commit"] {
    --p-tone: var(--p-good);
    --p-tone-fg: var(--p-good-fg);
    --p-tone-text: var(--p-good-text);
    --p-tone-surface: var(--p-good-surface);
    --p-tone-surface-subtle: var(--p-good-surface-subtle);
    --p-tone-border: var(--p-good-border);
  }

  [data-intent="proceed"] {
    --p-tone: var(--p-accent);
    --p-tone-hover: var(--p-accent-hover);
    --p-tone-active: var(--p-accent-active);
    --p-tone-fg: var(--p-accent-fg);
    --p-tone-text: var(--p-text-accent);
    --p-tone-surface: var(--p-tint-soft);
    --p-tone-surface-subtle: var(--p-tint-subtle);
    --p-tone-border: var(--p-border-accent);
  }

  /* Import, export, upload, download, sync. Info blue rather than the
   * accent, because the accent is the user's and moves with the theme
   * while this needs to mean the same thing in every project — and
   * because "data is crossing the boundary" is a statement about the
   * world, not about what the app would like you to click. */
  [data-intent="transfer"] {
    --p-tone: var(--p-info);
    --p-tone-fg: var(--p-info-fg);
    --p-tone-text: var(--p-info-text);
    --p-tone-surface: var(--p-info-surface);
    --p-tone-surface-subtle: var(--p-info-surface-subtle);
    --p-tone-border: var(--p-info-border);
  }

  [data-intent="cancel"],
  [data-intent="neutral"] {
    --p-tone: var(--p-surface-raised);
    --p-tone-hover: var(--p-wash-hover);
    --p-tone-active: var(--p-wash-active);
    --p-tone-fg: var(--p-text-strong);
    --p-tone-text: var(--p-text-muted);
    --p-tone-surface: var(--p-surface-raised);
    --p-tone-surface-subtle: var(--p-surface-raised);
    --p-tone-border: var(--p-border);
  }

  /* Same mixing rule the semantic tones get, for the same reason. */
  [data-intent="danger"],
  [data-intent="caution"],
  [data-intent="transfer"],
  [data-intent="commit"] {
    --p-tone-hover: color-mix(in oklab, var(--p-tone) 90%, var(--p-text-strong));
    --p-tone-active: color-mix(in oklab, var(--p-tone) 82%, var(--p-text-strong));
  }

  /* spec: button-collapse */
  /* spec: sizes-and-shapes */
  /* spec: choosing-one */
  /* spec: destructive-friction */
  /* spec: using-one */
  /* spec: busy-controls */
  /* =================================================================
   * Buttons — tiered for complex workflows.
   *   (default)  secondary: the workhorse
   *   primary    one per region, filled
   *   subtle     tonal, for repeated row actions
   *   ghost      lowest tier, toolbars and icon rails
   *   link       inline, in prose
   * ================================================================= */
  button,
  .p-btn,
  [type="button"],
  [type="submit"],
  [type="reset"] {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--p-space-2);
    position: relative;
    block-size: var(--p-control-h);
    padding-inline: var(--p-space-4);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-control);
    background: var(--p-surface-raised);
    color: var(--p-text-strong);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-semibold);
    line-height: 1;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
    user-select: none;
    transition:
      background-color var(--p-dur-1) var(--p-ease),
      border-color var(--p-dur-1) var(--p-ease),
      color var(--p-dur-1) var(--p-ease),
      translate var(--p-dur-1) var(--p-ease);
  }

  button:hover,
  .p-btn:hover,
  [type="button"]:hover,
  [type="submit"]:hover,
  [type="reset"]:hover {
    background: color-mix(in oklab, var(--p-surface-raised) 88%, var(--p-text-strong));
    border-color: var(--p-border-strong);
  }

  button:active,
  .p-btn:active {
    translate: 0 1px;
  }

  /* EVERY TIER MATCHES THE SAME LIST THE BASE RULE DOES, and the list is
   * why. The base look above is written for `button, .p-btn, [type=…]`,
   * and the variants were written for `button` alone — so a form built
   * the way server-rendered projects build them, with
   * `<input type="submit" value="Save">`, took the secondary look and
   * then silently ignored `data-variant="primary"`. Nothing in the CSS
   * is wrong when that happens and nothing is reported: the attribute
   * simply matches no rule.
   *
   * A project converting to Pulse is supposed to keep its markup and
   * delete its own CSS. A tier that works on one way of writing a button
   * and not another breaks that promise in the place it is least likely
   * to be noticed. */
  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="primary"] {
    background: var(--p-tone);
    border-color: transparent;
    color: var(--p-tone-fg);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="primary"]:hover {
    background: var(--p-tone-hover);
    border-color: transparent;
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="primary"]:active {
    background: var(--p-tone-active);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="subtle"] {
    background: var(--p-tone-surface);
    border-color: transparent;
    color: var(--p-tone-text);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="subtle"]:hover {
    background: color-mix(in oklab, var(--p-tone-surface) 80%, var(--p-tone));
    border-color: transparent;
  }

  /* ---- outline — the tier between ghost and filled -------------------
   *
   * A destructive action that is nonetheless recoverable: clearing one
   * saved shortcut, removing one row. It needs the tone's colour and a
   * visible boundary, and it must not have the weight of a filled
   * primary — a table with a red Delete on every row reads as a page
   * full of alarms.
   *
   * Ghost takes too much away for that job: with no border, a per-row
   * action becomes a coloured word the reader has to discover by
   * hovering. Four settings tables across two projects wrote this tier by
   * hand as project CSS under this exact name, which is the point at
   * which inventing it privately has to stop.
   *
   * Border from the tone, background transparent until hover. The text is
   * `--p-tone-text` rather than `--p-tone`, because the tone itself is
   * mixed for a fill behind `--p-tone-fg` and does not clear 4.5:1 as
   * text on the page's own surface. */
  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="outline"] {
    background: transparent;
    border-color: var(--p-tone-border);
    color: var(--p-tone-text);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="outline"]:hover {
    background: var(--p-tone-surface);
    border-color: var(--p-tone);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="outline"]:active {
    background: color-mix(in oklab, var(--p-tone-surface) 80%, var(--p-tone));
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="ghost"] {
    background: transparent;
    border-color: transparent;
    color: var(--p-text-muted);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="ghost"]:hover {
    background: var(--p-wash-hover);
    border-color: transparent;
    color: var(--p-text-strong);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="ghost"]:is([aria-pressed="true"], [aria-expanded="true"], [data-active]) {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="link"] {
    block-size: auto;
    padding: 0;
    border: 0;
    background: none;
    color: var(--p-tone-text);
    text-decoration: underline;
    text-underline-offset: 0.18em;
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="link"]:hover {
    background: none;
    color: var(--p-text-strong);
  }

  /* ARMED. One click has happened and the next one does the thing.
   *
   * The state is the project's to set and clear — Pulse cannot know the
   * timeout, and the LABEL has to change too ("Delete" -> "Click again to
   * confirm"), which is the project's job because it is real text a
   * screen reader reads. Rule 8: never rewrite accessible text from CSS.
   * All that lives here is the look.
   *
   * It fills in its own tone, whatever the variant was resting. That is
   * the point: a ghost Delete in a table row is quiet until it is armed,
   * and then it is the loudest thing on the row for as long as the window
   * is open. Promotion IS the warning.
   *
   * Deliberately not a new colour. An armed danger button is danger, an
   * armed caution is caution — arming changes how loud the intent is, not
   * what it means.
   *
   * AFTER EVERY VARIANT, and that placement is load-bearing. This rule
   * and the variants above match the same list, so they weigh the same
   * and nothing but source order separates them. Written above the
   * variants, this one lost to every tier — an armed button silently
   * kept its resting fill, which is the one state where failing to shout
   * is the whole bug.
   *
   * The list has to be the SAME list, too, and not the shorter one this
   * rule used to carry: a bare `button[data-armed]` is (0,1,1) against a
   * variant's (0,2,0) and loses on weight wherever it sits. */
  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-armed] {
    background: var(--p-tone);
    border-color: transparent;
    color: var(--p-tone-fg);
    /* The static ring of the focus system, reused: this is the same claim
     * — "the keyboard/pointer is about to act here" — and a second, novel
     * emphasis treatment would be one more thing to recognise. */
    outline: var(--p-ring-width) solid var(--p-tone);
    outline-offset: var(--p-ring-offset);
  }

  /* ARMED HAS TO STATE ITS OWN HOVER, and the reason is a specificity
   * trap worth naming. The base hover rule up the file lists
   * `[type="button"]:hover` alongside `button:hover`, and an attribute
   * plus a pseudo-class is (0,2,0) against this rule's (0,1,1). So an
   * armed button written the careful way — with an explicit
   * type="button" — dropped its tone fill for the neutral grey wash the
   * moment a pointer touched it, while one written `<button>` bare kept
   * it. Same markup intent, two different looks, decided by an attribute
   * nobody thought was styling.
   *
   * Matched through the same list as the base rule so the specificity is
   * (0,3,0) and the question does not come up again. It deepens rather
   * than fading: an armed control going quiet under the pointer reads as
   * "disarmed", which is the opposite of true, and it is the pointer that
   * is about to fire it. */
  /* A BIGGER STEP THAN THE SYSTEM'S ORDINARY HOVER, deliberately.
   *
   * --p-tone-hover is a 10% mix toward --p-text-strong, which is the
   * right size of nudge for a control that is merely hoverable: enough to
   * say "this is live", not enough to shout. On an armed control the
   * pointer is not browsing, it is one press from something irreversible,
   * and a 10% shift is invisible against a fill that is already the
   * loudest thing in the row.
   *
   * Same mechanism as every other interactive state here — mix toward
   * --p-text-strong, which lightens on dark and darkens on light and so
   * always moves toward more contrast against the paired --p-tone-fg.
   * Only the distance is different.
   *
   * The ring grows with it. Colour alone is doing too much work on a
   * button whose fill and text are both already fixed by the tone, and a
   * ring inflating under the pointer is a size change rather than a
   * colour one — it survives being looked at out of the corner of an eye,
   * which is where this button will actually be looked at. */
  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-armed]:hover {
    background: color-mix(in oklab, var(--p-tone) 72%, var(--p-text-strong));
    border-color: transparent;
    color: var(--p-tone-fg);
    outline-width: calc(var(--p-ring-width) * 2);
    outline-offset: calc(var(--p-ring-offset) + 1px);
  }

  /* Pressed settles back in: the ring snaps to its resting width while
   * the fill goes further still, so the press reads as the thing closing
   * rather than as one more step of the same approach. */
  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-armed]:active {
    background: color-mix(in oklab, var(--p-tone) 60%, var(--p-text-strong));
    outline-width: var(--p-ring-width);
    outline-offset: var(--p-ring-offset);
  }

  /* Intent on a button, at every tier.
   *
   * Intent and variant are orthogonal. Intent is what the action means;
   * variant is how loud it is. The same Delete is a filled red button in
   * a confirmation dialog and a red-on-hover ghost in a table row, and
   * both are correct — so intent must read at every tier rather than
   * only on the filled one.
   *
   * The default tier tints text and border rather than filling. Filling
   * is what data-variant="primary" is for, and an unqualified intent
   * should not silently promote an action to the loudest thing on the
   * screen. Below the tint, hue is never the only signal: the label
   * still says "Delete" and placement still puts it on the left.
   *
   * primary and subtle need no rule here — they already read --p-tone,
   * which the intent has redefined above. */
  /* The fill is the subtle tint, not the raised neutral. On a light page
   * a white button on a white surface is a border and a word, and the
   * intent's colour — the whole point of the rule below — was carried by
   * two hairlines. The subtle tint gives it a body without giving it the
   * weight of a filled control, which is the distinction this variant
   * exists to make.
   *
   * SUBTLE AND NOT SOFT so that hover has somewhere to go: soft at rest
   * would leave the hovered state either identical or dark enough to put
   * the label under 4.5:1 in light mode, which is where the margin is
   * thinnest. */
  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"]):is([data-intent="danger"], [data-intent="caution"],
      [data-intent="transfer"], [data-intent="commit"],
      [data-intent="proceed"]):not([data-variant]):not([data-armed]) {
    background: var(--p-tone-surface-subtle);
    color: var(--p-tone-text);
    border-color: var(--p-tone-border);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"]):is([data-intent="danger"], [data-intent="caution"],
      [data-intent="transfer"], [data-intent="commit"],
      [data-intent="proceed"]):not([data-variant]):not([data-armed]):hover {
    background: var(--p-tone-surface);
    border-color: var(--p-tone);
    color: var(--p-tone-text);
  }

  /* A PASTEL FILL HAS TO SHOW ITS EDGE. Every set clears the four
   * contrast floors, but those are about the ink on a control and the
   * text beside it — none of them asks whether the control is findable
   * against the PAGE. A pale fill on a light page is 1.65:1 at worst,
   * which is a button you have to hunt for.
   *
   * So in the pastel set a filled control keeps its border, and the
   * border is the one Pulse already solves to 3:1 against the surface.
   * The boundary carries the separation the fill cannot. */
  [data-pulse-palette="pastel"] :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="primary"],
  [data-pulse-palette="pastel"] :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="subtle"] {
    border-color: var(--p-tone-border);
  }

  /* Backing out should be the quietest thing in the row. It is still a
   * full button — a cancel a user cannot find is worse than a loud one. */
  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"]):is([data-intent="cancel"], [data-intent="neutral"]):not([data-variant]):not([data-armed]) {
    color: var(--p-text-muted);
  }

  /* Ghost carries the intent in its text. cancel and neutral are left
   * out: their tone-text is already the muted default, and overriding
   * hover would cost them the lift to full-strength text. */
  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="ghost"]:is([data-intent="danger"], [data-intent="caution"],
      [data-intent="transfer"], [data-intent="commit"], [data-intent="proceed"]):not([data-armed]) {
    color: var(--p-tone-text);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-variant="ghost"]:is([data-intent="danger"], [data-intent="caution"],
      [data-intent="transfer"], [data-intent="commit"], [data-intent="proceed"]):not([data-armed]):hover {
    background: var(--p-tone-surface);
    color: var(--p-tone-text);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-size="sm"] {
    block-size: var(--p-control-h-sm);
    padding-inline: var(--p-space-3);
    border-radius: var(--p-radius-control-sm);
    font-size: var(--p-text-xs);
  }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-size="lg"] {
    block-size: var(--p-control-h-lg);
    padding-inline: var(--p-space-6);
    border-radius: var(--p-radius-control-lg);
    font-size: var(--p-text-md);
  }

  button[data-shape="icon"],
  .p-btn[data-shape="icon"] {
    flex: none;
    inline-size: var(--p-control-h);
    padding-inline: 0;
  }

  button[data-shape="icon"][data-size="sm"] { inline-size: var(--p-control-h-sm); }
  button[data-shape="icon"][data-size="lg"] { inline-size: var(--p-control-h-lg); }

  :is(button, .p-btn, [type="button"], [type="submit"], [type="reset"])[data-block] {
    display: flex;
    inline-size: 100%;
  }

  /* Compact controls, generous targets: a transparent hit expansion, only
   * where the pointer is actually coarse. */
  @media (pointer: coarse) {
    button::after,
    .p-btn::after,
    [role="tab"]::after,
    summary::after {
      content: "";
      position: absolute;
      inset-inline: 0;
      inset-block-start: 50%;
      block-size: max(100%, var(--p-hit));
      translate: 0 -50%;
    }

    /* A summary's expanded hit plate paints after its children and would
     * otherwise sit on top of a real control nested in the header. Yield
     * that transparent plate when the summary contains its own button; the
     * summary keeps its visible box while the child keeps its actual target. */
    summary:has(> :is(button, .p-btn, [role="button"]))::after {
      pointer-events: none;
    }
  }

  /* spec: choice-controls */
  /* spec: range-and-file */
  /* =================================================================
   * Fields
   * ================================================================= */
  label {
    display: inline-block;
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-medium);
    color: var(--p-text);
  }

  fieldset {
    display: flex;
    flex-direction: column;
    gap: var(--p-gap-group);
    padding: var(--p-space-6);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-md);
  }

  legend {
    padding-inline: var(--p-space-2);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-semibold);
    color: var(--p-text-strong);
  }

  input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], [type="image"], [type="submit"], [type="button"], [type="reset"]),
  textarea,
  select {
    inline-size: 100%;
    min-inline-size: 0;
    block-size: var(--p-control-h);
    padding-inline: var(--p-space-3);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-control);
    background: var(--p-surface-inset);
    color: var(--p-text-strong);
    font-size: var(--p-text-sm);
    transition:
      border-color var(--p-dur-1) var(--p-ease),
      background-color var(--p-dur-1) var(--p-ease);
  }

  textarea {
    block-size: auto;
    min-block-size: calc(var(--p-control-h) * 2.4);
    padding-block: var(--p-space-3);
    line-height: var(--p-leading-snug);
    resize: vertical;
  }

  /* A select is a control that opens something, not a field you type into.
   * It takes the raised surface and the weight of a button so the two are
   * never confused — inputs stay recessed on --p-surface-inset. */
  select {
    appearance: none;
    padding-inline-end: var(--p-space-8);
    cursor: pointer;
    background-color: var(--p-surface-raised);
    font-weight: var(--p-weight-medium);
    /* Chevron from two gradients so it inherits currentColor and needs no
     * asset — an SVG data URI would hardcode a hex and break theming. */
    background-image:
      linear-gradient(var(--p-border), var(--p-border)),
      linear-gradient(45deg, transparent 50%, currentColor 50%),
      linear-gradient(135deg, currentColor 50%, transparent 50%);
    background-position:
      calc(100% - var(--p-control-h)) 0,
      calc(100% - var(--p-space-5)) calc(50% + 1px),
      calc(100% - var(--p-space-3)) calc(50% + 1px);
    background-size: 1px 100%, 5px 5px, 5px 5px;
    background-repeat: no-repeat;
  }

  select:hover:not(:disabled) {
    background-color: color-mix(in oklab, var(--p-surface-raised) 88%, var(--p-text-strong));
  }

  select[multiple] {
    block-size: auto;
    padding-block: var(--p-space-2);
    background-color: var(--p-surface-inset);
    background-image: none;
    font-weight: var(--p-weight-regular);
  }

  /* Options.
   *
   * In a legacy popup the OS draws the list and only some platforms honour
   * any of this. In select[multiple] it is ours outright, and under
   * base-select below the whole picker is ours. Same declarations serve
   * all three, so the accent shows up wherever the browser lets it. */
  option {
    padding: var(--p-space-2) var(--p-space-3);
    background: var(--p-surface-overlay);
    color: var(--p-text);
  }

  option:checked,
  option[selected] {
    background: var(--p-accent);
    color: var(--p-accent-fg);
    font-weight: var(--p-weight-semibold);
  }

  select[multiple] option {
    border-radius: var(--p-radius-sm);
  }

  select[multiple] option:hover:not(:checked) {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
  }

  /* The picker itself is only styleable where base-select has shipped.
   * Everywhere else the OS popup stands, which is why the plain `option`
   * rules above are not nested in here. */
  @supports (appearance: base-select) {
    select,
    ::picker(select) {
      appearance: base-select;
    }

    select {
      background-image: none;
      justify-content: space-between;
      gap: var(--p-space-3);
      padding-inline-end: var(--p-space-3);
    }

    select::picker-icon {
      color: var(--p-text-muted);
      transition: rotate var(--p-dur-2) var(--p-ease);
    }

    select:open::picker-icon {
      rotate: 180deg;
    }

    ::picker(select) {
      margin-block-start: var(--p-space-1);
      padding: var(--p-space-1);
      border: 1px solid var(--p-border);
      border-radius: var(--p-radius-md);
      background: var(--p-surface-overlay);
      box-shadow: var(--p-shadow-3);
    }

    ::picker(select) option {
      border-radius: var(--p-radius-sm);
      cursor: pointer;
    }

    ::picker(select) option:hover:not(:checked),
    ::picker(select) option:focus:not(:checked) {
      background: var(--p-tint-soft);
      color: var(--p-text-accent);
    }
  }

  input:hover:not(:disabled),
  textarea:hover:not(:disabled),
  select:hover:not(:disabled) {
    border-color: var(--p-border-strong);
  }

  /* Text fields take the ring inside their own border — they are large,
   * recessed and never accent-filled, so an inset ring reads cleanly and
   * does not shift layout.
   *
   * Checkboxes, radios, switches, range and colour are deliberately NOT
   * in this list. They are small and accent-filled when checked, so an
   * inset ring would be drawn on top of the accent and disappear. They
   * keep the offset ring and separator from the base layer.
   *
   * Neither are submit, button and reset, which are buttons in every
   * other rule in this file — the base exclusion list above already
   * says so. They were missing here alone, so a server-rendered form's
   * submit focused with a text field's inset ring while the <button>
   * beside it wore the offset one: same intent, two rings, decided by
   * an attribute nobody thought was styling. */
  input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], [type="image"], [type="submit"], [type="button"], [type="reset"]):focus-visible,
  textarea:focus-visible,
  select:focus-visible {
    border-color: var(--p-accent);
    /* Move the ring inside the border and drop the separator, which has
     * nothing to separate from there. Nothing else is restated: the
     * outline, the glow and the breath all come from the base layer and
     * follow these two values, because the keyframes substitute them per
     * element rather than baking in one geometry. */
    --p-ring-offset: -1px;
    --p-ring-sep: 0px;
  }

  ::placeholder {
    color: var(--p-text-subtle);
    opacity: 1;
  }

  input:user-invalid,
  textarea:user-invalid,
  select:user-invalid,
  [aria-invalid="true"] {
    border-color: var(--p-bad);
  }

  input:disabled,
  textarea:disabled,
  select:disabled,
  button:disabled,
  .p-btn[aria-disabled="true"],
  fieldset:disabled {
    opacity: 0.45;
    cursor: not-allowed;
    pointer-events: none;
  }

  /* A TEXT FIELD you cannot type into. The exclusion list is long because
   * `:read-only` is not "the author set readonly" — it matches any input
   * that is not a text-entry field at all. A range, a colour well and a
   * file button are all `:read-only` by definition, and every one of them
   * was being painted with this recessed, greyed-out surface: the range
   * in the specification had a dark slab behind its track for exactly
   * this reason, which read as a rendering fault rather than a rule.
   *
   * Same list as the base input rule, and for the same reason. */
  input:read-only:not(
    [type="checkbox"], [type="radio"], [type="range"], [type="color"],
    [type="file"], [type="image"], [type="submit"], [type="button"], [type="reset"]
  ) {
    background: var(--p-surface-sunken);
    color: var(--p-text-muted);
  }

  /* ---- checkbox, radio, switch ---------------------------------- */
  [type="checkbox"],
  [type="radio"] {
    appearance: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    position: relative;
    flex: none;
    inline-size: calc(15px * var(--p-fit));
    block-size: calc(15px * var(--p-fit));
    margin: 0;
    border: 1px solid var(--p-border-strong);
    background: var(--p-surface-inset);
    color: var(--p-accent-fg);
    cursor: pointer;
    transition:
      background-color var(--p-dur-1) var(--p-ease),
      border-color var(--p-dur-1) var(--p-ease);
  }

  [type="checkbox"] { border-radius: max(2px, var(--p-radius-xs)); }
  [type="radio"] { border-radius: var(--p-radius-full); }

  [type="checkbox"]:checked,
  [type="checkbox"]:indeterminate,
  [type="radio"]:checked {
    background: var(--p-accent);
    border-color: var(--p-accent);
  }

  /* Tick and dot are drawn from borders, not shipped as assets: an SVG
   * data URI would have to bake a hex and would stop tracking the theme. */
  [type="checkbox"]::before {
    content: "";
    inline-size: 31%;
    block-size: 60%;
    border: 2px solid currentColor;
    border-block-start: 0;
    border-inline-start: 0;
    border-radius: 1px;
    rotate: 42deg;
    translate: 0 -9%;
    scale: 0.5;
    opacity: 0;
    transition:
      opacity var(--p-dur-1) var(--p-ease),
      scale var(--p-dur-1) var(--p-ease);
  }

  [type="checkbox"]:checked::before {
    opacity: 1;
    scale: 1;
  }

  [type="checkbox"]:indeterminate::before {
    inline-size: 56%;
    block-size: 0;
    border: 0;
    border-block-start: 2px solid currentColor;
    rotate: none;
    translate: none;
    scale: 1;
    opacity: 1;
  }

  [type="radio"]::before {
    content: "";
    inline-size: 44%;
    block-size: 44%;
    border-radius: var(--p-radius-full);
    background: currentColor;
    scale: 0;
    transition: scale var(--p-dur-1) var(--p-ease);
  }

  [type="radio"]:checked::before {
    scale: 1;
  }

  [type="checkbox"][role="switch"] {
    inline-size: calc(28px * var(--p-fit));
    block-size: calc(16px * var(--p-fit));
    justify-content: flex-start;
    padding: 2px;
    border-radius: var(--p-radius-full);
  }

  [type="checkbox"][role="switch"]::before {
    inline-size: calc(10px * var(--p-fit));
    block-size: calc(10px * var(--p-fit));
    border: 0;
    border-radius: var(--p-radius-full);
    background: var(--p-text-muted);
    rotate: none;
    translate: none;
    scale: 1;
    opacity: 1;
    transition:
      translate var(--p-dur-2) var(--p-ease),
      background-color var(--p-dur-2) var(--p-ease);
  }

  [type="checkbox"][role="switch"]:checked::before {
    background: var(--p-accent-fg);
    translate: calc(12px * var(--p-fit)) 0;
  }

  /* ---- range ----------------------------------------------------- */
  [type="range"] {
    appearance: none;
    inline-size: 100%;
    block-size: var(--p-control-h);
    margin: 0;
    background: transparent;
    cursor: pointer;
  }

  /* THE TRAVELLED PART IS FILLED. Without it a slider says where the
   * handle is but not which direction means more, which is the one thing
   * the shape is supposed to make obvious.
   *
   * Firefox has a pseudo-element for it. Chromium does not, so there the
   * track is painted with a hard-stopped gradient and `--p-range-at` —
   * set by pulse-controls.js — says where to stop. Without the script the
   * track is simply unfilled, which is where this started. */
  [type="range"] {
    --p-range-at: 0%;
  }

  [type="range"]::-webkit-slider-runnable-track {
    block-size: 4px;
    border-radius: var(--p-radius-full);
    border: 1px solid var(--p-line);
    background:
      linear-gradient(to right,
        var(--p-accent) 0 var(--p-range-at),
        var(--p-surface-inset) var(--p-range-at) 100%);
  }

  [type="range"]::-moz-range-track {
    block-size: 4px;
    border-radius: var(--p-radius-full);
    background: var(--p-surface-inset);
    border: 1px solid var(--p-line);
  }

  [type="range"]::-moz-range-progress {
    block-size: 4px;
    border-radius: var(--p-radius-full);
    background: var(--p-accent);
  }

  [type="range"]::-webkit-slider-thumb {
    appearance: none;
    inline-size: 14px;
    block-size: 14px;
    margin-block-start: -6px;
    border: 2px solid var(--p-accent-fg);
    border-radius: var(--p-radius-full);
    background: var(--p-accent);
    box-shadow: var(--p-shadow-1);
  }

  [type="range"]::-moz-range-thumb {
    inline-size: 14px;
    block-size: 14px;
    border: 2px solid var(--p-accent-fg);
    border-radius: var(--p-radius-full);
    background: var(--p-accent);
    box-shadow: var(--p-shadow-1);
  }

  /* ---- colour and file ------------------------------------------- */
  [type="color"] {
    inline-size: var(--p-control-h);
    block-size: var(--p-control-h);
    padding: 2px;
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-control);
    background: var(--p-surface-inset);
    cursor: pointer;
  }

  [type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
  [type="color"]::-webkit-color-swatch {
    border: 0;
    border-radius: calc(var(--p-radius-control) - 2px);
  }
  [type="color"]::-moz-color-swatch {
    border: 0;
    border-radius: calc(var(--p-radius-control) - 2px);
  }

  [type="file"] {
    font-size: var(--p-text-sm);
    color: var(--p-text-muted);
  }

  ::file-selector-button {
    block-size: var(--p-control-h-sm);
    margin-inline-end: var(--p-space-3);
    padding-inline: var(--p-space-3);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-control-sm);
    background: var(--p-surface-raised);
    color: var(--p-text-strong);
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-semibold);
    cursor: pointer;
  }

  /* =================================================================
   * Tables
   * ================================================================= */
  table {
    inline-size: 100%;
    font-size: var(--p-text-sm);
  }

  /* A caption sits inside the table's own box, so it needs the cells'
   * inline padding or it collides with a rounded corner, and a rule under
   * it or it reads as part of whatever card the table is in. */
  caption {
    padding: var(--p-space-3) var(--p-space-4);
    border-block-end: 1px solid var(--p-line);
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
    text-align: start;
  }

  th {
    padding: var(--p-space-3) var(--p-space-4);
    border-block-end: 1px solid var(--p-border);
    /* The header row is one of the "major structural surfaces" the brief
     * wants the accent present on. */
    background: var(--p-tint-subtle);
    color: var(--p-text);
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-bold);
    letter-spacing: var(--p-tracking-label);
    text-align: start;
    white-space: nowrap;
  }

  td {
    padding: var(--p-space-3) var(--p-space-4);
    border-block-end: 1px solid var(--p-line);
    vertical-align: middle;
  }

  /* Column rules. A dense grid is scanned down as much as across, so the
   * columns get a separator too — routine neutral, weaker than the row
   * lines under the header. */
  :is(th, td):not(:last-child) {
    border-inline-end: 1px solid var(--p-line);
  }

  tbody tr:last-child td {
    border-block-end: 0;
  }

  tbody tr:hover td {
    background: var(--p-wash-hover);
  }

  tr[aria-selected="true"] td,
  tr[data-selected] td {
    background: var(--p-tint-soft);
  }

  /* spec: table-actions */
  /* ---- a cell of controls, not of data -----------------------------
   *
   * Row actions are one group, in one cell, taking only the width their
   * buttons need. Two things go wrong without this and both look
   * deliberate: an ordinary column divides the table's spare width among
   * its cells, so a Restore-and-Clear pair drifts to the middle of a
   * quarter-page column; and splitting the two buttons into two cells
   * draws a column rule between controls that belong together.
   *
   * `inline-size: 1%` is the shrink-wrap idiom for a table cell. A
   * table at `inline-size: 100%` distributes the surplus in proportion
   * to declared widths, so the smallest non-zero number takes as close
   * to none of it as the algorithm allows; `max-content` would be read
   * as a minimum and still grow. Balanced insets rather than the
   * column's own, because the cell is a control group and its edges are
   * now visible.
   *
   * The header cell needs it too, or the header keeps the wide column
   * the body cell just gave up. */
  :is(th, td)[data-cell="actions"] {
    inline-size: 1%;
    padding: var(--p-space-3);
    white-space: nowrap;
  }

  :is(th, td)[data-cell="actions"] > .p-cluster {
    inline-size: max-content;
    gap: var(--p-space-1);
  }

  table[data-density="tight"] :is(th, td) {
    padding: var(--p-space-2) var(--p-space-3);
  }

  table[data-density="relaxed"] :is(th, td) {
    padding: var(--p-space-4) var(--p-space-5);
  }

  /* =================================================================
   * Dialog, popover
   * ================================================================= */
  dialog {
    margin: auto;
    inline-size: min(calc(100vw - var(--p-space-8)), 34rem);
    padding: var(--p-inset);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-lg);
    background: var(--p-surface-overlay);
    color: var(--p-text);
    box-shadow: var(--p-shadow-4);
  }

  dialog::backdrop {
    /* Crisp dim, not atmospheric blur. */
    background: var(--p-scrim);
  }

  /* Closed means gone. Every variant below must therefore scope its own
   * `display` to `[open]` — this rule and a bare `dialog[data-…]` have
   * the same specificity, so the later one simply wins and a closed
   * dialog renders at full size in the middle of the page. That shipped
   * once, in the overview, at 800px tall. */
  dialog:not([open]) {
    display: none;
  }

  [popover] {
    margin: 0;
    padding: var(--p-space-2);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-overlay);
    color: var(--p-text);
    box-shadow: var(--p-shadow-3);
  }

  /* Author layout classes can outrank the browser's closed-popover rule.
   * Firefox then paints a `.p-stack[popover]` permanently in the page even
   * though `:popover-open` is false, so its opener appears to do nothing.
   * State the closed half in Pulse's own cascade; open popovers keep the
   * display chosen by their layout class. `!important` is deliberate for
   * the same reason it is on `[hidden]`: a later cascade layer must not turn
   * an explicitly closed thing back into visible layout. */
  [popover]:not(:popover-open) {
    display: none !important;
  }

  /* spec: disclosure */
  /* =================================================================
   * Disclosure
   * ================================================================= */
  details {
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-raised);
  }

  summary {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    position: relative;
    --p-pad-block: var(--p-space-3);
    padding-inline: var(--p-space-4);
    border-radius: inherit;
    color: var(--p-text-strong);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-semibold);
    list-style: none;
  }

  summary:hover {
    background: var(--p-wash-hover);
  }

  /* A square box with the triangle clipped inside it, rather than the
   * usual zero-size box with borders painted outward. Two reasons, and
   * both of them are things you only notice once:
   *
   *   · A border triangle is 5px wide and 8px tall. Rotating it 90deg
   *     swaps those, so the header gets 3px wider every time it opens.
   *     A square box rotates to itself.
   *   · The polygon's centroid is placed at exactly 50% 50%, so the
   *     rotation pivots on the mark's visual centre instead of swinging
   *     it. Base at 30% and apex at 90% gives (30+30+90)/3 = 50.
   */
  /* :not([data-icon]) because a summary has exactly one ::before and two
   * things want it. Give the summary a data-icon and the icons layer
   * takes over the size, the mask and the fill — but only those, because
   * they are the only properties it names. The caret's `clip-path` and
   * its muted `color` are not conflicts, so they survive onto the icon,
   * and the mask comes out cut to a triangular wedge in the wrong grey.
   *
   * Scoping the caret is the honest fix rather than undoing it further
   * down: `data-icon` on a summary means "draw this instead of the
   * marker", so the marker should not be drawn in the first place. The
   * rotate goes with it — a caret turns a quarter to say the disclosure
   * opened, and an icon that is not a caret has no business spinning. */
  summary:not([data-icon])::before {
    content: "";
    flex: none;
    inline-size: var(--p-caret-size);
    block-size: var(--p-caret-size);
    background: currentColor;
    clip-path: polygon(30% 12%, 30% 88%, 90% 50%);
    color: var(--p-text-muted);
    transition: rotate var(--p-dur-2) var(--p-ease);
  }

  details[open] > summary:not([data-icon])::before {
    rotate: 90deg;
  }

  details > :not(summary) {
    padding: var(--p-space-3) var(--p-space-4) var(--p-space-4);
  }

  /* spec: horizontal-tabs */
  /* spec: pill-tabs */
  /* spec: overflow */
  /* =================================================================
   * Tabs — ARIA-driven, no class contract
   * ================================================================= */
  /* Tabs are one rank. A second row of them reads as a second rank, so
   * they scroll rather than wrap — and the scrollbar is hidden because
   * the overflow is a consequence of the window, not information.
   * scroll-snap and scroll-margin keep a tab that was reached by keyboard
   * from stopping half off the edge. */
  [role="tablist"] {
    display: flex;
    gap: var(--p-space-1);
    border-block-end: 1px solid var(--p-line);
    overflow-x: auto;
    scrollbar-width: none;
    scroll-snap-type: x proximity;
  }

  [role="tablist"]::-webkit-scrollbar { display: none; }

  [role="tab"] {
    position: relative;
    flex: none;
    scroll-snap-align: start;
    scroll-margin-inline: var(--p-space-4);
    --p-pad-block: var(--p-space-3);
    padding-inline: var(--p-space-4);
    border: 0;
    border-radius: var(--p-radius-sm) var(--p-radius-sm) 0 0;
    background: none;
    color: var(--p-text-muted);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-semibold);
    white-space: nowrap;
    cursor: pointer;
  }

  [role="tab"]:hover {
    background: var(--p-wash-hover);
    color: var(--p-text-strong);
  }

  [role="tab"][aria-selected="true"] {
    color: var(--p-text-accent);
    background: var(--p-tint-subtle);
    box-shadow: inset 0 -2px 0 var(--p-accent);
  }

  /* ---- pill tabs ----------------------------------------------------
   * The same control with no rule under it, for a tablist that filters a
   * list rather than switching a page. An underline says "the thing
   * below belongs to me"; a pill row says "pick one of these", which is
   * the honest claim when the panel is a filtered feed. */
  [role="tablist"][data-variant="pill"] {
    border-block-end: 0;
    gap: var(--p-space-2);
  }

  [role="tablist"][data-variant="pill"] [role="tab"] {
    --p-pad-block: var(--p-space-2);
    border-radius: var(--p-radius-control);
    border: 1px solid transparent;
  }

  [role="tablist"][data-variant="pill"] [role="tab"][aria-selected="true"] {
    background: var(--p-tint-soft);
    border-color: var(--p-border-accent);
    box-shadow: none;
  }

  /* ---- vertical tabs ------------------------------------------------
   * aria-orientation is the markup already saying which way this runs,
   * so it is the hook — no second attribute for the same fact. The rule
   * moves to the inline edge with it, and the selected marker with that. */
  [role="tablist"][aria-orientation="vertical"] {
    flex-direction: column;
    align-items: stretch;
    gap: var(--p-space-px);
    border-block-end: 0;
    border-inline-end: 1px solid var(--p-line);
    overflow-x: visible;
    overflow-y: auto;
  }

  [role="tablist"][aria-orientation="vertical"] [role="tab"] {
    justify-content: flex-start;
    text-align: start;
    border-radius: var(--p-radius-sm) 0 0 var(--p-radius-sm);
    white-space: normal;
  }

  [role="tablist"][aria-orientation="vertical"] [role="tab"][aria-selected="true"] {
    box-shadow: inset -2px 0 0 var(--p-accent);
  }

  [role="tabpanel"] {
    padding-block-start: var(--p-space-6);
  }

  /* =================================================================
   * Progress, meter
   * ================================================================= */
  progress {
    appearance: none;
    inline-size: 100%;
    block-size: 6px;
    border: 0;
    border-radius: var(--p-radius-full);
    background: var(--p-surface-inset);
    overflow: hidden;
  }

  progress::-webkit-progress-bar {
    background: var(--p-surface-inset);
    border-radius: var(--p-radius-full);
  }

  progress::-webkit-progress-value {
    background: var(--p-accent);
    border-radius: var(--p-radius-full);
  }

  progress::-moz-progress-bar {
    background: var(--p-accent);
    border-radius: var(--p-radius-full);
  }

  /* =================================================================
   * Text-level
   * ================================================================= */
  hr {
    block-size: 1px;
    background: var(--p-line);
  }

  pre {
    padding: var(--p-space-4);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-inset);
    color: var(--p-text);
    font-size: var(--p-text-sm);
    line-height: var(--p-leading-normal);
    overflow-x: auto;
  }

  pre code {
    font-size: inherit;
  }

  :not(pre) > code {
    padding: 0.1em 0.36em;
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-xs);
    background: var(--p-surface-inset);
    color: var(--p-text-strong);
  }

  kbd {
    display: inline-block;
    min-inline-size: 1.6em;
    --p-pad-block: 0.12em;
    padding-inline: 0.4em;
    border: 1px solid var(--p-border);
    border-block-end-width: 2px;
    border-radius: var(--p-radius-xs);
    background: var(--p-surface-raised);
    color: var(--p-text-strong);
    font-size: var(--p-text-xs);
    text-align: center;
  }

  blockquote {
    padding-inline-start: var(--p-space-4);
    border-inline-start: 2px solid var(--p-border-accent);
    color: var(--p-text-muted);
  }

  mark {
    padding: 0 0.2em;
    border-radius: var(--p-radius-xs);
    background: color-mix(in oklab, var(--p-warn) 34%, transparent);
    color: var(--p-text-strong);
  }

  abbr[title] {
    text-decoration: underline dotted;
    text-underline-offset: 0.2em;
    cursor: help;
  }
}
/* Pulse — patterns
 *
 * Compound pieces that native HTML has no element for. Class-based, `p-`
 * prefixed, and deliberately few: if semantic HTML plus a data-* variant
 * can express it, it does not belong here.
 *
 * Adding a pattern is additive — a new block, no changes above.
 */
@layer pulse.patterns {
  /* spec: layout-primitives */
  /* spec: grid */
  /* =================================================================
   * Layout primitives
   * ================================================================= */
  .p-stack {
    display: flex;
    flex-direction: column;
    gap: var(--p-gap-group);
  }

  .p-stack[data-gap="tight"] { gap: var(--p-gap-tight); }
  .p-stack[data-gap="sm"] { gap: var(--p-gap); }
  .p-stack[data-gap="lg"] { gap: var(--p-gap-section); }

  .p-cluster {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--p-gap);
  }

  .p-cluster[data-gap="tight"] { gap: var(--p-gap-tight); }
  .p-cluster[data-gap="lg"] { gap: var(--p-gap-group); }
  .p-cluster[data-wrap="nowrap"] { flex-wrap: nowrap; }
  .p-cluster[data-justify="between"] { justify-content: space-between; }
  .p-cluster[data-justify="end"] { justify-content: flex-end; }

  /* spec: action-rows */
  /* ---- action row -------------------------------------------------
   * A cluster that places its children by what they do. Write the
   * buttons in any order and they land where they belong: the ways out
   * on the left, the ways forward on the right, the commit last.
   *
   *     <div class="p-actions">
   *       <button data-intent="danger">Delete</button>
   *       <button data-intent="cancel">Cancel</button>
   *       <button data-intent="commit" data-variant="primary">Save</button>
   *     </div>
   *
   * Left-hand placement for destructive and cancel is a distance rule,
   * not a taste one: the pointer travels to the commit button, and an
   * irreversible action should not be sitting next to where it lands.
   *
   * `order` does not move focus order, so DOM order still has to match.
   * That is deliberate — the rule below is a safety net that fixes the
   * appearance, and `pulse check` reports the markup so the tab order
   * gets fixed too. Anything that looked right while tabbing wrong
   * would be worse than no rule at all.
   * ------------------------------------------------------------------ */
  .p-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-end;
    gap: var(--p-gap-tight);
  }

  .p-actions > * { order: 3; }
  .p-actions > [data-intent="danger"] { order: 1; }
  .p-actions > [data-intent="cancel"] { order: 2; }
  .p-actions > [data-intent="proceed"] { order: 4; }
  .p-actions > [data-intent="commit"] { order: 5; }

  /* The gap between the two sides is an auto margin on whichever
   * left-hand item ends up last, so the row needs no spacer element and
   * degrades to a plain right-aligned cluster when there is no left
   * side at all. */
  .p-actions > [data-intent="cancel"],
  .p-actions:not(:has(> [data-intent="cancel"])) > [data-intent="danger"] {
    margin-inline-end: auto;
  }

  .p-actions[data-align="start"] { justify-content: flex-start; }

  /* A CONFIRMATION IS THE ONE ROW THAT SPREADS. Everywhere else the row
   * is a cluster of endings at the trailing edge, weighted so the commit
   * is last. A confirmation has no cluster to make: there are exactly two
   * answers, one of them cannot be undone, and both of them end the
   * dialog. So they go to opposite corners — the destructive one to the
   * leading corner, the way out to the trailing corner where the pointer
   * already is.
   *
   * That is the reverse of the ranking above, and deliberately: here the
   * safe answer is the expected one. Ranking the destructive action last
   * puts it under the pointer's resting place and next to the button the
   * reader is most likely to be aiming for, which is the one arrangement
   * a confirmation exists to avoid. Distance is the whole mechanism, and
   * a corner is as far as a row can put it.
   *
   * Marking the ROW rather than the button keeps that a property of the
   * situation, which is what it is. */
  .p-actions[data-confirm] > [data-intent="danger"] {
    order: 1;
    margin-inline-end: auto;
  }

  .p-actions[data-confirm] > [data-intent="cancel"] {
    order: 5;
    margin-inline-end: 0;
  }

  /* AND THE TWO ANSWERS ARE THE SAME WEIGHT. Everywhere else the cancel
   * is deliberately the quietest thing in the row, because the row has a
   * commit it should not compete with. Here it has no such neighbour: it
   * is one of two answers, standing in the opposite corner, and an
   * outlined word beside a filled button does not read as the other
   * option — it reads as smaller. Give it a fill of its own.
   *
   * A WASH, not a surface token. `--p-tone-surface` for the cancel intent
   * is `--p-surface-raised`, which on a dark theme is a step up from the
   * dialog and on a light one is the same white the dialog already is —
   * so the fix worked in one mode and did nothing in the other. A wash is
   * a percentage of the text colour, so it moves away from whatever it is
   * painted on in both directions. Not a colour, either: the point is
   * equal presence, not equal loudness.
   *
   * Only where the author has not already chosen a variant. */
  .p-actions[data-confirm] > [data-intent="cancel"]:not([data-variant]) {
    background-color: var(--p-wash-hover);
    color: var(--p-text-strong);
  }

  .p-actions[data-confirm] > [data-intent="cancel"]:not([data-variant]):hover {
    background-color: var(--p-wash-active);
  }

  /* In a dialog the row is the floor of the box, so it owns the gap
   * above itself rather than making every caller remember one. */
  :is(dialog, dialog > form) > .p-actions {
    margin-block-start: var(--p-gap-group);
  }

  /* A panel has no padding of its own — the head and the body each carry
   * theirs — so an action row dropped straight into one has to bring its
   * own or it sits flush against the border. A card already has padding
   * and is deliberately left alone. */
  .p-panel > .p-actions {
    padding: var(--p-space-4);
  }

  /* The body's own bottom padding is already the gap. Two would read as
   * a mistake rather than as breathing room. */
  .p-panel-body + .p-actions {
    padding-block-start: 0;
  }

  .p-grid {
    --p-grid-fit: auto-fill;
    display: grid;
    gap: var(--p-gap-group);
    grid-template-columns: repeat(var(--p-grid-fit), minmax(min(var(--p-grid-min, 15rem), 100%), 1fr));
  }

  .p-grid[data-fill] { --p-grid-fit: auto-fit; }

  /* Reading width is a local concern — primary regions stay edge to edge. */
  .p-prose {
    max-inline-size: 68ch;
  }

  .p-prose > * + * {
    margin-block-start: var(--p-space-6);
  }

  .p-prose :is(h1, h2, h3) {
    margin-block-start: var(--p-space-12);
  }

  .p-prose {
    line-height: var(--p-leading-normal);
  }

  /* spec: page-column */
  /* spec: preference-profiles */
  /* Shortcut collisions are row problems, not just form problems. Mark every
   * involved row so the reader can find both sides without scanning the table. */
  tr[data-conflict="true"] {
    background: var(--p-bad-surface-subtle);
    color: var(--p-bad-text);
  }

  tr[data-conflict="true"] > :first-child {
    box-shadow: inset 3px 0 0 var(--p-bad);
  }

  /* ---- .p-page — a destination's readable column -------------------
   *
   * Settings, account, about: a full-screen destination whose content is
   * a column of groups rather than a workspace. Left against the edge of
   * a 1600px window, that column reads as a page that failed to load the
   * rest of itself, and its fields end up a metre from the tabs that
   * switch them.
   *
   * Every screen that needed it wrote `max-inline-size` and
   * `margin-inline: auto` by hand, including Pulse's own worked settings
   * screen — which is the definition of a missing primitive rather than
   * a project preference.
   *
   * The column centres; what is inside it does not. Field labels, table
   * cells and headings all stay start-aligned, because centred prose is
   * harder to read and centred labels lose the edge the eye returns to.
   * The one exception is the navigation that switches pages, which sits
   * on the column's own axis. */
  .p-page {
    inline-size: min(100%, var(--p-page-measure, 60rem));
    margin-inline: auto;
  }

  .p-page[data-measure="wide"] { --p-page-measure: 72rem; }
  .p-page[data-measure="narrow"] { --p-page-measure: 44rem; }

  /* SAFE center, not center. Both of these scroll rather than wrap, and
   * `justify-content: center` on a scroll container overflows it equally at
   * both ends — but only the end side is reachable. Scrolling starts at the
   * start edge, so whatever centring pushed off that edge sits in space the
   * container can never scroll to: the first destinations are on screen at
   * 1600px and gone, unreachably, on a phone. SpoofySort's settings row lost
   * "Keyboard shortcuts" down to "tcuts".
   *
   * `safe` says centre only while it fits, and fall back to start alignment
   * the moment it does not. The row stays centred on the axis of the column
   * it belongs to, and stays completely scrollable when it outgrows it.
   *
   * Not `flex-wrap: wrap`, which is the other obvious answer and is wrong
   * here: tabs are one rank, and a second row of them reads as a rank below
   * the first. See the overflow note on the tablist itself. */
  .p-page > :is([role="tablist"], .p-nav[data-variant="underline"]) {
    justify-content: safe center;
  }

  /* spec: section */
  /* spec: section-fields */
  /* spec: dependent-settings */
  /* ---- .p-section — one titled group inside a page column ----------
   *
   * `.p-page` gave the column a width and stopped. What actually fills
   * it — a heading, a line saying what the group is for, then its
   * fields — had no pattern, so every settings screen rebuilt it. AIUI
   * invented `.section` in seven components, `.intro` in five and
   * `.fields` in three, and they did not agree with each other: the
   * field grid was 13rem in one file and 15rem in another.
   *
   * The giveaway was the spacing. Every one of them ended up writing
   *
   *     .section-note { margin-block-start: calc(-1 * var(--p-space-2)); }
   *
   * a negative margin dragging the note back up under its title,
   * because a stack had spaced them as two things when they are one.
   * Pulse's own worked example for `.p-page` had the same problem and
   * papered over it with two inline `margin: 0` declarations — a
   * missing primitive is exactly what it looks like when the
   * specification has to cheat in the same place its readers do.
   *
   * So the head is one block. Title and note sit tight together at
   * `--p-gap-tight`, and the group gap falls between the head and the
   * fields, where the eye expects the break. Nothing needs a negative
   * margin because nothing was spaced wrongly in the first place. */
  .p-section {
    display: flex;
    flex-direction: column;
    gap: var(--p-gap-group);
    min-inline-size: 0;
  }

  .p-section-head {
    display: flex;
    flex-direction: column;
    gap: var(--p-gap-tight);
  }

  /* The heading keeps its size from the type scale and loses only its
   * margins — the head owns the spacing now. */
  .p-section-head > :is(h1, h2, h3, h4, h5, h6) { margin: 0; }

  .p-section-head > p {
    margin: 0;
    color: var(--p-text-muted);
    font-size: var(--p-text-sm);
    line-height: var(--p-leading-normal);
  }

  /* Fields side by side, with ONE measure. `--p-fields-min` is the knob
   * the two disagreeing projects were each reaching for privately; a
   * settings field wants to be narrower than a card, so this is not
   * `.p-grid`'s 15rem. */
  .p-fields {
    display: grid;
    gap: var(--p-gap);
    grid-template-columns:
      repeat(auto-fit, minmax(min(var(--p-fields-min, 14rem), 100%), 1fr));
  }

  /* ---- .p-depends — settings that belong to a switch ---------------
   *
   * Nearly every settings screen has one: a switch, and under it the
   * handful of settings that only mean anything while it is on. Pulse
   * had no pattern for it, so it arrived hand-built with the same
   * negative margin as the section note above.
   *
   * The dependents DIM rather than disappearing. Hiding them means
   * nobody discovers what the switch is for until they flip it, and
   * jumps the page height on every toggle. Dimmed, they stay legible,
   * so the screen still says what the switch would turn on.
   *
   * But they are genuinely INACTIVE while dim, not merely faded. The
   * first cut of this pattern kept them operable, on the theory that
   * settings could be arranged before the switch that activates them —
   * which reads, correctly, as a control that looks off and behaves on.
   * `pointer-events` is only half of it; `pulse-controls.js` puts
   * `inert` on the same block so the keyboard agrees, because a Tab that
   * lands on a control nobody can click is worse than either extreme.
   * Without that module the dim is honest but the Tab order is not. */
  .p-depends {
    display: flex;
    flex-direction: column;
    gap: var(--p-gap-group);
  }

  .p-depends-on {
    padding-inline-start: var(--p-space-8);
    transition: opacity var(--p-dur-3) var(--p-ease);
  }

  /* Scoped to a DIRECT child label so a checked box among the dependents
   * cannot pass for the switch that governs them. */
  .p-depends:has(> :is(label, .p-check) input:not(:checked)) > .p-depends-on {
    opacity: var(--p-depends-off, .30);
    pointer-events: none;
  }

  /* spec: app-shell */
  /* spec: side-min */
  /* =================================================================
   * App shell — edge-to-edge, user-resizable regions.
   * A project persists --p-sidebar-w itself; Pulse only enforces limits.
   * ================================================================= */
  /* ---- how narrow a rail may get -----------------------------------
   *
   * The floor is a rail, not a sidebar. 11rem was chosen when the only
   * side was a list of labelled links, and it silently forbade the other
   * shape a side can take: an icon rail with a caption under each icon,
   * which is what an app with a dozen destinations reaches for. The
   * ceiling still stops a sidebar eating the page.
   *
   * BUT A FLOOR THAT SUITS AN ICON RAIL BREAKS A SIDEBAR WITH FURNITURE
   * IN IT. Dragged to 3.5rem, a rail carrying a search field puts the
   * field's own min-content at 4.9rem — so the filter button ends up
   * painted over the workspace, and the nav labels clip mid-word. The
   * sidebar does not read as deliberately narrow; it reads as broken.
   *
   * So the floor is a token, and it rises on its own when the rail
   * contains something that cannot become an icon. `.p-side-tools` is
   * exactly that thing, and 11rem is the width a split panel already
   * uses for the same reason: enough for a real control and a label.
   *
   * A project that knows better sets `--p-side-min` itself. */
  .p-app {
    --p-side-min: 3.5rem;
    --p-side-end-min: 3.5rem;
    display: grid;
    grid-template-columns: clamp(var(--p-side-min), var(--p-sidebar-w, 15rem), 26rem) minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr);
    grid-template-areas:
      "header header"
      "side   main";
    block-size: 100svh;
    overflow: hidden;
  }

  /* An embedded shell borrows the height its containing workspace gives it.
   * It keeps the same grid, side rails and overflow contract as the page
   * shell, but must never claim the browser viewport for itself. The zero
   * minimum lets it shrink inside an outer grid/flex main rather than
   * forcing that parent to grow around its 100svh default. */
  .p-app[data-embedded] {
    block-size: 100%;
    min-block-size: 0;
  }

  .p-app[data-embedded] > main {
    min-block-size: 0;
    overflow: auto;
  }

  .p-app:has(> .p-side > .p-side-tools) { --p-side-min: 11rem; }
  .p-app:has(> .p-side-end > .p-side-tools) { --p-side-end-min: 11rem; }

  .p-app > header { grid-area: header; }
  .p-app > .p-side { grid-area: side; }
  .p-app > .p-side-end { grid-area: side-end; }

  /* The shell's side is the same adjustable edge as a split panel. It
   * establishes the grip's containing block; pulse-controls writes the
   * track variable on `.p-app` when `data-grip-target` names it. */
  .p-app > :is(.p-side, .p-side-end) { position: relative; }
  /* Locked while an overlay is up. The document is not always the thing
   * that scrolls — a shell puts the scroll on its own regions, so
   * locking the root alone leaves these scrolling behind the overlay
   * with their scrollbars still drawn at the edge of the screen, which
   * is the same fault one level down.
   *
   * IN THIS LAYER, NOT THE BASE ONE, because `.p-app > main` sets
   * `overflow: auto` here and patterns beats base whatever the
   * specificity. Fourth time in this file that layer order, rather than
   * specificity, was the reason a rule did nothing.
   *
   * Named regions rather than a blanket rule: the overlay's own body
   * must keep scrolling, and anything broad enough to catch every
   * scroll container catches that one too. Every region Pulse gives a
   * scroll of its own belongs in this list — the shell's three, the
   * sidebar's inner scrollport, and the split view's two. Add a
   * scrolling pattern, add it here. */
  html:has(dialog:modal) :is(.p-app > main, .p-app > .p-side,
                             .p-app > .p-side-end, .p-side-scroll,
                             .p-split-main, .p-split-body),
  html:has([data-drawer][data-open]) :is(.p-app > main, .p-app > .p-side,
                                         .p-app > .p-side-end, .p-side-scroll,
                                         .p-split-main, .p-split-body),
  html:has(.p-app[data-side-open]) :is(.p-app > main, .p-app > .p-side,
                                       .p-app > .p-side-end, .p-side-scroll,
                                       .p-split-main, .p-split-body),
  html:has(.p-app[data-side-end-open]) :is(.p-app > main, .p-app > .p-side,
                                           .p-app > .p-side-end, .p-side-scroll,
                                           .p-split-main, .p-split-body) {
    overflow: hidden;
  }

  /* The well the shell is built around. */
  .p-app > main {
    grid-area: main;
    overflow: auto;
    background: var(--p-fill-sunken);
  }

  .p-app[data-side="right"] {
    grid-template-columns: minmax(0, 1fr) clamp(var(--p-side-min), var(--p-sidebar-w, 15rem), 26rem);
    grid-template-areas:
      "header header"
      "main   side";
  }

  /* No side means no empty side track. `:has(> .p-side)` contributes a
   * class to specificity, so both arms live after the right-side variant;
   * otherwise that variant wins at equal weight and strands its empty
   * track on the right instead of the left. */
  .p-app:not(:has(> .p-side)),
  .p-app[data-side="right"]:not(:has(> .p-side)) {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "header"
      "main";
  }

  .p-side,
  .p-side-end {
    display: flex;
    flex-direction: column;
    gap: var(--p-gap-tight);
    padding: var(--p-space-3);
    border-inline-end: 1px solid var(--p-line);
    /* Furniture sits on top of the floor — see "Surfaces". A sidebar
     * feels secondary, which is exactly why this reads backwards at
     * first: the reader looks AT the content and WITH the sidebar. */
    background: var(--p-fill-raised);
    overflow: auto;
  }

  .p-app[data-side="right"] > .p-side {
    border-inline: 1px solid var(--p-line) none;
  }

  /* spec: sidebar-tools-over-a-scrolling-list */
  /* ---- fixed furniture over a scrolling result list -----------------
   *
   * `.p-side` scrolls as one box, which is right for a list of links and
   * wrong the moment the sidebar carries its own search, filters or a
   * create button: those scroll away from the rows they act on. Worse,
   * and much harder to see, is what it does to a resize grip. The grip is
   * absolutely positioned inside the side, so a scrollbar shrinks the
   * padding box that positions it and drags its hit target ~13px inboard
   * of the edge the reader can see. Grabbing the visible edge then does
   * nothing at all. Short demo sidebars never scroll, so both faults
   * shipped; SpoofySort hit them with 22 playlists in the rail.
   *
   * The repair is structural rather than a z-index or an inset tweak:
   * move the scroll one level in. `.p-side-scroll` owns the overflow,
   * `.p-side-tools` stays above it, and the grip is left as a
   * non-scrolling child of a panel that no longer scrolls.
   *
   * The side gives up its own padding and overflow ONLY when it contains
   * one of these, so every sidebar written before today behaves exactly
   * as it did. */
  :is(.p-side, .p-side-end):has(> .p-side-scroll) {
    padding: 0;
    overflow: visible;
  }

  .p-side-tools {
    flex: none;
    display: flex;
    flex-direction: column;
    gap: var(--p-gap-tight);
    padding: var(--p-space-3);
    border-block-end: 1px solid var(--p-line);
    /* The floor above keeps this readable; these two keep it CONTAINED
     * if a project lowers the floor anyway. Without them a search field
     * refuses to shrink past its own min-content and the control on its
     * trailing edge is painted over the workspace — the side cannot clip
     * it, because `overflow: visible` is what lets the resize grip sit
     * half outside the panel. So the strip clips itself. */
    min-inline-size: 0;
    overflow: clip;
  }

  .p-side-tools > * {
    min-inline-size: 0;
  }

  .p-side-scroll {
    flex: 1;
    /* Without this the flex item takes its content's height as a floor
     * and the scroll never engages — the whole column grows instead. */
    min-block-size: 0;
    display: flex;
    flex-direction: column;
    gap: var(--p-gap-tight);
    padding: var(--p-space-3);
    overflow: auto;
  }

  /* spec: two-rails */
  /* ---- the second rail ----------------------------------------------
   *
   * Navigation on one side, inspector or activity on the other, with the
   * workspace between them and each rail sized by its own grip. Pulse had
   * one `.p-side` track and one width token, so the second rail could
   * only be project CSS outside the shell — which then had to reinvent
   * the drawer, the scrim and the narrow-width behaviour, none of which
   * survives contact with a phone.
   *
   * A separate class rather than nth-child: which rail is which is a fact
   * about the markup's meaning, and a selector that counts children
   * silently reassigns both when somebody inserts an element. */
  .p-app[data-side="both"] {
    grid-template-columns:
      clamp(var(--p-side-min), var(--p-sidebar-w, 15rem), 26rem)
      minmax(0, 1fr)
      clamp(var(--p-side-end-min), var(--p-sidebar-w-end, 18rem), 30rem);
    grid-template-areas:
      "header header header"
      "side   main   side-end";
  }

  /* The rail's line is on the edge it faces, not the one it sits against. */
  .p-side-end {
    border-inline: 1px solid var(--p-line) none;
  }

  /* Declared both-sided with only one rail present. Same reasoning as the
   * no-side arms above: an empty track is worse than a missing one. */
  .p-app[data-side="both"]:not(:has(> .p-side-end)) {
    grid-template-columns: clamp(var(--p-side-min), var(--p-sidebar-w, 15rem), 26rem) minmax(0, 1fr);
    grid-template-areas:
      "header header"
      "side   main";
  }

  .p-app[data-side="both"]:not(:has(> .p-side)) {
    grid-template-columns: minmax(0, 1fr) clamp(var(--p-side-end-min), var(--p-sidebar-w-end, 18rem), 30rem);
    grid-template-areas:
      "header header"
      "main   side-end";
  }

  /* Responsive: reprioritise rather than shrink. The primary task keeps the
   * viewport; secondary regions move behind a control. */
  @media (max-width: 60rem) {
    /* Every arm of the shell collapses, including the two-sided ones.
     * They are listed by name because `[data-side="both"]` outweighs a
     * bare `.p-app` wherever both match, so the plain selector alone
     * would leave a three-column grid on a phone. */
    .p-app,
    .p-app[data-side="right"],
    .p-app[data-side="both"],
    .p-app[data-side="both"]:not(:has(> .p-side-end)),
    .p-app[data-side="both"]:not(:has(> .p-side)) {
      grid-template-columns: minmax(0, 1fr);
      grid-template-areas:
        "header"
        "main";
    }

    .p-app > :is(.p-side, .p-side-end) {
      position: fixed;
      inset-block: 0;
      z-index: var(--p-z-overlay);
      inline-size: min(20rem, 84vw);
      box-shadow: var(--p-shadow-4);
      transition: translate var(--p-dur-3) var(--p-ease);
    }

    .p-app > .p-side {
      inset-inline-start: 0;
      translate: -101% 0;
    }

    .p-app[data-side-open] > .p-side {
      translate: 0 0;
    }

    /* The second rail arrives from the edge it lives on, and answers to
     * its own flag — two rails sharing one open attribute would open
     * together, which is exactly the horizontal overflow the two-sided
     * shell exists to avoid. */
    .p-app > .p-side-end {
      inset-inline: auto 0;
      translate: 101% 0;
    }

    .p-app[data-side-end-open] > .p-side-end {
      translate: 0 0;
    }

    .p-app:not([data-side-end-open]) > .p-side-end {
      box-shadow: none;
    }

    /* ---------------------------------------------------------------
     * The scrim
     *
     * A panel lying over the page owns every click outside itself. It
     * closes on one, and that click must not reach whatever it landed
     * on — a tap meant to dismiss a drawer that also opens a link is
     * the worst outcome available, because the reader gets a page they
     * did not ask for and no way to know why.
     *
     * A PSEUDO-ELEMENT, NOT A DIV. Nothing to add to the markup, nothing
     * to forget, and nothing to leave behind when the drawer is removed.
     *
     * IT CANNOT LIVE ON THE PANEL, which is where it used to be and where
     * it reads best. The panel slides with `translate`, and a transformed
     * element is the containing block for its own `position: fixed`
     * descendants — so `inset: 0` resolved against the drawer and the
     * scrim came out exactly the size of the drawer already covering that
     * area. It dimmed nothing, and a press outside the panel reached
     * whatever was under it: the tap-dismiss-that-also-opens-a-link this
     * comment warns about, caused by the code the comment was attached
     * to. Silent in every project using a drawer, because Escape and the
     * close button still worked. AIUI found it.
     *
     * The root element is never transformed, so its pseudo-element is the
     * one place a scrim can be certain of the viewport. It stays in the
     * document at zero opacity rather than being created by the `:has()`
     * match, because a pseudo-element that springs into existence cannot
     * transition. Clicks land on <html>, which is outside every panel, so
     * pulse-overlay.js's rect test reads them correctly.
     *
     * ONLY INSIDE THIS MEDIA QUERY, which is the whole point. Above
     * 60rem these panels are ordinary columns; a scrim that stayed
     * behind would cover a page nobody was covering and swallow every
     * click on it. A project with its own breakpoint must scope its own
     * open flag the same way. */
    html::before {
      content: "";
      position: fixed;
      inset: 0;
      /* Under the drawer (`--p-z-modal`) and under the app's own side
       * panel (`--p-z-overlay`), over everything either of them covers. */
      z-index: calc(var(--p-z-overlay) - 1);
      background: var(--p-scrim);
      opacity: 0;
      pointer-events: none;
      transition: opacity var(--p-dur-3) var(--p-ease);
    }

    html:has(:is([data-drawer][data-open],
                 .p-app[data-side-open] > .p-side,
                 .p-app[data-side-end-open] > .p-side-end))::before {
      opacity: 1;
      pointer-events: auto;
    }

    /* The panel keeps its shadow while parked off-canvas, which lays a
     * soft dark band down the edge of a page that has no drawer over it.
     * Reported alongside the scrim, from the same screen. */
    :is([data-drawer], .p-app > .p-side, .p-app > .p-side-end):not([data-open]) {
      box-shadow: none;
    }

    .p-app:not([data-side-open]) > .p-side {
      box-shadow: none;
    }

    /* A drawer marked as one, but positioned by its own project. Pulse
     * supplies the scrim and the dismissal; where it sits and how wide
     * it is stay with whoever laid the page out. */
    [data-drawer] {
      z-index: var(--p-z-modal);
    }

    /* spec: phone-panels */
    /* ---------------------------------------------------------------
     * THE PANEL ARM — the other answer to a shell on a phone.
     *
     * Everything above turns a rail into a drawer that lies OVER the
     * workspace. That is right when the rail is a detour: open it, pick
     * something, and it goes away. It is wrong when the rail is one of
     * the things the reader came for — a two-rail workspace whose
     * playlists, songs and activity are three views of one job, where a
     * drawer means the thing you are comparing against is the thing you
     * just covered up.
     *
     * `data-panels` opts into the other arrangement: the rails come back
     * into the flow, below the workspace, and exactly one of them is on
     * screen at a time with a segmented control saying which. SpoofySort
     * asked for both, a day apart, and the drawer is still the default —
     * this is opt-in because a rail that IS a detour should stay one.
     *
     * The order matters and is not the DOM's: a rail is written before
     * or after `<main>` because that is where it belongs on a desktop,
     * and here they all belong underneath it. `order` says so without a
     * project having to rewrite its markup for one breakpoint.
     *
     * Which panel is showing is `data-panel-open`, written by
     * pulse-panels.js. Not the value of `data-panels`, because CSS
     * cannot match an attribute against another attribute's value and a
     * rule per panel name is a rule nobody can write in advance. */
    .p-app[data-panels] {
      display: flex;
      flex-direction: column;
    }

    .p-app[data-panels] > header {
      order: 0;
      flex: none;
    }

    .p-app[data-panels] > main {
      order: 1;
      display: flex;
      flex-direction: column;
      min-block-size: 0;
      /* Only as tall as the card and the switch, unless the panel it is
       * holding is the one on screen. */
      flex: none;
    }

    /* With no persistent primary content, `<main>` can be one of the
     * switchable peers itself. `:has()` cannot match the element it is on,
     * so the ordinary child-panel sizing rule above never reaches it. */
    .p-app[data-panels] > main[data-panel] {
      flex: 1;
      min-block-size: 0;
    }

    .p-app[data-panels] > main:has([data-panel][data-panel-open]) {
      flex: 1;
    }

    /* A panel written straight into <main> fills what main was given.
     * One nested inside a stack or a grid cannot be sized from here —
     * the wrapper between them is the project's and Pulse has no way to
     * know what it is — so that case gets its natural height and the
     * project grows its own wrapper if it wants more. */
    .p-app[data-panels] > main > [data-panel] {
      flex: 1;
      min-block-size: 0;
    }

    /* Back into the flow. Every one of these undoes a drawer rule above,
     * because a panel and a drawer are the same element wearing two
     * different answers to the same question. */
    .p-app[data-panels] > :is(.p-side, .p-side-end) {
      order: 2;
      position: static;
      inset: auto;
      inline-size: auto;
      min-inline-size: 0;
      flex: 1;
      min-block-size: 0;
      translate: none;
      box-shadow: none;
      z-index: auto;
      transition: none;
    }

    /* A DESCENDANT SELECTOR, NOT A CHILD ONE. The rails are children of
     * the shell, but main's own region is however deep the project's
     * markup puts it — inside a stack, a grid, a wrapper div. Written as
     * `> main > [data-panel]` this hid nothing at all on the first
     * project to nest one, and two regions were on screen together with
     * the switch insisting one of them was not.
     *
     * The switch's own buttons carry `data-panel` too, since that is how
     * they name what they choose, so they are excluded by hand. */
    .p-app[data-panels] [data-panel]:not([data-panel-open], [data-panel-switch] > *) {
      display: none;
    }

    /* The switch spans the width it is given. Three destinations in a
     * row on a phone want the whole line; the control's own inline-flex
     * would leave them huddled at the leading edge. */
    .p-app[data-panels] [data-panel-switch] {
      display: flex;
      inline-size: 100%;
    }

    .p-app[data-panels] [data-panel-switch] > button {
      flex: 1;
      min-inline-size: 0;
    }
  }

  /* spec: window-modal */
  /* spec: modal-parts */
  /* spec: screen-modal */
  /* spec: confirmation */
  /* =================================================================
   * Screen Modal.
   *
   * TWO KINDS, AND THE DIFFERENCE IS WHAT THE READER IS DOING.
   * The default `dialog` is a DETOUR: confirm a delete, edit three
   * fields, pick a date. It stays a window because seeing the page
   * around it is the reassurance that you have not gone anywhere.
   * `data-size="full"` is a PLACE: an editor, a viewer, an import with
   * steps. A sliver of page behind that is a distraction from work with
   * the reader's whole attention, and the margins are width it could use.
   *
   * IN THE PATTERNS LAYER, NOT WITH THE OTHER DIALOG RULES, and that is
   * load-bearing rather than filing. `.p-panel-body + .p-actions` zeroes
   * the action row's top padding — right for a card, wrong here — and it
   * lives in this layer. A Screen Modal rule written beside `dialog` in
   * the elements layer loses to it no matter how specific it is, which
   * is precisely what happened: the footer came out flush against its
   * own border.
   *
   * It reuses the panel's parts rather than inventing a second
   * vocabulary for head, body and actions — but not the panel's SCALE. A
   * head measured for a card is a thirteen-pixel label stranded on a
   * thousand-pixel surface, and eight pixels of inset that reads as
   * comfortable on a card reads as broken edge-to-edge.
   * ================================================================= */
  /* ---- both kinds: the scroll goes BETWEEN the head and the actions --
   * A dialog that scrolls as a whole takes its title and its buttons off
   * the screen with the content, and puts the scrollbar down the outside
   * edge of the window — so the reader loses the one control that ends
   * the task, and the bar they are dragging belongs to the frame rather
   * than to what they are reading.
   *
   * Opt in by structure rather than by attribute: a dialog built from
   * the panel's parts is asking for this, and one that is three lines
   * and two buttons is not. */
  /* THE PARTS MAY SIT INSIDE A <form>. Submitting a modal is the normal
   * case, so a form is usually the dialog's only child and the head,
   * body and actions are its children instead. Everything below accepts
   * either shape rather than making the markup work around the
   * stylesheet. */
  /* EVERY dialog, not only the ones built from panel parts. A dialog that
   * is a title, a sentence and two buttons still has an action row in its
   * bottom corners, and those corners have to be the same distance in as
   * a Window Modal's or the two read as different components. Below, the
   * bare kind spends this on a negative margin instead of on padding. */
  dialog {
    --p-dialog-inset: var(--p-inset-sm);
  }

  dialog:has(.p-panel-body) {
    /* One inset for every dialog, of either kind. The head, the body and
     * the action row all read it, so their contents line up on one edge
     * and every corner stays square — and a window and a screen frame
     * their contents identically, which is what makes them read as two
     * sizes of the same thing rather than two components.
     *
     * It is also the size control: the corner rule ties a head's height
     * to it, since a head is one control tall plus the inset above and
     * below. Changing the chrome is changing this number. */
    --p-dialog-inset: var(--p-inset-sm);
    padding: 0;
    /* The body is the only row allowed to grow, so the dialog stops at
     * the viewport instead of running past the bottom of it. */
    max-block-size: min(44rem, calc(100dvh - var(--p-space-8) * 2));
    overflow: clip;
  }

  /* `[open]` is load-bearing: `dialog:not([open])` sets display:none and
   * a display here at equal-or-higher specificity simply beats it,
   * leaving a closed dialog painted over the page. That shipped once. */
  /* THE COLUMN IS AS LOAD-BEARING AS THE ROWS.
   *
   * Only the rows were declared, so the implicit single column sized to
   * the widest child's max-content — and `inline-size: 100%` on the
   * dialog cannot stop it, because a grid item's max-content contribution
   * beats a percentage on the container. AIUI measured a Screen Modal at
   * 493px on a 390px phone: a head carrying a title, a badge and two
   * buttons, with the action row hanging off the side with everything
   * else. Their two simpler Screen Modals sat at exactly 390, which is
   * what made it look content-specific rather than a missing line. */
  dialog[open]:has(.p-panel-body) {
    display: grid;
    /* One row when a form is in between; the form takes the three. */
    grid-template-rows: minmax(0, 1fr);
    grid-template-columns: minmax(0, 1fr);
  }

  :is(dialog[open], dialog > form):has(> .p-panel-body) {
    display: grid;
    grid-template-rows: auto minmax(0, 1fr) auto;
    grid-template-columns: minmax(0, 1fr);
    min-block-size: 0;
  }

  /* The work sits in a well and the furniture sits on top of it. The
   * dialog's own surface is already the raised one, so the head and the
   * action row simply keep it and the BODY takes the darker step. A seam
   * you can see is what says the middle moves and the ends do not. */
  /* Bigger than a card's. A dialog head is a title for a window, and the
   * card scale reads as a caption on one. */
  /* Equal on both axes, and the same floor for each, so the two ends of
   * the frame match — see "Corners". A title in the top-left sits as far
   * from the top as from the left, and the Save button in the far corner
   * does the same. The floor is one control tall so a head holding text
   * and a footer holding buttons still come out the same height. */
  :is(dialog, dialog > form) > :is(.p-panel-head, .p-actions) {
    padding: var(--p-dialog-inset);
    min-block-size: calc(var(--p-control-h) + var(--p-dialog-inset) * 2);
  }

  :is(dialog, dialog > form) > .p-panel-head {
    font-size: var(--p-text-md);
    color: var(--p-text-strong);
  }

  /* Why the head's contents are given a control's height rather than
   * left at their natural one: the two ends of the frame are held to the
   * same height, and a title is shorter than a button. Centred in that
   * taller box, a title ends up further from the top edge than from the
   * side one, and the corner stops being square — which is the whole
   * thing the inset exists to get right. Give the item the height the
   * row is built around and its box starts exactly at the inset, with
   * the glyphs centred inside it where they belong. */
  :is(dialog, dialog > form) > .p-panel-head > * {
    display: flex;
    align-items: center;
    min-block-size: var(--p-control-h);
  }

  :is(dialog, dialog > form) > .p-panel-body {
    padding: var(--p-dialog-inset);
    background: var(--p-surface);
  }

  /* The Window Modal stacks its actions after the content when there is
   * no body to separate, and then the gap is right. With a body above
   * it, the row is a grid track with a rule along its top and the margin
   * only pushes that rule away from what it separates. */
  /* Both of these are undoing `.p-panel-body + .p-actions`, which zeroes
   * an action row's top padding and out-specifies a plain
   * `dialog > .p-actions` — two classes beats an element and a class.
   * Right for a card, where the body's own padding is already the gap;
   * wrong for a bordered footer, which then sits flush against its rule. */
  /* The seam belongs to a row that follows a body — there it is the
   * bottom edge of a frame. A dialog that is three lines and two buttons
   * has no frame to close, and a rule above its actions would be a line
   * across nothing, so that kind keeps the plain gap above instead. */
  :is(dialog, dialog > form) > .p-panel-body + .p-actions {
    margin-block-start: 0;
    padding-block-start: var(--p-dialog-inset);
    border-block-start: 1px solid var(--p-line);
  }

  /* THE BARE DIALOG'S FOOTER REACHES THE CORNERS TOO.
   *
   * A dialog built from panel parts has no padding of its own — each part
   * brings the inset — so its buttons sit one inset from the corner. A
   * bare one keeps `padding: var(--p-inset)` because the sentence inside
   * it needs the room, and that put its buttons a whole step further in
   * than the same row in a Window Modal: two dialogs, two spacings, no
   * reason a reader could see.
   *
   * So the row alone escapes the box's padding and brings back the same
   * inset every other footer uses. The prose stays held off the edge; the
   * buttons land in the corners. The block-start margin gives up an inset
   * in exchange for the padding the row now carries, so the gap above it
   * is unchanged. */
  dialog:not(:has(.p-panel-body)) > .p-actions,
  dialog:not(:has(.p-panel-body)) > form > .p-actions {
    margin-inline: calc(-1 * var(--p-inset));
    margin-block-end: calc(-1 * var(--p-inset));
    margin-block-start: calc(var(--p-gap-group) - var(--p-dialog-inset));
  }

  dialog[data-size="full"] {
    /* Plain 100%, and the base layer is what makes that the whole
     * screen: while a modal is open the page's overflow is off and its
     * scrollbar lane is released, so there is no gutter left to stop
     * short of. Sizing past the containing block instead — 100dvw — is
     * how the trailing corner ends up clipped in engines that keep the
     * lane reserved. */
    inline-size: 100%;
    max-inline-size: 100%;
    block-size: 100%;
    max-block-size: 100%;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 0;
    box-shadow: none;
  }

  /* A screen's title, one step up again from a window's. The inset is
   * already equal on both axes above; only the type grows. */
  dialog[data-size="full"] > .p-panel-head,
  dialog[data-size="full"] > form > .p-panel-head {
    font-size: var(--p-text-lg);
  }



  /* spec: split */
  /* spec: split-grip */
  /* =================================================================
   * Split view — a panel beside a workspace.
   *
   * The shape behind a file tree beside an editor, a conversation list
   * beside a thread, a browse panel beside a feed. Distinct from the app
   * shell, which is the whole window: a split lives INSIDE one region and
   * a page can hold more than one.
   *
   * WHY THIS OWNS ITS OFF-CANVAS SLIDE WHEN `[data-drawer]` DOES NOT.
   * A drawer can be anywhere, so Pulse supplies only the scrim and the
   * dismissal and lets the project say where it sits. A split panel is
   * not anywhere — it is the leading edge of a split, at the breakpoint
   * where the split stops fitting. That is knowable, so it is written
   * once here instead of in every project that has one.
   * ================================================================= */
  .p-split {
    display: flex;
    /* Both required, and for the same reason: a flex child defaults to
     * min-height:auto, which refuses to shrink below its content, and
     * the panel's own scroller then never gets a height to scroll in. */
    block-size: 100%;
    min-block-size: 0;
  }

  .p-split-side {
    position: relative;
    display: flex;
    flex-direction: column;
    flex: none;
    inline-size: clamp(11rem, var(--p-split-w, 17rem), 30rem);
    border-inline-end: 1px solid var(--p-line);
    /* Furniture, so it comes forward; the workspace beside it is the
     * well. See "Surfaces". */
    background: var(--p-fill-raised);
    /* The tree's trailing-action overlay fades to whatever it sits on,
     * and here that is not the default surface. */
    --p-tree-bg: var(--p-surface-raised);
  }

  /* The strip at the top of the panel — a search field, a filter button.
   * Fixed, while the body below it scrolls. */
  .p-split-head {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    flex: none;
    padding: var(--p-space-3);
    border-block-end: 1px solid var(--p-line);
  }

  .p-split-head > .p-input-group {
    flex: 1;
    min-inline-size: 0;
  }

  /* Deliberately no display or gap: this element is usually the .p-tree
   * or .p-nav itself, and restating either pins the row spacing to
   * whatever was written here instead of what the pattern sets. */
  .p-split-body {
    flex: 1;
    overflow: auto;
    padding: var(--p-space-2);
  }

  .p-split-main {
    flex: 1;
    background: var(--p-fill-sunken);
    display: flex;
    flex-direction: column;
    gap: var(--p-gap-group);
    /* Without this a long unbroken word in the workspace widens the flex
     * item and pushes the panel off the screen. */
    min-inline-size: 0;
    /* The workspace does its own scrolling and its own padding, because
     * the page around it gave up both to let the panel reach full
     * height. */
    overflow: auto;
    padding: var(--p-inset);
  }

  /* ---- the grip -----------------------------------------------------
   * Sits ON the border, half in each side, because a grip you have to
   * hit exactly is a grip nobody finds. It is a hit area rather than a
   * mark: the border is already the visible line.
   *
   * Pulse moves it; it does not remember where you left it. Persisting a
   * width means knowing what identifies this panel across a reload, and
   * that is the project's question — the same split on two pages may or
   * may not be the same panel. Listen for `pulse:resize`. */
  .p-split-grip {
    position: absolute;
    inset-block: 0;
    inset-inline-end: calc(var(--p-space-3) * -0.5);
    inline-size: var(--p-space-3);
    cursor: col-resize;
    z-index: var(--p-z-raised, 1);
    touch-action: none;
  }

  /* A panel on the trailing edge of the screen is dragged by its LEADING
   * edge, so the grip changes sides — and, in the script, changes sign.
   * Declared rather than inferred: a grip's edge is a fact about the
   * layout, and guessing it from geometry gets it wrong for exactly the
   * panel that is momentarily zero-width. `.p-side-end` is the common
   * case and carries it without the project having to say so. */
  .p-split-grip[data-grip-edge="start"],
  .p-side-end > .p-split-grip {
    inset-inline: calc(var(--p-space-3) * -0.5) auto;
  }

  .p-split-grip:focus-visible {
    outline: var(--p-ring-width) solid var(--p-focus);
    outline-offset: calc(var(--p-space-3) * -0.25);
  }

  @media (max-width: 60rem) {
    /* Off canvas, and now a drawer. Pair with `data-drawer` for the
     * scrim, Escape and light dismiss — this rule is only where it
     * sits and how it arrives. */
    .p-split-side {
      position: fixed;
      inset-block: 0;
      inset-inline-start: 0;
      z-index: var(--p-z-modal);
      /* Not the desktop width: a 17rem panel on a 20rem phone leaves a
       * sliver of page behind it that reads as a rendering fault. */
      inline-size: min(19rem, 84vw);
      box-shadow: var(--p-shadow-4);
      /* 101 rather than 100 — a fractional device pixel ratio can leave
       * a hairline of panel visible at exactly -100%. */
      translate: -101% 0;
      transition: translate var(--p-dur-3) var(--p-ease);
    }

    .p-split-side[data-open] {
      translate: 0 0;
    }

    /* Nothing to drag: the panel is over the page, not beside it. */
    .p-split-grip {
      display: none;
    }
  }

  /* spec: bars */
  /* spec: toolbar */
  /* =================================================================
   * Bars
   * ================================================================= */
  .p-bar {
    display: flex;
    align-items: center;
    gap: var(--p-gap);
    min-block-size: calc(var(--p-control-h) + var(--p-space-4));
    padding-inline: var(--p-space-4);
    border-block-end: 1px solid var(--p-line);
    /* Headers pick up the accent — one of the "major structural surfaces". */
    background: var(--p-tint-subtle);
  }

  .p-bar[data-tone] {
    background: var(--p-tone-surface);
  }

  .p-bar[data-sticky] {
    position: sticky;
    inset-block-start: 0;
    z-index: var(--p-z-sticky);
  }

  /* spec: two-tier-shell */
  /* THE SECOND TIER — a bar that carries nothing but destinations.
   *
   * Shorter than the application header above it, because it holds one
   * rank of links rather than controls, and its group sits at the centre
   * of the window rather than at the centre of what is left over.
   *
   * The centring is the auto-margin trick the pinned rail already uses,
   * for the same reason: free space is split between the two margins
   * when the destinations fit, and when they do not there is no free
   * space, the margins collapse, and the rail packs and scrolls from the
   * start. One mechanism, both states, and nothing that has to know how
   * many destinations there are. `justify-content: center` cannot do
   * this — an overflowing centred flex row is unreachable at its start
   * edge, which is where the first destination is. */
  .p-bar[data-rail] {
    min-block-size: auto;
    padding-block: var(--p-space-1);
  }

  .p-bar[data-rail] > .p-nav[data-axis="x"] {
    flex: 1;
    min-inline-size: 0;
  }

  .p-bar[data-rail] > .p-nav[data-axis="x"] > :first-child:not([data-pin]) {
    margin-inline-start: auto;
  }

  .p-bar[data-rail] > .p-nav[data-axis="x"] > :last-child:not([data-pin]) {
    margin-inline-end: auto;
  }

  .p-toolbar {
    display: flex;
    align-items: center;
    gap: var(--p-space-1);
    padding: var(--p-space-1);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-raised);
  }

  .p-toolbar > hr {
    inline-size: 1px;
    block-size: 60%;
    margin-inline: var(--p-space-1);
  }

  /* spec: panels-and-cards */
  /* spec: empty-state */
  /* =================================================================
   * Panels and cards
   *
   * Structured hybrid: tone, whitespace, border and elevation each carry
   * part of the separation, so no single one has to shout.
   * ================================================================= */
  .p-panel,
  .p-card {
    display: flex;
    flex-direction: column;
    min-inline-size: 0;
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-lg);
    background: var(--p-fill-raised);
  }

  .p-card {
    gap: var(--p-space-4);
    padding: var(--p-space-6);
  }

  .p-card[data-elevation="1"] { box-shadow: var(--p-shadow-1); }
  .p-card[data-elevation="2"] { box-shadow: var(--p-shadow-2); border-color: transparent; }
  .p-card[data-elevation="3"] { box-shadow: var(--p-shadow-3); border-color: transparent; }

  /* Important boundaries take the low-chroma accent border. */
  .p-card[data-emphasis="strong"],
  .p-panel[data-emphasis="strong"] {
    border-color: var(--p-border);
  }

  .p-card[data-tint] {
    background: var(--p-tint-subtle);
    border-color: var(--p-border);
  }

  .p-card[data-tint="soft"] { background: var(--p-tint-soft); }
  .p-card[data-tint="medium"] { background: var(--p-tint-medium); }

  .p-card[data-tone] {
    background: var(--p-tone-surface);
    border-color: var(--p-tone-border);
  }

  /* THE HOVER LIFTS THE CARD ONE PIXEL, and the owner chose it twice —
   * once when it was written and again after a report argued it away.
   * Kept here as a note against arguing it away a third time.
   *
   * The report was right about what it saw and wrong about the cause. A
   * card whose text is CENTRED inside a fractional-width grid track
   * repaints its glyph run every frame of the move, and the centring
   * lands on a different subpixel phase each time — so the text appears
   * to shimmer against a card that is, measurably, not moving relative
   * to it at all. Measured on the reporting screen: a tile 192.797px
   * wide at x 17.594, `text-align: center`. Its own text never budges
   * from 71.203px off the card's leading edge, lifted or resting.
   *
   * That is a paint artefact, and its remedy belongs to the screen that
   * has it: give that card `will-change: translate` so the move is
   * composited and the contents are not repainted. Not a default,
   * because it is a layer per card and most cards do not need one. */
  .p-card[data-interactive] {
    cursor: pointer;
    transition:
      border-color var(--p-dur-1) var(--p-ease),
      background-color var(--p-dur-1) var(--p-ease),
      translate var(--p-dur-1) var(--p-ease);
  }

  .p-card[data-interactive]:hover {
    border-color: var(--p-border-strong);
    translate: 0 -1px;
  }

  .p-card[data-interactive][aria-selected="true"] {
    border-color: var(--p-accent);
    background: var(--p-tint-soft);
  }

  /* spec: card-row-bands */
  /* Optional card facts still occupy their row. A description reserves its
   * clamp line boxes, while the timestamp and badge keep their own slots;
   * absent facts use same-shaped aria-hidden placeholders in the markup. */
  .p-card-row-bands-desc {
    min-block-size: calc(var(--p-clamp-lines, 2) * 1lh);
  }

  .p-card-row-bands-meta > .p-when {
    flex: 1 1 auto;
    min-inline-size: 0;
  }

  .p-card-row-bands-meta > .p-badge { flex: none; }
  .p-card-row-bands-meta [aria-hidden="true"] { visibility: hidden; }

  .p-panel-head {
    display: flex;
    align-items: center;
    gap: var(--p-gap);
    padding: var(--p-space-3) var(--p-space-4);
    border-block-end: 1px solid var(--p-line);
    color: var(--p-text-strong);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-semibold);
  }

  /* The body is the work and the head is furniture. Give that distinction
   * the surface ramp already reserved for it instead of asking a border to
   * separate two identical fills. The inset radius keeps the fill inside
   * the panel's own hairline without clipping menus or profile artwork. */
  .p-panel > .p-panel-head {
    border-start-start-radius: calc(var(--p-radius-lg) - 1px);
    border-start-end-radius: calc(var(--p-radius-lg) - 1px);
    background: var(--p-surface-overlay);
  }

  /* A head is a strip, not prose. A heading dropped into one arrives
   * carrying document margins and its own scale — which sets the strip's
   * height from the h-level somebody happened to pick and pushes the
   * text off the vertical centre. The head has already said how big its
   * text is; the heading is here for the outline, not the size. */
  /* Controls in a head go to the trailing side, and everything from the
   * first one onward goes with them. No spacer element to remember, and
   * no way to end up with a button stranded in the middle of a title.
   *
   * WHAT BELONGS HERE, as opposed to in the action row below: whatever
   * is peculiar to this one screen. Configure, Edit, Export, a view
   * switch. The action row is for finishing or abandoning the task —
   * the same two answers on every screen — and mixing a screen's own
   * oddities into it makes the way out harder to find. See "Action
   * rows". */
  .p-panel-head > :is(button, .p-btn, .p-select, .p-toolbar):first-of-type {
    margin-inline-start: auto;
  }

  /* A CORNER ITEM IS CENTRED IN THE SQUARE ITS TWO EDGES FORM, and what
   * has to be centred is the MARK, not the box around it. A bare icon is
   * a small square in a box as tall as the row: give it the inset on
   * both sides and the box lines up, while the mark inside it still
   * hangs closer to the edge it touches than to the one it does not.
   * Squaring the box is what puts the mark the same distance from both.
   *
   * Controls do not need this — a button's painted edge IS its box, so
   * insetting the box insets what you see. */
  .p-panel-head > :is(span, i)[data-icon] {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    inline-size: var(--p-control-h);
    block-size: var(--p-control-h);
  }

  .p-panel-head > :is(h1, h2, h3, h4, h5, h6) {
    margin: 0;
    font-size: inherit;
    font-weight: inherit;
    color: inherit;
  }

  /* A title with a note under it is one block — the house default for
   * section heads (hd-section-note) holds in a panel or dialog head too.
   * `hgroup` is the element for it; a plain div holding a paragraph is
   * what agents write first, so it gets the same answer rather than the
   * dialog head's row of flex items, which put a three-line title beside
   * its own subtitle. */
  .p-panel-head > :is(hgroup, div:has(> p)),
  :is(dialog, dialog > form) > .p-panel-head > :is(hgroup, div:has(> p)) {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: var(--p-gap-tight);
    min-inline-size: 0;
    margin: 0;
  }

  .p-panel-head > :is(hgroup, div:has(> p)) > :is(h1, h2, h3, h4, h5, h6) {
    margin: 0;
    font-size: inherit;
    font-weight: inherit;
    color: inherit;
  }

  .p-panel-head > :is(hgroup, div:has(> p)) > p {
    margin: 0;
    color: var(--p-text-muted);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-regular);
    line-height: var(--p-leading-normal);
  }

  .p-panel-body {
    padding: var(--p-space-4);
    overflow: auto;
  }

  /* Nesting has a defined maximum of 3. Past that the borders stop reading
   * as structure and start reading as noise, so depth 4 flattens. */
  .p-panel .p-panel .p-panel .p-panel {
    border: 0;
    border-radius: 0;
    padding-inline: 0;
    background: none;
  }

  /* spec: fields */
  /* spec: validation */
  /* spec: input-group */
  /* spec: input-clear */
  /* spec: search-bar */
  /* =================================================================
   * Fields
   * ================================================================= */
  .p-field {
    display: flex;
    flex-direction: column;
    gap: var(--p-space-2);
    min-inline-size: 0;
  }

  .p-field-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--p-space-3);
    min-inline-size: 0;
  }

  .p-field-head > label {
    min-inline-size: 0;
  }

  .p-field > .p-hint {
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
    line-height: var(--p-leading-snug);
  }

  .p-field > .p-error {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    color: var(--p-bad-text);
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-medium);
  }

  /* spec: saved-status */
  /* A permanent slot for a transient save result. It may sit beside a
   * field label or as the one shared status in a document toolbar; the
   * component owns its quiet presentation, while either parent owns the
   * surrounding layout. "Saved" never inserts a line or moves the work. */
  .p-saved {
    min-inline-size: 5ch;
    min-block-size: 1lh;
    flex: none;
    color: var(--p-good-text);
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-medium);
    opacity: 0;
    text-align: end;
    transition: opacity var(--p-dur-2) var(--p-ease);
  }

  .p-saved[data-visible] { opacity: 1; }

  /* An empty inline has a bottom-edge flex baseline; one with text has a
   * text baseline. Reserve the word invisibly so its parent does not change
   * size when the real result arrives. */
  .p-saved:empty::before {
    content: "Saved";
    visibility: hidden;
  }

  /* spec: drop-zone */
  .p-drop {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--p-space-2);
    min-block-size: 8rem;
    padding: var(--p-inset-sm);
    border: 1px dashed var(--p-border-strong);
    border-radius: var(--p-radius-lg);
    background: var(--p-surface-sunken);
    color: var(--p-text);
    text-align: center;
    cursor: pointer;
  }

  .p-drop:is(:hover, [data-over]) {
    border-color: var(--p-accent);
    background: var(--p-tint-subtle);
  }

  .p-drop:has(> input[type="file"]:focus-visible) {
    outline: var(--p-ring-width) solid var(--p-focus);
    outline-offset: var(--p-ring-offset);
  }

  .p-drop > input[type="file"] {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    margin: -1px;
    clip-path: inset(50%);
    overflow: hidden;
  }

  /* Reordering marks an insertion point without occupying it. A fixed
   * line can sit in the gap while every real row keeps its exact place. */
  .p-drop-slot {
    position: fixed;
    z-index: var(--p-z-popover);
    block-size: 0;
    border-block-start: 2px solid var(--p-accent);
    pointer-events: none;
  }

  /* THE CONTAINER TAKING THE DROP says so, because the insertion line
   * alone cannot: dropped into a folder that is shut or empty there is
   * no gap for the line to sit in, and nothing on screen would confirm
   * where the row was about to go. Only ever on a container the item is
   * arriving at from somewhere else — a row moving inside its own list
   * has the line, and lighting up its own container as well would say
   * something happened that has not. */
  [data-reorder][data-reorder-over] {
    outline: 2px dashed var(--p-border-accent);
    outline-offset: -2px;
    border-radius: var(--p-radius-md);
    background: var(--p-tint-subtle);
  }

  /* Never hue alone: the error line carries a mark as well as a colour. */
  .p-field > .p-error::before {
    content: "!";
    display: inline-flex;
    align-items: center;
    justify-content: center;
    inline-size: 1.1em;
    block-size: 1.1em;
    flex: none;
    border-radius: var(--p-radius-full);
    background: var(--p-bad);
    color: var(--p-bad-fg);
    font-size: 0.85em;
    font-weight: var(--p-weight-bold);
  }

  .p-field[data-orientation="horizontal"] {
    flex-direction: row;
    align-items: center;
    gap: var(--p-gap);
  }

  .p-field[data-orientation="horizontal"] > :is(label, .p-field-head) {
    inline-size: var(--p-label-w, 9rem);
    flex: none;
  }

  /* A checkbox and its label are one target. */
  .p-check {
    display: inline-flex;
    align-items: center;
    gap: var(--p-space-3);
    cursor: pointer;
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-regular);
    color: var(--p-text);
  }

  /* One field that happens to contain more than a field. The group draws
   * the border and the focus ring; the input inside gives its own up, or
   * the result is a box inside a box and a clear button that reads as a
   * separate control standing next to the search rather than part of it. */
  .p-input-group {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    min-inline-size: 0;
    padding-inline: var(--p-space-3);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-control);
    background: var(--p-surface-inset);
  }

  .p-input-group > :is(input, textarea, select) {
    flex: 1;
    min-inline-size: 0;
    border: 0;
    background: none;
    padding-inline: 0;
  }

  /* The group owns the ring, so the field inside must not draw a second
   * one inside it. Pulse never deletes a focus ring without relocating
   * it — this is the relocation. */
  .p-input-group > :is(input, textarea, select):focus-visible {
    outline: none;
    box-shadow: none;
  }

  .p-input-group:focus-within {
    border-color: var(--p-accent);
    outline: var(--p-ring-width) solid var(--p-focus);
    outline-offset: var(--p-ring-offset);
  }

  /* A button inside the group is chrome, not a control beside it — and a
   * square one, so it can be centred on both axes rather than only the
   * one a text control happens to give it. It was previously sized by
   * its own inline padding alone, which left it taller than it was wide
   * and further from the trailing edge than from the top and bottom. */
  .p-input-group > button {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    inline-size: var(--p-control-h-sm);
    block-size: var(--p-control-h-sm);
    padding: 0;
    border: 0;
    background: none;
    /* A shade larger than an icon beside a word. This one has no label
     * to be read with, so it is carrying the whole meaning by itself. */
    --p-icon-size: 1.3em;
  }

  /* The trailing gap is derived from the vertical one rather than picked,
   * so the button sits the same distance from the edge as it does from
   * the top and bottom. Anything hand-chosen here is right at one control
   * size and wrong at the other two. */
  .p-input-group:has(> button:last-child) {
    padding-inline-end: calc((var(--p-control-h) - var(--p-control-h-sm)) / 2);
  }

  .p-input-group:has(> button:first-child) {
    padding-inline-start: calc((var(--p-control-h) - var(--p-control-h-sm)) / 2);
  }

  /* A clear button is only there to undo something. With an empty field
   * it does nothing, so it says nothing: mark it `data-clear` and it
   * stays out of the way until there is something to remove.
   *
   * HIDDEN, NOT REMOVED. `display: none` would hand the space back to the
   * field, so the text would shift sideways on the first keystroke and
   * back again on the last — the field would flinch every time you
   * started typing. `visibility: hidden` keeps the box and still takes
   * the button out of the accessibility tree and out of the tab order,
   * which is the part that actually matters for a control that cannot do
   * anything yet.
   *
   * :placeholder-shown IS THE ONLY WAY TO ASK. CSS has no "this field has
   * a value" selector, and that pseudo-class is the closest thing —
   * which means A FIELD WITH A `data-clear` BUTTON MUST HAVE A
   * PLACEHOLDER. Without one it never matches, and the button simply
   * stays visible: the failure is a control that shows too often, never
   * one that hides when it was needed. `pulse check` flags the pairing. */
  .p-input-group > button[data-clear] {
    transition:
      opacity var(--p-dur-1) var(--p-ease),
      visibility var(--p-dur-1) var(--p-ease);
  }

  .p-input-group:has(> :is(input, textarea):placeholder-shown) > button[data-clear] {
    visibility: hidden;
    opacity: 0;
  }

  /* A custom clear button replaces the browser's own search affordance,
   * but only inside the group that supplies that replacement. Bare search
   * fields keep the native cancel button. */
  .p-input-group > input[type="search"]::-webkit-search-cancel-button {
    appearance: none;
  }

  /* inline-flex, not the inline default. An icon is a ::before box with
   * no baseline of its own, so in an inline box it lands on the text
   * baseline and rides low — which is exactly what a search glyph in a
   * field looks like when it is a pixel or two out. */
  .p-input-group > .p-affix {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--p-text-muted);
    font-size: var(--p-text-sm);
  }

  /* spec: select */
  /* =================================================================
   * Select
   *
   * Styling for pulse-controls.js, which replaces the native popup. The
   * native <select> stays in the DOM as the value and the form field; it
   * is just moved out of sight. Without the script none of this matches
   * and the native control is used, styled as far as CSS can reach it.
   * ================================================================= */
  .p-select {
    position: relative;
    display: block;
    min-inline-size: 0;
  }

  /* Kept for forms and assistive tech, not for looking at. Not
   * display:none — that would take it out of form submission in some
   * engines and out of the accessibility tree entirely. */
  /* The real <select>, kept for the form and the accessibility tree and
   * hidden from view. PINNED to the corner, and that is not decoration:
   * without an inset it sits at its static position, which is after the
   * button — i.e. at the wrapper's trailing edge — and from there its
   * own intrinsic width lands in the page's scrollable overflow. A
   * `<select>` whose widest option is 62px did that to a settings column
   * on a phone: nothing on screen looked wrong, and the whole page
   * scrolled sideways by the width of an option nobody could see.
   *
   * `min-inline-size: 0` because a form control's automatic minimum is
   * its content, which is exactly what the 1px above is trying to deny. */
  .p-select-native {
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 0;
    inline-size: 1px;
    min-inline-size: 0;
    block-size: 1px;
    padding: 0;
    margin: 0;
    overflow: hidden;
    clip-path: inset(50%);
    border: 0;
    pointer-events: none;
  }

  .p-select-button {
    inline-size: 100%;
    justify-content: space-between;
    gap: var(--p-space-3);
    /* Room for the chevron zone, which is positioned over this space. */
    padding-inline-end: var(--p-control-h);
    font-weight: var(--p-weight-medium);
  }

  /* The chevron gets a divided zone rather than a lone glyph: a 4px
   * triangle is a miserable tap target, and the divider tells you the
   * whole zone is the thing you press. The button as a whole still
   * opens the list — the zone widens the target, it does not narrow it. */
  /* No divider. A rule down the middle says the chevron is a second
   * control, and it is not — the whole button opens the list, so the
   * chevron is a marker on one control rather than a button beside a
   * field. Still absolutely positioned so the optical block padding on
   * the button cannot drag it off centre. */
  .p-select-icon {
    display: grid;
    place-items: center;
    position: absolute;
    inset-block: 0;
    inset-inline-end: 0;
    inline-size: var(--p-control-h);
    color: var(--p-text-muted);
    transition: color var(--p-dur-1) var(--p-ease);
  }

  .p-select-icon::before {
    content: "";
    inline-size: 0;
    block-size: 0;
    border-inline: 4px solid transparent;
    border-block-start: 5px solid currentColor;
    color: var(--p-text-muted);
    transition: rotate var(--p-dur-2) var(--p-ease);
  }

  /* The wash went with the divider — a lit panel behind the chevron is
   * the same claim, that this corner is its own control. The marker
   * brightens instead. */
  .p-select-button:hover .p-select-icon,
  .p-select[data-open] .p-select-icon {
    color: var(--p-text-strong);
  }

  .p-select[data-open] .p-select-icon::before {
    rotate: 180deg;
  }

  .p-select-value {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* FIXED, AND PLACED BY pulse-anchor.js — the same arrangement every
   * other floating box in the system uses, and for the reason that file
   * states: an absolutely positioned popup is cut off by the first
   * ancestor that clips, and the places a select is used are exactly the
   * places that have one. A rating select inside a media card opened into
   * the card's own `overflow: clip` and showed three pixels of its first
   * row. Width is written by the script to match the button, because a
   * fixed box has no percentage of the field to resolve against. */
  .p-select-list {
    display: none;
    position: fixed;
    inset-block-start: 0;
    inset-inline-start: 0;
    z-index: var(--p-z-popover);
    max-block-size: 15rem;
    padding: var(--p-space-1);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-overlay);
    box-shadow: var(--p-shadow-3);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .p-select[data-open] .p-select-list {
    display: block;
  }

  .p-select-option {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    --p-pad-block: var(--p-space-2);
    padding-inline: var(--p-space-3);
    border-radius: var(--p-radius-sm);
    color: var(--p-text);
    font-size: var(--p-text-sm);
    cursor: pointer;
  }

  .p-select-option[data-active] {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
  }

  .p-select-option[aria-selected="true"] {
    background: var(--p-accent);
    color: var(--p-accent-fg);
    font-weight: var(--p-weight-semibold);
  }

  .p-select-option[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  /* spec: combobox */
  /* =================================================================
   * Combobox
   *
   * Styling for pulse-combo.js. The native <select multiple> stays in
   * the DOM as the value and the form field — it wears .p-select-native
   * above, the same hiding the select popup uses, because it is the same
   * arrangement: a real control kept for the form and the accessibility
   * tree, with something typeable in front of it.
   * ================================================================= */
  .p-combo {
    position: relative;
    display: block;
    min-inline-size: 0;
  }

  /* THE BOX IS THE INPUT, and it grows. Tokens and the text cursor share
   * one field rather than sitting in a strip above it: a row of chips
   * over a separate box reads as two controls, and the reader has to work
   * out which one their typing goes into. Wrapping is why the height is a
   * minimum rather than a height — six tags take three lines and all
   * three belong to the field. */
  .p-combo-field {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--p-space-2);
    min-block-size: var(--p-control-h);
    /* Room for the marker, which is positioned over this space. */
    padding: var(--p-space-1) var(--p-control-h) var(--p-space-1) var(--p-space-2);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-control);
    background: var(--p-surface-inset);
    cursor: text;
    transition: border-color var(--p-dur-1) var(--p-ease);
  }

  /* The field owns the ring; the input inside must not draw a second one
   * within it. Pulse never deletes a focus ring without relocating it —
   * this is the relocation, exactly as `.p-input-group` does it. */
  .p-combo-field:focus-within {
    border-color: var(--p-accent);
    outline: var(--p-ring-width) solid var(--p-focus);
    outline-offset: var(--p-ring-offset);
  }

  /* ABSOLUTE, not the last flex item. The field wraps — six tokens make
   * three lines — and a marker in the flow wraps with them, ending up
   * under the tokens instead of at the corner of the control. Pinned to
   * the first row's height, it stays where a select's chevron is however
   * tall the box gets. */
  .p-combo-icon {
    display: grid;
    place-items: center;
    position: absolute;
    inset-block-start: 0;
    inset-inline-end: 0;
    inline-size: var(--p-control-h);
    block-size: var(--p-control-h);
    color: var(--p-text-muted);
    pointer-events: none;
    transition: color var(--p-dur-1) var(--p-ease);
  }

  .p-combo-icon::before {
    content: "";
    inline-size: 0;
    block-size: 0;
    border-inline: 4px solid transparent;
    border-block-start: 5px solid currentColor;
    transition: rotate var(--p-dur-2) var(--p-ease);
  }

  .p-combo:hover .p-combo-icon,
  .p-combo[data-open] .p-combo-icon {
    color: var(--p-text-strong);
  }

  .p-combo[data-open] .p-combo-icon::before {
    rotate: 180deg;
  }

  .p-combo-input {
    flex: 1 1 6rem;
    min-inline-size: 6rem;
    block-size: auto;
    padding: 0;
    border: 0;
    background: none;
  }

  .p-combo-input:focus-visible {
    outline: none;
    box-shadow: none;
  }

  /* A token is a chip that is part of a value, so it looks like a chip
   * and carries its own way out. Tinted rather than plain, because these
   * are things the reader put here — the same distinction the chip makes
   * against a badge. */
  .p-combo-token {
    display: inline-flex;
    align-items: center;
    gap: var(--p-space-1);
    max-inline-size: 100%;
    padding-block: 2px;
    padding-inline: var(--p-space-2) var(--p-space-1);
    border: 1px solid var(--p-border-accent);
    border-radius: var(--p-radius-sm);
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
    font-size: var(--p-text-xs);
    white-space: nowrap;
  }

  .p-combo-drop {
    display: inline-grid;
    place-items: center;
    flex: none;
    inline-size: 1.125rem;
    block-size: 1.125rem;
    padding: 0;
    border: 0;
    border-radius: var(--p-radius-full);
    background: none;
    color: inherit;
    --p-icon-size: 0.75em;
  }

  .p-combo-drop:hover {
    background: color-mix(in oklab, var(--p-accent) 22%, transparent);
    color: var(--p-text-strong);
  }

  .p-combo-list {
    display: none;
    position: absolute;
    inset-block-start: calc(100% + var(--p-space-1));
    inset-inline: 0;
    z-index: var(--p-z-popover);
    max-block-size: 16rem;
    padding: var(--p-space-1);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-overlay);
    box-shadow: var(--p-shadow-3);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .p-combo[data-open] .p-combo-list {
    display: block;
  }

  .p-combo-option {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    --p-pad-block: var(--p-space-2);
    padding-inline: var(--p-space-3);
    border-radius: var(--p-radius-sm);
    color: var(--p-text);
    font-size: var(--p-text-sm);
    cursor: pointer;
  }

  /* The accent wash every menu in the system uses for "this is the one
   * you are on". A combobox row is a menu row that happens to have been
   * filtered, and it should not have a highlight of its own invention. */
  .p-combo-option[data-active] {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
  }

  /* The chosen one, in a single-value list. A full accent fill rather
   * than the wash, exactly as the select popup does it, so "the one you
   * are on" and "the one that is set" never look the same. A multiple
   * combobox never shows this: what is chosen is in the box as a token
   * and is taken out of the list entirely. */
  .p-combo-option[aria-selected="true"] {
    background: var(--p-accent);
    color: var(--p-accent-fg);
    font-weight: var(--p-weight-semibold);
  }

  .p-combo-option[aria-selected="true"] .p-combo-note {
    color: inherit;
    opacity: 0.8;
  }

  .p-combo-option[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  /* TWO LINES, and the second one is the reason this control exists. The
   * body is a column so the note sits under the name rather than beside
   * it — beside it, the name of the longest option decides where every
   * note starts, and the column of notes ends up further right than the
   * list is wide. */
  .p-combo-body {
    display: flex;
    flex-direction: column;
    gap: 1px;
    min-inline-size: 0;
  }

  .p-combo-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .p-combo-note {
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
    line-height: var(--p-leading-snug);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* The matched run. A <mark> is the element for "this is why you are
   * seeing this row", and it is painted rather than highlighted in
   * yellow because a yellow band across a dark overlay is the loudest
   * thing on the screen for the least important reason. */
  .p-combo-option mark,
  .p-palette-option mark {
    /* PADDING OFF, and that is the whole of this rule's reason for
     * existing beyond the colour. A `<mark>` in prose is a highlighter
     * stripe and wants breathing room inside it; a `<mark>` around the
     * two letters you just typed is INSIDE A WORD, and 0.2em on each
     * side prises it apart — "Se curity", which reads as a typo in the
     * data rather than as a match in the search. */
    padding: 0;
    border-radius: 0;
    background: none;
    color: var(--p-text-accent);
  }

  .p-combo-option[data-active] mark,
  .p-palette-option[data-active] mark {
    color: inherit;
    text-decoration: underline;
    text-underline-offset: 2px;
  }

  .p-combo-create {
    color: var(--p-text-accent);
  }

  .p-combo-empty {
    margin: 0;
    padding: var(--p-space-3);
    color: var(--p-text-muted);
    font-size: var(--p-text-sm);
  }

  /* spec: command-palette */
  /* =================================================================
   * Command palette
   *
   * Styling for pulse-palette.js. A <dialog>, so the inertness, the
   * Escape and the focus trap are the platform's; what is here is the
   * shape — a box near the top of the screen rather than the middle,
   * because the list under it grows downward and a centred box would
   * walk up the screen as the reader types.
   * ================================================================= */
  .p-palette {
    inline-size: min(38rem, calc(100vw - var(--p-space-8)));
    max-block-size: min(28rem, 80vh);
    /* Near the top, not in the middle. The list grows downward as the
     * query gets shorter, and a centred box grows both ways — so the
     * search field walks up the screen while somebody is typing into it
     * and the row under their pointer is no longer the row they meant. */
    margin-block: 12vh auto;
    margin-inline: auto;
    padding: 0;
    overflow: hidden;
  }

  /* Scoped to `[open]`, and it has to be: `dialog:not([open])` sets
   * display:none in the elements layer, and this layer beats that layer
   * whatever the specificity — a bare `.p-palette { display: flex }`
   * paints a closed palette over the page. That has shipped once here
   * already, at 800px tall, which is why the base rule says so out loud. */
  .p-palette[open] {
    display: flex;
    flex-direction: column;
  }

  .p-palette-head {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    flex: none;
    padding-inline: var(--p-space-4);
    border-block-end: 1px solid var(--p-line);
    color: var(--p-text-muted);
  }

  /* A `data-icon` mask is sized in inline dimensions, and an inline box
   * ignores those — so a bare <span> in a flex row is a 16px icon that
   * measures zero and paints nothing. The head is a flex container, which
   * makes the span a block-level item, and its ::before is still inline
   * inside it. Same rule `.p-panel-head` needs, for the same reason. */
  .p-palette-head > :is(span, i)[data-icon] {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
  }

  /* NO BOX INSIDE THE BOX. The dialog is the field: a bordered input
   * sitting inside a bordered panel is two frames around one thing, and
   * the reader's cursor is already here because the palette just opened.
   * The ring is not deleted — it is the dialog's own, and the dialog is
   * the only thing on screen. */
  .p-palette-input {
    flex: 1;
    min-inline-size: 0;
    block-size: calc(var(--p-control-h) * 1.3);
    padding: 0;
    border: 0;
    background: none;
    color: var(--p-text-strong);
    font-size: var(--p-text-md);
  }

  .p-palette-input:focus-visible {
    outline: none;
    box-shadow: none;
  }

  .p-palette-list {
    flex: 1;
    min-block-size: 0;
    padding: var(--p-space-2);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* A GROUP HEADING IS A SIBLING OF ITS ROWS, not a parent of them. The
   * list has to be one flow for the arrow keys to cross a boundary
   * without stopping at it, and nesting each group in its own box makes
   * that a special case in the script rather than a fact about the
   * markup. */
  .p-palette-group {
    margin: 0;
    padding: var(--p-space-3) var(--p-space-3) var(--p-space-1);
    color: var(--p-text-subtle);
    font-size: var(--p-text-2xs);
    font-weight: var(--p-weight-semibold);
    letter-spacing: var(--p-tracking-label);
    text-transform: uppercase;
  }

  .p-palette-group:first-child {
    padding-block-start: var(--p-space-1);
  }

  .p-palette-option {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: var(--p-space-3);
    inline-size: 100%;
    block-size: auto;
    --p-pad-block: var(--p-space-2);
    padding-inline: var(--p-space-3);
    border: 0;
    border-radius: var(--p-radius-sm);
    background: none;
    color: var(--p-text);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-regular);
    text-align: start;
  }

  /* The same accent wash as every other list of things you are choosing
   * between. Hover and the arrow keys must agree — a palette with one
   * highlight for the pointer and another for the keyboard shows two
   * candidates at once and answers to neither. */
  .p-palette-option[data-active],
  .p-palette-option:hover {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
  }

  .p-palette-label {
    flex: 1;
    min-inline-size: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .p-palette-detail {
    flex: 1 1 auto;
    min-inline-size: 0;
    overflow: hidden;
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .p-palette-option:has(> .p-palette-detail) > .p-palette-label {
    flex: 0 1 auto;
  }

  .p-palette-option[data-active] > .p-palette-detail { color: inherit; }

  @media (max-width: 30rem) {
    .p-palette-option:has(> .p-palette-detail) { flex-wrap: wrap; }

    .p-palette-option > .p-palette-detail {
      flex-basis: 100%;
      padding-inline-start: calc(var(--p-icon-size) + var(--p-space-3));
    }
  }

  .p-palette-empty {
    margin: 0;
    padding: var(--p-space-5) var(--p-space-3);
    color: var(--p-text-muted);
    font-size: var(--p-text-sm);
    text-align: center;
  }

  /* A chord printed beside something, wherever that is — a palette row, a
   * menu item. Not `.p-keyhint`, which is the overlay `?` raises and is
   * deliberately louder; this is a caption on a row that is already
   * there. */
  .p-keys {
    display: inline-flex;
    flex: none;
    gap: 2px;
    color: var(--p-text-muted);
  }

  .p-keys > kbd {
    font-size: var(--p-text-2xs);
    min-inline-size: 0;
    padding-inline: 0.3em;
    border-block-end-width: 1px;
    background: none;
    color: inherit;
  }

  /* spec: segmented-control */
  /* =================================================================
   * Segmented control
   * ================================================================= */
  .p-segmented {
    display: inline-flex;
    /* `inline-flex` hugs its contents in normal flow, but a flex or grid
     * item stretches on the cross axis by default — so in a column of
     * rows, which is where these usually live, the frame ran the full
     * width with the options huddled at one end. */
    align-self: start;
    padding: 2px;
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-control);
    background: var(--p-surface-inset);
  }

  .p-segmented > button {
    block-size: calc(var(--p-control-h) - 6px);
    border: 0;
    border-radius: calc(var(--p-radius-control) - 2px);
    background: none;
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
  }

  .p-segmented > button:hover {
    background: var(--p-wash-hover);
    color: var(--p-text-strong);
  }

  /* The chosen one wears the accent. A raised surface alone says "this
   * one is different"; the accent says "this one is chosen", which is the
   * thing a segmented control exists to communicate — and it is the same
   * mark selection wears everywhere else in the system.
   *
   * Tint and text, and no ring. The ring was a third statement of a fact
   * the other two already make, and at this size it read as a box drawn
   * around the answer rather than as the answer. */
  .p-segmented > button[aria-pressed="true"],
  .p-segmented > button[aria-checked="true"] {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
  }

  /* spec: badges */
  /* spec: chips */
  /* =================================================================
   * Badges and chips
   * ================================================================= */
  .p-badge {
    display: inline-flex;
    align-items: center;
    gap: var(--p-space-1);
    --p-pad-block: 0.15em;
    padding-inline: var(--p-space-3);
    border: 1px solid transparent;
    /* A badge states a fact; it is not actionable. A single line of text
     * reads as a pill so it is never mistaken for a button, which keeps
     * the corner radius. Still scales with --p-round, so a fully square
     * theme gets square badges. */
    border-radius: calc(1em * var(--p-round) * 2);
    background: var(--p-tone-surface);
    color: var(--p-tone-text);
    font-size: var(--p-text-2xs);
    font-weight: var(--p-weight-semibold);
    letter-spacing: var(--p-tracking-label);
    white-space: nowrap;
  }

  .p-badge[data-variant="solid"] {
    background: var(--p-tone);
    color: var(--p-tone-fg);
  }

  .p-badge[data-variant="outline"] {
    background: none;
    border-color: var(--p-tone-border);
  }

  /* A status dot is never the only signal — it sits beside its label. */
  .p-badge[data-dot]::before {
    content: "";
    inline-size: 0.45em;
    block-size: 0.45em;
    border-radius: var(--p-radius-full);
    background: var(--p-tone);
    /* Undo the host's optical text shift. That offset exists to centre
     * capitals, which a circle does not have — left alone the dot rides
     * low. See --p-optical-applied above. */
    translate: 0 calc(-1 * var(--p-optical-applied));
  }

  .p-chip {
    display: inline-flex;
    align-items: center;
    gap: var(--p-space-2);
    block-size: var(--p-control-h-sm);
    padding-inline: var(--p-space-3);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-control-sm);
    background: var(--p-surface-raised);
    color: var(--p-text);
    font-size: var(--p-text-xs);
  }

  .p-chip[aria-selected="true"] {
    background: var(--p-tint-soft);
    border-color: var(--p-accent);
    color: var(--p-text-accent);
  }

  /* spec: tag-editor */
  .p-tag-editor {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--p-space-2);
  }

  .p-tag-editor > input {
    flex: 1 1 8rem;
    min-inline-size: 0;
  }

  .p-chip > button {
    inline-size: 1.1em;
    min-inline-size: 1.1em;
    block-size: 1.1em;
    min-block-size: 1.1em;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
  }

  /* spec: file */
  /* spec: numbered-candidates */
  /* ---- file ----------------------------------------------------------
   * One attached thing: a picture queued to send, a document that came
   * back with a reply.
   *
   * Not a chip. A chip is "a filter or a token the user applied", and it
   * comes off — that is the whole of what it means. A file is a noun
   * rather than a filter, it carries a name that can be far too long, it
   * often has a size beside it, and the trailing control is sometimes
   * remove and sometimes download, which are opposites.
   *
   * ONLY THE NAME GIVES WAY. The same rule the tree row settled: a
   * shortened filename is still recognisable, a shortened "2.4 MB" is
   * wrong, and a shortened button is unusable. So the name flexes and
   * ellipses and everything else keeps its size. */
  .p-file {
    display: inline-flex;
    align-items: center;
    gap: var(--p-space-2);
    max-inline-size: 100%;
    padding: var(--p-space-1) var(--p-space-2);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-sm);
    background: var(--p-surface-raised);
    color: var(--p-text);
    font-size: var(--p-text-xs);
  }

  .p-file-name {
    min-inline-size: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .p-file-meta {
    flex: none;
    color: var(--p-text-subtle);
    font-variant-numeric: tabular-nums;
  }

  /* A QUEUE OF FILES, and the reason it is a real element rather than
   * any old wrapper: the tile size is a fact about how many there are,
   * and only a parent can count its children.
   *
   * Up to three, they are worth looking at — a staged image is usually
   * one someone is about to send and wants to check. Past three the row
   * is a manifest rather than a preview, and a manifest that wraps onto a
   * second line has stopped being glanceable, so the tiles step down. The
   * count lives in `:has()` so nobody has to remember to set a class when
   * a fourth file lands. */
  .p-files {
    display: flex;
    flex-wrap: wrap;
    gap: var(--p-space-3);
    --p-file-thumb: 6.5rem;
  }

  .p-files:has(> .p-file:nth-of-type(4)) {
    --p-file-thumb: 4.5rem;
  }

  /* The picture IS the label when there is one, so the name goes under it
   * rather than beside it and the whole thing becomes a tile. Square,
   * because a queue of staged images with three aspect ratios in it reads
   * as a mess rather than as a queue. */
  .p-file[data-shape="thumb"] {
    flex-direction: column;
    align-items: stretch;
    inline-size: var(--p-file-thumb, 4.5rem);
    padding: 0;
    overflow: hidden;
  }

  /* Descendant, not child. The picture may be sitting inside the button
   * that opens it — written by the project or put there by the viewer —
   * and the tile should look the same either way. */
  .p-file[data-shape="thumb"] img {
    display: block;
    inline-size: 100%;
    aspect-ratio: 1;
    object-fit: cover;
  }

  .p-file[data-shape="thumb"] > .p-img-open {
    display: block;
    inline-size: 100%;
  }

  .p-file[data-shape="thumb"] > .p-file-name {
    padding: var(--p-space-1) var(--p-space-2);
  }

  /* IN A MESSAGE, THE PICTURE IS THE MESSAGE. A filename under a staged
   * upload earns its place — the sender is checking what they are about
   * to send, and "IMG_4417.jpg" is how they tell two of them apart. Sent,
   * it stops being a file and becomes a picture somebody is showing you,
   * and the name under it is a caption nobody wrote.
   *
   * Hidden rather than dropped from the markup, deliberately: the same
   * element is the viewer's title and the accessible name of the button
   * that opens it, both of which read `textContent` and neither of which
   * cares whether it is painted. Ask a project to leave it out and it is
   * left out of those too. */
  .p-bubble .p-file[data-shape="thumb"] > .p-file-name {
    display: none;
  }

  /* EXCEPT WHEN THE NAME IS THE QUESTION.
   *
   * Digest asks the reader to pick one of several candidate covers and
   * routes their answer — "the second one" — back to a parked job by
   * position. There the label is not a caption nobody wrote, it is the
   * only way the reader can name what they are choosing: hide it and
   * they are asked to pick a number that is not on screen.
   *
   * It paints the element the rule above hid rather than adding a second
   * one, so the corner badge, the viewer's title and the open button's
   * accessible name stay the same string. Leading corner, because the
   * trailing one is already `.p-file-act`'s, and it takes the scrim for
   * the same reason everything else over a photograph does. */
  .p-files[data-numbered] .p-file[data-shape="thumb"] > .p-file-name {
    display: grid;
    place-items: center;
    position: absolute;
    inset-block-start: var(--p-space-1);
    inset-inline-start: var(--p-space-1);
    min-inline-size: calc(var(--p-space-10) + 1px);
    min-block-size: calc(var(--p-space-10) + 1px);
    /* Josefin Sans's digits carry more room below their baseline than
     * above their caps. A two-pixel top inset puts the visible numeral in
     * the middle without growing the plate back to its previous size. */
    padding: 2px var(--p-space-1) 0;
    border-radius: var(--p-radius-full);
    background: var(--p-scrim);
    color: var(--p-text-strong);
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-semibold);
    /* The numbers line up under each other, which is what makes a row of
     * them read as an ordered set rather than as decoration. */
    font-variant-numeric: tabular-nums;
    text-align: center;
  }

  /* The trailing control sits ON the picture in thumb form, because a
   * 4.5rem tile has no room for a row of furniture under it — and it
   * takes the scrim for the same reason the gallery's marks do: it is
   * over a photograph of anything. */
  .p-file[data-shape="thumb"] > .p-file-act {
    position: absolute;
    inset-block-start: var(--p-space-1);
    inset-inline-end: var(--p-space-1);
    border-radius: var(--p-radius-full);
    background: var(--p-scrim);
    color: var(--p-text-strong);
  }

  .p-file[data-shape="thumb"] { position: relative; }

  /* spec: presence */
  /* ---- presence -------------------------------------------------------
   * The state of something that is NOT this app's own work — a service
   * that is up, a model that is loaded, a peer that is connected.
   *
   * Every indicator in Feedback answers "is my work done yet": progress,
   * skeleton, busy, stream, spinner. All five are about the local app and
   * all five end. This one may sit unchanged for a week, and nothing it
   * says is about waiting, so none of them fit.
   *
   * THE SHAPE IS THE SIGNAL, NOT THE COLOUR. `.p-badge[data-dot]` already
   * covers a status dot that has a label beside it, which is what makes
   * its colour safe. This one has no label — it is a bare dot in a status
   * strip — so under rule 6 hue cannot be carrying the meaning alone.
   * Filled means on, a hollow ring means off, and a dashed ring means
   * nobody knows. Read those with the colour removed and they still say
   * three different things.
   *
   * Sized in em so it tracks whatever it sits beside, the way an icon
   * does. */
  /* NEUTRAL UNLESS ASKED. `var(--p-tone, …)` looks like it defaults to the
   * fallback and does not: --p-tone is defined on :root as the accent, so
   * it is never missing and the fallback is dead code. Every unstyled dot
   * came out accent-purple, including the one that meant "offline", which
   * is the theme colour announcing a dead service.
   *
   * So the default is written into a property of this pattern's own, and a
   * tone only replaces it when one is actually declared. */
  .p-presence {
    --p-presence: var(--p-text-muted);
    display: inline-block;
    flex: none;
    inline-size: 0.6em;
    block-size: 0.6em;
    border: 0.15em solid var(--p-presence);
    border-radius: var(--p-radius-full);
    background: var(--p-presence);
    /* The same correction the badge's dot needs, for the same reason: a
     * circle has no cap height, so a shift sized for capitals drops it. */
    translate: 0 calc(-1 * var(--p-optical-applied));
  }

  /* An explicit tone or intent is the only thing that colours it. */
  .p-presence[data-tone],
  .p-presence[data-intent] {
    --p-presence: var(--p-tone);
  }

  /* Hollow. The border stays, the fill goes — so the dot keeps its exact
   * size and the row does not shift by a pixel when a service drops. */
  .p-presence[data-state="off"] {
    background: none;
  }

  /* Unknown is a third answer and not a dimmer second one — "faint" is
   * what a disabled control looks like, and this is not disabled. It is a
   * live reading that has not arrived.
   *
   * FOUR DOTS, NOT A DASHED RING. A dashed border draws its gaps from the
   * stroke length, so at 0.6em there is barely more than one dash in the
   * circumference and it comes out as a stray comma rather than as a ring
   * with holes in it. Whatever the size, four round dots are four round
   * dots — the shape survives being small, which is the whole reason the
   * shape is carrying the state.
   *
   * And it turns, because this is the one state that is going somewhere.
   * On and off are settled facts that can sit still for a week; checking
   * is a thing in progress, so the only moving indicator in the set is on
   * the only state that is not at rest. */
  .p-presence[data-state="unknown"] {
    border-color: transparent;
    background:
      radial-gradient(circle at 50% 50%, var(--p-presence) 46%, transparent 50%) 50% 0%,
      radial-gradient(circle at 50% 50%, var(--p-presence) 46%, transparent 50%) 100% 50%,
      radial-gradient(circle at 50% 50%, var(--p-presence) 46%, transparent 50%) 50% 100%,
      radial-gradient(circle at 50% 50%, var(--p-presence) 46%, transparent 50%) 0% 50%;
    background-size: 42% 42%;
    background-repeat: no-repeat;
    /* Painted across the border box, so the four dots use the whole
     * circle the other two states fill — not the smaller box inside a
     * border that has been made invisible. */
    background-origin: border-box;
    background-clip: border-box;
    animation: p-spin var(--p-dur-spin) linear infinite;
  }

  /* NOT aria-hidden by default, and not given a role here. A dot that is
   * the only statement of a fact has to be announced, and only the
   * project knows the words: put `role="img"` and an `aria-label` on it,
   * or a visible label beside it and aria-hidden on the dot. `pulse
   * check` reports one that has neither. */

  /* spec: banners */
  /* spec: toasts */
  /* =================================================================
   * Banners and toasts
   * ================================================================= */
  .p-banner {
    display: flex;
    align-items: flex-start;
    gap: var(--p-space-3);
    padding: var(--p-space-4);
    border: 1px solid var(--p-tone-border);
    border-inline-start-width: 3px;
    border-radius: var(--p-radius-md);
    background: var(--p-tone-surface);
    color: var(--p-text);
    font-size: var(--p-text-sm);
  }

  .p-banner > .p-banner-icon {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    inline-size: 1.25rem;
    block-size: 1.25rem;
    border-radius: var(--p-radius-full);
    background: var(--p-tone);
    color: var(--p-tone-fg);
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-bold);
  }

  .p-banner strong {
    color: var(--p-tone-text);
  }

  /* The icon is centred geometrically; the copy beside it needs the cap
   * correction or it reads high against the circle. */
  .p-banner > :not(.p-banner-icon) {
    translate: 0 var(--p-optical-text);
  }

  /* The stack is PINNED AT THE CORNER and grows away from it, and that
   * one decision is what makes the motion cheap. Newest is appended, so
   * it lands nearest the corner; oldest is at the far end, so when it
   * expires the stack simply gets shorter and nothing that is still on
   * screen moves. Anchor the other edge and every dismissal shuffles the
   * whole column, which needs FLIP to look like anything. */
  .p-toasts {
    position: fixed;
    inset-block-end: var(--p-space-6);
    inset-inline-end: var(--p-space-6);
    z-index: var(--p-z-toast);
    display: flex;
    flex-direction: column;
    gap: var(--p-space-3);
    inline-size: min(24rem, calc(100vw - var(--p-space-12)));
    pointer-events: none;
  }

  .p-toast {
    pointer-events: auto;
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    padding: var(--p-space-3) var(--p-space-4);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-overlay);
    box-shadow: var(--p-shadow-3);
    font-size: var(--p-text-sm);
    /* A transition and `@starting-style`, not an animation, and the
     * difference matters: an entry animation fires again every time the
     * element is restyled, re-shown, or moved in the DOM. A transition
     * from a starting style runs once, when the element first appears,
     * which is the actual event being described. */
    transition:
      opacity var(--p-dur-3) var(--p-ease),
      translate var(--p-dur-3) var(--p-ease),
      scale var(--p-dur-3) var(--p-ease);
  }

  /* A TONE COLOURS THE WHOLE TOAST, exactly the way it colours a banner —
   * same surface, same border, same 3px leading edge — so "green means it
   * worked" is one fact in this system rather than one per component. It
   * was a coloured dot on a neutral slab first, on the argument that a
   * tinted slab makes a passing message look like a state the page is now
   * in. Against a wall of grey confirmations that argument loses: the
   * colour is the only part of a toast anybody reads at a glance, and a
   * 6px dot is not a glance.
   *
   * The surface is the SUBTLE one and the border is not, which is the
   * difference from a banner. A toast floats over the page on a shadow
   * rather than sitting in the flow, so it needs the edge to hold its
   * shape against whatever is behind it and it needs the fill to stay
   * quiet enough to read white text on. */
  .p-toast[data-tone] {
    border-color: var(--p-tone-border);
    border-inline-start: 3px solid var(--p-tone);
    background: var(--p-tone-surface-subtle);
  }

  .p-toast[data-tone] strong {
    color: var(--p-tone-text);
  }

  /* It rises INTO the corner it will live in. Sliding in from the side
   * crosses the page to get here and reads as something being delivered;
   * coming up out of the edge reads as something that was already down
   * there being surfaced, which is what a toast is. */
  @starting-style {
    .p-toast {
      opacity: 0;
      translate: 0 var(--p-space-6);
      scale: 0.96;
    }
  }

  /* Leaving is the entrance backwards, and slightly quicker. A dismissal
   * the reader asked for should feel answered, not sat through. */
  .p-toast[data-leaving] {
    opacity: 0;
    translate: 0 var(--p-space-4);
    scale: 0.96;
    transition-duration: var(--p-dur-2);
  }

  /* The close button is the only reason a toast is focusable, and it is
   * why the container is `pointer-events: none` — dead space between
   * toasts must not eat clicks meant for the page. */
  .p-toast > .p-toast-close {
    flex: none;
    margin-inline-start: auto;
    inline-size: var(--p-control-h-sm);
    block-size: var(--p-control-h-sm);
    padding: 0;
    border: 0;
    border-radius: var(--p-radius-sm);
    background: none;
    color: var(--p-text-subtle);
  }

  .p-toast > .p-toast-close:hover {
    background-color: var(--p-wash-hover);
    color: var(--p-text-strong);
  }

  /* spec: menus */
  /* spec: context-menu */
  /* spec: tooltips */
  /* =================================================================
   * Menus and tooltips
   * ================================================================= */
  .p-menu {
    display: flex;
    flex-direction: column;
    min-inline-size: 11rem;
    padding: var(--p-space-1);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-overlay);
    box-shadow: var(--p-shadow-3);
  }

  .p-menu > button,
  .p-menu > a {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    justify-content: flex-start;
    block-size: var(--p-control-h-sm);
    padding-inline: var(--p-space-3);
    border: 0;
    border-radius: var(--p-radius-sm);
    background: none;
    color: var(--p-text);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-regular);
    text-decoration: none;
  }

  /* THE ACCENT, not the neutral wash — and the condition for that is
   * worth stating, because it is what decides every other list in the
   * system.
   *
   * A menu is a list in which nothing is selected. Every row is an
   * action that is available right now, so the accent is unspoken for
   * and free to mean the one thing hover has to say: this one. That is
   * the whole test. Where the accent is already carrying a state, hover
   * cannot have it.
   *
   * Which is why this is deliberately not everywhere. `.p-nav > a` and a
   * tree row spend the accent on `aria-current` — the same
   * `--p-tint-soft` and `--p-text-accent` written below — so an accent
   * hover would make the page you are on indistinguishable from the row
   * under the pointer. Those two stay on the neutral wash on purpose,
   * and moving them is not a tidy-up.
   *
   * The select popup passes the test the other way round: its active
   * option is this same tint, and its SELECTED option goes a step
   * further to a full accent fill, so the two never collide.
   *
   * Focus is in the same breath as hover, so a menu walked with the
   * arrow keys lights the row a pointer would have lit. */
  .p-menu > :is(button, a):is(:hover, :focus-visible) {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
  }

  .p-menu > hr {
    margin-block: var(--p-space-1);
  }

  /* spec: keyboard-hints */
  /* ---- keyboard hints -------------------------------------------------
   * One badge per shortcut on screen, put there by `pulse-keys.js` while
   * `?` is held open. The keycaps are the system's own `kbd`, at the size
   * they come out — a hint that needed its own type scale would be a
   * second keycap style to keep in step with the first.
   *
   * pointer-events: none is not politeness. These sit over live controls
   * by definition, and a hint that eats the click for the button it is
   * labelling has broken the thing it exists to explain. */
  .p-keyhint {
    display: flex;
    gap: 2px;
    pointer-events: none;
    z-index: var(--p-z-popover);
  }

  .p-keyhint > kbd {
    font-size: var(--p-text-2xs);
    /* Tighter than a keycap in prose. That one sits in a sentence and can
     * afford to look like a key; this one is lying on top of a control,
     * so every pixel of it is covering something the reader wanted. */
    min-inline-size: 0;
    padding-inline: 0.25em;
    border-block-end-width: 1px;
    /* The accent border is what separates a hint from a keycap printed in
     * the page's own prose. Same object, two jobs, and only one of them
     * is a live overlay the reader asked to see. */
    border-color: var(--p-border-accent);
    background: var(--p-surface-overlay);
    box-shadow: var(--p-shadow-2);
  }

  .p-keyhint[hidden] { display: none; }

  /* Tethered: this one was pushed off its own control to keep it from
   * hiding under another, so the line below is the only thing still
   * saying which control it belongs to. Solid rather than dimmed —
   * a badge that has been moved needs MORE explaining, not less. */
  .p-keyhint[data-tethered] > kbd {
    border-color: var(--p-accent);
  }

  .p-keyhint-lines {
    position: fixed;
    inset: 0;
    inline-size: 100%;
    block-size: 100%;
    pointer-events: none;
    /* ABOVE the badges, not below them. A tether threads past whatever
     * pushed its badge out of place, so underneath means hidden by the
     * exact thing it exists to explain — and a line you cannot follow is
     * worse than no line, because the badge still looks unmoored and now
     * there is ink claiming otherwise. The lines start at their badge's
     * edge rather than its centre, so drawing on top costs nothing. */
    z-index: calc(var(--p-z-popover) + 1);
  }

  .p-keyhint-lines line {
    stroke: var(--p-accent);
    stroke-width: 1;
    stroke-dasharray: 2 2;
  }

  /* A menu opened at a point. `pulse-anchor.js` sets the position and
   * this sets the layer — the script never writes a z-index, because
   * which layer a thing belongs on is a decision about the system rather
   * than about arithmetic. Popover: above the page and any sticky bar,
   * below a dialog, which cannot have one of these over it. */
  .p-menu[data-open] { z-index: var(--p-z-popover); }

  /* Stated here rather than left to the user-agent sheet, for the same
   * reason the tooltip's is: `display: flex` above outranks a bare
   * `[hidden]`, so a menu told to hide would stay exactly where it was. */
  .p-menu[hidden] { display: none; }

  .p-menu-label {
    padding: var(--p-space-2) var(--p-space-3) var(--p-space-1);
    color: var(--p-text-subtle);
    font-size: var(--p-text-2xs);
    font-weight: var(--p-weight-semibold);
    letter-spacing: var(--p-tracking-label);
  }

  .p-tooltip {
    --p-pad-block: var(--p-space-1);
    padding-inline: var(--p-space-2);
    border-radius: var(--p-radius-sm);
    /* FOLLOWS THE MODE, it does not inverse it. This was
     * text-strong-on-surface, which paints a near-white slab on a dark
     * page and a near-black one on a light page — the tooltip was the
     * only thing in the system wearing the opposite mode to everything
     * around it, and on a dark screen it read as a flash. It is a small
     * popover, so it is dressed as one: the same overlay surface a menu
     * gets, and the border that keeps it off the page behind it. */
    background: var(--p-surface-overlay);
    color: var(--p-text-strong);
    border: 1px solid var(--p-border);
    font-size: var(--p-text-xs);
    box-shadow: var(--p-shadow-2);
    /* Wraps rather than running off the side. A tip long enough to need
     * two lines is a tip that should have been a label, but it happens,
     * and a single line 400px wide is worse than two short ones. */
    max-inline-size: 20rem;
    /* NEVER EATS A POINTER EVENT. `pulse-anchor.js` puts this over the
     * page on a fixed layer, and a tooltip that swallows the click aimed
     * at the control underneath it is a tooltip that breaks the control
     * it exists to explain. It also stops the pointer entering the tip
     * and re-triggering the hover it was leaving. */
    pointer-events: none;
    /* Above popovers, below modals: a tip may explain a control inside an
     * open menu, and must never cover a dialog's own content. */
    z-index: var(--p-z-popover);
  }

  /* Nothing to see until it has been placed. `hidden` is what the script
   * toggles, and a bare `[hidden]` loses to any `display` this rule set
   * or a project sets — so it is stated here, next to the thing it hides,
   * rather than left to the user-agent sheet. */
  .p-tooltip[hidden] { display: none; }

  /* spec: vertical-tabs */
  /* spec: colouring-a-folder */
  /* spec: rearrangeable-folders */
  /* spec: grip-menu */
  /* spec: trailing-actions */
  /* spec: the-created-line */
  /* spec: rows */
  /* spec: nav-lists */
  /* spec: icon-rails */
  /* spec: underline-nav */
  /* spec: elastic-end */
  /* spec: breadcrumb */
  /* =================================================================
   * Navigation
   * ================================================================= */
  .p-nav {
    display: flex;
    flex-direction: column;
    gap: 1px;
  }

  /* A minimum, not a height. A nav item is usually one line and should
   * match a control, but an icon rail stacks a caption under the icon
   * and a long destination name wraps — a fixed block-size made both of
   * those overlap the item below instead of growing. */
  .p-nav > a {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    min-block-size: var(--p-control-h);
    padding-inline: var(--p-space-3);
    border-radius: var(--p-radius-control);
    color: var(--p-text-muted);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-medium);
    text-decoration: none;
  }

  .p-nav > a:hover {
    background: var(--p-wash-hover);
    color: var(--p-text-strong);
  }

  /* :not([aria-current="false"]), not a bare [aria-current]. The
   * attribute's OFF state is the string "false", not absence — that is
   * how ARIA defines it and how server templates write it, so a bare
   * attribute selector marks every item in the list as current. It went
   * unnoticed for as long as every nav in the project omitted the
   * attribute instead of writing it false. */
  .p-nav > a[aria-current]:not([aria-current="false"]) {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
    font-weight: var(--p-weight-semibold);
  }

  /* ---- stacked: icon over label -------------------------------------
   * For a rail of many destinations, where the icons are what you
   * actually navigate by and the caption is there for the ones you are
   * not sure about. Works down a side or across the top; a project sets
   * the direction on .p-nav itself.
   *
   * --p-optical-applied is forced to 0 here, and that is the whole
   * reason this is a Pulse variant rather than four lines in a project.
   * The optical rule shifts a control's content down so its CAPITALS
   * look centred, and publishes the shift so icons inside can subtract
   * it back out. Both halves are right for a single line of text with an
   * icon beside it, and both are wrong for a column: there is nothing to
   * optically centre, and the icon ends up lifted off its own caption by
   * a correction that was never applied to anything.
   *
   * That second half bit Digest. Its rail set the `padding-block`
   * shorthand, which blew the shift away — but the token kept announcing
   * it, so every icon in the rail sat 0.1em high for a shift that was
   * not there. Publish the correction only where you applied it. */
  .p-nav[data-stack] > a {
    flex-direction: column;
    justify-content: center;
    gap: var(--p-space-1);
    padding-block: var(--p-space-3);
    text-align: center;
    line-height: var(--p-leading-tight);
    --p-optical-applied: 0px;
  }

  .p-nav[data-stack] > a > span {
    font-size: var(--p-text-2xs);
    letter-spacing: var(--p-tracking-label);
    max-inline-size: 100%;
    text-wrap: balance;
  }

  /* Across the top rather than down the side. One rank, so it scrolls
   * rather than wraps — the same argument as a tablist, and for the same
   * reason: a second row of destinations reads as a second rank. */
  .p-nav[data-axis="x"] {
    flex-direction: row;
    overflow-x: auto;
    scrollbar-width: none;
    scroll-snap-type: x proximity;
  }

  .p-nav[data-axis="x"]::-webkit-scrollbar { display: none; }

  /* A rail that shares its bar with anything else never gives way to
   * it. A scroller's min-width is 0 in a flex row, so left alone a long
   * status badge beside the rail squeezes it to a few pixels and the
   * hidden scrollbar keeps the loss quiet — Judge showed one of nine
   * destinations. The rail is not the shrinker: it keeps its content
   * width, the sibling ellipsizes, and if the bar then no longer fits
   * the window the page scrolls sideways where the probe can see it.
   * The rail on its own tier (`[data-rail]`, above) still fills and
   * scrolls, because there it has nobody to lose to. */
  .p-bar > .p-nav[data-axis="x"] {
    flex: none;
  }

  .p-nav[data-axis="x"] > a {
    flex: none;
    scroll-snap-align: start;
  }

  /* ---- pinned ends ---------------------------------------------------
   * data-pin="start" and data-pin="end" hold a destination against its
   * edge while everything between them scrolls. For the two that are
   * never "one of the list" — the thing the app is for at one end,
   * settings at the other.
   *
   * position: sticky, not a separate element outside the scroller. The
   * obvious build is three boxes — pin, scroller, pin — and it costs a
   * wrapper, a second .p-nav, and a copy of every rail rule for items
   * that are no longer inside one. Sticky keeps all of them in the same
   * list, which is also what a screen reader should hear: one nav, in
   * order, not three.
   *
   * The auto margins are what centre the middle. Free space is split
   * between them when the items fit, so the group sits centred between
   * the pins; when the rail overflows there is no free space, the
   * margins collapse to nothing, and it packs and scrolls instead. One
   * mechanism, both states, no media query.
   *
   * A pin needs an opaque backdrop or the list scrolls THROUGH it rather
   * than behind it, so set --p-nav-bg to whatever the rail sits on. */
  .p-nav[data-axis="x"] {
    --p-nav-bg: var(--p-surface);
    /* Keep a keyboard-focused item clear of the pins. Without it, tabbing
     * along the rail scrolls the next item to the container edge — which
     * is underneath a pin, so the thing you just focused is the one thing
     * you cannot see. */
    scroll-padding-inline: var(--p-nav-pin-w, 5rem);
  }

  .p-nav[data-axis="x"] > [data-pin] {
    position: sticky;
    z-index: 1;
    background: var(--p-nav-bg);
  }

  /* The offset shadow is the pin's backdrop extended OUTWARD, and it is
   * load-bearing rather than decoration.
   *
   * Sticky offsets are measured from the scrollport, which is the
   * container's PADDING box — so a rail with any inline padding of its
   * own parks its pins that far in from the edge, and the list scrolls
   * through the strip of padding beside them in full view. A solid
   * same-colour shadow thrown outward covers exactly that strip, at
   * whatever width the project chose, and the scroll container clips it
   * at the border edge so it can never spill onto the page. No token to
   * keep in sync with the padding, and nothing to get wrong. */
  .p-nav[data-axis="x"] > [data-pin="start"] {
    inset-inline-start: 0;
    margin-inline-end: auto;
    border-inline-end: 1px solid var(--p-line);
    box-shadow: calc(-1 * var(--p-nav-bleed, 3rem)) 0 0 var(--p-nav-bg);
  }

  .p-nav[data-axis="x"] > [data-pin="end"] {
    inset-inline-end: 0;
    margin-inline-start: auto;
    border-inline-start: 1px solid var(--p-line);
    box-shadow: var(--p-nav-bleed, 3rem) 0 0 var(--p-nav-bg);
  }

  .p-nav[data-axis="x"]:dir(rtl) > [data-pin="start"] {
    box-shadow: var(--p-nav-bleed, 3rem) 0 0 var(--p-nav-bg);
  }

  .p-nav[data-axis="x"]:dir(rtl) > [data-pin="end"] {
    box-shadow: calc(-1 * var(--p-nav-bleed, 3rem)) 0 0 var(--p-nav-bg);
  }

  /* A bare <hr> separates groups of destinations, exactly as it does in
   * a toolbar, and it turns with the rail — which way a separator points
   * is the container's business, not the rule's.
   *
   * Bare, NOT .p-divider, and that is not a style choice. Utilities are
   * the last layer, so a pattern cannot restyle one: .p-divider pins
   * block-size to 1px and no rule here can win, whatever its
   * specificity. Wrote it with the class first and got a horizontal line
   * lying across a horizontal rail. */
  .p-nav > hr {
    flex: none;
    block-size: 1px;
    inline-size: auto;
    margin-block: var(--p-space-2);
    border: 0;
    background: var(--p-line);
  }

  .p-nav[data-axis="x"] > hr {
    inline-size: 1px;
    block-size: auto;
    align-self: stretch;
    margin-block: var(--p-space-2);
    margin-inline: var(--p-space-1);
  }

  /* ---- elastic end ---------------------------------------------------
   * Styling for pulse-scroll.js, which sets --p-overscroll while a
   * wheel gesture pushes against an end that has already been reached.
   * Without the script the property is never set and this is inert.
   *
   * ONLY THE SCROLLING CONTENT MOVES. The transform is on the children,
   * not the container, and pinned ends are excluded from it.
   *
   * The container was the first attempt, on the theory that a rail
   * bending as one object reads as "this is all of it". It does not. A
   * pinned end is pinned precisely because it is NOT part of the list,
   * and a scroll gesture that shifts it says the opposite — that it was
   * in the list all along and the whole strip is one scrolling thing.
   * Held still, the pins are the fixed frame the content pulls against,
   * which is what makes the pull read as the content running out.
   *
   * The same offset goes on every child, so the group moves as one, and
   * translate does not affect layout, so nothing reflows.
   *
   * No transition while pulling and one on release, which is what makes
   * it feel elastic rather than animated: the stretch tracks the gesture
   * exactly, and only the spring back is timed.
   * ------------------------------------------------------------------ */
  [data-overscroll] > * {
    translate: var(--p-overscroll) 0;
    transition: translate var(--p-dur-3) var(--p-ease-spring);
  }

  [data-overscroll][data-overscrolling] > * {
    transition: none;
  }

  [data-overscroll] > [data-pin] {
    translate: none;
  }

  /* Reduced motion keeps the signal and drops the travel. Removing it
   * outright would leave the same silence the effect exists to break, so
   * the content dims for the length of the push instead — the
   * information is "you are at the end", and it survives in either
   * channel. The pins hold still here too, for the same reason. */
  @media (prefers-reduced-motion: reduce) {
    [data-overscroll] > * {
      translate: none;
      transition: opacity var(--p-dur-2) var(--p-ease);
    }

    [data-overscroll][data-overscrolling] > *:not([data-pin]) {
      opacity: 0.72;
    }
  }

  /* ---- underline: navigation that looks like tabs --------------------
   * A row of LINKS across the top of a section — settings pages, saved
   * sub-views — where each one is a real URL you can bookmark and go
   * back from.
   *
   * Visually this is the tablist, and it is deliberately not one.
   * role="tab" describes a control that swaps a panel in place without
   * leaving the page; put it on something that navigates and every
   * screen reader announces a tab that then throws the whole document
   * away. So the markup stays a nav of anchors and the state stays
   * aria-current="page", which is what "this is the page you are on"
   * has always been called. */
  .p-nav[data-variant="underline"] {
    flex-direction: row;
    gap: var(--p-space-1);
    border-block-end: 1px solid var(--p-line);
    overflow-x: auto;
    scrollbar-width: none;
    scroll-snap-type: x proximity;
  }

  .p-nav[data-variant="underline"]::-webkit-scrollbar { display: none; }

  .p-nav[data-variant="underline"] > a {
    flex: none;
    scroll-snap-align: start;
    scroll-margin-inline: var(--p-space-4);
    --p-pad-block: var(--p-space-3);
    padding-inline: var(--p-space-4);
    border-radius: var(--p-radius-sm) var(--p-radius-sm) 0 0;
    font-weight: var(--p-weight-semibold);
    white-space: nowrap;
  }

  /* The same three declarations the real tablist uses for its selected
   * tab, so the two are indistinguishable on screen. If they ever drift
   * apart, a reader learns that an underline means two different things. */
  .p-nav[data-variant="underline"] > a[aria-current]:not([aria-current="false"]) {
    color: var(--p-text-accent);
    background: var(--p-tint-subtle);
    box-shadow: inset 0 -2px 0 var(--p-accent);
  }

  .p-breadcrumb {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    font-size: var(--p-text-xs);
    color: var(--p-text-muted);
  }

  .p-breadcrumb > * + *::before {
    content: "/";
    margin-inline-end: var(--p-space-2);
    color: var(--p-text-subtle);
  }

  /* spec: tables */
  /* spec: key-value */
  /* spec: choosing-an-indicator */
  /* spec: what-the-checker-enforces */
  /* spec: the-command-line */
  /* =================================================================
   * Data display
   * ================================================================= */
  .p-table-wrap {
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-md);
    background: var(--p-fill-raised);
    overflow: auto;
  }

  .p-table-wrap > table thead th {
    position: sticky;
    inset-block-start: 0;
    z-index: 1;
    /* Opaque, or rows show through when it sticks — the tint is layered
     * over the wrapper's own surface rather than replacing it. */
    background:
      linear-gradient(var(--p-tint-subtle), var(--p-tint-subtle)),
      linear-gradient(var(--p-surface-raised), var(--p-surface-raised));
  }

  /* spec: metric-panel */
  /* A PANEL SHOWING ONE LIVE MEASUREMENT.
   *
   * The frame around a chart, a sparkline, a bar or a gauge — not the
   * drawing itself, which stays the project's. What this owns is the
   * thing that goes wrong when nine of them sit in one grid: the size
   * of the number.
   *
   * Dashboard measured its own screen and found single-series panels
   * reading their current value at 18.99px under a 39.78px head, beside
   * multi-series panels at 15.29px under 33.38px — because each panel
   * had picked its own type scale out of the generic card, and level 3
   * had nothing to say about either. Nine displays, nine answers, one
   * screen. `.p-metric` is one answer.
   *
   * The value pins itself to the trailing edge with an auto margin
   * rather than needing a `.p-grow` beside it, so the head stays two
   * elements: what it is, and what it reads. */
  .p-metric {
    margin-inline-start: auto;
    display: flex;
    align-items: baseline;
    gap: var(--p-space-2);
    color: var(--p-text-strong);
    font-size: var(--p-text-md);
    font-weight: var(--p-weight-semibold);
    /* Tabular figures, because the whole point of a live reading is that
     * it changes: proportional digits make the number jitter sideways
     * every tick and drag the panel's edge with it. */
    font-variant-numeric: tabular-nums;
    line-height: var(--p-leading-tight);
    white-space: nowrap;
  }

  /* The unit, and any second reading — a temperature beside a load, a
   * ceiling beside a used figure. Quieter and smaller, because it is
   * the same fact's caption rather than a competing number. */
  .p-metric :is(small, .p-metric-unit) {
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-regular);
  }

  /* The drawing's own box. A fixed height is what lines nine panels up
   * across a grid — left to the content, one chart with a legend and
   * one without disagree by a row and the grid goes ragged. `--p-plot-h`
   * is per-project and per-panel; it is not a theme token because it is
   * a fact about a screen's density, not about a palette. */
  .p-plot {
    /* Both are written by `pulse-chart.js` once it knows how wide the
     * value labels came out and how tall the time labels are. Declared
     * here so the overlays are correctly inset with no script at all. */
    --p-plot-gutter: 0px;
    --p-plot-foot: 0px;
    position: relative;
    display: block;
    inline-size: 100%;
    block-size: var(--p-plot-h, 7rem);
    min-inline-size: 0;
  }

  .p-plot > svg {
    display: block;
    inline-size: 100%;
    block-size: 100%;
  }

  /* Nothing yet, and nothing wrong. Both states keep the plot's height
   * so a grid of panels does not reflow the moment one of them answers
   * — which is the failure the reader notices, because it moves the
   * panel they were reading. */
  .p-plot > :is(.p-skeleton, .p-empty) {
    block-size: 100%;
    padding: 0;
  }

  .p-plot > .p-empty {
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--p-text-sm);
  }

  /* spec: chart-card */
  /* THE SCAFFOLDING AROUND A TIME SERIES.
   *
   * `.p-plot` above is the box. This is what goes in it when the drawing
   * is a line against time: the grid, the numbers down the side, the
   * times along the bottom, the crosshair, and the legend that turns
   * into the readout. The project still draws its own data — Pulse draws
   * everything that is the same on every chart, because that is the part
   * nine projects each get wrong in a different way.
   *
   * `pulse-chart.js` writes every element below. None of it is authored
   * by hand: the markup a project writes is the plot, the paths, and the
   * legend. Without the script you get the drawing and nothing else,
   * which is a correct sparkline rather than a broken chart.
   *
   * THE GUTTER AND THE FOOT ARE PADDING, not margin, and they come out
   * of the plot's declared height. Nine panels in a grid keep exactly
   * the same outer height whether or not they carry axes — the drawing
   * gets smaller, the grid stays level. */
  .p-plot[data-chart] {
    box-sizing: border-box;
    padding-inline-start: var(--p-plot-gutter);
    padding-block-end: var(--p-plot-foot);
  }

  /* Both overlays sit on the drawing's content box, not the plot's
   * padding box, so a percentage inside them is a percentage of the
   * data area. The grid is written before the <svg> so it paints under
   * the lines; the readout layer after it, so a dot is never buried. */
  .p-plot > :is(.p-plot-grid, .p-plot-read) {
    position: absolute;
    inset-block: 0 var(--p-plot-foot);
    inset-inline: var(--p-plot-gutter) 0;
    pointer-events: none;
  }

  /* Every mark below is placed by one or two percentages the script
   * writes. Both are declared so a mark with nothing set lands at the
   * plot's origin rather than having its placement dropped. */
  :is(.p-plot-rule, .p-plot-value, .p-plot-time, .p-plot-hair, .p-plot-dot) {
    --p-at: 0%;
    --p-y: 0%;
  }

  /* A mark with no series number falls back to the accent, which is
   * right for the single-series case: one line in a panel does not need
   * a colour that distinguishes it from anything. */
  :is(.p-series, .p-plot-dot, .p-legend > *) {
    --p-key: var(--p-accent);
  }

  /* Hairline, solid, never dashed, one step off the surface. A dashed
   * gridline reads as a line on the chart — as a threshold, a target, a
   * projection — and the reader has to look twice to learn it is not
   * data. Solid and quiet is unambiguous. */
  .p-plot-rule {
    position: absolute;
    background: var(--p-line);
  }

  .p-plot-rule[data-axis="value"] {
    inset-inline: 0;
    inset-block-start: var(--p-at);
    block-size: 1px;
    margin-block-start: -0.5px;
  }

  .p-plot-rule[data-axis="time"] {
    inset-block: 0;
    inset-inline-start: var(--p-at);
    inline-size: 1px;
    margin-inline-start: -0.5px;
  }

  /* The baseline is not a gridline. Zero is a fact about the chart
   * rather than a reading off it, so it stays when the grid is off. */
  .p-plot-rule[data-base] {
    background: var(--p-border);
  }

  .p-plot-value,
  .p-plot-time {
    position: absolute;
    color: var(--p-text-subtle);
    font-size: var(--p-text-2xs);
    font-variant-numeric: tabular-nums;
    line-height: 1;
    white-space: nowrap;
  }

  .p-plot-value {
    inset-inline-end: 100%;
    inset-block-start: var(--p-at);
    translate: 0 -50%;
    margin-inline-end: var(--p-space-2);
  }

  .p-plot-time {
    inset-block-start: 100%;
    inset-inline-start: var(--p-at);
    translate: -50% 0;
    margin-block-start: var(--p-space-2);
  }

  /* A tick that would hang off the panel is pulled inside it instead of
   * being dropped: the reader loses the exact centring, not the label. */
  .p-plot-time[data-edge="start"] {
    translate: 0 0;
  }

  .p-plot-time[data-edge="end"] {
    translate: -100% 0;
  }

  /* The crosshair, and one dot per series that has a sample there. The
   * whole plot is the hit target, so this appears wherever the pointer
   * is over the panel rather than only on the 2px lines. */
  .p-plot-hair {
    position: absolute;
    inset-block: 0;
    inset-inline-start: var(--p-at);
    inline-size: 1px;
    margin-inline-start: -0.5px;
    background: var(--p-border-strong);
  }

  .p-plot-dot {
    position: absolute;
    inset-inline-start: var(--p-at);
    inset-block-start: var(--p-y);
    inline-size: 8px;
    block-size: 8px;
    border-radius: var(--p-radius-full);
    background: var(--p-key);
    /* The ring is the panel's own background, so the dot stays readable
     * where two lines cross and one dot lands on the other's stroke. */
    box-shadow: 0 0 0 2px var(--p-surface-raised);
    translate: -50% -50%;
  }

  /* The axis labels are always there; the crosshair and its dots are
   * not. Both live in the same overlay because both are placed against
   * the drawing's content box, so the hiding is per-mark rather than
   * per-layer — hiding the layer takes the numbers with it. */
  .p-plot:not([data-reading]) :is(.p-plot-hair, .p-plot-dot) {
    visibility: hidden;
  }

  /* THE SERIES THEMSELVES. Written as <path> rather than <polyline>
   * because a path can stop and start again, and a gap in telemetry has
   * to draw as a gap. A line ruled straight across an outage is the
   * chart telling the reader something that did not happen. */
  .p-series {
    fill: none;
    stroke: var(--p-key);
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    vector-effect: non-scaling-stroke;
  }

  /* Identity, not rank. `data-key` is the series' own number and it does
   * not change when a sibling is filtered out — which is the whole point,
   * because the reader has learned which colour is which machine. */
  :is(.p-series, .p-plot-dot, .p-legend > *)[data-key="1"] { --p-key: var(--p-series-1); }
  :is(.p-series, .p-plot-dot, .p-legend > *)[data-key="2"] { --p-key: var(--p-series-2); }
  :is(.p-series, .p-plot-dot, .p-legend > *)[data-key="3"] { --p-key: var(--p-series-3); }
  :is(.p-series, .p-plot-dot, .p-legend > *)[data-key="4"] { --p-key: var(--p-series-4); }
  :is(.p-series, .p-plot-dot, .p-legend > *)[data-key="5"] { --p-key: var(--p-series-5); }

  /* While a range reloads, hold the last render rather than flashing a
   * skeleton: the reader keeps their place, and the numbers they are
   * looking at are a few seconds old rather than gone. */
  .p-plot[data-stale] > :is(svg, .p-plot-grid) {
    opacity: 0.55;
    transition: opacity var(--p-dur-2) var(--p-ease);
  }

  /* THE LEGEND, WHICH IS ALSO THE READOUT.
   *
   * Resting it names the series. Hovered it shows that series' value at
   * the crosshair, in the same place, so the eye does not travel and
   * nothing floats over the data. The head keeps saying what the panel
   * is and what it reads now. */
  /* SECONDARY INFORMATION, PRICED AS SUCH. This row is a key and a
   * readout, not a caption anybody reads twice, and nine of them stack
   * up: Dashboard reported the footer taking conspicuous height and
   * width out of every panel in a nine-card grid. The gaps are one step
   * down from where they were and the swatch is a dot. */
  .p-legend {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--p-space-1) var(--p-space-3);
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
    line-height: var(--p-leading-tight);
  }

  .p-legend > * {
    display: inline-flex;
    align-items: center;
    gap: var(--p-space-1);
  }

  /* A dot, not a line sample. The line sample was the more literal
   * answer — it is a piece of the mark the reader is hunting for — and
   * it cost 0.875rem of width per series to say what 6px says, on a row
   * whose whole job is to be small. The plot's own hover dot is round
   * too, so the shapes still rhyme. */
  .p-legend > [data-key]::before {
    content: "";
    flex: none;
    inline-size: 6px;
    block-size: 6px;
    border-radius: var(--p-radius-full);
    background: var(--p-key);
  }

  /* Values lead and labels follow, which here means the value takes the
   * label's place. The item's resting width is pinned by the script so a
   * six-character name giving way to two digits cannot make the row
   * reflow under the pointer. */
  .p-legend-read {
    display: none;
    color: var(--p-text-strong);
    font-weight: var(--p-weight-semibold);
    font-variant-numeric: tabular-nums;
  }

  .p-legend[data-reading] .p-legend-name {
    display: none;
  }

  .p-legend[data-reading] .p-legend-read {
    display: inline;
  }

  /* The window at rest, the hovered time while reading — one slot, at
   * the head of the row, so the reader always knows which moment the
   * numbers beside it belong to. */
  .p-legend-when {
    color: var(--p-text-subtle);
    font-variant-numeric: tabular-nums;
  }

  /* THE ROW PULSE WRITES WHEN THE PROJECT DID NOT.
   *
   * A chart of one line has no legend and should not have one — the head
   * already names it — but it needs the readout just as much, and until
   * this existed a single-series chart drew a crosshair and a dot and
   * then said nothing anywhere. Six of Dashboard's panels.
   *
   * At rest it shows only the window, so it costs one short line and no
   * furniture. The marks appear with the numbers, and they keep their
   * space when they go, because a readout that resizes the row under the
   * pointer is the failure the pinning exists to prevent. */
  .p-legend[data-readout]:not([data-reading]) > [data-series] {
    visibility: hidden;
  }

  /* spec: fitted-table */
  /* A TABLE THAT FITS INSTEAD OF SCROLLING.
   *
   * The default wrapper above exists so a wide table scrolls sideways,
   * which is right when the reader is comparing many numbers across a
   * row and every column has to keep its own width. It is wrong for a
   * library of short semantic fields — song, album, added, status —
   * where the sideways gesture hides data that would have fitted if
   * anything had been allowed to wrap. Both are real, so this is opt-in
   * and the default does not move. */
  .p-table-wrap[data-fit] {
    container-type: inline-size;
    overflow-x: hidden;
    /* WITHOUT THESE TWO THE WRAPPER COLLAPSES TO ITS BORDERS. Inline-size
     * containment is what makes the container queries below possible, and
     * it also means the element's own width may not be computed from its
     * contents — so anywhere the width WOULD have come from the contents,
     * which is every flex and grid parent that shrink-to-fits, it becomes
     * zero and the table vanishes. It did, in this specification's own
     * demo frame, the first time this was built. Asking for the parent's
     * width instead is the answer; `max-inline-size` still caps it, and
     * the flex floor keeps a long cell from pushing a row open. */
    inline-size: 100%;
    min-inline-size: 0;
  }

  .p-table-wrap[data-fit] > table {
    inline-size: 100%;
    /* `overflow-wrap` alone changes nothing here. The automatic
     * algorithm sizes each column from its widest cell first, so the
     * column never becomes narrow enough for wrapping to apply — the
     * table just gets wider. Fixing the layout is what hands the width
     * back to the container. Column widths then come from the header
     * row, so set them there: <th style="inline-size: 40%">. */
    table-layout: fixed;
  }

  .p-table-wrap[data-fit] :is(th, td) {
    min-inline-size: 0;
    white-space: normal;
    /* `anywhere` rather than `break-word`, because the values that
     * overflow a narrow column are the ones with no spaces in them —
     * an id, a path, a track name run together. */
    overflow-wrap: anywhere;
  }

  /* AN ACTIONS COLUMN NEEDS A LENGTH HERE. Its `inline-size: 1%` is the
   * shrink-wrap idiom under the automatic layout; under `fixed` it
   * resolves literally, and Airlock's inspect button was left in a 4px
   * column off the right edge of a phone. The fixed layout cannot size a
   * column from its content, so give it one icon button and its insets,
   * and let a row with more controls say how wide on the header:
   * <th data-cell="actions" style="--p-actions-w: 5rem">. */
  .p-table-wrap[data-fit] :is(th, td)[data-cell="actions"] {
    inline-size: var(--p-actions-w, calc(var(--p-control-h) + var(--p-space-3) * 2));
  }

  /* Columns give way in a declared order as the container narrows:
   * data-yield="1" goes first, then 2, then 3. Mark every cell in the
   * column, header included — CSS can address a table column only by
   * position, and a rule counting positions breaks the moment a column
   * is inserted.
   *
   * The three widths are literal because they have to be: a container
   * query condition cannot read a custom property, so there is no token
   * to theme here. Named in one place, at least.
   *
   * NEVER YIELD THE ONLY COPY OF SOMETHING. A hidden column is data the
   * reader on a phone cannot reach at all, so what goes is what is
   * repeated elsewhere on the row or recoverable by opening it. */
  @container (max-width: 34rem) {
    .p-table-wrap[data-fit] :is(th, td)[data-yield="1"] { display: none; }
  }

  @container (max-width: 26rem) {
    .p-table-wrap[data-fit] :is(th, td)[data-yield="2"] { display: none; }
  }

  @container (max-width: 21rem) {
    .p-table-wrap[data-fit] :is(th, td)[data-yield="3"] { display: none; }
  }

  /* spec: the-next-page */
  /* The line at the end of a list that is still arriving. It carries
   * both sentences and Pulse shows one: the words stay the project's,
   * and a failure that says "couldn't load" in English is not something
   * a stylesheet can generate. */
  .p-more {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--p-space-2);
    margin: 0;
    padding: var(--p-space-4);
    color: var(--p-text-muted);
    font-size: var(--p-text-sm);
  }

  .p-more:not([data-failed]) [data-when="failed"] { display: none; }

  .p-more[data-failed] > :is(.p-spinner, [data-when="loading"]) {
    display: none;
  }

  .p-more[data-failed] {
    color: var(--p-text);
  }

  /* The line itself is clickable only when nothing in it is. A failed
   * page ships a Retry button, and that button is the affordance — a
   * pointer cursor over the sentence beside it would promise a second
   * target that looks like prose. The fallback stays for a project that
   * has not added the button yet, because "scroll up and back" is a
   * sentence about something impossible on a list one screen long. */
  .p-more[data-failed]:not(:has(button)) {
    cursor: pointer;
  }

  .p-kv {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--p-space-2) var(--p-space-6);
    font-size: var(--p-text-sm);
  }

  .p-kv > dt {
    color: var(--p-text-muted);
  }

  .p-kv > dd {
    margin: 0;
    color: var(--p-text-strong);
  }

  .p-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--p-space-3);
    padding: var(--p-space-16) var(--p-space-8);
    color: var(--p-text-muted);
    text-align: center;
  }

  .p-empty > h3 {
    color: var(--p-text);
  }

  /* spec: avatar */
  .p-avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    inline-size: var(--p-control-h);
    block-size: var(--p-control-h);
    flex: none;
    border-radius: var(--p-radius-full);
    background: var(--p-tint-medium);
    color: var(--p-text-accent);
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-semibold);
    overflow: hidden;
    /* The containing block for the picture that lies over the initials. */
    position: relative;
  }

  /* A picture fills the plate rather than sitting in it. `object-fit`
   * and not a background-image, because a profile picture has a subject
   * and letterboxing it to fit the box crops the face out as reliably as
   * a bad crop does — cover keeps the centre, which is where faces are.
   * The initials underneath stay in the markup as the fallback: an <img>
   * that 404s leaves them showing rather than a hole. */
  .p-avatar > img {
    /* Layered OVER the initials, not laid out beside them.
     *
     * `.p-avatar` is a flex container, so the <img> and the initials text
     * were siblings on the same line: both rendered, and the letters shoved
     * the picture out of the plate. The prose above has always described
     * the layered behaviour and the CSS never implemented it, which meant
     * the documented fallback could not be tested — the failure was in the
     * success case. AIUI hit it on two independent surfaces and wrote this
     * same fix both times. */
    position: absolute;
    inset: 0;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
  }

  /* =================================================================
   * When — the timestamp line, in one fixed shape:
   *
   *     <relative> · <time> <date>
   *
   *     <span class="p-when">
   *       <span class="p-ago">2 hours ago</span>
   *       <time datetime="2026-08-01T14:32">14:32 1 Aug</time>
   *     </span>
   *
   * Both halves, because they answer different questions. "2 hours ago"
   * is what you scan a list with; "14:32 1 Aug" is what you need the
   * moment you care which of two things came first, and a relative time
   * can never answer that. The separator is a ::before on the <time> so
   * it cannot be forgotten in one row and doubled in the next.
   *
   * A real <time datetime> rather than a span: the machine-readable
   * stamp is the only part that survives being read aloud, copied, or
   * looked at in a month.
   *
   * It is its own pattern rather than a tree part because a tree row and
   * a conversation turn both want exactly this and neither owns it.
   * ================================================================= */
  .p-when {
    display: flex;
    align-items: baseline;
    gap: var(--p-space-2);
    min-inline-size: 0;
    font-size: var(--p-text-2xs);
    color: var(--p-text-subtle);
    font-variant-numeric: tabular-nums;
  }

  /* Both halves of the line are one colour, and it is the quiet one.
   * Two greys inside a single timestamp read as a hierarchy that is not
   * there — the relative time and the stamp are the same fact twice, at
   * two precisions. What the contrast is for is separating the whole
   * line from the title above it. */
  .p-ago {
    flex: none;
  }

  .p-when > time {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* The empty alt text after the slash is not decoration: generated
   * content lands in the accessible name, so without it every row is
   * announced as "… yesterday dot 22:56 Jul 30". The separator is a
   * visual join between two things already read in order. */
  .p-when > :not(:first-child)::before {
    content: "·" / "";
    margin-inline-end: var(--p-space-2);
    color: var(--p-text-subtle);
  }

  /* =================================================================
   * Tree — a vertical list that folds. Pulse's vertical tabs.
   *
   * A sidebar of conversations, files or saved searches. The static
   * anatomy is four slots in a fixed order:
   *
   *     caret · icon · name · count
   *
   *     <nav class="p-tree" aria-label="Conversations">
   *       <details class="p-tree-folder" open>
   *         <summary>
   *           <span class="p-tree-icon" data-icon="film" aria-hidden="true"></span>
   *           <span class="p-tree-name">Movies</span>
   *           <span class="p-tree-count">12</span>
   *         </summary>
   *         <div class="p-tree-items">
   *           <a class="p-tree-row" href="…" aria-current="page">Blade Runner</a>
   *         </div>
   *       </details>
   *       <a class="p-tree-row" href="…">Loose</a>
   *     </nav>
   *
   * <details> because a folder IS a disclosure, so open and shut is a
   * real attribute a script can persist rather than a class it has to
   * invent — and the whole header is the hit target for free, because
   * that is what <summary> already means. One level of folders,
   * deliberately: arbitrary nesting is a different component with
   * different problems.
   *
   * ---- THE GRIP RULE ---------------------------------------------
   *
   * A grip is EARNED, not decoration. Add one only when the rows can
   * genuinely be reordered by dragging. Likewise the overflow menu:
   * only when a folder has an action beyond opening and shutting.
   *
   * When a folder can ONLY expand and collapse, both come off and the
   * header is one target that does one thing — which is the common
   * case, because most folder sets are arranged somewhere else (in
   * settings, or by the data). A grip that drags nothing is a promise
   * the interface does not keep, and a second control in the header
   * turns one obvious click into a choice between two.
   *
   * The cost of putting them in anyway is not only visual. A <button>
   * inside a <summary> toggles the disclosure when clicked unless the
   * handler stops the event, so every extra control in a header is a
   * bug waiting for someone to forget. `pulse check` reports a grip
   * inside a tree that is not marked data-reorder.
   *
   *     <nav class="p-tree" data-reorder>          ← now a grip is honest
   *       <a class="p-tree-row" data-reorder-item>
   *         <button class="p-grip" data-icon="grip" aria-label="Reorder row"></button>
   *         …
   *
   * The two-line body, the avatar and the overflow menu are all still
   * here for the rows that need them — see .p-tree-body below.
   * ================================================================= */
  .p-tree {
    display: flex;
    flex-direction: column;
    gap: var(--p-tree-gap);
    min-inline-size: 0;

    /* One number for every gap in the tree: between rows, between
     * folders, and between a folder header and its first row. They have
     * to move together or the list stops reading as evenly spaced. */
    --p-tree-gap: var(--p-space-1);

    /* What the row actually sits on. Only the action overlay needs it —
     * it fades from transparent to this so a button revealed on top of a
     * title does not land on the words. A tree in a sunken sidebar
     * should set it to match, or the fade will end in the wrong grey. */
    --p-tree-bg: var(--p-surface);
    --p-tree-fade-to: var(--p-tree-bg);
  }

  .p-tree-row,
  .p-tree-folder > summary {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    min-block-size: var(--p-control-h);
    --p-pad-block: var(--p-space-1);
    padding-inline: var(--p-space-2);
    border: 0;
    border-radius: var(--p-radius-sm);
    background: none;
    color: var(--p-text);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-regular);
    text-align: start;
    text-decoration: none;
    cursor: pointer;
    transition: background-color var(--p-dur-1) var(--p-ease);
    /* A safety net for the simplest row — <a class="p-tree-row">Title</a>
     * with no .p-tree-name inside it. Raw text in a flex container is an
     * anonymous item that text-overflow cannot reach, so without this a
     * long title wraps and that one row becomes taller than the rest.
     * Clipping is not as good as an ellipsis; put the label in a
     * .p-tree-name and you get the ellipsis. */
    white-space: nowrap;
    overflow: hidden;
  }

  /* A row can be one line or two. The body is what flexes, so the grip,
   * the avatar and the menu keep their size while the title gives way. */
  .p-tree-body {
    display: flex;
    flex-direction: column;
    gap: 0;
    flex: 1;
    min-inline-size: 0;
  }

  /* The name truncates and nothing else does. A shortened title is still
   * recognisable; a shortened timestamp or count is simply wrong. */
  .p-tree-name {
    flex: 1;
    min-inline-size: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .p-tree-meta {
    font-size: var(--p-text-2xs);
    color: var(--p-text-subtle);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* ---- icon and count ------------------------------------------------
   * The icon says what KIND of thing the folder holds, which is the one
   * fact a colour cannot carry: two folders can both be amber, and only
   * one of them is films. It sits between the caret and the name so the
   * column reads as a column — caret, icon, name — instead of the icons
   * landing wherever each name happens to start. */
  .p-tree-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    color: var(--p-text-muted);
  }

  /* The count sits at the trailing edge, in its own column. Tried it
   * beside the name first, on the theory that it reads as part of the
   * label — but a column of folders is a thing you scan down, and ragged
   * numbers that start at a different x on every row are exactly what
   * scanning cannot do. Aligned, the counts are a column you can read at
   * once. The name takes the slack and truncates; the number never does,
   * because a shortened number is a different number. */
  /* The trailing padding is not symmetry with the leading edge, it is the
   * ⋯ lane. A row's inline padding is sized for text at the start of it;
   * at the end it leaves a two-digit number welded to the border, and the
   * count and the trailing mark — which occupy the same lane and swap
   * places on hover — would sit at two different insets. Both now stop
   * `--p-space-5` from the edge. */
  .p-tree-count {
    flex: none;
    margin-inline-start: auto;
    padding-inline-start: var(--p-space-2);
    padding-inline-end: var(--p-space-3);
    font-size: var(--p-text-2xs);
    font-variant-numeric: tabular-nums;
    color: var(--p-text-subtle);
  }

  .p-tree-row:hover,
  .p-tree-folder > summary:hover {
    background: var(--p-wash-hover);
    color: var(--p-text-strong);
    /* The wash is a translucent mix, so the overlay cannot fade to it —
     * it has to fade to the same mix already composited over the row's
     * background, or the title shows through the fade. */
    --p-tree-fade-to: color-mix(in oklab, var(--p-text) 8%, var(--p-tree-bg));
  }

  .p-tree [aria-current]:not([aria-current="false"]) {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
    --p-tree-fade-to: var(--p-tint-soft);
  }

  /* ---- the grip -----------------------------------------------------
   * Dimmed rather than hidden, and in the layout. A grip has to be
   * pointed at before it does anything, so it cannot be revealed by the
   * act of pointing at it — and it is at the START of the row, where
   * there is nothing for it to be in the way of. */
  .p-grip,
  .p-tree-grip {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--p-text-subtle);
    opacity: 0.45;
    cursor: grab;
    transition: opacity var(--p-dur-1) var(--p-ease);
  }

  :is(button, .p-btn).p-grip {
    inline-size: var(--p-control-h-sm);
    block-size: var(--p-control-h-sm);
    padding: 0;
    border-color: transparent;
    background: transparent;
    /* A GRIP IS ITS ICON, so it must not publish a correction meant for
     * letterforms. Controls announce `--p-optical-applied` so a mask
     * inside them can subtract back out the shift their capitals were
     * given — but this one zeroes its padding on the line above and
     * never applied that shift, so the mask subtracted a correction
     * that was not there and the six dots rode high inside their own
     * hover wash. Visible on every grip in the system, and reported
     * from a screen with eleven of them side by side.
     *
     * Fixed here rather than by telling projects to add
     * `data-shape="icon"` to the worked markup: the geometry is a fact
     * about `.p-grip`, and a contract that needs a second attribute to
     * be correct is one that will be copied without it. */
    --p-optical-applied: 0px;
  }

  :is(button, .p-btn).p-grip:hover {
    border-color: transparent;
    background: var(--p-wash-hover);
  }

  :is(.p-grip, .p-tree-grip):active { cursor: grabbing; }

  [data-reorder] [data-reorder-item]:hover .p-grip,
  .p-grip:focus-visible {
    opacity: 1;
  }

  /* focus-VISIBLE, not focus-within, and the difference is a bug report.
   * Clicking a trailing control leaves it focused, so a `:focus-within`
   * reveal stays on after the pointer has gone — the row keeps a strip of
   * controls lit until something else is clicked, which reads as stuck.
   * `:focus-visible` is exactly "reached by keyboard", which is the case
   * the reveal is for; a mouse already has hover.
   *
   * `:is(:focus-visible, :has(:focus-visible))` because the row itself is
   * a tab stop as well as its contents, and only the second half of that
   * covers a focused descendant. */
  .p-tree-row:hover :is(.p-grip, .p-tree-grip),
  .p-tree-folder > summary:hover :is(.p-grip, .p-tree-grip),
  .p-tree-row:is(:focus-visible, :has(:focus-visible)) :is(.p-grip, .p-tree-grip),
  .p-tree-folder > summary:is(:focus-visible, :has(:focus-visible)) :is(.p-grip, .p-tree-grip) {
    opacity: 1;
  }

  [data-reorder-item][data-dragging] { opacity: 0.25; }

  .p-drag-ghost {
    position: fixed;
    z-index: var(--p-z-popover);
    margin: 0;
    pointer-events: none;
    box-shadow: var(--p-shadow-3);
    opacity: 0.92;
  }

  /* ---- trailing actions ---------------------------------------------
   * These sit OVER the end of the row rather than in the flow of it, and
   * are revealed on hover. A permanent control at the trailing edge
   * costs every row that width forever, and most rows never need it —
   * so a sidebar of forty conversations pays forty times over for a
   * button used twice, and every title is shortened to make room for
   * empty space.
   *
   * Out of flow, the title gets the whole row and only gives ground for
   * the moment the control is actually on screen. The overlay fades in
   * from transparent so it lands on top of the words legibly rather than
   * cutting them off with a hard edge.
   *
   * The honest cost is that a control revealed by hovering cannot be
   * found by hovering on a touchscreen. That is handled below rather
   * than accepted — see the hover:none block.
   *
   *     <a class="p-tree-row">
   *       <span class="p-tree-body">…</span>
   *       <span class="p-tree-actions">
   *         <button class="p-tree-more" data-icon="archive" …></button>
   *       </span>
   *     </a>
   *
   * A bare .p-tree-more with no wrapper behaves the same way, for the
   * common case of exactly one action. */
  .p-tree-actions,
  .p-tree-row > .p-tree-more,
  .p-tree-folder > summary > .p-tree-more {
    position: absolute;
    inset-block: 0;
    inset-inline-end: 0;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--p-space-1);
    /* Two different jobs. The leading pad is the fade's runway; the
     * trailing pad is the mark's breathing room from the row's edge, and
     * it is deliberately wider than the row's own inline padding. A mark
     * that floats over someone's title has to look placed, and 2 — the
     * same inset the count sits at — reads as shoved into the corner
     * rather than put there. */
    padding-inline: var(--p-space-10) var(--p-space-5);
    opacity: 0;
    /* Invisible and still clickable is a control that swallows clicks
     * meant for the row behind it, along the whole trailing edge where
     * nothing looks like a target. It goes live in the same breath it
     * becomes visible; keyboard focus is unaffected either way. */
    pointer-events: none;
    /* background-IMAGE, not the shorthand: the button inside paints a
     * hover wash into background-color, and the shorthand would wipe the
     * fade out from under it the moment you reached for the control. */
    background-image: linear-gradient(
      to right, transparent, var(--p-tree-fade-to) var(--p-space-10)
    );
    transition: opacity var(--p-dur-1) var(--p-ease);
  }

  /* Gradients take a physical direction, so the right-to-left case has
   * to be stated. Everything else here is already logical. */
  .p-tree:dir(rtl) :is(.p-tree-actions, .p-tree-more) {
    background-image: linear-gradient(
      to left, transparent, var(--p-tree-fade-to) var(--p-space-10)
    );
  }

  /* See the grip above for why this is :focus-visible: a clicked control
   * keeps focus, and a focus-within reveal never turns off again. */
  .p-tree-row:hover :is(.p-tree-actions, .p-tree-more),
  .p-tree-folder > summary:hover :is(.p-tree-actions, .p-tree-more),
  .p-tree-row:is(:focus-visible, :has(:focus-visible)) :is(.p-tree-actions, .p-tree-more),
  .p-tree-folder > summary:is(:focus-visible, :has(:focus-visible)) :is(.p-tree-actions, .p-tree-more),
  /* Its own menu is open. The menu takes focus, so without this the mark
   * the menu is pointing at fades out from under it. */
  .p-tree-row:has([data-menu-open]) :is(.p-tree-actions, .p-tree-more),
  .p-tree-folder > summary:has([data-menu-open]) :is(.p-tree-actions, .p-tree-more) {
    opacity: 1;
    pointer-events: auto;
  }

  /* Where there is no pointer there is no hover, so a control that waits
   * to be hovered is a control that does not exist. On touch they are
   * simply always shown — the row loses the width, which is the correct
   * trade when the alternative is an unreachable action. */
  @media (hover: none) {
    .p-tree-actions,
    .p-tree-row > .p-tree-more,
    .p-tree-folder > summary > .p-tree-more {
      opacity: 1;
      pointer-events: auto;
    }
  }

  /* THE BARE MARK IS THE STRIP. With no .p-tree-actions wrapper around
   * it — the common case of exactly one action — this button is doing
   * both jobs, and it cannot do them at one size. `inset-block: 0` and a
   * control-sized `block-size` over-constrain an absolute box, and the
   * engine settles that by keeping the top edge and dropping the bottom.
   * The dots ended up in the row's top corner instead of level with the
   * name: 3px high on a one-line row, 14px on a two-line one, which is
   * how it read as "stuck to the corner" rather than "slightly off".
   *
   * So let it be the strip. Full height, the glyph centred in it by the
   * flex rules above, and the trailing padding is the gap from the row's
   * edge rather than a remainder the fixed width happened to leave. */
  .p-tree-row > .p-tree-more,
  .p-tree-folder > summary > .p-tree-more {
    inline-size: auto;
    block-size: auto;
  }

  /* THE MARK IS THE CONTROL. Nothing is drawn around it.
   *
   * `border: 0` was here and a box was still showing, because the shape
   * was never the border — it was the button's own surface, inherited
   * from the element layer and never reset. That painted a filled
   * rounded square behind the dots, sitting on top of the row's fade
   * strip, so a row that was hovered grew two overlapping boxes at its
   * trailing edge.
   *
   * background-COLOR, not the shorthand: the strip's gradient is a
   * background-image on this same element in the bare, no-wrapper case,
   * and the shorthand would take it off with the fill. */
  .p-tree-more {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    inline-size: var(--p-control-h-sm);
    block-size: var(--p-control-h-sm);
    padding: 0;
    border: 0;
    border-radius: var(--p-radius-sm);
    background-color: transparent;
    color: var(--p-text-subtle);
  }

  /* Hover brightens the dots rather than lighting a panel behind them.
   * The wash that used to be here was the box again by another name, and
   * a mark this small does not need a surface to say it is live —
   * subtle to strong is a bigger change on three dots than a wash is. */
  .p-tree-more:hover {
    color: var(--p-text-strong);
  }

  /* ---- the created line ----------------------------------------------
   * A row's optional second line is `.p-when`, defined up with the
   * avatar because a conversation turn needs the identical line and two
   * copies of one shape drift. Nothing about it is tree-specific. */

  /* ---- folder --------------------------------------------------------
   * A folder can carry a colour, and it tints its own row rather than
   * only its caret — the row is what stays on screen when the folder is
   * shut, so that is where the colour has to live to be any use. A tint
   * this faint cannot be the only signal, which is why the name is right
   * there beside it. */
  /* The default is NEUTRAL, not the accent. Most folder sets have no
   * colour at all, and an accent caret plus an accent icon on every one
   * of them turns a sidebar into a column of purple — which also spends
   * the accent on the furniture, leaving it nothing to say when a row is
   * actually selected. A folder earns a colour by being given one. */
  .p-tree-folder {
    --p-tree-color: var(--p-text-muted);
    border: 0;
    border-radius: 0;
    background: none;
  }

  .p-tree-folder > summary {
    background: color-mix(in oklab, var(--p-tree-color) 10%, transparent);
    color: var(--p-text-strong);
    font-weight: var(--p-weight-medium);
  }

  .p-tree-folder > summary:hover {
    background: color-mix(in oklab, var(--p-tree-color) 18%, transparent);
  }

  /* Caret and icon both take the folder's colour. They are the two marks
   * that stay put while the name changes length, so they are what the
   * eye actually uses to tell one collapsed group from another. */
  .p-tree-folder > summary::before,
  .p-tree-folder > summary > .p-tree-icon {
    color: var(--p-tree-color);
  }

  /* Both states are stated, because one of them had to be. Setting
   * `display` on a details child beats the UA rule that hides a closed
   * one, and once you have taken that property over you own the other
   * half too — leaving it to the engine gave shut folders that still
   * listed their contents and overlapped the row beneath. */
  .p-tree-folder:not([open]) > .p-tree-items {
    display: none;
  }

  .p-tree-folder[open] > .p-tree-items {
    display: flex;
    flex-direction: column;
    gap: var(--p-tree-gap);
  }

  /* One guide rail for the group rather than a tick per row. The rail is
   * what says "these belong to the folder above"; per-row marks say the
   * same thing once per row.
   *
   * padding-block is the SAME token as the gap, so the step from a
   * folder header to its first row matches the step from one row to the
   * next. They were both a hairline before and read as unequal anyway:
   * a header is a filled row and a row is not, so the same distance
   * between two backgrounds and between two pieces of text does not
   * look like the same distance. Giving both more room fixes the one
   * that was too tight without opening a hole under the header. */
  .p-tree-items {
    margin-inline-start: var(--p-space-4);
    padding-inline-start: var(--p-space-2);
    border-inline-start: 1px solid var(--p-line);
    padding-block: var(--p-tree-gap);
  }

  .p-tree-folder + .p-tree-folder,
  .p-tree-folder + .p-tree-row {
    margin-block-start: var(--p-space-2);
  }

  /* ---- row marks -----------------------------------------------------
   * Unread is a dot rather than a weight change, so it survives beside a
   * title that is already bold, and it sits before the title so it is
   * not lost when the title truncates. */
  .p-tree-dot {
    flex: none;
    inline-size: 0.42em;
    block-size: 0.42em;
    border-radius: var(--p-radius-full);
    background: var(--p-accent);
  }

  .p-tree-title-line {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    min-inline-size: 0;
  }

  /* spec: media-card */
  /* spec: peculiar-to-this-card */
  /* spec: narrow-cards */
  /* =================================================================
   * Media — a thing with artwork, a description and actions on it
   *
   * A row: artwork down the left, text beside it, and the actions along
   * the bottom. Dense enough that a feed of them scans, which is the
   * job — a reader is deciding what to open, not admiring the posters.
   *
   *     <article class="p-media">
   *       <img class="p-media-art" src="…" alt="">
   *       <div class="p-media-body">
   *         <h3 class="p-media-title">Helvetica</h3>
   *         <p class="p-media-meta">Gary Hustwit · film · 2007</p>
   *         <p class="p-media-summary">A documentary about a typeface…</p>
   *       </div>
   *       <div class="p-actions"> … </div>
   *     </article>
   *
   * The actions are `.p-actions`, the same row as everywhere else, and
   * they get the same intent placement and the same collapse-to-icons
   * when the card is narrow. This replaced a vertical rail, which had
   * needed an exception written into the intent rule — left-to-right
   * placement is about pointer travel, and a column four buttons tall
   * is not that journey. Turning the rail ninety degrees deleted the
   * exception instead of documenting it.
   *
   * It also fixed the thing that made a feed of these look wrong: a
   * rail up to six controls tall decided the card's height, so a list
   * read as a column of button rails with some text beside them.
   * ================================================================= */
  .p-media {
    /* One inset for every edge of the card, so the corner rule holds:
     * the artwork's top-left corner is as far from the top as from the
     * left, and so is the title's. */
    --p-media-pad: var(--p-inset-sm);
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    gap: 0 var(--p-gap);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-lg);
    /* Darker than its own footer. A card has a dialog's anatomy at a
     * smaller size — the part you read is the pane, the strip of buttons
     * is dressing around it — and the same rule decides which is which. */
    background: var(--p-surface);
    overflow: clip;
    position: relative;
    container-type: inline-size;
  }

  /* Fixed width, and THE HEIGHT OF THE CARD. A feed lines up down its
   * left edge, which is what the fixed width is for, and the picture
   * fills the space that width was given rather than stopping where its
   * own proportions run out. `object-fit: cover` crops; overfilling is
   * the point.
   *
   * This followed the picture's own shape first, clamped between square
   * and 16/9. It is a better rule for one card and a worse one for a
   * feed: two cards side by side with different amounts of summary got
   * different amounts of empty plate under the artwork, and a strip of
   * bare surface below a photograph reads as a picture that failed to
   * load rather than as a picture with a considered aspect ratio.
   *
   * TWO DECLARATIONS DO IT, and neither works without the other.
   *
   *   contain: size    the picture stops contributing its own height to
   *                    the row. Without this a 600x900 photograph in a
   *                    106px column asks for 159px and GETS it, so the
   *                    card grows to the picture and the text sits in a
   *                    tall empty column beside it — the same bug the
   *                    other way up.
   *   block-size: 100% now that the row is the body's height, fill it.
   *
   * `align-self: stretch` is the obvious way to write this and does
   * nothing: stretch behaves as `start` on a replaced element, so an
   * <img> ignores it. That is an hour nobody else should spend.
   *
   * The floor stays, so a card of one short line still gets a picture
   * rather than a letterbox. `--p-media-art` moves the width for a whole
   * feed at once. */
  .p-media-art {
    grid-row: 1;
    contain: size;
    inline-size: var(--p-media-art);
    /* Less its own margins: `100%` is the whole grid area, and the two
     * insets are on top of it, so the plain figure overflows the row by
     * exactly the padding and `overflow: clip` shaves the bottom corners
     * off the picture. */
    block-size: calc(100% - var(--p-media-pad) * 2);
    min-block-size: calc(var(--p-media-art) * 9 / 16);
    object-fit: cover;
    margin: var(--p-media-pad) 0 var(--p-media-pad) var(--p-media-pad);
    /* Concentric with the card, not equal to it: a rounded thing inside
     * a rounded thing takes the outer radius LESS the gap between them,
     * or the two curves run at different rates and the inner one looks
     * wrong. Both terms follow --p-round, so the theme still owns it. */
    border-radius: max(0px, calc(var(--p-radius-lg) - var(--p-media-pad)));
    background: var(--p-surface-inset);
  }

  /* Not everything has a picture, and the answer is not to drop the
   * column — that puts a ragged edge back in the list one card at a
   * time. Render the element empty and it becomes a plate; put an icon
   * in it and the plate says what kind of thing is missing its art.
   *
   * Square, because a plate has no picture to take a shape from and the
   * tallest bound is the one that reads as deliberate.
   *
   * `:not(img)` is doing real work. An <img> is a void element, so it is
   * ALWAYS `:empty` — without this, every photograph in the system
   * matched the no-photograph rule and was squared off, and the whole
   * follow-the-picture's-shape behaviour above quietly did nothing. It
   * hid for a while because the spec was painting its own stand-in
   * ratio over the top of it. */
  /* A plate has no picture to take a shape from, so it takes the card's,
   * the same as a photograph does — the rule above already gives it the
   * body's height and a 16/9 floor, and `aspect-ratio` is inert beside
   * two definite dimensions. It is left in as the shape this element
   * falls back to if either of those is ever removed. */
  .p-media-art:not(img):empty,
  .p-media-art[data-empty] {
    display: grid;
    place-items: center;
    aspect-ratio: 1;
    color: var(--p-text-subtle);
    font-size: var(--p-text-xl);
  }

  /* The safety net for a caller who omits the element entirely. The
   * card still reads; it just loses the alignment the column was for. */
  .p-media:not(:has(> .p-media-art)) > .p-media-body {
    padding-inline-start: var(--p-media-pad);
  }

  /* min-inline-size: 0 on the body is what stops a long unbroken title
   * from forcing the grid wider than its container. A minmax(0, 1fr)
   * track says the same thing; both are here because either one alone
   * fails on a different browser's idea of an implicit minimum. */
  .p-media-body {
    grid-column: 2;
    min-inline-size: 0;
    display: flex;
    flex-direction: column;
    gap: var(--p-space-1);
    padding: var(--p-media-pad) var(--p-media-pad) var(--p-media-pad) 0;
  }

  /* The floor of the card, and the full width of it — a footer that
   * stopped short of the artwork would be a strip under the text rather
   * than the end of the frame. Lighter than the body for the same
   * reason a dialog's is: it is the dressing, not the pane.
   *
   * The row is placed by hand, like the body. Auto-placement would put
   * the body in the first column on any card whose artwork is missing,
   * which is a card that silently loses its layout rather than its
   * picture.
   *
   * ONE INSET ON ALL FOUR SIDES, not the action row's own block padding
   * beside the media inset. The corner rule says an action nearest a
   * corner sits equally far from both edges it is near, and the last
   * button in this footer is that action — with two different values
   * here it came out 6px from the bottom and 9.46px from the right on
   * every card Pulse shipped. SpoofySort found it by measuring, which is
   * the only way anyone was ever going to: a 3px difference reads as
   * slightly wrong rather than as a broken rule. `pulse check --level 3`
   * now measures it, because prose had already stated the rule and the
   * primitive broke it anyway. */
  .p-media > .p-actions {
    grid-column: 1 / -1;
    padding: var(--p-media-pad);
    border-block-start: 1px solid var(--p-line);
    background: var(--p-surface-raised);
  }

  /* Peculiar to this one card — the region a dialog keeps for Configure
   * and Export. A card has no head to put it in, so it goes in the
   * card's own top right, equally far from both edges it is near. */
  .p-media-aside {
    position: absolute;
    inset-block-start: var(--p-media-pad);
    inset-inline-end: var(--p-media-pad);
    z-index: 1;
  }

  /* Reserve the room rather than letting a long title run underneath.
   * Descendant, not child: the corner control is often a submit button
   * inside a `display: contents` form, which generates no box but is
   * still the DOM child, so `> .p-media-aside` would miss it. Cards do
   * not nest, so there is nothing else for it to reach. */
  .p-media:has(.p-media-aside) > .p-media-body {
    padding-inline-end: calc(var(--p-control-h-sm) + var(--p-media-pad) + var(--p-space-2));
  }

  /* ---- .p-media-head — the part the corner action actually overlaps --
   *
   * The reservation above is right for the summary card it was written
   * for, where every line is short, and wrong for a rich one: a seek bar
   * and a row of chips three lines below the button are nowhere near it,
   * yet they were being held out of a lane the button does not occupy.
   * SpoofySort's track card lost that width on every row.
   *
   * So a body may name the part that genuinely sits beside the corner.
   * Where it does, the reservation moves off the body and onto that
   * region alone and everything below reclaims the full width. Where it
   * does not, nothing changes — the simple card keeps the blanket
   * reservation, which is the correct answer when the whole body is one
   * heading.
   *
   * The head's own share is the control plus a gap; the card's inset is
   * already back on the body, so adding it here again would double it. */
  .p-media-head {
    display: flex;
    flex-direction: column;
    gap: var(--p-space-1);
    min-inline-size: 0;
  }

  .p-media:has(.p-media-aside) > .p-media-body:has(> .p-media-head) {
    padding-inline-end: var(--p-media-pad);
  }

  .p-media:has(.p-media-aside) > .p-media-body > .p-media-head {
    padding-inline-end: calc(var(--p-control-h-sm) + var(--p-space-2));
  }

  .p-media-title {
    margin: 0;
    font-size: var(--p-text-md);
    font-weight: var(--p-weight-semibold);
    color: var(--p-text-strong);
    text-wrap: balance;
  }

  .p-media-meta {
    margin: 0;
    font-size: var(--p-text-xs);
    color: var(--p-text-muted);
  }

  /* Clamped rather than truncated with an ellipsis on one line: three
   * lines is enough to decide from, and a feed of equal-height blocks
   * is easier to scan than a ragged one. `--p-media-clamp` moves it. */
  .p-media-summary {
    margin: var(--p-space-1) 0 0;
    font-size: var(--p-text-sm);
    color: var(--p-text);
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: var(--p-media-clamp);
    line-clamp: var(--p-media-clamp);
    overflow: hidden;
  }

  /* Hover lifts the card rather than lightening it. Filling the body on
   * hover would take it past its own footer, and the pane being darker
   * than the dressing is the thing that makes a card read as a small
   * frame in the first place — losing it for a moment reads as a fault. */
  .p-media[data-interactive] {
    cursor: pointer;
    transition:
      border-color var(--p-dur-1) var(--p-ease),
      box-shadow var(--p-dur-1) var(--p-ease);
  }

  .p-media[data-interactive]:hover {
    border-color: var(--p-border);
    box-shadow: var(--p-shadow-2);
  }

  /* Narrow: a smaller plate and a shorter summary, and that is the whole
   * adaptation. The action row needs no rule here — `.p-actions` already
   * collapses its buttons to icons one at a time as the room runs out,
   * which is the same behaviour it has in a dialog or a toolbar.
   *
   * A container query, not a media query — a card in a sidebar is narrow
   * on a wide screen, and the layout should follow the space it has
   * rather than the size of the device.
   *
   * Note that a container query cannot style its own container: an
   * element that establishes one is never matched by a query against
   * it. So every rule here is written about a CHILD of `.p-media`, and
   * the art width is set on the child rather than on the card. */
  @container (max-width: 30rem) {
    /* The token, not `inline-size` — the plate's two height bounds are
     * derived from its width, so setting the width directly would leave
     * a 4.5rem-wide picture allowed to grow 7rem tall and stop being
     * bounded by square. Same for the inset: every rule that reads
     * --p-media-pad is written about a child, precisely so a query
     * against the card can still reach it. */
    .p-media-art { --p-media-art: 4.5rem; }
    .p-media-summary { --p-media-clamp: 2; }

    .p-media-art,
    .p-media-aside,
    .p-media-body,
    .p-media > .p-actions { --p-media-pad: var(--p-space-3); }

    /* A body that names its head gets the aside treatment for the
     * artwork too: the plate overlaps only the head, and everything
     * below reclaims the full width. At 4.5rem the art column was
     * costing a 20rem card a quarter of every line of summary for a
     * picture that ended three lines earlier — SpoofySort's track card
     * measured it. Without a head nothing changes: when the whole body
     * is one heading, sitting beside the plate is the correct answer.
     *
     * The plate goes absolute — same corner logic as `.p-media-aside`,
     * opposite corner — and square, because a plate that no longer owns
     * a column has no column to derive a height from, and equal insets
     * are the corner rule. The head holds the plate's height so the
     * summary starts below it, not behind it. */
    .p-media:has(> .p-media-art) > .p-media-body:has(> .p-media-head) {
      grid-column: 1 / -1;
      padding-inline-start: var(--p-media-pad);
    }

    .p-media:has(> .p-media-body > .p-media-head) > .p-media-art {
      position: absolute;
      inset-block-start: var(--p-media-pad);
      inset-inline-start: var(--p-media-pad);
      inline-size: var(--p-media-art);
      block-size: var(--p-media-art);
      min-block-size: 0;
      margin: 0;
    }

    .p-media:has(> .p-media-art) > .p-media-body > .p-media-head {
      padding-inline-start: calc(var(--p-media-art) + var(--p-gap));
      min-block-size: var(--p-media-art);
    }
  }


  /* spec: thumbnails */
  /* spec: full */
  /* spec: the-full-view */
  /* =================================================================
   * Gallery — two layouts, one tile
   *
   * THUMB is a grid of uniform square crops. Every tile is the same
   * size, so the eye scans positions rather than shapes and forty
   * pictures fit on a screen. It is the mode for finding one.
   *
   * FULL is justified rows: every tile keeps its own aspect ratio, and
   * a row is filled by letting the tiles grow in proportion to it. It
   * is the mode for looking at what you have — a panorama reads as a
   * panorama and a portrait is not beheaded to fit a square.
   *
   *     <div class="p-gallery" data-layout="full" data-viewer="#shots">
   *       <div class="p-gallery-tile" style="--p-ar: 1.5" data-full="…/01.jpg">
   *         <button class="p-gallery-open" type="button"
   *                 aria-label="Harbour at dusk">
   *           <img src="…/01-thumb.jpg" alt="">
   *         </button>
   *         <input class="p-gallery-check" type="checkbox"
   *                aria-label="Select Harbour at dusk">
   *         <span class="p-gallery-pill" data-corner="br">0:42</span>
   *         <span class="p-gallery-caption">Harbour at dusk</span>
   *       </div>
   *     </div>
   *
   * TWO CONTROLS, because there are two different things to do to a
   * picture and one control cannot mean both. The picture is a button
   * that opens it. The checkbox selects it. Both are real controls, so
   * both are reachable by Tab and operable without a pointer — where a
   * div with a click handler and a modifier key is reachable by
   * neither.
   *
   * Selection is therefore a CHECKED CHECKBOX, not a class and not an
   * aria attribute borrowed as a hook. Everything it looks like follows
   * from `:has(:checked)` in this file, so the state cannot fall out of
   * step with the control that sets it.
   * ================================================================= */
  .p-gallery {
    --p-gallery-thumb: 9rem;
    --p-gallery-row: 12rem;
    /* One size for both corner marks, so the check and the menu read as
     * a matched pair rather than as two things that happen to be small. */
    --p-gallery-mark: 1.6rem;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(var(--p-gallery-thumb), 1fr));
    gap: var(--p-gap-tight);
  }

  /* Justified rows. `flex-grow` in proportion to the ratio is what makes
   * a row come out flush: wide tiles take more of the leftover width
   * than tall ones, which is exactly the share they need to end level.
   * The basis is the nominal row height, and the cap stops a lone tile
   * on the last row from stretching across the whole page. */
  .p-gallery[data-layout="full"] {
    display: flex;
    flex-wrap: wrap;
  }

  .p-gallery[data-layout="full"] > .p-gallery-tile {
    aspect-ratio: var(--p-ar, 1);
    flex-grow: calc(var(--p-ar, 1) * 100);
    flex-basis: calc(var(--p-ar, 1) * var(--p-gallery-row));
    max-inline-size: calc(var(--p-ar, 1) * var(--p-gallery-row) * 1.6);
  }

  /* NOT `overflow: clip`. The tile has to let a menu out of it, so the
   * clipping moves down to the picture's own button and the caption
   * rounds its own two corners. A tile that clips is a tile whose action
   * menu is a sliver along its bottom edge. */
  .p-gallery-tile {
    position: relative;
    aspect-ratio: 1;
    /* A press and hold selects on touch, and a browser's own answer to a
     * long press on a picture is a save-image menu. Only one of them can
     * win, and it should be the gallery's. */
    user-select: none;
    -webkit-touch-callout: none;
    border-radius: var(--p-radius-md);
    background: var(--p-surface-inset);
    /* The tile scales, not the picture inside it — see the counter-move
     * below. Transforming the whole thing including its crop would make
     * the framing breathe every time the pointer passed over. */
    transition: scale var(--p-dur-1) var(--p-ease);
  }

  /* The picture is the button, and it fills the tile — a target the
   * size of the thing you are aiming at. Stripped of every button
   * decoration, because a border and a background here would be a frame
   * around a frame. */
  .p-gallery-tile > .p-gallery-open {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    padding: 0;
    border: 0;
    border-radius: inherit;
    background: none;
    overflow: clip;
    cursor: pointer;
  }

  .p-gallery-open > img {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
    transition:
      scale var(--p-dur-2) var(--p-ease),
      filter var(--p-dur-1) var(--p-ease);
  }

  .p-gallery-tile:hover .p-gallery-open > img { scale: 1.05; }

  /* ---- selection ----------------------------------------------------
   * A real checkbox, drawn round so it reads as a picker rather than as
   * a form field, and revealed on hover where there is a pointer to
   * hover with. On touch there is no hover, so it is always there; in
   * select mode it is always there for everyone, because that is the
   * mode where the answer to "can I pick these" must be yes on sight. */
  .p-gallery-check {
    position: absolute;
    inset-block-start: var(--p-space-2);
    inset-inline-start: var(--p-space-2);
    z-index: 2;
    inline-size: var(--p-gallery-mark);
    block-size: var(--p-gallery-mark);
    margin: 0;
    border-radius: var(--p-radius-full);
    /* A scrim rather than the usual inset surface, because this one sits
     * on a photograph and has to read over any of them. */
    background: var(--p-scrim);
    border-color: var(--p-text-strong);
    transition: opacity var(--p-dur-1) var(--p-ease);
  }

  /* The checked look has to be restated. The element rules paint a ticked
   * box in the accent, but they live in an EARLIER layer than this one,
   * so the scrim above was winning in both states — a checkbox that went
   * on looking exactly like a checkbox that was off, with its tick drawn
   * in accent-foreground over near-black. */
  .p-gallery-check:checked {
    background: var(--p-accent);
    border-color: var(--p-accent);
    color: var(--p-accent-fg);
  }

  /* WITH A POINTER, the check waits to be hovered — a permanent circle
   * on every tile is chrome over forty photographs, and hover is a free
   * way to ask for it. */
  @media (hover: hover) and (pointer: fine) {
    .p-gallery-check { opacity: 0; }

    .p-gallery-tile:hover > .p-gallery-check,
    .p-gallery-check:focus-visible,
    .p-gallery-check:checked,
    .p-gallery[data-select="on"] .p-gallery-check { opacity: 1; }
  }

  /* WITHOUT ONE there is no hover to ask with, so the two marks part
   * company. Selection is a press and hold, which needs no target at
   * all, so the check stays out of the way until a selection is under
   * way — then every tile needs one, because that is when a reader is
   * counting them. The menu has no gesture standing in for it, so it is
   * simply always there. */
  @media (hover: none) {
    .p-gallery-check { opacity: 0; }

    .p-gallery-check:checked,
    .p-gallery[data-select="on"] .p-gallery-check,
    .p-gallery:has(.p-gallery-check:checked) .p-gallery-check { opacity: 1; }
  }

  /* Selected: the tile shrinks a little, takes an accent ring and a
   * wash of the accent over the picture. Three signals rather than one,
   * so the state survives being read at a glance across a grid of forty
   * and does not depend on colour alone. */
  .p-gallery-tile:has(> .p-gallery-check:checked) { scale: 0.94; }

  .p-gallery-tile:has(> .p-gallery-check:checked) .p-gallery-open > img {
    filter: brightness(0.7);
  }

  /* The ring is a pseudo-element, never a border. A border would change
   * the tile's box, which in a grid of them means selecting one nudges
   * every tile after it — the whole gallery twitching once per click. */
  .p-gallery-tile::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    border: 2px solid var(--p-border-accent);
    /* Translucent, and it has to be. --p-tint-soft is a solid colour —
     * it is the accent MIXED INTO a surface, not the accent at an alpha —
     * so using it here painted an opaque sheet over the tile and the
     * picture simply vanished when you selected it. */
    background: color-mix(in oklab, var(--p-accent) 18%, transparent);
    opacity: 0;
    scale: 1.04;
    transition:
      opacity var(--p-dur-1) var(--p-ease),
      scale var(--p-dur-1) var(--p-ease);
    pointer-events: none;
  }

  .p-gallery-tile:has(> .p-gallery-check:checked)::after {
    opacity: 1;
    scale: 1;
  }

  /* ---- what can be done to this picture ------------------------------
   * The opposite corner to the check, and revealed on the same terms. A
   * <details> rather than a popover: the menu has to sit under the
   * button that opened it, and placing a popover still wants anchor
   * positioning. This one is positioned against the tile, which is a
   * box CSS has always been able to measure.
   *
   * It used to cost light dismiss, and that is no longer true: an
   * outside press closes it, Escape closes it, the marker again closes
   * it, and choosing an action closes it. pulse-overlay.js pays for the
   * first of those on behalf of every <details> menu in the system, so
   * this one keeps what it was chosen for — placement by CSS against a
   * box — without keeping the exception that came with it. */
  /* A <details> is a panel by default in this system — filled, hairlined
   * and rounded — and this one is not a panel. It is a marker sitting on
   * a photograph with a menu attached, so it wears none of that chrome.
   *
   * This was invisible for as long as the summary carried an opaque
   * scrim circle: the disc covered the box exactly, and the box only
   * appeared when the disc came off. What was left was a small grey
   * outlined square on the picture with three dots inside it, which is
   * the thing that looked broken rather than the dots. Reset the element
   * here, not on the summary — the summary was never what was drawing
   * it. */
  .p-gallery-act {
    position: absolute;
    inset-block-start: var(--p-space-2);
    inset-inline-end: var(--p-space-2);
    z-index: 3;
    border: 0;
    border-radius: 0;
    background: none;
    padding: 0;
    transition: opacity var(--p-dur-1) var(--p-ease);
  }

  /* Three dots on the check's own disc — same size, same scrim, same
   * hairline, same round, opposite corner. The icon is what differs,
   * because the icon is the only thing that should: one mark selects,
   * one mark opens a menu, and everything else about them is the job of
   * "a control sitting on a photograph", which they share.
   *
   * A bare glyph was tried and it is not enough. Over a pale, busy
   * picture 16px of white line work disappears whatever shadow it
   * carries, and it disappears WORST in the gallery of someone's actual
   * photos rather than in a demo of four dark ones. The disc is not
   * decoration: it is the only thing here that does not depend on what
   * the photograph happens to be.
   *
   * The hairline is `--p-text-strong` and not a grey, matching the
   * check. A 45%-transparent grey ring was what read as the "outline
   * box" — on a dark photo it is the brightest thing on the tile and it
   * is drawing attention to a border rather than to a control.
   *
   * No drop-shadow with it. The disc already separates the mark from the
   * picture, and a shadow under an opaque disc is a second answer to a
   * question that has been answered. */
  .p-gallery-act > summary {
    display: grid;
    place-items: center;
    inline-size: var(--p-gallery-mark);
    block-size: var(--p-gallery-mark);
    /* A summary's inline padding is sized for a label. There is no label
     * here, and it squeezed the content box to 8px around a 16px glyph,
     * which is what stopped the dots sitting in the middle of the disc. */
    padding-inline: 0;
    border: 1px solid var(--p-text-strong);
    border-radius: var(--p-radius-full);
    background: var(--p-scrim);
    color: var(--p-text-strong);
    cursor: pointer;
    list-style: none;
  }

  .p-gallery-act > summary::-webkit-details-marker { display: none; }

  .p-gallery-act > summary:hover,
  .p-gallery-act[open] > summary { color: var(--p-text-accent); }

  .p-gallery-act > .p-menu {
    position: absolute;
    inset-block-start: calc(100% + var(--p-space-1));
    inset-inline-end: 0;
    z-index: var(--p-z-popover);
  }

  /* An open menu has to sit above the tiles after it in the grid, and
   * above its own tile's ring. */
  .p-gallery-tile:has(> .p-gallery-act[open]) { z-index: 4; }

  @media (hover: hover) and (pointer: fine) {
    .p-gallery-act { opacity: 0; }

    .p-gallery-tile:hover > .p-gallery-act,
    .p-gallery-act:focus-within,
    .p-gallery-act[open] { opacity: 1; }
  }

  /* ---- overlays on the picture --------------------------------------
   * A pill for whatever the picture cannot say itself — a duration, a
   * rating, a count. On a scrim, because it has to stay legible over a
   * photograph of anything. */
  /* ALL text on a tile is a pill. There is no caption bar and no
   * gradient along the bottom: a gradient is a guess that the text will
   * be over the dark part of the picture, and a photograph does not
   * promise that. A pill carries its own background wherever it lands,
   * so it is legible over anything and costs only the space of the words
   * rather than a strip the full width of the tile. */
  .p-gallery-pill {
    position: absolute;
    z-index: 1;
    max-inline-size: calc(100% - var(--p-space-4));
    padding: 0 var(--p-space-2);
    border-radius: var(--p-radius-full);
    background: var(--p-scrim);
    color: var(--p-text-strong);
    font-size: var(--p-text-2xs);
    line-height: 1.5rem;
    text-overflow: ellipsis;
    white-space: nowrap;
    overflow: hidden;
    pointer-events: none;
  }

  /* `:not([data-corner])` for the default, NOT a bare `.p-gallery-pill`.
   * A bare selector sets the right inset on every pill, so a pill asking
   * for the left corner ends up with BOTH — and an absolutely positioned
   * box with both insets stretches to fill the width instead of shrinking
   * to its words. A one-word pill spanning the whole tile is what that
   * looks like. */
  .p-gallery-pill[data-corner="tl"] { inset-block-start: var(--p-space-2); inset-inline-start: var(--p-space-2); }
  .p-gallery-pill[data-corner="tr"] { inset-block-start: var(--p-space-2); inset-inline-end: var(--p-space-2); }
  .p-gallery-pill[data-corner="bl"] { inset-block-end: var(--p-space-2); inset-inline-start: var(--p-space-2); }
  .p-gallery-pill:not([data-corner]),
  .p-gallery-pill[data-corner="br"] { inset-block-end: var(--p-space-2); inset-inline-end: var(--p-space-2); }

  /* ---- a picture that opens -----------------------------------------
   * The frame around any content picture outside a gallery: a staged
   * upload, an attachment, one image in a card.
   *
   * A BUTTON, because pressing it opens a modal dialog and that is what
   * a button is. An `<img>` with a click handler is unreachable by a
   * keyboard, announces nothing, and cannot take a focus ring — so where
   * a project has not written the control, the script wraps the picture
   * in one. This rule is what makes that wrapper invisible: it is a
   * frame, not a button anyone should see. */
  .p-img-open {
    /* Inline-block with the baseline taken off, because that is how the
     * `<img>` it replaced was laid out — a wrapper that changes the flow
     * around the picture is a wrapper that moves the page. NOT `display:
     * contents`: a box that generates no box cannot take a focus ring,
     * and the engines have a history of dropping such an element out of
     * the accessibility tree entirely, which would undo the one thing
     * this element is for.
     *
     * Everything else here is undoing the button: a control's fixed
     * height would crop a photograph to 2.25rem. */
    display: inline-block;
    vertical-align: bottom;
    max-inline-size: 100%;
    block-size: auto;
    padding: 0;
    border: 0;
    border-radius: inherit;
    background: none;
    color: inherit;
    font: inherit;
    line-height: 0;
    cursor: zoom-in;
    /* A press dips a button by a pixel. A photograph is not a button and
     * has no business moving under the pointer. */
    transition: none;
  }

  .p-img-open:active { translate: none; }

  .p-img-open:hover,
  .p-img-open:focus-visible {
    background: none;
    border-color: transparent;
  }

  /* ---- the full view ------------------------------------------------
   * A Screen Modal and nothing more exotic: it inherits the pinned
   * head and footer, the matching end heights, the corner insets and the
   * scroll lock from the overlay rules, so a viewer cannot drift away
   * from every other overlay in the system. */
  .p-viewer > .p-viewer-stage {
    /* Flex, not grid, and the difference is load-bearing: `max-block-size:
     * 100%` on the picture has to resolve against something definite, and
     * a grid row sized by its own content is not — the percentage is
     * cyclic, resolves to none, and the picture quietly overflows the
     * stage it is supposed to be fitting inside. A flex container's cross
     * size is definite here, so the cap holds. */
    display: flex;
    align-items: center;
    justify-content: center;
    /* Fit means fit. Nothing scrolls until actual size is asked for. */
    overflow: hidden;
    /* Inset, not the body surface. A picture is judged against its
     * surroundings, and the darkest thing available is the least
     * opinionated frame to judge it in. */
    background: var(--p-surface-inset);
  }

  /* A viewer's footer holds no endings to weigh against each other —
   * there is nothing to commit here and nothing to lose, so the rule
   * that keeps cancel away from the commit has no commit to keep it away
   * from. Paging IS the work, so it takes the middle, and Close takes
   * the corner any other frame would give it. */
  .p-viewer > .p-actions {
    position: relative;
    justify-content: center;
  }

  /* Nothing in this row is placed by `order`, so nothing should be
   * ranked by it either — otherwise Close would be dragged left by the
   * cancel rule before the corner rule below puts it back. `data-order=
   * "dom"` on the row says so out loud, and `pulse check` reads it: a row
   * that paints in DOM order has no order to get wrong. */
  .p-viewer > .p-actions > * { order: 0; }

  /* ONE PICTURE HAS NOTHING TO PAGE TO. Previous and Next stay in the
   * markup — the same viewer opens a queue of one and a gallery of forty,
   * and rewriting its footer per open would be the script deciding what
   * the frame looks like. It reports the count instead, and the row takes
   * the dead controls away. */
  .p-viewer[data-single] > .p-actions > :is([data-viewer-prev], [data-viewer-next]),
  .p-viewer[data-single] [data-viewer-pos] {
    display: none;
  }

  .p-viewer > .p-actions > [data-intent="cancel"] {
    position: absolute;
    inset-block: 0;
    inset-inline-end: var(--p-dialog-inset);
    margin-block: auto;
    margin-inline-end: 0;
  }

  .p-viewer-img {
    min-block-size: 0;
    max-inline-size: 100%;
    max-block-size: 100%;
    object-fit: contain;
    border-radius: var(--p-radius-md);
  }

  /* Zoom is a scale on the picture and pan is a translate, both written
   * by the script and both read from here. Scrolling the stage is NOT
   * how a zoomed picture is moved: a scrollport can only reach what is
   * inside it, and a picture scaled past its box is outside it.
   *
   * `touch-action: none` is what stops the browser treating a pinch as a
   * page zoom and a drag as a page scroll. Nothing here scrolls, so
   * nothing is being taken away. */
  .p-viewer > .p-viewer-stage {
    touch-action: none;
  }

  .p-viewer-img {
    cursor: zoom-in;
    /* No transition on scale. A wheel notch or a pinch produces a stream
     * of small changes, and animating each one turns a direct
     * manipulation into a lag — the picture arrives where the fingers
     * were, not where they are. */
    transition: none;
  }

  .p-viewer[data-zoomed="on"] .p-viewer-img {
    cursor: grab;
    border-radius: 0;
  }

  .p-viewer[data-panning="on"] .p-viewer-img { cursor: grabbing; }

  /* Motion here is decoration on top of a state change that has already
   * happened in the markup, so removing it costs nothing. */
  @media (prefers-reduced-motion: reduce) {
    .p-gallery-tile,
    .p-gallery-open > img,
    .p-gallery-tile::after,
    .p-gallery-act,
    .p-gallery-check {
      transition-duration: 1ms;
    }

    .p-gallery-tile:hover .p-gallery-open > img { scale: 1; }
  }

  /* spec: progress */
  /* spec: spinner */
  /* spec: skeleton */
  /* =================================================================
   * Waiting
   *
   * Five indicators, and which one to use is decided by the shape of
   * the wait rather than by taste. Ordered, first match wins — the same
   * form as the intent procedure, for the same reason: an answer that
   * two people reach independently.
   *
   *   1. Do you know the fraction, and does knowing it help?
   *                                        -> <progress value max>
   *   2. Is the shape of what is coming already known?
   *                                        -> .p-skeleton
   *   3. Did one control start it, and is only that control blocked?
   *                                        -> aria-busy on the control
   *   4. Is the answer already arriving in pieces?
   *                                        -> .p-stream
   *   5. Otherwise                         -> .p-spinner
   *
   * Rule 1 asks two questions on purpose. A percentage that crawls,
   * stalls at 90 and jumps is worse than no number at all, and a bar
   * that fills in 200ms is a flash of noise.
   *
   * Never two of these for one wait. A spinner inside a skeleton says
   * the page is loading twice.
   *
   * All five are silent to a screen reader on their own — they are
   * pictures. The waiting region carries aria-busy="true", and anything
   * that should be announced needs role="status".
   * ================================================================= */
  /* One definition, two hosts. The busy control's ::before is the same
   * spinner, which is also how it takes the slot back off an icon:
   * while the wait runs the spinner IS the icon, so the button neither
   * grows a second glyph nor changes width. */
  .p-spinner,
  :is(button, .p-btn)[aria-busy="true"]::before {
    flex: none;
    inline-size: var(--p-spinner-size);
    block-size: var(--p-spinner-size);
    border-radius: var(--p-radius-full);

    /* A comet rather than an arc: the head is solid and the tail falls
     * away behind it, so the shape reads as travelling even in a still
     * frame. The flat colour underneath is the track, and the ring is
     * cut out of both at once by the mask.
     *
     * currentColor throughout, like an icon: it matches the text beside
     * it in every context without a per-tone variant, and inside a
     * filled button it is legible for free. Set `color` to accent it.
     *
     * Every stop is a color-mix, including the invisible ones. Mixing
     * toward `transparent` is mixing toward transparent *black*, which
     * greys the taper on the way out. */
    background-color: color-mix(in oklab, currentColor 14%, transparent);
    background-image: conic-gradient(
      from 0deg,
      color-mix(in oklab, currentColor 0%, transparent) 0deg,
      color-mix(in oklab, currentColor 10%, transparent) 100deg,
      color-mix(in oklab, currentColor 42%, transparent) 215deg,
      color-mix(in oklab, currentColor 100%, transparent) 325deg,
      color-mix(in oklab, currentColor 0%, transparent) 344deg
    );
    -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - var(--p-spinner-stroke)), #000 0);
    mask: radial-gradient(farthest-side, #0000 calc(100% - var(--p-spinner-stroke)), #000 0);
    animation: p-spin var(--p-dur-spin) var(--p-ease-spin) infinite;
  }

  .p-spinner { display: inline-block; }

  :is(button, .p-btn)[aria-busy="true"]::before {
    content: "";
    translate: 0 calc(-1 * var(--p-optical-applied));
  }

  @keyframes p-spin {
    to { rotate: 1turn; }
  }

  /* spec: level */
  /* spec: level-rows */
  /* =================================================================
   * Level — a live signal, not a filling container
   *
   * NOT A GAUGE, and the difference is the whole design. A gauge asks
   * "how full", its colour means "you are running out", and the number
   * moves when something happens. A level is a measurement of what is
   * happening right now: it moves thirty times a second whether or not
   * anything is wrong, it spends most of its life going back down, and
   * its colour zones mean "you are loud" rather than "act". Reading a
   * gauge at 30Hz would also mean writing aria-valuenow at 30Hz, which
   * fills a screen reader's queue with noise and buries everything else
   * on the page.
   *
   * So: THE BAR IS DECORATIVE, and the readout beside it is the
   * accessible fact. Mark the bar aria-hidden and let the number carry
   * the value on demand; announce only the things that change state —
   * connected, silent, clipping — and announce those politely.
   *
   *     <div class="p-levels">
   *       <span>Microphone</span>
   *       <div class="p-level" aria-hidden="true"
   *            style="--p-level: .58; --p-level-peak: .86"></div>
   *       <strong class="p-level-read">−18 dB</strong>
   *     </div>
   *
   * The project owns the scale. --p-level and --p-level-peak are 0 to 1
   * of the BAR, not of anything acoustic: dBFS is logarithmic and where
   * −20dB should sit is a decision about the meter's ballistics, which
   * belongs to whoever knows what is being measured. Pulse draws it.
   * ================================================================= */

  .p-level {
    --p-level: 0;
    --p-level-peak: 0;
    --p-level-ring-gap: 2px;
    position: relative;
    display: block;
    min-inline-size: 3rem;
    block-size: var(--p-level-bar);
    border-radius: var(--p-radius-full);
    background: var(--p-surface-inset);
    overflow: hidden;
    /* Its own layer. Everything below animates by transform so a level
     * running at frame rate repaints this box and nothing else. */
    contain: strict;
  }

  /* THE SCALE IS PAINTED ONCE AND MASKED, rather than a fill that grows.
   * A gradient on a growing element stretches with it, so the colour at
   * the pointer would change as the level moved — the exact opposite of
   * a meter, where a given position always means a given loudness. The
   * zones live on the track, and a shade over the quiet end is what
   * moves. Solid colour scaled from the trailing edge: no reflow, no
   * gradient resampling, and it composites. */
  .p-level::after {
    content: "";
    position: absolute;
    inset: 0;
    background: var(--p-surface-inset);
    transform-origin: right;
    transform: scaleX(calc(1 - var(--p-level)));
  }

  .p-level:dir(rtl)::after { transform-origin: left; }

  .p-level::before {
    content: "";
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    inline-size: 2px;
    border-radius: 1px;
    background: var(--p-text-strong);
    /* Over the shade, not under it. ::after comes later in tree order and
     * so paints on top by default — which hid the peak marker in the one
     * position it is ever in, above the current level. Invisible while
     * the level was loud enough to reach it, and invisible again the
     * instant it fell back. */
    z-index: 1;
    /* The peak sits ON the level's own scale, so it is placed by the
     * same 0..1 the fill uses. It holds and decays on the project's
     * clock; Pulse only draws where it is. */
    translate: calc(var(--p-level-peak) * (100cqi - 2px)) 0;
    opacity: clamp(0, var(--p-level-peak) * 100000, 1);
  }

  .p-level:dir(rtl)::before { translate: calc(var(--p-level-peak) * (2px - 100cqi)) 0; }

  /* THE ZONES COME FROM THE PALETTE, NOT FROM THE MEANINGS.
   *
   * They were --p-good, --p-warn and --p-bad, and that was wrong twice
   * over. Those three are the colours of *act on this*: a gauge wearing
   * them says you are running out of disk. A level at 90% says somebody
   * is talking loudly, which is not a problem and must not borrow the
   * vocabulary of one. And a meter that reached for the semantic tokens
   * came out looking like a traffic light rather than like Pulse.
   *
   * So the ramp is built from the twenty-five-hue palette instead, and
   * deliberately from the hues that are NOT spoken for: red is danger,
   * gold is caution, green is success, azure is utility and indigo is
   * the accent, so all five are skipped. What is left — emerald, lime,
   * citron, amber, vermilion — still runs calm to hot, because that is
   * the one thing a level has to say without a label, but it says it in
   * this system's own colours.
   *
   * Each stop is constructed exactly as a semantic colour is: the solved
   * per-mode lightness, that hue's band, and that hue's per-mode chroma
   * trim. Nothing here is a literal. */
  .p-level {
    container-type: inline-size;
    --p-level-1: oklch(
      calc(var(--p-l-sem) + var(--p-hue-12-hi) * (var(--p-l-sem-lift) - var(--p-l-sem)))
      calc(var(--p-c-sem) * (var(--p-hue-12-cx)
           + var(--p-lt) * (var(--p-hue-12-cxl) - var(--p-hue-12-cx))))
      var(--p-hue-12));
    --p-level-2: oklch(
      calc(var(--p-l-sem) + var(--p-hue-9-hi) * (var(--p-l-sem-lift) - var(--p-l-sem)))
      calc(var(--p-c-sem) * (var(--p-hue-9-cx)
           + var(--p-lt) * (var(--p-hue-9-cxl) - var(--p-hue-9-cx))))
      var(--p-hue-9));
    --p-level-3: oklch(
      calc(var(--p-l-sem) + var(--p-hue-8-hi) * (var(--p-l-sem-lift) - var(--p-l-sem)))
      calc(var(--p-c-sem) * (var(--p-hue-8-cx)
           + var(--p-lt) * (var(--p-hue-8-cxl) - var(--p-hue-8-cx))))
      var(--p-hue-8));
    --p-level-4: oklch(
      calc(var(--p-l-sem) + var(--p-hue-5-hi) * (var(--p-l-sem-lift) - var(--p-l-sem)))
      calc(var(--p-c-sem) * (var(--p-hue-5-cx)
           + var(--p-lt) * (var(--p-hue-5-cxl) - var(--p-hue-5-cx))))
      var(--p-hue-5));
    --p-level-5: oklch(
      calc(var(--p-l-sem) + var(--p-hue-3-hi) * (var(--p-l-sem-lift) - var(--p-l-sem)))
      calc(var(--p-c-sem) * (var(--p-hue-3-cx)
           + var(--p-lt) * (var(--p-hue-3-cxl) - var(--p-hue-3-cx))))
      var(--p-hue-3));

    background-image: linear-gradient(
      to right,
      var(--p-level-1) 0%,
      var(--p-level-2) 46%,
      var(--p-level-3) 66%,
      var(--p-level-4) 84%,
      var(--p-level-5) 100%
    );
  }

  /* NO GATE MARKER, and it was drawn before it was cut. A noise floor
   * belongs on the track, and the track is what the shade covers — so a
   * hairline at the gate position was hidden for exactly as long as the
   * signal was below it, which is the whole time the reader wants to see
   * where it is. Both pseudo-elements are already spent (the shade and
   * the peak), and a third would mean an element in the markup, which
   * this pattern does not have. Voice Control listed it as optional and
   * it is not built; a project that needs it should report that, and it
   * arrives as its own thing rather than as a line that lies.
   * ------------------------------------------------------------- */

  /* CLIPPING LATCHES. A peak that lasts one frame is a peak nobody saw,
   * and it is the one thing on this bar worth acting on — so it stays
   * lit until the project clears it, and it is the only state here that
   * is worth announcing. */
  .p-level[data-clip]::after { background: var(--p-surface-inset); }

  /* TWO SHADOWS, NOT AN OUTLINE. An outline's corner radius does not grow
   * with its offset, so around a 10px pill the ring sat hard against the
   * top and bottom edges while the long sides looked clear — it read as
   * touching underneath. A spread shadow expands the border-radius with
   * it, so both rings stay concentric with the bar at any height: first
   * the page colour as a gap, then the ring. Neither takes layout space,
   * which is why the rows below reserve some. */
  .p-level[data-clip] {
    box-shadow:
      0 0 0 var(--p-level-ring-gap) var(--p-surface),
      0 0 0 calc(var(--p-level-ring-gap) + 2px) var(--p-bad);
  }

  /* Nothing is arriving. Not zero — zero is silence, which is a real
   * measurement and looks like an empty bar. A dead input has no
   * measurement at all, so the scale itself goes flat. */
  .p-level[data-state="off"],
  .p-level[data-state="stale"] {
    background-image: none;
  }

  .p-level[data-state="stale"] { opacity: 0.55; }

  /* A meter that animates its own value would be lying about when the
   * sound happened. The only thing that eases is the state change. */
  .p-level::after,
  .p-level::before { transition: none; }

  /* ---- the row a level lives in ------------------------------------
   * Two meters stacked have to share one bar start and one readout
   * width, or the eye reads the difference in their left edges as a
   * difference in level. A grid does that; two flex rows cannot. */
  .p-levels {
    display: grid;
    grid-template-columns: auto minmax(4rem, 1fr) auto;
    align-items: center;
    /* Room for a clipping bar's ring, which is drawn outside the bar and
     * takes no space of its own. Stacked any tighter and the ring on one
     * meter overlaps the bar of the next. */
    gap: var(--p-space-6) var(--p-space-3);
    font-size: var(--p-text-sm);
  }

  /* Tabular, and wide enough for the longest reading it will ever show,
   * so the number does not shove the bar sideways as it changes. */
  .p-level-read {
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum" 1;
    min-inline-size: 4.5ch;
    text-align: end;
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
  }

  .p-levels:has(.p-level[data-clip]) .p-level-read { color: var(--p-bad-text); }

  /* spec: exclusive-mark */
  /* =================================================================
   * The one that is on — an exclusive choice, marked in the row
   *
   * A list where exactly one member is live: the voice being used, the
   * audio device in play, the environment being deployed to, the
   * profile in force. Not navigation — you are not going anywhere — and
   * not a toggle per row, because turning this one on turns another one
   * off.
   *
   * WHY IT IS ALWAYS VISIBLE. `.p-tree-actions` hides its buttons until
   * the row is hovered, which is right for things you rarely do and
   * wrong for a fact you need to read. The reader's question is "which
   * one is on", and an answer that requires hovering every row in turn
   * is not an answer. This mark is part of the row, at rest.
   *
   * WHY IT IS A RADIO. It is exclusive, one per named group, and the
   * browser already has that: arrow keys move within the group, the
   * group takes one tab stop, and assistive technology says "3 of 7"
   * without being told. A checkbox with JavaScript enforcing
   * exclusivity is the same picture and none of the behaviour — it
   * announces "checked" with no sense that anything else changed.
   *
   *     <div class="p-tree-row">
   *       <button class="p-grip" data-icon="grip" …></button>
   *       <span class="p-tree-name">Katelyn</span>
   *       <input class="p-choice" type="radio" name="voice" checked
   *              aria-label="Use Katelyn">
   *     </div>
   *
   * Clicking the row and clicking the mark do the same thing; wrapping
   * the row in a <label for> is the cheapest way to get that, and it
   * keeps the grip clickable because a button inside a label is not a
   * label activator.
   * ================================================================= */

  /* NOT `.p-mark`, which is already the inline mark on a piece of content
   * — Pinned, Edited, Forwarded. That name was taken and reusing it put
   * the two on the same page in the specification's own overview, where
   * the message marks started answering to a rule written for a radio.
   * The suite caught it; nothing in the stylesheet would have. */
  .p-choice {
    flex: none;
    margin: 0;
    inline-size: var(--p-choice-size);
    block-size: var(--p-choice-size);
    accent-color: var(--p-accent);
  }

  /* A row with a mark reserves the space whether or not this row is the
   * chosen one, so the names stay on one left edge and the marks on one
   * right edge. */
  .p-tree-row:has(> .p-choice) { padding-inline-end: var(--p-space-2); }

  /* SWITCHING IS A STATE, not a spinner in place of the mark. Replacing
   * it would move the row's contents at the moment the reader is
   * looking to see whether their click landed. The mark stays, the row
   * says it is busy, and the pointer says the answer is not in yet. */
  .p-tree-row[data-busy] {
    cursor: progress;
    color: var(--p-text-muted);
  }

  .p-tree-row[data-busy] > .p-choice {
    opacity: 0.5;
    animation: p-choice-waiting var(--p-dur-3) var(--p-ease) infinite alternate;
  }

  @keyframes p-choice-waiting {
    to { opacity: 1; }
  }

  @media (prefers-reduced-motion: reduce) {
    .p-tree-row[data-busy] > .p-choice { animation: none; }
  }

  /* The switch did not take. The row keeps the mark it actually has —
   * which is the old one — and says so, rather than showing the choice
   * the reader tried to make as though it had worked. */
  .p-tree-row[data-failed] {
    color: var(--p-bad-text);
    box-shadow: inset 0 0 0 1px var(--p-bad);
  }

  .p-tree-row:has(> .p-choice:disabled) {
    color: var(--p-text-subtle);
    cursor: not-allowed;
  }

  /* The chosen row reads as chosen without the mark being the only
   * evidence — the same weight `aria-current` gets in a nav, for the
   * same reason: colour alone is never the whole message. */
  .p-tree-row:has(> .p-choice:checked) {
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
    font-weight: var(--p-weight-semibold);
  }

  /* spec: card-folders */
  /* =================================================================
   * Card folders — one level of grouping over a card grid
   *
   * A library big enough to need grouping but not deep enough to need a
   * tree: voices in folders, models by family, saved boards. `.p-tree`
   * already folds, but its rows are one line of text — this is for when
   * the things being grouped are worth looking at rather than reading.
   *
   * ONE LEVEL, on purpose. A card grid inside a card grid inside a card
   * grid is a file manager, and a file manager wants the tree. Nesting
   * these is not prevented — CSS cannot — but nothing here is designed
   * for it, and a project that needs it wants `.p-tree` down the side
   * with the cards on the right.
   *
   *     <details class="p-card-folder" open>
   *       <summary>
   *         <span class="p-tree-name">Favourites</span>
   *         <span class="p-badge">4</span>
   *       </summary>
   *       <div class="p-card-folder-body">
   *         <article class="p-card">…</article>
   *       </div>
   *     </details>
   *
   * The children are ordinary cards. Whatever a project already renders
   * into a `.p-grid` goes in here unchanged, which is the point: this is
   * a container, not a new kind of card.
   * ================================================================= */

  .p-card-folder {
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-lg);
    background: var(--p-fill-raised);
    /* The body's corners are square; the folder's are not. Without this
     * the grid's first card paints over the rounded corner at exactly
     * the moment the folder is open, which is when anyone is looking. */
    overflow: clip;
  }

  /* The element layer already gives every summary its flex row, its
   * caret and the quarter turn that opens it. All this adds is the
   * height of a control, so a folder head lines up with the rows and
   * buttons around it. */
  .p-card-folder > summary {
    min-block-size: var(--p-control-h);
    cursor: pointer;
  }

  .p-card-folder > summary:hover { background: var(--p-wash-hover); }

  /* Everything after the name goes to the trailing edge: the count, the
   * folder's own menu, a colour swatch. One rule rather than a class
   * per thing that might sit there. */
  .p-card-folder > summary > .p-tree-name { flex: 1; min-inline-size: 0; }

  .p-card-folder-body {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(var(--p-grid-min, 15rem), 1fr));
    gap: var(--p-space-4);
    padding: var(--p-space-4);
    border-block-start: 1px solid var(--p-line);
  }

  /* A folder with nothing in it is still a drop target, so it keeps a
   * body rather than collapsing to its summary. Empty and closed look
   * different, which is the difference between "nothing here" and "you
   * have not looked". */
  .p-card-folder-body:empty {
    min-block-size: 4.5rem;
    place-content: center;
  }

  /* spec: bar-lanes */
  /* =================================================================
   * Three lanes, and the middle one really is the middle
   *
   * A status a reader glances at rather than reads — Live, Recording,
   * Connected — belongs at the centre of the bar, and it has to STAY
   * there when the brand on one side and the controls on the other are
   * different widths. `.p-bar` with a `.p-grow` centres in the space
   * that is left over, so the status drifts off centre by half the
   * difference between the two ends, and drifts further every time a
   * label changes. It reads as a wobble in a thing that is supposed to
   * be fixed.
   *
   *     <header class="p-bar" data-lanes="3">
   *       <div>brand and navigation</div>
   *       <div>status</div>
   *       <div>controls</div>
   *     </header>
   *
   * `1fr auto 1fr` is what makes it true: the two edge tracks are equal
   * by construction whatever they hold, so the middle track's centre is
   * the bar's centre. No measuring, no absolute positioning, and the
   * centre lane still takes part in layout — which is what stops it
   * sliding under the controls when the window narrows.
   * ================================================================= */

  .p-bar[data-lanes="3"] {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
    align-items: center;
    gap: var(--p-space-3);
  }

  .p-bar[data-lanes="3"] > :first-child {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    min-inline-size: 0;
  }

  .p-bar[data-lanes="3"] > :nth-child(2) {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    justify-content: center;
    min-inline-size: 0;
  }

  /* The controls end at the trailing edge, so the last thing in the bar
   * sits where every other trailing action in the system sits. */
  .p-bar[data-lanes="3"] > :last-child {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--p-space-3);
    min-inline-size: 0;
  }

  /* WHAT GIVES WAY FIRST, in order, so a narrow window degrades the
   * same way every time instead of whichever way the content happens to
   * fall. The edges are allowed to shrink and truncate; the centre is
   * not, because a status truncated to "Rec…" is worse than no status.
   * When even that will not fit, the centre leaves the middle rather
   * than colliding — it is the only thing here that can be moved
   * without losing a control. */
  .p-bar[data-lanes="3"] > :first-child,
  .p-bar[data-lanes="3"] > :last-child { overflow: hidden; }

  .p-bar[data-lanes="3"] > :nth-child(2) { white-space: nowrap; }

  /* A COMPONENT THAT RESHAPES ITSELF NEEDS SOMEBODY ELSE TO MEASURE.
   *
   * The two reflows below — the three-lane bar dropping its centre onto
   * a second row, and a route turning from a row into a column — are
   * container queries against the component's OWN box, so the container
   * has to be its parent. Nothing declared one, in this file or in any
   * project, so neither query has ever matched anywhere: the bar stayed
   * `1fr auto 1fr` at every width and its own overflow rules quietly
   * clipped the application's name off the leading edge, which is how
   * Dashboard found it.
   *
   * Pulse's other container queries look the other way — `.p-media` and
   * the fitted table are containers themselves and reshape their
   * CHILDREN — so this failure mode did not exist until a component
   * needed to reshape itself.
   *
   * `:has(> …)` puts the container on whatever the parent happens to be,
   * which is the only thing that works without inventing a wrapper
   * element for a contract that never had one. Inline-size containment
   * means that parent's width may no longer come from its contents, so
   * a shrink-to-fit parent would collapse — every real host of these two
   * is a full-width row (a shell's <header>, a page column, body), and
   * the alternative was leaving both patterns broken. */
  :has(> .p-bar[data-lanes="3"]),
  :has(> .p-route) {
    container-type: inline-size;
  }

  @container (max-width: 46rem) {
    .p-bar[data-lanes="3"] {
      grid-template-columns: minmax(0, 1fr) auto;
      /* The trailing actions span both rows and centre on the whole bar.
       * Left on the first row, the settings cog sat high in a two-line
       * header — "the top right settings button is not centered" (the
       * owner, in Chorus). */
      grid-template-areas:
        "lead  trail"
        "mid   trail";
      row-gap: var(--p-space-2);
    }

    .p-bar[data-lanes="3"] > :first-child { grid-area: lead; }
    .p-bar[data-lanes="3"] > :nth-child(2) {
      grid-area: mid;
      justify-content: flex-start;
      /* Wrapping beats truncating once it is on its own line: there is
       * room for two lines here and there was never room for half a
       * word. */
      white-space: normal;
      flex-wrap: wrap;
    }
    .p-bar[data-lanes="3"] > :last-child { grid-area: trail; align-self: center; }
  }

  /* spec: route */
  /* =================================================================
   * Route — a short signal path you can edit
   *
   * Where something comes from, what happens to it, and where it goes:
   * a microphone into a converter into an output, a file through an
   * importer into a table, a camera through an encoder into a stream.
   * The reader's question is not "what are these three things" — it is
   * "where does the sound stop", and a route answers it by drawing the
   * order the parts are in.
   *
   * LINEAR ONLY, with one branch. A patch bay where anything can reach
   * anything is a node graph: it needs pan, zoom, edge routing and a
   * canvas, and none of that is this. The moment a project needs a
   * second input feeding a second processor, it has outgrown this
   * pattern, and drawing it here badly would be worse than saying so.
   *
   *     <div class="p-route">
   *       <button class="p-route-node" data-icon="mic">…</button>
   *       <div class="p-route-node" data-state="live" data-icon="waveform">…</div>
   *       <button class="p-route-node" data-icon="speaker">…</button>
   *     </div>
   *
   * A node is a button when it can be changed and a div when it cannot,
   * which is the only difference the reader needs and the only one
   * assistive technology can act on. Nothing here invents an edit
   * affordance: a node that is a button already says it can be pressed.
   * ================================================================= */

  .p-route {
    display: flex;
    align-items: stretch;
    gap: var(--p-space-3);
    min-inline-size: 0;
  }

  /* THE CONNECTOR IS BETWEEN THE NODES, drawn by the node that follows
   * one. An arrow written into the markup is a character assistive
   * technology reads aloud — "Shure MV7 right arrow MMVC Engine" — and
   * a decorative span that has to be repeated between every pair. The
   * order is already in the DOM; this only makes it visible. */
  .p-route-node + .p-route-node::before {
    content: "";
    position: absolute;
    inset-inline-start: calc(var(--p-space-3) * -1);
    inline-size: var(--p-space-3);
    inset-block-start: 50%;
    border-block-start: 1px solid var(--p-border);
  }

  .p-route-node + .p-route-node::after {
    content: "";
    position: absolute;
    inset-inline-start: calc(var(--p-space-3) * -0.5);
    inset-block-start: 50%;
    translate: 0 -50%;
    inline-size: 5px;
    block-size: 5px;
    rotate: 45deg;
    border-block-start: 1px solid var(--p-border);
    border-inline-end: 1px solid var(--p-border);
  }

  .p-route-node {
    position: relative;
    flex: 1 1 0;
    /* Equal widths, and none of them may push the row wider than the
     * page — a device name is arbitrary text and one long one used to
     * take the whole route sideways. */
    min-inline-size: 0;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: var(--p-space-3);
    /* A button is one line of the height of a control. A node is two —
     * what kind of thing it is, and which one — so it has to take its
     * height back from the element layer and keep a control's height only
     * as a floor. Left as it was, the div node grew to fit and the two
     * button nodes beside it did not, which put three boxes of two
     * different heights on a row whose whole job is to line up. */
    block-size: auto;
    min-block-size: var(--p-control-h);
    white-space: normal;
    /* BOTH, and they are not redundant. A node is a button when it can
     * be changed and a div when it cannot, so this one selector has to
     * pad two different kinds of element to the same height.
     *
     * `--p-pad-block` is how a control asks for vertical padding here —
     * the optical rule further down owns `padding-block` on every button
     * at (0,1,2) and splits it asymmetrically so capitals sit centred.
     * A plain `padding-block` on this class simply loses to it, which is
     * why the buttons came out 14px shorter than the div beside them
     * with their labels against the border.
     *
     * The plain declaration is what pads the div, which that rule never
     * matches. The two agree because the optical split still sums to
     * twice the pedestal — same height, only the content sits truer. */
    --p-pad-block: var(--p-space-4);
    padding-block: var(--p-space-4);
    padding-inline: var(--p-space-4);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-md);
    background: var(--p-fill-raised);
    color: var(--p-text);
    font: inherit;
    font-size: var(--p-text-sm);
    line-height: var(--p-leading-tight);
    text-align: start;
  }

  button.p-route-node { cursor: pointer; }

  button.p-route-node:hover {
    border-color: var(--p-border-strong);
    background: var(--p-wash-hover);
  }

  /* The stage doing the work. One node at a time, and it is the accent
   * because it is the thing the reader is looking for. */
  .p-route-node[data-state="live"] {
    border-color: var(--p-border-accent);
    background: var(--p-tint-subtle);
  }

  .p-route-node[data-state="bad"] {
    border-color: var(--p-bad);
    color: var(--p-bad-text);
  }

  .p-route-node[data-state="off"] {
    color: var(--p-text-subtle);
    border-style: dashed;
  }

  /* Type over selection, in that order: the reader scans the row for
   * "output" and then reads which one. Both truncate rather than wrap,
   * because a node that grows a line taller than its neighbours breaks
   * the line the connectors sit on. */
  .p-route-body { min-inline-size: 0; flex: 1; }

  .p-route-kind {
    display: block;
    color: var(--p-text-subtle);
    font-size: var(--p-text-2xs);
    font-weight: var(--p-weight-semibold);
    letter-spacing: var(--p-tracking-label);
    text-transform: uppercase;
  }

  .p-route-pick {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* ---- the branch ---------------------------------------------------
   * A tap off the path rather than a stage in it — headphones listening
   * to the output, a recorder copying the stream. It hangs under the
   * node it comes from, so which one it is tapping is a matter of
   * position rather than a label saying so. */
  .p-route-tap {
    position: relative;
    margin-block-start: var(--p-space-5);
    margin-inline-start: var(--p-space-6);
  }

  .p-route-tap::before {
    content: "";
    position: absolute;
    inset-inline-start: calc(var(--p-space-5) * -1);
    inset-block-start: calc(var(--p-space-5) * -1);
    inline-size: var(--p-space-5);
    block-size: calc(var(--p-space-5) + 50%);
    border-inline-start: 1px solid var(--p-border);
    border-block-end: 1px solid var(--p-border);
    border-end-start-radius: var(--p-radius-md);
  }

  /* A route is a row until it cannot be. Stacked, the connector turns to
   * face down the column — the order is the message, so it has to
   * survive the reflow rather than becoming three unrelated cards. */
  @container (max-width: 46rem) {
    .p-route { flex-direction: column; }

    /* `flex: 1 1 0` is what makes the nodes equal widths in a row, and
     * once the row becomes a column that same basis of zero applies to
     * their HEIGHT — so every node collapsed to its floor and its two
     * lines of text spilled over the border. Equal heights were never
     * wanted here; the column is as tall as what is in it. */
    .p-route > .p-route-node { flex: none; }

    .p-route-node + .p-route-node::before {
      inset-inline-start: 50%;
      inset-block-start: calc(var(--p-space-3) * -1);
      inline-size: 0;
      block-size: var(--p-space-3);
      border-block-start: 0;
      border-inline-start: 1px solid var(--p-border);
    }

    .p-route-node + .p-route-node::after {
      inset-inline-start: 50%;
      inset-block-start: calc(var(--p-space-3) * -0.5);
      translate: -50% 0;
      rotate: 135deg;
    }
  }

  /* spec: gauge */
  /* spec: ring */
  /* spec: small-ring */
  /* =================================================================
   * Gauge — how full, not how far along
   *
   * A progress bar is a task advancing toward completion and it always
   * ends. A gauge is a bounded resource being consumed, and it may sit
   * at 30% forever: a context window, a quota, a disk. They look alike
   * and mean opposite things, so they are separate components and the
   * question to ask is whether the number can go back down.
   *
   * Four stages, because "72% full" answers a question nobody asked.
   * What a reader wants is whether to act, and that is a category, not
   * a number. Colour carries the category and length carries the
   * magnitude, so the two are never doing the same job twice.
   *
   * The stage colours are the fixed semantic hues rather than the
   * accent, for the reason the transfer intent uses info blue: running
   * out of room has to mean the same thing in every project, and the
   * accent belongs to the user.
   *
   * Set one custom property, and give it a name and a number for
   * anything that is not looking at it:
   *
   *     <div class="p-gauge" role="meter" aria-valuenow="62"
   *          aria-valuemin="0" aria-valuemax="100"
   *          aria-label="Context window" style="--p-gauge-value: .62">
   * ================================================================= */

  /* Each of these is 0 below its threshold and 1 above it — clamp
   * turned into a step by multiplying the difference up until it
   * saturates. CSS has no conditional, but it does have arithmetic, and
   * this keeps the whole component declarative rather than making every
   * caller compute a class name. */
  .p-gauge {
    --p-g1: clamp(0, (var(--p-gauge-value) - var(--p-gauge-t1)) * 100000, 1);
    --p-g2: clamp(0, (var(--p-gauge-value) - var(--p-gauge-t2)) * 100000, 1);
    --p-g3: clamp(0, (var(--p-gauge-value) - var(--p-gauge-t3)) * 100000, 1);

    /* Three nested mixes, each choosing outright because its percentage
     * is only ever 0% or 100%. Read from the inside out: the base, then
     * each threshold overwriting it in turn. */
    --p-gauge-tone: color-mix(in oklab,
      color-mix(in oklab,
        color-mix(in oklab, var(--p-good), var(--p-info) calc(var(--p-g1) * 100%)),
        var(--p-warn) calc(var(--p-g2) * 100%)),
      var(--p-bad) calc(var(--p-g3) * 100%));

    /* The cap colour vanishes at exactly zero, so an empty gauge is an
     * empty track rather than a lone dot sitting at twelve o'clock. */
    --p-gauge-cap: color-mix(in oklab,
      var(--p-gauge-tone) calc(clamp(0, var(--p-gauge-value) * 100000, 1) * 100%),
      transparent);

    display: block;
    block-size: var(--p-gauge-bar);
    border-radius: var(--p-radius-full);
    background: var(--p-surface-inset);
    overflow: hidden;
  }

  .p-gauge::before {
    content: "";
    display: block;
    block-size: 100%;
    inline-size: clamp(0%, calc(var(--p-gauge-value) * 100%), 100%);
    border-radius: inherit;
    background: var(--p-gauge-tone);
    transition:
      inline-size var(--p-dur-3) var(--p-ease),
      background-color var(--p-dur-3) var(--p-ease);
  }

  /* The same reading as a ring, for where a bar would be a stripe
   * across a card that is about something else. The fill lives on the
   * pseudo-element rather than the box so the box can hold a label. */
  .p-gauge[data-shape="ring"] {
    position: relative;
    /* inline-grid so a small one sits in a line of text like a spinner
     * does, and a large one still behaves as a block when it is alone. */
    display: inline-grid;
    place-items: center;
    inline-size: var(--p-gauge-size);
    block-size: var(--p-gauge-size);
    border-radius: 0;
    background: none;
    overflow: visible;
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-semibold);
    font-variant-numeric: tabular-nums;
    color: var(--p-text-strong);
  }

  /* Spinner-sized, for a table row or a status line rather than a
   * dashboard. No label — there is no room for one, and at this size
   * the colour and the arc are the whole message. */
  /* Proportionally heavier than the large ring, not just scaled down.
   * A stroke that is a fixed fraction of the diameter reads as hairline
   * once the diameter is a line of text tall, and at this size the arc
   * is the whole message. */
  .p-gauge[data-size="sm"] {
    --p-gauge-size: 1.2em;
    --p-gauge-stroke: max(3px, 0.22em);
    --p-gauge-bar: 4px;
  }

  .p-gauge[data-size="lg"] {
    --p-gauge-size: 6rem;
    --p-gauge-stroke: 10px;
    --p-gauge-bar: 10px;
    font-size: var(--p-text-md);
  }

  /* A conic gradient's angle is inside a background-image, which does
   * not interpolate, so the ring steps where the bar glides. Left as
   * it is: a gauge changes when its number changes, and inventing a
   * sweep would imply the resource drained smoothly in between. */
  .p-gauge[data-shape="ring"]::before {
    position: absolute;
    inset: 0;
    inline-size: auto;
    border-radius: var(--p-radius-full);
    /* Two layers: the arc itself, and a round cap at its start. A conic
     * gradient ends on a radius, so without these the arc is cut off
     * square at both ends while the bar beside it is a pill. The cap is
     * a dot the width of the stroke, centred on the stroke — which puts
     * it at 50% across and half a stroke down from the outer edge. */
    background:
      radial-gradient(
        circle at 50% calc(var(--p-gauge-stroke) / 2),
        var(--p-gauge-cap) 0 calc(var(--p-gauge-stroke) / 2 - 0.5px),
        transparent calc(var(--p-gauge-stroke) / 2)
      ),
      conic-gradient(
        var(--p-gauge-tone) calc(clamp(0, var(--p-gauge-value), 1) * 360deg),
        var(--p-surface-inset) 0
      );
    -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - var(--p-gauge-stroke)), #000 0);
    mask: radial-gradient(farthest-side, #0000 calc(100% - var(--p-gauge-stroke)), #000 0);
    transition: none;
  }

  /* The other cap. Same dot, on a box rotated to the end of the arc —
   * the rotation is the only way to put it there, since a background
   * position cannot be given in polar coordinates. Masked to the same
   * ring so antialiasing cannot leak it outside the stroke. */
  .p-gauge[data-shape="ring"]::after {
    content: "";
    position: absolute;
    inset: 0;
    rotate: calc(clamp(0, var(--p-gauge-value), 1) * 360deg);
    background: radial-gradient(
      circle at 50% calc(var(--p-gauge-stroke) / 2),
      var(--p-gauge-cap) 0 calc(var(--p-gauge-stroke) / 2 - 0.5px),
      transparent calc(var(--p-gauge-stroke) / 2)
    );
    -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - var(--p-gauge-stroke)), #000 0);
    mask: radial-gradient(farthest-side, #0000 calc(100% - var(--p-gauge-stroke)), #000 0);
  }

  /* Content-shaped, so the page does not jump when the content lands.
   * A skeleton is decoration by definition — mark it aria-hidden and
   * put aria-busy on the region it stands in for. */
  .p-skeleton {
    display: block;
    block-size: 1em;
    border-radius: var(--p-radius-sm);
    background: linear-gradient(
      90deg,
      var(--p-surface-inset) 25%,
      var(--p-surface-raised) 50%,
      var(--p-surface-inset) 75%
    );
    background-size: 200% 100%;
    animation: p-shimmer var(--p-dur-shimmer) linear infinite;
  }

  .p-skeleton[data-shape="circle"] {
    aspect-ratio: 1;
    inline-size: auto;
    border-radius: var(--p-radius-full);
  }

  .p-skeleton[data-shape="block"] {
    block-size: auto;
    border-radius: var(--p-radius-md);
  }

  @keyframes p-shimmer {
    to { background-position: -200% 0; }
  }

  /* An indeterminate <progress> is one with no value attribute. Engines
   * disagree completely on what their internal pseudo-elements do in
   * that state, so the animation is put on the element's own background
   * and both value pseudos are cleared out from under it. One drawing,
   * every engine. */
  /* A seamless loop, by construction rather than by taste.
   *
   * The tile is exactly twice the bar, it repeats, and one cycle moves
   * it by exactly one tile — so the last frame and the first frame are
   * the same picture and there is no seam to hide. Getting that
   * arithmetic right is the whole trick, and the numbers below are not
   * adjustable independently:
   *
   *   size 200% -> free space is (100% - 200%) = -1 bar
   *   a background-position of P%  shifts the image by P × -1 bar
   *   so P = 200% shifts it exactly 2 bars = one tile.
   *
   * The band sits at 75% of the tile, which is where it is off both
   * ends of the bar at the moment the cycle wraps. That is the other
   * half of a clean loop: even an identical frame reads as a jump if
   * the band is visible while it happens.
   *
   * Linear, and it has to be. A seamless loop needs the same velocity
   * at both ends of the cycle, and any ease that is fast in the middle
   * buys its slow ends by flinging the band across the visible part —
   * which is the flash this was reported as. */
  progress:indeterminate {
    background: var(--p-surface-inset);
    background-image: linear-gradient(
      90deg,
      color-mix(in oklab, var(--p-accent) 0%, transparent) 0 62%,
      var(--p-accent) 70% 80%,
      color-mix(in oklab, var(--p-accent) 0%, transparent) 88% 100%
    );
    background-size: 200% 100%;
    background-repeat: repeat;
    animation: p-slide var(--p-dur-slide) var(--p-ease-slide) infinite;
  }

  progress:indeterminate::-webkit-progress-bar,
  progress:indeterminate::-webkit-progress-value { background: none; }
  progress:indeterminate::-moz-progress-bar { background: none; }

  @keyframes p-slide {
    from { background-position: 200% 0; }
    to { background-position: 0% 0; }
  }

  /* These own their spacing through gap, so the base heading margin would
   * double up. */
  :is(.p-stack, .p-cluster, .p-grid, .p-card, .p-menu, .p-empty, .p-side)
    > :is(h1, h2, h3, h4, h5, h6) {
    margin-block-end: 0;
  }

  /* =================================================================
   * Focus trail
   *
   * Styling for pulse-focus.js. Uses the same outline as the real ring so
   * the ghost is visually identical to the thing it is trailing. Without
   * the script this rule never matches anything.
   * ================================================================= */
  .p-focus-trail {
    display: none;
    position: fixed;
    inset-block-start: 0;
    inset-inline-start: 0;
    z-index: var(--p-z-toast);
    outline: 2px solid var(--p-focus);
    outline-offset: 2px;
    pointer-events: none;
    will-change: transform, width, height, opacity;
  }

  @media (prefers-reduced-motion: reduce) {
    .p-focus-trail { display: none !important; }
  }

  /* =================================================================
   * Optical vertical centring
   *
   * Applied last, because it has to beat the `padding` shorthands in the
   * rules above. Flex centring puts the content box centre at
   * (padTop - padBottom) / 2, so a top pad of 2x the offset shifts the
   * text by exactly the offset and nothing else moves.
   *
   * Only single-line, centred, text-bearing things. Multi-line boxes
   * (textarea, prose) centre on nothing and must not be touched, and
   * icon-only buttons are geometrically centred already.
   * ================================================================= */
  :is(
    button, .p-btn, [type="button"], [type="submit"], [type="reset"],
    input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], [type="image"]),
    select, summary, [role="tab"],
    .p-badge, .p-chip, .p-avatar, .p-tooltip, .p-banner-icon, kbd,
    .p-segmented > button, .p-nav > a, .p-menu > button, .p-menu > a
  ):not(textarea),
  .p-field > .p-error::before {
    /* Split the block padding asymmetrically around a pedestal. The two
     * sides still sum to 2x the pedestal, so nothing changes height — the
     * content just sits `--p-optical-text` lower.
     *
     * The pedestal is at least |offset| so neither side can go negative
     * and get clamped, which would silently eat the correction. Rules that
     * want real vertical padding set --p-pad-block rather than writing the
     * `padding` shorthand, which would blow this away. */
    --p-pad-pedestal: max(
      var(--p-pad-block, 0px),
      var(--p-optical-text),
      calc(-1 * var(--p-optical-text))
    );
    padding-block:
      calc(var(--p-pad-pedestal) + var(--p-optical-text))
      calc(var(--p-pad-pedestal) - var(--p-optical-text));
    /* Announce the shift to anything inside that has no baseline to
     * correct for — an icon mask, a status dot. They are geometric
     * boxes, so a correction sized for letterforms just drops them.
     * Publishing it here rather than restating this selector list
     * elsewhere means a control added later is handled by having been
     * added, not by someone remembering. */
    --p-optical-applied: var(--p-optical-text);
  }

  /* An icon has no baseline to correct for. */
  :is(button, .p-btn)[data-shape="icon"],
  button[data-variant="link"] {
    padding-block: 0;
    --p-optical-applied: 0px;
  }

  /* Neither does a picture, nor a bare disclosure marker. A gallery's
   * tile is a <button> holding a photograph and its menu opens from a
   * <summary> holding one glyph; both are in the list above and both
   * were getting the correction. On the picture it showed: the image sat
   * about 3px down and the tile's dark backing came through as a black
   * hairline along the top edge, which the hover scale then covered — so
   * it read as a hover bug rather than a resting one.
   *
   * Two classes each, because the rule above resolves to (0,1,2) and a
   * single class loses to it. And BOTH declarations every time: zeroing
   * the space while still announcing the shift is what put every icon in
   * Digest's rail 0.1em too high. Publish it only where you applied it. */
  .p-gallery-tile > .p-gallery-open,
  .p-gallery-tile > .p-gallery-act > summary {
    padding-block: 0;
    --p-optical-applied: 0px;
  }

  /* A reaction is a tiny picture and a number, not a word-shaped label.
   * The text optical offset lowers both geometric marks inside their pill;
   * restore equal padding so their combined box is truly centred. */
  .p-react.p-react {
    padding-block: var(--p-pad-block, 0px);
    --p-optical-applied: 0px;
  }

  /* Nor does a picture wrapped in its own opener — the same black
   * hairline along the top edge, only now on a staged upload or an
   * attachment rather than on a tile.
   *
   * The class is stated twice for SPECIFICITY and for no other reason:
   * the rule above resolves to (0,1,2) and there is no ancestor to
   * qualify this one with, because a content picture turns up anywhere. */
  .p-img-open.p-img-open {
    padding-block: 0;
    --p-optical-applied: 0px;
  }

  /* Neither does a tree row. It is a container of arbitrary children —
   * an avatar, a stacked body, an action overlay — not a single line of
   * capitals, and a two-line body centres on nothing.
   *
   * The bug this fixes is worse than a couple of pixels. A row written
   * as <a> was never in the optical selector list and a row written as
   * <button> always was, so the same component sat at two different
   * heights depending on which element the project reached for — 32px
   * even in the spec, 30px and top-heavy in Digest. Whichever tag you
   * use, a tree row is now the same row.
   *
   * Scoped through .p-tree for SPECIFICITY, not for scoping. An :is()
   * takes the specificity of its most specific argument, and the list
   * above contains `.p-nav > a`, so the whole thing weighs (0,1,1) — a
   * bare .p-tree-row is (0,1,0) and silently loses. */
  .p-tree .p-tree-row,
  .p-tree .p-tree-folder > summary {
    padding-block: var(--p-pad-block, 0px);
    --p-optical-applied: 0px;
  }

  /* Reduced-motion equivalent: transitions collapse to near-zero via the
   * duration tokens, so state changes stay perceptible without movement.
   *
   * This block is total — it stops every animation in the system,
   * including the focus breath, which then can only appear and disappear.
   * That is correct behaviour and it is also an excellent disguise: a
   * developer with the OS preference set sees a permanently broken
   * animation and no error, and every fix they try changes nothing.
   *
   * So there is an explicit way back in. data-pulse-motion="always" on
   * <html> overrides the preference. It is opt-in per project and per
   * person, it is never the default, and it exists for the case where
   * someone has deliberately chosen to see this. */
  @media (prefers-reduced-motion: reduce) {
    /* No ping, no breathe, no flight. The glow holds still at a strength
     * that still reads as a glow, so the locator survives without motion. */
    html:not([data-pulse-motion="always"]) :focus-visible {
      animation: none;
      --p-ring-glow-alpha-min: 0.35;
    }

    html:not([data-pulse-motion="always"]),
    html:not([data-pulse-motion="always"]) *,
    html:not([data-pulse-motion="always"]) *::before,
    html:not([data-pulse-motion="always"]) *::after {
      animation-duration: 1ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 1ms !important;
      scroll-behavior: auto !important;
    }
  }

  /* ---- still alive ------------------------------------------------
   * The rule above stops every animation in the system. That is right
   * for decoration and wrong for a progress indicator: a stopped
   * spinner is not a calmer spinner, it is a broken one, and it says
   * the work died. The preference asked for less motion, not for less
   * information.
   *
   * So these four keep running and only their alpha moves. Nothing
   * travels across the retina, which is what the preference is actually
   * about, and one slow cycle reads as breathing rather than blinking —
   * the same mechanism and the same cadence as the focus ring, arrived
   * at the same way and for the same reasons.
   *
   * !important here is overriding an !important nine lines up, which is
   * the one situation where it is the right answer rather than a way of
   * avoiding an argument with the cascade.
   * ------------------------------------------------------------------ */
  @media (prefers-reduced-motion: reduce) {
    html:not([data-pulse-motion="always"]) :is(.p-spinner, .p-skeleton, progress:indeterminate),
    html:not([data-pulse-motion="always"]) .p-presence[data-state="unknown"],
    html:not([data-pulse-motion="always"]) :is(button, .p-btn)[aria-busy="true"]::before {
      animation: p-alive var(--p-dur-breathe) var(--p-ease-breathe) infinite !important;
    }

    /* Both of these were gradients whose whole job was to travel. With
     * the travel gone they are just an uneven fill, so flatten them. */
    html:not([data-pulse-motion="always"]) .p-skeleton {
      background: var(--p-surface-inset);
    }

    html:not([data-pulse-motion="always"]) progress:indeterminate {
      background: var(--p-accent);
    }
  }

  @keyframes p-alive {
    50% { opacity: 0.4; }
  }

  /* spec: conversation */
  /* spec: streaming-text */
  /* =================================================================
   * Conversation — a log of turns, and the box you type into.
   *
   * Every one of these rules is here because a hand-written version got
   * it wrong first. They are not a guess at what a chat window needs.
   *
   * A turn is an avatar and a body, and the body is a meta line above a
   * bubble:
   *
   *     <article class="p-turn">
   *       <span class="p-avatar"><img src="…" alt=""></span>
   *       <div class="p-turn-body">
   *         <p class="p-turn-meta">
   *           <span class="p-turn-who">Guide</span>
   *           <span class="p-when">
   *             <span class="p-ago">2 hours ago</span>
   *             <time datetime="2026-08-02T09:14">09:14 2 Aug</time>
   *           </span>
   *         </p>
   *         <div class="p-bubble">…</div>
   *       </div>
   *     </article>
   *
   * THE ROW FLIPS, NOT THE BUBBLE. `data-side="end"` reverses the row,
   * so a bubble never needs to know whose turn it is and there is no
   * second set of side-specific rules to keep matched. Avatars, meta
   * lines and trailing actions all come along for free.
   *
   * The meta line is INSIDE the turn, not a row above it. It was a
   * sibling of the turn once, and a sibling has no idea how wide the
   * bubble under it is or which side the avatar took — so it sat at the
   * edge of the thread while the name it belonged to sat an avatar's
   * width inward. Nesting it is what makes the name line up with the
   * words it names, from either side, at every width, for free.
   * ================================================================= */
  .p-thread {
    display: flex;
    flex-direction: column;
    gap: var(--p-space-3);
    overflow: auto;
    padding: var(--p-inset-sm);
  }

  .p-turn {
    display: flex;
    /* Top, not bottom. The avatar belongs beside the name — that is the
     * line that says whose picture it is. Bottom-aligning it against a
     * six-line message parks the face level with the last word of a
     * paragraph nobody has read yet. */
    align-items: flex-start;
    gap: var(--p-space-2);
    /* The measure belongs to the CONVERSATION, not to the window. A
     * thread that runs the full width of a wide screen is a line of
     * text nobody can track back to the start of; and once the turns
     * are held to a measure, the composer has to hold the same one or
     * it reads as part of the furniture rather than part of the talk. */
    inline-size: 100%;
    max-inline-size: var(--p-thread-measure, 46rem);
    margin-inline: auto;
  }

  .p-turn[data-side="end"] {
    flex-direction: row-reverse;
  }

  /* ---- the picture is the button --------------------------------------
   * What can be done to one message hangs off that message's avatar.
   *
   *     <details class="p-turn-act">
   *       <summary class="p-avatar">RM</summary>
   *       <div class="p-menu">…</div>
   *     </details>
   *
   * The avatar is already the one part of a turn that is unmistakably
   * "this message, this speaker", it is already a comfortable tap target,
   * and it is already sitting in the margin where nothing else wants to
   * be. A separate ⋯ button would be a second mark competing with it for
   * the same job in the same corner.
   *
   * The plain `<span class="p-avatar">` still works and stays inert —
   * "the actions for this message, if any" means a turn with nothing to
   * offer must not look pressable.
   *
   * A <details>, not a popover, for the reason the gallery states: the
   * menu has to sit under the mark that opened it, and placing a popover
   * still wants anchor positioning. Outside-click dismissal is not a cost
   * here — pulse-overlay.js closes every menu on a press outside it, the
   * same rule for every menu in the system. */
  /* A <details> is a panel by default in this system — filled, hairlined
   * and rounded — and this one is not a panel. It is an avatar with a
   * menu attached, so it wears none of that chrome. Left in, it drew a
   * grey rounded square around the picture. Reset the element, not the
   * summary: the summary was never the thing painting it. */
  .p-turn-act {
    position: relative;
    flex: none;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
  }

  /* No caret, and no room made for one. THE PICTURE IS THE MARK — a
   * disclosure triangle inside somebody's face says "this is a twisty"
   * about the one element on the row that already says what it is, and a
   * summary's label padding and gap then shove the initials off centre
   * to make space for it. */
  .p-turn-act > summary {
    padding: 0;
    gap: 0;
    cursor: pointer;
    list-style: none;
  }

  .p-turn-act > summary::before { content: none; }
  .p-turn-act > summary::-webkit-details-marker { display: none; }

  /* A ring rather than a tint: the plate underneath may be a photograph,
   * and there is no tint that reads on every photograph. */
  .p-turn-act > summary:hover,
  .p-turn-act[open] > summary {
    box-shadow: 0 0 0 2px var(--p-accent);
  }

  /* UPWARD, which is the opposite of every other menu in the system, and
   * it is the thread that makes it right. A conversation is pinned to its
   * newest turn, so the message you reach for is the one at the bottom of
   * a container with `overflow: auto` — a menu opening down from there is
   * clipped by the thread and has to be scrolled into view to be read.
   * Above the avatar there is always the rest of the conversation.
   *
   * The second reason survives even in a thread that is not pinned: down
   * from the avatar is exactly where this turn's own bubble is, and the
   * one thing a menu of actions must not cover is the message they act
   * on. */
  .p-turn-act > .p-menu {
    position: absolute;
    inset-block-end: calc(100% + var(--p-space-1));
    inset-inline-start: 0;
    z-index: var(--p-z-popover);
  }

  /* Anchored to the avatar's own edge, so it opens into the thread rather
   * than off the side it is already sitting against. */
  .p-turn[data-side="end"] .p-turn-act > .p-menu {
    inset-inline: auto 0;
  }

  /* An open menu overlaps the turns before it, which are later in the
   * stacking order until something says otherwise. */
  .p-turn:has(.p-turn-act[open]) {
    position: relative;
    z-index: 1;
  }

  /* Everything that is not the avatar. A column, so the meta line sits
   * on top of the bubble and both start at the same x — the whole point
   * of nesting them.
   *
   * `align-items` and not `text-align`: the children have to SHRINK to
   * their content and then be pushed to one end. A stretched bubble with
   * right-aligned text is a full-width box that merely looks ragged. */
  .p-turn-body {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--p-space-1);
    /* A flex item defaults to min-content, and min-content for a
     * paragraph is its longest word — without this a turn refuses to
     * wrap and shoves the avatar off the edge instead. */
    min-inline-size: 0;
    /* The cap lives HERE, not on the bubble, and this is the one thing
     * about this pattern that is easy to get backwards. A percentage max
     * resolves against the parent, so a bubble capped at 80% of a body
     * that is itself sized to that bubble's text comes out 80% of its
     * own content and wraps a short message for no reason. Cap the
     * column; let the bubble fill it. */
    max-inline-size: 80%;
  }

  .p-turn[data-side="end"] > .p-turn-body {
    align-items: flex-end;
  }

  .p-bubble {
    padding: var(--p-space-3) var(--p-space-4);
    border-radius: var(--p-radius-lg);
    background: var(--p-surface-raised);
    border: 1px solid var(--p-line);
    color: var(--p-text);
    /* Not the full row: a bubble that reaches both edges is a paragraph,
     * and the shape is doing the work of saying who spoke. Inside a
     * body the column already carries this, hence the reset below. */
    max-inline-size: 80%;
    /* A pasted URL is one word as far as wrapping is concerned, and one
     * of them can widen the whole column past the measure above. */
    overflow-wrap: anywhere;
  }

  .p-turn-body > .p-bubble {
    max-inline-size: 100%;
  }

  /* The reader's own turn. Keyed off the ROW so the two never disagree
   * about which side is which. */
  .p-turn[data-side="end"] .p-bubble {
    background: var(--p-accent);
    border-color: transparent;
    color: var(--p-accent-fg);
  }

  /* Who and when — a label ON the message rather than part of it, so it
   * is small and quiet and never competes with what was said. */
  .p-turn-meta {
    display: flex;
    align-items: baseline;
    /* Wider than the gap inside `.p-when`, and deliberately. The two
     * halves of a timestamp are one fact and belong tight together; the
     * name is a different fact and needs the daylight to read as one. */
    gap: var(--p-space-5);
    min-inline-size: 0;
    max-inline-size: 100%;
    margin: 0;
    padding-inline: var(--p-space-1);
    font-size: var(--p-text-xs);
    /* The bubble below is the thing with the shape; a meta line that
     * wraps to two ragged lines above it reads as part of the message.
     * It ellipses instead — the stamp is the half that gives, because
     * `.p-ago` right beside it has already answered "when". */
    overflow: hidden;
  }

  /* Reversed with the row, so the name always sits against the same edge
   * as the avatar it belongs to. */
  .p-turn[data-side="end"] > .p-turn-body > .p-turn-meta {
    flex-direction: row-reverse;
  }

  /* The one place in this line that gets a second colour, and it earns
   * it: the name and the time are different FACTS, not one fact at two
   * precisions. (That is the rule `.p-when` states for its own two
   * halves — it holds there and does not hold here.) */
  .p-turn-who {
    flex: 0 1 auto;
    min-inline-size: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--p-text);
    font-weight: var(--p-weight-semibold);
  }

  /* Weighted so the stamp gives ground first. A truncated name is a
   * different person; a truncated timestamp is still the right one, and
   * `.p-ago` beside it is holding the answer anyway. Both can still
   * ellipse, because a long enough name has to give eventually. */
  .p-turn-meta > .p-when {
    flex: 0 3 auto;
  }

  /* Still typing. Three dots rather than a spinner: a spinner says the
   * app is working, and this says somebody is. */
  .p-bubble[data-pending]::after {
    /* Empty alt text after the slash, the same trick `.p-when` uses on
     * its separator: generated content lands in the accessible name, so
     * without it a pending bubble is announced as "dot dot dot" — three
     * characters of punctuation read out as if somebody had typed them.
     * The waiting is said by aria-busy on the region, not by this. */
    content: "···" / "";
    letter-spacing: 0.15em;
    animation: p-alive var(--p-dur-breathe) var(--p-ease) infinite;
  }

  .p-bubble[data-pending]:empty::after {
    display: inline-block;
  }

  /* ---- streaming ------------------------------------------------------
   * Text arriving a token at a time, with a caret at the head of it.
   *
   *     <div class="p-bubble"><span class="p-stream"></span></div>
   *
   *     import { stream } from "/pulse/pulse-stream.js";
   *     const s = stream(el);  s.push("Once ");  s.push("upon ");  s.done();
   *
   * `.p-stream` means STREAMING RIGHT NOW, not "this was streamed" — the
   * script takes the class off at the end and the caret goes with it,
   * because a caret still sitting there is a promise of more text that
   * is not coming.
   *
   * THE CARET TRAVELS FOR FREE. It is an ::after on the region, so it is
   * the last inline box in it; put a token in front and layout moves the
   * caret. Nothing measures anything, and it stays correct through a
   * line wrap, a resize, a font change and a zoom, which is more than a
   * positioned caret manages.
   *
   * SOLID WHILE MOVING, BLINKING WHILE STALLED. The blink is the whole
   * signal, and it is worth being exact about what it says: not "the
   * model is thinking" — a stalled stream and a slow stream look
   * identical from here — but "the connection is still open and nothing
   * has arrived for a moment". A caret that blinked the whole way
   * through would say that continuously and mean nothing. */
  .p-stream::after {
    content: "";
    display: inline-block;
    /* In em, so it stays a caret rather than a bar at 200% zoom. The
     * floor is for small text, where 0.09em rounds to nothing. */
    inline-size: max(1.5px, 0.09em);
    block-size: 1.05em;
    margin-inline-start: 0.08em;
    vertical-align: text-bottom;
    border-radius: 1px;
    /* Follows the text inside a filled bubble, where the accent is the
     * background and an accent caret would be invisible. */
    background: var(--p-stream-caret, var(--p-accent));
  }

  .p-turn[data-side="end"] .p-stream {
    --p-stream-caret: currentColor;
  }

  .p-stream[data-stalled]::after {
    animation: p-caret var(--p-dur-caret) steps(1) infinite;
  }

  @keyframes p-caret {
    50% { opacity: 0; }
  }

  /* A token emerges FROM the caret: it starts a fraction to the right,
   * roughly under where the caret already sits, and slides back into the
   * slot it will keep. Offset the other way and it would crawl out of
   * the settled text instead, which is the same motion telling the
   * opposite story about where the words come from.
   *
   * `pre-wrap`, not `pre`: a chunk arrives with its own leading space
   * and that space has to survive, but a chunk of several words still
   * has to be allowed to break across lines. */
  .p-tok {
    display: inline-block;
    white-space: pre-wrap;
    animation: p-tok-in var(--p-dur-3) var(--p-ease) backwards;
  }

  @keyframes p-tok-in {
    from { opacity: 0; translate: 0.25em 0; }
  }

  /* The text is the message. Take the motion away and every word still
   * arrives, in order, with a caret showing where the next one lands —
   * so this degrades to a plain caret rather than to nothing. The script
   * reads the same query and stops boxing tokens at all. */
  @media (prefers-reduced-motion: reduce) {
    .p-stream[data-stalled]::after { animation: none; }
    .p-tok { animation: none; }
  }

  /* spec: a-break-across-the-stream */
  /* =================================================================
   * A labelled break across a stream
   *
   * Mercy filed this twice on the same day — once as a day separator and
   * once as an unread divider — and said in both reports that they are
   * one thing: identical geometry, different meaning, and shipping them
   * apart guarantees they drift. They were right, so this is one pattern
   * with a tone.
   *
   * NOT `.p-divider`. That is an `<hr>` and an `<hr>` cannot hold a
   * label, which is the whole job here — a rule that says nothing cannot
   * tell you the date or that you stopped reading. The line is two
   * pseudo-elements flexing out from the words rather than a border, so
   * a label of any length centres itself with no measuring.
   * ================================================================= */
  .p-sep {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    /* More above than below: the label belongs to what follows it. */
    margin-block: var(--p-gap-group) var(--p-space-2);
    font-size: var(--p-text-2xs);
    font-weight: var(--p-weight-semibold);
    letter-spacing: var(--p-tracking-label);
    text-transform: uppercase;
    color: var(--p-text-muted);
  }

  .p-sep::before,
  .p-sep::after {
    content: "";
    flex: 1;
    block-size: 1px;
    background: var(--p-line);
  }

  /* The reader stopped here. Louder than a date because it is the one
   * thing they are looking for when they open a conversation, and it is
   * gone next time — a mark that only appears when it matters can afford
   * to be seen. */
  .p-sep[data-tone="accent"] {
    color: var(--p-text-accent);
  }

  .p-sep[data-tone="accent"]::before,
  .p-sep[data-tone="accent"]::after {
    background: var(--p-border-accent);
  }

  /* spec: the-message-this-one-answers */
  /* =================================================================
   * The message this one answers
   *
   * A compact preview INSIDE the new message's bubble. It is an anchor,
   * not a quotation or a project-wired button: pressing it has native
   * keyboard semantics and lands on the original message by fragment ID.
   *
   * The same element serves both placements: above a bubble, and above
   * the composer while the reply is being written. Two placements, one
   * pattern, because they are the same fact shown at two moments.
   * ================================================================= */
  .p-reply {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    inline-size: 100%;
    min-inline-size: 0;
    margin-block-end: var(--p-space-2);
    padding: var(--p-space-2) var(--p-space-3);
    /* The bar is the whole visual idea: a stub of the thing being
     * answered, hanging off its edge. */
    border-inline-start: 2px solid var(--p-border-accent);
    border-radius: 0 var(--p-radius-sm) var(--p-radius-sm) 0;
    background: var(--p-surface-sunken);
    font-size: var(--p-text-xs);
    color: var(--p-text-muted);
    text-align: start;
    text-decoration: none;
    cursor: pointer;
  }

  .p-reply:hover {
    background: var(--p-wash-hover);
    color: var(--p-text);
  }

  .p-reply-copy {
    display: flex;
    flex: 1;
    flex-direction: column;
    gap: var(--p-space-1);
    min-inline-size: 0;
  }

  .p-reply-head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0 var(--p-space-2);
    min-inline-size: 0;
  }

  .p-reply-head > .p-when {
    flex: none;
  }

  .p-reply-head > .p-when > time { overflow: visible; }

  .p-reply-who {
    flex: none;
    color: var(--p-text-accent);
    font-weight: var(--p-weight-semibold);
  }

  /* Two lines at most: enough to recognise the original without turning
   * the reference into a second full message. */
  .p-reply-text {
    min-inline-size: 0;
    display: -webkit-box;
    overflow: hidden;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    white-space: normal;
  }

  /* A multi-image original still gets one recognisable thumbnail. The
   * contract is defensive: projects may pass the original file list and
   * Pulse paints only its first member. */
  .p-reply-media {
    display: block;
    flex: none;
    inline-size: 3rem;
    block-size: 3rem;
    overflow: hidden;
    border-radius: var(--p-radius-sm);
    background: var(--p-surface-raised);
  }

  .p-reply-media > img:first-child {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
  }

  .p-reply-media > img:not(:first-child) {
    display: none;
  }

  /* THE BAR HAS TO BE MOVED, NOT INHERITED.
   *
   * The reader's own turn is flipped with `flex-direction: row-reverse`
   * and `align-items: flex-end`, which reverses the LAYOUT and leaves the
   * writing direction alone — so `border-inline-start` stays on the left
   * and the stub ends up on the far side from the avatar it belongs to.
   * Logical properties flip with `direction`, and nothing here changes
   * `direction`. Written down because it looked like it worked, and the
   * first screenshot is what said otherwise. */
  .p-turn[data-side="end"] .p-reply {
    border-inline-start: 0;
    border-inline-end: 2px solid var(--p-border-accent);
    border-radius: var(--p-radius-sm) 0 0 var(--p-radius-sm);
    /* The bar moves; the CONTENTS do not. Reversing these too put the
     * name after the words it attributes — "…rest of them. Ada" — which
     * is a different sentence. Whose message this was is read first from
     * either side. */
  }

  /* The original is gone and the fact that this was a reply is not.
   * Still shown, no longer a control — there is nowhere to go. */
  .p-reply[data-missing] {
    border-inline-start-color: var(--p-line);
    color: var(--p-text-subtle);
    font-style: italic;
    cursor: default;
  }

  .p-reply[data-missing]:hover {
    background: none;
    color: var(--p-text-subtle);
  }

  /* In the composer it is a strip above the box with a way out of it. */
  .p-composer-reply {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    padding: var(--p-space-2) var(--p-space-2) 0;
    margin-block-end: var(--p-space-2);
  }

  .p-composer-reply > .p-reply {
    flex: 1;
    margin-block-end: 0;
    cursor: default;
  }

  /* spec: inline-marks */
  /* =================================================================
   * An inline mark on a piece of content
   *
   * "(edited)" after the last word, a pin, "forwarded", "failed to send",
   * "only visible to you". Mercy asked for two and then said the honest
   * thing: solve it as *an inline mark on a piece of content* rather
   * than as those two, because the family is open.
   *
   * The mark is a compact semantic chip. Colour distinguishes caution,
   * utility and danger at a glance; the icon AND word keep colour from
   * carrying the meaning alone.
   * ================================================================= */
  .p-mark {
    display: inline-flex;
    align-items: center;
    gap: var(--p-space-1);
    margin-inline-start: var(--p-space-2);
    padding: var(--p-space-1) var(--p-space-3);
    border: 1px solid var(--p-tone-border);
    border-radius: var(--p-radius-full);
    background: var(--p-tone-surface-subtle);
    font-size: var(--p-text-2xs);
    font-weight: var(--p-weight-regular);
    font-style: normal;
    letter-spacing: normal;
    text-transform: none;
    color: var(--p-tone-text);
    white-space: nowrap;
    vertical-align: middle;
  }

  /* AN ICON MARK NEEDS THE PSEUDO-ELEMENT TO HAVE A BOX.
   *
   * The base icon rule sizes `::before` with `inline-size`/`block-size`,
   * and an INLINE box ignores both — which is why every other icon host
   * in Pulse is a flex or grid container. A mark cannot be: it has to
   * stay inline to sit on the text baseline, which is the one thing this
   * pattern exists to do. Giving the pseudo-element its own
   * `inline-block` is how it gets both, and without it a `data-icon`
   * mark renders as nothing at all — silently, in the middle of a
   * sentence that still reads fine. It shipped that way for an hour and
   * a screenshot is what caught it. */
  .p-mark[data-icon]::before {
    flex: none;
    font-size: 0.9em;
  }

  /* spec: spoiler */
  /* =================================================================
   * Spoiler
   *
   * Hidden until the reader asks. The block is the text's own colour, so
   * it covers exactly the words it hides and nothing around them.
   *
   * pulse-controls.js makes it operable — a plain `<span class="p-spoiler">`
   * gets the role, the tab stop and the label, because a project that has
   * to remember `role="button" tabindex="0" aria-label="…"` will
   * eventually not, and the failure mode is content silently withheld
   * from somebody with no way to ask for it.
   * ================================================================= */
  .p-spoiler {
    border-radius: var(--p-radius-xs);
    background: var(--p-text-subtle);
    color: transparent;
    /* The words are still there and still selectable-looking; hide the
     * caret so it does not read as editable text. */
    cursor: pointer;
    user-select: none;
    transition: background var(--p-dur-2) var(--p-ease),
                color var(--p-dur-2) var(--p-ease);
  }

  .p-spoiler[data-shown] {
    background: var(--p-tint-subtle);
    color: inherit;
    cursor: pointer;
    user-select: auto;
  }

  /* spec: an-inline-player */
  /* =================================================================
   * An inline player
   *
   * A voice note in a thread, sized to sit inside a bubble rather than
   * to dominate it. The native `<audio controls>` does the work — it is
   * keyboard-operable, it never autoplays, it knows the duration, and
   * every engine's scrubber is better than one built here. This pattern
   * is the box around it and the leading glyph that says what kind of
   * recording it is.
   * ================================================================= */
  .p-player {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    inline-size: fit-content;
    max-inline-size: 100%;
    padding: var(--p-space-2) var(--p-space-3);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-lg);
    background: var(--p-surface-sunken);
  }

  .p-player > audio {
    /* `inline-size: 100%` on a fit-content parent is circular, so the
     * width is stated once here and the box takes it. */
    inline-size: min(18rem, 60vw);
    block-size: 2rem;
  }

  .p-player > [data-icon] {
    flex: none;
    color: var(--p-text-muted);
  }

  /* In a bubble it is the message, so it drops the second surface — a
   * sunken box inside a raised one is two frames around one thing. */
  .p-bubble > .p-player {
    border: 0;
    padding: 0;
    background: none;
  }

  /* spec: profile */
  /* spec: profile-modal */
  /* =================================================================
   * A profile
   *
   * A banner across the top, the avatar overlapping it, then who they
   * are. `.p-media` was the near miss: it describes *a thing with
   * artwork* — one picture, laid out beside the body — and a profile has
   * two pictures with different jobs, one behind and one in front.
   * ================================================================= */
  .p-profile-banner {
    block-size: 6rem;
    background: var(--p-tint-medium);
    background-size: cover;
    background-position: center;
  }

  /* The avatar rides the seam without pulling the adjacent name into the
   * panel body's clipped padding. The row keeps the avatar's full layout
   * height; only its plate moves, so the facts below still clear it. */
  .p-profile-id {
    display: flex;
    align-items: flex-start;
    gap: var(--p-space-3);
    margin-block-start: calc(-1 * var(--p-space-3));
  }

  /* A panel body normally scrolls, but a profile deliberately paints its
   * avatar above that body's top edge. Leaving the generic overflow here
   * clips the part that is supposed to overlap the banner. */
  .p-panel-body:has(> .p-profile-id) {
    overflow: visible;
  }

  .p-profile-id + * {
    margin-block-start: calc(-1 * var(--p-space-8));
  }

  .p-profile-id > .p-avatar {
    /* Big enough to read as the subject rather than as a list marker,
     * and ringed in the surface it overlaps so the seam reads as depth. */
    inline-size: 4rem;
    block-size: 4rem;
    font-size: var(--p-text-lg);
    box-shadow: 0 0 0 3px var(--p-surface-raised);
    position: relative;
    z-index: 1;
    transform: translateY(calc(-1 * var(--p-space-12)));
  }

  .p-profile-name {
    margin: 0;
    font-size: var(--p-text-lg);
    font-weight: var(--p-weight-semibold);
    line-height: var(--p-leading-tight);
  }

  /* A PROFILE IN A DIALOG HAS FOUR ROWS, NOT THREE.
   *
   * The dialog grid is `auto / 1fr / auto` — head, body, actions — so a
   * banner between the head and the body lands in the growing row and
   * the body drops into an implicit one. Nothing looks wrong until the
   * dialog reaches its height cap: then the banner stretches to fill and
   * the body, which is the part with the reading in it, is the one that
   * gets squeezed.
   *
   * It measures as correct right up until it is not, which is the kind
   * of bug that ships. Name the fourth row. */
  :is(dialog[open], dialog > form):has(> .p-profile-banner) {
    grid-template-rows: auto auto minmax(0, 1fr) auto;
  }

  /* spec: sign-in */
  /* =================================================================
   * SIGNING IN
   *
   * One column, and it does not care whether it is a whole page or the
   * inside of a dialog. That matters more than it sounds: an application
   * redirects to a shared branded sign-in surface, and the same
   * application also has to ask again when a session lapses. Two
   * separately built things drift, and the one that drifts is the one
   * that handles somebody's password.
   *
   * WHAT THIS IS NOT. It is not a registration form. Enrolment here is
   * by invitation, so there is no "create an account" route to put at
   * the bottom, and adding one is not a small kindness — it is a door.
   * `.p-signin-note` is for what a stranger who landed here should be
   * told instead.
   *
   * The width is deliberately narrower than a reading measure. A sign-in
   * column is two fields and a button; run it to 40rem and the fields
   * become long empty troughs that read as a form with something
   * missing.
   * ================================================================= */
  /* spec: sign-in-airlock */
  /* The Airlock hand-off is this same column holding one link instead of
   * a form; the column's stretch is what makes it full width. */
  .p-signin {
    display: flex;
    flex-direction: column;
    gap: var(--p-space-5);
    inline-size: 100%;
    max-inline-size: var(--p-signin-w, 22rem);
    margin-inline: auto;
  }

  /* As a page rather than a dialog: centred in the viewport, and pulled
   * slightly above true centre because optical centre is higher than
   * geometric centre and a form sitting at exactly 50% reads as low. */
  .p-signin-page {
    display: grid;
    place-items: center;
    min-block-size: 100svh;
    padding: var(--p-inset);
    padding-block-end: calc(var(--p-inset) + var(--p-space-10));
    background: var(--p-surface-inset);
  }

  .p-signin-page > .p-signin {
    max-inline-size: calc(var(--p-signin-w, 22rem) + 2 * var(--p-inset) + 2px);
    padding: var(--p-inset);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-lg);
    background: var(--p-surface-raised);
    box-shadow: var(--p-shadow-4);
  }

  /* The mark, the application's name, and one line saying which account
   * is being asked for. Centred: there is nothing else on the screen for
   * it to align with, and a left edge with no column under it is just an
   * indent. */
  .p-signin-mark {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--p-space-2);
    text-align: center;
  }

  .p-signin-mark > :is(h1, h2, h3) + .p-muted {
    margin-block-start: var(--p-space-1);
  }

  /* The mark is bigger than an icon in a control, so it moves the size
   * token rather than the box: `[data-icon]::before` sizes itself from
   * `--p-icon-size`, and overriding the box directly leaves the mask's
   * own dimensions behind. */
  .p-signin-mark > :is(.p-icon, i[data-icon]) {
    --p-icon-size: 2.5rem;
    color: var(--p-text-accent);
  }

  .p-signin-mark > :is(svg, img) {
    inline-size: 2.5rem;
    block-size: 2.5rem;
  }

  /* Past the first step the mark is the person, not the application:
   * they have said who they are and the screen should show that it
   * heard, which is also what stops a password going to the wrong
   * account on a shared machine. */
  .p-signin-mark > .p-avatar {
    inline-size: 3rem;
    block-size: 3rem;
    font-size: var(--p-text-md);
  }

  .p-signin-mark h1 {
    margin: 0;
    font-size: var(--p-text-lg);
    line-height: var(--p-leading-tight);
  }

  /* A step of the sequence. Only one is ever on screen: identifier, then
   * the method that account actually has, then a second factor if it is
   * enrolled. Hidden with the attribute rather than a class so a project
   * with no script at all can render the step the server decided. */
  .p-signin-step {
    display: flex;
    flex-direction: column;
    gap: var(--p-space-4);
  }

  .p-signin-step[hidden] {
    display: none;
  }

  /* The other way in, under a labelled rule. Passkeys are the preferred
   * method and a password is the fallback, but the passkey cannot lead:
   * it is unavailable on a borrowed machine, which is exactly when
   * somebody is most likely to be reading this screen carefully. */
  .p-signin-alt {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    color: var(--p-text-subtle);
    font-size: var(--p-text-xs);
    text-transform: lowercase;
  }

  .p-signin-alt::before,
  .p-signin-alt::after {
    content: "";
    flex: 1;
    block-size: 1px;
    background: var(--p-line);
  }

  /* The six digits of a TOTP code. `inline-size` rather than `size`,
   * tabular figures, and centred — a code is read back off a phone one
   * character at a time and the eye needs the field to be obviously the
   * right length for what it is being asked for. */
  .p-signin-code {
    align-self: center;
    inline-size: 9rem;
    font-family: var(--p-font-mono);
    font-size: var(--p-text-lg);
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.25em;
    text-align: center;
  }

  .p-signin-note {
    color: var(--p-text-subtle);
    font-size: var(--p-text-xs);
    text-align: center;
    text-wrap: balance;
  }

  /* spec: account-switcher */
  /* =================================================================
   * THE ACCOUNT BUTTON, AND THE ACCOUNTS BEHIND IT
   *
   * The picture leads the header. It is the first thing in the
   * application bar, before the application's own name, because it
   * answers the question that has to be answered before any other one on
   * a screen holding somebody's data: whose is this?
   *
   * Signed out it opens the sign-in dialog. Signed in it opens the
   * accounts dialog. One control, one place, one habit — the reader
   * never has to know which state they are in to know where to click.
   * ================================================================= */
  /* TWO WAYS THIS GOT CROPPED IN A BAR, and they are worth writing down
   * because neither is visible in the markup.
   *
   * A bar's lane clips its overflow, so a ring drawn OUTSIDE the plate —
   * a box-shadow spreads beyond the border box — is cut off flat at the
   * lane's leading edge. The plate has to be small enough that the ring
   * still lands inside the lane, which is what the padding below buys.
   *
   * And the plate is an inline-flex box, so in a button that is not
   * itself a flex container it sits on the text baseline. With
   * `line-height: 0` that baseline is near the middle of the line box
   * and the picture ends up a pixel and a half low — clipped along the
   * bottom, and the crop is small enough to read as a rendering fault
   * rather than as a layout one. Making the button a flex container is
   * the whole fix; the plate is then a flex item and baselines never
   * enter into it. */
  .p-account {
    --p-account-size: calc(var(--p-control-h) - 6px);
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    block-size: auto;
    border: 0;
    border-radius: var(--p-radius-full);
    background: none;
    cursor: pointer;
    line-height: 0;
  }

  /* THE CLASS IS STATED TWICE FOR SPECIFICITY, exactly as the gallery
   * tile and the staged picture above it are: the block-padding rule
   * resolves to (0,1,2) and a single class loses to it.
   *
   * There are no capitals in here — there is a circle — so the optical
   * correction meant for letterforms landed as 2.97px above and 0 below.
   * That did two visible things at once: it dropped the picture a pixel
   * and a half low, and it left the ring no room at the bottom of a bar
   * lane, which clips. The plate came out cut along its bottom edge and
   * along its leading edge, and the crop is small enough to read as a
   * rendering fault rather than as a layout one.
   *
   * Both declarations, every time. Zeroing the space while still
   * announcing the shift is what put every icon in Digest's rail 0.1em
   * too high. */
  .p-account.p-account {
    /* Room for the ring, inside the button's own box, on all four
     * sides — a box-shadow spreads outside the border box, and a bar's
     * lane hides its overflow. */
    padding: 2px;
    --p-optical-applied: 0px;
  }

  /* The ring is the affordance. A bare avatar in a bar reads as a label
   * — it is the same plate that appears inertly beside a name in a list
   * — so the button needs something that says it can be pressed without
   * putting a box around a face. */
  .p-account > .p-avatar {
    inline-size: var(--p-account-size);
    block-size: var(--p-account-size);
    font-size: var(--p-text-xs);
    box-shadow: 0 0 0 2px var(--p-border);
    transition: box-shadow var(--p-dur-1) var(--p-ease);
  }

  .p-account:is(:hover, :focus-visible) > .p-avatar {
    box-shadow: 0 0 0 2px var(--p-border-strong);
  }

  .p-account[aria-expanded="true"] > .p-avatar {
    box-shadow: 0 0 0 2px var(--p-accent);
  }

  /* Signed out there is no face to show, so the plate carries the
   * generic person glyph and loses the initials' background — a filled
   * plate with a stranger's icon in it reads as somebody, and nobody is
   * signed in. */
  .p-account[data-signed-out] > .p-avatar {
    background: var(--p-fill-raised);
    color: var(--p-text-muted);
  }

  .p-accounts {
    display: flex;
    flex-direction: column;
    gap: var(--p-space-1);
  }

  /* One account. The grip leads because it is also the row's menu — see
   * the grip that is also the menu — and a row with a handle at one end
   * and a ⋯ at the other is two controls doing one job. */
  .p-account-row {
    display: flex;
    align-items: center;
    gap: var(--p-space-1);
    padding: var(--p-space-1);
    border: 1px solid transparent;
    border-radius: var(--p-radius-md);
  }

  /* Switching is a button, and it is the whole rest of the row rather
   * than a word at the end — the target is the face and the name,
   * because that is what the reader is aiming at.
   *
   * A button beside the grip rather than a row that is itself a button:
   * a control cannot be nested inside another control, and the grip has
   * to stay a real button because it is also the row's menu. */
  .p-account-pick {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: var(--p-space-3);
    min-inline-size: 0;
    /* Every one of these gives back something the button base sets for a
     * control with one line of centred label in it. A row is two lines,
     * left-aligned, as tall as its contents — so the fixed control
     * height, the centring and the nowrap all have to go, or the name
     * and the address stack inside a 33px box and clip. */
    block-size: auto;
    padding: var(--p-space-2);
    border: 0;
    border-radius: var(--p-radius-sm);
    background: none;
    color: inherit;
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-regular);
    white-space: normal;
    text-align: start;
    cursor: pointer;
  }

  .p-account-pick:hover {
    background: var(--p-fill-raised);
  }

  /* The account you are already using is not somewhere to go. */
  .p-account-row[data-current] .p-account-pick {
    cursor: default;
  }

  .p-account-row[data-current] .p-account-pick:hover {
    background: none;
  }

  /* The one you are using. Marked by fill and a border rather than by a
   * word, so the row does not have to carry a badge that repeats what
   * its position already says — and marked at all, because a list of
   * faces with nothing distinguishing one of them is a list that cannot
   * answer "who am I right now". */
  .p-account-row[data-current] {
    border-color: var(--p-border-accent);
    background: var(--p-tint-subtle);
  }

  .p-account-pick > .p-avatar {
    flex: none;
  }

  .p-account-who {
    display: flex;
    flex-direction: column;
    gap: 0;
    min-inline-size: 0;
    flex: 1;
  }

  .p-account-name {
    font-weight: var(--p-weight-medium);
    line-height: var(--p-leading-tight);
  }

  /* An address, and it is the one thing on the row that is allowed to
   * truncate: two accounts on the same provider differ in the part
   * before the @, which is at the start. */
  .p-account-mail {
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
    line-height: var(--p-leading-tight);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* spec: the-full-view */
  /* spec: version-history */
  /* =================================================================
   * A stage — a zoomable, pannable window onto something
   *
   * Not a scrollport. A scrollport can only reach what is inside it, and
   * a scene scaled past its box is outside it, so the scene is MOVED and
   * the stage stays still.
   *
   * `touch-action: none` is the load-bearing declaration and the easiest
   * to leave out. Without it the browser claims every drag for its own
   * scrolling before a single pointer event is delivered, and the stage
   * simply does not respond on a touchscreen — with no error and nothing
   * to see in a desktop browser. pulse-stage.js does the rest.
   * ================================================================= */
  .p-stage {
    position: relative;
    overflow: hidden;
    touch-action: none;
    /* `grab`, not `move`: this is a surface you take hold of, and the
     * cursor is the only thing that says so before the first drag. */
    cursor: grab;
    background: var(--p-fill-sunken);
    /* A stage inside a panel body has no height of its own — it is a
     * window, and a window with no size shows nothing. */
    min-block-size: 12rem;
  }

  .p-stage[data-panning] {
    cursor: grabbing;
  }

  .p-stage-scene {
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 0;
    /* `max-content` and `transform-origin: 0 0` are what let the scene be
     * laid out once in CSS pixels and then scaled, rather than re-laid
     * out at every zoom step. */
    inline-size: max-content;
    transform-origin: 0 0;
    will-change: transform;
  }

  /* =================================================================
   * A version graph
   *
   * A spine with version fans, and that is a measurement rather than a
   * preference: AIUI counted 299 single-child nodes out of 300 in a real
   * conversation, with zero branch points. Folding runs is therefore the
   * layout and not an optimisation — without it the common case is a
   * 300-item vertical line with the only interesting parts three screens
   * apart.
   *
   * THE PROJECT WRITES A NESTED LIST. `<ol>` and `<li>`, the shape the
   * data already has. Without the script that is a legible nested list
   * of messages, which is the right fallback; with it, pulse-graph.js
   * packs the columns and draws the edges.
   * ================================================================= */
  .p-graph {
    --p-graph-card: 200px;
    --p-graph-gap-x: 24px;
    --p-graph-gap-y: 56px;
    position: relative;
    margin: 0;
    padding: var(--p-space-6);
    list-style: none;
  }

  .p-graph :is(ol, ul) {
    margin: 0;
    padding: 0;
    list-style: none;
  }

  /* Laid out by the packing pass, which writes `translate` on each item.
   * Absolute, because a packed diagram is positions rather than flow —
   * and `translate` rather than `inset`, so a relayout never invalidates
   * anything above it. */
  .p-graph li {
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 0;
    inline-size: var(--p-graph-card);
    /* The pass positions each column by its CENTRE, which is the only
     * origin that makes a parent's children symmetrical without knowing
     * their widths. */
    margin-inline-start: calc(var(--p-graph-card) / -2);
  }

  /* A NESTED COLUMN IS ANCHORED TO ITS PARENT'S CENTRE.
   *
   * Every `li` is absolutely positioned, so every `li` is the containing
   * block for the ones inside it — there is no way to write a page
   * coordinate on a child and have it land there. Rather than fight
   * that, use it: `50%` of a parent that is exactly one card wide IS the
   * parent's centre, so the script writes each child's offset from its
   * parent and the browser resolves the chain. Moving a node then moves
   * its whole subtree for free, which is what makes the final shift one
   * assignment per root instead of a second walk. */
  .p-graph li li {
    inset-inline-start: 50%;
  }

  /* No script, no packing — and then this has to read as an ordinary
   * indented list rather than as a heap of overlapping cards. */
  .p-graph:not([data-pulse-graph]) li {
    position: static;
    inline-size: auto;
    margin-inline-start: var(--p-space-5);
  }

  .p-graph-node {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    inline-size: 100%;
    padding: var(--p-space-2) var(--p-space-3);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-raised);
    font-size: var(--p-text-xs);
    text-align: start;
    cursor: pointer;
  }

  .p-graph-node:hover {
    border-color: var(--p-border);
    background: var(--p-surface-overlay);
  }

  /* The path being read. Border rather than background, so a live node
   * and a hovered node are different facts and can both be true. */
  .p-graph-node[data-live] {
    border-color: var(--p-border-accent);
  }

  /* Where the reader actually is. One of these on the screen, ever. */
  .p-graph-node[aria-current] {
    border-color: var(--p-accent);
    box-shadow: 0 0 0 1px var(--p-accent);
  }

  .p-graph-name {
    flex: 1;
    min-inline-size: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .p-graph-edges {
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 50%;
    overflow: visible;
    pointer-events: none;
    /* THE RESET CLAMPS EVERY svg TO `max-width: 100%`, and this one is
     * deliberately wider than its column: an edge canvas spans from the
     * leftmost child to the rightmost, while the `li` it lives in is one
     * card wide. Clamped, the viewBox was squeezed into 200px — the
     * strokes still painted, because `overflow` is visible, so the only
     * symptom was elbows landing short of the cards they pointed at, at
     * a ratio nobody would think to look for. */
    max-inline-size: none;
  }

  .p-graph-edge {
    fill: none;
    stroke: var(--p-border);
    stroke-width: 1.5;
    stroke-linecap: round;
    stroke-linejoin: round;
  }

  /* Both ends on the head path. The one continuous accent line down the
   * middle is what tells a reader which conversation they are in. */
  .p-graph-edge[data-live] {
    stroke: var(--p-accent);
    stroke-width: 2.5;
  }

  /* A folded run. The count is the point — "and then 46 more" is the
   * only thing a reader needs from a stretch with no decisions in it. */
  .p-graph-node[data-run]::after {
    content: "+" attr(data-run);
    flex: none;
    padding: 0 var(--p-space-2);
    border-radius: var(--p-radius-full);
    background: var(--p-tint-subtle);
    color: var(--p-text-subtle);
    font-variant-numeric: tabular-nums;
  }

  /* Folded: the card goes, the column stays.
   *
   * `display: none` on the `li` took the rest of the run with it — the
   * unfolded message at the end of a folded stretch is a DESCENDANT of
   * the folded ones, so hiding them hid the thing they were standing in
   * for, and the graph ended in an accent stroke pointing at nothing. */
  .p-graph li[data-folded] > .p-graph-node,
  .p-graph li[data-folded] > .p-graph-edges {
    display: none;
  }

  .p-composer {
    display: flex;
    align-items: center;
    gap: var(--p-space-2);
    padding: var(--p-space-2);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-lg);
    background: var(--p-surface-raised);
    inline-size: 100%;
    max-inline-size: var(--p-thread-measure, 46rem);
    margin-inline: auto;
  }

  /* One line at rest, growing as you type. `field-sizing` does this
   * natively and is Chromium-only, so pulse-controls.js does it
   * everywhere else — which is why this is a MIN and not a height. The
   * script writes an explicit height on every keystroke, and a fixed
   * one here would be overwritten on the first and then fight it. */
  .p-composer > :is(textarea, .p-field) {
    flex: 1;
    min-inline-size: 0;
  }

  .p-composer > textarea {
    resize: none;
    field-sizing: content;
    min-block-size: var(--p-control-h);
    max-block-size: 12rem;
    border-color: transparent;
    background: transparent;
    /* One line plus both real borders must equal the adjacent control at
     * every density. This is geometry, not a one-pixel visual nudge. */
    padding-block: max(0px, calc(
      (var(--p-control-h) - 1lh - var(--p-space-px) * 2) / 2
    ));
  }

  /* Square, and the size of a control — never measured from the
   * textarea. Buttons pinned to the textarea's mount height grew with
   * the message, and a send button does not get bigger because the
   * thing being sent did. */
  .p-composer > button {
    flex: none;
    inline-size: var(--p-control-h);
    block-size: var(--p-control-h);
    --p-pad-block: 0px;
    padding-inline: 0;
  }

  /* spec: mentions */
  /* =================================================================
   * Mention
   *
   * A person, channel or thing named INSIDE a line of text — in a
   * message, in a comment, in prose.
   * ================================================================= */

  /* INLINE, not inline-block, and no vertical padding. Both of those are
   * the same lesson: an inline box's padding does not grow the line box
   * it sits in, so vertical padding on a mention silently overlaps the
   * line above it — worst in exactly the case this is for, a paragraph
   * with several mentions in it. And inline-block cannot break across a
   * line end, so a mention at the wrap point shoves the whole name onto
   * the next line and leaves a ragged hole.
   *
   * The sigil is MARKUP. `@Ada` is what the person typed and what the
   * reader copies; generating it from a pseudo-element would mean a
   * copied message says "Ada" and a screen reader announces a name with
   * no indication it was a mention at all. */
  .p-mention {
    /* INLINE-BLOCK, AND THE LINE-HEIGHT IS WHAT MAKES THAT SAFE. A
     * plain inline box ignores vertical padding when the line box is
     * measured, so a padded mention silently overlaps the line above it.
     * An inline-block is measured — which is the point, and also the
     * danger: its box is `line-height + padding`, and anything over the
     * paragraph's own leading pushes that one line taller than its
     * neighbours and makes the whole paragraph ripple.
     *
     * So the pill sets its own leading BELOW the document's (1.32) and
     * spends the difference on padding: 1.05 + 0.13 + 0.13 = 1.31em,
     * which is a visible band above and below the letters and still
     * fits inside the space the line already had. Change one of those
     * three numbers and check a paragraph with a mention in the middle
     * of it against one without. */
    display: inline-block;
    padding: 0.13em 0.4em;
    line-height: 1.05;
    border-radius: var(--p-radius-sm);
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
    font-weight: var(--p-weight-medium);
    text-decoration: none;
    /* A name is one token. Breaking "@Ada Chen" across two lines makes
     * two half-mentions, each of which reads as a different person. */
    white-space: nowrap;
  }

  a.p-mention:hover,
  a.p-mention:focus-visible {
    background: color-mix(in oklab, var(--p-accent) 26%, transparent);
    color: var(--p-text-strong);
    text-decoration: none;
  }

  /* THE READER'S OWN NAME. Being mentioned is the one event in a busy
   * thread that has to survive a scroll past it, so it is the only
   * mention that gets the full accent rather than the tint. */
  .p-mention[data-me] {
    background: var(--p-accent);
    color: var(--p-accent-fg);
  }

  /* Inside the reader's own bubble the accent is already the background,
   * so a tinted mention is invisible and an accent one is invisible
   * twice. Both invert onto the foreground colour instead. */
  .p-turn[data-side="end"] .p-bubble .p-mention {
    background: color-mix(in oklab, var(--p-accent-fg) 24%, transparent);
    color: var(--p-accent-fg);
  }

  .p-turn[data-side="end"] .p-bubble .p-mention[data-me] {
    background: var(--p-accent-fg);
    color: var(--p-accent);
  }

  /* spec: reactions */
  /* =================================================================
   * Reactions
   *
   * What was said about a message, by everyone, in one row — and the
   * picker that adds one. Styling for pulse-reacts.js; the row itself
   * is markup and works without the script, which only adds the picker
   * and the counting.
   * ================================================================= */
  .p-reacts {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--p-space-1);
  }

  /* A PRESSED BUTTON, not a chip with a click handler. "I reacted with
   * this" is a two-state fact about the reader, which is what
   * `aria-pressed` means — and it comes with the announcement and the
   * toggle semantics already written. A chip would need both invented. */
  .p-react {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--p-space-2);
    min-block-size: 1.75rem;
    --p-pad-block: var(--p-space-1);
    padding-inline: var(--p-space-3);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-full);
    background: var(--p-surface-raised);
    color: var(--p-text);
    font-size: var(--p-text-xs);
    font-weight: var(--p-weight-regular);
    line-height: 1;
  }

  .p-react[aria-pressed="true"] {
    border-color: var(--p-accent);
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
  }

  /* The glyph carries no colour of its own — an emoji is already a
   * picture, and tinting it is how a thumbs-up ends up looking broken
   * on one theme and fine on another. */
  .p-react-emoji {
    font-size: var(--p-text-sm);
    line-height: 1;
  }

  .p-react-count {
    line-height: 1;
    font-variant-numeric: tabular-nums;
  }

  /* Same pill, one glyph, no count. Sized from the row rather than from
   * the icon so it lines up with the reactions beside it however many
   * there are. */
  .p-react-add {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    block-size: auto;
    min-block-size: 1.45rem;
    --p-pad-block: 2px;
    padding-inline: var(--p-space-2);
    border: 1px dashed var(--p-border);
    border-radius: var(--p-radius-full);
    background: none;
    color: var(--p-text-muted);
    /* Always rendered — this is never a hover-to-reveal — but quiet at
     * rest, the way the picker's rail is quiet: the affordance is the
     * lift below, not the button's resting state. */
    opacity: 0.7;
    --p-icon-size: 0.85em;
  }

  /* Hover and keyboard focus are the same moment of intent, so the
    * muted pill lifts for both: the colour solidifies and the quiet
    * opacity comes back to full. The ring is base's job and stays
    * untouched here. */
  .p-react-add:is(:hover, :focus-visible) {
    border-style: solid;
    background: var(--p-tint-soft);
    color: var(--p-text-accent);
    opacity: 1;
  }

  /* BESIDE THE MESSAGE, not after the row. As the row's last child the
    * button was furniture — present, but nothing in the line said
    * "react", an act on the message, belongs with the message. Beside
    * the bubble, centred on the message, it says what it says on
    * first paint. It is never gated on hover: the rest is the muted
    * dashed pill at quiet opacity above, and hover and focus only
    * lift it.
    *
    * The side facing the middle of the thread — the side the flipped
    * row leaves open. On a turn that starts at the avatar the avatar
    * holds the inline start, so the button takes the bubble's inline
    * end; on data-side="end" the row is reversed and the avatar holds
    * the inline end, so the button takes the bubble's inline start.
    * That is why there are two horizontal rules instead of one: the
    * bubble's own inline end would be the avatar's face on an end
    * turn, and a button sitting on someone's face is not an
    * affordance. Logical properties throughout, so the two rules are
    * the entire story at every width and in both directions.
    *
    * The frame is the BODY, not the bubble. An absolutely positioned
    * box resolves its offsets against its nearest positioned
    * ANCESTOR, and the button lives inside .p-reacts — a SIBLING of
    * the bubble, not a child — so position: relative on the bubble
    * frames nothing: the button falls through to the initial
    * containing block and anchors to the viewport. .p-turn-body is
    * the nearest element that is an ancestor of both. Its edge is the
    * bubble's edge while the bubble is the widest child — the meta
    * line and the chip row both run under it — and every ordinary
    * message is one, so hanging the button from the body lands it one
    * space-step off the bubble. If some other child outruns the
    * bubble (a one-word message under a long name, or a row of chips
    * that wraps past it), the button takes that child's edge instead:
    * still beside the message, never below it, never on the avatar's
    * face. A flex or grid arrangement cannot do this job at all —
    * the button's DOM parent is the row, and no rule on the column may
    * reach into a nested flex row to seat a grandchild beside another
    * grandchild, so out-of-flow is the only door.
    *
    * `:has` keeps the positioning off a body whose own row carries no
    * add button, and a row rendered outside a turn — the worked demo,
    * a project surface that shows reactions alone — keeps the button
    * in the row, where no bubble exists to sit beside. */
  .p-turn .p-turn-body:has(> .p-bubble ~ .p-reacts > .p-react-add) {
    position: relative;
  }

  .p-turn .p-turn-body > .p-bubble ~ .p-reacts > .p-react-add {
    position: absolute;
    inset-block-start: 50%;
    translate: 0 -50%;
  }

  .p-turn:not([data-side="end"])
    .p-turn-body > .p-bubble ~ .p-reacts > .p-react-add {
    inset-inline-start: 100%;
    margin-inline-start: var(--p-space-2);
  }

  .p-turn[data-side="end"]
    .p-turn-body > .p-bubble ~ .p-reacts > .p-react-add {
    inset-inline-end: 100%;
    margin-inline-end: var(--p-space-2);
  }

  /* =================================================================
   * Emoji picker
   *
   * Styling for pulse-emoji.js. Its own pattern, not part of the
   * reaction row: a composer wants one too, and a picker that lives
   * inside one caller means every other caller grows its own.
   * ================================================================= */
  .p-picker {
    /* Two columns and two rows: the rail runs the full height beside a
     * column holding the search box over the grid. */
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr);
    gap: var(--p-space-2);
    z-index: var(--p-z-popover);
    padding: var(--p-space-2);
    border: 1px solid var(--p-border);
    border-radius: var(--p-radius-md);
    background: var(--p-surface-overlay);
    box-shadow: var(--p-shadow-3);
  }

  .p-picker-rail {
    grid-row: 1 / -1;
    display: flex;
    flex-direction: column;
    gap: 2px;
    padding-inline-end: var(--p-space-2);
    border-inline-end: 1px solid var(--p-line);
    overflow-y: auto;
    scrollbar-width: none;
  }

  .p-picker-rail > button {
    inline-size: 1.5rem;
    block-size: 1.5rem;
    padding: 0;
    border: 0;
    border-radius: var(--p-radius-sm);
    background: none;
    font-size: var(--p-text-sm);
    line-height: 1;
    /* Every glyph in the rail is an emoji, so the row of them is already
     * colourful; a saturated one at rest would make the rail louder than
     * the grid it is a table of contents for. */
    filter: saturate(0.25);
    opacity: 0.7;
  }

  .p-picker-rail > button:is(:hover, [aria-selected="true"]) {
    background: var(--p-tint-soft);
    filter: none;
    opacity: 1;
  }

  .p-picker-find {
    block-size: var(--p-control-h-sm);
    font-size: var(--p-text-sm);
  }

  /* A FIXED EIGHT COLUMNS AND NO SIDEWAYS SCROLL. The grid is sized from
   * its own columns rather than from the panel, so the panel takes its
   * width from the grid and the two can never disagree — which is what
   * put a horizontal scrollbar under it: the cells were laid out to one
   * width and the box measured to another, by exactly the width of the
   * vertical scrollbar. `scrollbar-gutter` is the other half of that:
   * the lane is reserved whether or not a search has filled the grid, so
   * the panel does not change width as you type. */
  .p-picker-grid {
    display: grid;
    grid-template-columns: repeat(8, 2rem);
    gap: 2px;
    inline-size: max-content;
    /* START, not stretch. A grid row is auto-sized, and a grid with room
     * to spare stretches its rows to fill it — so three rows of emoji in
     * a panel whose height is set by the rail beside them came out
     * spaced like a menu. */
    align-content: start;
    max-block-size: 12.5rem;
    overflow-x: hidden;
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-gutter: stable;
    scrollbar-width: thin;
  }

  .p-picker-grid > button {
    inline-size: 2rem;
    block-size: 2rem;
    padding: 0;
    border: 0;
    border-radius: var(--p-radius-sm);
    background: none;
    font-size: var(--p-text-lg);
    line-height: 1;
  }

  /* The accent wash every other list of things-you-are-choosing-between
   * uses. Hover and the arrow keys light the same cell, because a grid
   * with one highlight for the pointer and another for the keyboard is
   * showing two answers at once. */
  .p-picker-grid > button:is(:hover, [data-active]) {
    background: var(--p-tint-soft);
  }

  .p-picker-empty {
    grid-column: 2;
    margin: 0;
    max-inline-size: 17rem;
    padding: var(--p-space-2) var(--p-space-1);
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
  }

  /* spec: media-search-picker */
  /* A media search picker is the no-rail composition of `.p-picker`, not a
   * second picker. It keeps the same box chrome and the existing search and
   * empty-state parts, changing only the rail-dependent layout and cells. */
  .p-picker[data-variant="media-search"] {
    display: flex;
    flex-direction: column;
    gap: var(--p-space-2);
    inline-size: min(38rem, calc(100vw - var(--p-space-8)));
    max-block-size: calc(100dvh - var(--p-space-8));
  }

  .p-picker[data-variant="media-search"] > .p-picker-grid {
    --p-gallery-thumb: 9rem;
    --p-grid-min: var(--p-gallery-thumb);
    grid-template-columns: repeat(auto-fill, minmax(min(var(--p-grid-min, 15rem), 100%), 1fr));
    gap: var(--p-gap-tight);
    grid-auto-rows: minmax(var(--p-gallery-thumb), auto);
    inline-size: auto;
    max-block-size: calc(100dvh - var(--p-space-8));
    align-content: normal;
  }

  .p-picker[data-variant="media-search"] > .p-picker-grid > button {
    aspect-ratio: 1;
    inline-size: 100%;
    block-size: auto;
    --p-pad-block: 0;
    padding-block: var(--p-pad-block);
    padding-inline: 0;
    border: 0;
    border-radius: var(--p-radius-sm);
    overflow: hidden;
    background: var(--p-surface-sunken);
    cursor: pointer;
  }

  .p-picker[data-variant="media-search"] > .p-picker-grid > button:hover {
    background: var(--p-tint-soft);
  }

  .p-picker[data-variant="media-search"] > .p-picker-grid > button img {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
  }

  .p-picker[data-variant="media-search"] > .p-picker-empty {
    grid-column: auto;
  }

  /* spec: phone-control-target */
  /* Compact controls keep their desktop dimensions; on a narrow coarse
   * pointer, only the marked controls receive the measured 45px target. */
  @media (max-width: 720px) and (pointer: coarse) {
    .p-phone-control-target {
      min-block-size: calc(var(--p-control-h-lg) + var(--p-space-2));
      min-inline-size: calc(var(--p-control-h-lg) + var(--p-space-2));
    }
  }

  /* spec: typing */
  /* =================================================================
   * Typing
   *
   * Somebody at the other end is writing.
   * ================================================================= */
  .p-typing {
    display: inline-flex;
    align-items: center;
    gap: var(--p-space-2);
    margin: 0;
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
  }

  /* THREE DOTS, ONE ELEMENT. The dot itself, plus its two pseudo-
   * elements — so a project writes one empty span rather than three, and
   * cannot write two by mistake and get a stutter.
   *
   * Positioned rather than laid out in a row: the middle dot has to stay
   * on the text baseline while the outer two are moving, and a flex row
   * of three animated boxes resizes its own line every frame. */
  .p-typing-dots {
    position: relative;
    display: inline-block;
    inline-size: 5px;
    block-size: 5px;
    margin-inline: 8px;
    border-radius: var(--p-radius-full);
    /* BACKGROUND COLOUR, NOT OPACITY, and it has to be. The other two
     * dots are this one's pseudo-elements, so they are its CHILDREN —
     * and opacity on a parent multiplies with the opacity of everything
     * inside it. The middle dot animated between 0.22 and 1 while the
     * outer two could never be brighter than whatever it happened to be,
     * so the middle one always looked strongest. Alpha in a background
     * colour does not inherit, so all three now run the identical
     * animation and differ only in when it starts. */
    background: currentColor;
    animation: p-typing var(--p-dur-typing) ease-in-out infinite;
  }

  .p-typing-dots::before,
  .p-typing-dots::after {
    content: "";
    position: absolute;
    inset-block-start: 0;
    inline-size: 5px;
    block-size: 5px;
    border-radius: var(--p-radius-full);
    background: currentColor;
    animation: p-typing var(--p-dur-typing) ease-in-out infinite;
  }

  .p-typing-dots::before {
    inset-inline-end: 8px;
    animation-delay: calc(var(--p-dur-typing) / -6);
  }

  .p-typing-dots::after {
    inset-inline-start: 8px;
    animation-delay: calc(var(--p-dur-typing) / 6);
  }

  /* OPACITY ONLY. Three earlier goes at this moved the dots — a hop, a
   * swell, a swell with a lift — and all three read as fidgeting. At
   * 5px a dot cannot travel or grow by an amount you notice without the
   * amount being most of the dot, so anything that moves is a twitch.
   * Brightness has no such floor: it goes from barely there to fully
   * there with nothing leaving its place, and the line of text beside
   * the dots stays perfectly still.
   *
   * The long flat rest is what makes it read as one thing travelling
   * rather than three things blinking. */
  @keyframes p-typing {
    0%, 65%, 100% { background-color: color-mix(in oklab, currentColor 22%, transparent); }
    /* Both stops written the same way on purpose: Firefox interpolates
     * between a color-mix() and a bare colour discretely, so mixing the
     * two forms makes the fade flip at the halfway point instead of
     * easing. `pulse lint` knows this one and says so. */
    32% { background-color: color-mix(in oklab, currentColor 100%, transparent); }
  }

  /* Still three dots, still clearly "in progress", with nothing moving.
   * The state is the message; the bounce is the decoration on it. */
  @media (prefers-reduced-motion: reduce) {
    .p-typing-dots,
    .p-typing-dots::before,
    .p-typing-dots::after {
      animation: none;
      background-color: color-mix(in oklab, currentColor 60%, transparent);
    }
  }

  /* spec: avatar-stack */
  /* =================================================================
   * Avatar stack
   *
   * A compact inline identity for a small group. Plates overlap by one
   * spacing step so the group occupies one row slot while every participant
   * remains visibly distinct.
   * ================================================================= */
  .p-avatar-stack {
    display: inline-flex;
    align-items: center;
    flex: none;
  }

  .p-avatar-stack > .p-avatar + .p-avatar {
    margin-inline-start: calc(var(--p-space-4) * -1);
  }

  /* spec: avatar-status */
  /* =================================================================
   * Avatar status
   *
   * One small state is attached to the identity plate instead of claiming
   * another row column. The wrapper accepts either one avatar or a complete
   * avatar stack; the mark belongs to the identity as a whole.
   * ================================================================= */
  .p-avatar-status {
    position: relative;
    display: inline-flex;
    flex: none;
  }

  .p-avatar-status-mark {
    position: absolute;
    inset-block-start: calc(var(--p-space-1) * -1);
    inset-inline-end: calc(var(--p-space-1) * -1);
    inline-size: var(--p-space-3);
    block-size: var(--p-space-3);
    border-radius: var(--p-radius-full);
    background: var(--p-accent);
    box-shadow: 0 0 0 var(--p-space-1) var(--p-surface);
    pointer-events: none;
  }

  /* spec: skip-link */
  /* =================================================================
   * Skip link
   *
   * The first keyboard stop bypasses repeated application furniture. It
   * stays outside layout until focused, then appears in the same leading
   * corner every time so it neither moves the bar nor has to be hunted.
   * ================================================================= */
  .p-skip {
    position: fixed;
    inset-block-start: var(--p-space-2);
    inset-inline-start: var(--p-space-2);
    z-index: var(--p-z-sticky);
    translate: 0 calc(-100% - var(--p-space-4));
  }

  /* Plain :focus, not :focus-visible. A skip link is deliberately outside
   * the screen at rest, so any route that focuses it has to reveal it; a
   * focused control nobody can see is a trap however the focus arrived. */
  .p-skip:focus {
    translate: none;
  }

  /* spec: arrival-highlight */
  /* =================================================================
   * Arrival highlight
   *
   * A programmatic jump moves the viewport, but movement alone does not
   * identify which row was the destination. This short wash connects the
   * navigation action to the thing that arrived without leaving selection
   * state behind after the reader has found it.
   * ================================================================= */
  .p-arrival {
    border-radius: var(--p-radius-lg);
    animation: p-arrival-highlight var(--p-dur-shimmer) var(--p-ease) both;
  }

  @keyframes p-arrival-highlight {
    from { background-color: var(--p-tint-medium); }
    to { background-color: transparent; }
  }

  @media (prefers-reduced-motion: reduce) {
    .p-arrival {
      animation: none;
      background: var(--p-tint-soft);
    }
  }


  /* spec: long-form-prose-field */
  /* =================================================================
   * Long-form prose field
   *
   * A textarea for thousands of characters of authored prose. It reads
   * like .p-prose (68ch measure, normal leading, reading size) rather
   * than like a form control, starts eight lines tall, grows with the
   * writing and hands over to its own scrollbar at twenty-eight. The
   * limit meter beneath is a .p-gauge, which already escalates quietly
   * toward its end; the count beside it is the only overage signal —
   * the owner's verdict (2026-09-05): the number turning bad is enough,
   * no error line under it.
   * ================================================================= */
  .p-long-form-prose-field > textarea {
    display: block;
    inline-size: 100%;
    max-inline-size: 68ch;
    resize: none;
    field-sizing: content;
    min-block-size: 8lh;
    max-block-size: 28lh;
    overflow-y: auto;
    padding: var(--p-space-6);
    color: var(--p-text-strong);
    font-size: var(--p-text-lg);
    line-height: var(--p-leading-normal);
  }

  .p-long-form-prose-field-meter {
    display: flex;
    align-items: center;
    gap: var(--p-space-3);
    max-inline-size: 68ch;
  }

  .p-long-form-prose-field-meter > .p-gauge { flex: 1; }

  .p-long-form-prose-field-count {
    flex: none;
    color: var(--p-text-muted);
    font-size: var(--p-text-xs);
    font-variant-numeric: tabular-nums;
  }

  .p-long-form-prose-field-count[data-tone="bad"] {
    color: var(--p-bad-text);
    font-weight: var(--p-weight-medium);
  }

  /* spec: unsaved-guard */
  /* The confirmation the router opens when leaving would discard work.
   * A plain window modal; the class only fixes its measure, which the
   * owner saw at 34rem when choosing the dialog over an inline warning. */
  .p-unsaved-guard {
    max-inline-size: 34rem;
  }

  /* spec: assistant-edit */
  /* =================================================================
   * Assistant edit
   *
   * A field a second writer — an assistant — can change while the
   * human is editing the same document. When its edit lands the field
   * confesses twice, per the owner's verdict (2026-09-05): a brief
   * accent wash (the arrival-highlight wash, same keyframe) and the
   * word "Applied" in the field's own .p-saved slot. A held edit has no
   * CSS of its own: it is a badge in the head and an Apply/Dismiss row.
   * ================================================================= */
  .p-assistant-edit[data-applied] {
    border-radius: var(--p-radius-lg);
    animation: p-arrival-highlight var(--p-dur-shimmer) var(--p-ease) both;
  }

  @media (prefers-reduced-motion: reduce) {
    .p-assistant-edit[data-applied] {
      animation: none;
      background: var(--p-tint-soft);
    }
  }

  /* spec: month-calendar */
  /* =================================================================
   * Month calendar — incubating, from Chorus
   *
   * Seven equal columns of day cells, each a button listing what is on
   * that day. On a phone the cells go square and the list becomes a row
   * of dots, one per entry, so a whole month still fits a screen.
   * ================================================================= */
  .p-month-calendar {
    display: grid;
    grid-template-columns: repeat(7, minmax(0, 1fr));
    gap: var(--p-space-2);
  }

  .p-month-calendar-head,
  .p-month-calendar-day {
    min-inline-size: 0;
  }

  .p-month-calendar-head {
    padding-inline: var(--p-space-2);
    color: var(--p-text-muted);
    font-size: var(--p-text-2xs);
    font-weight: var(--p-weight-semibold);
    text-transform: uppercase;
  }

  .p-month-calendar-day {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    /* A button centres its content; a day reads from the top, empty or not. */
    justify-content: flex-start;
    min-block-size: calc(var(--p-space-40) + var(--p-space-8));
    padding: var(--p-space-2);
    border: 1px solid var(--p-line);
    border-radius: var(--p-radius-sm);
    background: var(--p-surface-raised);
    overflow: hidden;
    text-align: start;
  }

  .p-month-calendar-day[data-outside="true"] {
    background: var(--p-surface-inset);
    color: var(--p-text-subtle);
  }

  .p-month-calendar-day[data-today="true"] {
    border-color: var(--p-text-accent);
  }

  .p-month-calendar-number {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: var(--p-space-2);
    margin-block-end: var(--p-space-2);
    font-size: var(--p-text-sm);
    font-weight: var(--p-weight-semibold);
  }

  .p-month-calendar-entries {
    display: grid;
    align-content: start;
    gap: var(--p-space-1);
  }

  .p-month-calendar-entry {
    display: block;
    min-inline-size: 0;
    overflow: hidden;
    color: var(--p-text-muted);
    font-size: var(--p-text-2xs);
    line-height: var(--p-leading-snug);
    text-overflow: ellipsis;
    white-space: nowrap;
    text-align: start;
  }

  .p-month-calendar-entry[data-own="true"] {
    color: var(--p-text-strong);
    font-weight: var(--p-weight-semibold);
  }

  .p-month-calendar-entry[data-done="true"] {
    text-decoration: line-through;
  }

  .p-month-calendar-entry[data-projected="true"] {
    opacity: 0.58;
    font-style: italic;
    border-block-end: var(--p-space-px) dashed var(--p-line);
  }

  .p-month-calendar-more {
    color: var(--p-text-subtle);
    font-size: var(--p-text-2xs);
  }

  .p-month-calendar-dots {
    display: none;
  }

  @media (max-width: 40rem) {
    .p-month-calendar {
      gap: var(--p-space-1);
    }

    .p-month-calendar-head {
      padding-inline: var(--p-space-1);
    }

    .p-month-calendar-day {
      /* A square grid item with auto width shrinks to its content instead
       * of filling its column; say the width so the ratio sets the height. */
      inline-size: 100%;
      block-size: auto;
      aspect-ratio: 1;
      min-block-size: 0;
      padding: var(--p-space-1);
    }

    .p-month-calendar-number {
      margin-block-end: 0;
    }

    /* A phone cell is too small for the count; the dots say it instead. */
    .p-month-calendar-number .p-badge {
      display: none;
    }

    .p-month-calendar-entries {
      display: none;
    }

    .p-month-calendar-dots {
      display: flex;
      align-items: center;
      gap: var(--p-space-1);
      min-block-size: var(--p-space-4);
    }

    .p-month-calendar-dot {
      inline-size: var(--p-space-3);
      block-size: var(--p-space-3);
      border-radius: var(--p-radius-full);
      background: var(--p-text-muted);
      color: var(--p-text-muted);
    }

    .p-month-calendar-dot[data-own="true"] {
      background: var(--p-text-accent);
      color: var(--p-text-accent);
    }

    .p-month-calendar-dot[data-done="true"] {
      opacity: 0.45;
      background: transparent;
      border: var(--p-space-px) solid currentColor;
    }

    .p-month-calendar-dot[data-projected="true"] {
      opacity: 0.45;
      border: var(--p-space-px) dashed currentColor;
      background: transparent;
    }
  }

  /* spec: gallery-viewer-action */
  /* =================================================================
   * A corner button that opens the viewer — incubating, from Judge
   *
   * A second .p-gallery-open in the tile's top-end corner, for a tile
   * whose own click does something else. Scrim and a strong ring so it
   * reads on any picture. Two classes deep so it beats the subtle
   * button variant it is also marked with (for the icon and the name).
   * ================================================================= */
  .p-gallery-tile > .p-gallery-viewer-action {
    position: absolute;
    inset-block-start: var(--p-space-2);
    inset-inline-end: var(--p-space-2);
    z-index: 3;
    display: grid;
    place-items: center;
    inline-size: var(--p-gallery-mark);
    block-size: var(--p-gallery-mark);
    min-inline-size: 0;
    min-block-size: 0;
    padding: 0;
    border: 1px solid var(--p-text-strong);
    border-radius: var(--p-radius-full);
    background: var(--p-scrim);
    color: var(--p-text-strong);
    cursor: pointer;
  }

  /* spec: image-spoiler */
  /* =================================================================
   * Image spoiler
   *
   * An inline image that stays blurred until its reader chooses to see
   * it. The reveal is a real button over the picture, so it is reachable
   * by keyboard and named for a screen reader; the application removes
   * it and sets data-revealed. Blur is the concealment, not opacity: the
   * reader can tell there is a picture and roughly its shape.
   * ================================================================= */
  .p-image-spoiler {
    position: relative;
    display: inline-block;
    overflow: hidden;
    border-radius: var(--p-radius-md);
  }

  .p-image-spoiler > img {
    display: block;
    filter: blur(var(--p-space-4));
    transition: filter var(--p-dur-2) var(--p-ease);
  }

  .p-image-spoiler > .p-image-spoiler-reveal {
    position: absolute;
    inset: var(--p-space-3);
    margin: 0;
  }

  .p-image-spoiler[data-revealed] > img { filter: none; }

}
/* Pulse — utilities
 *
 * Deliberately tiny. This is not a utility framework; it is the handful of
 * things that are genuinely cross-cutting and awkward to express otherwise.
 * If you find yourself wanting a spacing scale of utilities here, the
 * answer is a pattern, not a hundred class names.
 */
/* spec: utilities */
@layer pulse.utilities {
  .p-sr-only {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  .p-sr-only:focus-visible {
    position: static;
    inline-size: auto;
    block-size: auto;
    margin: 0;
    overflow: visible;
    clip-path: none;
    white-space: normal;
  }

  .p-truncate {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-inline-size: 0;
  }

  .p-clamp {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: var(--p-clamp-lines, 2);
    line-clamp: var(--p-clamp-lines, 2);
    overflow: hidden;
  }

  .p-grow { flex: 1 1 auto; min-inline-size: 0; }
  .p-none { flex: none; }

  /* Surface escape hatches, for when the container is not a pattern. */
  .p-on-sunken { background: var(--p-surface-sunken); }
  .p-on-raised { background: var(--p-surface-raised); }
  .p-on-inset { background: var(--p-surface-inset); }
  .p-on-tint { background: var(--p-tint-subtle); }

  .p-muted { color: var(--p-text-muted); }
  .p-subtle { color: var(--p-text-subtle); }
  .p-strong { color: var(--p-text-strong); }
  .p-accent-text { color: var(--p-text-accent); }

  .p-mono {
    font-family: var(--p-font-mono);
    font-variant-numeric: tabular-nums;
  }

  .p-label {
    font-size: var(--p-text-2xs);
    font-weight: var(--p-weight-semibold);
    letter-spacing: var(--p-tracking-label);
    color: var(--p-text-muted);
  }

  /* Optical alignment: small nudges that compensate for icon shapes and
   * capitals. Used sparingly and always with a stated reason. */
  .p-optical-up { translate: 0 -1px; }
  .p-optical-down { translate: 0 1px; }

  .p-scroll {
    overflow: auto;
    overscroll-behavior: contain;
  }

  .p-divider {
    block-size: 1px;
    background: var(--p-line);
    border: 0;
  }
}
/* Pulse — icons
 * Generated by `pulse icons` from pulse/icons.svg. Do not edit.
 *
 * One attribute, anywhere:
 *
 *     <button data-icon="trash" data-intent="danger">Delete</button>
 *     <i data-icon="search" aria-hidden="true"></i>
 *
 * The icon is a ::before mask painted in currentColor, so it inherits
 * colour from whatever it sits in and needs no per-intent variants.
 *
 * Two things follow from ::before that are worth knowing before you
 * reach for data-icon:
 *
 *   · Firefox gives <input> no pseudo-elements, so data-icon does
 *     nothing on one. Use .p-input-group with a .p-affix instead.
 *   · An element with data-icon needs to be a flex or grid container
 *     for the gap to apply. Buttons and the .p- patterns already are;
 *     a bare <span> is not.
 */

@layer pulse.icons {
  /* The box. inline-size and block-size are set rather than left to
   * the mask, so a missing or misspelled name leaves a visible hole
   * instead of collapsing the layout into looking fine. */
  [data-icon]::before {
    content: "";
    flex: none;
    inline-size: var(--p-icon-size);
    block-size: var(--p-icon-size);
    background: currentColor;
    -webkit-mask: var(--p-icon-url) center / contain no-repeat;
    mask: var(--p-icon-url) center / contain no-repeat;
    /* Controls carry asymmetric block padding so their capitals sit
     * optically centred rather than their em box. A mask has neither a
     * baseline nor a cap height, so it inherits a correction meant for
     * letterforms and rides low. Give back exactly what the host added;
     * outside a control --p-optical-applied is 0px and this is a no-op. */
    translate: 0 calc(-1 * var(--p-optical-applied));
  }

  /* A standalone icon is an inline box that sizes and colours itself. */
  i[data-icon],
  .p-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    vertical-align: -0.15em;
  }

  /* =================================================================
   * THREE WAYS TO SHOW A CONTROL, and the reader picks.
   *
   *   both    icon and label      (default, and absent from the DOM)
   *   icons   the icon alone
   *   text    the label alone
   *
   * A real setting, not a preference quietly ignored: icons genuinely
   * help some people read an interface and genuinely clutter it for
   * others, and which of those you are is not something an app can work
   * out. Set `data-pulse-icons` on <html>; the theme builder writes it.
   * `off` is still honoured as the old spelling of `text`.
   *
   * NEITHER SETTING CAN LEAVE A BLANK CONTROL. Text mode keeps the icon
   * on anything shaped icon-only, because there the icon IS the label.
   * Icon mode only hides a label where an icon exists to replace it.
   * ================================================================= */
  :is([data-pulse-icons="text"], [data-pulse-icons="off"])
    [data-icon]:not([data-shape="icon"])::before {
    display: none;
  }

  /* Hiding the label without hiding the button. `font-size: 0` collapses
   * the text run while leaving it in the accessibility tree, so the
   * control keeps its name for a screen reader and loses it only on
   * screen. */
  [data-pulse-icons="icons"] :is(button, .p-btn)[data-icon]:not([data-shape="icon"]),
  :is(button, .p-btn)[data-icon][data-collapsed] {
    font-size: 0;
    gap: 0;
    padding-inline: 0;
    inline-size: var(--p-control-h);
    justify-content: center;
  }

  [data-pulse-icons="icons"] :is(button, .p-btn)[data-icon][data-size="sm"],
  :is(button, .p-btn)[data-icon][data-collapsed][data-size="sm"] {
    inline-size: var(--p-control-h-sm);
  }

  [data-pulse-icons="icons"] :is(button, .p-btn)[data-icon][data-size="lg"],
  :is(button, .p-btn)[data-icon][data-collapsed][data-size="lg"] {
    inline-size: var(--p-control-h-lg);
  }

  /* The mask is sized in em everywhere else, and em of zero is nothing. */
  [data-pulse-icons="icons"] :is(button, .p-btn)[data-icon]:not([data-shape="icon"])::before,
  :is(button, .p-btn)[data-icon][data-collapsed]::before {
    --p-icon-size: 1.05rem;
  }

  /* ---- when a row of buttons runs out of room ----------------------
   * `data-collapsed` is set one button at a time by pulse-controls.js,
   * least important first, until the row fits. Only when every one of
   * them is already an icon and it STILL does not fit does the row
   * scroll — a scrollbar hides actions behind a gesture the reader has
   * to discover, and a label is the cheaper thing to give up. */
  :is(.p-actions, .p-toolbar)[data-overflowing] {
    overflow-x: auto;
    scrollbar-width: thin;
  }

  /* ---- the set ---------------------------------------------------- */
  [data-icon="trash"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 7h16M10 11v6M14 11v6'/%3E %3Cpath d='M6 7l.9 12a2 2 0 0 0 2 1.9h6.2a2 2 0 0 0 2-1.9L18 7'/%3E %3Cpath d='M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2'/%3E%3C/svg%3E"); }
  [data-icon="x"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M6 6l12 12M18 6L6 18'/%3E%3C/svg%3E"); }
  [data-icon="check"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7'/%3E%3C/svg%3E"); }
  [data-icon="plus"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 5v14M5 12h14'/%3E%3C/svg%3E"); }
  [data-icon="chat-plus"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 6a1.6 1.6 0 0 1 1.6-1.6h12.8A1.6 1.6 0 0 1 20 6v8.6a1.6 1.6 0 0 1-1.6 1.6H9.3L4 20.4V6z'/%3E %3Cpath d='M12 7v6M9 10h6'/%3E%3C/svg%3E"); }
  [data-icon="folder-plus"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 6.5a1.5 1.5 0 0 1 1.5-1.5h3.6l2 2.2h7.4A1.5 1.5 0 0 1 20 8.7v9.8a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5v-12z'/%3E %3Cpath d='M12 11v6M9 14h6'/%3E%3C/svg%3E"); }
  [data-icon="minus"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E"); }
  [data-icon="pencil"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 20.5h4L20.2 8.3a2.6 2.6 0 0 0-3.7-3.7L4 16.5v4z'/%3E %3Cpath d='M14.5 6.5l3.5 3.5'/%3E%3C/svg%3E"); }
  [data-icon="copy"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='9' y='9' width='11' height='11' rx='2'/%3E %3Cpath d='M5 15.5V6a1.5 1.5 0 0 1 1.5-1.5H16'/%3E%3C/svg%3E"); }
  [data-icon="save"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M19.5 20.5h-15a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1H16l4.5 4.5v11.5a1 1 0 0 1-1 1z'/%3E %3Cpath d='M8 3.5v6h7v-6'/%3E %3Cpath d='M7 20.5v-6h10v6'/%3E%3C/svg%3E"); }
  [data-icon="send"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M20.5 3.5L3 10.4l7 2.6 2.6 7 7.9-16.5z'/%3E %3Cpath d='M10 13L20.5 3.5'/%3E%3C/svg%3E"); }
  [data-icon="play"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M7.5 4.8L19 12 7.5 19.2V4.8z'/%3E%3C/svg%3E"); }
  [data-icon="pause"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M9 5v14M15 5v14'/%3E%3C/svg%3E"); }
  [data-icon="stop"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='6' y='6' width='12' height='12' rx='2'/%3E%3C/svg%3E"); }
  [data-icon="upload"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 15.5V3.5'/%3E %3Cpath d='M7.5 8L12 3.5 16.5 8'/%3E %3Cpath d='M4 16.5v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3'/%3E%3C/svg%3E"); }
  [data-icon="download"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 3.5v12'/%3E %3Cpath d='M7.5 11l4.5 4.5L16.5 11'/%3E %3Cpath d='M4 16.5v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3'/%3E%3C/svg%3E"); }
  [data-icon="share"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 11.5v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8'/%3E %3Cpath d='M12 14.5v-11'/%3E %3Cpath d='M8 7.5l4-4 4 4'/%3E%3C/svg%3E"); }
  [data-icon="sync"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M20 11.2A8.2 8.2 0 0 0 6.3 6.3L3.9 8.6'/%3E %3Cpath d='M3.9 4.5v4.1H8'/%3E %3Cpath d='M4 12.8a8.2 8.2 0 0 0 13.7 4.9l2.4-2.3'/%3E %3Cpath d='M20.1 19.5v-4.1H16'/%3E%3C/svg%3E"); }
  [data-icon="undo"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4.4 8.6h5.2'/%3E %3Cpath d='M4.4 3.9v4.7'/%3E %3Cpath d='M4.9 8.2A8.2 8.2 0 1 1 4 13.4'/%3E%3C/svg%3E"); }
  [data-icon="link"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M10.2 13.8a4 4 0 0 0 5.7 0l3-3a4 4 0 1 0-5.7-5.7l-1.6 1.6'/%3E %3Cpath d='M13.8 10.2a4 4 0 0 0-5.7 0l-3 3a4 4 0 1 0 5.7 5.7l1.6-1.6'/%3E%3C/svg%3E"); }
  [data-icon="external"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M14 4h6v6'/%3E %3Cpath d='M20 4l-8.5 8.5'/%3E %3Cpath d='M18 13.5v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-12a1 1 0 0 1 1-1h6'/%3E%3C/svg%3E"); }
  [data-icon="arrow-right"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 12h15'/%3E%3Cpath d='M13 6l6 6-6 6'/%3E%3C/svg%3E"); }
  [data-icon="arrow-left"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M20 12H5'/%3E%3Cpath d='M11 6l-6 6 6 6'/%3E%3C/svg%3E"); }
  [data-icon="arrow-up"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 20V5'/%3E%3Cpath d='M6 11l6-6 6 6'/%3E%3C/svg%3E"); }
  [data-icon="arrow-down"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 4v15'/%3E%3Cpath d='M6 13l6 6 6-6'/%3E%3C/svg%3E"); }
  [data-icon="chevron-right"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M9.5 5l7 7-7 7'/%3E%3C/svg%3E"); }
  [data-icon="chevron-left"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M14.5 5l-7 7 7 7'/%3E%3C/svg%3E"); }
  [data-icon="chevron-up"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M5 14.5l7-7 7 7'/%3E%3C/svg%3E"); }
  [data-icon="chevron-down"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M5 9.5l7 7 7-7'/%3E%3C/svg%3E"); }
  [data-icon="menu"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 7h16M4 12h16M4 17h16'/%3E%3C/svg%3E"); }
  [data-icon="more"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='5.2' cy='12' r='1.3' fill='currentColor' stroke='none'/%3E %3Ccircle cx='12' cy='12' r='1.3' fill='currentColor' stroke='none'/%3E %3Ccircle cx='18.8' cy='12' r='1.3' fill='currentColor' stroke='none'/%3E%3C/svg%3E"); }
  [data-icon="grip"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='9' cy='6' r='1.35' fill='currentColor' stroke='none'/%3E %3Ccircle cx='15' cy='6' r='1.35' fill='currentColor' stroke='none'/%3E %3Ccircle cx='9' cy='12' r='1.35' fill='currentColor' stroke='none'/%3E %3Ccircle cx='15' cy='12' r='1.35' fill='currentColor' stroke='none'/%3E %3Ccircle cx='9' cy='18' r='1.35' fill='currentColor' stroke='none'/%3E %3Ccircle cx='15' cy='18' r='1.35' fill='currentColor' stroke='none'/%3E%3C/svg%3E"); }
  [data-icon="more-vertical"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5.2' r='1.3' fill='currentColor' stroke='none'/%3E %3Ccircle cx='12' cy='12' r='1.3' fill='currentColor' stroke='none'/%3E %3Ccircle cx='12' cy='18.8' r='1.3' fill='currentColor' stroke='none'/%3E%3C/svg%3E"); }
  [data-icon="swap"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 8.4h13.2'/%3E %3Cpath d='m14 5.2 3.2 3.2L14 11.6'/%3E %3Cpath d='M20 15.6H6.8'/%3E %3Cpath d='m10 12.4-3.2 3.2L10 18.8'/%3E%3C/svg%3E"); }
  [data-icon="pin"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M9.4 3.6h5.2l-.7 5.4 3.3 3.1v1.7H6.8v-1.7l3.3-3.1-.7-5.4z'/%3E %3Cpath d='M12 13.8v6.6'/%3E%3C/svg%3E"); }
  [data-icon="archive"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='3.5' y='4.5' width='17' height='4.2' rx='1.2'/%3E %3Cpath d='M5.2 8.7v10.3a1.5 1.5 0 0 0 1.5 1.5h10.6a1.5 1.5 0 0 0 1.5-1.5V8.7'/%3E %3Cpath d='M10 12.4h4'/%3E%3C/svg%3E"); }
  [data-icon="unarchive"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='3.5' y='4.5' width='17' height='4.2' rx='1.2'/%3E %3Cpath d='M5.2 8.7v10.3a1.5 1.5 0 0 0 1.5 1.5h10.6a1.5 1.5 0 0 0 1.5-1.5V8.7'/%3E %3Cpath d='M12 18.2v-6'/%3E %3Cpath d='M9.2 15l2.8-2.8 2.8 2.8'/%3E%3C/svg%3E"); }
  [data-icon="info"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E %3Cpath d='M12 11.2v4.8'/%3E%3Cpath d='M12 8.2h.01'/%3E%3C/svg%3E"); }
  [data-icon="alert"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M13.5 4.4l7.8 13.9a1.7 1.7 0 0 1-1.5 2.5H4.2a1.7 1.7 0 0 1-1.5-2.5l7.8-13.9a1.7 1.7 0 0 1 3 0z'/%3E %3Cpath d='M12 9.8v4'/%3E%3Cpath d='M12 17h.01'/%3E%3C/svg%3E"); }
  [data-icon="error"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E %3Cpath d='M15 9l-6 6M9 9l6 6'/%3E%3C/svg%3E"); }
  [data-icon="success"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E %3Cpath d='M8.2 12.3l2.6 2.6 5-5.5'/%3E%3C/svg%3E"); }
  [data-icon="help"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E %3Cpath d='M9.7 9.6a2.4 2.4 0 1 1 3.2 2.3c-.6.2-.9.8-.9 1.5v.4'/%3E %3Cpath d='M12 17h.01'/%3E%3C/svg%3E"); }
  [data-icon="search"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='6.5'/%3E%3Cpath d='M15.8 15.8L20.5 20.5'/%3E%3C/svg%3E"); }
  [data-icon="filter"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 5.5h16l-6.2 7.3v6.7l-3.6-2v-4.7L4 5.5z'/%3E%3C/svg%3E"); }
  [data-icon="sort"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 6.5h14M4 12h9.5M4 17.5h5.5'/%3E%3C/svg%3E"); }
  [data-icon="settings"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M10.5 2.5L13.5 2.5 13.5 5.6 15.4 6.4 17.6 4.2 19.8 6.4 17.6 8.6 18.4 10.5 21.5 10.5 21.5 13.5 18.4 13.5 17.6 15.4 19.8 17.6 17.6 19.8 15.4 17.6 13.5 18.4 13.5 21.5 10.5 21.5 10.5 18.4 8.6 17.6 6.4 19.8 4.2 17.6 6.4 15.4 5.6 13.5 2.5 13.5 2.5 10.5 5.6 10.5 6.4 8.6 4.2 6.4 6.4 4.2 8.6 6.4 10.5 5.6z'/%3E %3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E"); }
  [data-icon="user"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='8' r='3.6'/%3E %3Cpath d='M4.8 20.4a7.2 7.2 0 0 1 14.4 0'/%3E%3C/svg%3E"); }
  [data-icon="folder"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 6.5a1.5 1.5 0 0 1 1.5-1.5h3.6l2 2.2h7.4A1.5 1.5 0 0 1 20 8.7v9.8a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5v-12z'/%3E%3C/svg%3E"); }
  [data-icon="palette"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 3.5a8.5 8.5 0 0 0 0 17c1.3 0 2-.8 2-1.7 0-.6-.3-1-.6-1.4-.3-.4-.6-.8-.6-1.4 0-.9.7-1.6 1.7-1.6h1.4a4.6 4.6 0 0 0 4.6-4.6c0-3.5-3.7-6.3-8.5-6.3z'/%3E %3Ccircle cx='8.2' cy='10.4' r='1' fill='currentColor' stroke='none'/%3E %3Ccircle cx='12' cy='7.9' r='1' fill='currentColor' stroke='none'/%3E %3Ccircle cx='15.9' cy='10.4' r='1' fill='currentColor' stroke='none'/%3E%3C/svg%3E"); }
  [data-icon="layers"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 3.6 21 8.3l-9 4.7-9-4.7 9-4.7z'/%3E %3Cpath d='m4.4 12 7.6 4 7.6-4'/%3E %3Cpath d='m4.4 15.9 7.6 4 7.6-4'/%3E%3C/svg%3E"); }
  [data-icon="users"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='9.4' cy='8.4' r='3.4'/%3E %3Cpath d='M3 20.2a6.4 6.4 0 0 1 12.8 0'/%3E %3Cpath d='M16.2 5.4a3.4 3.4 0 0 1 0 6.6'/%3E %3Cpath d='M17.4 14.6a6.4 6.4 0 0 1 3.6 5.6'/%3E%3C/svg%3E"); }
  [data-icon="file"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M13.8 3.5H7a1.5 1.5 0 0 0-1.5 1.5v14A1.5 1.5 0 0 0 7 20.5h10a1.5 1.5 0 0 0 1.5-1.5V8.2L13.8 3.5z'/%3E %3Cpath d='M13.5 3.6v4.7h4.7'/%3E%3C/svg%3E"); }
  [data-icon="calendar"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='4' y='5.5' width='16' height='15' rx='1.8'/%3E %3Cpath d='M4 10.2h16M8.5 3.4v4M15.5 3.4v4'/%3E%3C/svg%3E"); }
  [data-icon="clock"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E %3Cpath d='M12 6.8v5.6l3.4 2'/%3E%3C/svg%3E"); }
  [data-icon="tag"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 4.5h7.2l9 9-6.7 6.7-9-9V4.5z'/%3E %3Cpath d='M8 8.5h.01'/%3E%3C/svg%3E"); }
  [data-icon="grid"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='4' y='4' width='7' height='7' rx='1.4'/%3E %3Crect x='13' y='4' width='7' height='7' rx='1.4'/%3E %3Crect x='4' y='13' width='7' height='7' rx='1.4'/%3E %3Crect x='13' y='13' width='7' height='7' rx='1.4'/%3E%3C/svg%3E"); }
  [data-icon="list"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M9 6.5h11M9 12h11M9 17.5h11'/%3E %3Cpath d='M4.6 6.5h.01M4.6 12h.01M4.6 17.5h.01'/%3E%3C/svg%3E"); }
  [data-icon="branch"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='7' cy='5.5' r='2.4'/%3E %3Ccircle cx='7' cy='18.5' r='2.4'/%3E %3Ccircle cx='17' cy='9.5' r='2.4'/%3E %3Cpath d='M7 7.9v8.2'/%3E %3Cpath d='M17 11.9c0 3.4-3.1 4.4-6.2 4.9'/%3E%3C/svg%3E"); }
  [data-icon="brain"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M11.1 5.1a3.3 3.3 0 0 0-5.2 2.7 3.2 3.2 0 0 0-1.3 5 3.3 3.3 0 0 0 2.1 4.6 3.3 3.3 0 0 0 4.4 1.1z'/%3E %3Cpath d='M12.9 5.1a3.3 3.3 0 0 1 5.2 2.7 3.2 3.2 0 0 1 1.3 5 3.3 3.3 0 0 1-2.1 4.6 3.3 3.3 0 0 1-4.4 1.1z'/%3E%3C/svg%3E"); }
  [data-icon="model"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='5' y='5' width='14' height='14' rx='3'/%3E %3Cpath d='M9 2.5V5M15 2.5V5M9 19v2.5M15 19v2.5M2.5 9H5M2.5 15H5M19 9h2.5M19 15h2.5'/%3E %3Cpath d='M12 8.3l1.1 2.6 2.6 1.1-2.6 1.1-1.1 2.6-1.1-2.6-2.6-1.1 2.6-1.1L12 8.3z'/%3E%3C/svg%3E"); }
  [data-icon="memory"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='6.5' y='6.5' width='11' height='11' rx='2'/%3E %3Cpath d='M9.5 3.2v3.3M14.5 3.2v3.3M9.5 17.5v3.3M14.5 17.5v3.3'/%3E %3Cpath d='M3.2 9.5h3.3M3.2 14.5h3.3M17.5 9.5h3.3M17.5 14.5h3.3'/%3E%3C/svg%3E"); }
  [data-icon="gallery"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='7.5' y='3.5' width='13' height='13' rx='2'/%3E %3Cpath d='M7.5 12.6l3.3-3a1.6 1.6 0 0 1 2.1 0l4.4 4'/%3E %3Ccircle cx='16.4' cy='7.8' r='1.5'/%3E %3Cpath d='M16.5 20.5H5.5A2 2 0 0 1 3.5 18.5V7.5'/%3E%3C/svg%3E"); }
  [data-icon="sparkle"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1-5.1-1.9 5.1-1.9L12 3.5z'/%3E %3Cpath d='M18.5 16.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2z'/%3E%3C/svg%3E"); }
  [data-icon="web"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E %3Cpath d='M3.5 12h17'/%3E %3Cpath d='M12 3.5c2.4 2.6 3.6 5.4 3.6 8.5s-1.2 5.9-3.6 8.5c-2.4-2.6-3.6-5.4-3.6-8.5S9.6 6.1 12 3.5z'/%3E%3C/svg%3E"); }
  [data-icon="attach"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M19 11.5l-7.6 7.6a4.6 4.6 0 0 1-6.5-6.5l8.2-8.2a3.1 3.1 0 0 1 4.4 4.4l-8.2 8.2a1.6 1.6 0 0 1-2.2-2.2l7.3-7.3'/%3E%3C/svg%3E"); }
  [data-icon="smile"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E %3Cpath d='M8.6 14.2a4.4 4.4 0 0 0 6.8 0'/%3E %3Cpath d='M9.2 9.4h.01M14.8 9.4h.01'/%3E%3C/svg%3E"); }
  [data-icon="home"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M3.6 10.6L12 3.5l8.4 7.1'/%3E %3Cpath d='M5.4 12.1v7.4a1 1 0 0 0 1 1h11.2a1 1 0 0 0 1-1v-7.4'/%3E %3Cpath d='M9.6 20.5v-6h4.8v6'/%3E%3C/svg%3E"); }
  [data-icon="book"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 6.6C10.3 5.3 8 4.6 5.5 4.6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1c2.5 0 4.8.7 6.5 2 1.7-1.3 4-2 6.5-2a1 1 0 0 0 1-1v-12a1 1 0 0 0-1-1c-2.5 0-4.8.7-6.5 2z'/%3E %3Cpath d='M12 6.6v13'/%3E%3C/svg%3E"); }
  [data-icon="chat"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 6a1.6 1.6 0 0 1 1.6-1.6h12.8A1.6 1.6 0 0 1 20 6v8.6a1.6 1.6 0 0 1-1.6 1.6H9.3L4 20.4V6z'/%3E%3C/svg%3E"); }
  [data-icon="expand"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M14.5 4h5.5v5.5'/%3E %3Cpath d='M20 4l-6.4 6.4'/%3E %3Cpath d='M9.5 20H4v-5.5'/%3E %3Cpath d='M4 20l6.4-6.4'/%3E%3C/svg%3E"); }
  [data-icon="collapse"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M19.5 9.5H14V4'/%3E %3Cpath d='M14 9.5L20.3 3.2'/%3E %3Cpath d='M4.5 14.5H10V20'/%3E %3Cpath d='M10 14.5L3.7 20.8'/%3E%3C/svg%3E"); }
  [data-icon="mic"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 3.4a3 3 0 0 1 3 3v5.2a3 3 0 0 1-6 0V6.4a3 3 0 0 1 3-3z'/%3E %3Cpath d='M6.2 11.2a5.8 5.8 0 0 0 11.6 0'/%3E %3Cpath d='M12 17v3.6'/%3E%3C/svg%3E"); }
  [data-icon="headphones"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4.6 15.4v-3a7.4 7.4 0 0 1 14.8 0v3'/%3E %3Cpath d='M4.6 14.2h1.9a1.3 1.3 0 0 1 1.3 1.3v2.8a1.3 1.3 0 0 1-1.3 1.3H5.9a1.3 1.3 0 0 1-1.3-1.3v-4.1z'/%3E %3Cpath d='M19.4 14.2h-1.9a1.3 1.3 0 0 0-1.3 1.3v2.8a1.3 1.3 0 0 0 1.3 1.3h.6a1.3 1.3 0 0 0 1.3-1.3v-4.1z'/%3E%3C/svg%3E"); }
  [data-icon="speaker"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M4 9.4h3.2L12 5.2v13.6L7.2 14.6H4a.8.8 0 0 1-.8-.8V10.2a.8.8 0 0 1 .8-.8z'/%3E %3Cpath d='M15.4 9.6a3.6 3.6 0 0 1 0 4.8'/%3E %3Cpath d='M18.1 7.2a7.2 7.2 0 0 1 0 9.6'/%3E%3C/svg%3E"); }
  [data-icon="waveform"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M3.5 11v2M7.2 7.6v8.8M11 4.4v15.2M14.8 8.8v6.4M18.5 6.2v11.6M21.5 10.4v3.2'/%3E%3C/svg%3E"); }
  [data-icon="film"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='3.5' y='4.5' width='17' height='15' rx='2'/%3E %3Cpath d='M8.2 4.5v15M15.8 4.5v15M3.5 12h17'/%3E %3Cpath d='M3.5 8.2h4.7M3.5 15.8h4.7M15.8 8.2h4.7M15.8 15.8h4.7'/%3E%3C/svg%3E"); }
  [data-icon="tv"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='3' y='7.2' width='18' height='12.3' rx='2'/%3E %3Cpath d='M8 3.5l4 3.7 4-3.7'/%3E%3C/svg%3E"); }
  [data-icon="music"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M9.2 17.8V6.3l10-2v11.5'/%3E %3Ccircle cx='6.6' cy='17.8' r='2.6'/%3E %3Ccircle cx='16.6' cy='15.8' r='2.6'/%3E%3C/svg%3E"); }
  [data-icon="game"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M8.6 7.8h6.8a5.6 5.6 0 0 1 5.5 6.6l-.5 2.6a2.6 2.6 0 0 1-4.4 1.3L14.4 16H9.6l-1.6 2.3a2.6 2.6 0 0 1-4.4-1.3l-.5-2.6a5.6 5.6 0 0 1 5.5-6.6z'/%3E %3Cpath d='M7.2 11v2.6M5.9 12.3h2.6'/%3E %3Ccircle cx='15.4' cy='11.8' r='.95' fill='currentColor' stroke='none'/%3E %3Ccircle cx='17.7' cy='13.6' r='.95' fill='currentColor' stroke='none'/%3E%3C/svg%3E"); }
  [data-icon="thumb-up"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M7.2 20.5V10.1l4.4-6.2a2 2 0 0 1 3.5 1.8l-1.1 4.6h5.1a2 2 0 0 1 2 2.4l-1.3 6.3a2.6 2.6 0 0 1-2.6 2.1H7.2z'/%3E %3Cpath d='M7.2 10.1H4.6a1 1 0 0 0-1 1v8.4a1 1 0 0 0 1 1h2.6'/%3E%3C/svg%3E"); }
  [data-icon="thumb-down"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M7.2 3.5v10.4l4.4 6.2a2 2 0 0 0 3.5-1.8l-1.1-4.6h5.1a2 2 0 0 0 2-2.4l-1.3-6.3a2.6 2.6 0 0 0-2.6-2.1H7.2z'/%3E %3Cpath d='M7.2 13.9H4.6a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1h2.6'/%3E%3C/svg%3E"); }
  [data-icon="server"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='3.5' y='4.5' width='17' height='6' rx='1.6'/%3E %3Crect x='3.5' y='13.5' width='17' height='6' rx='1.6'/%3E %3Ccircle cx='7' cy='7.5' r='1' fill='currentColor' stroke='none'/%3E %3Ccircle cx='7' cy='16.5' r='1' fill='currentColor' stroke='none'/%3E%3C/svg%3E"); }
  [data-icon="computer"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='6.5' y='3.5' width='11' height='17' rx='1.8'/%3E %3Cpath d='M9.5 7.5h5'/%3E %3Cpath d='M9.5 10.5h5'/%3E %3Ccircle cx='12' cy='16.5' r='1.1' fill='currentColor' stroke='none'/%3E%3C/svg%3E"); }
  [data-icon="laptop"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='4.5' y='4.5' width='15' height='10' rx='1.5'/%3E %3Cpath d='M2.5 17.5h19l-1.2 2.2a1 1 0 0 1-.9.5H4.6a1 1 0 0 1-.9-.5z'/%3E%3C/svg%3E"); }
  [data-icon="eye"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M2.6 12S6.2 5.6 12 5.6 21.4 12 21.4 12 17.8 18.4 12 18.4 2.6 12 2.6 12z'/%3E %3Ccircle cx='12' cy='12' r='2.9'/%3E%3C/svg%3E"); }
  [data-icon="eye-off"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M9.8 6a9.7 9.7 0 0 1 2.2-.4c5.8 0 9.4 6.4 9.4 6.4a17.4 17.4 0 0 1-2.9 3.7'/%3E %3Cpath d='M6.2 8A17.4 17.4 0 0 0 2.6 12S6.2 18.4 12 18.4a9.6 9.6 0 0 0 4-.9'/%3E %3Cpath d='M9.9 9.9a2.9 2.9 0 0 0 4.1 4.1'/%3E %3Cpath d='M3.5 3.5l17 17'/%3E%3C/svg%3E"); }
  [data-icon="lock"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='4.5' y='10.2' width='15' height='10.3' rx='2'/%3E %3Cpath d='M8 10.2V7.4a4 4 0 0 1 8 0v2.8'/%3E%3C/svg%3E"); }
  [data-icon="unlock"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Crect x='4.5' y='10.2' width='15' height='10.3' rx='2'/%3E %3Cpath d='M14 10.2V6.9a3.5 3.5 0 0 1 7 0v1.7'/%3E%3C/svg%3E"); }
  [data-icon="key"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='6.6' cy='12' r='3.9'/%3E %3Cpath d='M10.5 12h10.9M14.6 12v2.3M17.6 12v2.9M20.6 12v2.3'/%3E%3C/svg%3E"); }
  [data-icon="login"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M13.6 3.5h4.4a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2h-4.4'/%3E %3Cpath d='M3.6 12h9.8M10.1 8.7L13.4 12l-3.3 3.3'/%3E%3C/svg%3E"); }
  [data-icon="sun"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='4'/%3E %3Cpath d='M12 2.4v2.6M12 19v2.6M21.6 12H19M5 12H2.4'/%3E %3Cpath d='M18.8 5.2L17 7M7 17l-1.8 1.8M18.8 18.8L17 17M7 7L5.2 5.2'/%3E%3C/svg%3E"); }
  [data-icon="moon"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M20.7 14.6A9.1 9.1 0 0 1 9.4 3.3a9.1 9.1 0 1 0 11.3 11.3z'/%3E%3C/svg%3E"); }
  [data-icon="star"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M12 3.6l2.7 5.5 6.1.9-4.4 4.3 1 6-5.4-2.9-5.4 2.9 1-6L3.2 10l6.1-.9L12 3.6z'/%3E%3C/svg%3E"); }
  [data-icon="bell"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M6.4 10.2a5.6 5.6 0 0 1 11.2 0c0 4.1 1.6 5.6 1.6 5.6H4.8s1.6-1.5 1.6-5.6z'/%3E %3Cpath d='M10 19a2.2 2.2 0 0 0 4 0'/%3E%3C/svg%3E"); }
  [data-icon="bell-off"] { --p-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='M6.4 10.2a5.6 5.6 0 0 1 11.2 0c0 4.1 1.6 5.6 1.6 5.6H4.8s1.6-1.5 1.6-5.6z'/%3E %3Cpath d='M10 19a2.2 2.2 0 0 0 4 0'/%3E %3Cpath d='M3.5 3.5l17 17'/%3E%3C/svg%3E"); }
}
