/*
 * aparté — THE TOKENS. Every knob the library exposes, and nothing else: the light
 * palette, the dark overrides, and the derived layer that re-anchors on the five
 * selectors a palette can change on.
 *
 * Split out of aparte.css because 18% of that file was data, not style, and the two
 * are read for different reasons — you come here to change a value, and to aparte.css
 * to change a look. The cut is a CONTIGUOUS prefix of the original file and this sheet
 * is imported immediately before it, so the cascade is byte-identical: concatenating
 * the two reproduces the original exactly.
 *
 * `scripts/check-derived-vars.mjs` reads both sheets in import order, the way a
 * browser does — the anchored layer lives here, its responsive overrides live at the
 * end of aparte.css, and the rule that binds them spans the pair.
 */
/*
 * Aparte CSS
 * Single source of truth for all component styles.
 * Theme is controlled via CSS variables - app sets [data-aparte-theme="dark"] to switch.
 */

/* =========================================
   Light Theme (Default)
   ========================================= */
:root,
:host {
  /* Base Colors */
  --aparte-primary: #b07d33;
  --aparte-primary-hover: #a06f28;
  --aparte-secondary: #9a8fb0;
  --aparte-neutral: #6d6479;

  /* Backgrounds & Surfaces */
  --aparte-bg: #f6f2ea;
  --aparte-surface-1: #fffdf8;
  /* Bubbles, cards */
  --aparte-surface-2: #f1ebdf;
  /* `--aparte-surface-3` (code blocks, headers) and `--aparte-text-inverse` are no
     longer literals here: both are derived from the masters in the anchored layer
     below, so the eight-line rebrand the theming guide documents actually covers them.
     They were the two palette hexes that survived a rebrand — a consumer on a blue
     brand kept the brass palette's cream for its tooltip ink and its code headers. */

  /* Text */
  --aparte-text: #241f2b;
  --aparte-text-muted: #6d6479;

  /* Borders */
  --aparte-border: #e5ddce;
  --aparte-border-width: 1px;

  /* Status Colors */
  --aparte-info: #3b82f6;
  --aparte-success: #10b981;
  --aparte-warning: #f59e0b;
  --aparte-error: #ef4444;

  /* THE SAME FIVE, AS TEXT ON THE PAGE — and DERIVED, not written down.
     The five fills above are for a solid button, a badge, a wash. Read as text on
     `--aparte-bg` they measured 3.23 (primary), 3.29 (info), 2.27 (success), 1.92
     (warning) and 3.37 (error) against the 4.5:1 AA asks of body text, and every ghost,
     outline and soft button, the tool-call status, the form error and every link core
     renders was reading the fill.

     These used to be ten hand-picked hexes — five here, five in the dark block — each
     measured against THIS repo's `--aparte-bg`. Same defect the solid ink had: a
     consumer's palette got a colour computed for someone else's page. Each is now the
     accent with its own hue and chroma kept and its LIGHTNESS forced to
     `--aparte-ink-l`, which is the one value that has to flip with the theme.

     Why forced rather than mixed: pulling the accent toward `--aparte-text` reads well
     and fails on an accent already close to the background — measured 3.41 on a navy
     brand primary, which cannot be rescued by moving toward the text since it has to move
     PAST it. Setting the lightness outright has no such blind spot.

     Measured across 80 combinations — 5 intents x 4 palettes (ours light, ours dark, and
     two invented) x 4 grounds (bg, surface-1/2/3) — the worst case is 4.80. */
  /* The target lightness itself is a literal and stays here; the five inks that read
     it are derived, so they live on the anchored layer below. */
  --aparte-ink-l: 0.40;

  /* Masters — one knob each. Every step of every scale is COMPUTED from these
     (see the anchored block below), so moving one moves the whole family. That
     is the property a scale written out as literals cannot have.

     Spacing and radius are px, type is rem, and the split is deliberate: rem
     type follows the reader's browser font size, which is the one accessibility
     setting a chat has to honour. Compounding that into every gap as well makes
     a large-type page grow twice over. Wanting rem spacing anyway is now a
     one-line change here — which is the point of having a master at all. */
  --aparte-space-unit: 2px;
  --aparte-radius-unit: 3px;
  --aparte-font-scale: 1.08;

  /* Not a step of the radius scale: a pill is a shape, not a size. */
  --aparte-radius-full: 9999px;

  /* Font-weight scale — every font-weight references these (no raw weights). */
  --aparte-font-weight-normal: 400;
  --aparte-font-weight-medium: 500;
  --aparte-font-weight-semibold: 600;
  --aparte-font-weight-bold: 700;

  /* Line-height scale — every line-height references these (no raw values). */
  --aparte-line-height-none: 1;
  --aparte-line-height-tight: 1.25;
  --aparte-line-height-normal: 1.5;
  --aparte-line-height-relaxed: 1.6;
  --aparte-line-height-loose: 1.7;

  /* Typography */
  --aparte-font-family: system-ui, -apple-system, sans-serif;

  /* WHERE THE INK ON A COLOURED FILL COMES FROM, and why there is no literal here.
     `--aparte-btn-on-intent` / `--aparte-badge-on-intent` / `--aparte-checkbox-on-intent`
     are DERIVED from their own fill, in a `@supports` block in each recipe — see the
     long note in `button.css`. `--aparte-on-intent` below survives only as the fallback
     for a browser without relative colour syntax.

     THE PAIR IS THE CONTRACT. Every intent has two names — the fill and the ink that
     goes on it — and a theme may declare either half:

       --aparte-primary   / --aparte-on-primary
       --aparte-secondary / --aparte-on-secondary
       --aparte-neutral   / --aparte-on-neutral
       --aparte-info      / --aparte-on-info
       --aparte-success   / --aparte-on-success
       --aparte-warning   / --aparte-on-warning
       --aparte-error     / --aparte-on-error

     None of the seven `--aparte-on-*` is declared here, and that is the point: an
     undeclared partner means "core, work it out", and the recipes derive it from the
     fill. Declare one and it wins, everywhere that intent is used. So a theme author
     decides where they have an opinion and stays silent where they do not, and nothing
     is guessed behind their back — which is what the old single frozen constant did.

     This is the shape shadcn uses (`--primary` / `--primary-foreground`), with
     Bootstrap's computed default behind it. Before, one hex served all seven and only
     `neutral` had an escape, hardcoded. */

  /* The lightness at which the derived ink flips from light to dark. One knob, read by
     all three recipes, so the threshold is tunable in one place instead of three. 0.57
     sits in a wide gap: on this palette the only fill wanting light ink measures 0.519
     and the lowest wanting dark measures 0.623. */
  --aparte-ink-flip: 0.57;
  /* And how dark the dark end goes. NOT 0: at a lightness of zero OKLCH ignores chroma
     entirely, so the ink came out pure #000000 on every fill and the hue this
     derivation carries was thrown away — the first version of it claimed a warm black
     on brass and shipped a flat one. 0.176 is the lightness of `#14100a`, the ink that
     was hand-picked here before, so the brass keeps the warm near-black it had and a
     blue fill gets a cool one, each from its own hue. */
  --aparte-ink-dark: 0.176;
  /* FALLBACK ONLY — a browser without relative colour syntax (Firefox before 128).
     Everywhere else the ink is derived from the fill and this value is never read.

     It is a hex chosen by measuring against THIS repo's own five fills, which is
     exactly why it stopped being the default path: it made core's rendering depend on
     core's palette, in a library whose consumers bring their own. Our theming guide
     teaches an eight-line rebrand and a one-attribute
     `<aparte-chat style="--aparte-primary: …">`, and names this token in neither — so
     a consumer with a dark brand primary got near-black on it and no signal (1.11:1
     measured on `#1a1a2e`). Kept as the fallback because a degraded old browser is
     better than an unstyled one; it is not the contract.

     It does not flip with the theme, and in a themed system that anomaly was the tell
     I wrote a paragraph justifying instead of reading. */
  --aparte-on-intent: #14100a;
  /* Outline width for keyboard focus + drag-over indicators. */
  --aparte-focus-outline-width: 2px;
  /* Scrollbars rendered by the library. */
  --aparte-scrollbar-width: 6px;
  /* The transcript's scrollbar track; the thumb is derived from the neutral, in the
     derived layer. A host page with a scrollbar of its own (the docs site has one)
     sets both so the chat's does not read as a second, foreign scrollbar. */
  --aparte-scrollbar-track: transparent;
  /* Comfortable hit-area on coarse (touch) pointers. */
  --aparte-touch-target-size: 44px;

  /* Messages */
  --aparte-message-max-width: 800px;

  /* Bubbles. The user's bubble tint is DERIVED from the accent in the anchored layer
     below (it was a frozen plum hex that outlived every rebrand); the assistant is
     plain prose (AI-chat convention) — flip it to get a two-bubble chat-app look. */
  --aparte-message-content-bg-assistant: transparent;

  /* Aliases used by some segment renderers (artifact card, tool pill).
     Mapped to existing tokens so consumers that override --aparte-surface-1/2
     get cohesive results without touching every rule. */

  /* Code typography — exposed so apps can swap in Geist Mono / Cascadia / … */
  --aparte-code-font-family: ui-monospace, 'Cascadia Code', 'JetBrains Mono', 'Geist Mono', SFMono-Regular, Consolas, 'Liberation Mono', monospace;

  /* Attachments */
  --aparte-attachment-image-size: 72px;
  --aparte-attachments-max-height: 140px;

  /* Attachment thumbnail tile (composer preview + sent-message strip) */
  --aparte-thumb-name-color: #ffffff;
  --aparte-thumb-name-scrim: linear-gradient(to top, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0));
  --aparte-thumb-name-padding: 14px 5px 4px;
  --aparte-thumb-remove-size: 18px;
  --aparte-thumb-remove-inset: 3px;
  --aparte-thumb-remove-bg: rgba(0, 0, 0, 0.6);
  --aparte-thumb-remove-bg-hover: rgba(0, 0, 0, 0.85);
  --aparte-thumb-remove-color: #ffffff;

  /* The mark (display/mark.css): how much of its intent a marked row's ground takes,
     and how wide the bar on its start edge is. One pair for the select's chosen option,
     the checked field choice, the active conversation and an answered question. */
  --aparte-mark-tint: 18%;
  --aparte-mark-bar: 2px;

  /* Branch Picker */
  --aparte-branch-picker-label-min-width: 32px;

  /* Avatars */
  --aparte-avatar-size: 32px;
  /* Fractions of the size, computed on the element (display/avatar.css): the initials'
     type, the corner and the group's overlap follow the ramp instead of sitting at one
     absolute value for 20px and 56px alike. */
  --aparte-avatar-initials-ratio: 0.34;
  --aparte-avatar-radius-ratio: 0.28;
  --aparte-avatar-overlap-ratio: 0.28;
  --aparte-avatar-image-user: none;
  --aparte-avatar-image-assistant: none;
  --aparte-avatar-image-size: 90%;

  /* Text Sizes */

  /* Input */
  --aparte-input-editor-max-height: 200px;

  /* Icons: one knob, inherited — a container declares `--aparte-icon-size` and every
     glyph below follows. The steps are on the type scale, in the derived layer. */

  /* Send button + inline action buttons (their size is the lg step, in the derived layer) */
  --aparte-input-action-btn-icon-size: 20px;
  --aparte-model-select-min-width: 120px;

  /* Status Indicator */
  --aparte-status-bg: transparent;
  --aparte-status-dot-size: 6px;
  /* The floor of every pulsing dot's opacity: above the perception threshold. */
  --aparte-pulse-floor: 0.55;

  /* Waiting Indicator (the bubble's own dots, before the first token) */
  --aparte-waiting-height: 1.5em;

  /* Thinking Segment */
  --aparte-thinking-content-max-height: 300px;
  --aparte-thinking-content-line-height: 1.55;
  --aparte-thinking-rail-width: 2px;

  /* Elicitation — the panel a tool's question is asked in.
     These exist because the panel's CSS used to be a <style> injected from JS with
     every value hardcoded, so it was the one surface in the library that could not
     be themed and did not appear in the generated CSS reference. */
  --aparte-elic-max-height: 50vh;
  --aparte-elic-control-size: 15px;
  /* The tool-call row's corner. Declared here rather than left as an inline
     fallback in the renderer: an undeclared custom property still WORKS, which is
     what makes it dangerous — it is absent from the generated CSS reference, so a
     consumer has no way to discover the knob exists. */
  --aparte-elic-textarea-min-height: 64px;
  /* Tabs, not pills.
     Two passes to get here, both wrong for the same reason — decorating a control
     instead of choosing what it IS. First a fully-round pill filled with the primary
     colour: the only 9999px thing in a panel built from 4/6/8px corners, and it read
     as a second send button. Then a small bordered rectangle: still a button.
     A step indicator is a TAB — text on a rail, and the current one carries a thick
     underline. No box, no fill, no border. */
  --aparte-elic-step-underline: 2px;
  /* Space kept clear at the end of the tab rail for the corner escape. */
  --aparte-elic-dismiss-room: 72px;

  /* Code Blocks */
  --aparte-code-copy-hover-bg: rgba(0, 0, 0, 0.05);
  --aparte-code-copy-icon-size: 14px;
  --aparte-code-inline-font-size: 0.875em;

  /* Scroll-to-bottom button (its size is the lg step, in the derived layer) */
  --aparte-scroll-btn-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);

  /* Error segment */
  --aparte-error-bg: #fef2f2;
  --aparte-error-border: #fecaca;
  --aparte-error-solid: #dc2626;
  --aparte-error-text: #991b1b;
  --aparte-error-rail-width: 4px;

  /* Prose (aparte-segment-content) */
  --aparte-prose-p-margin: 0.4em 0;
  --aparte-prose-h1-size: 1.4em;
  --aparte-prose-h2-size: 1.25em;
  --aparte-prose-h3-size: 1.1em;
  --aparte-prose-list-indent: 1.5em;
  --aparte-prose-list-margin: 0.4em 0;
  --aparte-prose-li-margin: 0.15em 0;
  /* Heading and block margins, in em so they scale with the heading's own size —
     the one place em beats the px scale. The family already named its sizes and
     weights; these were the only members still written out in the rules. */
  --aparte-prose-h1-margin: 0.6em 0 0.2em;
  --aparte-prose-h2-margin: 0.5em 0 0.2em;
  --aparte-prose-h3-margin: 0.4em 0 0.15em;
  --aparte-prose-h4-margin: 0.3em 0 0.1em;
  --aparte-prose-blockquote-margin: 0.4em 0;
  --aparte-prose-blockquote-indent: 1em;
  --aparte-prose-hr-margin: 0.75em 0;
  --aparte-prose-code-padding: 0.1em 0.3em;
  --aparte-prose-blockquote-border-width: 3px;

  /* Viewport */
  /* Breathing room UNDER the composer, so it does not sit flush against the bottom
     edge of a full-height chat. Same 16px the viewport already puts between the last
     bubble and the composer — this restores the symmetry rather than inventing a
     value. Set it to 0 for a flush composer (a full-bleed mobile shell with a docked
     keyboard is the legitimate case). */

  /* Status */

  /* Input (composer) */


  /* Scroll rail — the ticks beside the transcript. Off-scale on purpose: a 2px line
     and a 14px mark are a minimap's measures, not spacing steps. */
  --aparte-scroll-rail-tick-size: 14px;
  --aparte-scroll-rail-tick-thickness: 2px;
  /* What a tick is to a POINTER, as opposed to what it is to the eye: WCAG 2.5.8's
     24px minimum. The drawn line stays 14×2 — this sizes the pseudo-element around it,
     and the gap below derives from it so the pitch rises with the zone. The rail is
     `display: none` under `(pointer: coarse)`, so 2.5.8's 24px is the bar here and not
     2.5.5's 44px. */
  --aparte-scroll-rail-hit-size: 24px;
  /* The share of the transcript's height the rail may take: a list of ticks, not a
     minimap, so it is the height of its list up to this, centred, and scrolls past it. */
  --aparte-scroll-rail-share: .6;

  /* Context gauge, ring variant — a diameter, and a stroke in the ring's own 36-unit
     box. Literals like the rail's: a 22px ring is a toolbar's measure, not a spacing step. */
  --aparte-context-ring-size: 22px;

  /* Shell — the sidebar's column, the header's row and the split's seam. */
  --aparte-sidebar-width: 260px;
  --aparte-app-header-height: 48px;
  --aparte-scrim: rgba(0, 0, 0, 0.35);
  /* The split's primary pane and the bounds it moves between. Any length or
     percentage: the browser clamps them in the grid template, so nothing in JS
     parses a unit. The seam is 4px because that is a seam; the grab zone around it
     is 12px because that is a target, and it grows to the touch target on a coarse
     pointer (responsive.css). */
  --aparte-split-position: 38%;
  /* Never more than the container has: at 320px this floor was wider than a phone, so
     the primary pane took everything and the end pane was annihilated to 0. */
  --aparte-split-min: min(20rem, 100%);
  --aparte-split-max: 60%;
  /* The seam's TRACK — the grab zone, and what the grid reserves. The painted line
     inside it is `--aparte-split-seam-width` (derived layer): one token used to size
     both, so the line could not be thinned without moving the layout, and at 4px it
     was four times the kit's rule with no grip — an interaction drawn as a decoration. */
  --aparte-split-handle-size: 12px;
  --aparte-split-hit-area: 12px;
  /* Sizes that are not steps of a scale. Each is off-scale on purpose — a 40px
     file tile is a tile, not fourteen spacing units — so each gets a name rather
     than sitting as a magic number in a rule. Literal, like every other size
     token here (`--aparte-send-btn-size`, `--aparte-avatar-size`): deriving the
     control sizes from --aparte-space-unit is a separate decision, not a
     side effect of naming them. */
  /* Motion. Four durations because the sheet actually uses four — 0.12s and
     0.15s were doing the same job on the same properties, which is drift; both
     are named here rather than collapsed, so the choice is visible and stays
     yours. `spin` and `pulse` are loop lengths, not state changes. */
  --aparte-duration-fast: 0.12s;
  --aparte-duration-base: 0.15s;
  --aparte-duration-slow: 0.2s;
  --aparte-duration-slower: 0.3s;
  --aparte-duration-spin: 0.7s;
  --aparte-duration-pulse: 1.2s;
  --aparte-ease: ease;
  /* How far a panel travels as it appears. */
  --aparte-slide-distance: 8px;

  /* Stacking. Two names because the sheet has exactly two stacked things: the
     dismiss control that must clear its own row, and the scroll button floating
     over the transcript. No invented elevation scale — a host that needs its
     modal above the button raises it here. */
  --aparte-z-raised: 1;
  --aparte-z-dropdown: 1000;
  --aparte-z-floating: 10;

  /* Select. Its dropdown shadow used to live as a fallback inside select.css,
     which meant it had no dark value at all — rgba(0,0,0,0.1) over a dark
     surface is no shadow. Declared here, it themes like every other. */

  --aparte-line-height-snug: 1.4;

  /* One value for "this control is disabled". It was seven states carrying five
     different opacities — 0.3, 0.45, 0.5, 0.5, 0.55, 0.6, 0.6 — and not one of them
     had a comment saying why, so there was nothing to preserve in keeping them.
     Deliberately ONE knob and not Material's content/container pair: that split
     tints a container's background apart from its text, and every case here is a
     whole control fading. Not Bootstrap's per-component variable either — that is
     the drift we just removed, with names on it. */
  --aparte-disabled-opacity: 0.5;

  /* Elevation for the layered surfaces. Literal, and each has a dark value below —
     a shadow tuned for a light ground is no shadow at all on a dark one, which is how
     the select's dropdown lost its lift in dark mode until it was caught. */
  --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16);
  --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
  /* The composer laid over the transcript (overlay-composer): a thing that floats has to
     be seen floating, and its ground is the transcript's own. */
  --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
  --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
  --aparte-range-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);

  --aparte-select-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16);
  --aparte-select-min-width: 200px;
  --aparte-select-dropdown-max-height: 300px;
  --aparte-select-status-dot-size: 8px;
  --aparte-select-spinner-size: 12px;

  /* Button sizes. Literal like every other control size: tightening the spacing
     scale must not shrink a button. The three are the three this library already
     uses — a conversation action, an action-bar button, a send button. */

  /* Display primitives — sizes. Literal like every other control size: tightening the
     spacing scale must not shrink an avatar or a spinner. */
  /* Sizes for the five native controls the field sheet dresses. Literal, like every
     other control size. The date minimum width exists because a date field otherwise
     collapses to the intrinsic width of its digits, which differs per locale. */
  --aparte-color-size: 32px;
  --aparte-datetime-min-width: 160px;
  --aparte-meter-height: 8px;
  --aparte-datetime-indicator-opacity: 0.6;

  --aparte-thumbnail-size: 56px;
  --aparte-thumbnail-size-sm: 40px;
  --aparte-thumbnail-size-lg: 72px;

  --aparte-avatar-size-xs: 20px;
  --aparte-avatar-size-sm: 24px;
  --aparte-avatar-size-lg: 40px;
  --aparte-avatar-size-xl: 56px;
  --aparte-spinner-size-sm: 12px;
  --aparte-spinner-size-md: 16px;
  --aparte-spinner-size-lg: 24px;
  --aparte-skeleton-rect-height: 5rem;
  --aparte-skeleton-text-last-width: 60%;
  --aparte-progress-indeterminate-width: 40%;

  /* The control height scale — four steps, and EVERY control reads one: a text
     button used to have no height (23px of padding and line-height) beside 24, 29, 32,
     36 and 44px neighbours, and the 36px family (send, the input's action button, the
     scroll button) lived off the scale as literals. sm is the small control (action bar,
     branch arrows, a row's ⋯), md the default button, lg the field and the composer's
     controls at rest, xl the large button. */
  --aparte-btn-size-sm: 24px;
  --aparte-btn-size-md: 32px;
  --aparte-btn-size-lg: 36px;
  --aparte-btn-size-xl: 40px;
  /* The field family's literal sizes, beside the button's: the derived knobs
     (paddings, radius) sit in the anchored layer below, like the button's own. */
  --aparte-field-textarea-min-height: 80px;
  --aparte-checkbox-size: 18px;
  --aparte-radio-size: 18px;
  --aparte-radio-dot-size: 8px;
  --aparte-switch-width: 40px;
  --aparte-switch-height: 22px;
  --aparte-switch-thumb-inset: 2px;
  --aparte-range-thumb-size: 16px;
  --aparte-range-track-height: 4px;

  --aparte-tool-spinner-size: 10px;
  --aparte-model-select-chevron-room: 28px;
}

/* =========================================
   The system decides, by default.

   Dark used to exist only behind the attribute: on a dark OS an un-attributed chat
   rendered LIGHT on the host's dark page, unreadable, with no error. This block is
   the DARK block above, verbatim, applied when the OS asks and the host has not
   said otherwise — `data-aparte-theme="light"` on the root is the veto.

   A duplicate on purpose (CSS cannot share one block between a media query and an
   attribute selector): `check:derived-vars` holds the two byte-identical, so
   editing one without the other fails the build rather than forking the theme.
   No `color-scheme` here — it is not namespaced, and a library setting it on the
   host's :root would flip the whole page's native controls.
   ========================================= */
@media (prefers-color-scheme: dark) {
  :root:not([data-aparte-theme="light"]) {
    --aparte-bg: #17141c;
    --aparte-surface-1: #211b28;
    --aparte-surface-2: #2a2333;
    --aparte-message-content-bg-assistant: transparent;
    --aparte-text: #f2eee7;
    --aparte-text-muted: #a89bb6;
    --aparte-neutral: #a89bb6;
    --aparte-border: #342a40;
    --aparte-primary: #d9a24b;
    --aparte-primary-hover: #e7c588;
    --aparte-scroll-btn-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
    --aparte-select-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
    --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
    --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.4);
    /* Declared once at :root it darkened a dark page by 8/255: the modal state vanished. */
    --aparte-scrim: rgba(0, 0, 0, 0.6);
    --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
    --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
    --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
    --aparte-range-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
    --aparte-ink-l: 0.85;
    --aparte-error-bg: #2d1b1b;
    --aparte-error-border: #7f1d1d;
    --aparte-error-solid: #f87171;
    --aparte-error-text: #fca5a5;
  }
}

/* =========================================
   Forced light — the veto, and the subtree escape.

   New with the system default: without it, a host that wants light-always on a
   dark OS has no lever, and a light island inside a dark page cannot exist. The
   values are the light literals from the `:root` block above, one per property
   the dark block overrides — `check:derived-vars` holds that pairing, so a light
   literal edited above cannot drift from its copy here.
   ========================================= */
[data-aparte-theme="light"] {
  color-scheme: light;
  --aparte-bg: #f6f2ea;
  --aparte-surface-1: #fffdf8;
  --aparte-surface-2: #f1ebdf;
  --aparte-message-content-bg-assistant: transparent;
  --aparte-text: #241f2b;
  --aparte-text-muted: #6d6479;
  --aparte-neutral: #6d6479;
  --aparte-border: #e5ddce;
  --aparte-primary: #b07d33;
  --aparte-primary-hover: #a06f28;
  --aparte-scroll-btn-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
  --aparte-select-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16);
  --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16);
  --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
  /* The composer laid over the transcript (overlay-composer): a thing that floats has to
     be seen floating, and its ground is the transcript's own. */
  --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
  --aparte-scrim: rgba(0, 0, 0, 0.35);
  --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
  --aparte-range-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
  --aparte-ink-l: 0.40;
  --aparte-error-bg: #fef2f2;
  --aparte-error-border: #fecaca;
  --aparte-error-solid: #dc2626;
  --aparte-error-text: #991b1b;
}


/* =========================================
   Dark Theme (App-controlled via attribute)
   Usage: <div data-aparte-theme="dark">...</div>
   ========================================= */
[data-aparte-theme="dark"] {
  color-scheme: dark;
  /* Backgrounds */
  --aparte-bg: #17141c;
  --aparte-surface-1: #211b28;
  --aparte-surface-2: #2a2333;
  /* `--aparte-surface-3`, `--aparte-text-inverse` and the user bubble's tint are not
     re-declared here: they derive from the masters (anchored layer), so the dark
     masters above are enough to move them. */

  /* Bubbles — user only; assistant is plain prose (AI-chat convention) */
  --aparte-message-content-bg-assistant: transparent;

  /* Text */
  --aparte-text: #f2eee7;
  --aparte-text-muted: #a89bb6;
  /* Equal to text-muted, as it is in the light palette. The two had drifted apart in
     dark for the only reason that this line was missing — which left the scrollbar
     thumb, and now a neutral badge, at the light value on a dark ground. */
  --aparte-neutral: #a89bb6;

  /* Borders */
  --aparte-border: #342a40;

  /* Primary */
  --aparte-primary: #d9a24b;
  --aparte-primary-hover: #e7c588;

  /* Scroll-to-bottom button */
  --aparte-scroll-btn-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
  --aparte-select-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
  --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
  --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.4);
  --aparte-scrim: rgba(0, 0, 0, 0.6);
  --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
  --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
  --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
  --aparte-range-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);

  /* Error segment */
  /* The text inks, going the other way: on a dark ground the accent gains
     contrast by getting BRIGHTER. Three keep their fill value, which already
     clears AA there; error and info do not — 4.46 and 4.56 on `--aparte-surface-1`
     and 3.60 / 3.68 on `--aparte-surface-3` — so they take the lighter tone the
     alert family already ships. */
  /* Only the target lightness flips. The five inks derive from it and from each
     accent's own hue — see the note in the light block. */
  --aparte-ink-l: 0.85;

  --aparte-error-bg: #2d1b1b;
  --aparte-error-border: #7f1d1d;
  --aparte-error-solid: #f87171;
  --aparte-error-text: #fca5a5;
}

/* =========================================
   Derived layer — re-anchored, on purpose

   Every value below READS another variable. A custom property is substituted
   where it is DECLARED, so a derived value declared only on `:root` is computed
   once against the root palette and merely inherits after that: overriding
   `--aparte-primary` on a subtree moved the send button and nothing else, and
   `[data-aparte-theme="dark"]` — which overrides eight masters — left every
   derived value on its light substitution.

   So the layer is declared again at each anchor where a palette can change:
   the document root, a shadow host, any themed subtree, any aparté host, and
   the `aparte-chat` element itself (per-instance theming). Substitution then
   re-runs there against that element's own masters.

   Only DERIVED values may live here. The literal palette stays in the block
   above with `:root, :host` alone — widening THAT list would re-declare the
   light literals on an `<aparte-chat>` nested in a dark wrapper, where a local
   declaration beats the inherited dark value, and the chat would go light.
   `scripts/check-derived-vars.mjs` holds both halves of that rule.
   ========================================= */
:root,
:host,
[data-aparte-theme],
[data-aparte-host],
aparte-chat {
  /* The five accents AS TEXT, each derived from its own fill — see the long note in
     the literal block. Anchored, not declared once on `:root`, for the reason this
     whole layer exists: an <aparte-chat> that sets its own `--aparte-primary` only
     gets a matching ink if the ink is declared on that element too. */
  --aparte-primary-ink: oklch(from var(--aparte-primary) var(--aparte-ink-l) c h);
  --aparte-info-ink: oklch(from var(--aparte-info) var(--aparte-ink-l) c h);
  --aparte-success-ink: oklch(from var(--aparte-success) var(--aparte-ink-l) c h);
  --aparte-warning-ink: oklch(from var(--aparte-warning) var(--aparte-ink-l) c h);
  --aparte-error-ink: oklch(from var(--aparte-error) var(--aparte-ink-l) c h);

  /* The scales, computed from the masters above.

     They live in the ANCHORED block and not with the literals because a value
     that reads a var is substituted where it is DECLARED: an <aparte-chat> that
     sets its own --aparte-space-unit only follows it if the steps are declared
     on that element too. `scripts/check-derived-vars.mjs` holds that rule. */
  --aparte-space-1: calc(var(--aparte-space-unit) * 1);
  --aparte-space-2: calc(var(--aparte-space-unit) * 2);
  --aparte-space-3: calc(var(--aparte-space-unit) * 3);
  --aparte-space-4: calc(var(--aparte-space-unit) * 4);
  --aparte-space-5: calc(var(--aparte-space-unit) * 5);
  --aparte-space-6: calc(var(--aparte-space-unit) * 6);
  --aparte-space-7: calc(var(--aparte-space-unit) * 7);
  --aparte-space-8: calc(var(--aparte-space-unit) * 8);

  --aparte-radius-xs: calc(var(--aparte-radius-unit) * 1);
  --aparte-radius-sm: calc(var(--aparte-radius-unit) * 2);
  --aparte-radius-md: calc(var(--aparte-radius-unit) * 3);
  --aparte-radius-lg: calc(var(--aparte-radius-unit) * 4);
  --aparte-radius-xl: calc(var(--aparte-radius-unit) * 6);

  /* rem, so the reader's browser font size is honoured; times the scale, so an
     app can size the whole chat down or up without restating six values. */
  --aparte-font-size-2xs: calc(0.625rem * var(--aparte-font-scale));
  --aparte-font-size-xs: calc(0.6875rem * var(--aparte-font-scale));
  --aparte-font-size-sm: calc(0.75rem * var(--aparte-font-scale));
  --aparte-font-size-md: calc(0.8125rem * var(--aparte-font-scale));
  --aparte-font-size-base: calc(0.9375rem * var(--aparte-font-scale));
  /* Above the body, in the order the names say. `lg` used to be 0.875rem — SMALLER than
     `base` — with nothing above it, so a title and a placeholder shared a size and a
     card's title was smaller than its body. A scale whose name lies is worse than a
     short one. */
  --aparte-font-size-lg: calc(1.0625rem * var(--aparte-font-scale));
  --aparte-font-size-xl: calc(1.25rem * var(--aparte-font-scale));
  --aparte-font-size-2xl: calc(1.5rem * var(--aparte-font-scale));

  /* Elicitation and the conversation item, on the scale.
     They were literals off every step — 0.76 / 0.78 / 0.8 / 0.82rem and a `7px 10px`
     padding written twice — so two panels sat outside the type and spacing systems
     and neither followed --aparte-font-scale. Each lands on its nearest step; the
     largest move is under half a pixel. Derived, so they live here. */
  /* The component tier, on the scales.

     These were literals whose values already WERE steps — `--aparte-message-gap` was
     12px, which is space-6 — so a master reached the scale and stopped there: a chat at
     --aparte-space-unit:3px grew its gaps and kept its message padding at 16px.

     The line drawn here, and it is a line: the spacing scale governs gutters, padding
     and margin; the radius scale governs corners; the type scale governs text. None of
     them governs a STROKE width or a CONTROL size. --aparte-thinking-rail-width stays
     2px because loosening spacing must not thicken a rule, and --aparte-avatar-size
     stays 32px because tightening it must not shrink an avatar.

     The eight text sizes were px and are rem now, like the rest of the typography:
     identical at a 16px root, and following the reader's setting elsewhere. */
  --aparte-message-gap: var(--aparte-space-6);
  /* Split, because the action bar's offset below a message is computed from the
     vertical half (#56) — a shorthand cannot be read by parts. */
  --aparte-message-padding-block: var(--aparte-space-8);
  --aparte-message-padding-inline: var(--aparte-space-6);
  --aparte-message-content-padding: var(--aparte-space-5) var(--aparte-space-7);
  --aparte-action-bar-gap: var(--aparte-space-2);
  --aparte-branch-picker-gap: var(--aparte-space-2);
  --aparte-input-gap: var(--aparte-space-4);
  --aparte-waiting-dot-gap: var(--aparte-space-2);
  --aparte-elic-gap: var(--aparte-space-3);
  --aparte-elic-padding: var(--aparte-space-3) var(--aparte-space-2);
  --aparte-elic-field-gap: var(--aparte-space-4);
  --aparte-elic-step-padding: var(--aparte-space-2) var(--aparte-space-1);
  --aparte-elic-step-gap: var(--aparte-space-7);
  --aparte-viewport-padding: var(--aparte-space-8);
  --aparte-scrollbar-thumb: var(--aparte-neutral);
  --aparte-input-padding-x: var(--aparte-space-6);
  /* The editor at rest is the control height: half of what the control size leaves once
     one line of its own text is taken out. It used to be a flat 10px, which made the editor
     44px beside 36px buttons and, the row aligning at the end, sat the send button 4px
     below the editor's centre. Derived from the editor's own line (1lh), so a coarse
     pointer's 44 and a changed font both keep the editor and its buttons on one centre. */
  --aparte-input-padding-y: calc((var(--aparte-composer-control-size) - 1lh) / 2);
  --aparte-conv-list-gap: var(--aparte-space-1);
  --aparte-conv-item-gap: var(--aparte-space-3);
  --aparte-elic-option-radius: var(--aparte-radius-md);
  --aparte-elic-input-radius: var(--aparte-radius-md);
  --aparte-tool-row-radius: var(--aparte-radius-md);
  /* The split's painted seam: the kit's rule, inside a 12px track. */
  --aparte-split-seam-width: var(--aparte-border-width);
  --aparte-name-font-size: var(--aparte-font-size-md);
  --aparte-timestamp-font-size: var(--aparte-font-size-sm);
  --aparte-branch-picker-label-size: var(--aparte-font-size-sm);
  --aparte-input-font-size: var(--aparte-font-size-base);
  --aparte-content-font-size: var(--aparte-font-size-base);
  --aparte-input-editor-font-size: var(--aparte-font-size-base);
  --aparte-status-font-size: var(--aparte-font-size-md);

  /* The button recipe's knobs. Derived, so a chat that moves --aparte-space-unit or
     --aparte-font-scale takes its buttons with it. Defaults are what the 33 control
     rules already measured agree on: a muted ghost that fills on hover. */

  /* Display primitives. Every one composed from a scale or the palette, so a chat that
     moves --aparte-space-unit or --aparte-font-scale carries them along. */
  /* A tooltip is an INVERTED surface: dark on a light theme, light on a dark one.
     Reading the text pair gives exactly that, and for free — no third palette. */
  /* The distance between a placed tooltip (`data-side`) and its anchor: the arrow's
     half-diagonal, so the tip lands on the trigger rather than 2px short of it. */
  --aparte-tooltip-gap: var(--aparte-space-3);
  --aparte-tooltip-bg: var(--aparte-text);
  --aparte-tooltip-color: var(--aparte-text-inverse);

  /* A meter is judged against a range, so its three bands take the three status
     colours rather than one accent. */
  /* Shared by both spinners the library ships — the display recipe and the
     <aparte-progress-spinner> element. Declared here rather than on one of them, so
     the other can resolve it too. */
  --aparte-spinner-size: var(--aparte-spinner-size-md);

  /* A choice row: the whole line is the target, so it carries the padding a control
     alone would not. */
  --aparte-field-choice-gap: var(--aparte-space-5);
  --aparte-field-choice-padding: var(--aparte-space-4) var(--aparte-space-5);
  --aparte-field-choice-radius: var(--aparte-radius-lg);

  --aparte-meter-track: var(--aparte-surface-3);
  --aparte-meter-optimum: var(--aparte-success);
  --aparte-meter-suboptimum: var(--aparte-warning);
  --aparte-meter-poor: var(--aparte-error);
  --aparte-output-padding: var(--aparte-space-1) var(--aparte-space-2);
  --aparte-output-bg: var(--aparte-surface-2);
  --aparte-link-color: var(--aparte-primary-ink);
  --aparte-link-color-hover: var(--aparte-primary-hover);
  --aparte-link-underline-thickness: var(--aparte-border-width);
  --aparte-link-underline-offset: var(--aparte-space-1);

  --aparte-thumbnail-bg: var(--aparte-surface-2);
  --aparte-thumbnail-border: var(--aparte-border);
  --aparte-thumbnail-radius: var(--aparte-radius-lg);

  --aparte-alert-gap: var(--aparte-space-3);
  --aparte-alert-padding: var(--aparte-space-4) var(--aparte-space-5);
  --aparte-alert-radius: var(--aparte-radius-md);
  --aparte-alert-rail-width: var(--aparte-space-1);
  /* The icon scale, in rem times the type factor — the one scale in the theme that
     was pinned in px while every type size followed `--aparte-font-scale`, so a glyph
     beside text shrank optically the moment a reader enlarged the text. The loose
     default is 16 (it was 14, and its documentation already said 16). */
  --aparte-icon-size: calc(1rem * var(--aparte-font-scale));
  --aparte-icon-size-sm: calc(0.75rem * var(--aparte-font-scale));
  --aparte-icon-size-lg: calc(1.125rem * var(--aparte-font-scale));
  --aparte-icon-size-xl: calc(1.25rem * var(--aparte-font-scale));
  --aparte-alert-icon-size: var(--aparte-space-8);
  --aparte-alert-dismiss-size: var(--aparte-btn-size-sm);
  --aparte-alert-title-font-size: var(--aparte-font-size-md);
  --aparte-alert-message-font-size: var(--aparte-font-size-sm);

  --aparte-avatar-fallback-bg: var(--aparte-surface-3);
  --aparte-avatar-fallback-color: var(--aparte-text);
  --aparte-avatar-group-ring-width: calc(var(--aparte-border-width) * 2);
  --aparte-avatar-group-ring-color: var(--aparte-surface-1);

  --aparte-badge-font-size: var(--aparte-font-size-2xs);
  --aparte-badge-gap: var(--aparte-space-1);
  --aparte-badge-padding: var(--aparte-space-1) var(--aparte-space-2);
  --aparte-badge-radius: var(--aparte-radius-full);
  --aparte-badge-min-width: var(--aparte-space-8);
  --aparte-badge-dot-size: var(--aparte-space-4);

  --aparte-tag-font-size: var(--aparte-font-size-sm);
  --aparte-tag-gap: var(--aparte-space-2);
  --aparte-tag-padding: var(--aparte-space-1) var(--aparte-space-3);
  --aparte-tag-radius: var(--aparte-radius-sm);
  --aparte-tag-remove-size: var(--aparte-space-6);
  --aparte-tag-remove-icon-size: var(--aparte-space-5);

  --aparte-card-radius: var(--aparte-radius-xl);
  --aparte-card-header-padding: var(--aparte-space-4) var(--aparte-space-5);
  --aparte-card-body-padding: var(--aparte-space-5);
  --aparte-card-footer-padding: var(--aparte-space-4) var(--aparte-space-5);
  --aparte-card-header-font-size: var(--aparte-font-size-base);

  --aparte-divider-margin: var(--aparte-space-5);
  --aparte-divider-label-gap: var(--aparte-space-4);
  --aparte-divider-label-font-size: var(--aparte-font-size-sm);

  --aparte-kbd-bg: var(--aparte-surface-2);
  --aparte-kbd-radius: var(--aparte-radius-sm);
  --aparte-kbd-padding: var(--aparte-space-1) var(--aparte-space-2);
  --aparte-kbd-font-size: var(--aparte-font-size-2xs);
  --aparte-kbd-min-width: var(--aparte-space-8);
  --aparte-kbd-border-bottom-width: var(--aparte-space-1);

  --aparte-progress-height: var(--aparte-space-2);
  --aparte-progress-fill: var(--aparte-primary);

  /* The ground of a gauge or a placeholder — the thing a value is read against, so it
     has to be seen. Derived RELATIVE to the page, not a surface level: a level sat at
     two different distances from the ground in light and dark. */
  --aparte-track: color-mix(in srgb, var(--aparte-text) 8%, var(--aparte-bg));
  --aparte-skeleton-base: var(--aparte-track);
  /* The rail is as wide as its widest tick (the current one is 1.6× the resting size);
     it used to be a 20px literal clipping a 22.4px tick. The floor is one tick's
     pressable zone: the rail clips, so a column narrower than the zone would cut the
     target back below 24px on the very edge a reader aims at. */
  --aparte-scroll-rail-width: max(var(--aparte-scroll-rail-hit-size), calc(var(--aparte-scroll-rail-tick-size) * 1.6));
  /* The distance between two ticks IS the zone minus the line: gap + thickness = the
     24px pitch, so the zones tile edge to edge. Growing the zone alone would have
     overlapped them by 14px and left the z-order to decide every press. */
  --aparte-scroll-rail-gap: calc(var(--aparte-scroll-rail-hit-size) - var(--aparte-scroll-rail-tick-thickness));
  --aparte-skeleton-shine: var(--aparte-surface-3);
  /* The line it stands for: as tall as the content type, and a gap that is the rest of
     the content line, so the text that replaces it lands where it was (T21). It was 12px
     on an 18px step for a 16px line on a 27px step — a third of a line of jump per line. */
  --aparte-skeleton-text-height: var(--aparte-content-font-size);
  --aparte-skeleton-text-gap: calc(var(--aparte-content-font-size) * (var(--aparte-content-line-height) - 1));
  --aparte-skeleton-circle-size: var(--aparte-avatar-size);

  --aparte-spinner-thickness: var(--aparte-space-1);


  /* The field family's knobs, beside the button's — they used to be declared on
     `.aparte-field` itself, which is why `.aparte-field-group` (their PARENT) and
     `.aparte-color` (a sibling recipe) read a property that did not exist there and
     rendered SQUARE: a custom property is substituted where it is declared and only
     inherits downwards. Same reason they belong here rather than there: the theming
     guide sends a consumer to `:root`, and every other family's knobs are here.
     The *channels* stay local on purpose (`--aparte-checkbox-intent` and friends are
     filled by a modifier, not set by a theme). */
  --aparte-field-padding: var(--aparte-space-2) var(--aparte-space-3);
  --aparte-field-padding-sm: var(--aparte-space-1) var(--aparte-space-2);
  --aparte-field-padding-lg: var(--aparte-space-3) var(--aparte-space-4);
  --aparte-field-radius: var(--aparte-radius-md);
  /* The field's resting height is the lg step of the control scale, one step above the
     default button and level with the composer's controls; `--sm` and `--lg` sit one
     step either side. It used to be padding plus line-height, ~29px, the second
     tallest thing in a row of five different heights. */
  --aparte-field-size: var(--aparte-btn-size-lg);
  --aparte-field-size-sm: var(--aparte-btn-size-md);
  --aparte-field-size-lg: var(--aparte-btn-size-xl);
  /* The 36px family, on the scale: named, not literal. */
  --aparte-send-btn-size: var(--aparte-btn-size-lg);
  --aparte-input-action-btn-size: var(--aparte-btn-size-lg);
  --aparte-scroll-btn-size: var(--aparte-btn-size-lg);

  --aparte-btn-gap: var(--aparte-space-2);
  --aparte-btn-padding: var(--aparte-space-2) var(--aparte-space-3);
  --aparte-btn-radius: var(--aparte-radius-md);
  /* Control text is the md step (14px). One step lower was the origin of the "poor"
     look: 13px labels in 32px boxes, beside 15px prose. */
  --aparte-btn-font-size: var(--aparte-font-size-md);
  --aparte-btn-bg-hover: var(--aparte-surface-2);
  --aparte-btn-bg-active: var(--aparte-surface-3);
  --aparte-btn-bg-toggled: var(--aparte-surface-2);
  --aparte-btn-color-toggled: var(--aparte-text);
  --aparte-btn-size: var(--aparte-btn-size-md);
  --aparte-btn-ink: var(--aparte-on-intent);
  --aparte-btn-icon-size: var(--aparte-icon-size-lg);
  --aparte-btn-padding-sm: var(--aparte-space-1) var(--aparte-space-2);
  --aparte-btn-padding-lg: var(--aparte-space-3) var(--aparte-space-5);

  /* One knob for the composer's control set — the send button, the input's action
     buttons, the editor's single-line height: the lg step at rest, the touch target
     under a coarse pointer (responsive.css). It used to be undeclared, with `44px`
     written as a fallback at every reader, so a desktop composer was a 62px bar around
     15px of text and the coarse-pointer block had nothing left to enlarge. */
  --aparte-composer-control-size: var(--aparte-btn-size-lg);
  --aparte-input-container-min-height: var(--aparte-touch-target-size);
  --aparte-elic-message-size: var(--aparte-font-size-base);
  --aparte-elic-title-size: var(--aparte-font-size-base);
  --aparte-elic-desc-size: var(--aparte-font-size-sm);
  --aparte-elic-option-title-size: var(--aparte-font-size-md);
  --aparte-elic-option-desc-size: var(--aparte-font-size-sm);
  --aparte-elic-skip-size: var(--aparte-font-size-md);
  --aparte-elic-step-size: var(--aparte-font-size-sm);
  --aparte-elic-option-padding: var(--aparte-space-4) var(--aparte-space-5);
  --aparte-conv-item-padding: var(--aparte-space-4) var(--aparte-space-5);
  --aparte-conv-item-font-size: var(--aparte-font-size-md);

  /* Borders */
  --aparte-border-focus: var(--aparte-primary);
  /* THE focus ring: a 2px outline in this colour, one spacing step OUTSIDE the box, on
     every control. It used to be drawn two ways — this outline on seventeen recipes and a
     soft box-shadow wash (30 % of the accent, 1.39:1 against the page) on five — and the
     offset was −2px, inside the box, so on a bordered row the ring sat as a second
     concentric line and the next row painted over its edge. */
  --aparte-focus-outline-offset: var(--aparte-space-1);
  /* The edge of a CONTROL — a field, a choice box, the select's trigger. `--aparte-border`
     separates two regions and is right to be faint; a thing someone can touch has to be
     seen, and six previews showed a control with no visible edge. Derived from the ink
     and the ground so both schemes follow; the mix is a first setting. */
  --aparte-border-control: color-mix(in srgb, var(--aparte-text) 30%, var(--aparte-surface-1));

  /* Component-specific radius (overridable per app) */
  --aparte-radius-input: var(--aparte-radius-lg);
  --aparte-radius-input-footer: var(--aparte-radius-lg);
  --aparte-radius-send-btn: var(--aparte-radius-md);
  --aparte-radius-action-btn: var(--aparte-radius-sm);
  --aparte-radius-select: var(--aparte-radius-md);
  --aparte-radius-bubble: var(--aparte-radius-lg);
  /* The bubble's corner IS the bubble radius above: it was a 14px literal off the scale,
     the most visible corner of the transcript, and the knob named for it drove nothing. */
  --aparte-message-content-radius: var(--aparte-radius-bubble);
  --aparte-radius-code: var(--aparte-radius-lg);
  --aparte-radius-thinking: var(--aparte-radius-lg);
  --aparte-radius-error: var(--aparte-radius-md);
  /* The floating surfaces. They read a step of the scale directly, so a theme that
     wanted square menus and round bubbles had to move the step — and every other
     reader of it. */
  --aparte-radius-menu: var(--aparte-radius-lg);
  --aparte-radius-popover: var(--aparte-radius-lg);
  --aparte-radius-dialog: var(--aparte-radius-xl);
  --aparte-radius-tooltip: var(--aparte-radius-sm);

  /* The error ring on an avatar. A full shadow value, like
     --aparte-scroll-btn-shadow: the whole ring is the knob, not its width. */
  --aparte-avatar-error-ring: 0 0 0 2px var(--aparte-error-solid);

  /* Messages */
  --aparte-message-content-text-user: var(--aparte-text);
  --aparte-message-content-text-assistant: var(--aparte-text);
  /* The user's bubble: a wash of the accent over the card surface. It was a literal
     (`#efe7f6` light / `#2f2740` dark) — the one palette colour a consumer's eight-line
     rebrand could not reach, so a chat moved to a blue brand kept a plum bubble. Mixed
     in sRGB rather than derived with `oklch(from …)`, because a wash has to keep the
     SURFACE's lightness, not the accent's; 12 % is where the default palette reads as a
     tint, not a fill, on both grounds. */
  /* The mark's own tint: at 12 % the user bubble — the one mark that says "this turn is
     yours" — sat at 1.03:1 on the light page. */
  --aparte-message-content-bg-user: color-mix(in srgb, var(--aparte-primary) var(--aparte-mark-tint), var(--aparte-surface-1));
  /* The third surface: the second, pulled 6 % toward the text. Close to what the old
     literals encoded (`#e7dfce` from `#f1ebdf` light, `#342a40` from `#2a2333` dark —
     5 to 9 % per channel, not one figure), now one rule that follows the masters. */
  --aparte-surface-3: color-mix(in srgb, var(--aparte-text) 6%, var(--aparte-surface-2));
  /* Text on the text colour used as a fill (the tooltip, the avatar). The card
     surface is the lightest tone in light and the darkest-but-one in dark, which is
     what the two literals were. */
  --aparte-text-inverse: var(--aparte-surface-1);
  --aparte-surface:         var(--aparte-surface-1);
  --aparte-surface-hover:   var(--aparte-surface-2);
  --aparte-text-secondary:  var(--aparte-text-muted);
  --aparte-accent:          var(--aparte-primary);
  --aparte-success-border:  var(--aparte-success);
  --aparte-success-surface: color-mix(in oklch, var(--aparte-success) 12%, transparent);
  --aparte-error-border-soft: var(--aparte-error);
  --aparte-error-surface:   color-mix(in oklch, var(--aparte-error) 12%, transparent);

  /* Attachments */
  --aparte-attachment-chip-bg: var(--aparte-surface-2);
  --aparte-attachment-chip-border: var(--aparte-border);
  --aparte-attachment-chip-radius: var(--aparte-radius-sm);
  --aparte-attachment-chip-color: var(--aparte-text);

  /* Attachment thumbnail tile (composer preview + sent-message strip) */
  --aparte-thumb-radius: var(--aparte-radius-lg);

  /* Action Bar */
  /* The small controls of a turn and of a conversation row are the recipe's small step
     (24px, the WCAG 2.5.8 floor). The action bar used to feed 28 with a 24 exception for
     the last user turn, the branch arrows redrew 20 over their own `--sm`, the row's
     `⋯` redrew 20: three control heights in one 14px row. */
  --aparte-action-bar-btn-size: var(--aparte-btn-size-sm);
  --aparte-conv-action-btn-size: var(--aparte-btn-size-sm);
  --aparte-action-bar-btn-color: var(--aparte-text-muted);
  --aparte-action-bar-btn-hover-bg: var(--aparte-surface-2);
  --aparte-action-bar-btn-hover-color: var(--aparte-text);

  /* Branch Picker */
  --aparte-branch-picker-btn-color: var(--aparte-text-muted);
  --aparte-branch-picker-btn-hover-color: var(--aparte-text);
  --aparte-branch-picker-label-color: var(--aparte-text-muted);

  /* Avatars */
  --aparte-avatar-bg-user: var(--aparte-primary);
  --aparte-avatar-text-user: var(--aparte-text-inverse);
  --aparte-avatar-bg-assistant: var(--aparte-surface-3);
  --aparte-avatar-text-assistant: var(--aparte-text);

  /* Text Sizes */
  --aparte-name-color: var(--aparte-text);
  --aparte-timestamp-color: var(--aparte-text-muted);
  --aparte-content-color: var(--aparte-text);

  /* Input */
  --aparte-input-bg: var(--aparte-surface-1);
  --aparte-input-border: var(--aparte-border);
  --aparte-input-text: var(--aparte-text);
  --aparte-input-placeholder: var(--aparte-text-muted);
  --aparte-input-focus-border: var(--aparte-primary);

  /* Status Indicator */
  --aparte-status-color: var(--aparte-text-muted);

  /* Thinking Segment */
  --aparte-thinking-border: var(--aparte-border);
  --aparte-thinking-bg: var(--aparte-surface-2);
  --aparte-thinking-text: var(--aparte-text-muted);
  --aparte-thinking-content: var(--aparte-text);
  --aparte-thinking-content-bg: var(--aparte-surface-1);

  /* Code Blocks */
  --aparte-code-bg: var(--aparte-surface-1);
  --aparte-code-header-bg: var(--aparte-surface-2);
  --aparte-code-border: var(--aparte-border);
  --aparte-code-header-text: var(--aparte-text-muted);
  /* The block's own type. It used to inherit the prose's size and leading (16px on 1.7)
     under the browser's generic monospace — the largest, airiest text in the message. */
  --aparte-code-block-font-size: var(--aparte-font-size-md);
  --aparte-code-block-line-height: var(--aparte-line-height-normal);

  /* Scroll-to-bottom button */

  /* Prose (aparte-segment-content) */
  --aparte-prose-blockquote-border: var(--aparte-border);
  --aparte-prose-code-bg: var(--aparte-surface-2);

  /* Viewport */
  --aparte-chat-bottom-gap: var(--aparte-space-8);

  /* Content */
  --aparte-content-line-height: var(--aparte-line-height-loose);
  --aparte-cursor-color: var(--aparte-primary);

  /* Input (composer) */
  --aparte-input-line-height: var(--aparte-line-height-normal);

  /* Shell */
  --aparte-sidebar-bg: var(--aparte-surface-2);
  /* The one inline inset of the column's four regions — header, search, body, footer.
     They used to carry three (16 / 12 / 12 / 16) and the content sat on two axes. */
  --aparte-sidebar-inset: var(--aparte-space-3);

  /* Conversation List */
  --aparte-conv-item-radius: var(--aparte-radius-md);
  --aparte-conv-item-color: var(--aparte-text-muted);
  --aparte-conv-item-color-active: var(--aparte-text);
  --aparte-conv-item-bg-hover: var(--aparte-surface-3);
  --aparte-conv-item-bg-active: var(--aparte-surface-3);
  --aparte-conv-item-font-weight-active: var(--aparte-font-weight-medium);
}

/*
 * aparté — Shared odds and ends — the screen-reader helper, the dot, the spacers.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 */
/* ─── Bottom spacer ───────────────────────────────────────────────────────── */

.aparte-bottom-spacer {
  flex-shrink: 0;
  height: 0px;
}

.aparte-header {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-4);
  margin-bottom: var(--aparte-space-3);
}

/* =========================================
   Action Bar
   ========================================= */

.aparte-footer {
  display: flex;
  align-items: center;
  gap: var(--aparte-action-bar-gap);
  margin-top: var(--aparte-space-3);
  min-height: var(--aparte-action-bar-btn-size);
}

/* Action slots */
.aparte-actions-left,
.aparte-actions-right {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-2);
  flex-shrink: 0;
}

.aparte-dots {
  display: flex;
  align-items: center;
}

/* Screen-reader-only text. Used by the bubble's waiting indicator, whose dots are
   decorative: the state is conveyed by aria-busy + this localized label. */
.aparte-sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.aparte-dot {
  width: var(--aparte-status-dot-size);
  height: var(--aparte-status-dot-size);
  background: var(--aparte-status-color);
  border-radius: 50%;
  animation: aparte-pulse var(--aparte-duration-pulse) infinite ease-in-out;
}

/* Under reduced motion the loop stops outright. The duration tokens are reset to
   0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
   is not stillness, it is a flicker. The descendant sweep there does not reach a
   recipe used outside aparté's own elements, so each looping recipe stops itself,
   the way display/icon.css already does. */
@media (prefers-reduced-motion: reduce) {
  .aparte-dot { animation: none; }
}

/* `hidden` means hidden. 71 recipes in these sheets set `display` on their base class,
   and an author declaration beats the browser's own `[hidden] { display: none }` — so a
   hidden `.aparte-btn` was out of the accessibility tree and still painted: the copy
   button of a tool-only turn stood there with `hidden` on it. Scoped to our own classes
   (a prefix match, not a bare `[hidden]`, so a host's markup is not touched), and
   `!important` because the whole point is to outrank the recipe's `display`. The
   handful of per-element `[hidden]` rules that predate this stay; they are now
   redundant, not wrong. */
[class^="aparte-"][hidden],
[class*=" aparte-"][hidden] {
  display: none !important;
}

/* =========================================
   aparte-conversation-list
   ========================================= */

aparte-conversation-list {
  display: flex;
  flex-direction: column;
  gap: var(--aparte-conv-list-gap);
  overflow-y: auto;
  overflow-x: hidden;
}

/* ── Link ────────────────────────────────────────────────────────────────────
   A standalone link. Prose already styles the links a model writes; this is the one
   an application places itself, in a toolbar or an empty state, where no prose
   context applies.

   The underline is offset and thinned rather than removed: an underline is the only
   affordance a link has that survives both colour blindness and forced-colors, so
   dropping it on a text link removes the only cue some readers get. */
.aparte-link {
  color: var(--aparte-link-color);
  text-decoration: underline;
  text-decoration-thickness: var(--aparte-link-underline-thickness);
  text-underline-offset: var(--aparte-link-underline-offset);
  border-radius: var(--aparte-radius-xs);
  transition: color var(--aparte-duration-fast) var(--aparte-ease);
}
.aparte-link:hover { color: var(--aparte-link-color-hover); }
.aparte-link:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}
/* Quiet — for a link inside a dense row, where a coloured one would shout. It keeps
   the underline, which is the part that carries the meaning. */
.aparte-link--muted { color: var(--aparte-text-muted); }
.aparte-link--muted:hover { color: var(--aparte-text); }

/* ─── The library's shared motion ─────────────────────────────────────────────
   Two keyframes for everything that moves on its own, declared HERE because this is
   where the first of them is used and because a keyframe that lives far from its
   consumer is a keyframe that gets re-declared: there were three identical rotations
   under three names (`aparte-spinner-spin`, `aparte-spin`, `aparte-icon-spin`) and a
   fourth, `tool-spin`, that nothing used at all — and which, being unprefixed, would
   have shadowed a rule of the same name on the consumer's own page.

   `aparte-spinner-rotate` in the progress spinner is deliberately NOT folded in: it
   starts at -90deg because an SVG arc's zero is at three o'clock, so it is a different
   curve, not a differently-named copy of this one. */

/* One pulse for every dot the library shows. There were three keyframes doing the
   same thing at slightly different rhythms; a travelling wave, where a row wants one,
   comes from an animation-delay on its later dots, not from a skewed curve. */
@keyframes aparte-pulse {
  /* Opacity alone: the scale that used to ride along made a row of waiting dots change
     width in a loop. And a floor above the perception threshold — at 0.3 the status
     dot spent half of every cycle at 1.55:1, and a capture froze it there. */
  0%, 100% { opacity: var(--aparte-pulse-floor); }
  50%      { opacity: 1; }
}

/* One rotation, for every spinner and every spinning glyph. */
@keyframes aparte-spin {
  to { transform: rotate(360deg); }
}
/*
 * aparté — the button. Ready-made classes; nothing to configure.
 *
 * TWO AXES, and keeping them apart is the whole design. An INTENT says which colour
 * the button means; a FILL says what to do with that colour. Seven intents times five
 * fills is thirty-five buttons out of twelve classes, and every combination works
 * because neither axis knows about the other.
 *
 *     <button class="aparte-btn aparte-btn--primary aparte-btn--solid">Send</button>
 *     <button class="aparte-btn aparte-btn--danger aparte-btn--outline">Delete</button>
 *     <button class="aparte-btn aparte-btn--icon" aria-label="Copy">
 *       <aparte-icon name="copy"></aparte-icon>
 *     </button>
 *     <div class="aparte-btn-group" role="group" aria-label="Versions">
 *       <button class="aparte-btn aparte-btn--surface" aria-label="Previous"><aparte-icon name="prevBranch"></aparte-icon></button>
 *       <button class="aparte-btn aparte-btn--surface">1 / 2</button>
 *       <button class="aparte-btn aparte-btn--surface" aria-label="Next"><aparte-icon name="nextBranch"></aparte-icon></button>
 *     </div>
 *     <div class="aparte-btn-group" role="group" aria-label="View">
 *       <button class="aparte-btn aparte-btn--primary aparte-btn--outline" aria-pressed="true">Chat</button>
 *       <button class="aparte-btn aparte-btn--primary aparte-btn--outline" aria-pressed="false">Preview</button>
 *     </div>
 *
 * An intent on its own is a ghost — the quietest form, and the one a chat uses most.
 * Add a fill when the button has to carry more weight.
 *
 * `.aparte-btn-group` fuses adjacent buttons into ONE control: the inner corners
 * drop, the shared borders overlap, and only the group's ends keep the radius. Give
 * the wrapper `role="group"` and a name — visually one control, it should read as
 * one too. A SEGMENTED control is the same group with one segment selected: mark it
 * `aria-pressed="true"` (or `aria-selected="true"` under `role="tablist"`) and it is
 * painted solid in the group's intent, at rest — not only under the pointer.
 *
 * This layer is NEUTRAL: it is what a UI library offers, not a summary of what this
 * repo happens to use. A variant nothing wears still ships, because the plugin that
 * needs it is not written yet, and its absence is what makes an author invent a
 * seventh shade of orange.
 *
 * Your own class stays on the element, for events and for targeting. It just stops
 * carrying the look.
 */

.aparte-btn {
  /* The glyph reads this: an `<aparte-icon>` (or a raw `svg.aparte-icon`) inside a button
     takes the button's icon size, per size modifier. */
  --aparte-icon-size: var(--aparte-btn-icon-size);
  /* The box, for EVERY button: `--icon` and `--circle` used to be the only readers of
     the size, so a text button was padding plus line-height — 23px beside a 32px icon
     button in the same row. The size is declared here and read below as a minimum. */
  --aparte-btn-size: var(--aparte-btn-size-md);
  /* The intent, as a value. Every fill below reads THIS and nothing else, which is
     what keeps the two axes independent. */
  --aparte-btn-intent: var(--aparte-neutral);
  /* The fallback for a browser without relative colour syntax. The DEFAULT path is
     the `@supports` block below, which derives this from the fill instead. */
  --aparte-btn-on-intent: var(--aparte-btn-ink);
  /* The derived ink, as a named value so each intent can offer it as the fallback
     behind the theme's own `--aparte-on-*`. Declared here too, un-derived, because a
     `var()` whose target is undefined outside `@supports` would take the whole chain
     down with it. */
  --aparte-derived-ink: var(--aparte-on-intent);
  /* The intent as TEXT. Ghost, outline and soft paint the label with the intent on
     the PAGE background, which is the case the note below never measured — and on
     the light theme every one of them came out under AA. Defaults to the fill, so
     a caller setting only `--aparte-btn-intent` keeps working. */
  --aparte-btn-intent-ink: var(--aparte-btn-intent);

  display: inline-flex;

  vertical-align: middle;
  align-items: center;
  justify-content: center;
  gap: var(--aparte-btn-gap);
  flex-shrink: 0;
  cursor: pointer;
  /* The recipe declares both a border and, for `--icon`/`--circle`, a size — so the two
     have to mean the same box. Without this a 44px icon button measures 46. It used to
     be set by the composer's own row rule, which is exactly the kind of thing a recipe
     should not need a caller for. */
  box-sizing: border-box;
  min-block-size: var(--aparte-btn-size);
  padding: var(--aparte-btn-padding);
  border: var(--aparte-border-width) solid transparent;
  border-radius: var(--aparte-btn-radius);
  font: inherit;
  font-size: var(--aparte-btn-font-size);
  line-height: var(--aparte-line-height-none);
  /* The native reset. Without `appearance` Safari and iOS paint their own chrome over
     this one; without `touch-action` a tap costs 300ms; without `user-select` a double
     click selects the label instead of firing twice. */
  appearance: none;
  -webkit-appearance: none;
  user-select: none;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  white-space: nowrap;
  transition:
    background var(--aparte-duration-fast) var(--aparte-ease),
    border-color var(--aparte-duration-fast) var(--aparte-ease),
    color var(--aparte-duration-fast) var(--aparte-ease),
    opacity var(--aparte-duration-fast) var(--aparte-ease);

  /* Default fill: ghost. */
  background: transparent;
  color: var(--aparte-btn-intent-ink);
}

/* ── The ink on a solid fill is DERIVED from that fill, not written down ────────
   This is the one rule in the file that exists because aparté is a LIBRARY.

   It used to be a constant: `--aparte-btn-on-intent` resolved through
   `--aparte-btn-ink` to `--aparte-on-intent: #14100a`, a hex chosen by measuring
   contrast against THIS repo's own five intent fills. That made core's rendering
   depend on core's palette. A consumer following our own theming guide — which
   teaches an eight-line rebrand and a one-attribute
   `<aparte-chat style="--aparte-primary: #16a34a">`, and names neither token — got
   near-black painted on their own fill with no signal. Measured: 1.11:1 on a navy
   `#1a1a2e`, 1.83:1 on slate `#334155`.

   Deriving it is not new art. Bootstrap has had `color-contrast()` for years and
   Material's `on-*` tokens are generated per palette; this repo had borrowed
   Material's NAME and backed it with a constant.

   How it reads: keep the fill's hue, drop its chroma to a trace, and set lightness to
   either `--aparte-ink-dark` or 1 depending on which side of `--aparte-ink-flip` the
   fill's own lightness falls. `clamp()` is the branch, because CSS has no `if`.

   The dark end is 0.176, not 0, and that is load-bearing: at a lightness of zero OKLCH
   ignores chroma, so the ink resolved to a flat `#000000` on every fill and the hue
   this expression carries was discarded. The first version of this rule shipped that
   while its comment promised a warm black — caught by measuring the computed colour
   rather than re-reading the code. 0.176 is the lightness of `#14100a`, the ink that
   was hand-picked here, so brass keeps the warm near-black it had and a blue fill gets
   a cool one, each derived from its own hue.

   Measured in a browser against all nine fills this palette declares, the derivation
   matches or beats the constant on every one, and needs no per-intent exception:
     primary 5.83 light / 9.22 dark · secondary 6.93 · neutral 5.60 light / 8.03 dark
     info 5.71 · success 8.28 · warning 9.78 · error 5.58
   `neutral` is the one that used to need a hardcoded white: it now gets white for free
   on the light fill and correctly loses it on the dark one, where the forced white
   read 2.62.

   `@supports`, not a bare second declaration, because a custom property does NOT fall
   back on an unparsable value the way a real property does — it is stored as tokens
   and fails later, at use. Outside this block the old constant stands, so a browser
   without relative colour syntax (Firefox before 128) keeps today's rendering. A
   consumer setting `--aparte-btn-on-intent` still wins: same specificity, later sheet. */
@supports (color: oklch(from red l c h)) {
  .aparte-btn {
    --aparte-derived-ink: oklch(
      from var(--aparte-btn-intent)
      clamp(var(--aparte-ink-dark), (var(--aparte-ink-flip) - l) * 1000, 1)
      0.02
      h
    );
  }
}

/* ── Axis 1 — INTENT. Sets a colour; says nothing about how it is used. ─────────
   Ink is the default text colour on a solid fill, measured rather than assumed: on
   every intent this palette declares, ink beats white — warning 7.49 against 2.15,
   success 6.34 against 2.54 — except neutral, which is dark enough to want white.
   Three of them reach neither 4.5 with either colour (primary 4.46, info 4.37,
   danger 4.27); that is the palette's mid-luminance, not this file's doing. */
.aparte-btn--primary   { --aparte-btn-intent: var(--aparte-primary); --aparte-btn-on-intent: var(--aparte-on-primary, var(--aparte-derived-ink)); --aparte-btn-intent-ink: var(--aparte-primary-ink); }
.aparte-btn--secondary { --aparte-btn-intent: var(--aparte-secondary); --aparte-btn-on-intent: var(--aparte-on-secondary, var(--aparte-derived-ink)); }
/* `--neutral` no longer forces an ink. It used to set `--aparte-on-primary` (white),
   which was right on the light fill `#6d6479` (5.60) and WRONG on the dark one
   `#a89bb6` (2.62) — because the fill flips with the theme and the forced ink did not.
   The derivation below flips with it: 5.60 light, 8.03 dark. Same deletion in
   `display/badge.css` and `field.css`, which had copied this line. */
.aparte-btn--neutral   { --aparte-btn-intent: var(--aparte-neutral); --aparte-btn-on-intent: var(--aparte-on-neutral, var(--aparte-derived-ink)); }
.aparte-btn--info      { --aparte-btn-intent: var(--aparte-info); --aparte-btn-on-intent: var(--aparte-on-info, var(--aparte-derived-ink)); --aparte-btn-intent-ink: var(--aparte-info-ink); }
.aparte-btn--success   { --aparte-btn-intent: var(--aparte-success); --aparte-btn-on-intent: var(--aparte-on-success, var(--aparte-derived-ink)); --aparte-btn-intent-ink: var(--aparte-success-ink); }
.aparte-btn--warning   { --aparte-btn-intent: var(--aparte-warning); --aparte-btn-on-intent: var(--aparte-on-warning, var(--aparte-derived-ink)); --aparte-btn-intent-ink: var(--aparte-warning-ink); }
.aparte-btn--danger    { --aparte-btn-intent: var(--aparte-error); --aparte-btn-on-intent: var(--aparte-on-error, var(--aparte-derived-ink)); --aparte-btn-intent-ink: var(--aparte-error-ink); }

/* ── Axis 2 — FILL. Reads the intent; says nothing about which one. ───────────── */

/* Ghost is the default and needs no class. Named anyway, so a button that must be a
   ghost can say so rather than merely omit. */
.aparte-btn--ghost {
  background: transparent;
  border-color: transparent;
  color: var(--aparte-btn-intent-ink);
}

/* Solid — the action of its row. */
.aparte-btn--solid {
  background: var(--aparte-btn-intent);
  border-color: var(--aparte-btn-intent);
  color: var(--aparte-btn-on-intent);
}

/* Outline — the weight of solid, half the ink. */
/* The BORDER keeps the fill colour: a border is not text, and 3:1 is the bar it has
   to clear. Only the label moves to the ink. */
.aparte-btn--outline {
  background: transparent;
  border-color: var(--aparte-btn-intent);
  color: var(--aparte-btn-intent-ink);
}

/* Soft — a wash of the intent. Between ghost and solid, and the one that reads best
   in a dense transcript. */
.aparte-btn--soft {
  background: color-mix(in srgb, var(--aparte-btn-intent) 14%, transparent);
  border-color: transparent;
  color: var(--aparte-btn-intent-ink);
}

/* Surface — the one fill that ignores the intent, on purpose: it is not a colour, it
   is a raised plane. For a button that floats ON the transcript rather than in a row. */
.aparte-btn--surface {
  background: var(--aparte-surface-1);
  border-color: var(--aparte-border);
  color: var(--aparte-text);
}

/* ── States ──────────────────────────────────────────────────────────────────── */

.aparte-btn:hover:not(:disabled) { background: var(--aparte-btn-bg-hover); }
.aparte-btn--outline:hover:not(:disabled),
.aparte-btn--soft:hover:not(:disabled) { background: color-mix(in srgb, var(--aparte-btn-intent) 22%, transparent); }
.aparte-btn--solid:hover:not(:disabled) { background: var(--aparte-btn-intent); filter: brightness(1.07); }
.aparte-btn--surface:hover:not(:disabled) { background: var(--aparte-surface-2); }

/* Pressed. There was no `:active` anywhere in this library, which is what makes a
   button feel broken: it takes the click and never acknowledges it. */
.aparte-btn:active:not(:disabled) { background: var(--aparte-btn-bg-active); }
.aparte-btn--outline:active:not(:disabled),
.aparte-btn--soft:active:not(:disabled) { background: color-mix(in srgb, var(--aparte-btn-intent) 30%, transparent); }
.aparte-btn--solid:active:not(:disabled) { background: var(--aparte-btn-intent); filter: brightness(0.93); }

/* Drawn, not faded. Opacity on the whole button faded the glyph with its ground and
   read at 2:1 on the send button; "inactive" and "disappearing" are not the same
   message. A neutral ground, the muted ink, no intent — whatever the fill was. */
.aparte-btn:disabled,
.aparte-btn[aria-disabled='true'] {
  cursor: not-allowed;
  background: var(--aparte-surface-2);
  color: var(--aparte-text-muted);
  border-color: transparent;
  filter: none;
}

.aparte-btn:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

/* Toggled — a disclosure that is open, a toggle that is on, a tab that is current. */
.aparte-btn[aria-expanded='true'],
.aparte-btn[aria-pressed='true'],
.aparte-btn[aria-selected='true'] {
  background: var(--aparte-btn-bg-toggled);
  color: var(--aparte-btn-color-toggled);
}
/* On a coloured fill the toggled wash must be DEEPER than the hover wash (22 %), or the
   pointer reads as the state and the state reads as nothing — measured on a dark
   `Chat | Preview` group, where `--aparte-btn-bg-toggled` (surface-2) sits a hair from
   surface-1 and the selected segment showed as an empty outline until hovered (#53). */
.aparte-btn--outline[aria-pressed='true'],
.aparte-btn--soft[aria-pressed='true'],
.aparte-btn--outline[aria-selected='true'],
.aparte-btn--soft[aria-selected='true'] {
  background: color-mix(in srgb, var(--aparte-btn-intent) 30%, transparent);
}

/* Busy is NOT disabled: it stays focusable, and the app is what swaps the icon for a
   spinner. Only the cursor changes here. */
.aparte-btn[aria-busy='true'] { cursor: progress; }

/* ── Shape and size ──────────────────────────────────────────────────────────── */

.aparte-btn--icon { padding: 0; width: var(--aparte-btn-size); height: var(--aparte-btn-size); }
.aparte-btn--pill { border-radius: var(--aparte-radius-full); }
.aparte-btn--circle { padding: 0; width: var(--aparte-btn-size); height: var(--aparte-btn-size); border-radius: var(--aparte-radius-full); }
.aparte-btn--block { display: flex; width: 100%; }

.aparte-btn--sm { --aparte-btn-size: var(--aparte-btn-size-sm); --aparte-btn-icon-size: var(--aparte-icon-size-sm); font-size: var(--aparte-font-size-sm); padding: var(--aparte-btn-padding-sm); }
.aparte-btn--lg { --aparte-btn-size: var(--aparte-btn-size-lg); --aparte-btn-icon-size: var(--aparte-icon-size-xl); font-size: var(--aparte-font-size-base); padding: var(--aparte-btn-padding-lg); }
.aparte-btn--xl { --aparte-btn-size: var(--aparte-btn-size-xl); --aparte-btn-icon-size: var(--aparte-icon-size-xl); font-size: var(--aparte-font-size-base); padding: var(--aparte-btn-padding-lg); }
.aparte-btn--sm.aparte-btn--icon,
.aparte-btn--sm.aparte-btn--circle,
.aparte-btn--lg.aparte-btn--icon,
.aparte-btn--lg.aparte-btn--circle,
.aparte-btn--xl.aparte-btn--icon,
.aparte-btn--xl.aparte-btn--circle { padding: 0; }

/* An icon inside a button never takes the pointer: the click belongs to the button,
   and an SVG target makes `event.target` lie to a delegated listener. */
.aparte-btn > svg,
.aparte-btn > aparte-icon,
.aparte-btn > .aparte-btn__icon {
  pointer-events: none;
  flex-shrink: 0;
}

/* A raw SVG or a consumer's own icon box is sized with the button so the two axes stay
   in step. An `<aparte-icon>` is NOT sized here: it renders its `<svg>` inside itself, a
   grandchild this child combinator never reached, so the documented markup kept the
   glyph at its loose 14px default in a button that asked for 16. The recipe feeds the
   icon's own knob instead — `--aparte-icon-size`, declared on `.aparte-btn` above — and
   `display/icon.css` draws it, at `--sm`'s 12 and `--lg`'s 20 like everything else. */
.aparte-btn > svg:not(.aparte-icon),
.aparte-btn > .aparte-btn__icon {
  width: var(--aparte-btn-icon-size);
  height: var(--aparte-btn-icon-size);
}

/* ── Group — buttons that read as one control. ───────────────────────────────── */
.aparte-btn-group { display: inline-flex;
 vertical-align: middle; }
.aparte-btn-group > .aparte-btn { border-radius: 0; }
/* Logical properties, so the joined edge follows the writing direction instead of
   assuming left-to-right. */
.aparte-btn-group > .aparte-btn:first-child {
  border-start-start-radius: var(--aparte-btn-radius);
  border-end-start-radius: var(--aparte-btn-radius);
}
.aparte-btn-group > .aparte-btn:last-child {
  border-start-end-radius: var(--aparte-btn-radius);
  border-end-end-radius: var(--aparte-btn-radius);
}
.aparte-btn-group > .aparte-btn:not(:first-child) { margin-inline-start: calc(var(--aparte-border-width) * -1); }
.aparte-btn-group > .aparte-btn:hover,
.aparte-btn-group > .aparte-btn:focus-visible { z-index: var(--aparte-z-raised); }
/* The selected segment. Solid in the group's intent, whatever fill the segments wear:
   a segmented control has exactly one thing to say at rest, and a wash could not say
   it on dark (#53). `--surface` ignores the intent, so its groups select in neutral.
   Hover leaves it alone — the pointer must never be what makes selection legible. */
.aparte-btn-group > .aparte-btn[aria-pressed='true'],
.aparte-btn-group > .aparte-btn[aria-selected='true'],
.aparte-btn-group > .aparte-btn[aria-current] {
  background: var(--aparte-btn-intent);
  border-color: var(--aparte-btn-intent);
  color: var(--aparte-btn-on-intent);
  z-index: var(--aparte-z-raised);
}
.aparte-btn-group > .aparte-btn[aria-pressed='true']:hover:not(:disabled),
.aparte-btn-group > .aparte-btn[aria-selected='true']:hover:not(:disabled),
.aparte-btn-group > .aparte-btn[aria-current]:hover:not(:disabled) {
  background: var(--aparte-btn-intent);
  filter: none;
}

/* In forced-colors the UA repaints everything and drops backgrounds, so a solid button
   loses the only thing marking it as the primary action. A border survives. */
@media (forced-colors: active) {
  .aparte-btn { border-color: currentColor; }
  .aparte-btn:focus-visible { outline-color: Highlight; }
}
/*
 * aparté — form fields. Ready-made classes; nothing to configure.
 *
 * Every input control a UI library offers: text entry, textarea, select, checkbox,
 * radio, switch, range. The library renders into the LIGHT DOM — there is no shadow
 * root and no ::part() — so a class is the only handle a consumer has onto a native
 * form control, and `appearance: none` is how this file takes the browser's own
 * chrome off a checkbox/radio/switch/range so these classes can repaint it.
 *
 *     <label class="aparte-field-label" for="email">
 *       Email <span class="aparte-field-required" aria-hidden="true">*</span>
 *     </label>
 *     <input id="email" class="aparte-field" type="email" placeholder="you@example.com" />
 *     <p class="aparte-field-hint">We only use this to send a receipt.</p>
 *     <p class="aparte-field-warning">Changing it signs you out of other devices.</p>
 *
 *     <div class="aparte-field-group">
 *       <span class="aparte-field-group__prefix">https://</span>
 *       <input class="aparte-field" placeholder="example.com" />
 *     </div>
 *
 *     <p><label><input type="checkbox" class="aparte-checkbox aparte-checkbox--danger" /> Delete my account</label></p>
 *     <p><label><input type="checkbox" role="switch" class="aparte-switch aparte-switch--success" checked /> Email notifications</label></p>
 *
 * `.aparte-field` is ONE shared recipe — border, background, radius, padding, focus
 * ring, placeholder — applied to <input>, <textarea> and <select> alike, so a form
 * built from all three still reads as one control family. Checkbox, radio and switch
 * are drawn from the native control itself (`appearance: none` plus a pseudo-element),
 * never a div standing in for one, so the browser's own focus/keyboard/AT handling
 * stays intact — only the paint changes.
 *
 * Checkbox, radio, switch and range carry an INTENT axis, exactly like the button: a
 * checked checkbox can be success or danger, not only primary. Unlike the button
 * there is no separate FILL axis here — a checkbox has exactly one filled look, not
 * five — so intent is the only modifier each of these needs.
 *
 * This layer is NEUTRAL: it is what a UI library offers, not a summary of what this
 * repo happens to use. A control nothing wears yet still ships, because the form the
 * repo hasn't built is not a reason to leave it out.
 */

/* ── The shared text-entry recipe ────────────────────────────────────────────── */

.aparte-field {
  /* Every knob this family reads lives in `theme.css` on `:root` — including the
     radius and the paddings, which used to be declared right here. That was the one
     construction bug of the kit: `.aparte-field-group` is this element's PARENT and
     `.aparte-color` a sibling recipe, both read `--aparte-field-radius`, and a custom
     property declared here is invisible to both, so they computed `border-radius: 0`
     and every field group in the library rendered square. */
  box-sizing: border-box;
  display: block;
  width: 100%;
  /* A resting height from the control scale, so a field and the button beside it stop
     disagreeing by 6px; the size modifiers move it a step either way. */
  min-block-size: var(--aparte-field-size);
  font: inherit;
  font-size: var(--aparte-font-size-md);
  line-height: var(--aparte-line-height-normal);
  color: var(--aparte-text);
  background: var(--aparte-surface-1);
  border: var(--aparte-border-width) solid var(--aparte-border-control);
  border-radius: var(--aparte-field-radius);
  padding: var(--aparte-field-padding);
  transition:
    border-color var(--aparte-duration-fast) var(--aparte-ease),
    background var(--aparte-duration-fast) var(--aparte-ease),
    box-shadow var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-field::placeholder {
  color: var(--aparte-text-muted);
}

/* Native select keeps its own platform arrow and popup: replacing them with a CSS
   affordance would mean either an inline-SVG chevron that cannot track the user's
   colour scheme (a data-URI image is painted outside the CSS cascade, so it cannot
   read `currentColor`) or a `mask` hack that fights the box this recipe already
   draws. The shared border/background/padding/focus treatment below still applies —
   only the arrow is left to the platform, which already themes and localises it. */

/* Shared by two selectors that don't nest — the field itself and the field-group
   wrapper around it — so it lives at :root rather than on either one alone. Shape
   matches the avatar's error ring (see theme.css): a solid ring in the error colour,
   the same width as the focus outline. */

/* ── States ──────────────────────────────────────────────────────────────────── */

.aparte-field:hover:not(:disabled) {
  background: var(--aparte-surface-hover);
}

.aparte-field:active:not(:disabled) {
  border-color: var(--aparte-border-focus);
}

/* Drawn, not faded: the muted ink on the quieter ground, and the edge back to the region
   colour. Opacity faded the text with its box and read under 3:1. */
.aparte-field:disabled {
  cursor: not-allowed;
  background: var(--aparte-surface-2);
  color: var(--aparte-text-muted);
  border-color: var(--aparte-border);
}

.aparte-field[readonly] {
  background: var(--aparte-surface-2);
}

/* `:invalid` fires the moment a `type="email"` or `pattern` field is empty — before
   the user has typed a single character, which is why this reads `aria-invalid`
   instead: the app sets it once validation has actually run. */
.aparte-field[aria-invalid='true'] {
  border-color: var(--aparte-error);
}

/* The one ring (see --aparte-focus-outline-offset in theme.css): an outline outside the
   box, and the border left alone — a highlighted border under an offset ring read as two
   concentric lines. An invalid field's ring takes the error colour. */
.aparte-field:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-field[aria-invalid='true']:focus-visible {
  outline-color: var(--aparte-error-solid);
}

/* ── Sizes ───────────────────────────────────────────────────────────────────── */

.aparte-field--sm {
  --aparte-field-size: var(--aparte-field-size-sm);
  padding: var(--aparte-field-padding-sm);
  font-size: var(--aparte-font-size-sm);
}

.aparte-field--lg {
  --aparte-field-size: var(--aparte-field-size-lg);
  padding: var(--aparte-field-padding-lg);
  font-size: var(--aparte-font-size-base);
}

/* ── Textarea ────────────────────────────────────────────────────────────────── */

.aparte-field--textarea {

  display: block;
  min-block-size: var(--aparte-field-textarea-min-height);
  line-height: var(--aparte-line-height-relaxed);
  resize: vertical;
}

/* ── Field group — a shared border around a prefix and/or suffix slot ──────────── */

.aparte-field-group {
  box-sizing: border-box;
  display: flex;
  align-items: stretch;
  background: var(--aparte-surface-1);
  border: var(--aparte-border-width) solid var(--aparte-border-control);
  border-radius: var(--aparte-field-radius);
  /* The addons' ground follows the group's corners; the focus ring is a shadow on the
     group itself, outside this box, so it is not clipped. */
  overflow: hidden;
  transition:
    border-color var(--aparte-duration-fast) var(--aparte-ease),
    box-shadow var(--aparte-duration-fast) var(--aparte-ease);
}

/* The inner field gives up its own border/background/radius/ring: the GROUP carries
   those now, so the whole row reads as one control instead of a field glued to two
   labels. */
.aparte-field-group > .aparte-field {
  flex: 1 1 auto;
  width: auto;
  min-inline-size: 0;
  border: none;
  background: transparent;
  border-radius: 0;
}

/* The GROUP draws the ring (below): the field inside it draws none. */
.aparte-field-group > .aparte-field:focus-visible {
  outline: none;
}

/* A button in the group is as tall as the field it sits beside — the one row where the
   two recipes meet, and the one where a 4px difference shows. */
.aparte-field-group > .aparte-btn {
  --aparte-btn-size: var(--aparte-field-size);
}

/* An addon has a ground of its own and a rule against the field: on the field's own
   ground, in muted text, "https://" read as the start of what the user had typed. */
.aparte-field-group__prefix,
.aparte-field-group__suffix {
  display: inline-flex;
  align-items: center;
  padding-inline: var(--aparte-space-3);
  background: var(--aparte-surface-2);
  font-size: var(--aparte-font-size-sm);
  color: var(--aparte-text-muted);
  white-space: nowrap;
}
.aparte-field-group__prefix {
  border-inline-end: var(--aparte-border-width) solid var(--aparte-border);
}
.aparte-field-group__suffix {
  border-inline-start: var(--aparte-border-width) solid var(--aparte-border);
}

.aparte-field-group:hover:not(:has(.aparte-field:disabled)) {
  background: var(--aparte-surface-hover);
}

.aparte-field-group:focus-within {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

/* `aria-invalid` lives on the field itself, not the wrapper the app didn't write a
   prop for — `:has()` lets the group notice its child's state instead of asking the
   consumer to duplicate the attribute on the div too. */
.aparte-field-group:has(.aparte-field[aria-invalid='true']) {
  border-color: var(--aparte-error);
}

.aparte-field-group:has(.aparte-field[aria-invalid='true']):focus-within {
  outline-color: var(--aparte-error-solid);
}

/* ── Checkbox ────────────────────────────────────────────────────────────────── */

.aparte-checkbox {
  /* A control on a line of text sits ON that line. Without this its baseline is the
     bottom margin edge (these are `inline-grid` / `inline-flex` boxes with no text
     inside), so the box rode above the label it belongs to — visible the moment the
     class examples put one next to a word, which is the commonest way any of these is
     used. `middle` and not `text-bottom`: the label may wrap. */
  vertical-align: middle;
  /* Same two-part axis as the button: INTENT is the colour, ON-INTENT is what sits
     on top of it. On-intent is DERIVED from the fill (see the note in button.css);
     this line is only the fallback for a browser without relative colour syntax. */
  --aparte-checkbox-intent: var(--aparte-primary);
  --aparte-checkbox-on-intent: var(--aparte-on-intent);
  /* The derived ink, as a named value so each intent can offer it as the fallback
     behind the theme's own `--aparte-on-*`. Declared here too, un-derived, because a
     `var()` whose target is undefined outside `@supports` would take the whole chain
     down with it. */
  --aparte-derived-ink: var(--aparte-on-intent);
  /* No shared control-size token covers a checkbox yet (the button's own sizes are
     button-specific), so its box is declared here. */

  box-sizing: border-box;
  appearance: none;
  -webkit-appearance: none;
  margin: 0;
  flex-shrink: 0;
  display: inline-grid;
  place-content: center;
  width: var(--aparte-checkbox-size);
  height: var(--aparte-checkbox-size);
  border: var(--aparte-border-width) solid var(--aparte-border-control);
  border-radius: var(--aparte-radius-sm);
  background: var(--aparte-surface-1);
  cursor: pointer;
  transition:
    background var(--aparte-duration-fast) var(--aparte-ease),
    border-color var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-checkbox::after {
  content: '';
  opacity: 0;
  transition: opacity var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-checkbox:checked,
.aparte-checkbox:indeterminate {
  background: var(--aparte-checkbox-intent);
  border-color: var(--aparte-checkbox-intent);
}

/* The checkmark: a rotated corner of a box, drawn from two borders rather than an
   image, so it inherits `on-intent` instead of shipping its own fixed colour.

   SIZED FROM THE TOKEN, not in percent, and that is the fix rather than a preference.
   These were `inline-size: 30%` / `block-size: 55%`, and a percentage on a grid ITEM
   resolves against its track — which `place-content: center` on the box collapses to
   the content's own size. The content is an empty `::after`, so the track was zero and
   the mark computed to 0.59 x 1.09px: not a check, just the 2px corner where its two
   borders meet. That is the "dot" it drew in every checked checkbox the library has
   ever rendered. The indeterminate dash had it worse — 55% of zero is zero, so it drew
   nothing at all.

   Measured, not read: `getComputedStyle(el, '::after')` reported the 0.59px. The rule
   looks correct in the file, which is why four passes over this sheet never caught it. */
.aparte-checkbox:checked::after {
  inline-size: calc(var(--aparte-checkbox-size) * 0.3);
  block-size: calc(var(--aparte-checkbox-size) * 0.55);
  border-inline-end: calc(var(--aparte-checkbox-size) * 0.12) solid var(--aparte-checkbox-on-intent);
  border-block-end: calc(var(--aparte-checkbox-size) * 0.12) solid var(--aparte-checkbox-on-intent);
  transform: rotate(45deg) translateY(-10%);
  opacity: 1;
}

.aparte-checkbox:indeterminate::after {
  inline-size: calc(var(--aparte-checkbox-size) * 0.55);
  block-size: 0;
  border-block-end: calc(var(--aparte-checkbox-size) * 0.16) solid var(--aparte-checkbox-on-intent);
  opacity: 1;
}

/* The ink follows the fill — see the long note in `button.css`. Same expression, this
   recipe's own intent. Here it paints the CHECKMARK, which is a border trick rather
   than text, so it reads `--aparte-checkbox-on-intent` twice below. */
@supports (color: oklch(from red l c h)) {
  .aparte-checkbox {
    --aparte-derived-ink: oklch(
      from var(--aparte-checkbox-intent)
      clamp(var(--aparte-ink-dark), (var(--aparte-ink-flip) - l) * 1000, 1)
      0.02
      h
    );
  }
}

.aparte-checkbox--primary   { --aparte-checkbox-intent: var(--aparte-primary); --aparte-checkbox-on-intent: var(--aparte-on-primary, var(--aparte-derived-ink)); }
.aparte-checkbox--secondary { --aparte-checkbox-intent: var(--aparte-secondary); --aparte-checkbox-on-intent: var(--aparte-on-secondary, var(--aparte-derived-ink)); }
/* No forced ink on neutral: the checkmark now follows the fill, which flips with the
   theme. The old line pinned it white, so the check sat at 2.62 on the dark fill
   `#a89bb6` — invisible against the box it was inside. See button.css. */
.aparte-checkbox--neutral   { --aparte-checkbox-intent: var(--aparte-neutral); --aparte-checkbox-on-intent: var(--aparte-on-neutral, var(--aparte-derived-ink)); }
.aparte-checkbox--info      { --aparte-checkbox-intent: var(--aparte-info); --aparte-checkbox-on-intent: var(--aparte-on-info, var(--aparte-derived-ink)); }
.aparte-checkbox--success   { --aparte-checkbox-intent: var(--aparte-success); --aparte-checkbox-on-intent: var(--aparte-on-success, var(--aparte-derived-ink)); }
.aparte-checkbox--warning   { --aparte-checkbox-intent: var(--aparte-warning); --aparte-checkbox-on-intent: var(--aparte-on-warning, var(--aparte-derived-ink)); }
.aparte-checkbox--danger    { --aparte-checkbox-intent: var(--aparte-error); --aparte-checkbox-on-intent: var(--aparte-on-error, var(--aparte-derived-ink)); }

.aparte-checkbox:hover:not(:disabled) {
  border-color: var(--aparte-checkbox-intent);
}

.aparte-checkbox:active:not(:disabled) {
  opacity: 0.85;
}

.aparte-checkbox:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-checkbox:disabled {
  cursor: not-allowed;
  opacity: var(--aparte-disabled-opacity);
}

/* Forced-colors strips backgrounds, and background is the only thing telling checked
   from unchecked here — so the check mark's border becomes the affordance instead. */
@media (forced-colors: active) {
  .aparte-checkbox { border-color: currentColor; }
  .aparte-checkbox:checked,
  .aparte-checkbox:indeterminate { background: Canvas; border-color: Highlight; }
  .aparte-checkbox:checked::after,
  .aparte-checkbox:indeterminate::after { border-color: Highlight; }
  .aparte-checkbox:focus-visible { outline-color: Highlight; }
}

/* ── Radio ───────────────────────────────────────────────────────────────────── */

.aparte-radio {
  /* A control on a line of text sits ON that line. Without this its baseline is the
     bottom margin edge (these are `inline-grid` / `inline-flex` boxes with no text
     inside), so the box rode above the label it belongs to — visible the moment the
     class examples put one next to a word, which is the commonest way any of these is
     used. `middle` and not `text-bottom`: the label may wrap. */
  vertical-align: middle;
  --aparte-radio-intent: var(--aparte-primary);

  box-sizing: border-box;
  appearance: none;
  -webkit-appearance: none;
  margin: 0;
  flex-shrink: 0;
  display: inline-grid;
  place-content: center;
  width: var(--aparte-radio-size);
  height: var(--aparte-radio-size);
  border: var(--aparte-border-width) solid var(--aparte-border-control);
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-surface-1);
  cursor: pointer;
  transition: border-color var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-radio::after {
  content: '';
  width: var(--aparte-radio-dot-size);
  height: var(--aparte-radio-dot-size);
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-radio-intent);
  transform: scale(0);
  transition: transform var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-radio:checked {
  border-color: var(--aparte-radio-intent);
}

.aparte-radio:checked::after {
  transform: scale(1);
}

.aparte-radio--primary   { --aparte-radio-intent: var(--aparte-primary); }
.aparte-radio--secondary { --aparte-radio-intent: var(--aparte-secondary); }
.aparte-radio--neutral   { --aparte-radio-intent: var(--aparte-neutral); }
.aparte-radio--info      { --aparte-radio-intent: var(--aparte-info); }
.aparte-radio--success   { --aparte-radio-intent: var(--aparte-success); }
.aparte-radio--warning   { --aparte-radio-intent: var(--aparte-warning); }
.aparte-radio--danger    { --aparte-radio-intent: var(--aparte-error); }

.aparte-radio:hover:not(:disabled) {
  border-color: var(--aparte-radio-intent);
}

.aparte-radio:active:not(:disabled) {
  opacity: 0.85;
}

.aparte-radio:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-radio:disabled {
  cursor: not-allowed;
  opacity: var(--aparte-disabled-opacity);
}

@media (forced-colors: active) {
  .aparte-radio { border-color: currentColor; }
  .aparte-radio::after { background: Highlight; }
  .aparte-radio:checked { border-color: Highlight; }
  .aparte-radio:focus-visible { outline-color: Highlight; }
}

/* ── Switch — a track and a thumb, not a checkbox pretending to be one ─────────── */

.aparte-switch {
  /* A control on a line of text sits ON that line. Without this its baseline is the
     bottom margin edge (these are `inline-grid` / `inline-flex` boxes with no text
     inside), so the box rode above the label it belongs to — visible the moment the
     class examples put one next to a word, which is the commonest way any of these is
     used. `middle` and not `text-bottom`: the label may wrap. */
  vertical-align: middle;
  --aparte-switch-intent: var(--aparte-primary);
  /* Unlike the plain intent alias above, the track's colour is a STATE (it moves from
     neutral to `--aparte-switch-intent` on check), not a fixed structural value, so it
     has to live here where `:checked` can override it — a central default could never
     react to the class + state combo. */
  --aparte-switch-track: var(--aparte-surface-3);
  /* DERIVED from the track, not a fourth literal to keep in step with the other three:
     the thumb used to be 16px in a 20px track with a 1px border and a 2px top inset —
     2px of air above it and none below, visibly low ("le cercle pas centré"). The
     thumb is what is left once the border and the inset are paid on both sides, so
     it is centred by construction, whatever the track's size. */
  --aparte-switch-thumb-size: calc(var(--aparte-switch-height) - 2 * var(--aparte-border-width) - 2 * var(--aparte-switch-thumb-inset));
  /* No shared shadow token fits a small thumb like this one, so it's a plain literal —
     kept the same in both themes rather than reaching for a per-theme split this file
     has no other precedent for. */

  box-sizing: border-box;
  appearance: none;
  -webkit-appearance: none;
  position: relative;
  margin: 0;
  flex-shrink: 0;
  inline-size: var(--aparte-switch-width);
  block-size: var(--aparte-switch-height);
  border: var(--aparte-border-width) solid var(--aparte-border-control);
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-switch-track);
  cursor: pointer;
  transition:
    background var(--aparte-duration-fast) var(--aparte-ease),
    border-color var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-switch::after {
  content: '';
  position: absolute;
  inset-block-start: var(--aparte-switch-thumb-inset);
  inset-inline-start: var(--aparte-switch-thumb-inset);
  inline-size: var(--aparte-switch-thumb-size);
  block-size: var(--aparte-switch-thumb-size);
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-surface-1);
  box-shadow: var(--aparte-switch-thumb-shadow);
  transition: inset-inline-start var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-switch:checked {
  --aparte-switch-track: var(--aparte-switch-intent);
  border-color: var(--aparte-switch-intent);
}

.aparte-switch:checked::after {
  inset-inline-start: calc(100% - var(--aparte-switch-thumb-size) - var(--aparte-switch-thumb-inset));
}

.aparte-switch--primary   { --aparte-switch-intent: var(--aparte-primary); }
.aparte-switch--secondary { --aparte-switch-intent: var(--aparte-secondary); }
.aparte-switch--neutral   { --aparte-switch-intent: var(--aparte-neutral); }
.aparte-switch--info      { --aparte-switch-intent: var(--aparte-info); }
.aparte-switch--success   { --aparte-switch-intent: var(--aparte-success); }
.aparte-switch--warning   { --aparte-switch-intent: var(--aparte-warning); }
.aparte-switch--danger    { --aparte-switch-intent: var(--aparte-error); }

.aparte-switch:hover:not(:disabled) {
  border-color: var(--aparte-switch-intent);
}

.aparte-switch:active:not(:disabled) {
  opacity: 0.85;
}

.aparte-switch:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-switch:disabled {
  cursor: not-allowed;
  opacity: var(--aparte-disabled-opacity);
}

/* Both the track's on/off state and the thumb's position carry the meaning here —
   forced-colors keeps the thumb's travel (that survives) but needs real borders to
   keep the track and the thumb readable as two distinct shapes once colour is gone. */
@media (forced-colors: active) {
  .aparte-switch { border-color: currentColor; background: Canvas; }
  .aparte-switch::after { background: CanvasText; box-shadow: none; forced-color-adjust: none; }
  .aparte-switch:checked { border-color: Highlight; background: Canvas; }
  .aparte-switch:checked::after { background: Highlight; }
  .aparte-switch:focus-visible { outline-color: Highlight; }
}

/* ── Range ───────────────────────────────────────────────────────────────────── */

.aparte-range {
  --aparte-range-intent: var(--aparte-primary);

  appearance: none;
  -webkit-appearance: none;
  width: 100%;
  height: var(--aparte-range-thumb-size);
  background: transparent;
  cursor: pointer;
}

/* WebKit/Blink and Firefox never agree to share a selector list for these
   pseudo-elements — an engine that doesn't recognise one drops the whole rule — so
   every ::-webkit-* gets its own rule, separate from its ::-moz-* twin. */
.aparte-range::-webkit-slider-runnable-track {
  block-size: var(--aparte-range-track-height);
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-surface-3);
}

.aparte-range::-moz-range-track {
  block-size: var(--aparte-range-track-height);
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-surface-3);
}

.aparte-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  margin-block-start: calc((var(--aparte-range-track-height) - var(--aparte-range-thumb-size)) / 2);
  inline-size: var(--aparte-range-thumb-size);
  block-size: var(--aparte-range-thumb-size);
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-range-intent);
  border: var(--aparte-border-width) solid var(--aparte-surface-1);
  box-shadow: var(--aparte-range-thumb-shadow);
  cursor: pointer;
  transition: transform var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-range::-moz-range-thumb {
  inline-size: var(--aparte-range-thumb-size);
  block-size: var(--aparte-range-thumb-size);
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-range-intent);
  border: var(--aparte-border-width) solid var(--aparte-surface-1);
  box-shadow: var(--aparte-range-thumb-shadow);
  cursor: pointer;
  transition: transform var(--aparte-duration-fast) var(--aparte-ease);
}

/* Firefox draws its own dotted focus ring around the thumb on top of ours. */
.aparte-range::-moz-focus-outer {
  border: 0;
}

.aparte-range--primary   { --aparte-range-intent: var(--aparte-primary); }
.aparte-range--secondary { --aparte-range-intent: var(--aparte-secondary); }
.aparte-range--neutral   { --aparte-range-intent: var(--aparte-neutral); }
.aparte-range--info      { --aparte-range-intent: var(--aparte-info); }
.aparte-range--success   { --aparte-range-intent: var(--aparte-success); }
.aparte-range--warning   { --aparte-range-intent: var(--aparte-warning); }
.aparte-range--danger    { --aparte-range-intent: var(--aparte-error); }

.aparte-range:hover:not(:disabled)::-webkit-slider-thumb { transform: scale(1.1); }
.aparte-range:hover:not(:disabled)::-moz-range-thumb { transform: scale(1.1); }

.aparte-range:active::-webkit-slider-thumb { transform: scale(1.15); }
.aparte-range:active::-moz-range-thumb { transform: scale(1.15); }

.aparte-range:focus-visible::-webkit-slider-thumb {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}
.aparte-range:focus-visible::-moz-range-thumb {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-range:disabled { cursor: not-allowed; opacity: var(--aparte-disabled-opacity); }
.aparte-range:disabled::-webkit-slider-thumb { cursor: not-allowed; }
.aparte-range:disabled::-moz-range-thumb { cursor: not-allowed; }

@media (forced-colors: active) {
  .aparte-range::-webkit-slider-runnable-track { background: ButtonFace; border: 1px solid currentColor; }
  .aparte-range::-moz-range-track { background: ButtonFace; border: 1px solid currentColor; }
  .aparte-range::-webkit-slider-thumb { background: Highlight; border-color: Highlight; }
  .aparte-range::-moz-range-thumb { background: Highlight; border-color: Highlight; }
  .aparte-range:focus-visible::-webkit-slider-thumb { outline-color: Highlight; }
  .aparte-range:focus-visible::-moz-range-thumb { outline-color: Highlight; }
}

/* ── Label, hint, error, required marker ────────────────────────────────────── */

.aparte-field-label {
  display: inline-block;
  vertical-align: middle;
  margin-block-end: var(--aparte-space-1);
  font-size: var(--aparte-font-size-sm);
  font-weight: var(--aparte-font-weight-medium);
  color: var(--aparte-text);
}

.aparte-field-hint {
  display: block;
  margin-block-start: var(--aparte-space-1);
  font-size: var(--aparte-font-size-xs);
  color: var(--aparte-text-muted);
}

.aparte-field-error {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-1);
  margin-block-start: var(--aparte-space-1);
  font-size: var(--aparte-font-size-xs);
  color: var(--aparte-error-text);
}

/* The third tone of a field's sub-text: a consequence, not a fault. "This setting
   invalidates the saved states" is neither a hint nor an error, and a shell that had
   only those two painted it as one of them. Same shape as the error line; the warning
   ink is the derived one every other warning reads. */
.aparte-field-warning {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-1);
  margin-block-start: var(--aparte-space-1);
  font-size: var(--aparte-font-size-xs);
  color: var(--aparte-warning-ink);
}

/* A bare colour on the marker, not injected `*` content: a locale that doesn't mark
   required fields with an asterisk still gets a place to say so, in its own text. */
.aparte-field-required {
  color: var(--aparte-error-ink);
}

/* ── Grouping — fieldset and a field row ────────────────────────────────────── */

.aparte-fieldset {
  min-inline-size: 0;
  margin: 0;
  padding: 0;
  border: none;
  display: flex;
  flex-direction: column;
  gap: var(--aparte-space-4);
}

.aparte-fieldset > legend {
  padding: 0;
  margin-block-end: var(--aparte-space-2);
  font-size: var(--aparte-font-size-md);
  font-weight: var(--aparte-font-weight-semibold);
  color: var(--aparte-text);
}

.aparte-fieldset:disabled > legend {
  opacity: var(--aparte-disabled-opacity);
}

.aparte-field-row {
  display: flex;
  align-items: flex-start;
  gap: var(--aparte-space-4);
}

.aparte-field-row > * {
  flex: 1 1 0;
  min-inline-size: 0;
}

/* ── Colour ──────────────────────────────────────────────────────────────────
   A colour input is a swatch in a chrome the UA draws itself, so it takes
   `appearance: none` and then its swatch pseudo-element on each engine — WebKit
   wraps the swatch in a second element that carries its own padding, which is what
   makes an unstyled one look inset. */
.aparte-color {
  appearance: none;
  -webkit-appearance: none;
  inline-size: var(--aparte-color-size);
  block-size: var(--aparte-color-size);
  padding: 0;
  border: var(--aparte-border-width) solid var(--aparte-border-control);
  border-radius: var(--aparte-field-radius);
  background: none;
  cursor: pointer;
}
.aparte-color::-webkit-color-swatch-wrapper { padding: 0; }
.aparte-color::-webkit-color-swatch { border: none; border-radius: var(--aparte-radius-sm); }
.aparte-color::-moz-color-swatch { border: none; border-radius: var(--aparte-radius-sm); }
.aparte-color:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}
.aparte-color:disabled { cursor: not-allowed; opacity: var(--aparte-disabled-opacity); }

/* ── Date and time ───────────────────────────────────────────────────────────
   These wear `.aparte-field` for their box; what needs saying is the parts only
   WebKit exposes. The picker indicator is a UA-drawn glyph that cannot take a
   colour, so it is tinted with opacity rather than fought — and Firefox, which
   exposes none of these, simply renders the field it already inherited. */
.aparte-field[type='date'],
.aparte-field[type='time'],
.aparte-field[type='datetime-local'],
.aparte-field[type='month'],
.aparte-field[type='week'] {
  /* Without this the field collapses to the intrinsic width of its digits, which
     differs per locale and makes a form jump when the value changes. */
  min-inline-size: var(--aparte-datetime-min-width);
}
.aparte-field::-webkit-calendar-picker-indicator {
  cursor: pointer;
  opacity: var(--aparte-datetime-indicator-opacity);
  border-radius: var(--aparte-radius-xs);
}
.aparte-field::-webkit-calendar-picker-indicator:hover { opacity: 1; }
.aparte-field::-webkit-datetime-edit-fields-wrapper { padding: 0; }
.aparte-field::-webkit-datetime-edit-text { color: var(--aparte-text-muted); padding-inline: var(--aparte-space-1); }

/* ── Meter ───────────────────────────────────────────────────────────────────
   A gauge, not a progress bar: the value is judged against a range, so the three
   bands the element defines — optimum, suboptimum, poor — get the three status
   colours rather than one accent. Both engines name their parts differently and
   neither inherits from the other, so both are written out. */
.aparte-meter {
  appearance: none;
  -webkit-appearance: none;
  inline-size: 100%;
  block-size: var(--aparte-meter-height);
  border: none;
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-meter-track);
  overflow: hidden;
}
.aparte-meter::-webkit-meter-bar { background: var(--aparte-meter-track); border: none; border-radius: var(--aparte-radius-full); }
.aparte-meter::-webkit-meter-optimum-value { background: var(--aparte-meter-optimum); border-radius: var(--aparte-radius-full); }
.aparte-meter::-webkit-meter-suboptimum-value { background: var(--aparte-meter-suboptimum); border-radius: var(--aparte-radius-full); }
.aparte-meter::-webkit-meter-even-less-good-value { background: var(--aparte-meter-poor); border-radius: var(--aparte-radius-full); }
.aparte-meter:-moz-meter-optimum::-moz-meter-bar { background: var(--aparte-meter-optimum); }
.aparte-meter:-moz-meter-sub-optimum::-moz-meter-bar { background: var(--aparte-meter-suboptimum); }
.aparte-meter:-moz-meter-sub-sub-optimum::-moz-meter-bar { background: var(--aparte-meter-poor); }

/* ── Output ──────────────────────────────────────────────────────────────────
   A computed result, not an input: it reads as a value the page produced, so it is
   set in the code face and on a quiet ground rather than in a bordered box that
   would invite a click. */
.aparte-output {
  display: inline-block;
  vertical-align: middle;
  padding: var(--aparte-output-padding);
  border-radius: var(--aparte-radius-sm);
  background: var(--aparte-output-bg);
  color: var(--aparte-text);
  font-family: var(--aparte-code-font-family);
  font-size: var(--aparte-font-size-sm);
}

@media (forced-colors: active) {
  .aparte-meter,
  .aparte-output,
  .aparte-color { border: var(--aparte-border-width) solid currentColor; }
}

/* ── Choice row ──────────────────────────────────────────────────────────────
   A whole row that selects — a label wrapping a radio or a checkbox, with a title
   and often a description beside it. The control alone is a small target; the row
   is the affordance, which is why the cursor and the hover live here and not on
   the input.

       <label class="aparte-field-choice">
         <input type="radio" class="aparte-radio" name="k">
         <span class="aparte-field-choice__body">Ship to this address</span>
       </label>

   `align-items: flex-start` rather than centre: a description makes the row two or
   three lines tall, and a control centred against that floats away from the title
   it belongs to. */
.aparte-field-choice {
  display: flex;
  align-items: flex-start;
  gap: var(--aparte-field-choice-gap);
  padding: var(--aparte-field-choice-padding);
  border: var(--aparte-border-width) solid transparent;
  border-radius: var(--aparte-field-choice-radius);
  cursor: pointer;
  transition:
    background var(--aparte-duration-fast) var(--aparte-ease),
    border-color var(--aparte-duration-fast) var(--aparte-ease);
}
/* Not on a chosen row: its mark (below) is what the pointer must not erase. */
.aparte-field-choice:not(:has(:checked)):not(.aparte-field-choice--selected):hover { background: var(--aparte-surface-2); }

/* Boxed — each row carries its own edge, for a list that must read as a set of
   separate targets rather than a menu. */
.aparte-field-choice--boxed { border-color: var(--aparte-border); }

/* Selected. `:has()` covers the native case with no class to toggle; the explicit
   modifier covers a row whose state lives elsewhere — a button acting as an option,
   where there is no checked input to look at. It wears the mark (display/mark.css):
   the tint and the start bar the select's chosen option wears, so a picked choice in
   an elicitation panel and a picked option in a dropdown say it the same way. The
   border stays its own — it was the only signal before, and a boxed row keeps its
   edge coloured. Written as the mark's declarations rather than its class because a
   `:has()` state cannot add a class to itself; the modifier is doubled so both halves
   of this rule sit at (0,2,0), above the hover. The bar is the mark's pseudo-element,
   on the logical start edge — see display/mark.css for why not a shadow. */
.aparte-field-choice:has(:checked),
.aparte-field-choice.aparte-field-choice--selected {
  position: relative;
  border-color: var(--aparte-primary);
  background: color-mix(in srgb, var(--aparte-primary) var(--aparte-mark-tint), transparent);
}

.aparte-field-choice:has(:checked)::before,
.aparte-field-choice.aparte-field-choice--selected::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  width: var(--aparte-mark-bar);
  background: var(--aparte-primary-ink);
  pointer-events: none;
}

.aparte-field-choice:has(:disabled),
.aparte-field-choice[aria-disabled='true'] {
  cursor: not-allowed;
  opacity: var(--aparte-disabled-opacity);
}
.aparte-field-choice:has(:focus-visible) {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-field-choice__body { flex: 1 1 auto; min-width: 0; }
/* =========================================
   aparte-avatar — sizes, shape, image + initials fallback
   =========================================
   `--aparte-avatar-size` is NOT invented here: it already exists centrally for the
   message avatar (bubble.css), and this component reads the same one — a generic
   avatar and a chat-message avatar are the same object, and a size modifier applied
   here resizes a role-coloured message avatar too. The corner, the initials and the
   group's overlap are FRACTIONS of that size (`--aparte-avatar-radius-ratio`,
   `--aparte-avatar-initials-ratio`, `--aparte-avatar-overlap-ratio`), computed on the
   element so a size modifier moves them: as absolute tokens the 40px avatar drew the
   same 11px initials as the 32px one, the corner drifted from squircle to square up
   the ramp, and a 6px overlap was a fifth of a small avatar and a tenth of a large one.

       <span class="aparte-avatar aparte-avatar--sm">AP</span>
       <span class="aparte-avatar">JD</span>
       <span class="aparte-avatar aparte-avatar--lg aparte-avatar--square">MK</span>
       <span class="aparte-avatar-group">
         <span class="aparte-avatar">AP</span>
         <span class="aparte-avatar">JD</span>
         <span class="aparte-avatar">+3</span>
       </span>
   */

.aparte-avatar {
  position: relative;
  display: inline-flex;
  vertical-align: middle;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  width: var(--aparte-avatar-size);
  height: var(--aparte-avatar-size);
  border-radius: calc(var(--aparte-avatar-size) * var(--aparte-avatar-radius-ratio));
  background: var(--aparte-avatar-fallback-bg);
  color: var(--aparte-avatar-fallback-color);
  font-size: calc(var(--aparte-avatar-size) * var(--aparte-avatar-initials-ratio));
  font-weight: var(--aparte-font-weight-semibold);
  line-height: var(--aparte-line-height-none);
  text-transform: uppercase;
  user-select: none;
  white-space: nowrap;
}

/* An <img>, present only when the consumer has a picture to show; the initials
   are the element's own text content otherwise. The library does not attempt an
   image-error fallback in CSS — that is a DOM swap, and belongs to the consumer's
   own onerror handler, the same division of labour as everywhere else in this
   file: primitives, not behaviour. */
.aparte-avatar__image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  object-fit: cover;
}

/* Square is the default shape and needs no class (it is what the message avatar
   already renders); named anyway so a square avatar can say so rather than omit. */
.aparte-avatar--square { border-radius: calc(var(--aparte-avatar-size) * var(--aparte-avatar-radius-ratio)); }
.aparte-avatar--circle { border-radius: var(--aparte-radius-full); }

.aparte-avatar--xs { --aparte-avatar-size: var(--aparte-avatar-size-xs); }
.aparte-avatar--sm { --aparte-avatar-size: var(--aparte-avatar-size-sm); }
.aparte-avatar--lg { --aparte-avatar-size: var(--aparte-avatar-size-lg); }
.aparte-avatar--xl { --aparte-avatar-size: var(--aparte-avatar-size-xl); }

/* ── Group — a fan of avatars that read as one party. ─────────────────────────
   No z-index is set: later siblings simply paint over earlier ones in normal
   source order, which is already "each new avatar sits on top of the last" —
   the one thing z-index would otherwise be needed for. */
.aparte-avatar-group {
  display: inline-flex;
  vertical-align: middle;
  align-items: center;
}
.aparte-avatar-group > .aparte-avatar {
  box-shadow: 0 0 0 var(--aparte-avatar-group-ring-width) var(--aparte-avatar-group-ring-color);
}
.aparte-avatar-group > .aparte-avatar:not(:first-child) {
  margin-inline-start: calc(var(--aparte-avatar-size) * var(--aparte-avatar-overlap-ratio) * -1);
}
.aparte-avatar-group > .aparte-avatar:hover {
  z-index: var(--aparte-z-raised);
}
/*
 * aparté — the icon. Every glyph the library draws carries this class.
 *
 *     <aparte-icon name="check"></aparte-icon>
 *
 * The glyph does NOT carry its own size. It used to — `width="14"`, `width="16"`,
 * `width="20"` baked into the markup — and that is precisely what kept the same drawing
 * from being shared: an ✕ sized for a remove button could not also be the ✕ of a tool
 * call, so the library ended up with three of them, on three grids, at three stroke
 * widths. Size is a property of the PLACE an icon sits in, so it is declared by the
 * container and inherited down.
 *
 * `--aparte-icon-size` is therefore the one knob, and it inherits: set it on any
 * ancestor and every glyph below follows. A container with a rule of its own
 * (`.aparte-action-button svg`, `.aparte-tool-icon svg`) still wins on specificity —
 * those already expressed size in CSS and are left alone.
 */

/* The ELEMENT's own box. `<aparte-icon>` is an unknown tag to CSS, so it defaults to
   `display: inline` — a box with no dimensions of its own that contributes a line box,
   which left a few pixels of leading under every icon button. The class below sizes the
   SVG; this sizes the wrapper around it, and `inline-flex` makes the two the same box. */
aparte-icon {
  display: inline-flex;
  vertical-align: middle;
}

.aparte-icon {
  inline-size: var(--aparte-icon-size);
  block-size: var(--aparte-icon-size);
  /* A glyph never absorbs the row's slack: it is a fixed mark beside text, and a
     shrunk icon reads as a rendering fault rather than as a smaller icon. */
  flex-shrink: 0;
  display: inline-block;
  vertical-align: middle;
}

.aparte-icon--sm { --aparte-icon-size: var(--aparte-icon-size-sm); }
.aparte-icon--lg { --aparte-icon-size: var(--aparte-icon-size-lg); }
.aparte-icon--xl { --aparte-icon-size: var(--aparte-icon-size-xl); }

/* The spinning glyph — the `loading` icon wears this. It carried the class and nothing
   declared it, so core's own loading icon sat still. The rotation itself is
   `aparte-spin`, declared once in base.css and shared with every other spinner. */
.aparte-icon-spin {
  animation: aparte-spin var(--aparte-duration-spin) linear infinite;
  transform-origin: center;
}

@media (prefers-reduced-motion: reduce) {
  .aparte-icon-spin { animation: none; }
}
/*
 * aparté — Display primitives: the non-interactive pieces a UI library offers.
 * Ready-made classes; nothing to configure.
 *
 *     <span class="aparte-badge aparte-badge--success aparte-badge--solid">Live</span>
 *     <div class="aparte-progress" style="--aparte-progress-value: 60">
 *       <div class="aparte-progress__bar"></div>
 *     </div>
 *     <div class="aparte-alert aparte-alert--warning" role="alert">
 *       <span class="aparte-alert__icon"><aparte-icon name="alertTriangle"></aparte-icon></span>
 *       <div class="aparte-alert__body">
 *         <p class="aparte-alert__title">Heads up</p>
 *         <p class="aparte-alert__message">Your session expires soon.</p>
 *       </div>
 *     </div>
 *
 * This layer is NEUTRAL: it is what a UI library offers, not a summary of what this
 * repo happens to use. A class nothing wears today still ships, because the plugin
 * that needs it is not written yet.
 *
 * Badge and alert repeat the button's two-axis shape — an intent class sets a local
 * custom property, a fill (or the intent alone) reads it — for the same reason: colour
 * meaning and paint style are independent questions, and collapsing them into one
 * flat list of classes is what produces a ninth accidental shade of orange.
 */

/* =========================================
   aparte-badge — small count or status pill
   =========================================
   Two axes like the button's: an intent says what it means, a fill says how loudly.
   `--dot` is the third form — no label, so it is always painted solid.

       <span class="aparte-badge aparte-badge--success aparte-badge--solid">Live</span>
       <span class="aparte-badge aparte-badge--warning">Draft</span>
       <span class="aparte-badge aparte-badge--danger aparte-badge--outline">Failed</span>
       <span class="aparte-badge aparte-badge--info aparte-badge--sm">12</span>
       <span class="aparte-badge aparte-badge--danger aparte-badge--dot" aria-label="Unread"></span>
   ========================================= */

.aparte-badge {
  --aparte-badge-intent: var(--aparte-neutral);
  /* Fallback only — the `@supports` block below derives this from the fill. */
  --aparte-badge-on-intent: var(--aparte-on-intent);

  /* The intent AS TEXT, which is a different question from the ink ON a solid fill.
     `--aparte-badge-on-intent` answers the second; this answers the first, and the
     badge had no answer at all — base, `--soft` and `--outline` painted the label with
     the raw FILL. On the light theme a soft warning badge measured 1.75:1. A fill is
     chosen to be seen as an area; the same value as 12px text on the page background is
     a different requirement, which is why `theme.css` derives five accent inks for it.
     `button.css` was given this in a574dfa and the badge was not; same name, same
     defaulting, so a custom intent still resolves to its own fill rather than to
     nothing. `--secondary` and `--neutral` set no ink here either — their fills are
     already muted enough to read, exactly as in `button.css`. */
  --aparte-badge-intent-ink: var(--aparte-badge-intent);
  /* The derived ink, as a named value so each intent can offer it as the fallback
     behind the theme's own `--aparte-on-*`. Declared here too, un-derived, because a
     `var()` whose target is undefined outside `@supports` would take the whole chain
     down with it. */
  --aparte-derived-ink: var(--aparte-on-intent);

  display: inline-flex;

  vertical-align: middle;
  align-items: center;
  justify-content: center;
  gap: var(--aparte-badge-gap);
  min-width: var(--aparte-badge-min-width);
  padding: var(--aparte-badge-padding);
  border: var(--aparte-border-width) solid transparent;
  border-radius: var(--aparte-badge-radius);
  font-size: var(--aparte-badge-font-size);
  font-weight: var(--aparte-font-weight-semibold);
  line-height: var(--aparte-line-height-none);
  white-space: nowrap;

  /* Soft is the default and needs no class — the resting weight for a pill that
     sits inline in a row of text. Named anyway, so a badge that must be soft can
     say so rather than merely omit. */
  background: color-mix(in srgb, var(--aparte-badge-intent) 14%, transparent);
  color: var(--aparte-badge-intent-ink);
}

/* The ink follows the fill — see the long note in `button.css` for why a library
   cannot write this down as a constant. Same expression, this recipe's own intent. */
@supports (color: oklch(from red l c h)) {
  .aparte-badge {
    --aparte-derived-ink: oklch(
      from var(--aparte-badge-intent)
      clamp(var(--aparte-ink-dark), (var(--aparte-ink-flip) - l) * 1000, 1)
      0.02
      h
    );
  }
}

/* ── Axis 1 — INTENT. ─────────────────────────────────────────────────────────
   No on-intent swap for neutral any more, and no swap for anything else either: the
   ink is derived from the fill above, so it follows a fill that flips with the theme
   instead of being pinned against one of its two values. The old line forced white,
   which measured 5.60 on the light fill and 2.62 on the dark one — illegible in dark
   out of the box. See the long note in `button.css`, which this file mirrors. */
.aparte-badge--primary   { --aparte-badge-intent: var(--aparte-primary); --aparte-badge-on-intent: var(--aparte-on-primary, var(--aparte-derived-ink)); --aparte-badge-intent-ink: var(--aparte-primary-ink); }
.aparte-badge--secondary { --aparte-badge-intent: var(--aparte-secondary); --aparte-badge-on-intent: var(--aparte-on-secondary, var(--aparte-derived-ink)); }
.aparte-badge--neutral   { --aparte-badge-intent: var(--aparte-neutral); --aparte-badge-on-intent: var(--aparte-on-neutral, var(--aparte-derived-ink)); }
.aparte-badge--info      { --aparte-badge-intent: var(--aparte-info); --aparte-badge-on-intent: var(--aparte-on-info, var(--aparte-derived-ink)); --aparte-badge-intent-ink: var(--aparte-info-ink); }
.aparte-badge--success   { --aparte-badge-intent: var(--aparte-success); --aparte-badge-on-intent: var(--aparte-on-success, var(--aparte-derived-ink)); --aparte-badge-intent-ink: var(--aparte-success-ink); }
.aparte-badge--warning   { --aparte-badge-intent: var(--aparte-warning); --aparte-badge-on-intent: var(--aparte-on-warning, var(--aparte-derived-ink)); --aparte-badge-intent-ink: var(--aparte-warning-ink); }
.aparte-badge--danger    { --aparte-badge-intent: var(--aparte-error); --aparte-badge-on-intent: var(--aparte-on-error, var(--aparte-derived-ink)); --aparte-badge-intent-ink: var(--aparte-error-ink); }

/* ── Axis 2 — FILL. ──────────────────────────────────────────────────────────── */
.aparte-badge--soft {
  background: color-mix(in srgb, var(--aparte-badge-intent) 14%, transparent);
  border-color: transparent;
  color: var(--aparte-badge-intent-ink);
}
.aparte-badge--solid {
  background: var(--aparte-badge-intent);
  border-color: var(--aparte-badge-intent);
  color: var(--aparte-badge-on-intent);
}
.aparte-badge--outline {
  background: transparent;
  border-color: var(--aparte-badge-intent);
  color: var(--aparte-badge-intent-ink);
}

/* A bare notification dot: no label, so it has no OTHER way to carry the intent —
   it is always painted solid, regardless of a fill class placed alongside it. */
.aparte-badge--dot {
  min-width: 0;
  width: var(--aparte-badge-dot-size);
  height: var(--aparte-badge-dot-size);
  padding: 0;
  border-radius: var(--aparte-radius-full);
  background: var(--aparte-badge-intent);
  border-color: transparent;
}

.aparte-badge--sm { gap: var(--aparte-space-1); padding: var(--aparte-space-1) var(--aparte-space-2); font-size: var(--aparte-font-size-2xs); }
.aparte-badge--lg { gap: var(--aparte-space-2); padding: var(--aparte-space-2) var(--aparte-space-4); font-size: var(--aparte-font-size-sm); }

/* A dot has no text, so it has no OTHER cue once forced-colors drops its
   background — a border makes the shape itself survive. */
@media (forced-colors: active) {
  .aparte-badge { border-color: currentColor; }
}
/* =========================================
   aparte-tag — a removable chip
   =========================================

       <span class="aparte-tag">
         <span class="aparte-tag__label">typescript</span>
         <button class="aparte-tag__remove" aria-label="Remove typescript"><aparte-icon name="close"></aparte-icon></button>
       </span>
       <span class="aparte-tag aparte-tag--sm"><span class="aparte-tag__label">draft</span></span>
   */

.aparte-tag {
  box-sizing: border-box;
  display: inline-flex;
  vertical-align: middle;
  align-items: center;
  gap: var(--aparte-tag-gap);
  max-width: 100%;
  padding: var(--aparte-tag-padding);
  border: var(--aparte-border-width) solid var(--aparte-border);
  border-radius: var(--aparte-tag-radius);
  background: var(--aparte-surface-2);
  color: var(--aparte-text);
  font-size: var(--aparte-tag-font-size);
  line-height: var(--aparte-line-height-none);
}

.aparte-tag__label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* The tag's own `line-height: none` is 1 — a line box exactly as tall as the font —
     so every descender fell outside it and `overflow: hidden` cut it off. "typescript"
     lost the tail of its `y`, and so does any label with a p, g, q or j. The tag keeps
     `none` for the box it draws; the LABEL needs the room its glyphs actually occupy. */
  line-height: var(--aparte-line-height-tight);
}

.aparte-tag__remove {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: var(--aparte-tag-remove-size);
  height: var(--aparte-tag-remove-size);
  padding: 0;
  margin: 0;
  border: 0;
  border-radius: var(--aparte-radius-full);
  background: transparent;
  color: var(--aparte-text-muted);
  font: inherit;
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  transition:
    background var(--aparte-duration-fast) var(--aparte-ease),
    color var(--aparte-duration-fast) var(--aparte-ease);
}
.aparte-tag__remove > svg { pointer-events: none; width: var(--aparte-tag-remove-icon-size); height: var(--aparte-tag-remove-icon-size); }

.aparte-tag__remove:hover:not(:disabled) { background: var(--aparte-surface-3); color: var(--aparte-text); }
.aparte-tag__remove:active:not(:disabled) { filter: brightness(0.93); }
.aparte-tag__remove:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}
.aparte-tag__remove:disabled { opacity: var(--aparte-disabled-opacity); cursor: not-allowed; pointer-events: none; }

.aparte-tag--sm { gap: var(--aparte-space-1); padding: var(--aparte-space-1) var(--aparte-space-2); font-size: var(--aparte-font-size-2xs); }
/*
 * aparté — the thumbnail. A media tile: a fixed square that clips whatever is dropped
 * into it, with room for a control in a corner.
 *
 *     <span class="aparte-thumbnail aparte-thumbnail--lg"><img class="aparte-thumbnail__image"
 *           src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 3'%3E%3Crect width='2' height='3' fill='%23b07d33'/%3E%3C/svg%3E"
 *           alt="A screenshot"></span>
 *     <span class="aparte-thumbnail"><span class="aparte-thumbnail__label">PDF</span></span>
 *     <span class="aparte-thumbnail aparte-thumbnail--sm"><span class="aparte-thumbnail__label">MD</span></span>
 *
 * Distinct from `.aparte-avatar`, which is the same shape for a different reason: an
 * avatar stands for a PERSON and falls back to initials, so it carries type styling and
 * a text colour. A thumbnail stands for a FILE and falls back to an icon or a label, so
 * it carries none of that — and it is `position: relative` because a remove button or a
 * badge sits on it, which an avatar never has.
 */

.aparte-thumbnail {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* Tiles of different sizes in one row line up by their TOP edge. As inline boxes
     they aligned on the baseline instead, which is the bottom edge here — so a large
     tile beside two small ones pushed the small ones down and the row read as three
     unrelated things rather than one strip. An attachment strip mixes sizes by nature
     (a wide screenshot next to a file chip), so this is the common case, not the
     exception. Same family as the `vertical-align: middle` the form controls needed,
     opposite value: a control belongs ON its line of text, a tile starts a row. */
  vertical-align: top;
  flex-shrink: 0;
  box-sizing: border-box;
  inline-size: var(--aparte-thumbnail-size);
  block-size: var(--aparte-thumbnail-size);
  border: var(--aparte-border-width) solid var(--aparte-thumbnail-border);
  border-radius: var(--aparte-thumbnail-radius);
  background: var(--aparte-thumbnail-bg);
  /* The tile is the frame: anything larger is cropped by it rather than allowed to
     push the row it sits in. */
  overflow: hidden;
}

.aparte-thumbnail--sm { --aparte-thumbnail-size: var(--aparte-thumbnail-size-sm); }
.aparte-thumbnail--lg { --aparte-thumbnail-size: var(--aparte-thumbnail-size-lg); }

/* `cover` rather than `contain`: a tile of mixed aspect ratios reads as a grid only if
   every one of them fills its frame. */
.aparte-thumbnail__image {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  display: block;
}

/* A label instead of an image — a file extension, a page count. */
.aparte-thumbnail__label {
  font-size: var(--aparte-font-size-2xs);
  font-weight: var(--aparte-font-weight-semibold);
  color: var(--aparte-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

@media (forced-colors: active) {
  .aparte-thumbnail { border-color: currentColor; }
}

/* ── The attachment strip and its tiles ─────────────────────────────────────────
   Shared by the composer preview and the strip under a sent message: the bubble
   emits the very same classes. These rules lived in composer.css, a COMPONENT sheet,
   so the bubble — which imports no composer — rendered its file chips bare. A recipe
   lives in the display layer; the composer keeps only what is its own (the ✗ button). */
.aparte-attachments {
  display: flex;
  flex-wrap: wrap;
  /* The strip sits directly above the message bubble and belongs to it, so it
     must share the bubble's edge. The user bubble hugs its text on the LEADING
     edge (AI-chat convention, see .aparte-message-content below), so anchoring
     the strip to the trailing edge would split one message across both sides. */
  justify-content: flex-start;
  gap: var(--aparte-space-3);
  margin-bottom: var(--aparte-space-4);
  /* Really small tiles inside the conversation. */
  --aparte-attachment-image-size: 40px;
  /* Many attachments → cap the height and scroll instead of growing forever. */
  max-height: var(--aparte-attachments-max-height);
  overflow-y: auto;
}

/* The class `display: flex` above would otherwise override the `hidden`
   attribute — restore it so an empty attachment strip takes no space. */
.aparte-attachments[hidden] {
  display: none;
}

/* ── Attachment thumbnail tile ──────────────────────────────────────────────
   Shared by the composer attachment preview and the sent-message attachment
   strip. A square that shows the image itself (or a file glyph); the filename
   and — in the composer — a remove button surface on hover. */
/* The tile is `.aparte-thumbnail`; the composer only sets its own measurements and
   the quieter ground an attachment strip uses. */
.aparte-thumb {
  --aparte-thumbnail-size: var(--aparte-attachment-image-size);
  --aparte-thumbnail-radius: var(--aparte-thumb-radius);
  --aparte-thumbnail-bg: var(--aparte-attachment-chip-bg);
  --aparte-thumbnail-border: var(--aparte-attachment-chip-border);
}

/* Only an image the app declared it can preview is a button — see
   aparteGlobalConfig.setHostHandlers(). Undeclared, it is a picture, not a control.

   Two selectors because the two strips carry the role in two places, and both are
   right. The sent-message strip has no ✕, so its TILE is the whole control. The
   composer's tile wraps the remove button, so the control there is the IMAGE — a
   button inside a button is what the role on the tile used to make. */
.aparte-thumb--image[role='button'],
.aparte-thumbnail__image[role='button'] {
  cursor: pointer;
}

.aparte-thumb--image[role='button']:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

/* Inset, unlike the tile's. The image fills the tile edge to edge and the tile is the
   frame (`overflow: hidden` above), so an outline drawn outward is cropped away — the
   focus ring would simply not exist. Pulled in by its own width, it lands just inside
   the border and stays whole. */
.aparte-thumbnail__image[role='button']:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: calc(-1 * var(--aparte-focus-outline-width));
}

.aparte-thumb--file {
  display: flex;
  align-items: center;
  justify-content: center;
}

.aparte-thumb__name {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: var(--aparte-thumb-name-padding);
  font-size: var(--aparte-font-size-2xs);
  line-height: var(--aparte-line-height-tight);
  color: var(--aparte-thumb-name-color);
  background: var(--aparte-thumb-name-scrim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 0;
  transition: opacity var(--aparte-duration-base) var(--aparte-ease);
  pointer-events: none;
}
.aparte-thumb:hover .aparte-thumb__name {
  opacity: 1;
}
/* =========================================
   aparte-spinner — a rotating indicator
   =========================================

       <span class="aparte-spinner aparte-spinner--sm" role="status" aria-label="Loading"></span>
       <span class="aparte-spinner" role="status" aria-label="Loading"></span>
       <span class="aparte-spinner aparte-spinner--lg" role="status" aria-label="Loading"></span>
   */

.aparte-spinner {
  display: inline-block;
  vertical-align: middle;
  flex-shrink: 0;
  /* The token is the ring's outer size, border included: without this the ring painted
     16/20/28 for tokens of 12/16/24 (the 2px stroke added on each side), and painted
     the token's value only on a host page with its own border-box reset. */
  box-sizing: border-box;
  width: var(--aparte-spinner-size);
  height: var(--aparte-spinner-size);
  border: var(--aparte-spinner-thickness) solid currentColor;
  /* The one open side that makes rotation read as motion. Any logical side works
     here — the animation is symmetric, so nothing about it needs to differ
     between LTR and RTL; it is a logical property purely to stay off left/right. */
  border-inline-end-color: transparent;
  border-radius: var(--aparte-radius-full);
  color: var(--aparte-text-muted);
  animation: aparte-spin var(--aparte-duration-spin) linear infinite;
}

.aparte-spinner--sm { --aparte-spinner-size: var(--aparte-spinner-size-sm); }
.aparte-spinner--lg { --aparte-spinner-size: var(--aparte-spinner-size-lg); }

/* Under reduced motion the loop stops outright. The duration tokens are reset to
   0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
   is not stillness, it is a flicker. The descendant sweep there does not reach a
   recipe used outside aparté's own elements, so each looping recipe stops itself,
   the way display/icon.css already does. */
@media (prefers-reduced-motion: reduce) {
  .aparte-spinner { animation: none; }
}
/* =========================================
   aparte-progress — determinate bar + indeterminate variant
   =========================================
   The consumer owns the ARIA: role="progressbar" plus aria-valuenow/min/max on
   the determinate form, and aria-valuetext (or nothing, per platform convention)
   on the indeterminate one — this file only paints what those attributes mean.

       <div class="aparte-progress" role="progressbar" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100"
            style="--aparte-progress-value: 60">
         <div class="aparte-progress__bar"></div>
       </div>
       <div class="aparte-progress aparte-progress--indeterminate" role="progressbar" aria-label="Working">
         <div class="aparte-progress__bar"></div>
       </div>
   */

.aparte-progress {
  --aparte-progress-value: 0;

  position: relative;
  overflow: hidden;
  width: 100%;
  height: var(--aparte-progress-height);
  border-radius: var(--aparte-radius-full);
  /* The track: a reference the value is read against, so it has to be seen. A surface
     level put it at 1.06:1 in light and 1.20:1 in dark — one token, two distances. */
  background: var(--aparte-track);
}

.aparte-progress__bar {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  width: clamp(0%, calc(var(--aparte-progress-value) * 1%), 100%);
  border-radius: inherit;
  background: var(--aparte-progress-fill);
  transition: width var(--aparte-duration-slow) var(--aparte-ease);
}

.aparte-progress--indeterminate .aparte-progress__bar {
  width: var(--aparte-progress-indeterminate-width);
  animation: aparte-progress-slide var(--aparte-duration-pulse) var(--aparte-ease) infinite;
}

/* Under reduced motion the loop stops outright. The duration tokens are reset to
   0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
   is not stillness, it is a flicker. The descendant sweep there does not reach a
   recipe used outside aparté's own elements, so each looping recipe stops itself,
   the way display/icon.css already does.
   Stopped, the sliding segment would sit at one spot and read as a value; the whole
   track filled says "busy" without naming an amount. */
@media (prefers-reduced-motion: reduce) {
  .aparte-progress--indeterminate .aparte-progress__bar {
    animation: none;
    width: 100%;
    inset-inline-start: 0;
  }
}

/* inset-inline-start, not transform: translateX — a transform has no logical
   axis, so an RTL page would slide the indeterminate segment backwards. Animating
   a logical inset instead makes the sweep follow reading direction automatically. */
@keyframes aparte-progress-slide {
  0%   { inset-inline-start: -40%; }
  100% { inset-inline-start: 100%; }
}

/* The fill's BACKGROUND is the only thing that says how much is done — no text,
   no border marks its edge — so forced-colors, which drops backgrounds, would
   erase the value entirely. Highlight is a system colour forced-colors is
   required to honour when an author asks for it explicitly, the same escape
   button.css's own focus ring already uses. */
@media (forced-colors: active) {
  .aparte-progress { border: var(--aparte-border-width) solid CanvasText; }
  .aparte-progress__bar { background: Highlight; }
}
/* =========================================
   aparte-skeleton — a loading placeholder
   =========================================
   Decorative: give the element aria-hidden="true" (or wrap the group behind a
   single aria-busy region) so a screen reader does not read out an empty box.
   A skeleton is a block with no margin of its own: the recipe spaces consecutive
   text lines and nothing else, so a GROUP is laid out by its container — a grid
   with a gap, the way the real card it stands in for is. Four lines glued edge to
   edge is what the example used to show.

       <div style="display: grid; grid-template-columns: auto 1fr; gap: var(--aparte-space-3); align-items: start" aria-busy="true">
         <span class="aparte-skeleton aparte-skeleton--circle" aria-hidden="true"></span>
         <div>
           <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span>
           <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span>
           <span class="aparte-skeleton aparte-skeleton--text" style="max-inline-size: 60%" aria-hidden="true"></span>
         </div>
         <span class="aparte-skeleton aparte-skeleton--rect" style="grid-column: 2" aria-hidden="true"></span>
       </div>

   The last line is shorter because the last line of a paragraph is, and the block sits
   in the text column, under the lines it stands for — not across the avatar's. The
   rect's height is the family's token (`--aparte-skeleton-rect-height`), not an inline
   value that contradicts it.
   */

.aparte-skeleton {
  display: block;
  background-color: var(--aparte-skeleton-base);
  background-image: linear-gradient(
    90deg,
    transparent,
    var(--aparte-skeleton-shine),
    transparent
  );
  background-repeat: no-repeat;
  background-size: 200% 100%;
  animation: aparte-skeleton-shimmer var(--aparte-duration-pulse) linear infinite;
}

/* Under reduced motion the loop stops outright. The duration tokens are reset to
   0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
   is not stillness, it is a flicker. The descendant sweep there does not reach a
   recipe used outside aparté's own elements, so each looping recipe stops itself,
   the way display/icon.css already does.
   The shine goes with the shimmer: a stopped gradient would sit as a pale band. */
@media (prefers-reduced-motion: reduce) {
  .aparte-skeleton {
    animation: none;
    background-image: none;
  }
}

.aparte-skeleton--text {
  width: 100%;
  height: var(--aparte-skeleton-text-height);
  border-radius: var(--aparte-radius-sm);
}
.aparte-skeleton--text + .aparte-skeleton--text { margin-block-start: var(--aparte-skeleton-text-gap); }
/* The ragged last line reads as "text", where a full-width block reads as a bar. */
.aparte-skeleton--text:last-child:not(:first-child) { width: var(--aparte-skeleton-text-last-width); }

.aparte-skeleton--circle {
  width: var(--aparte-skeleton-circle-size);
  height: var(--aparte-skeleton-circle-size);
  border-radius: var(--aparte-radius-full);
}

.aparte-skeleton--rect {
  width: 100%;
  height: var(--aparte-skeleton-rect-height);
  border-radius: var(--aparte-radius-md);
}

/* A translucent white sweep lightens whatever sits under it in EITHER theme,
   which a second named surface colour cannot promise — this repo's own surface
   steps are not consistently ordered light-to-dark across the two palettes, so
   picking two of them for base/shine would read backwards in one of the themes. */
@keyframes aparte-skeleton-shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
/* =========================================
   aparte-divider — horizontal, vertical, optionally labelled
   =========================================
   The line is a BORDER, not a background — see the progress bar's comment above
   for why: a background disappears under forced-colors, a border does not.

       <hr class="aparte-divider">
       <div class="aparte-divider aparte-divider--label">
         <span class="aparte-divider__label">or</span>
       </div>
   */

.aparte-divider {
  border: 0;
  border-block-end: var(--aparte-border-width) solid var(--aparte-border);
  inline-size: 100%;
  block-size: 0;
  margin-block: var(--aparte-divider-margin);
}

.aparte-divider--vertical {
  border-block-end: 0;
  border-inline-end: var(--aparte-border-width) solid var(--aparte-border);
  inline-size: 0;
  block-size: auto;
  align-self: stretch;
  margin-block: 0;
  margin-inline: var(--aparte-divider-margin);
}

/* A labelled divider is a row: [line][label][line]. Meant for a <div
   role="separator">, not an <hr> — generated content on a replaced-ish void
   element is not worth the fragility when a div does the job plainly. */
.aparte-divider--label {
  display: flex;
  align-items: center;
  gap: var(--aparte-divider-label-gap);
  border-block-end: 0;
  inline-size: 100%;
  block-size: auto;
}
.aparte-divider--label::before,
.aparte-divider--label::after {
  content: '';
  flex: 1;
  border-block-end: var(--aparte-border-width) solid var(--aparte-border);
}
.aparte-divider__label {
  flex-shrink: 0;
  color: var(--aparte-text-muted);
  font-size: var(--aparte-divider-label-font-size);
  white-space: nowrap;
}
/* =========================================
   aparte-alert — a callout
   =========================================

       <div class="aparte-alert aparte-alert--info" role="status">
         <span class="aparte-alert__icon"><aparte-icon name="info"></aparte-icon></span>
         <div class="aparte-alert__body">
           <p class="aparte-alert__title">Model switched</p>
           <p class="aparte-alert__message">The next reply uses the model you just picked.</p>
         </div>
         <button class="aparte-alert__dismiss" aria-label="Dismiss"><aparte-icon name="close"></aparte-icon></button>
       </div>
       <div class="aparte-alert aparte-alert--danger" role="alert">
         <span class="aparte-alert__icon"><aparte-icon name="alertTriangle"></aparte-icon></span>
         <div class="aparte-alert__body">
           <p class="aparte-alert__message">The request was refused: no API key is set.</p>
         </div>
       </div>
   */

.aparte-alert {
  --aparte-alert-intent: var(--aparte-info);

  position: relative;
  display: flex;
  align-items: flex-start;
  gap: var(--aparte-alert-gap);
  padding: var(--aparte-alert-padding);
  border: var(--aparte-border-width) solid color-mix(in srgb, var(--aparte-alert-intent) 30%, transparent);
  border-inline-start: var(--aparte-alert-rail-width) solid var(--aparte-alert-intent);
  border-radius: var(--aparte-alert-radius);
  background: color-mix(in srgb, var(--aparte-alert-intent) 8%, var(--aparte-surface-1));
  color: var(--aparte-text);
}

/* Seven intents, one uniform formula. Two of these already have bespoke central
   tokens (--aparte-error-surface, --aparte-success-surface) built for the error
   segment, but reaching for them here would make two of seven intents look like a
   different component from the other five — the tint has to come from the same
   color-mix as every other intent for the family to read as one thing. */
.aparte-alert--primary   { --aparte-alert-intent: var(--aparte-primary); }
.aparte-alert--secondary { --aparte-alert-intent: var(--aparte-secondary); }
.aparte-alert--neutral   { --aparte-alert-intent: var(--aparte-neutral); }
.aparte-alert--info      { --aparte-alert-intent: var(--aparte-info); }
.aparte-alert--success   { --aparte-alert-intent: var(--aparte-success); }
.aparte-alert--warning   { --aparte-alert-intent: var(--aparte-warning); }
.aparte-alert--danger    { --aparte-alert-intent: var(--aparte-error); }

.aparte-alert__icon {
  display: inline-flex;
  flex-shrink: 0;
  width: var(--aparte-alert-icon-size);
  height: var(--aparte-alert-icon-size);
  color: var(--aparte-alert-intent);
}
.aparte-alert__icon > svg { width: 100%; height: 100%; }

.aparte-alert__body { flex: 1; min-width: 0; }

.aparte-alert__title {
  margin: 0 0 var(--aparte-space-1);
  color: var(--aparte-text);
  font-weight: var(--aparte-font-weight-semibold);
  font-size: var(--aparte-alert-title-font-size);
}
/* Body text stays neutral rather than tinted by the intent: the accent rail and
   the icon already say what kind of alert this is, and copy tinted seven
   different colours across seven intents is copy some of which reads worse than
   plain text does. */
.aparte-alert__message {
  margin: 0;
  color: var(--aparte-text-secondary);
  font-size: var(--aparte-alert-message-font-size);
  line-height: var(--aparte-line-height-normal);
}
.aparte-alert__body > .aparte-alert__message:only-child { margin: 0; }

.aparte-alert__dismiss {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: var(--aparte-alert-dismiss-size);
  height: var(--aparte-alert-dismiss-size);
  padding: 0;
  margin-inline-start: auto;
  border: 0;
  border-radius: var(--aparte-radius-sm);
  background: transparent;
  color: var(--aparte-text-muted);
  font: inherit;
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  transition:
    background var(--aparte-duration-fast) var(--aparte-ease),
    color var(--aparte-duration-fast) var(--aparte-ease);
}
.aparte-alert__dismiss:hover:not(:disabled) {
  background: color-mix(in srgb, var(--aparte-alert-intent) 16%, transparent);
  color: var(--aparte-text);
}
.aparte-alert__dismiss:active:not(:disabled) { filter: brightness(0.93); }
.aparte-alert__dismiss:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}
.aparte-alert__dismiss:disabled { opacity: var(--aparte-disabled-opacity); cursor: not-allowed; pointer-events: none; }
/* =========================================
   aparte-card — a surface with optional header/body/footer
   =========================================

       <div class="aparte-card">
         <div class="aparte-card__header">Usage this month</div>
         <div class="aparte-card__body">
           <p>412 000 tokens across 38 conversations.</p>
         </div>
         <div class="aparte-card__footer">Resets on the 1st.</div>
       </div>
   */

.aparte-card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: var(--aparte-border-width) solid var(--aparte-border);
  border-radius: var(--aparte-card-radius);
  background: var(--aparte-surface-1);
  color: var(--aparte-text);
}

.aparte-card__header {
  padding: var(--aparte-card-header-padding);
  border-block-end: var(--aparte-border-width) solid var(--aparte-border);
  font-weight: var(--aparte-font-weight-semibold);
  font-size: var(--aparte-card-header-font-size);
}

.aparte-card__body {
  flex: 1;
  padding: var(--aparte-card-body-padding);
}

/* The body owns its edges: a paragraph's block margins fold into the padding instead of
   stacking on it. The sheet's own example puts a <p> here, and the body band measured
   twice the height of the header and footer beside it. */
.aparte-card__body > :first-child { margin-block-start: 0; }
.aparte-card__body > :last-child { margin-block-end: 0; }

.aparte-card__footer {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-3);
  padding: var(--aparte-card-footer-padding);
  border-block-start: var(--aparte-border-width) solid var(--aparte-border);
}
/* =========================================
   aparte-kbd — a keyboard key
   =========================================
   The monospace stack is --aparte-code-font-family, reused rather than declared
   again: it is already the library's one answer to "what font is code", and a
   key label is exactly that question asked a second time.

       Press <kbd class="aparte-kbd">Ctrl</kbd> + <kbd class="aparte-kbd">K</kbd> to search,
       or <kbd class="aparte-kbd">Esc</kbd> to close.
   */

.aparte-kbd {
  display: inline-flex;
  /* A key chip sits IN a sentence, so it aligns to that sentence. As an `inline-flex`
     box its baseline is its bottom margin edge, which pushed it above the text it was
     printed among — "Press Ctrl + K to search" read as if the keys were superscript.
     Third instance of the same defect today: the form controls needed `middle`, the
     thumbnail row needed `top`, and this needs `middle` too, for the same reason each
     time — an inline box with no text of its own has no baseline worth aligning to. */
  vertical-align: middle;
  align-items: center;
  justify-content: center;
  min-width: var(--aparte-kbd-min-width);
  padding: var(--aparte-kbd-padding);
  border: var(--aparte-border-width) solid var(--aparte-border);
  /* A thicker bottom edge is the whole bevel — a raised key, with no shadow to
     go blank under forced-colors or a reduced-transparency setting. */
  border-block-end-width: var(--aparte-kbd-border-bottom-width);
  border-radius: var(--aparte-kbd-radius);
  background: var(--aparte-kbd-bg);
  color: var(--aparte-text-secondary);
  font-family: var(--aparte-code-font-family);
  font-size: var(--aparte-kbd-font-size);
  line-height: var(--aparte-line-height-none);
}

/*
 * aparté — The mark: a row a list has settled on.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 *
 * WHAT IT IS. A chosen option, an accepted answer, the conversation you are in: the
 * one item among its peers that a decision landed on. Before this sheet the select
 * drew it one way (an accent tint plus an inset bar on the start edge), the field
 * choice another (a primary border), the conversation list a third (its own
 * background and weight), and the ask-user receipt none at all — four vocabularies
 * for one meaning, measured in the UI audit of 2026-08-28. The market agrees on two
 * signals: a tint on the ground (Radix, shadcn, Material's state layer) and a bar on
 * the row's start edge (GitHub Primer's ActionList). This recipe is both, on one
 * intent axis, and every row that means "this one" wears it.
 *
 * THE AXIS. `--aparte-mark-intent` is the fill, primary by default: chosen. The
 * modifiers set it to the theme's other masters — success for an answer that was
 * given or a call that was approved, danger for a destructive outcome, neutral for
 * the item you are merely in. `--quiet` is the outcome that did NOT happen: a declined
 * request, a version superseded — no tint, no bar, the muted voice. The market never
 * strikes through; neither does this.
 *
 * THE BAR IS DRAWN IN INK. The tint is the fill at 18%, which carries almost no
 * contrast by itself (1.2:1 in the light theme); the bar is the whole non-text signal,
 * so it reads `--aparte-mark-intent-ink` — the master's ink, the one the button and
 * the badge already derive for text on a ground. The raw success fill measured 2.27:1
 * on the light surface, under the 3:1 WCAG asks of a graphic; its ink is 7.1:1.
 *
 * THE BAR IS A PSEUDO-ELEMENT, NOT A SHADOW. An inset shadow knows only left and
 * right, so a right-to-left row got its bar on the wrong edge unless a document-level
 * `[dir="rtl"]` rule caught it — which a `dir` on the row itself, or `dir="auto"`,
 * escapes. `inset-inline-start` is the start edge whatever the direction, at any level.
 * Nothing shifts: the pseudo-element is out of the flow.
 *
 * THE MEASURES. `--aparte-mark-tint` and `--aparte-mark-bar` live in theme.css so a
 * theme moves every mark at once.
 */
.aparte-mark {
  --aparte-mark-intent: var(--aparte-primary);
  --aparte-mark-intent-ink: var(--aparte-primary-ink);
  position: relative;
  background: color-mix(in srgb, var(--aparte-mark-intent) var(--aparte-mark-tint), transparent);
}

.aparte-mark::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  width: var(--aparte-mark-bar);
  background: var(--aparte-mark-intent-ink);
  pointer-events: none;
}

.aparte-mark--success {
  --aparte-mark-intent: var(--aparte-success);
  --aparte-mark-intent-ink: var(--aparte-success-ink);
}

.aparte-mark--danger {
  --aparte-mark-intent: var(--aparte-error);
  --aparte-mark-intent-ink: var(--aparte-error-ink);
}

/* The neutral master has no derived ink; the fill is its own ink (5:1 light, 6.9:1 dark). */
.aparte-mark--neutral {
  --aparte-mark-intent: var(--aparte-neutral);
  --aparte-mark-intent-ink: var(--aparte-neutral);
}

/* The outcome that did not happen. */
.aparte-mark--quiet {
  background: none;
  color: var(--aparte-text-muted);
}

.aparte-mark--quiet::before {
  display: none;
}
/*
 * aparté — layered surfaces. Ready-made classes for the things that float ABOVE the
 * transcript: tabs, an accordion, a dropdown menu, a popover, a tooltip.
 *
 * Drawer and toast are deliberately NOT here: each needs a stack manager — something
 * that owns the order of what floats over the page — and that is the consuming
 * application's job, not a component stylesheet's. The dialog was on that list until
 * 0.16.0; it has its own recipe now (`dialog.css`), because the browser's `<dialog>`
 * brings the top layer, the focus trap and Escape with it.
 *
 * Everything below is the LOOK only. Where and when a surface opens is markup and
 * script the caller owns: a <details open> toggling itself, a positioning library placing
 * a menu next to its trigger. Nothing here reaches for position:fixed on your behalf.
 *
 *     <div class="aparte-tabs" role="tablist" aria-label="Result view">
 *       <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-a-preview" aria-controls="tabs-a-preview-panel" aria-selected="true" tabindex="0">Preview</button>
 *       <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-a-code" aria-controls="tabs-a-code-panel" aria-selected="false" tabindex="-1">Code</button>
 *     </div>
 *     <div class="aparte-tabs__panel" role="tabpanel" id="tabs-a-preview-panel" aria-labelledby="tabs-a-preview">The panel for the selected tab.</div>
 *     <div class="aparte-tabs__panel" role="tabpanel" id="tabs-a-code-panel" aria-labelledby="tabs-a-code" hidden>The panel the other tab opens.</div>
 *
 *     <div class="aparte-accordion">
 *       <details class="aparte-accordion__item" open>
 *         <summary class="aparte-accordion__header">Section title
 *           <aparte-icon name="expand"></aparte-icon>
 *         </summary>
 *         <div class="aparte-accordion__panel">What the section says once it is open.</div>
 *       </details>
 *     </div>
 *
 *     <div class="aparte-menu" role="menu">
 *       <div class="aparte-menu__label">Export as</div>
 *       <button class="aparte-menu__item" role="menuitem">PNG</button>
 *       <div class="aparte-menu__separator"></div>
 *       <button class="aparte-menu__item" role="menuitemcheckbox" aria-checked="true">
 *         Include metadata
 *       </button>
 *     </div>
 *
 * `.aparte-popover` and `.aparte-tooltip` are single boxes: drop content straight in
 * and position the element yourself, however the app already places floating things
 * (an absolutely-placed div, a floating-UI anchor, a JS library's own logic).
 *
 * This layer is NEUTRAL: it is what a UI library offers, not a summary of what this
 * repo happens to use. A class nothing wears today still ships, because the plugin
 * that needs it is not written yet.
 */

/* ── Tabs ────────────────────────────────────────────────────────────────────── */

/* Why this file carries TWO banners: the one above it opens the whole Surfaces group
   (`aparté — …`), and the generated reference consumes that as the group's intro. A
   family gets its prose and its live example from a banner named after it
   (`aparte-tabs — …`), so without the second one the Tabs family reached the page as a
   bare list of class names — no sentence, no preview — while its own content was shown
   as the Surfaces overview. `bannersIn()` in gen-css-classes.mjs tells the two apart by
   that first word; a comment like this one, named after neither, is ignored. */

/* =========================================
   aparte-tabs — one row of tabs, and the panel under it
   =========================================
   Two looks, one behaviour. `--underline` marks the current tab with a rule beneath it
   and is the quieter of the two; `--segmented` puts the whole row in a track and fills
   the current tab, which reads as a control rather than as navigation. Neither moves
   anything: `aria-selected` is what says which tab is current, and the app owns it.

   The markup below is the whole contract of the role, and all of it is yours to write:
   ONE tab stop for the list (`tabindex="0"` on the selected tab, `-1` on the rest),
   `aria-controls` from each tab to its `role="tabpanel"`, and `aria-labelledby` back.
   The app also owns the ArrowLeft/ArrowRight/Home/End handler that moves the selection
   and the stop together — `@aparte/plugin-artifacts`' card has a working one to copy.
   A tablist without those is louder than plain buttons and says less: every tab a tab
   stop, and none of them naming what it opens.

       <div class="aparte-tabs aparte-tabs--underline" role="tablist" aria-label="Result view">
         <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-u-preview" aria-controls="tabs-u-preview-panel" aria-selected="true" tabindex="0">Preview</button>
         <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-u-code" aria-controls="tabs-u-code-panel" aria-selected="false" tabindex="-1">Code</button>
       </div>
       <div class="aparte-tabs__panel" role="tabpanel" id="tabs-u-preview-panel" aria-labelledby="tabs-u-preview">What the selected tab shows.</div>
       <div class="aparte-tabs__panel" role="tabpanel" id="tabs-u-code-panel" aria-labelledby="tabs-u-code" hidden>What the other tab shows.</div>

       <div class="aparte-tabs aparte-tabs--segmented" role="tablist" aria-label="Theme">
         <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-s-light" aria-controls="tabs-s-light-panel" aria-selected="true" tabindex="0">Light</button>
         <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-s-dark" aria-controls="tabs-s-dark-panel" aria-selected="false" tabindex="-1">Dark</button>
         <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-s-system" aria-controls="tabs-s-system-panel" aria-selected="false" tabindex="-1">System</button>
       </div>
       <div class="aparte-tabs__panel" role="tabpanel" id="tabs-s-light-panel" aria-labelledby="tabs-s-light">The light theme's settings.</div>
       <div class="aparte-tabs__panel" role="tabpanel" id="tabs-s-dark-panel" aria-labelledby="tabs-s-dark" hidden>The dark theme's settings.</div>
       <div class="aparte-tabs__panel" role="tabpanel" id="tabs-s-system-panel" aria-labelledby="tabs-s-system" hidden>Whatever the system asks for.</div>
   ========================================= */

.aparte-tabs {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-1);
  /* A tab list is exactly as wide as its container, and a chat panel is often
     narrower than the tab labels it holds — a code-preview strip with six
     languages, say. Scrolling beats wrapping: a wrapped tab row eats vertical
     space that was never budgeted for it and reflows the panel beneath it every
     time a tab is added. */
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-inline: contain;
  /* No scrollbar: a chrome-less horizontal scroll region reads as "this row of
     tabs continues", where a scrollbar reads as "this is a list with a bottom".
     `scrollbar-width` covers Firefox, the pseudo-element covers Chrome/Safari,
     and `-ms-overflow-style` is free insurance for the one legacy engine that
     never got either. */
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.aparte-tabs::-webkit-scrollbar {
  display: none;
}

.aparte-tabs__tab {
  appearance: none;
  -webkit-appearance: none;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  gap: var(--aparte-space-2);
  padding: var(--aparte-space-4) var(--aparte-space-5);
  border: none;
  /* The active indicator IS a border, at the same width whether it is showing or
     not — so selecting a tab never nudges its neighbours by changing box height.
     `--aparte-space-unit` doubles as the width because it is the one allowed
     token that already equals the thickness an indicator needs to read as a
     line rather than a hairline. */
  border-block-end: var(--aparte-space-unit) solid transparent;
  background: transparent;
  color: var(--aparte-text-muted);
  font: inherit;
  font-size: var(--aparte-font-size-md);
  font-weight: var(--aparte-font-weight-medium);
  line-height: var(--aparte-line-height-none);
  cursor: pointer;
  white-space: nowrap;
  user-select: none;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition:
    color var(--aparte-duration-fast) var(--aparte-ease),
    border-color var(--aparte-duration-fast) var(--aparte-ease),
    background var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-tabs__tab:hover:not(:disabled):not([aria-disabled='true']) {
  color: var(--aparte-text);
}

.aparte-tabs__tab:active:not(:disabled):not([aria-disabled='true']) {
  background: var(--aparte-surface-2);
  /* Logical corners: the press feedback rounds the two edges nearest the START of
     the block (the top, in the horizontal writing modes this row is normally used
     in) and leaves the underline edge square, so it reads as one continuous strip
     rather than four independent pills. */
  border-start-start-radius: var(--aparte-radius-sm);
  border-start-end-radius: var(--aparte-radius-sm);
}

.aparte-tabs__tab:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-tabs__tab[aria-selected='true'] {
  color: var(--aparte-primary-ink);
  border-block-end-color: var(--aparte-primary);
}

.aparte-tabs__tab:disabled,
.aparte-tabs__tab[aria-disabled='true'] {
  cursor: not-allowed;
  opacity: var(--aparte-disabled-opacity);
}

.aparte-tabs__panel {
  padding-block: var(--aparte-space-6);
  /* The tab's own inline inset, so the panel's text starts under the label of the tab
     that opened it rather than 11px to its left. */
  padding-inline: var(--aparte-space-5);
}

.aparte-tabs__panel:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

/* Underline is the default look and needs no class — restated explicitly anyway,
   the same reasoning `.aparte-btn--ghost` uses, so a caller that must be explicit
   about it (next to a `--segmented` sibling elsewhere on the page) can say so
   rather than merely omit the modifier. */
.aparte-tabs--underline {
  gap: var(--aparte-space-1);
  padding: 0;
  background: transparent;
  border-radius: 0;
}

.aparte-tabs--underline .aparte-tabs__tab {
  border-block-end-width: var(--aparte-space-unit);
  border-radius: 0;
}

/* Segmented — the whole list becomes one pill-shaped track and the active tab is
   its own raised chip, rather than an underline beneath a plain row. Same markup,
   same states; only the container and the active tab's fill change. */
.aparte-tabs--segmented {
  /* A control, not a bar: the track hugs its segments. As a block-level flex row it
     inherited its container's width — 1207px of track for 160px of chips at 1280. */
  display: inline-flex;
  gap: 0;
  padding: var(--aparte-space-1);
  background: var(--aparte-surface-2);
  border-radius: var(--aparte-radius-full);
}

.aparte-tabs--segmented .aparte-tabs__tab {
  border-block-end: none;
  border-radius: var(--aparte-radius-full);
  padding: var(--aparte-space-3) var(--aparte-space-5);
}

.aparte-tabs--segmented .aparte-tabs__tab:active:not(:disabled):not([aria-disabled='true']) {
  border-radius: var(--aparte-radius-full);
}

.aparte-tabs--segmented .aparte-tabs__tab[aria-selected='true'] {
  background: var(--aparte-surface-1);
  color: var(--aparte-text);
  /* Raised RELATIVE to the track, not an absolute surface level: surface-1 over
     surface-2 read as raised in light and as sunken in dark, the same rule inverting
     its elevation with the theme. A 1px ring in the border colour says "on top" on
     either ground. */
  box-shadow: 0 0 0 var(--aparte-border-width) var(--aparte-border);
}
/* =========================================
   aparte-accordion — stacked disclosures
   =========================================
   A column of items, each a header you press and a panel it reveals. The header works
   as a native `<summary>` inside `<details>` OR as a `<div role="button" aria-expanded>`
   — that choice decides whether the open state is the browser's or yours, and the CSS
   does not care either way. It is the whole clickable row, so it takes the same reset a
   button does.

       <div class="aparte-accordion">
         <details class="aparte-accordion__item" open>
           <summary class="aparte-accordion__header">
             Shipping
             <aparte-icon name="expand"></aparte-icon>
           </summary>
           <div class="aparte-accordion__panel">Free above 50 €, otherwise 4,90 €. Two to four working days.</div>
         </details>
         <details class="aparte-accordion__item">
           <summary class="aparte-accordion__header">
             Returns
             <aparte-icon name="expand"></aparte-icon>
           </summary>
           <div class="aparte-accordion__panel">Thirty days, in the original packaging.</div>
         </details>
         <details class="aparte-accordion__item">
           <summary class="aparte-accordion__header">
             Payment
             <aparte-icon name="expand"></aparte-icon>
           </summary>
           <div class="aparte-accordion__panel">Card, PayPal, or an invoice for a company account.</div>
         </details>
       </div>

   The chevron turns 180° when the item is open, and there are two selectors for it
   because there are two ways to be open: `aria-expanded` on the header for the ARIA
   pattern, `details[open]` on the ancestor for the native one. Nothing here opens or
   closes anything — this is a stylesheet, and the disclosure is the element's job or
   yours. */
.aparte-accordion {
  display: flex;
  flex-direction: column;
}

.aparte-accordion__item {
  border-block-end: var(--aparte-border-width) solid var(--aparte-border);
}

.aparte-accordion__item:last-child {
  border-block-end: none;
}

/* Works as a <summary> (native <details> disclosure) or as a <div role="button"
   aria-expanded>. Either way it is the whole clickable row, so it gets the same
   reset a button does: no native chrome, full width, cursor and text alignment
   fixed for a header that reads left-to-right of its own toggle icon. */
.aparte-accordion__header {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--aparte-space-3);
  width: 100%;
  padding: var(--aparte-space-5) var(--aparte-space-2);
  border: none;
  background: transparent;
  color: var(--aparte-text);
  font: inherit;
  font-size: var(--aparte-font-size-md);
  font-weight: var(--aparte-font-weight-medium);
  text-align: start;
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  /* Removes the native disclosure triangle when this sits on a <summary>. Firefox
     and current Chrome/Safari honour `list-style`; the pseudo-elements below catch
     the two engines that don't. Harmless, and a no-op, on a plain <div>. */
  list-style: none;
  transition: background var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-accordion__header::-webkit-details-marker {
  display: none;
}

.aparte-accordion__header::marker {
  display: none;
}

.aparte-accordion__header:hover:not(:disabled):not([aria-disabled='true']) {
  background: var(--aparte-surface-2);
}

.aparte-accordion__header:active:not(:disabled):not([aria-disabled='true']) {
  background: var(--aparte-surface-3);
}

.aparte-accordion__header:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-accordion__header:disabled,
.aparte-accordion__header[aria-disabled='true'] {
  cursor: not-allowed;
  opacity: var(--aparte-disabled-opacity);
}

/* Two spellings for the chevron, because the header's documented markup is a bare
   `<aparte-icon>` and a renderer that wraps a raw glyph needs a class to hang it on:
   the wrapper `.aparte-accordion__icon` (the thinking block), or an `<aparte-icon>` /
   `svg.aparte-icon` sitting directly in the header. Both are sized and both turn — the
   documented form used to match neither rule, so the kit's own example showed an open
   panel under a chevron still pointing down. */
.aparte-accordion__icon,
.aparte-accordion__header > aparte-icon,
.aparte-accordion__header > .aparte-icon {
  display: inline-flex;
  flex-shrink: 0;
  /* Fed to the glyph through its own knob, not drawn as a width/height: the icon
     recipe reads `--aparte-icon-size`, and a box declared here around it would leave
     the glyph at its loose default. The step is the one the button recipe uses for an
     icon glyph, so a disclosure chevron reads as the same weight of icon everywhere. */
  --aparte-icon-size: var(--aparte-space-8);
  color: var(--aparte-text-muted);
  transition: transform var(--aparte-duration-slow) var(--aparte-ease);
}

/* Two selectors for two ways an item can be open: the ARIA disclosure pattern
   puts aria-expanded on the header itself; native <details> puts `open` on the
   ancestor and gives the browser no attribute to select on at all. */
.aparte-accordion__header[aria-expanded='true'] .aparte-accordion__icon,
.aparte-accordion__header[aria-expanded='true'] > aparte-icon,
.aparte-accordion__header[aria-expanded='true'] > .aparte-icon,
details[open] > .aparte-accordion__header .aparte-accordion__icon,
details[open] > .aparte-accordion__header > aparte-icon,
details[open] > .aparte-accordion__header > .aparte-icon {
  transform: rotate(180deg);
}

.aparte-accordion__panel {
  padding-inline: var(--aparte-space-2);
  padding-block-end: var(--aparte-space-5);
  color: var(--aparte-text-secondary);
  font-size: var(--aparte-font-size-md);
  line-height: var(--aparte-line-height-relaxed);
}
/* =========================================
   aparte-menu — a floating list of commands
   =========================================
   The surface a dropdown, a context menu or an overflow menu is drawn on: a bordered,
   elevated column of rows. Items are the pressable ones, a label groups them, a
   separator divides them.

       <div class="aparte-menu" role="menu">
         <span class="aparte-menu__label">Message</span>
         <button class="aparte-menu__item" role="menuitem">Copy</button>
         <button class="aparte-menu__item" role="menuitem">Retry</button>
         <div class="aparte-menu__separator" role="separator"></div>
         <button class="aparte-menu__item" role="menuitem" disabled>Delete</button>
         <div class="aparte-menu__separator" role="separator"></div>
         <span class="aparte-menu__label">Mode</span>
         <button class="aparte-menu__item" role="menuitemradio" aria-checked="true">
           <span class="aparte-menu__body">
             Ask
             <span class="aparte-menu__description">Every tool call waits for you</span>
           </span>
         </button>
         <button class="aparte-menu__item" role="menuitemradio" aria-checked="false">
           <span class="aparte-menu__body">
             Auto
             <span class="aparte-menu__description">Runs everything, reports after</span>
           </span>
         </button>
       </div>

   The check mark is DRAWN HERE, not by you: an item carrying `aria-checked` (a
   `menuitemradio` or `menuitemcheckbox`) reserves a gutter, and the glyph shows when
   the value is `"true"` — so set the attribute and do not add a "✓" of your own, or the
   row shows two. A two-line item wraps its name and its `__description` in a `__body`;
   the gutter then spans both lines.

   WHERE it sits is yours. There is no positioning here and no anchoring logic —
   floating a box next to a trigger needs collision detection and a scroll listener,
   which is an interaction library's job, not a stylesheet's. Same split as the popover
   and the tooltip below it.

   The roles above are not decoration either: this draws a menu, it does not announce
   one. `role="menu"` / `role="menuitem"` and the arrow-key handling that pattern
   requires are yours to add. */
.aparte-menu {
  /* Component-local tokens: no shared knob in the token set covers a dropdown's
     own width and elevation, so they live here — same width as the select's
     own dropdown (`--aparte-select-min-width`) and the same elevation recipe
     as `--aparte-select-shadow`, since both are the identical "floating list"
     surface. */
  --aparte-menu-min-width: 200px;
  /* And a CAP, which this recipe was missing while `.aparte-popover` — the file calls
     the two "the identical floating list surface" — has carried one all along. With
     only a minimum, a menu placed as a block child stretched to its container: a
     dropdown spanning the full width of whatever held it, which is not a dropdown.
     `width: max-content` is what makes it hug its longest item between the two bounds,
     the way a menu sizes itself to "Include metadata" and stops. */
  --aparte-menu-max-width: 320px;

  display: flex;
  flex-direction: column;
  width: max-content;
  min-width: var(--aparte-menu-min-width);
  max-width: var(--aparte-menu-max-width);
  /* The two caps are the box's outer width, padding and border included — same rule
     as the popover, for the same reason. */
  box-sizing: border-box;
  padding: var(--aparte-space-2);
  background: var(--aparte-surface-1);
  border: var(--aparte-border-width) solid var(--aparte-border);
  border-radius: var(--aparte-radius-menu);
  box-shadow: var(--aparte-menu-shadow);
  color: var(--aparte-text);
  z-index: var(--aparte-z-dropdown);
}

.aparte-menu__item {
  /* Its own width plus its own padding, because core is light DOM: a host that
     never wrote `* { box-sizing: border-box }` is not a broken host, and this rule
     said `width: 100%` next to a padding. Measured in a frame without a reset: the
     row came out its parent width PLUS both paddings — 328px inside 308 at one window
     size — and clipped its last button by the right one. The absolute numbers depend on
     the window; the 20px of overflow is the padding, whatever the width.
     Per element, never a `*` selector — the same reason every other box-sizing in
     these sheets is written on the thing it belongs to. */

  box-sizing: border-box;
  appearance: none;
  -webkit-appearance: none;
  display: flex;
  align-items: center;
  gap: var(--aparte-space-3);
  width: 100%;
  padding: var(--aparte-space-3) var(--aparte-space-4);
  border: none;
  border-radius: var(--aparte-radius-sm);
  background: transparent;
  color: inherit;
  font: inherit;
  font-size: var(--aparte-font-size-md);
  line-height: var(--aparte-line-height-snug);
  text-align: start;
  cursor: pointer;
  user-select: none;
  transition: background var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-menu__item:hover:not(:disabled):not([aria-disabled='true']) {
  background: var(--aparte-surface-2);
}

.aparte-menu__item:active:not(:disabled):not([aria-disabled='true']) {
  background: var(--aparte-surface-3);
}

.aparte-menu__item:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-menu__item:disabled,
.aparte-menu__item[aria-disabled='true'] {
  cursor: not-allowed;
  opacity: var(--aparte-disabled-opacity);
  /* A disabled row must stay inert, not merely look it — otherwise it still opens
     a submenu on hover or fires on click via a wrapper listener upstream. */
  pointer-events: none;
}

/* The check mark for menuitemcheckbox / menuitemradio. The gutter is reserved on EVERY
   item of a panel that holds a checkable one — as an invisible glyph — so all the
   labels of that panel share one edge. It used to be reserved per checkable item, and
   a plain role="menuitem" beside a checkable one started 16px further left: two label
   edges in one menu. Text colour rather than a fill or a box-shadow is deliberate: a
   glyph inherits `color`, which forced-colors mode preserves, so the checked state
   survives that mode with no separate override. */
.aparte-menu:has(.aparte-menu__item[aria-checked]) .aparte-menu__item::before {
  content: '✓';
  flex-shrink: 0;
  visibility: hidden;
}

.aparte-menu__item[aria-checked='true']::before {
  visibility: visible;
}

/* A two-line item: a name and, under it, what choosing it means (a mode picker, a
   model with its size). `__body` stacks the two so the check gutter spans both lines
   — a consumer laid a grid over the item to get exactly this, and that grid was the
   only piece of specificity CSS his menu needed. */
.aparte-menu__body {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: calc(var(--aparte-space-1) / 2);
  min-width: 0;
}

.aparte-menu__description {
  font-size: var(--aparte-font-size-xs);
  font-weight: var(--aparte-font-weight-normal);
  color: var(--aparte-text-muted);
  text-align: start;
}

.aparte-menu__item:disabled .aparte-menu__description,
.aparte-menu__item[aria-disabled='true'] .aparte-menu__description {
  color: inherit;
}

.aparte-menu__separator {
  height: var(--aparte-border-width);
  margin-block: var(--aparte-space-2);
  margin-inline: var(--aparte-space-2);
  background: var(--aparte-border);
}

.aparte-menu__label {
  padding: var(--aparte-space-2) var(--aparte-space-4);
  color: var(--aparte-text-muted);
  font-size: var(--aparte-font-size-2xs);
  font-weight: var(--aparte-font-weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
/* =========================================
   aparte-popover — a floating box, and nothing else
   =========================================
   Deliberately just a box. A popover's content is arbitrary — a form, a colour picker, a
   card of details — so this supplies the surface, border and elevation that say "this is
   floating above the page" and leaves the layout of what is inside, and where the box
   itself sits, entirely to the caller.

       <div class="aparte-popover" role="dialog" aria-label="Filters">
         <label class="aparte-field-label" for="q">Search</label>
         <input class="aparte-field" id="q" />
         <button class="aparte-btn aparte-btn--primary aparte-btn--solid">Apply</button>
       </div>

   `--aparte-popover-max-width` is the one cap it imposes, so arbitrary content cannot
   grow the box unbounded. */
.aparte-popover {
  /* Component-local tokens, same reasoning as the menu's above: a cap that
     keeps arbitrary caller content (a form, a colour picker) from growing
     unbounded, and the same elevation recipe as every other dropdown-class
     surface in the library. */
  --aparte-popover-max-width: 320px;

  display: flex;
  flex-direction: column;
  gap: var(--aparte-space-3);
  /* The cap is the box's outer width: it measured 342px for a documented 320 (padding
     and border outside the cap) until the recipe said so itself. */
  box-sizing: border-box;
  max-width: var(--aparte-popover-max-width);
  padding: var(--aparte-space-5);
  background: var(--aparte-surface-1);
  border: var(--aparte-border-width) solid var(--aparte-border);
  border-radius: var(--aparte-radius-popover);
  box-shadow: var(--aparte-popover-shadow);
  color: var(--aparte-text);
  font-size: var(--aparte-font-size-md);
  line-height: var(--aparte-line-height-normal);
  z-index: var(--aparte-z-dropdown);
}
/*
 * aparté — the modal, on the browser's own <dialog>.
 *
 * One of the sheets src/index.ts imports in cascade order; check:derived-vars reads
 * them concatenated in that same order, the way a browser does.
 */

/*
 * aparte-dialog — a modal, drawn on the native <dialog>.
 * The kit said for a year that a dialog was "deliberately absent — a modal needs a
 * portal and a stack manager". The browser has had both since 2022: `<dialog>` with
 * `showModal()` puts the box in the top layer above everything, traps focus, closes
 * on Escape, hands the focus back, and draws a `::backdrop`. So this recipe styles
 * THAT element, and core's one addition is the wiring — a `data-aparte-dialog-open`
 * on any control opens the dialog it names, a `data-aparte-dialog-close` inside one
 * closes it, and a click on the backdrop closes it unless the dialog carries
 * `data-aparte-dialog-static`. Nothing here reaches for position:fixed on your behalf:
 * the top layer is the browser's.
 *
 *     <dialog class="aparte-dialog" id="settings" aria-labelledby="settings-title">
 *       <div class="aparte-dialog__header">
 *         <h2 class="aparte-dialog__title" id="settings-title">Settings</h2>
 *         <button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-dialog__close" type="button" aria-label="Close" data-aparte-dialog-close>
 *           <aparte-icon name="close"></aparte-icon>
 *         </button>
 *       </div>
 *       <div class="aparte-dialog__body">
 *         <label class="aparte-field-label" for="endpoint">Endpoint</label>
 *         <input class="aparte-field" id="endpoint" value="http://localhost:11434/v1">
 *       </div>
 *       <div class="aparte-dialog__footer">
 *         <button class="aparte-btn aparte-btn--ghost" type="button" data-aparte-dialog-close>Cancel</button>
 *         <button class="aparte-btn aparte-btn--primary aparte-btn--solid" type="button" data-aparte-dialog-close="saved">Save</button>
 *       </div>
 *     </dialog>
 *     <button class="aparte-btn aparte-btn--surface" type="button" data-aparte-dialog-open="settings">Open settings</button>
 *
 * Sizes: `--sm` (24rem), the default (32rem), `--lg` (48rem); under 30rem of window
 * every size becomes a full-screen sheet, which is what a settings hub is on a phone.
 * The value passed to `data-aparte-dialog-close="…"` becomes the dialog's
 * `returnValue`, read in its native `close` event.
 */
.aparte-dialog {
  --aparte-dialog-width: 32rem;

  box-sizing: border-box;
  width: min(var(--aparte-dialog-width), calc(100vw - 2 * var(--aparte-space-8)));
  max-height: calc(100dvh - 2 * var(--aparte-space-8));
  margin: auto;
  padding: 0;
  display: none;
  flex-direction: column;
  overflow: hidden;
  background: var(--aparte-surface-1);
  color: var(--aparte-text);
  border: var(--aparte-border-width) solid var(--aparte-border);
  border-radius: var(--aparte-radius-dialog);
  box-shadow: var(--aparte-menu-shadow);
}

/* `display: flex` only while open: a closed <dialog> must stay `display: none`, and a
   flex declaration on the bare class would show it. */
.aparte-dialog[open] {
  display: flex;
}

.aparte-dialog--sm {
  --aparte-dialog-width: 24rem;
}

.aparte-dialog--lg {
  --aparte-dialog-width: 48rem;
}

.aparte-dialog::backdrop {
  background: var(--aparte-scrim);
}

.aparte-dialog__header {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-3);
  padding: var(--aparte-space-5) var(--aparte-space-6) var(--aparte-space-3);
}

.aparte-dialog__title {
  flex: 1;
  min-width: 0;
  margin: 0;
  font-size: var(--aparte-font-size-lg);
  font-weight: var(--aparte-font-weight-semibold);
  line-height: var(--aparte-line-height-snug);
}

.aparte-dialog__close {
  flex-shrink: 0;
  color: var(--aparte-text-muted);
}

/* The body is the one region that scrolls, so a long form never pushes the footer
   off screen. */
.aparte-dialog__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: var(--aparte-space-3) var(--aparte-space-6);
  display: flex;
  flex-direction: column;
  gap: var(--aparte-space-4);
}

.aparte-dialog__footer {
  display: flex;
  justify-content: flex-end;
  gap: var(--aparte-space-3);
  padding: var(--aparte-space-3) var(--aparte-space-6) var(--aparte-space-5);
}

/* The page behind a modal does not scroll — the browser inerts it, but does not
   freeze it. Scoped to this class, so a host's own dialogs keep their behaviour. */
body:has(.aparte-dialog[open]) {
  overflow: hidden;
}

/* A phone: every size is the whole screen — when shown MODALLY. `:modal` and not the
   bare class: a dialog opened with `show()` sits in the flow of its container (the
   kit's own preview does that), and a sheet the height of the viewport inside a
   narrow column was the wrong reading of a rule meant for a phone. */
@media (max-width: 30rem) {
  .aparte-dialog:modal {
    width: 100%;
    max-width: 100%;
    /* As tall as its content, capped at the screen, sitting on the bottom edge: a sheet
       stretched to 100dvh held a label, a field and two buttons over 586px of nothing.
       `fit-content`, not `auto` — on a positioned box with both block insets set, an
       auto height FILLS them (CSS 2 §10.6.4: the auto margins go to 0 and the height
       is solved for), so `auto` measured the same 720 the explicit 100dvh had. What
       the UA gives a dialog is `fit-content`, and it is what lets the auto top margin
       push the sheet onto the bottom edge. And the safe areas: the ✕ used to sit 6px
       from the physical top. */
    height: fit-content;
    max-height: 100dvh;
    margin: auto 0 0;
    padding-block: env(safe-area-inset-top, 0px) env(safe-area-inset-bottom, 0px);
    border: 0;
    border-radius: var(--aparte-radius-dialog) var(--aparte-radius-dialog) 0 0;
  }
}

/* Open and close with a short fade and lift; the reader who prefers reduced motion
   gets the box where it lands. */
@media (prefers-reduced-motion: no-preference) {
  .aparte-dialog[open] {
    animation: aparte-dialog-in var(--aparte-duration-slow) var(--aparte-ease);
  }

  .aparte-dialog[open]::backdrop {
    animation: aparte-dialog-backdrop-in var(--aparte-duration-slow) var(--aparte-ease);
  }
}

@keyframes aparte-dialog-in {
  from {
    opacity: 0;
    transform: translateY(var(--aparte-slide-distance));
  }
}

@keyframes aparte-dialog-backdrop-in {
  from {
    opacity: 0;
  }
}
/* =========================================
   aparte-tooltip — a short label, with an arrow
   =========================================
   A tooltip has to be legible against whatever it happens to sit on top of — a bright
   image, a dark code block, either app theme — so its colours are fixed literals rather
   than the themed surface/text tokens. This box does NOT follow the palette, on purpose,
   and skips the dark-theme override block for that reason; the artifact "paper" tokens
   are the same decision made for the same reason.

       <span class="aparte-tooltip-anchor" style="margin-block-start: 2.5rem; margin-inline-start: 5rem">
         <div class="aparte-tooltip" data-side="top" role="tooltip">
           Copy to clipboard
           <span class="aparte-tooltip__arrow"></span>
         </div>
         <button class="aparte-btn aparte-btn--icon" aria-label="Copy"><aparte-icon name="copy"></aparte-icon></button>
       </span>

   `data-side` places it — `top`, `bottom`, `start` or `end` — against the
   `.aparte-tooltip-anchor` that wraps the trigger, `--aparte-tooltip-gap` away, and turns
   the arrow to match. The two inline styles that used to do this in the example were a
   knob missing, not a division of labour: a demo that needs inline styles to work is a
   recipe with a parameter it forgot. What stays yours is the viewport: nothing here flips
   a tooltip that would leave the screen — that needs script, a positioning library's job.
   Without `data-side` the box is not positioned at all, as before, and its placement is
   yours (the one inline style left in the example is the demo's own margin).

   `pointer-events: none` is not a detail: a tooltip describes what the pointer is over,
   so it must never itself become that thing, or hovering it re-triggers the hover that
   produced it. */
.aparte-tooltip {
  /* Component-local tokens: `bg`/`color` are fixed literals on purpose (see the
     comment above) rather than the themed surface/text tokens, so they cannot
     come from the shared set — the dark chip literal already used for
     `--aparte-art-paper-text`, and the off-white already used for the light
     theme's `--aparte-text-inverse`, both reused rather than inventing new
     colours. `shadow` reuses the same elevation recipe as `--aparte-scroll-btn-shadow`.
     `max-width` keeps a tooltip a short label, not a paragraph. */
  --aparte-tooltip-max-width: 240px;

  position: relative;
  display: inline-block;
  vertical-align: middle;
  /* As wide as its label, up to the cap. With a `max-width` and no intrinsic width an
     absolutely-positioned chip shrinks to `min-content`: "Copy to clipboard" broke into
     two lines at every width, 71px wide — the widest word plus padding. */
  width: max-content;
  box-sizing: border-box;
  max-width: var(--aparte-tooltip-max-width);
  padding: var(--aparte-space-2) var(--aparte-space-4);
  background: var(--aparte-tooltip-bg);
  color: var(--aparte-tooltip-color);
  border-radius: var(--aparte-radius-tooltip);
  box-shadow: var(--aparte-tooltip-shadow);
  font-size: var(--aparte-font-size-sm);
  line-height: var(--aparte-line-height-snug);
  text-align: start;
  z-index: var(--aparte-z-dropdown);
  /* A tooltip describes the element under the pointer; it must never itself be
     what the pointer is over, or hovering the tooltip re-triggers the hover that
     produced it. */
  pointer-events: none;
}

/* Where the box goes: the anchor is the trigger's wrapper and the tooltip is placed
   against it. `translate` centres the box on the axis across the side: with a logical
   inset of 50% the box's start edge sits at the anchor's middle, and moving it back by
   half its own size centres it — in both writing directions, because the inset flips
   with the direction and the translate is half the box either way. */
.aparte-tooltip-anchor {
  position: relative;
  display: inline-block;
  vertical-align: middle;
}

.aparte-tooltip[data-side] {
  position: absolute;
}

.aparte-tooltip[data-side='top'] {
  inset-block-end: calc(100% + var(--aparte-tooltip-gap));
  inset-inline-start: 50%;
  translate: -50% 0;
}

.aparte-tooltip[data-side='bottom'] {
  inset-block-start: calc(100% + var(--aparte-tooltip-gap));
  inset-inline-start: 50%;
  translate: -50% 0;
}

.aparte-tooltip[data-side='start'] {
  inset-inline-end: calc(100% + var(--aparte-tooltip-gap));
  inset-block-start: 50%;
  translate: 0 -50%;
}

.aparte-tooltip[data-side='end'] {
  inset-inline-start: calc(100% + var(--aparte-tooltip-gap));
  inset-block-start: 50%;
  translate: 0 -50%;
}

/* The arrow points DOWN by default — the common case, a tooltip above the thing it
   labels — and each `data-side` turns it toward the anchor. Centring on the box's edge
   uses a logical inset plus a negative margin rather than a transform, so it re-centres
   under `direction: rtl` without a second rule. */
.aparte-tooltip__arrow {
  position: absolute;
  inset-inline-start: 50%;
  /* `--aparte-space-4` doubles as the arrow's size — an allowed token that
     already lands at a size an arrow reads at against the box it points from. */
  inset-block-end: calc(var(--aparte-space-4) * -0.5);
  margin-inline-start: calc(var(--aparte-space-4) * -0.5);
  width: var(--aparte-space-4);
  height: var(--aparte-space-4);
  background: var(--aparte-tooltip-bg);
  transform: rotate(45deg);
}

.aparte-tooltip[data-side='bottom'] .aparte-tooltip__arrow {
  inset-block-end: auto;
  inset-block-start: calc(var(--aparte-space-4) * -0.5);
}

/* On the two inline sides the arrow sits on the edge that faces the anchor, centred
   vertically: the box is to the START of the trigger, so its arrow is on its END edge,
   and the reverse. */
.aparte-tooltip[data-side='start'] .aparte-tooltip__arrow {
  inset-block-end: auto;
  inset-block-start: 50%;
  inset-inline-start: auto;
  inset-inline-end: calc(var(--aparte-space-4) * -0.5);
  margin-inline-start: 0;
  margin-block-start: calc(var(--aparte-space-4) * -0.5);
}

.aparte-tooltip[data-side='end'] .aparte-tooltip__arrow {
  inset-block-end: auto;
  inset-block-start: 50%;
  inset-inline-start: calc(var(--aparte-space-4) * -0.5);
  margin-inline-start: 0;
  margin-block-start: calc(var(--aparte-space-4) * -0.5);
}

/* ── Forced colors ───────────────────────────────────────────────────────────────
   The menu and the popover already carry a real border by default, so those two
   boxes survive forced-colors mode once background and box-shadow are both
   dropped — nothing more is needed there. The two states below are the ones that
   relied on a fill with NO border to carry them: the segmented tab's active pill
   (a background swap, nothing else) and the tooltip (deliberately borderless, so
   its whole silhouette is its background colour). Each gets the border the
   shadow can no longer give it. The tooltip's arrow is dropped rather than
   patched: under forced colors it would be an unlabelled bordered diamond, and
   the bordered tooltip body alone already reads as a floating panel. */
@media (forced-colors: active) {
  .aparte-tabs--segmented .aparte-tabs__tab[aria-selected='true'] {
    border: var(--aparte-border-width) solid currentColor;
  }

  .aparte-tooltip {
    border: var(--aparte-border-width) solid currentColor;
  }

  .aparte-tooltip__arrow {
    display: none;
  }
}

/**
 * Aparte Select CSS
 * Stylable via CSS variables
 */

aparte-select {
    display: inline-block;
    position: relative;
    min-width: var(--aparte-select-min-width);
    font-family: var(--aparte-font-family);
}

/* Trigger */
.aparte-select-trigger {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--aparte-space-4);
    box-sizing: border-box;
    min-block-size: var(--aparte-field-size);
    padding: var(--aparte-space-4) var(--aparte-space-6);
    /* Its own size: the trigger declared none and took the host page's, so every
       integrator saw a different select. The control step, like the field and the
       button. */
    font-size: var(--aparte-font-size-md);
    background: var(--aparte-select-bg, var(--aparte-surface-1));
    border: var(--aparte-border-width) solid var(--aparte-select-border, var(--aparte-border-control));
    border-radius: var(--aparte-radius-select);
    cursor: pointer;
    transition: border-color var(--aparte-duration-base), box-shadow var(--aparte-duration-base);
}

.aparte-select-trigger:hover {
    border-color: var(--aparte-select-border-hover, var(--aparte-primary));
}

.aparte-select-trigger:focus-visible {
    outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
    outline-offset: var(--aparte-focus-outline-offset);
}

aparte-select[disabled] .aparte-select-trigger {
    cursor: not-allowed;
    background: var(--aparte-surface-2);
    color: var(--aparte-text-muted);
    border-color: var(--aparte-border);
}

/* The label is a grid with ONE cell: the visible text and, under it, a hidden stack of
   every option's label. The stack sets the cell's width — the widest option's — so the
   control keeps one width whatever is selected, like a native <select>; the text layer
   ellipsises when a host constrains the control below that (`min-width: 0` is what lets
   a flex item shrink at all). */
.aparte-select-label {
    flex: 1;
    min-width: 0;
    display: grid;
    overflow: hidden;
    color: var(--aparte-select-text, var(--aparte-text));
}

.aparte-select-label > * {
    grid-area: 1 / 1;
    white-space: nowrap;
}

.aparte-select-label-text {
    overflow: hidden;
    text-overflow: ellipsis;
}

/* `min-width: 0; overflow: hidden` on the stack is what keeps the ellipsis alive: a
   grid item's automatic minimum size is its min-content — the widest nowrap label —
   and that floored the label's track, so a host that constrained the control got a
   hard clip mid-glyph instead of "…" (measured in Chromium: clientWidth 244 for a
   140px control). Its max-content contribution, the one that sets the width, is
   untouched. */
.aparte-select-label-sizer {
    display: grid;
    min-width: 0;
    overflow: hidden;
    visibility: hidden;
    pointer-events: none;
}

.aparte-select-label-sizer > span {
    grid-area: 1 / 1;
}

.aparte-select-chevron {
    --aparte-icon-size: var(--aparte-icon-size-sm);
    flex-shrink: 0;
    color: var(--aparte-select-chevron, var(--aparte-text-muted));
    transition: transform var(--aparte-duration-slow);
}

aparte-select[open] .aparte-select-chevron {
    transform: rotate(180deg);
}

/* Dropdown — position:fixed to escape overflow:hidden ancestors */
.aparte-select-dropdown {
    position: fixed;
    /* top / left / width are set dynamically by _updatePosition() */
    background: var(--aparte-select-dropdown-bg, var(--aparte-surface-1));
    /* The panel is a region, so its edge is the region border; `--aparte-select-border`
       is the TRIGGER's edge, a control's, and defaults to the control border. */
    border: var(--aparte-border-width) solid var(--aparte-border);
    border-radius: var(--aparte-radius-select);
    box-shadow: var(--aparte-select-shadow);
    z-index: var(--aparte-select-z, var(--aparte-z-dropdown));
    max-height: var(--aparte-select-dropdown-max-height);
    overflow: auto;
}

.aparte-select-dropdown[hidden] {
    display: none;
}

/* Search */
.aparte-select-search {
    box-sizing: border-box;
    display: block;
    width: 100%;
    padding: var(--aparte-space-4) var(--aparte-space-6);
    border: none;
    border-bottom: var(--aparte-border-width) solid var(--aparte-border);
    background: transparent;
    font-size: inherit;
    font-family: inherit;
}

/* Inside, not outside: the field sits at the top of a scrolling panel, and an outset
   ring would be clipped by it. The one documented exception to the shared offset. */
.aparte-select-search:focus-visible {
    outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
    outline-offset: calc(-1 * var(--aparte-focus-outline-width));
}

/* Options container */
.aparte-select-options {
    padding: var(--aparte-space-2);
}

/* Option */
aparte-option {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--aparte-space-4) var(--aparte-space-6);
    border-radius: var(--aparte-radius-sm);
    cursor: pointer;
    transition: background-color 0.1s;
    color: var(--aparte-select-text, var(--aparte-text));
}

aparte-option:hover {
    background: var(--aparte-select-option-hover, var(--aparte-surface-2));
}

/* Keyboard-active option (roving highlight driven by aria-activedescendant).
   Distinct from :hover so keyboard users get an unambiguous focus indicator. */
aparte-option[data-active] {
    background: var(--aparte-select-option-hover, var(--aparte-surface-2));
    box-shadow: inset 0 0 0 2px var(--aparte-primary);
}

/* Selected option: the mark (display/mark.css) — an accent TINT plus an accent bar
   on the start edge, keeping the theme's own text colour. A solid `--aparte-primary`
   fill with white text failed WCAG AA in both themes (the brass accent is
   mid-luminance, so white sits around 3.4:1), and an unreadable selected row is the
   one row that must be readable. The measures are the mark's tokens, so this option,
   a checked field choice and the active conversation move together; the option's own
   two tokens stay overridable for a design that wants the solid fill back. Not the
   class: an option's selected state is an attribute the select writes, and a second
   name for it on the same element would be one more thing to keep in step. */
aparte-option[selected] {
    position: relative;
    background: var(--aparte-select-option-selected, color-mix(in srgb, var(--aparte-primary) var(--aparte-mark-tint), transparent));
    color: var(--aparte-select-option-selected-text, var(--aparte-select-text, var(--aparte-text)));
}

/* The bar: the mark's pseudo-element on the logical start edge (see display/mark.css). */
aparte-option[selected]::before {
    content: "";
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    width: var(--aparte-mark-bar);
    background: var(--aparte-primary-ink);
    pointer-events: none;
}

aparte-option[disabled] {
    opacity: var(--aparte-disabled-opacity);
    cursor: not-allowed;
}

/* Status dot injected by AparteOption when data-status is set */
.aparte-status-dot {
    width: var(--aparte-select-status-dot-size);
    height: var(--aparte-select-status-dot-size);
    border-radius: 50%;
    flex-shrink: 0;
    margin-left: var(--aparte-space-4);
}

.aparte-status-dot[data-status="ready"] {
    background-color: var(--aparte-success);
}

.aparte-status-dot[data-status="cached"] {
    background-color: var(--aparte-warning);
}

.aparte-status-dot[data-status="not-downloaded"] {
    background-color: var(--aparte-border);
}

/* Optgroup */
aparte-optgroup {
    display: block;
}

.aparte-optgroup-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--aparte-space-4) var(--aparte-space-6);
    font-size: var(--aparte-font-size-sm);
    font-weight: var(--aparte-font-weight-semibold);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--aparte-text-muted);
}

/* Sizes the marker; does not draw it — see the note in `segment/tool-call.css`. This was
   the second CSS border-triangle in core, and the one built from PHYSICAL sides
   (`border-left`/`right`/`top`) where the other used logical ones: two hand-drawn
   markers, two constructions, in a repo whose icon rule exists because two chevrons had
   already diverged. It now draws `expand`, like every other disclosure. */
.aparte-optgroup-chevron {
    display: grid;
    place-items: center;
    flex: none;
    width: var(--aparte-space-8);
    height: var(--aparte-space-8);
    transition: transform var(--aparte-duration-slow);
}
.aparte-optgroup-chevron svg { width: 100%; height: 100%; display: block; }

aparte-optgroup[collapsed] .aparte-optgroup-chevron {
    transform: rotate(-90deg);
}

/* No dark block: every colour above reads a token the derived layer re-anchors on
   `[data-aparte-theme]`, so `--aparte-surface-1` IS the dark surface there. The block
   that used to sit here restated the trigger's and the panel's backgrounds from
   `--aparte-select-bg` — the TRIGGER's token — so a consumer making the trigger
   transparent (a pill on a coloured page) got a see-through panel in the dark, with
   the page's text showing through the options. */

.aparte-optgroup-loader {
    padding: var(--aparte-space-4) var(--aparte-space-6);
    font-size: var(--aparte-font-size-sm);
    color: var(--aparte-text-muted);
    font-style: italic;
    display: flex;
    align-items: center;
    gap: var(--aparte-space-4);
}

.aparte-spinner-small {
    box-sizing: border-box;
    width: var(--aparte-select-spinner-size);
    height: var(--aparte-select-spinner-size);
    border: 2px solid currentColor;
    border-top-color: transparent;
    border-radius: 50%;
    /* `aparte-spin` is declared once, in base.css — see the shared-motion block there. */
    animation: aparte-spin var(--aparte-duration-spin) linear infinite;
}

/* The element's own sweep in responsive.css already stops this one; the rule is here
   so the recipe stops on its own terms too, like every other looping recipe. */
@media (prefers-reduced-motion: reduce) {
    .aparte-spinner-small { animation: none; }
}/**
 * AparteProgressSpinner — CSS
 *
 * All colors are driven by CSS custom properties so consumers can theme freely.
 * The `color` property on the element controls both fill and track by default.
 */

aparte-progress-spinner {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--aparte-spinner-size);
    height: var(--aparte-spinner-size);
    flex-shrink: 0;
}

aparte-progress-spinner svg {
    width: 100%;
    height: 100%;
    /* Start arc from 12 o'clock */
    transform: rotate(-90deg);
    transform-origin: center;
    overflow: visible;
}

/* The same stroke as the CSS ring (`.aparte-spinner`), in SCREEN pixels: without
   `vector-effect` a stroke is in viewBox units and scaled with the box — 2.5 of 24 was
   1.67px in a 16px spinner, antialiased everywhere and 50 % lighter than its sibling.
   The track is a visible reference in the control-edge colour: at 15 % of the ink a
   determinate 62 % was the percentage of a circle nobody could see. */
.aparte-spinner-track {
    fill: none;
    stroke: var(--aparte-spinner-track, var(--aparte-border-control));
    stroke-width: var(--aparte-spinner-thickness);
    vector-effect: non-scaling-stroke;
}

.aparte-spinner-fill {
    fill: none;
    stroke: var(--aparte-spinner-color, currentColor);
    stroke-width: var(--aparte-spinner-thickness);
    vector-effect: non-scaling-stroke;
    stroke-linecap: round;
    transition: stroke-dashoffset 0.25s var(--aparte-ease);
}

/* Indeterminate: rotate the whole SVG continuously */
aparte-progress-spinner:not([value]) svg {
    /* The shared duration, not a number of its own: this was 0.9s in hard code, so it
       ignored --aparte-duration-spin like every other spinner reads it — and therefore
       ignored the prefers-reduced-motion reset that overrides that token. It turns very
       slightly faster now (0.7s), which is the price of stopping when asked. */
    animation: aparte-spinner-rotate var(--aparte-duration-spin) linear infinite;
}

@keyframes aparte-spinner-rotate {
    from { transform: rotate(-90deg); }
    to   { transform: rotate(270deg); }
}
/*
 * aparté — aparte-chat, the viewport, the scroll button, the status line.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 */
/* =========================================
   aparte-chat  (the drop-in shell)
   Composes a viewport + composer as a flex column. Size the element yourself
   (a height, or let it fill a sized parent); the viewport grows and scrolls and
   the composer sits below it. Mirrors the framework auto-center layout for the
   vanilla viewport, which otherwise has no flex sizing of its own.
   ========================================= */

/* Both host shapes core recognises get the same layout: the vanilla
   <aparte-chat> element AND a framework wrapper's `[data-aparte-chat]` root
   (React/Vue/Svelte render `<div data-aparte-chat class="aparte-chat-container">`;
   Angular's host IS `<aparte-chat>` and its shell is the inner
   `.aparte-chat-container`, which carries no attribute here — the overlay rules
   below list that third shape for exactly that reason). `[data-aparte-chat]`
   is the SAME selector core uses to resolve the host (see aparte-client /
   aparte-chat-bubble), so one rule covers both — no wrapper-specific class. */
aparte-chat,
[data-aparte-chat] {
  display: flex;
  flex-direction: column;
  /* Fill the parent by default so the height:100% chain the inner scroll container
     relies on has a definite anchor. Size the element yourself — a height on it, or
     a sized parent. */
  height: 100%;
  width: 100%;
  overflow: hidden;
  /* The height an author sets IS the box: with the bottom gap below outside it,
     `style="height: 24rem"` rendered 336px, and a split's seam hung 16px under both
     panels. */
  box-sizing: border-box;
  /* The composer used to touch the bottom edge as soon as a conversation started: the
     viewport gives 16px above the composer and there was nothing below it. An app
     cannot fix this from outside without padding the container, which also shrinks the
     scroll area — so the transcript would stop before the edge instead of scrolling to
     it. That makes the gap ours. */
  padding-block-end: var(--aparte-chat-bottom-gap);
}

/* The viewport takes the space left by the composer; the standalone height:100%
   default resolves against the shell's definite height above, and the inner
   .aparte-viewport-container keeps doing the scrolling (styled scrollbar, wheel,
   auto-scroll, scroll button all stay wired to it). */
aparte-chat > aparte-chat-viewport,
[data-aparte-chat] > aparte-chat-viewport {
  flex: 1 1 auto;
  min-height: 0;
}

/* ─── Overlay composer (`overlay-composer`) ───────────────────────────────────
   The ChatGPT anatomy, opt-in: the transcript's scroll surface spans the WHOLE
   column and the composer floats over it, so the scrollbar runs edge to edge
   instead of stopping at the composer's top. Opt-in, never the default — a chat
   embedded in a small box should not have its composer eating the transcript.

   The viewport leaves the flow (absolute over the shell) and everything else —
   elicitation, an above-composer row, the composer — keeps flowing, pushed to the
   bottom and painted over it. The transcript clears that stack through
   `--aparte-bottom-inset`, which the viewport measures from the stack's top edge
   (see `_updateOverlayInset`): the `::after` spacer grows by it in
   framework-managed mode, the container's own padding in core mode, and the
   scroll button's `bottom` rides it in both. The inset var and its readers are
   unconditional (0px when unset), so a host that overlays a composer of its own
   can write the variable itself without this attribute.

   Three shell shapes, because the wrappers differ: the vanilla <aparte-chat>
   element, the [data-aparte-chat] div React/Vue/Svelte render, and Angular's
   inner .aparte-chat-container (its host IS aparte-chat, but the viewport is a
   child of the inner div, so that div is the shell the recipe lays out — the
   Angular wrapper binds the attribute there). The inner-div shape declares its
   own flex column: unlike the other two it inherits none. */
aparte-chat[overlay-composer],
[data-aparte-chat][overlay-composer],
.aparte-chat-container[overlay-composer] {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}

aparte-chat[overlay-composer] > aparte-chat-viewport,
[data-aparte-chat][overlay-composer] > aparte-chat-viewport,
.aparte-chat-container[overlay-composer] > aparte-chat-viewport {
  position: absolute;
  /* The padding box, so the scroll surface reaches the shell's true bottom edge —
     the `--aparte-chat-bottom-gap` padding stays under the floating stack and the
     measured inset covers it. */
  inset: 0;
  height: auto;
  z-index: 0;
}

/* The bottom stack paints over the absolutely positioned viewport. The `:not()` names
   what is NOT the stack: the viewport (the scroll button lives inside it), and the
   scroll rail, which is a child of the shell too. `position: relative` here beat the
   rail's own `absolute`, so in overlay mode the floating rail dropped into the flow
   and pushed the composer down — the one child that floats by design has to be named. */
aparte-chat[overlay-composer] > :not(aparte-chat-viewport, aparte-scroll-rail),
[data-aparte-chat][overlay-composer] > :not(aparte-chat-viewport, aparte-scroll-rail),
.aparte-chat-container[overlay-composer] > :not(aparte-chat-viewport, aparte-scroll-rail) {
  position: relative;
  z-index: var(--aparte-z-raised);
}

/* A thing that floats has to be seen floating. The overlaid composer's shell shares the
   transcript's ground, so a z-index alone left it flat on the words it covers. */
aparte-chat[overlay-composer] .aparte-composer-shell,
[data-aparte-chat][overlay-composer] .aparte-composer-shell,
.aparte-chat-container[overlay-composer] .aparte-composer-shell {
  box-shadow: var(--aparte-composer-overlay-shadow);
}

/* center-empty: the shell centers the composer as a welcome state until the first
   message, then slides to the normal layout. The component toggles [data-empty]
   on itself by watching its viewport — only `flex-grow` animates, so the composer
   glides to the bottom instead of snapping. */
aparte-chat[center-empty] > aparte-chat-viewport {
  transition: flex-grow var(--aparte-duration-slower) var(--aparte-ease);
}

aparte-chat[center-empty][data-empty] {
  justify-content: center;
}

aparte-chat[center-empty][data-empty] > aparte-chat-viewport {
  flex-grow: 0;
  /* Release the standalone `height: 100%` (set on the viewport element for the
     scroll chain): an explicit height ignores `flex-grow: 0`, so the empty viewport
     would still fill the shell and leave `justify-content: center` no room to center
     the composer. Empty = nothing to scroll, so auto height is safe here; the moment
     a message lands, [data-empty] drops and height: 100% (scroll chain) is back. */
  height: auto;
}

/* And no block room at all: at `auto` the empty viewport still stood 32px tall — the
   rows' wrapper keeps its block padding with no row in it — so `center` centred three
   items of which the first was invisible, and the welcome group landed 16px low,
   measured. The padding goes, not the box: capping the viewport itself left a 32px
   scroll surface inside a 0px box, and "an empty transcript must not overflow" is a
   browser test every example runs. Inline geometry is kept — the composer's inset is
   measured from this element. */
aparte-chat[center-empty][data-empty] > aparte-chat-viewport .aparte-messages-wrapper {
  padding-block: 0;
}

/* =========================================
   aparte-chat-viewport
   ========================================= */

aparte-chat-viewport {
  display: block;
  width: 100%;
  height: 100%;
  overflow: hidden;
  contain: layout style;
  /* Container-query root: children adapt to the WIDGET's width, not the
     viewport's — an embedded chat can be narrow inside a wide page. */
  container-type: inline-size;
  text-align: left;
}

.aparte-viewport-container {
  /* Core ships no global border-box reset, so this box says it itself: the overlay
     clearance below is padding, and without this the padding was ADDED to the 100%
     height - the surface overran its host by the whole inset, clipped, with that much
     scrollbar and content cut off. Every example app carries a `* { box-sizing }` reset,
     which is exactly why it survived there and showed on the docs' bare frame. */
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  height: 100%;
  width: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  /* The overlay clearance (0 outside overlay mode). Padding is SAFE on this box —
     the scroll button is absolute on the host, which does not scroll — where the
     framework-managed host must carry the same clearance in its ::after instead,
     because there the button is a sticky CHILD and padding would clamp it. */
  padding-bottom: var(--aparte-bottom-inset, 0px);
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--aparte-scrollbar-thumb) var(--aparte-scrollbar-track);
  /* The gutter is reserved on BOTH edges whether or not the bar is showing, so the
     centred column does not shift by half a scrollbar the moment the first reply
     overflows — the same rule the reference kits' scroll surfaces carry. Symmetric,
     because the column is centred: a gutter on one side alone moves it. */
  scrollbar-gutter: stable both-edges;
}

/* NOT dead code: on modern Chromium/WebKit these ::-webkit-scrollbar-* blocks are
   ignored the moment `scrollbar-width`/`scrollbar-color` above are non-initial
   (CSSWG resolution, Chrome 121+). They exist for Safari BEFORE 18.2, which has no
   `scrollbar-width` at all - remove them and old Safari falls back to the fat
   native bar. Re-measured 2026-08-30 against MDN before a cleanup deletes them. */
.aparte-viewport-container::-webkit-scrollbar {
  width: var(--aparte-scrollbar-width);
}

.aparte-viewport-container::-webkit-scrollbar-track {
  background: var(--aparte-scrollbar-track);
}

/* The thumb too: the token pair was half-wired on the legacy WebKit path — the
   standard `scrollbar-color` read both, this block read only the track. */
.aparte-viewport-container::-webkit-scrollbar-thumb,
aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar-thumb {
  background: var(--aparte-scrollbar-thumb);
}

/* ─── Scroll-to-bottom button ─────────────────────────────────────────────── */

/* The button wears `aparte-btn--lg aparte-btn--circle`: `--lg` gives it the 20px glyph
   and the lg step of the scale (36px), `--circle` the round corner, and
   `--aparte-scroll-btn-size` (the same step, named for this button so a theme can move
   it alone) is fed to the recipe below. What stays here is only what is genuinely this
   button's — where it floats, and its own surface.

   It used to redraw all three: its own width/height, its own `border-radius: 50%`, and
   an icon-size override that existed only because the box was declared OUTSIDE the size
   scale, so the glyph stayed at the medium default — a 16px arrow in a 36px circle.
   Naming the size on the recipe's own token makes the override disappear.

   `--aparte-scroll-btn-size` stays the public knob, fed to the recipe. */
.aparte-scroll-btn {
  --aparte-btn-size: var(--aparte-scroll-btn-size);
  position: absolute;
  /* Rides the overlay clearance (0 outside overlay mode), so it floats above a
     composer laid over the transcript rather than behind it. */
  bottom: calc(var(--aparte-space-8) + var(--aparte-bottom-inset, 0px));
  left: 50%;
  transform: translateX(-50%);
  z-index: var(--aparte-z-floating);
  /* The one thing the recipe has no word for: this button FLOATS over the transcript,
     and nothing else in the library does. Everything else it used to declare here —
     background, colour, border, and the hover ground — was `aparte-btn--surface`
     written a second time through four aliases that resolved to the very same values
     (`--aparte-scroll-btn-bg` WAS `var(--aparte-surface-1)`, and so on down the list).
     Four names for one thing is the drift the recipes exist to end, so they are gone
     and the fill is the fill. */
  box-shadow: var(--aparte-scroll-btn-shadow);
  pointer-events: auto;
}

.aparte-scroll-btn--hidden {
  opacity: 0;
  pointer-events: none;
  transform: translateX(-50%) translateY(var(--aparte-slide-distance));
}

/* ─── Framework-managed viewport ──────────────────────────────────────────────
   When a framework wrapper (React/Vue/Svelte/Angular) owns the bubble elements,
   the viewport does NOT relocate them into an internal wrapper (that would break
   the framework's DOM reconciliation). Instead the HOST itself is the scroll
   surface — these rules mirror .aparte-viewport-container + .aparte-messages-wrapper
   onto the host, and the bottom spacer becomes an `::after` flex item — still
   nothing in the DOM, so it can't collide with the framework's children. */
aparte-chat-viewport.aparte-viewport--framework,
aparte-chat-viewport[framework-managed] {
  /* The base rule gives this element `width: 100%`, and core ships no global
     border-box reset — every `box-sizing` in these sheets is per-element, on purpose.
     So the padding below was ADDED to 100% and the host overflowed its own chat by
     32px, which `aparte-chat`'s `overflow: hidden` then clipped: measured on a 1500px
     chat, the host was 1532 wide and hung 32px past the end edge. An app with its own
     `* { box-sizing: border-box }` never saw it, which is exactly why it survived —
     every example in this repo has one. The composer cannot share a column with a
     transcript that is off-centre, so it is fixed here rather than compensated there. */
  box-sizing: border-box;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  display: flex;
  flex-direction: column;
  /* No flex gap — each bubble carries the distance to the next as its own padding,
     inside its contained box, where an older message's action bar floats (#56;
     the rationale is on `.aparte-messages-wrapper` in bubble.css). */
  gap: 0;
  padding: var(--aparte-viewport-padding);
  /* No bottom padding: a sticky child is clamped to its parent's CONTENT box, so
     clearance carried as padding held the scroll button `padding + spacer` above
     the bottom edge — the whole clearance lives in the `::after` below instead. */
  padding-bottom: 0;
  scrollbar-width: thin;
  scrollbar-color: var(--aparte-scrollbar-thumb) var(--aparte-scrollbar-track);
  /* Same gutter rule as `.aparte-viewport-container` above: the host is the scroll
     surface here, so it is the host that reserves it. */
  scrollbar-gutter: stable both-edges;
}

/* The framework wrapper renders bubbles as DIRECT flex children of the viewport
   (no inner wrapper, unlike the vanilla path). With the viewport as a fixed-height
   flex column, those children would `flex-shrink` to fit — compressing content
   instead of overflowing, so nothing scrolls. Pin their size so the content grows
   past the viewport and the scroll surface actually scrolls. */
aparte-chat-viewport.aparte-viewport--framework > *,
aparte-chat-viewport[framework-managed] > * {
  flex-shrink: 0;
}

/* The bottom spacer: the base padding plus whatever `_setSpacerHeight` writes to
   `--aparte-fw-spacer`. A flex item rather than host padding because the scroll
   button's `position: sticky` is clamped to the CONTENT box — clearance carried as
   padding is territory the button can never enter, and it floated the full
   `padding + spacer` above the bottom edge (measured: 48-278px for 32 asked).
   `flex: 0 0` spells out `flex-shrink: 0`: the `> *` pin above matches elements
   only, never a pseudo. */
aparte-chat-viewport.aparte-viewport--framework::after,
aparte-chat-viewport[framework-managed]::after {
  content: '';
  flex: 0 0 calc(var(--aparte-viewport-padding) + var(--aparte-fw-spacer, 0px) + var(--aparte-bottom-inset, 0px));
  /* The column has no `gap` any more (each bubble carries its own), so this item pays
     none — the negative margin that used to cancel it would now eat the last bubble's
     padding. (History: with a gap, an EMPTY transcript was 8px taller than its box and
     grew a scrollbar beside the welcome state.) */
}

aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar {
  width: var(--aparte-scrollbar-width);
}

aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar-track {
  background: var(--aparte-scrollbar-track);
}

/* The scroll button lives INSIDE the scrolling host here (last child), so it
   floats with `position: sticky` instead of the `absolute` used in core mode
   (where it sits outside the scroll container). `order: 1` puts its FLOW position
   after the `::after` spacer (source order cannot — a pseudo always renders last):
   a bottom-sticky element sits at its flow position whenever that is above the
   sticky line, so a button flowing before a 230px spacer drifted up to 262px from
   the edge as the reader neared the bottom. Flowing last, its flow position is the
   very end and the sticky line always wins. */
aparte-chat-viewport.aparte-viewport--framework > .aparte-scroll-btn {
  position: sticky;
  bottom: calc(var(--aparte-space-8) + var(--aparte-bottom-inset, 0px));
  left: auto;
  align-self: center;
  transform: none;
  flex-shrink: 0;
  order: 1;
}

/* Fade only, no slide: since `order: 1` the button is the LAST flow content, so a
   hidden-state translate overhangs the content end by its full distance — 8px of
   scrollable overflow, which on an EMPTY transcript is the whole scrollbar (the old
   padding used to absorb the overhang; the ::after spacer flows BEFORE the button
   and cannot). Core mode keeps the slide: its button is absolute, out of flow. */
aparte-chat-viewport.aparte-viewport--framework > .aparte-scroll-btn.aparte-scroll-btn--hidden {
  transform: none;
}

/* =========================================
   aparte-chat-status
   ========================================= */

aparte-chat-status {
  display: block;
  width: 100%;
  contain: layout style paint;
}

/* No layout footprint until the typing indicator is actually shown — otherwise the
   always-present element reserves height (visible via flex-shrink:0 in the
   framework-managed viewport) even on the empty/welcome screen. */
aparte-chat-status:not([visible]) {
  display: none;
}

.aparte-status-container {
  display: flex;
  align-items: center;
  opacity: 0;
  transform: translateY(var(--aparte-slide-distance));
  transition: opacity var(--aparte-duration-slow) var(--aparte-ease), transform var(--aparte-duration-slow) var(--aparte-ease);
  pointer-events: none;
}

.aparte-status-content {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-4);
}

.aparte-status-container[data-visible="true"] {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

.aparte-status-text {
  font-size: var(--aparte-status-font-size);
  font-family: var(--aparte-font-family);
  color: var(--aparte-status-color);
  font-style: italic;
}

/* ─────────────────────────────────────────────────────────────────────────────
   Opt-in "centered composer when empty" — add the `--auto-center` modifier
   (the wrappers do this from their `centerWhenEmpty` prop). Consumers that never
   opt in keep just the base layout above.

   Empty  → the [welcome + composer] group sits vertically centered.
   Active → on the first message the list grows and the composer slides to the
            bottom (~0.3s). Only `flex-grow` toggles between the two states
            (flex-basis/height stay constant) so the transition slides instead
            of snapping. Scoped to `.aparte-viewport--framework` so the native
            (vanilla) viewport is never touched.
   ───────────────────────────────────────────────────────────────────────────── */

.aparte-chat-container--auto-center {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
}

.aparte-chat-container--auto-center aparte-chat-viewport.aparte-viewport--framework {
  flex: 1 1 auto;
  min-height: 0;
  height: auto;
  transition: flex-grow var(--aparte-duration-slower) var(--aparte-ease); /* the "slide to bottom" on first message */
}

/* Empty delta — a wrapper sets [data-aparte-empty] only while centerWhenEmpty is
   on AND the message list is empty. `justify-content` flips instantly (not an
   animatable property) — fine, it only differs between the two steady states. */
.aparte-chat-container--auto-center[data-aparte-empty] {
  justify-content: center;
}

.aparte-chat-container--auto-center[data-aparte-empty]
  aparte-chat-viewport.aparte-viewport--framework {
  flex-grow: 0;
}

/* overlay-composer × center-empty: while the welcome state centres the composer,
   the overlay stands down — an empty transcript has nothing to scroll, so the
   surface has nothing to span, and an absolute viewport would pin the welcome to
   the top of the page while the composer floated alone at the bottom. One
   property: back in the flow, the centring rules above govern the rest. The first
   message drops the empty flag and the overlay takes over. */
aparte-chat[overlay-composer][center-empty][data-empty] > aparte-chat-viewport,
[data-aparte-chat][overlay-composer][data-aparte-empty] > aparte-chat-viewport,
.aparte-chat-container[overlay-composer][data-aparte-empty] > aparte-chat-viewport {
  position: static;
}
/*
 * aparté — A message: avatar, name, timestamp, body, action bar, branch picker.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 */
.aparte-messages-wrapper {
  display: flex;
  flex-direction: column;
  /* No flex gap: the space between two turns is each bubble's own padding-block-end
     (below), so that it lies INSIDE the bubble's contained box — where the action
     bar of an older message floats (#56). A flex gap is outside every box, and the
     bubble's paint containment clipped anything placed there. */
  gap: 0;
  padding: var(--aparte-viewport-padding);
}

/* =========================================
   aparte-chat-bubble
   ========================================= */

aparte-chat-bubble {
  display: block;
  width: 100%;
  overflow: hidden;
  /* Each bubble is its own layout/paint boundary, and bubbles scrolled out
     of the viewport are skipped entirely (layout + paint) via
     content-visibility. While a message streams the user is pinned to the
     bottom, so every bubble above the live one stops costing a paint on each
     token — that reclaimed GPU time goes back to the WebGPU decode.
     contain-intrinsic-size keeps the scrollbar stable: the `auto` keyword
     reuses a bubble's real height once it has been measured; the 320px is
     only a fallback for bubbles that have never been rendered yet. */
  contain: layout style paint;
  content-visibility: auto;
  contain-intrinsic-size: auto 320px;
  /* The distance to the next turn, carried by the bubble rather than by the list's
     `gap`, so it is part of this box: an older message's action bar floats over it
     (#56). Same token, same distance — nothing moved. */
  padding-block-end: var(--aparte-message-gap);
}

.aparte-message {
  box-sizing: border-box;
  display: flex;
  gap: var(--aparte-message-gap);
  /* The two parts, not the composite `--aparte-message-padding` that used to join them
     on `:root`: a custom property is substituted where it is declared, so the composite
     froze both parts at the root and the narrow-container override of them in
     responsive.css never reached this row. */
  padding: var(--aparte-message-padding-block) var(--aparte-message-padding-inline);
  width: 100%;
  max-width: var(--aparte-message-max-width);
  margin: 0 auto;
  min-width: 0;
  /* The containing block of a floating footer — see the action bar below. */
  position: relative;
}

/* The avatar's shape, size and type live in `display.css` — a message avatar IS an
   avatar, so the bubble does not redraw one. What stays here is the part that is
   genuinely the bubble's: who the avatar belongs to, and what a failed turn does to it. */

/* No message avatar by default — the slot only shows once an AvatarProvider
   (or a consumer) fills it. Role is carried by layout + colour instead. */
.aparte-avatar:empty {
  display: none;
}

.aparte-avatar[data-role="user"] {
  background: var(--aparte-avatar-bg-user);
  color: var(--aparte-avatar-text-user);
  background-image: var(--aparte-avatar-image-user);
  background-size: var(--aparte-avatar-image-size);
  background-repeat: no-repeat;
  background-position: center;
}

.aparte-avatar[data-role="assistant"] {
  background: var(--aparte-avatar-bg-assistant);
  color: var(--aparte-avatar-text-assistant);
  background-image: var(--aparte-avatar-image-assistant);
  background-size: var(--aparte-avatar-image-size);
  background-repeat: no-repeat;
  background-position: center;
}

.aparte-body {
  flex: 1;
  min-width: 0;
  /* The containing block of a floating footer: the bar starts where the text starts
     and sits under it, whichever way the avatar column is laid out. */
  position: relative;
}

/* The message bubble — the surface fills ONLY the content (segments + text).
   The sender name and attachment strip (above) and the action bar (below) are
   siblings in .aparte-body, so they stay OUTSIDE the coloured surface. */
.aparte-message-content {
  border-radius: var(--aparte-message-content-radius);
  max-width: 100%;
}

/* AI-chat convention (not messaging): the USER message is a subtle bubble that
   hugs its text; the ASSISTANT message is plain full-width prose — no bubble,
   like ChatGPT / Claude. */
.aparte-message[data-role="user"] .aparte-message-content {
  width: fit-content;
  padding: var(--aparte-message-content-padding);
  background: var(--aparte-message-content-bg-user);
  color: var(--aparte-message-content-text-user);
}

.aparte-message[data-role="assistant"] .aparte-message-content {
  background: var(--aparte-message-content-bg-assistant);
  color: var(--aparte-message-content-text-assistant);
}

.aparte-name {
  font-size: var(--aparte-name-font-size);
  font-weight: var(--aparte-font-weight-semibold);
  color: var(--aparte-name-color);
  font-family: var(--aparte-font-family);
}

.aparte-timestamp {
  font-size: var(--aparte-timestamp-font-size);
  color: var(--aparte-timestamp-color);
  font-family: var(--aparte-font-family);
}

.aparte-action-bar {
  display: flex;
  align-items: center;
  gap: var(--aparte-action-bar-gap);
  opacity: 0;
  transition: opacity var(--aparte-duration-base) var(--aparte-ease);
  height: var(--aparte-action-bar-btn-size);
  /* The INK meets the column, not the box: a glyph is centred in its 24px button, so
     its ink began half the slack — (24 − 12) / 2 — to the right of the paragraph above.
     Measured at +5 to +8px in 14 previews. The bar takes that slack back and the box
     overhangs into the message padding, where nothing else lives. */
  margin-inline-start: calc((var(--aparte-icon-size-sm) - var(--aparte-btn-size-sm)) / 2);
}

.aparte-message:hover .aparte-action-bar,
.aparte-message:focus-within .aparte-action-bar {
  opacity: 1;
}

/* Last assistant message: action bar always visible — but NOT while streaming */
aparte-chat-bubble:last-of-type .aparte-message[data-role="assistant"]:not(.aparte-message-streaming) .aparte-action-bar {
  opacity: 1;
}

/* Hide footer (action bar + branch picker) entirely while streaming */
.aparte-message-streaming .aparte-footer {
  display: none;
}

/* An older message reserves no row for its actions.
 *
 * Measured (UI audit 2026-08-28, §8.3 #7): 103px between the text of one turn and the
 * text of the next, and 34 of them were this footer — 28px of buttons nobody could
 * see plus its margin, under EVERY message, so that the bar could appear on hover
 * without moving anything. The market floats it instead, BELOW the message, in the
 * space that already separates two turns: that is where ChatGPT and Claude put it,
 * and it takes no height because the space is already there.
 *
 * It floated top-right first (#56), over the header row's empty end, for one reason:
 * the bubble is a paint-containment boundary (`content-visibility`), and the space
 * between two bubbles was the list's flex `gap` — outside every box — so a bar
 * placed under the text was clipped. The gap is the bubble's own padding-block-end
 * now (see `aparte-chat-bubble` above), inside its box. The bar hangs from the
 * content column's bottom edge by `message padding-block + gap`: 16 + 12 = 28px at
 * the default density, 24 + 18 = 42px at the comfortable one, and the bar is 24px on
 * these messages — it overlaps padding, never text. The distance between turns is
 * what it was. A narrow container (responsive.css) shrinks the padding to 12 + 8 = 20:
 * 4px of the bar reach the last line's leading, under glyphs that do not descend that
 * far; and a narrow container is mostly a coarse pointer, where this block does not
 * apply.
 *
 * Three footers stay in the flow. The last assistant message: its bar is always on
 * show, under the reply, where every chat puts it. A message whose branch picker is
 * visible (`data-branches`, written by the bubble beside the picker's own state): the
 * picker must stay in view. And every message under a pointer that cannot hover, or
 * that is coarse — `(hover: hover) and (pointer: fine)`, because the two features are
 * independent and a TV remote reports both hover and coarse; responsive.css gives a
 * coarse pointer a 44px bar in the flow, and floated under the text a 44px bar would
 * cover the next turn.
 *
 * Invisible but NOT inert, on purpose. `opacity: 0` keeps the bar hit-testable, so a
 * drag-select started under a message begins on a button nobody sees — a review
 * flagged it, and `pointer-events: none` until `:hover` was tried. It made the bar
 * unreachable to anything that clicks without hovering first: Playwright (which
 * hit-tests the point before it moves the mouse — the header "intercepted" the edit
 * button on four projects), and with it voice control and switch access, which
 * synthesise a click the same way. A control only a hover can enable is a control
 * some users cannot reach; a phantom button in the space between two turns is not. */
@media (hover: hover) and (pointer: fine) {
  aparte-chat-bubble:not(:last-of-type) .aparte-message:not([data-branches]) .aparte-footer,
  aparte-chat-bubble:last-of-type .aparte-message[data-role="user"]:not([data-branches]) .aparte-footer {
    position: absolute;
    inset-inline-start: 0;
    inset-block-end: calc(-1 * (var(--aparte-message-padding-block) + var(--aparte-message-gap)));
    margin: 0;
    z-index: 1;
  }
}

.aparte-action-btn {
  color: var(--aparte-action-bar-btn-color);
  --aparte-btn-radius: var(--aparte-radius-action-btn);
  /* The token, not the box: the recipe draws it, and its icon size moves with it.
     This used to set width/height directly while the element also carried
     `--sm` — two sources for one measure, and 28px is the `--md` default the
     modifier was contradicting. */
  --aparte-btn-size: var(--aparte-action-bar-btn-size);
}

.aparte-action-btn:hover {
  background: var(--aparte-action-bar-btn-hover-bg);
  color: var(--aparte-action-bar-btn-hover-color);
}

.aparte-action-btn[data-copied] {
  color: var(--aparte-success-ink);
}

.aparte-action-btn[data-submitted] {
  color: var(--aparte-primary);
}

/* =========================================
   Branch Picker
   ========================================= */

.aparte-branch-picker {
  display: flex;
  align-items: center;
  gap: var(--aparte-branch-picker-gap);
}

/* [hidden] is overridden by display:flex — force it back. The bar and the footer
   hide themselves when they hold nothing (see _syncFooterVisibility): an empty
   toolbar is announced to screen readers and reserves height for nothing. */
.aparte-action-bar[hidden],
.aparte-footer[hidden] {
  display: none;
}

.aparte-branch-picker[hidden] {
  display: none;
}

/* The arrows wear `--sm` and the recipe draws the box: this rule used to redraw
   width/height to a 20px token over it — two sources for one measure. */
.aparte-branch-prev,
.aparte-branch-next {
  color: var(--aparte-branch-picker-btn-color);
}

.aparte-branch-prev:hover:not(:disabled),
.aparte-branch-next:hover:not(:disabled) {
  color: var(--aparte-branch-picker-btn-hover-color);
  background: var(--aparte-action-bar-btn-hover-bg);
}

/* The button recipe draws the disabled state (a neutral ground, the muted ink); an
   opacity over it read the arrow at 1.74:1 — as absent, where the picker means "no
   further". `default` and not `not-allowed`: the end of a list is not a refusal. */
.aparte-branch-prev:disabled,
.aparte-branch-next:disabled {
  cursor: default;
}

.aparte-branch-label {
  font-size: var(--aparte-branch-picker-label-size);
  color: var(--aparte-branch-picker-label-color);
  font-family: var(--aparte-font-family);
  min-width: var(--aparte-branch-picker-label-min-width);
  text-align: center;
}

/* Edit mode keeps the action bar (now ✓ / ✗) visible even when the editor
   is not focused — otherwise the user can't reach the save button. */
.aparte-message[data-editing] .aparte-action-bar {
  opacity: 1;
}

/* The colours ARE the intents: these two carry `aparte-btn--success` and
   `aparte-btn--danger`, so the recipe tints them. Only the hover ground stays, because
   a ghost button hovers to a neutral surface and a confirm/cancel pair reads better
   hovering to its own. */
.aparte-action-btn.aparte-action-edit-save:hover { background: var(--aparte-success-surface); }
.aparte-action-btn.aparte-action-edit-cancel:hover { background: var(--aparte-error-surface); }

/* Action Buttons in Input */
/* The composer row's size first, this button's own second — see `.aparte-composer-row`
   in composer.css. Undefined anywhere else, so only the second applies there. */
.aparte-action-button {
  --aparte-btn-radius: var(--aparte-radius-action-btn);
  width: var(--aparte-composer-row-control-size, var(--aparte-input-action-btn-size));
  height: var(--aparte-composer-row-control-size, var(--aparte-input-action-btn-size));
}

.aparte-action-button:hover {
  color: var(--aparte-primary);
  /* Derive the hover tint from --aparte-primary instead of hardcoding indigo, so a
     custom theme's action buttons tint with its own accent. */
  background-color: color-mix(in srgb, var(--aparte-primary) 10%, transparent);
}

.aparte-action-button svg {
  width: var(--aparte-input-action-btn-icon-size);
  height: var(--aparte-input-action-btn-icon-size);
}

/* Errored bubble state — the whole message carries `data-error` while it holds
   an error segment (set by the bubble in _reflectError). Kept subtle by default
   since the error segment itself is already styled; this is a theming hook —
   restyle `.aparte-message[data-error]` (or its parts) freely. */
.aparte-message[data-error] .aparte-avatar {
  box-shadow: var(--aparte-avatar-error-ring);
}

/* An empty, settled turn renders no chrome. The bubble sets `data-empty` when it has
   no content, no segments, no attachments and is not streaming — a tool-only turn, or
   one stopped before its first token — and the whole row goes, name and timestamp
   included, instead of an orphan header floating over nothing. The element stays in
   the DOM (streaming and the action bar address it by id), it just does not paint. */
.aparte-message[data-empty] {
  display: none;
}

/* ─── The compaction notice ────────────────────────────────────────────────────
   A message with `compaction: true` — the summary `compact()` injected in place of
   the turns it replaced — is a notice, not a reply: centred, muted, without avatar,
   name, time or actions. Its role on the wire is `user` (context handed to the
   model), and the bubble would otherwise draw it as something the user said. */
aparte-chat-bubble[data-kind="compaction"] .aparte-avatar,
aparte-chat-bubble[data-kind="compaction"] .aparte-header,
aparte-chat-bubble[data-kind="compaction"] .aparte-footer,
aparte-chat-bubble[data-kind="compaction"] .aparte-action-bar,
aparte-chat-bubble[data-kind="compaction"] .aparte-branch-picker {
  display: none;
}

aparte-chat-bubble[data-kind="compaction"] .aparte-message {
  justify-content: center;
}

aparte-chat-bubble[data-kind="compaction"] .aparte-message-content {
  max-width: 100%;
  background: var(--aparte-surface-2);
  color: var(--aparte-text-muted);
  border: var(--aparte-border-width) dashed var(--aparte-border);
  font-size: var(--aparte-font-size-sm);
}
/*
 * aparté — The composer and everything it holds: editor, send, attachments, model picker.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 */
/* Model-selection gate (opt-in via aparteGlobalConfig.setRequireModelSelection): grey the
   composer and block its send button until a model is selected — e.g. while the
   model selector is still fetching. Ungates automatically on selection.
   Unscoped so it greys the composer in EVERY host (vanilla shell + all wrappers),
   not only a `[data-aparte-chat]` direct child. */
aparte-composer[data-model-gated] {
  /* No group fade: each control draws its own disabled state (the editor's muted ink,
     the send button's neutral ground), so the text stays legible while it waits. */
  cursor: not-allowed;
}
aparte-composer[data-model-gated] aparte-composer-send {
  pointer-events: none;
}

/* Panel mode: the composer is answering a question, not composing a message.
   The text input goes, and so does the attachment picker — an affordance nothing
   can honour is not rendered (ratified decision #8), and it stayed clickable
   through a whole elicitation. The attachments strip stays: pending attachments
   are the user's STATE, not an action to offer. So does the toolbar —
   switching model still does something. */
aparte-composer[data-panel-active] aparte-composer-input,
aparte-composer[data-panel-active] aparte-composer-add-attachment {
  display: none;
}

/* And the send button goes too when the PANEL says it has no act for it.
   A panel whose options settle on the first click — a single-choice question, an
   approval — never routes through this button, so it used to sit beside them
   permanently disabled, meaning nothing. Same rule as above, one level further:
   an affordance nothing can honour is not rendered (decision #8).

   Declared by the panel through `showPanel({ mode: 'none' })` rather than assumed
   here, because the same panel turns the button back ON the moment it grows an act
   (an "Other…" field, a written instruction) — see `AparteComposerPanelMode`.

   `display: none` and not `visibility`/opacity: this must leave the accessibility
   tree, which is why `<aparte-composer-send>` sets no `aria-hidden` of its own. */
aparte-composer[data-panel-mode='none'] aparte-composer-send {
  display: none;
}

aparte-composer {
  flex: 0 0 auto;
}

.aparte-viewport-container::-webkit-scrollbar-thumb {
  background: var(--aparte-neutral);
  border-radius: var(--aparte-radius-xs);
}

.aparte-viewport-container::-webkit-scrollbar-thumb:hover {
  background: var(--aparte-secondary);
}

aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar-thumb {
  background: var(--aparte-neutral);
  border-radius: var(--aparte-radius-xs);
}

/* =========================================
   Attachments
   ========================================= */


.aparte-thumb__remove {
  /* Icon size comes from `--sm` — see `--aparte-btn-icon-size` in button.css. */
  /* A scrim, not a ghost. This button sits ON the picture: the recipe rests
     transparent, which left a muted glyph directly on the image and made it vanish
     over anything light. The recipe still draws the box, the hover and the focus. */
  background: var(--aparte-thumb-remove-bg);
  color: var(--aparte-thumb-remove-color);
  position: absolute;
  top: var(--aparte-thumb-remove-inset);
  right: var(--aparte-thumb-remove-inset);
  width: var(--aparte-thumb-remove-size);
  height: var(--aparte-thumb-remove-size);
  border-radius: 50%;
  opacity: 0;
}
/* Hover is not the only way in. The ✕ is the ONLY way to drop a pending attachment,
   and at `opacity: 0` it was invisible to a keyboard user standing on it — a focus
   ring drawn around nothing. `:focus-within` on the tile is the same pair the message
   action bar (bubble.css) and the conversation row (conversation.css) already use;
   this one had been written with the hover half alone. The touch half is in
   responsive.css, beside the row's ⋯, for the same reason: a finger cannot hover. */
.aparte-thumb:hover .aparte-thumb__remove,
.aparte-thumb:focus-within .aparte-thumb__remove {
  opacity: 1;
}
.aparte-thumb__remove:hover {
  background: var(--aparte-thumb-remove-bg-hover);
}

/* =========================================
   Edit mode
   ========================================= */

/* Inline editor: the composer's contenteditable primitive, given the composer
   shell's box (bordered surface) so it reads as the same field. The inner
   .aparte-ci-editor inherits its padding/font/caret from the shared composer rule. */
.aparte-message[data-editing] aparte-composer-input {
  display: block;
  margin: var(--aparte-space-2) 0;
  background: var(--aparte-input-bg);
  border: var(--aparte-border-width) solid var(--aparte-input-border);
  border-radius: var(--aparte-radius-input);
  box-sizing: border-box;
  transition: border-color var(--aparte-duration-slow);
}
.aparte-message[data-editing] aparte-composer-input:focus-within {
  border-color: var(--aparte-input-focus-border);
}

/* =========================================
   Legacy composer input utility classes
   ========================================= */

.aparte-input-wrapper {
  display: flex;
  align-items: flex-end;
  gap: var(--aparte-input-gap);
  padding: var(--aparte-space-4) var(--aparte-space-6);
  background: transparent;
}

.aparte-input-container {
  flex: 1;
  min-height: var(--aparte-input-container-min-height);
  /* Matches _minHeight default */
  display: flex;
  flex-direction: column;
  /* justify-content: center; Removed to fix cursor pos */

}

.aparte-editor {
  box-sizing: border-box;
  width: 100%;
  max-height: var(--aparte-input-editor-max-height);
  /* Matches _maxHeight default */
  overflow-y: auto;
  outline: none;
  border: none;
  font-family: var(--aparte-font-family);
  font-size: var(--aparte-input-editor-font-size);
  line-height: var(--aparte-line-height-normal);
  color: var(--aparte-input-text);
  background: transparent;
  padding: var(--aparte-space-5) 0;
  resize: none;
  text-align: left;
}

/* Placeholder */
.aparte-editor:empty:before {
  content: attr(data-placeholder);
  color: var(--aparte-input-placeholder);
  pointer-events: none;
  display: block;
  /* Ensures proper rendering */
}

/* Disabled state */
.aparte-editor[contenteditable="false"] {
  color: var(--aparte-text-muted);
  cursor: not-allowed;
}

/* The row's size first, this button's own second — see `.aparte-composer-row`. Inside
   the row helper the two agree with the documented order; outside it, only the second
   is defined and a standalone send button keeps its own measure. */
.aparte-send-button {
  --aparte-btn-radius: var(--aparte-radius-send-btn);
  /* `--aparte-btn-icon-size`, not `--aparte-icon-size`: an icon that is a direct child
     of `.aparte-btn` is sized by the button rule, which out-specifies `.aparte-icon`.
     Declaring the other one here did nothing, and the glyph stayed at the 16px default
     inside a 36-44px button — 44% of its box, where every sized button in the library
     reads at ~57%. */
  --aparte-btn-icon-size: var(--aparte-icon-size-xl);
  /* NO ink override here, and that is the point.

     A white glyph lived on this line for ten minutes: `--aparte-send-btn-ink: #ffffff`,
     a hex in core chosen because THIS theme's brass is light. On a theme whose primary
     is dark it would have been white on white — the exact defect the derivation had just
     been built to remove, reintroduced one file away from where it was removed.

     The glyph is near-black, and the evidence that this is right was already in the
     repo: the landing page's hand-written `.btn-primary` has carried `color: #1a1206`
     on the same brass since long before any of this, a warm near-black whose lightness
     (0.189) is within a hair of what the recipe now computes (0.175). Nobody ever found
     that ugly.

     A theme that disagrees says so by NAME, not by patching this button:
     `--aparte-on-primary` sets the ink for every primary control at once, which is the
     honest scope — white there costs AA under a text label (3.60 light, 2.28 dark), and
     that cost belongs to whoever chooses it. */
  width: var(--aparte-composer-row-control-size, var(--aparte-send-btn-size));
  height: var(--aparte-composer-row-control-size, var(--aparte-send-btn-size));
}

.aparte-send-button:hover:not(:disabled) {
  background: var(--aparte-primary-hover);
}

.aparte-send-button:disabled {
  cursor: not-allowed;
  background: var(--aparte-send-disabled-bg, var(--aparte-surface-2));
  color: var(--aparte-text-muted);
}

/* =========================================
   Input Upper (for file chips, previews, etc.)
   ========================================= */

.aparte-input-upper {
  display: none; /* shown by JS when chips are added */
  flex-wrap: wrap;
  align-items: center;
  gap: var(--aparte-space-4);
  padding: var(--aparte-space-4) var(--aparte-space-6);
  border-bottom: var(--aparte-border-width) solid var(--aparte-border);
  font-size: var(--aparte-font-size-md);
}

.aparte-input-upper.aparte-has-content {
  display: flex;
}

/* =========================================
   Input Footer (for Model Selector, etc.)
   ========================================= */

.aparte-input-footer {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-6);
  padding: var(--aparte-space-4) var(--aparte-space-6);
  border-top: var(--aparte-border-width) solid var(--aparte-border);
  font-size: var(--aparte-font-size-md);
}

.aparte-input-footer:empty {
  display: none;
}

/* Slot areas */
.aparte-input-footer slot[name="footer-left"] {
  display: contents;
}

.aparte-input-footer slot[name="footer-center"] {
  flex: 1;
  display: contents;
}

.aparte-input-footer slot[name="footer-right"] {
  display: contents;
  margin-inline-start: auto;
}

/* Model Selector Component */
/* The element and the class said the same three things in two rules. The class is
   the hook for an app that lays out its own selector; grouping them is what stops
   the two from drifting — and it is why `[hidden]` now covers the class too, which
   it did not, so a hidden wrapper stayed laid out. */
aparte-model-selector,
.aparte-model-selector {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-4);
}
/* Respect native hidden attribute — overrides the display:flex above */
aparte-model-selector[hidden],
.aparte-model-selector[hidden] { display: none !important; }

.aparte-provider-select,
.aparte-model-select {
  appearance: none;
  background: var(--aparte-surface-1);
  border: var(--aparte-border-width) solid var(--aparte-border);
  border-radius: var(--aparte-radius-select);
  padding: var(--aparte-space-3) var(--aparte-model-select-chevron-room) var(--aparte-space-3) var(--aparte-space-5);
  font-size: var(--aparte-font-size-md);
  font-family: var(--aparte-font-family);
  color: var(--aparte-text);
  cursor: pointer;
  min-width: var(--aparte-model-select-min-width);
  /* Chevron: baked into a data-URI SVG (CSS vars can't interpolate into url()).
     The stroke colour is theme-matched via the [data-aparte-theme] override
     below rather than a single var — the one spot that needs two variants. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--aparte-space-4) center;
  transition: border-color var(--aparte-duration-base), box-shadow var(--aparte-duration-base);
}

.aparte-provider-select:hover,
.aparte-model-select:hover {
  border-color: var(--aparte-primary);
}

.aparte-provider-select:focus-visible,
.aparte-model-select:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

/* Dark mode overrides for selects */
[data-aparte-theme="dark"] .aparte-provider-select,
[data-aparte-theme="dark"] .aparte-model-select {
  background-color: var(--aparte-surface-1);
  border-color: var(--aparte-border);
  color: var(--aparte-text);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
}

/* ─────────────────────────────────────────────────────────────────────────────
   Composer Primitives
   Headless by design — consumers own layout. These rules provide only the
   minimum defaults needed for correctness (display, box model, interaction).
   ───────────────────────────────────────────────────────────────────────────── */

aparte-composer {
  display: block;
  /* Container-query root — see aparte-chat-viewport. */
  container-type: inline-size;
  /* The transcript's own inline gutter, read from the token that produces it.
     `.aparte-composer-shell` and `.aparte-message` both cap at
     `--aparte-message-max-width` and centre with `margin: 0 auto`, so on a container
     WIDER than the cap they lined up by construction. Below it the cap stops applying,
     each becomes 100% of its own parent — and this element had no padding, so the
     composer went flush to the chat's edges while the transcript kept its inset.
     Measured at a 512px chat: message column 26/26, composer 0/0. Every chat narrower
     than 800px was hit — a phone, an embedded widget, either pane of `<aparte-split>`,
     an app shell whose docked sidebar leaves the chat narrow on a wide window.

     INLINE ONLY, and that is not shorthand-avoidance: the block axis is already spaced
     deliberately, 16px above from the viewport and 16px below from
     `--aparte-chat-bottom-gap` (shell.css), which were tuned to match. The `padding`
     shorthand would add a second 16px to each.

     No 520px step to follow the transcript's, and it is not an oversight: a container
     query cannot match its own container, and this element IS one (the line above).
     Nothing above it is a container either, and `aparte-chat` may never become one —
     it would then be the containing block of the `position: fixed` select dropdown
     that exists to escape `overflow: hidden` (select.css, and the same warning in
     split.css). So below 520px the transcript tightens to 8px and this stays at 16.
     So the composer does not guess: the viewport MEASURES where its rows start — the
     transcript's padding plus the scrollbar gutter it reserves on both edges, at
     whatever step its container query has taken — and publishes it on the chat host as
     `--aparte-transcript-inset`. Padding by that puts the composer's box on the row's
     box at every width. Measured before: 10px apart at 768, the gutter's half at 1280,
     three left edges 12px apart on the reading-column demo. The fallback is for a
     composer with no viewport beside it. */
  padding-inline: var(--aparte-transcript-inset, var(--aparte-viewport-padding));
}

aparte-composer-input {
  display: block;
  flex: 1 1 auto;
  min-width: 0;
}

/* Contenteditable editor inside aparte-composer-input */
aparte-composer-input .aparte-ci-editor {
  display: block;
  width: 100%;
  min-height: var(--aparte-composer-control-size);
  padding: var(--aparte-input-padding-y) var(--aparte-input-padding-x);
  font-size: var(--aparte-input-font-size);
  line-height: var(--aparte-input-line-height);
  color: var(--aparte-text);
  background: transparent;
  border: none;
  outline: none;
  resize: none;
  white-space: pre-wrap;
  word-wrap: break-word;
  overflow-y: hidden;
  box-sizing: border-box;
  caret-color: var(--aparte-text);
}

/* Placeholder via CSS. It never wraps: a `::before` counts in `scrollHeight`, and the
   auto-grow measured it once in a box that had no width yet — "Type a message…" folded
   over a dozen lines and the editor came up ~300 px tall while empty (#55). A
   placeholder longer than the box is clipped, which is what every native input does. */
aparte-composer-input .aparte-ci-editor:empty::before {
  content: attr(data-placeholder);
  color: var(--aparte-input-placeholder);
  pointer-events: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  display: block;
}

aparte-composer-input .aparte-ci-editor[aria-disabled="true"] {
  color: var(--aparte-text-muted);
  cursor: not-allowed;
}

aparte-composer-send {
  display: contents;
}

aparte-composer-cancel {
  display: contents;
}

/* ── The attachment strip: lays out the thumbnail tiles (.aparte-thumb) ──
   One rule. It used to be two, fifteen lines apart in this same file, repeating
   `display`, `flex-wrap` and `gap` at identical values — so the later block silently
   owned those three and editing the earlier one changed nothing. Two owners for one
   value is the shape every drift in this stylesheet has taken. */
aparte-composer-attachments {
  display: flex;
  flex-wrap: wrap;
  gap: var(--aparte-space-3);
  padding: var(--aparte-space-4) var(--aparte-space-6);
  width: 100%;
  box-sizing: border-box;
  border-bottom: var(--aparte-border-width) solid var(--aparte-border);
  --aparte-attachment-image-size: 56px;
  /* Many attachments → cap the height and scroll. */
  max-height: var(--aparte-attachments-max-height);
  overflow-y: auto;
}

aparte-composer-attachments[hidden] {
  display: none;
}

/* ── Add attachment button ── */
aparte-composer-add-attachment {
  display: contents;
}

/* ── Generic action button ── */
aparte-composer-action {
  display: contents;
}

/* ── Drag-over state on root ── */
aparte-composer.aparte-is-dragover .aparte-composer-shell {
  outline: var(--aparte-focus-outline-width) dashed var(--aparte-primary);
  outline-offset: var(--aparte-focus-outline-offset);
  border-radius: var(--aparte-radius-input);
}
/* fallback: no shell */
aparte-composer.aparte-is-dragover:not(:has(.aparte-composer-shell)) {
  outline: var(--aparte-focus-outline-width) dashed var(--aparte-primary);
  outline-offset: var(--aparte-focus-outline-offset);
  border-radius: var(--aparte-radius-input);
}

/* ─────────────────────────────────────────────────────────────────────────────
   Composer Layout Helpers  (opt-in, not required)
   Use these classes when you want the composer look out of the box.

   <aparte-composer>
     <div class="aparte-composer-shell">
       <aparte-composer-attachments></aparte-composer-attachments>
       <div class="aparte-composer-row">
         <aparte-composer-add-attachment></aparte-composer-add-attachment>
         <aparte-composer-input></aparte-composer-input>
         <aparte-composer-send></aparte-composer-send>
       </div>
     </div>
   </aparte-composer>
   ───────────────────────────────────────────────────────────────────────────── */

/* Outer shell — visual border + radius. Mirrors .aparte-input-wrapper */
.aparte-composer-shell {
  background: var(--aparte-input-bg);
  border: var(--aparte-border-width) solid var(--aparte-input-border);
  border-radius: var(--aparte-radius-input);
  max-width: var(--aparte-message-max-width);
  margin: 0 auto;
  width: 100%;
  box-sizing: border-box;
  transition: border-color var(--aparte-duration-slow);
}

.aparte-composer-shell:focus-within {
  border-color: var(--aparte-primary);
}

/* Inner row — flex container for input + buttons */
/* Action buttons sized to the input's single-line height — they derive from
   --aparte-composer-control-size (default: the lg step, 36px; the touch target under a coarse pointer), so a theme resizes the whole
   composer control set with ONE variable and alignment holds. Combined with
   the row's `align-items: flex-end` this makes the buttons read as vertically
   centered against a single line of text, and keeps them anchored to the
   bottom once the input grows multi-line — no magic margins.

   The row DECLARES the size, it does not restyle anything: `--aparte-btn-size` is
   what `.aparte-btn--icon` already reads, and a custom property inherits. So every
   one of the composer's own controls — each is an icon button — picks 44px up from
   here, and nothing else does.

   This used to be `.aparte-composer-row button`, a TYPE selector, and that is a rule
   reaching into content it does not own: a panel mounts inside this row, and its
   options are buttons too. They came out 44x44 circles with their labels spilling
   out. The answer at the time was an undo rule in base.css (`[data-aparte-panel]
   button`) — same 0,1,1 specificity, so which one won was decided by import order
   alone, and splitting the stylesheet into families flipped that order and brought
   the circles back. A declaration that only the intended reader can see needs no
   undo, and cannot be re-broken by a re-ordering.

   `--aparte-composer-row-control-size` is the row's own reading of that knob, and it
   exists because an ancestor cannot out-rank a descendant's own declaration: the send
   and action buttons DECLARE a size of their own, so the row cannot inherit one over
   them. They opt in by reading this first and their own token second — which is
   exactly the documented contract ("wins over --aparte-send-btn-size there"), now
   written in the cascade instead of enforced by out-specifying it. Undefined outside
   this row, so the same rule sizes a standalone button from its own token. */
.aparte-composer-row {
  display: flex;
  align-items: flex-end;
  gap: var(--aparte-space-4);
  padding: var(--aparte-space-4) var(--aparte-space-6);
  /* The lg step at rest — 36px, what `--aparte-send-btn-size` says — and the touch
     target under a coarse pointer (responsive.css). It was 44 everywhere, which made
     the send button the one control the coarse-pointer block could not enlarge because
     it already was, and a 62px bar around 15px of text on a desktop. */
  --aparte-composer-row-control-size: var(--aparte-composer-control-size);
  --aparte-btn-size: var(--aparte-composer-row-control-size);
}

/* The composer's bottom row (mode picker, model selector, token counter…).
   `aparte-composer-toolbar` is the element consumers write — one name for vanilla and
   every wrapper. `.aparte-composer-footer` is the class the same row used to be built
   with, kept so markup that predates the element keeps its styling instead of silently
   rendering as a bare div. Removable at 1.0. */
aparte-composer-toolbar,
.aparte-composer-footer {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-4);
  padding: var(--aparte-space-3) var(--aparte-space-6);
  border-top: var(--aparte-border-width) solid var(--aparte-border);
}

/* A row with nothing in it must not draw its separator. The element reflects
   `data-empty` itself (see aparte-composer-toolbar.ts) because `:empty` is defeated by
   the whitespace text node an indented template leaves behind; the legacy class keeps
   `:empty`, which is all it ever had. */
aparte-composer-toolbar[data-empty],
.aparte-composer-footer:empty {
  display: none;
}

/* The cancel button.
   It used to be "cancel/dictate", paired in every one of these selectors with
   `aparte-composer-dictate .aparte-cd-button` — an element that does not exist and
   never has. No file defines it, nothing emits it, and git history has never deleted
   one: it was written for a component that was planned and never landed. An unknown
   element name is a valid selector that matches nothing, so it broke nothing; it just
   shipped three dead selectors to every consumer and told the next reader that a
   dictate component was somewhere to be found. */
aparte-composer-cancel .aparte-cc-button[hidden] {
  display: none !important;
}

/* The chrome is `.aparte-btn`. Only the padding stays: this one sits inside the
   composer row, tighter than a standalone icon button. */
aparte-composer-cancel .aparte-cc-button {
  --aparte-btn-radius: var(--aparte-radius-action-btn);
  padding: var(--aparte-space-3);
}

aparte-composer-cancel .aparte-cc-button:hover {
  color: var(--aparte-text);
  background: var(--aparte-surface-2);
}

.aparte-chat-container--auto-center aparte-composer {
  flex: 0 0 auto;
}
/*
 * aparté — Segment renderers: thinking, code, tool call, error.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 */
.aparte-segments {
  font-size: var(--aparte-content-font-size);
  line-height: var(--aparte-content-line-height);
  color: var(--aparte-content-color);
  font-family: var(--aparte-font-family);
}

.aparte-segments:empty {
  display: none;
}

/* The waiting indicator: between "user sends" and the first token. Built into the
   bubble, so it needs no wiring and behaves the same in core and every wrapper.
   Sits in the content region and disappears as soon as there is anything to show. */
.aparte-waiting {
  display: flex;
  align-items: center;
  min-height: var(--aparte-waiting-height);
}

.aparte-waiting[hidden] {
  display: none;
}

.aparte-waiting .aparte-dots {
  gap: var(--aparte-waiting-dot-gap);
}

/* Staggered so it reads as "working", not as three synchronized blobs. Scoped to
   the waiting region — the status element keeps its single-dot pulse. */
.aparte-waiting .aparte-dot:nth-child(2) {
  animation-delay: var(--aparte-duration-base);
}

.aparte-waiting .aparte-dot:nth-child(3) {
  animation-delay: var(--aparte-duration-slower);
}

/* =========================================
   Segment Renderers (Global Defaults)
   ========================================= */

/* Text */
.aparte-segment-thinking {
  /* Understated disclosure: a muted left rail, not a filled card. */
  border-left: var(--aparte-thinking-rail-width) solid var(--aparte-thinking-border);
  margin: var(--aparte-space-5) 0;
  background: transparent;
}

/* WHAT IS LEFT is only what belongs to REASONING, not to disclosure.
   The flex row, the `list-style`/`::-webkit-details-marker` reset, the chevron and its
   180° turn were all redrawn here under four private classes while
   `surface/accordion.css` already had every one of them. The block now wears
   `.aparte-accordion__header` / `__item` / `__panel` and adds these two lines: a
   quieter face than a real accordion header, and the padding the left rail needs. */
.aparte-thinking-header {
  /* The chevron sits beside the label: the accordion recipe puts it at the row's far
     end, which is what an accordion is, but this is a one-line disclosure, and "Reasoning"
     and its chevron were 692px apart at 1280 (T18). */
  justify-content: flex-start;
  padding: var(--aparte-space-1) 0 var(--aparte-space-1) var(--aparte-space-5);
  font-size: var(--aparte-font-size-sm);
  color: var(--aparte-thinking-text);
}

/* Code */
.aparte-code-header {
  display: flex;
  align-items: center;
  padding: var(--aparte-space-4) var(--aparte-space-6);
  background: var(--aparte-code-header-bg);
  border-bottom: var(--aparte-border-width) solid var(--aparte-code-border);
  font-size: var(--aparte-font-size-sm);
  color: var(--aparte-code-header-text);
}

.aparte-code-filename {
  font-weight: var(--aparte-font-weight-semibold);
  margin-inline-end: auto;
  /* Logical property: pushes language + copy to the right when filename is present */
}

.aparte-code-header-filler {
  flex: 1;
  /* No-filename placeholder — keeps language + copy right-aligned */
}

.aparte-code-language {
  margin-inline-end: var(--aparte-space-4);
  /* Logical property */
  text-transform: uppercase;
  /* The header's ink is already the muted one; an `opacity: .7` on top muted it twice. */
}


.aparte-code-copy:hover {
  background: var(--aparte-code-copy-hover-bg);
}

.aparte-code-copy svg {
  width: var(--aparte-code-copy-icon-size);
  height: var(--aparte-code-copy-icon-size);
}

/* Error */
.aparte-segment-tool-call { display: block; padding: var(--aparte-space-1) 0; }
/* The row is the summary. list-style:none plus the ::-webkit- pseudo is the
   pair that actually removes the native marker across engines. */
/* A ROW, not a badge.
   It was a rounded chip, which is what a tag looks like — and a tool call is not
   a tag, it is a step of the turn. Every current implementation draws it as a
   line: the disclosure control, the call's identity, and its state at the far
   end. The chip also had to carry a border and a fill, so it competed with the
   reply text beside it for attention it did not deserve. */
.aparte-tool-summary {
    display: flex; align-items: center; gap: var(--aparte-space-4);
    /* The negative margin is what puts the row's CONTENT on the message column rather
       than one padding in from it — the padding here is the hover surface, not the
       column. So it has to be given back on BOTH sides. It used to be
       `margin-inline-start` alone: the name, the icon and the chevron landed on the
       column, and the trailing state stopped `--aparte-space-3` short of the right edge
       every line of prose reaches. Measured at a 512px chat: text L26/R26, the state's
       right edge at R32 — six pixels, on the one line of a turn whose whole job is to
       read as a quiet aside beside the text. `margin-inline` puts it at R26 and makes
       the hover surface symmetric with it. */
    padding: var(--aparte-space-2) var(--aparte-space-3); margin-inline: calc(var(--aparte-space-3) * -1);
    border-radius: var(--aparte-tool-row-radius);
    cursor: pointer; list-style: none;
}
.aparte-tool-summary:hover { background: var(--aparte-surface-2); }
.aparte-tool-summary::-webkit-details-marker { display: none; }
/* SIZES the marker; does not draw it. This was a triangle built out of CSS borders — a
   third hand-drawn chevron in a repo whose icon rule exists because two had already
   diverged. Three clauses of the reason written in `thinking.ts` hold verbatim here: it
   could not take `--aparte-icon-size`, could not be replaced through `setIconProvider`,
   and rotated 90deg where every other disclosure in the library turns 180.

   The row asks the provider for four glyphs already (`tool`, `check`, `close`), so this
   was the single glyph in it that was not asked for — while `guides/tools.mdx` documents
   the part as "the chevron", a lever that answered to nothing.

   NOT moved onto the accordion recipe, deliberately: that header is `space-between` with
   a TRAILING icon, and `.aparte-tool-state` already holds the trailing slot below. The
   defect was the hand-drawn glyph, not a missing recipe. */
.aparte-tool-toggle {
    display: grid;
    place-items: center;
    flex: none;
    width: var(--aparte-space-8);
    height: var(--aparte-space-8);
    /* Quiet is a colour, not an opacity: at 0.5 the chevron measured 3.00:1 — on the
       WCAG floor exactly — and it is the control that reveals a `delete_file`'s
       arguments. The muted ink at rest, the full ink under the pointer. */
    color: var(--aparte-text-muted);
    transition: transform var(--aparte-duration-base), color var(--aparte-duration-base);
}
.aparte-tool-toggle svg { width: 100%; height: 100%; display: block; }
.aparte-tool-summary:hover .aparte-tool-toggle { color: var(--aparte-text); }
details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180deg); }
/* A rule down the leading edge, so the disclosure reads as belonging to the
   pill above it rather than continuing the message text below. Without it
   "Output" and the assistant's next sentence sat in one undifferentiated
   column. */
.aparte-tool-detail {
    display: flex; flex-direction: column; gap: var(--aparte-space-4);
    /* Aligned under the row's icon, so the rule reads as descending from the
       call rather than floating beside it. */
    margin: var(--aparte-space-2) 0 var(--aparte-space-3) var(--aparte-space-2); padding: var(--aparte-space-1) 0 var(--aparte-space-1) var(--aparte-space-6);
    border-inline-start: var(--aparte-border-width) solid var(--aparte-border);
}
.aparte-tool-part-label {
    display: block; font-size: var(--aparte-font-size-xs); text-transform: uppercase;
    letter-spacing: 0.04em; color: var(--aparte-text-muted); margin-bottom: var(--aparte-space-1);
}
/* A tool's input and result WRAP. They are a message, a path, a JSON object — not
   code to read line by line — and a `<pre>` keeps `white-space: pre` by default, so
   a one-line result ran past the bubble: measured 1 823px of text in a 723px body
   (407px on a phone), the whole disclosure clipped at the message's edge. Same pair
   the code block uses; `anywhere` breaks the unbreakable (a long path, a hash). */
.aparte-tool-part-body pre {
    margin: 0;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}
/* Always in the DOM so update can leave the node alone and toggle hidden
   instead — an attribute a test can read, where a CSS-only rule is invisible
   to jsdom. */
.aparte-tool-spinner[hidden] { display: none; }
/* The call's identity: its icon and its name, and no decoration of its own.
   No border, no fill, no tint at any status — the row is the container now, and
   the colour lives on the state badge alone. A resolved call used to be filled
   green, which made a finished step shout louder than the reply it belongs to. */
.aparte-tool-label {
    display: inline-flex;
    vertical-align: middle;
    align-items: center;
    gap: var(--aparte-space-3);
    min-width: 0;
    font-size: var(--aparte-font-size-md);
    font-weight: var(--aparte-font-weight-medium);
    color: var(--aparte-text);
    user-select: none;
}
/* A fixed square, and the glyph centred in it. The icon was baseline-aligned
   inside an inline box, so it sat low against the name and moved with whatever
   glyph the icon provider returned. */
.aparte-tool-icon {
    display: grid;
    place-items: center;
    width: 1em; height: 1em;
    flex: none;
    color: var(--aparte-text-muted);
}
.aparte-tool-icon svg { width: 1em; height: 1em; display: block; }
.aparte-tool-name { font-family: var(--aparte-code-font-family); }
/* The state, as a word with its glyph. Colour lives HERE and nowhere else.
   Pushed to the far end by DOM order plus an auto inline-start margin, which is
   the placement rule this repo already settled on: no left/right in a name, so
   a right-to-left locale mirrors it for free. */
/* Beside the tool's name, not at the row's far end: with `margin-inline-start: auto` the
   state word drifted 63px from the name at 375 and 596px at 1280. The state belongs to
   the label (T18). */
.aparte-tool-state {
    display: inline-flex;
    vertical-align: middle; align-items: center; gap: var(--aparte-space-2);
    font-size: var(--aparte-font-size-sm); font-weight: var(--aparte-font-weight-medium);
    color: var(--aparte-text-muted);
    user-select: none;
}
.aparte-tool-state svg { width: 0.9em; height: 0.9em; display: block; }
/* Scoped to the segment, not to a bare [data-status].
   These three were the only rules in this file whose leftmost compound is an unprefixed
   non-element selector, which means they reached OUT of core: a host wrapping the chat in
   <div data-status="rejected"> re-tinted every completed tool call's word red, because
   0,2,0 beats the base rule's 0,1,0 and source order puts rejected/aborted last. Core
   itself documents data-status as a free-form host attribute on <aparte-option>. */
.aparte-segment-tool-call[data-status="resolved"] .aparte-tool-state { color: var(--aparte-success-ink); }
/* Rejected and aborted have NO rule: they keep the base's muted ink. They used to
   share one — red, the error ink — which said "something went wrong" about a call the
   user declined or a run they stopped, and made the two indistinguishable. The market
   (UI audit 2026-08-28, coherence pass) keeps red for what is destructive and shows an
   outcome that did not happen in the muted voice with a glyph: the renderer draws a
   cross for rejected and a stop square for aborted, so the word and the glyph tell them
   apart, not a colour that meant a third thing. */
/* The Approve / Reject button rules were here. They moved with the buttons, to
   .aparte-approval-option in components/elicitation.css - and what stayed behind was a
   leftover: a rule tinting .aparte-tool-label's border while a decision was pending,
   which stopped painting anything the moment the row redesign took that border
   off .aparte-tool-label. It reached for a variable that was never declared, and it
   survived because a template literal is not a stylesheet anyone greps. The
   state is named in words by .aparte-tool-state now, and the decision is at the
   composer. */
/* The spinner is `.aparte-spinner`; only its size is the tool row's business — the
   row is one quiet line, so its indicator is smaller than a standalone one. */
.aparte-tool-spinner {
    width: var(--aparte-tool-spinner-size);
    height: var(--aparte-tool-spinner-size);
}

/* Pipeline waiting */
/* The error segment WEARS the alert recipe — frame and parts (`aparte-alert__icon`,
   `__body`, `__title`, `__message` come from display/alert.css). It used to put the
   recipe's class on its root and redraw every part under classes of its own, with a 20px
   literal for the icon among derived tokens and a title colour nothing else used. What is
   genuinely the segment's own is the one part the alert has no word for: the details
   block — a transport's stack, a status line — set as code under the message. */
.aparte-segment-error__details {
  margin: var(--aparte-space-4) 0 0;
  padding-block-start: var(--aparte-space-4);
  border-block-start: var(--aparte-border-width) solid var(--aparte-error-border);
  font-family: var(--aparte-code-font-family);
  font-size: var(--aparte-font-size-sm);
  line-height: var(--aparte-line-height-normal);
  color: var(--aparte-text-secondary);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* ─────────────────────────────────────────────────────────────────────────────
   Segment renderers — tool call · artifact card

   These three shipped their CSS from `getStyles()` until now: a template literal
   in the renderer's own .ts file, injected into <head> at runtime. That seam is
   for a CONSUMER's renderer — something registered through registerSegmentRenderer
   or registerToolRenderer cannot edit this file, so `getStyles()` is its only way
   onto the page. A BUILT-IN has this file, and two reasons to use it:

     - check:derived-vars reads this path and nothing else. A declaration whose
       value reads another variable has to sit on the 5-anchor layer or it cannot
       follow a palette a subtree overrides, and one hidden in a renderer was
       never checked at all.
     - it is read as CSS, by tools and by people. A backtick closes a template
       literal, and one did — the artifact card's own comment recorded it
       happening — then it happened three more times in one sitting, the worst of
       them rendering a `safe-text` source marker into an assistant's bubble as
       prose, because inside a template literal a `//` comment is just text.

   NOT a reason, though it was the first one I wrote here: reaching the public CSS
   reference. `gen-css-vars.mjs` walks all of core's source for `var(--aparte-*)`
   reads, so a knob a renderer read was already listed with its fallback as the
   default — the move changed that file by zero lines. Worth recording, because the
   plausible version of this argument is the one that gets repeated.

   Moving them changes no contract either: core's entry imports this file and
   package.json marks every .css a side effect, so importing @aparte/core has
   always brought this sheet along.
   ───────────────────────────────────────────────────────────────────────────── */

/* Tool call */
/* Markdown providers inject arbitrary HTML into .aparte-segment-text, and the bubble
   clips overflow (`aparte-chat-bubble { overflow: hidden }`) — without these
   guards a wide image or table is silently cut at the bubble edge. */

.aparte-segment-text {
  word-wrap: break-word;
}

/* Thinking */
.aparte-segment-code {
  margin: var(--aparte-space-6) 0;
  border-radius: var(--aparte-radius-code);
  overflow: hidden;
  border: var(--aparte-border-width) solid var(--aparte-code-border);
}

/* The callout is `.aparte-alert--danger`. What stays is what a callout IN A
   TRANSCRIPT needs: the rhythm between messages, and a wider rail than a standalone
   alert because a failed turn should be findable when you scroll past it. */
.aparte-segment-error {
  margin: var(--aparte-space-6) 0;
  --aparte-alert-rail-width: var(--aparte-error-rail-width);
}

.aparte-segment-text img {
  max-width: 100%;
  height: auto;
}

.aparte-segment-text table {
  display: block;
  max-width: 100%;
  overflow-x: auto;
  border-collapse: collapse;
}

/*
 * aparté — The elicitation panel and the approval gate.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 */
/* ─────────────────────────────────────────────────────────────────────────────
   The elicitation panel — a tool asking the user something, inside the composer.

   This lived in a template string injected as a <style> from
   `aparte-elicitation.ts`, with every size and radius hardcoded. Three costs, all
   paid: it was the one surface in the library that could not be themed, its
   variables were absent from the generated CSS reference, and the injection was a
   document singleton that was never re-created if anything removed it — the exact
   latent defect `injectRendererStyles` had until this release.
   ───────────────────────────────────────────────────────────────────────────── */
.aparte-elic-panel {
  display: flex;
  flex-direction: column;
  gap: var(--aparte-elic-gap);
  padding: var(--aparte-elic-padding);
  width: 100%;
  box-sizing: border-box;
  max-height: var(--aparte-elic-max-height);
  /* The panel itself does not scroll: its BODY does, so the tabs stay at the top and
     the corner escape stays put instead of sliding under the fold. */
  overflow: hidden;
  /* The dismiss corner positions against this. */
  position: relative;
}

.aparte-elic-body {
  flex: 1 1 auto;
  /* Without this a flex child refuses to shrink below its content and the scroll
     never engages — the classic flexbox min-height trap. */
  min-height: 0;
  overflow-y: auto;
  /* Room for the focus ring. A scroll container clips at its padding edge, and an
     option's ring is drawn OUTSIDE its box (the recipe's outline plus its offset), so
     a focused option lost its ring on every side — measured 4px cut left and right on
     a consumer's screenshot, then here. The padding gives the ring that much room and
     the negative margin gives it back to the layout, so nothing moves; `scroll-padding`
     keeps a keyboard-focused option's ring inside the viewport when the list scrolls. */
  --aparte-elic-ring-room: calc(var(--aparte-focus-outline-width) + var(--aparte-focus-outline-offset));
  padding: var(--aparte-elic-ring-room);
  margin: calc(-1 * var(--aparte-elic-ring-room));
  scroll-padding: var(--aparte-elic-ring-room);
  /* A thin, themed scrollbar: the native one is a grey slab across a soft card. */
  scrollbar-width: thin;
  scrollbar-color: var(--aparte-border) transparent;
}

.aparte-elic-message {
  /* The end margin is the room the corner "Skip" needs, from the same token the tab
     rail reserves — the button is absolutely positioned, so nothing in the flow knows
     it is there, and a long question printed its first line underneath it (#50). One
     token, because the button holds a word and the word changes width per locale. */
  margin: 0 var(--aparte-elic-dismiss-room) var(--aparte-space-3) var(--aparte-space-3);
  font-size: var(--aparte-elic-message-size);
  font-weight: var(--aparte-font-weight-semibold);
  color: var(--aparte-text);
  line-height: var(--aparte-line-height-snug);
}

/* One question per field, separated so a form of several reads as several. */
.aparte-elic-field + .aparte-elic-field {
  margin-top: var(--aparte-elic-field-gap);
  padding-top: var(--aparte-elic-field-gap);
  border-top: var(--aparte-border-width) solid var(--aparte-border);
}

.aparte-elic-title {
  margin: 0 var(--aparte-space-3) var(--aparte-space-2);
  font-size: var(--aparte-elic-title-size);
  font-weight: var(--aparte-font-weight-semibold);
  color: var(--aparte-text);
}

.aparte-elic-desc {
  margin: 0 var(--aparte-space-3) var(--aparte-space-3);
  font-size: var(--aparte-elic-desc-size);
  color: var(--aparte-text-muted);
}

.aparte-elic-options {
  display: flex;
  flex-direction: column;
  /* At least the focus ring's outset: at 2px the next row painted over the bottom of
     the focused row's ring and ~8px of it survived. Any list of options that wears an
     outset ring needs a gap at least as wide as the offset. */
  gap: var(--aparte-space-3);
}

/* The row is `.aparte-field-choice`; the panel only tightens its padding, because a
   question's options sit inside an already-padded panel.

   Scoped away from `--command`, which is a BUTTON and wears the button recipe —
   hover included. Out-specifying that recipe from here is the mistake
   `.aparte-composer-row button` made and paid for twice; a declaration only the
   intended reader can see needs no antidote. */
.aparte-elic-option:not(.aparte-elic-option--command) {
  --aparte-field-choice-padding: var(--aparte-elic-option-padding);
  --aparte-field-choice-radius: var(--aparte-elic-option-radius);
}

/* Blind to the chosen row: this rule is (0,3,0) in a sheet imported after field.css,
   so it used to out-specify the mark a checked choice wears there — hover the option
   you picked and its tint and primary border went back to the plain hover ground
   (measured in Chromium by the coherence pass's reviewer). The recipe's own hover is
   scoped the same way; a chosen row keeps saying it is chosen under the pointer. */
.aparte-elic-option:not(.aparte-elic-option--command):not(:has(:checked)):not(.aparte-field-choice--selected):hover {
  background: var(--aparte-surface-2);
  border-color: var(--aparte-border);
}

/* The control is `.aparte-checkbox` or `.aparte-radio` — whichever matches its own
   type. What stays here is what belongs to a control IN THIS ROW rather than to
   controls in general: the nudge that lines it up with the first line of a label that
   may run to two, and this panel's own measure fed into whichever recipe drew it. */
.aparte-elic-control {
  margin-top: var(--aparte-space-2);
  --aparte-checkbox-size: var(--aparte-elic-control-size);
  --aparte-radio-size: var(--aparte-elic-control-size);
  /* Keep the dot proportional to the box: the recipe's default is drawn for its own
     18px, and this panel's control is smaller. */
  --aparte-radio-dot-size: calc(var(--aparte-elic-control-size) * 0.45);
}

.aparte-elic-option-body {
  display: flex;
  flex-direction: column;
  gap: var(--aparte-space-1);
  flex: 1;
}

.aparte-elic-option-title {
  font-size: var(--aparte-elic-option-title-size);
  font-weight: var(--aparte-font-weight-medium);
  color: var(--aparte-text);
  line-height: var(--aparte-line-height-snug);
}

.aparte-elic-option-desc {
  font-size: var(--aparte-elic-option-desc-size);
  color: var(--aparte-text-muted);
  line-height: var(--aparte-line-height-snug);
}

/* The box is `.aparte-field`. What stays is what belongs to a field IN THIS PANEL
   rather than to fields in general: the gap above it, the quieter ground it sits on
   inside an already-raised panel, and a larger face than a filter input because this
   is a field someone composes an answer in. */
.aparte-elic-other-input,
.aparte-elic-text {
  --aparte-field-radius: var(--aparte-elic-input-radius);
  margin-top: var(--aparte-space-2);
  background: var(--aparte-surface-2);
  font-size: var(--aparte-elic-option-title-size);
}

textarea.aparte-elic-text {
  min-height: var(--aparte-elic-textarea-min-height);
}

.aparte-elic-option--recommended {
  border-color: var(--aparte-primary);
  background: color-mix(in srgb, var(--aparte-primary) 6%, transparent);
}
/* One ring at a time. The recommended option is the first to receive focus, and its
   tinted border under the recipe's focus ring read as a double outline (UI audit
   2026-08-28, §8.3 #9). While it holds focus the ring is the one edge, so the border
   steps aside — and ONLY the border. This rule used to clear the background too, which
   made the recommended row the only one in the list with no ground at all: sampled on
   the preview it rendered the page byte for byte while its sisters did not. The
   recommended state draws in addition to the resting state, never instead of it. */
.aparte-elic-option--recommended:focus-within {
  border-color: transparent;
}

/* The whole-request escape, in the corner and out of the way of anything that
   answers a question. It used to be a row at the bottom beside the button that
   advances through the form, and that adjacency promised "skip this question" while
   it declines the lot. */
.aparte-elic-dismiss {
  position: absolute;
  top: 0;
  inset-inline-end: 0;
  z-index: var(--aparte-z-raised);
}

/* ── An approval: a decision, not a value ───────────────────────────────────
   The options are buttons, not radios, because a decision has two primary actions
   of opposite polarity and the composer's one button cannot carry both. So they
   settle on the first click, and approving costs one gesture rather than two.

   The colours are the ones the inline Approve / Reject pair used, moved here from
   the runtime-injected `getStyles()` string in the tool_call renderer. That move is
   the point: injected CSS is invisible to a consumer's own rules, absent from the
   generated CSS reference, and unreachable by `check:derived-vars`. The elicitation
   panel made the same move for the same reason. */
/* A COLUMN, not a row of pills.
   The panel lives in a composer, which is narrow — two or three labels side by side
   either wrap into an unreadable jumble or get squeezed. And the labels are not short:
   a scope option reads "Approve, and always for this tool". A stacked list is also what
   every reference implementation of this shows, for the same reason: one target per
   line, full width, so the choice is read rather than aimed at. */
.aparte-approval-options {
  display: flex;
  flex-direction: column;
  gap: var(--aparte-approval-gap, 4px);
  margin: 0 var(--aparte-space-3);
}

/* ONE recipe: a full-width surface button. It used to carry the button recipe AND a
   boxed `.aparte-field-choice`, and that was wrong twice over. By meaning — an approval
   is a DECISION that settles on the click, not a value you pick and then submit, which
   is what a choice row is for. And by mechanics — both are single-class selectors, so
   the two sets of `display`/`padding`/`border`/`radius` were separated by nothing but
   the order of two imports.

   What the option adds is the reading direction: left-aligned and flush, because the eye
   reads DOWN a list and a centred label in a wide target is harder to scan. The measures
   are re-expressed as the button's own tokens, so they land on the recipe rather than
   beside it. `--surface` already carries the box and the hover.

   ONE rule for both, because they are the same object. A single-choice QUESTION settles
   on the click for the same reason an approval does — one act, one gesture — and giving
   it a second, identical set of declarations is how the two would drift apart. The two
   names stay because the meanings differ, and a consumer restyling one should not have
   to accept the other. */
.aparte-approval-option,
.aparte-elic-option--command {
  justify-content: flex-start;
  text-align: start;
  /* The recipe keeps a label on one line, which is right for a control in a row and
     wrong here: an option reads "Approve, and always for this tool" inside a composer
     narrow enough to make that two lines. */
  white-space: normal;
  font-size: var(--aparte-approval-option-size, 0.85rem);
  font-weight: var(--aparte-font-weight-medium);
  line-height: var(--aparte-line-height-snug);
  --aparte-btn-padding: var(--aparte-approval-option-padding, 8px 10px);
  --aparte-btn-radius: var(--aparte-approval-option-radius, 8px);
}

/* `.aparte-approval-option--affirm` and `--deny` deliberately have NO rule here, and no
   colour is applied to an option anywhere. They name the MEANING of an option, which is
   what a consumer restyling this panel selects on; the appearance is the plain button
   recipe. See `buildApprovalPanel` for why colour lost: an alert's vocabulary on a
   control, and 2.19:1 on the dark palette when it was tried as a fill instead. */

/* What is being decided, between the question and the options.

   The panel used to carry the tool's NAME and nothing else, so the arguments — the
   whole of what a person approves — were only in the transcript row, behind a closed
   disclosure. Capped and scrollable rather than unbounded: the panel itself is capped
   at half the viewport, and a plan or a diff arriving as a tool argument would
   otherwise push the Approve button off the bottom of the surface it is asked on.

   Monospace, because it is JSON: the model's own bytes, not prose about them. */
.aparte-approval-args-label {
  margin: var(--aparte-space-3) var(--aparte-space-3) 0;
  font-size: var(--aparte-font-size-xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--aparte-text-muted);
}

.aparte-approval-args {
  margin: var(--aparte-space-1) var(--aparte-space-3) 0;
  padding: var(--aparte-space-3);
  max-height: var(--aparte-approval-args-max-height, 8.5rem);
  overflow: auto;
  font-family: var(--aparte-code-font-family);
  font-size: var(--aparte-approval-args-size, var(--aparte-font-size-sm));
  line-height: var(--aparte-line-height-snug);
  color: var(--aparte-text);
  background: var(--aparte-surface-2);
  border-radius: var(--aparte-radius-sm);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.aparte-approval-args:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

/* The refusal's reason, in the user's words. Quiet by default and one line tall: it is
   the exception, not the expected act, and a box sized for a paragraph invites one. */
.aparte-approval-instruction {
  font: inherit;
  font-size: var(--aparte-elic-input-size, 0.85rem);
  color: var(--aparte-text);
  background: transparent;
  border: none;
  border-top: var(--aparte-border-width) solid var(--aparte-border);
  border-radius: 0;
  padding: var(--aparte-space-4) var(--aparte-space-5) var(--aparte-space-1);
  margin: var(--aparte-space-2) var(--aparte-space-3) 0;
  resize: none;
  overflow: hidden;
  min-height: 1.6em;
}

.aparte-approval-instruction:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-approval-instruction::placeholder {
  color: var(--aparte-text-muted);
}

/* A ghost `.aparte-btn`; only its type size is the panel's business. */
.aparte-elic-skip {
  font-size: var(--aparte-elic-skip-size);
}

.aparte-elic-skip:hover {
  background: var(--aparte-surface-2);
  color: var(--aparte-text);
}

/* One question at a time: a chip per question, and the chip is also how you go
   back. In this layout the fields are siblings with only one shown, so the
   separator between them would draw a border above whichever one is visible. */
.aparte-elic-panel--stepped .aparte-elic-field + .aparte-elic-field {
  margin-top: 0;
  padding-top: 0;
  border-top: none;
}

/* The tab rail: a hairline the tabs sit on, so the active underline reads as a tab
   rather than as a dash floating under a word. */
/* The row and the tabs are `.aparte-tabs--underline`. What stays is what belongs to
   a panel rather than to tabs in general: room for the corner escape, the rail the
   underline sits on, and a step's smaller type. */
.aparte-elic-steps {
  gap: var(--aparte-elic-step-gap);
  margin: 0 var(--aparte-elic-dismiss-room) var(--aparte-space-4) var(--aparte-space-3);
  border-bottom: var(--aparte-border-width) solid var(--aparte-border);
}

.aparte-elic-step {
  /* The tab recipe draws the indicator; this is only how thick this panel wants it. */
  border-block-end-width: var(--aparte-elic-step-underline);
  font-size: var(--aparte-elic-step-size);
  padding: var(--aparte-elic-step-padding);
  /* The underline sits ON the rail, not above it. */
  margin-bottom: calc(-1 * var(--aparte-border-width));
}

/* Answered: readable, but it is not where you are — and marked, because a colour is
   what the current chip has too. The mark is the `check` glyph, sized by the chip. */
.aparte-elic-step[data-answered] {
  color: var(--aparte-text);
}

.aparte-elic-step__mark {
  display: inline-flex;
  align-items: center;
  margin-inline-end: var(--aparte-space-1);
  color: var(--aparte-success);
  --aparte-icon-size: 0.85em;
}

/* The tag beside a recommended option's label: the recipe draws it, this only keeps it
   from stretching the title line. */
.aparte-elic-option-badge {
  vertical-align: middle;
  margin-inline-start: var(--aparte-space-1);
}

.aparte-elic-step[aria-selected='true'] {
  color: var(--aparte-text);
  font-weight: var(--aparte-font-weight-semibold);
  border-bottom-color: var(--aparte-primary);
}

.aparte-elic-step:hover:not([aria-selected='true']) {
  color: var(--aparte-text);
  border-bottom-color: var(--aparte-border);
}
/*
 * aparté — The conversation list.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 */

/* A date group: its heading wears the menu's label recipe (`.aparte-menu__label`),
   because that is what a heading over a list of rows is. Only the rhythm is its own. */
.aparte-conv-group {
  display: flex;
  flex-direction: column;
  gap: var(--aparte-conv-list-gap);
}

.aparte-conv-group + .aparte-conv-group {
  margin-block-start: var(--aparte-space-3);
}

/* A row in a list, which is what `.aparte-menu__item` is. What stays is what belongs
   to a CONVERSATION row: it truncates, so it needs `min-width: 0`; and its own
   measurements, because a sidebar row is tighter than a dropdown row.

   The row is a flex box around two real buttons — the title, which selects, and the
   `⋯`. The padding sits on the title button, not the row, so that the button's focus
   ring is the row's shape and a click on the row's inner margin lands on the button
   rather than on nothing. */
.aparte-conv-item {
  position: relative;
  min-width: 0;
  gap: var(--aparte-conv-item-gap);
  padding: 0;
  padding-inline-end: var(--aparte-space-2);
  border-radius: var(--aparte-conv-item-radius);
  color: var(--aparte-conv-item-color);
  font-size: var(--aparte-conv-item-font-size);
  cursor: default;
}

/* Hover moves the ground UP, so the label has to move with it.
   The row rests at `--aparte-text-muted` on the shell, which is the point of a resting
   list. On hover the background becomes `--aparte-surface-3` and the muted ink stayed
   put: 4.23:1 in the light theme, measured from the two hexes — an AA failure on body
   text, and on the row the pointer is actually over. It takes the ACTIVE colour instead,
   which is the same value the selected row already uses (12.13 light, 11.71 dark) and so
   costs the design nothing it had not already decided. */
.aparte-conv-item:hover {
  background: var(--aparte-conv-item-bg-hover);
  color: var(--aparte-conv-item-color-active);
}

.aparte-conv-item__select {
  appearance: none;
  -webkit-appearance: none;
  display: flex;
  align-items: center;
  flex: 1;
  min-width: 0;
  padding: var(--aparte-conv-item-padding);
  border: none;
  border-radius: inherit;
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: start;
  cursor: pointer;
}

/* Inside the edge: the list clips horizontally (`overflow-x: hidden` in base.css), and
   a ring drawn outside the row was cut on both sides. */
.aparte-conv-item__select:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

.aparte-conv-item__title {
  flex: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}

/* The conversation you are in wears the mark's bar (display/mark.css) on its start
   edge — Primer's ActionList pattern, and the same bar the select's chosen option and
   a checked field choice carry. Its ground keeps the list's own tokens: a navigation
   list is read for a long time, and the mark's tint on every visit was too loud. */
.aparte-conv-item--active {
  background: var(--aparte-conv-item-bg-active);
  color: var(--aparte-conv-item-color-active);
  font-weight: var(--aparte-conv-item-font-weight-active);
}

/* The selection mark: a bar the width of `--aparte-mark-bar` down the row's start
   edge. Painted as the first pixels of a pseudo the SIZE of the row that inherits its
   radius, so the bar follows the curve — drawn as a 2px box of its own it stood square
   in a 9px corner and a sliver of the page's ground showed between the two. Not
   `overflow: hidden` on the row: that would clip the title button's focus ring. */
.aparte-conv-item--active::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(to right, var(--aparte-primary-ink) var(--aparte-mark-bar), transparent 0);
  pointer-events: none;
}
.aparte-conv-item--active:dir(rtl)::before {
  background: linear-gradient(to left, var(--aparte-primary-ink) var(--aparte-mark-bar), transparent 0);
}

.aparte-conv-item--archived .aparte-conv-item__title {
  font-style: italic;
  opacity: 0.85;
}

/* The `⋯`: one quiet button per row, shown by hover, by focus, on the active row and
   while its menu is open — never a permanent control, and never a red one. What the
   recipe gives (shape, states, focus ring) it keeps; only the size and the resting
   colour are the row's. */
.aparte-conv-item__more {
  flex-shrink: 0;
  opacity: 0;
  /* Fed to the recipe, not drawn here: the token names the size, the button draws it. */
  --aparte-btn-size: var(--aparte-conv-action-btn-size);
  color: var(--aparte-text-muted);
}

.aparte-conv-item:hover .aparte-conv-item__more,
.aparte-conv-item:focus-within .aparte-conv-item__more,
.aparte-conv-item--active .aparte-conv-item__more,
.aparte-conv-item__more[aria-expanded="true"] {
  opacity: 1;
}

.aparte-conv-item__more:hover {
  color: var(--aparte-text);
}

/* The rename input takes the title button's place, so it inherits the row's slot and
   only needs the field recipe plus a little air. */
.aparte-conv-item__input {
  flex: 1;
  min-width: 0;
  margin: var(--aparte-space-1) 0 var(--aparte-space-1) var(--aparte-space-1);
}

/* The row's menu: the menu recipe draws it, the element places it (`position: fixed`,
   `top`/`left` computed at open time, closed on any scroll). Fixed rather than
   absolute because the list scrolls, and an absolute menu on the last rows was clipped
   by that overflow. */
.aparte-conv-menu {
  position: fixed;
  --aparte-icon-size: var(--aparte-icon-size-sm);
}

.aparte-conv-menu__item--danger {
  color: var(--aparte-error);
}

/* The one question the menu asks. */
.aparte-conv-menu__confirm {
  display: flex;
  flex-direction: column;
  gap: var(--aparte-space-3);
  padding: var(--aparte-space-2) var(--aparte-space-3);
}

.aparte-conv-menu__question {
  margin: 0;
  font-size: var(--aparte-font-size-sm);
  line-height: var(--aparte-line-height-snug);
}

.aparte-conv-menu__actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--aparte-space-2);
}
/*
 * aparté — <aparte-suggestions>: the row of prompt starters.
 *
 * One of the sheets src/index.ts imports in cascade order; check:derived-vars reads
 * them concatenated in that same order, the way a browser does.
 *
 * The chips are `aparte-btn aparte-btn--surface aparte-btn--pill` — the recipe draws
 * them, this sheet only lays them out. No token of its own: the gap is the global
 * spacing scale, so a theme reaches it without a knob to re-set.
 */
aparte-suggestions {
  display: block;
}
aparte-suggestions[hidden],
aparte-suggestions[data-empty] {
  display: none;
}

.aparte-suggestions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--aparte-space-2);
  padding-block: var(--aparte-space-2);
  /* The composer's column: past its 800px cap the chips no longer followed the composer,
     188px of offset on a wide host. Same measure, same centring. */
  box-sizing: border-box;
  max-inline-size: var(--aparte-message-max-width);
  margin-inline: auto;
}

/* A starter's label is a short phrase; it must not become a two-line chip. */
.aparte-suggestion {
  white-space: nowrap;
}
/*
 * aparté — <aparte-context>: how full the model's context window is.
 *
 * One of the sheets src/index.ts imports in cascade order; check:derived-vars reads
 * them concatenated in that same order, the way a browser does.
 *
 * The bar is the `aparte-progress` recipe; this sheet lays it out beside its reading
 * and recolours the fill at the two levels — from the global alert tokens, no token
 * of its own.
 */
aparte-context {
  display: block;
}
aparte-context[hidden],
aparte-context[data-empty] {
  display: none;
}

.aparte-context {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-2);
  color: var(--aparte-text-muted);
  font-size: var(--aparte-font-size-sm);
}

.aparte-context .aparte-progress {
  flex: 1;
  min-width: 4rem;
  /* A gauge has a measure: unbounded, the bar stretched to 1046×4px across a toolbar,
     88 % of its width for a line that says one number. */
  max-inline-size: 16rem;
}

.aparte-context__text {
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

aparte-context[data-level="warn"] .aparte-progress__bar {
  background: var(--aparte-warning);
}
aparte-context[data-level="danger"] .aparte-progress__bar {
  background: var(--aparte-error);
}

/* The ring variant: a track and a value circle on a 36-unit box, sized by the two tokens.
   `pathLength="100"` on the value circle (set by the element) makes the dash the
   percentage itself, so the sheet needs no circumference. Rotated a quarter turn so the
   value starts at twelve o'clock. */
.aparte-context--ring {
  gap: var(--aparte-space-1);
}

.aparte-context__ring {
  flex: none;
  width: var(--aparte-context-ring-size);
  height: var(--aparte-context-ring-size);
  transform: rotate(-90deg);
}

/* As heavy as the bar variant, in screen pixels: the same gauge was drawn at two
   weights — a 4px bar and a 2.44px antialiased ring. */
.aparte-context__track,
.aparte-context__value {
  fill: none;
  stroke-width: var(--aparte-progress-height);
  vector-effect: non-scaling-stroke;
}

.aparte-context__track {
  stroke: var(--aparte-surface-3);
}

.aparte-context__value {
  --aparte-context-ratio: 0;
  stroke: var(--aparte-progress-fill);
  stroke-linecap: round;
  stroke-dasharray: var(--aparte-context-ratio) 100;
  transition: stroke-dasharray var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-context__value--empty {
  visibility: hidden;
}

aparte-context[data-level="warn"] .aparte-context__value {
  stroke: var(--aparte-warning);
}
aparte-context[data-level="danger"] .aparte-context__value {
  stroke: var(--aparte-error);
}
/*
 * aparté — <aparte-scroll-rail>: the ticks beside the transcript.
 *
 * One of the sheets src/index.ts imports in cascade order; check:derived-vars reads
 * them concatenated in that same order, the way a browser does.
 *
 * The rail floats on the transcript's end edge as a child of the chat host. `:has()`
 * gives the host its containing block only while a rail is inside it — a bare
 * `position: relative` on every chat would have moved any absolutely positioned child
 * a consumer already placed there.
 *
 * The three shell shapes shell.css already lays out, because an absolute element lands
 * in the nearest POSITIONED ancestor and a shape missing from this list is a rail that
 * escaped to the page: the vanilla `<aparte-chat>`, the `[data-aparte-chat]` div
 * React/Vue/Svelte render, and Angular's inner `.aparte-chat-container` (its host IS
 * `<aparte-chat>`, but the rail is a child of that inner div). The first two shapes are
 * both true of the wrapper div, which carries the class and the attribute — listing all
 * three costs one selector and closes the Angular hole.
 */
aparte-chat:has(> aparte-scroll-rail),
[data-aparte-chat]:has(> aparte-scroll-rail),
.aparte-chat-container:has(> aparte-scroll-rail) {
  position: relative;
}

/* Where the rail sits is measured, not assumed. The element publishes three lengths on
   itself (aparte-scroll-rail.ts, `_layout`): `--aparte-scroll-rail-bar`, the width of
   the transcript's scrollbar when it is a classic one — 0 for an overlay bar — so the
   ticks never sit on the bar under Windows or Linux Chrome; and the two block insets,
   the transcript's distance from the host's top and bottom edges, so the rail centres
   on the messages and not on the composer under them. Without a measurement (jsdom, a
   rail with no transcript yet) the fallbacks centre it on the host, where it used to be.

   It is a LIST of ticks, not a minimap: it takes the height of its list, capped at a
   share of the transcript (`--aparte-scroll-rail-share`, .6), and sits centred on the
   transcript's span — its top is the middle of that span, pulled back by half its own
   height. Measured on the market: every per-turn rail that ships as a list caps itself
   (LobeChat at 50vh), and every full-height strip is a proportional minimap with a
   viewport window, a different component; a list stretched to the full transcript read
   as the second while behaving as the first.

   The block padding is the room the END zones need, and it is not decoration.
   The rail clips the list at its padding box, and the list has no padding of its own,
   so with no padding here the first tick's top edge sits exactly on the clip line. A
   zone grows symmetrically — half of `hit − thickness` above and below the line — so
   the first tick would lose its upper half and the last tick its lower half, to paint
   AND to hit-testing: 13px of the 24px, on the two ticks a reader aims at most ("jump
   to the first message", "jump to the latest"). Padding by exactly that half puts the
   clip line outside every zone instead of through the two end ones. `box-sizing` keeps
   `max-height` measuring the same box it measured before.

   When more ticks exist than 24px targets fit in the cap, the element tightens the
   pitch by setting `--aparte-scroll-rail-hit-size` on itself (never under 6px), and
   gap, padding and zone follow it here as they would a consumer's value. Past that
   floor the list still overflows, and the rail scrolls — the element keeps the current
   tick in its window, the arrows walk every tick, a wheel over the rail slides it —
   with no bar of its own: a 24px column showing a scrollbar would be all bar. */
aparte-scroll-rail {
  position: absolute;
  inset-inline-end: calc(var(--aparte-space-2) + var(--aparte-scroll-rail-bar, 0px));
  top: calc(var(--aparte-scroll-rail-block-start, 0px) + (100% - var(--aparte-scroll-rail-block-start, 0px) - var(--aparte-scroll-rail-block-end, 0px)) / 2);
  transform: translateY(-50%);
  z-index: var(--aparte-z-floating);
  display: flex;
  flex-direction: column;
  box-sizing: border-box;
  width: var(--aparte-scroll-rail-width);
  padding-block: calc((var(--aparte-scroll-rail-hit-size) - var(--aparte-scroll-rail-tick-thickness)) / 2);
  max-height: calc((100% - var(--aparte-scroll-rail-block-start, 0px) - var(--aparte-scroll-rail-block-end, 0px)) * var(--aparte-scroll-rail-share));
  overflow-x: hidden;
  overflow-y: auto;
  scrollbar-width: none;
}

aparte-scroll-rail::-webkit-scrollbar {
  display: none;
}

aparte-scroll-rail[hidden],
aparte-scroll-rail[data-empty] {
  display: none;
}

.aparte-scroll-rail__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  flex-shrink: 0;
  gap: var(--aparte-scroll-rail-gap);
}

/* A tick is a real button, drawn as a line. The hit area is the zone around it, drawn
   by the pseudo-element so the line itself can stay two pixels. */
.aparte-scroll-rail__tick {
  appearance: none;
  -webkit-appearance: none;
  position: relative;
  display: block;
  width: var(--aparte-scroll-rail-tick-size);
  height: var(--aparte-scroll-rail-tick-thickness);
  padding: 0;
  border: 0;
  border-radius: var(--aparte-radius-full);
  /* A tick is a thing you can press; at rest in the region colour it sat at 1.15:1, a
     14×2px dash under the perception threshold. */
  background: var(--aparte-border-control);
  cursor: pointer;
  transition:
    width var(--aparte-duration-fast) var(--aparte-ease),
    background var(--aparte-duration-fast) var(--aparte-ease);
}

/* The pressable zone: 24px square around a 14×2 line (WCAG 2.5.8). It used to be 22×10
   — half the 8px gap in the block axis, one spacing step in the inline one — and no
   spacing exemption covered it, because at a 10px pitch a neighbour's 24px circle
   overlaps this one's.
   Block: grow symmetrically to the hit size. The gap is `hit − thickness`, so the pitch
   is exactly the hit size and two zones meet without overlapping — and the rail pads its
   block axis by the same half-growth, so the clip does not eat the first and last zone.
   Inline: grow INWARD only. The rail clips and ticks are end-aligned, so a centred zone
   would hang past the end edge and come back cut to 19px; anchored to the tick's end
   edge it stays whole, and the rail's width carries the hit size as its floor so the
   column has room for it. */
.aparte-scroll-rail__tick::before {
  content: "";
  position: absolute;
  inset-block: calc((var(--aparte-scroll-rail-tick-thickness) - var(--aparte-scroll-rail-hit-size)) / 2);
  inset-inline-end: 0;
  inset-inline-start: calc(var(--aparte-scroll-rail-tick-size) - var(--aparte-scroll-rail-hit-size));
}

.aparte-scroll-rail__tick:hover {
  background: var(--aparte-text-muted);
}

.aparte-scroll-rail__tick[aria-current="true"] {
  width: calc(var(--aparte-scroll-rail-tick-size) * 1.6);
  background: var(--aparte-primary);
}

.aparte-scroll-rail__tick:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

@media (prefers-reduced-motion: reduce) {
  .aparte-scroll-rail__tick {
    transition: none;
  }
}
/*
 * aparté — the shell: the chrome a whole application puts around the chat. A sidebar
 * of conversations, a header, and the grid that holds them beside the transcript —
 * what a ChatGPT-style page is made of, so a site can be built on aparté alone.
 *
 * One of the sheets src/index.ts imports in cascade order; check:derived-vars reads
 * them concatenated in that same order, the way a browser does.
 */

/*
 * aparte-sidebar — the column beside the chat.
 * A sized column that becomes a drawer under 48rem of window. The recipe draws the
 * column and its four regions; `<aparte-sidebar>` adds the behaviour (collapse, the
 * drawer, the search filter) and sets `data-drawer` on itself from the media query,
 * which is the one attribute the rules below read.
 *
 *     <aside class="aparte-sidebar">
 *       <div class="aparte-sidebar__header">
 *         <span class="aparte-sidebar__brand">aparté</span>
 *         <button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="New chat">
 *           <aparte-icon name="edit"></aparte-icon>
 *         </button>
 *       </div>
 *       <div class="aparte-sidebar__search aparte-field-group">
 *         <input class="aparte-field aparte-field--sm" type="search" placeholder="Search" data-aparte-sidebar-search>
 *       </div>
 *       <div class="aparte-sidebar__body">
 *         <aparte-conversation-list></aparte-conversation-list>
 *       </div>
 *       <div class="aparte-sidebar__footer">
 *         <span class="aparte-avatar aparte-avatar--sm">P</span> Paul
 *       </div>
 *     </aside>
 *
 * The element wears the same class and adds the behaviour; `<aside class="aparte-sidebar">`
 * is the look alone, which is what this page shows.
 */
aparte-sidebar,
.aparte-sidebar {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  width: var(--aparte-sidebar-width);
  min-width: 0;
  height: 100%;
  overflow: hidden;
  background: var(--aparte-sidebar-bg);
  border-inline-end: var(--aparte-border-width) solid var(--aparte-border);
  color: var(--aparte-text);
  transition: width var(--aparte-duration-slow) var(--aparte-ease);
}

/* In the flow, collapsed means gone: the column folds to nothing and the chat takes
   the room. The children keep their width so the fold is a slide, not a squash. */
.aparte-sidebar[collapsed]:not([data-drawer]) {
  width: 0;
  border-inline-end-width: 0;
}

/* The children keep the column's width while it collapses, so nothing reflows during
   the transition. BORDER-box, or a padded child is the column plus its padding: the
   header's button sat flush against the edge and the search field ran off it, clipped
   — measured at 292px in a 260px column. The search field carries margins as well,
   which border-box does not cover; its own rule subtracts them. */
.aparte-sidebar > * {
  box-sizing: border-box;
  flex-shrink: 0;
  min-width: var(--aparte-sidebar-width);
}

.aparte-sidebar__header {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-3);
  /* Inline, every region reads `--aparte-sidebar-inset`: the header and footer padded
     16 while the search and the body padded 12, and the column's content stood on two
     vertical axes 4px apart — measured as four left edges on the app-shell demo. */
  padding: var(--aparte-space-4) var(--aparte-sidebar-inset) var(--aparte-space-2);
}

.aparte-sidebar__brand {
  flex: 1;
  min-width: 0;
  font-weight: var(--aparte-font-weight-semibold);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.aparte-sidebar__search {
  margin: var(--aparte-space-2) var(--aparte-sidebar-inset);
  min-width: calc(var(--aparte-sidebar-width) - 2 * var(--aparte-sidebar-inset));
}

/* The body is the one region that scrolls; the list inside keeps its own gap. */
.aparte-sidebar__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  padding: var(--aparte-space-2) var(--aparte-sidebar-inset);
  scrollbar-width: thin;
  scrollbar-color: var(--aparte-scrollbar-thumb) var(--aparte-scrollbar-track);
}

.aparte-sidebar__footer {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-3);
  padding: var(--aparte-space-3) var(--aparte-sidebar-inset);
  border-block-start: var(--aparte-border-width) solid var(--aparte-border);
  font-size: var(--aparte-font-size-sm);
}

/* The drawer: the same element, out of the flow, over the page. `inset-inline-start`
   rather than a transform, so the scrim — a fixed child — stays relative to the
   viewport and can cover the page from inside the element. */
.aparte-sidebar[data-drawer] {
  position: fixed;
  inset-block: 0;
  inset-inline-start: 0;
  z-index: var(--aparte-z-dropdown);
  width: var(--aparte-sidebar-width);
  box-shadow: var(--aparte-menu-shadow);
  transition: inset-inline-start var(--aparte-duration-slower) var(--aparte-ease);
}

.aparte-sidebar[data-drawer][collapsed] {
  inset-inline-start: calc(-1 * var(--aparte-sidebar-width) - var(--aparte-space-4));
  box-shadow: none;
}

.aparte-sidebar__scrim {
  position: fixed;
  inset: 0;
  z-index: -1;
  background: var(--aparte-scrim);
}
/*
 * aparté — the header row of an application shell.
 *
 * One of the sheets src/index.ts imports in cascade order; check:derived-vars reads
 * them concatenated in that same order, the way a browser does.
 */

/*
 * aparte-app-header — the bar above the chat.
 * A recipe and nothing more, because a header has no behaviour of its own: a toggle
 * for the sidebar (which `<aparte-sidebar>` wires from the `data-aparte-sidebar-toggle`
 * attribute), the conversation's title, and an actions zone pushed to the end — a
 * model selector, a share button, whatever the page needs. The toggle shows only
 * under 48rem, where the sidebar is a drawer; above it the column is simply there.
 *
 *     <header class="aparte-app-header">
 *       <button class="aparte-btn aparte-btn--icon aparte-app-header__toggle" type="button" aria-label="Toggle the sidebar" data-aparte-sidebar-toggle><aparte-icon name="menu"></aparte-icon></button>
 *       <span class="aparte-app-header__title">Deploy checklist</span>
 *       <div class="aparte-app-header__actions">
 *         <span class="aparte-tag">gpt-4.1</span>
 *         <button class="aparte-btn aparte-btn--sm aparte-btn--ghost" type="button">Share</button>
 *       </div>
 *     </header>
 */
.aparte-app-header {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: var(--aparte-space-3);
  min-height: var(--aparte-app-header-height);
  padding: var(--aparte-space-2) var(--aparte-space-4);
  background: var(--aparte-surface-1);
  border-block-end: var(--aparte-border-width) solid var(--aparte-border);
  color: var(--aparte-text);
}

.aparte-app-header__title {
  flex: 1;
  min-width: 0;
  font-weight: var(--aparte-font-weight-medium);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.aparte-app-header__actions {
  display: flex;
  align-items: center;
  gap: var(--aparte-space-2);
  margin-inline-start: auto;
}

.aparte-app-header__toggle {
  display: none;
}

/* The same 48rem the sidebar turns into a drawer at (`sidebar.css`). */
@media (max-width: 48rem) {
  .aparte-app-header__toggle {
    display: inline-flex;
  }
}
/*
 * aparté — the grid that holds a sidebar, a header and the chat.
 *
 * One of the sheets src/index.ts imports in cascade order; check:derived-vars reads
 * them concatenated in that same order, the way a browser does.
 */

/*
 * aparte-app-shell — sidebar beside, header above, the chat in the rest.
 * A recipe: two columns and two rows, the sidebar spanning both rows, the header and
 * the main area stacked in the second column. The chat fills `__main`, which is what
 * gives it the height it needs to scroll (see the layout guide). Size the shell —
 * `height: 100dvh` for a page — and everything inside follows. Under 48rem the sidebar
 * leaves the grid to become a drawer, and the header's toggle appears.
 *
 *     <div class="aparte-app-shell" style="height: 24rem">
 *       <aside class="aparte-sidebar">
 *         <div class="aparte-sidebar__header">
 *           <span class="aparte-sidebar__brand">aparté</span>
 *           <button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="New chat"><aparte-icon name="edit"></aparte-icon></button>
 *         </div>
 *         <div class="aparte-sidebar__search aparte-field-group">
 *           <input class="aparte-field aparte-field--sm" type="search" placeholder="Search">
 *         </div>
 *         <div class="aparte-sidebar__body">
 *           <div class="aparte-conv-group" role="group" aria-label="Today">
 *             <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">Today</div>
 *             <div class="aparte-menu__item aparte-conv-item aparte-conv-item--active"><button class="aparte-conv-item__select" type="button"><span class="aparte-conv-item__title">Deploy checklist</span></button></div>
 *             <div class="aparte-menu__item aparte-conv-item"><button class="aparte-conv-item__select" type="button"><span class="aparte-conv-item__title">Rename the staging bucket</span></button></div>
 *           </div>
 *           <div class="aparte-conv-group" role="group" aria-label="Yesterday">
 *             <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">Yesterday</div>
 *             <div class="aparte-menu__item aparte-conv-item"><button class="aparte-conv-item__select" type="button"><span class="aparte-conv-item__title">Why is total a float?</span></button></div>
 *           </div>
 *         </div>
 *         <div class="aparte-sidebar__footer"><span class="aparte-avatar aparte-avatar--sm">P</span> Paul</div>
 *       </aside>
 *       <header class="aparte-app-header">
 *         <span class="aparte-app-header__title">Deploy checklist</span>
 *         <div class="aparte-app-header__actions"><span class="aparte-tag">gpt-4.1</span></div>
 *       </header>
 *       <main class="aparte-app-shell__main">
 *         <aparte-chat>
 *           <aparte-chat-viewport>
 *             <aparte-chat-bubble message-id="u1" data-role="user" content="Walk me through the deploy."></aparte-chat-bubble>
 *             <aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="Build, run the gate, tag, then push the tag — the workflow publishes from it."></aparte-chat-bubble>
 *           </aparte-chat-viewport>
 *           <aparte-composer>
 *             <div class="aparte-composer-shell">
 *               <div class="aparte-composer-row">
 *                 <aparte-composer-input></aparte-composer-input>
 *                 <aparte-composer-send></aparte-composer-send>
 *               </div>
 *             </div>
 *           </aparte-composer>
 *         </aparte-chat>
 *       </main>
 *     </div>
 */
.aparte-app-shell {
  box-sizing: border-box;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-rows: auto minmax(0, 1fr);
  height: 100%;
  overflow: hidden;
  background: var(--aparte-surface);
}

.aparte-app-shell > aparte-sidebar,
.aparte-app-shell > .aparte-sidebar {
  grid-row: 1 / -1;
}

/* A drawer is out of the flow, so the grid closes the column it left. */
.aparte-app-shell > .aparte-sidebar[data-drawer] {
  grid-row: auto;
}

.aparte-app-shell > .aparte-app-header {
  grid-column: 2;
}

.aparte-app-shell__main {
  grid-column: 2;
  min-height: 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

/* A chat — or a split holding one — fills the main area. Stated for the direct child
   because that is where the height comes from: `<aparte-chat>` is `height: 100%` of a
   parent that has none until this rule gives it one, and a split between the two is
   the same case one level out. A pane CONTAINS a chat; a chat never contains a split. */
.aparte-app-shell__main > aparte-chat,
.aparte-app-shell__main > [data-aparte-chat],
.aparte-app-shell__main > aparte-split,
.aparte-app-shell__main > .aparte-split {
  flex: 1;
  min-height: 0;
  height: auto;
}

/* A phone folds the shell to one column. The sidebar ELEMENT already leaves the grid
   under 48rem — it becomes a fixed drawer behind the header's toggle — but the grid
   kept its first column for it, and a class-only sidebar had no story at all: at 375px
   the shell gave 259 of its 303px to the sidebar and left the chat a 43px band with a
   send button cut in half. Same query as the element's own, so the recipe teaches the
   screen the element renders. */
@media (max-width: 48rem) {
  .aparte-app-shell {
    grid-template-columns: minmax(0, 1fr);
  }
  .aparte-app-shell > .aparte-app-header,
  .aparte-app-shell__main {
    grid-column: 1;
  }
  /* A sidebar that is not (yet) a drawer — the class recipe alone, or the element
     before it upgrades — folds out of the flow rather than eating the column. */
  .aparte-app-shell > .aparte-sidebar:not([data-drawer]),
  .aparte-app-shell > aparte-sidebar:not([data-drawer]) {
    display: none;
  }
}
/*
 * aparté — the seam: two panes side by side, and the grid that lets one grow while
 * the other gives way. The fourth piece of the shell, after the sidebar, the header
 * and the grid that holds them.
 *
 * One of the sheets src/index.ts imports in cascade order; check:derived-vars reads
 * them concatenated in that same order, the way a browser does.
 */

/*
 * aparte-split — two panes and a draggable seam. The chat in one, your pane in the
 * other: a preview frame, an editor, an artifact card.
 *
 * The grid is the whole mechanism. `--aparte-split-position` is the primary pane's
 * size; `--aparte-split-min` and `--aparte-split-max` are clamp bounds, so the
 * browser does the clamping and nothing in JS parses a unit — px, %, rem and ch all
 * work. Used without <aparte-split> this is a static split: set the position from
 * your own media query and there is no drag, which is the Canvas shape.
 *
 * A pane CONTAINS a chat; a chat never contains a split.
 *
 *     <div class="aparte-split" style="height: 18rem">
 *       <div>the chat</div>
 *       <div class="aparte-split__handle"></div>
 *       <div class="aparte-split__pane">the pane</div>
 *     </div>
 *
 * The element wears the same class and adds the behaviour (drag, arrow keys, the APG
 * separator's ARIA, one pane at a time under a breakpoint); `<div class="aparte-split">`
 * is the look alone, which is what this page shows.
 */

/* The outer clamp is a guard, not decoration: a `--aparte-split-min` wider than the
   container would otherwise eat the free pane whole and push the handle off the end.
   Three tracks, always — the primary pane, the seam, the rest. */
aparte-split,
.aparte-split {
  box-sizing: border-box;
  display: grid;
  grid-template-columns:
    clamp(
      0px,
      clamp(var(--aparte-split-min), var(--aparte-split-position), var(--aparte-split-max)),
      calc(100% - var(--aparte-split-handle-size))
    )
    var(--aparte-split-handle-size)
    minmax(0, 1fr);
  height: 100%;
  min-width: 0;
  overflow: hidden;
}

/* A grid item's automatic minimum size is its content, so one long line in either
   pane refuses to shrink and the seam stops tracking the pointer.

   The tag is listed beside the class here and below for the reason the grid rule
   lists it: the element adds the class at connect, so between parse and upgrade a
   server-rendered split would be a grid whose items keep their content minimum. */
aparte-split > *,
.aparte-split > * {
  min-inline-size: 0;
  min-block-size: 0;
}

/* The outside-the-shell half of the direct-child trap: <aparte-chat> is `height: 100%`
   of a parent that has none unless the grid track gives it one. Inside the app shell
   the same fix lives on `.aparte-app-shell__main`; split.css is imported after
   app-shell.css, so a chat in a pane inside a shell reads this one.

   `box-sizing` with it, because the chat's own sheet gives it a bottom padding — the
   16px of breathing room under the composer that core owns on purpose. Left in the
   default content-box that padding lands OUTSIDE the 100%, so the chat's border box
   overflows its track by exactly the gap and `.aparte-split`'s `overflow: hidden`
   eats it: the composer sits flush on the seam's bottom edge and the gap is gone. */
aparte-split > aparte-chat,
aparte-split > [data-aparte-chat],
.aparte-split > aparte-chat,
.aparte-split > [data-aparte-chat] {
  box-sizing: border-box;
  height: 100%;
  min-height: 0;
}

/* The seam. `touch-action: none` so a drag on a touch screen resizes rather than
   scrolling the page — without it the browser claims the gesture at the first move
   and the pointer events stop arriving. */
.aparte-split__handle {
  position: relative;
  box-sizing: border-box;
  border: 0;
  padding: 0;
  /* A transparent TRACK: the seam is the 1px line painted by `::before`, centred in it,
     and the grip is a short bar drawn on the track under the pointer. The track used
     to be the seam — 4px of paint, four times the kit's rule, with nothing to hold. */
  background: transparent;
  background-repeat: no-repeat;
  background-position: center;
  cursor: col-resize;
  touch-action: none;
}

.aparte-split__handle::before {
  content: '';
  position: absolute;
  inset-block: 0;
  inset-inline-start: calc(50% - var(--aparte-split-seam-width) / 2);
  inline-size: var(--aparte-split-seam-width);
  background: var(--aparte-border);
  transition: background-color var(--aparte-duration-fast) var(--aparte-ease);
}

.aparte-split__handle:hover::before,
.aparte-split__handle[data-dragging]::before {
  background: var(--aparte-primary);
}

/* The grip: the convention of every editor's split, so the seam reads as something to
   take hold of. Drawn as a background so it needs no third element. */
.aparte-split__handle:hover,
.aparte-split__handle[data-dragging] {
  background-image: linear-gradient(var(--aparte-primary), var(--aparte-primary));
  background-size: calc(var(--aparte-split-seam-width) * 3) var(--aparte-space-8);
}

/* The one ring. The seam's whole keyboard story is arrowing it, and its only focus
   indicator used to be a soft wash at 1.39:1 against the page — not weak, absent, on a
   4px band with `border: 0` that a keyboard user has no other way to find. The outline
   carries the contrast (3.54:1 light, 7.36:1 dark, measured), and it is the same outline
   every other control draws. */
.aparte-split__handle:focus-visible {
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
  outline-offset: var(--aparte-focus-outline-offset);
}

/* The hit slop: a 4px seam is a 4px target, and a pointer misses it. The pseudo-element
   is wider than the seam and centred on it, so the grab zone reaches into both panes
   without the seam itself getting bigger. */
.aparte-split__handle::after {
  content: '';
  position: absolute;
  inset-block: 0;
  inset-inline-start: calc(var(--aparte-split-hit-area) / -2 + var(--aparte-split-handle-size) / 2);
  inline-size: var(--aparte-split-hit-area);
}

/* The drag overlay, a child of the HANDLE for the duration of one gesture. Being the
   topmost hit target it swallows an iframe pane's hit-testing (a drag over a frame is
   otherwise lost to the frame's document), stops a text selection from starting, and
   paints the drag cursor across the whole page. A child of the handle rather than of
   the body so `click` — and therefore `dblclick` — still dispatches at the handle,
   their nearest common ancestor.

   It is `position: fixed`, so nothing between it and the viewport may establish a
   containing block: `.aparte-split` must never get `container-type`, `transform`,
   `filter` or `backdrop-filter`, or the overlay covers the split alone. */
.aparte-split__scrim {
  position: fixed;
  inset: 0;
  z-index: var(--aparte-z-dropdown);
  cursor: col-resize;
}

/* An optional wrapper for the pane that is not a chat — the pane equivalent of
   `.aparte-sidebar__body`. The chat pane does not use it: the viewport scrolls itself. */
.aparte-split__pane {
  box-sizing: border-box;
  min-inline-size: 0;
  min-block-size: 0;
  overflow: auto;
  background: var(--aparte-surface-1);
}

/* `primary="end"` sizes the LAST pane instead of the first: the same three tracks,
   read the other way round. */
aparte-split[primary='end'],
.aparte-split--primary-end {
  grid-template-columns:
    minmax(0, 1fr)
    var(--aparte-split-handle-size)
    clamp(
      0px,
      clamp(var(--aparte-split-min), var(--aparte-split-position), var(--aparte-split-max)),
      calc(100% - var(--aparte-split-handle-size))
    );
}

/* The other axis. The attribute and the class are selected in the same rule so the
   static recipe and the live element can never draw two different things. */
aparte-split[orientation='vertical'],
.aparte-split--vertical {
  grid-template-columns: none;
  grid-template-rows:
    clamp(
      0px,
      clamp(var(--aparte-split-min), var(--aparte-split-position), var(--aparte-split-max)),
      calc(100% - var(--aparte-split-handle-size))
    )
    var(--aparte-split-handle-size)
    minmax(0, 1fr);
}

/* The one rule in this sheet that out-specifies the stacked block below — two
   attributes against its one — so it has to opt out of the stacked state by hand or a
   vertical `primary="end"` split keeps three rows while showing one pane, and the two
   empty tracks take the height off the pane that is left. */
aparte-split[orientation='vertical'][primary='end']:not([data-stacked]),
.aparte-split--vertical.aparte-split--primary-end:not(.aparte-split--only-start):not(.aparte-split--only-end) {
  grid-template-rows:
    minmax(0, 1fr)
    var(--aparte-split-handle-size)
    clamp(
      0px,
      clamp(var(--aparte-split-min), var(--aparte-split-position), var(--aparte-split-max)),
      calc(100% - var(--aparte-split-handle-size))
    );
}

aparte-split[orientation='vertical'] > .aparte-split__handle,
.aparte-split--vertical > .aparte-split__handle {
  cursor: row-resize;
  background-size: var(--aparte-space-8) calc(var(--aparte-split-seam-width) * 3);
}
aparte-split[orientation='vertical'] > .aparte-split__handle::before,
.aparte-split--vertical > .aparte-split__handle::before {
  inset-block: calc(50% - var(--aparte-split-seam-width) / 2) auto;
  inset-inline: 0;
  inline-size: auto;
  block-size: var(--aparte-split-seam-width);
}

aparte-split[orientation='vertical'] > .aparte-split__handle::after,
.aparte-split--vertical > .aparte-split__handle::after {
  inset-block: calc(var(--aparte-split-hit-area) / -2 + var(--aparte-split-handle-size) / 2) auto;
  inset-inline: 0;
  inline-size: auto;
  block-size: var(--aparte-split-hit-area);
}

aparte-split[orientation='vertical'] .aparte-split__scrim,
.aparte-split--vertical .aparte-split__scrim {
  cursor: row-resize;
}

/* One pane at a time. `data-stacked` is written by the element from its breakpoint;
   the two modifier classes are the byte-identical CSS route for a host that owns its
   own breakpoint. The shown pane spans all three tracks and the seam goes with the
   pane it no longer separates — including its tab stop, which the element removes.

   Not "position: 100% and a zero-size handle": that leaves a focusable separator of
   no width sitting in the tab order. */
aparte-split[data-stacked],
aparte-split[single],
.aparte-split--only-start,
.aparte-split--only-end {
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr);
}

aparte-split[data-stacked] > .aparte-split__handle,
aparte-split[single] > .aparte-split__handle,
.aparte-split--only-start > .aparte-split__handle,
.aparte-split--only-end > .aparte-split__handle {
  display: none;
}

/* `single` is the host's word for the same state (#54): one pane, whatever the width,
   the one `pane` names. Same rules as the breakpoint's, so the element reads both the
   same way. */
aparte-split[data-stacked]:not([pane='end']) > :nth-child(3),
aparte-split[single]:not([pane='end']) > :nth-child(3),
.aparte-split--only-start > :nth-child(3) {
  display: none;
}

aparte-split[data-stacked][pane='end'] > :nth-child(1),
aparte-split[single][pane='end'] > :nth-child(1),
.aparte-split--only-end > :nth-child(1) {
  display: none;
}

/* `disabled` is inert, so it must not look grabbable: the element takes the tab stop
   away and swallows the pointer, and a resize cursor plus a hover highlight on a seam
   that answers nothing is exactly the half-rendered affordance the house rule forbids.
   The seam itself stays drawn — it still separates two panes.

   Last in the sheet because it must out-rank the vertical `cursor: row-resize` above,
   which it ties with on specificity. Attribute only: the static recipe has no
   behaviour, so there is no live-versus-static pair to keep in step. */
aparte-split[disabled] > .aparte-split__handle {
  cursor: default;
}

aparte-split[disabled] > .aparte-split__handle:hover {
  background: var(--aparte-border);
}
/*
 * aparté — Reset-agnostic typography for model-authored markdown.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 */
.aparte-content {
  font-size: var(--aparte-content-font-size);
  line-height: var(--aparte-content-line-height);
  color: var(--aparte-content-color);
  font-family: var(--aparte-font-family);
  white-space: normal;
  word-wrap: break-word;
}

/* =========================================
   Prose — reset-agnostic typography
   Compact defaults for chat context.
   Customizable via --aparte-prose-* variables.
   ========================================= */

.aparte-content,
.aparte-segment-content {
  white-space: normal;
}

.aparte-content p,
.aparte-segment-content p,
.aparte-thinking-content p {
  margin: var(--aparte-prose-p-margin);
}

.aparte-content p:first-child,
.aparte-segment-content p:first-child,
.aparte-thinking-content p:first-child {
  margin-top: 0;
}

.aparte-content p:last-child,
.aparte-segment-content p:last-child,
.aparte-thinking-content p:last-child {
  margin-bottom: 0;
}

.aparte-content h1, .aparte-segment-content h1, .aparte-thinking-content h1 { font-size: var(--aparte-prose-h1-size); font-weight: var(--aparte-font-weight-semibold); margin: var(--aparte-prose-h1-margin); }
.aparte-content h2, .aparte-segment-content h2, .aparte-thinking-content h2 { font-size: var(--aparte-prose-h2-size); font-weight: var(--aparte-font-weight-semibold); margin: var(--aparte-prose-h2-margin); }
.aparte-content h3, .aparte-segment-content h3, .aparte-thinking-content h3 { font-size: var(--aparte-prose-h3-size); font-weight: var(--aparte-font-weight-semibold); margin: var(--aparte-prose-h3-margin); }
.aparte-content h4, .aparte-segment-content h4, .aparte-thinking-content h4,
.aparte-content h5, .aparte-segment-content h5, .aparte-thinking-content h5,
.aparte-content h6, .aparte-segment-content h6, .aparte-thinking-content h6 { font-size: 1em; font-weight: var(--aparte-font-weight-semibold); margin: var(--aparte-prose-h4-margin); }

.aparte-content ul,
.aparte-segment-content ul,
.aparte-thinking-content ul {
  list-style: disc;
  padding-left: var(--aparte-prose-list-indent);
  margin: var(--aparte-prose-list-margin);
}

.aparte-content ol,
.aparte-segment-content ol,
.aparte-thinking-content ol {
  list-style: decimal;
  padding-left: var(--aparte-prose-list-indent);
  margin: var(--aparte-prose-list-margin);
}

.aparte-content li,
.aparte-segment-content li,
.aparte-thinking-content li {
  margin: var(--aparte-prose-li-margin);
}

.aparte-content blockquote,
.aparte-segment-content blockquote,
.aparte-thinking-content blockquote {
  border-left: var(--aparte-prose-blockquote-border-width) solid var(--aparte-prose-blockquote-border);
  padding-left: var(--aparte-prose-blockquote-indent);
  margin: var(--aparte-prose-blockquote-margin);
  color: var(--aparte-text-muted);
  font-style: italic;
}

.aparte-content strong,
.aparte-segment-content strong,
.aparte-thinking-content strong {
  font-weight: var(--aparte-font-weight-semibold);
}

.aparte-content em,
.aparte-segment-content em,
.aparte-thinking-content em {
  font-style: italic;
}

.aparte-content code,
.aparte-segment-content code,
.aparte-thinking-content code {
  font-family: var(--aparte-code-font-family);
  font-size: var(--aparte-code-inline-font-size);
  background: var(--aparte-prose-code-bg);
  padding: var(--aparte-prose-code-padding);
  border-radius: var(--aparte-radius-xs);
}

.aparte-content a,
.aparte-segment-content a,
.aparte-thinking-content a {
  color: var(--aparte-primary-ink);
  text-decoration: underline;
}

.aparte-content hr,
.aparte-segment-content hr,
.aparte-thinking-content hr {
  border: none;
  border-top: var(--aparte-border-width) solid var(--aparte-border);
  margin: var(--aparte-prose-hr-margin);
}

/* Tables. The sanitizer allowlisted table/thead/tbody/tr/th/td from the start and no
   sheet ever drew them, so a GFM table — the second most common structure in an
   assistant reply, and a suggestion chip in our own example — rendered as words with
   no borders, no padding and columns that touched. `display: block` + `overflow-x` is
   the one honest way to keep a wide table inside a narrow bubble without a wrapper
   the markdown provider cannot emit. */
.aparte-content table,
.aparte-segment-content table,
.aparte-thinking-content table {
  display: block;
  max-width: 100%;
  overflow-x: auto;
  border-collapse: collapse;
  margin: var(--aparte-space-4) 0;
  font-size: var(--aparte-font-size-md);
}

.aparte-content th,
.aparte-segment-content th,
.aparte-thinking-content th,
.aparte-content td,
.aparte-segment-content td,
.aparte-thinking-content td {
  border: var(--aparte-border-width) solid var(--aparte-border);
  padding: var(--aparte-space-2) var(--aparte-space-4);
  text-align: start;
  vertical-align: top;
}

.aparte-content th,
.aparte-segment-content th,
.aparte-thinking-content th {
  font-weight: var(--aparte-font-weight-semibold);
  background: var(--aparte-surface-2);
}

.aparte-thinking-content {
  padding: var(--aparte-space-2) 0 var(--aparte-space-1) var(--aparte-space-5);
  font-size: var(--aparte-font-size-md);
  line-height: var(--aparte-thinking-content-line-height);
  color: var(--aparte-thinking-content);
  background: transparent;
  max-height: var(--aparte-thinking-content-max-height);
  overflow-y: auto;
}

/* A fenced block that came through MARKDOWN, not through the `code` segment.
   `@aparte/plugin-marked` renders ``` as a bare <pre><code>, and the recipe below is
   scoped to `.aparte-code-content-wrapper` — a class only the code SEGMENT renderer
   emits, which marked cannot produce. So a markdown block matched no `pre` rule at all
   and kept the UA's `white-space: pre`: it never wrapped, laid out at its own intrinsic
   width, and `aparte-chat-bubble`'s `overflow: hidden` amputated the tail. No scrollbar,
   no ellipsis — the code was simply gone. Measured on the vanilla example: one block's
   `scrollWidth` was a CONSTANT 963px at chat widths 1500, 800, 600, 512 and 380, against
   client widths 776 / 724 / 524 / 460 / 328. It overflowed even at 1500.

   Only the two wrapping declarations are shared, not the surface: the padding and the
   background below are the code CARD's, and a markdown block has no card. Giving it one
   is a look decision, not this fix. */
.aparte-content pre,
.aparte-segment-content pre,
.aparte-thinking-content pre {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.aparte-code-content-wrapper pre {
  margin: 0;
  padding: var(--aparte-space-6);
  /* The code typeface, at a code size. This rule declared neither and the block fell
     into the UA's generic `monospace` (Courier New on Windows) at the PROSE's size and
     leading; only inline code read the family. On the wrapper's `pre`, whatever class
     the highlighter puts on its replacement. */
  font-family: var(--aparte-code-font-family);
  font-size: var(--aparte-code-block-font-size);
  line-height: var(--aparte-code-block-line-height);
  /* Wrap long lines instead of a horizontal scrollbar. `pre-wrap` honours
     newlines + indentation but soft-wraps overflowing lines; `overflow-wrap:
     anywhere` breaks unbreakable tokens (long URLs, hashes). Applies to both
     the plain <pre><code> initial render and the shiki <pre class="shiki">
     replacement, since both sit inside .aparte-code-content-wrapper. */
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  overflow-x: hidden;
  background: var(--aparte-code-bg);
}
/*
 * aparté — Container-width and pointer-modality adjustments. LAST on purpose.
 *
 * One of the sheets split out of aparte.css. Order matters: src/index.ts imports
 * them in the order listed there, and check:derived-vars reads them concatenated in
 * that same order, the way a browser does.
 */
@media (prefers-reduced-motion: reduce) {
  /* Our own motion stops at the source: every transition and animation in this
     sheet reads one of these, so overriding them reaches the elements the
     descendant sweep below cannot match — which is why two hand-written patches
     used to sit further down for exactly two of them. The sweep stays: it also
     stops motion in CSS this sheet does not own. */
  :root,
  :host {
    --aparte-duration-fast: 0.01ms;
    --aparte-duration-base: 0.01ms;
    --aparte-duration-slow: 0.01ms;
    --aparte-duration-slower: 0.01ms;
    --aparte-duration-pulse: 0.01ms;
    --aparte-duration-spin: 0.01ms;
    --aparte-slide-distance: 0px;
  }
}

/* Windows high contrast. In forced-colors mode the UA repaints colours and drops
   box-shadow entirely. Every focus indicator is an outline now, which forced colours
   paint on their own — the one indicator drawn with a box-shadow is the error ring on
   an avatar, restated here as an outline so it does not vanish. */
@media (forced-colors: active) {
  .aparte-message[data-error] .aparte-avatar {
    outline: var(--aparte-focus-outline-width) solid CanvasText;
  }
}

/* Respect prefers-reduced-motion. Scoped to Aparte's own custom elements (never
   the consumer's page) — this covers every animated element the library renders
   inside them: typing dots, spinners, artifact pulses, segment
   animations. Activity stays conveyed via aria-live / aria-busy. */
@media (prefers-reduced-motion: reduce) {
  aparte-chat-viewport *,
  aparte-chat-bubble *,
  aparte-composer *,
  aparte-chat-status *,
  aparte-conversation-list *,
  aparte-select *,
  aparte-progress-spinner * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}



/* =========================================
   Adaptive layout (container width + input modality)
   ========================================= */

/* Narrow containers (embedded widgets, mobile) — tighter chrome. The theming
   variables are defined at :root, so `var(x, fallback)` fallbacks can never
   fire — instead each rule REASSIGNS the variable at the element, the same
   per-context pattern .aparte-attachments already uses. To retheme the narrow
   state, override these variables inside your own @container block. Browsers
   without container-query support keep the wide defaults. */
@container (max-width: 520px) {
  .aparte-message {
    --aparte-message-padding-block: var(--aparte-space-6);
    --aparte-message-padding-inline: var(--aparte-space-4);
    --aparte-message-gap: var(--aparte-space-4);
  }

  .aparte-avatar {
    --aparte-avatar-size: 24px;
  }

  .aparte-messages-wrapper {
    --aparte-viewport-padding: var(--aparte-space-6) var(--aparte-space-4);
  }

  .aparte-composer-row {
    padding: var(--aparte-space-4);
  }
}

/* Coarse pointers (touch) — comfortable hit areas. */
@media (pointer: coarse) {
  .aparte-scroll-btn {
    --aparte-scroll-btn-size: var(--aparte-touch-target-size);
  }

  .aparte-send-button {
    --aparte-send-btn-size: var(--aparte-touch-target-size);
  }

  .aparte-action-button {
    --aparte-input-action-btn-size: var(--aparte-touch-target-size);
  }

  /* The bubble's action bar too: 28px buttons under a thumb missed as often as they
     hit (UI audit 2026-08-28, §8.3 #10). The bar's height follows the same token.
     And it is SHOWN: the bar is revealed by hover, and a finger cannot hover, so on a
     touch device every message's actions were sitting at opacity 0 with no way to
     reach them — a larger target for something invisible. The footer stays in the
     flow here (the floating rule in bubble.css is under `(hover: hover) and
     (pointer: fine)`, so a device that hovers with a coarse pointer — a TV remote —
     keeps it in the flow too), so what a touch device gets is the bar every mobile
     chat shows: under each message, always. */
  .aparte-action-bar {
    --aparte-action-bar-btn-size: var(--aparte-touch-target-size);
    opacity: 1;
  }

  /* Nor the ✕ on a pending attachment, which is the only way to remove one — and
     seeing it is no use at 18px, under the 24 of WCAG 2.2 SC 2.5.8. 24 is not a new
     number: it is the box `aparte-btn--sm` already draws, so this is the component
     giving the recipe back its own size on touch rather than out-specifying it.
     Not `--aparte-touch-target-size`: 44px inside a 72px tile would swallow the
     picture, and full parity with the neighbouring controls needs the tile to grow
     with it — a second decision, not this floor. */
  .aparte-thumb__remove {
    opacity: 1;
    --aparte-thumb-remove-size: var(--aparte-btn-size-sm);
  }

  /* Hover cannot reveal the row's ⋯ on touch — keep it visible and tappable. */
  .aparte-conv-item__more {
    opacity: 1;
    --aparte-conv-action-btn-size: var(--aparte-touch-target-size);
  }

  /* The composer's own controls: 36 at rest, the touch target here — the send button, the
     input's action button and the editor's single-line height move together. */
  .aparte-composer-row {
    --aparte-composer-control-size: var(--aparte-touch-target-size);
  }

  /* A 4px seam is no target for a finger — the same modality rule the buttons above
     follow. The seam keeps its width; only the invisible grab zone grows. */
  .aparte-split__handle {
    --aparte-split-hit-area: var(--aparte-touch-target-size);
  }

  /* A 2px tick is no target for a finger; the minimaps of the market hide on touch too. */
  aparte-scroll-rail {
    display: none;
  }
}

