/**
 * ml-core.css — MusicLinkd primitives.
 *
 * Requires ml-tokens.css (generated). Every value here is a token; there are
 * no literal colours, sizes or durations below except where a comment says why.
 *
 * Built to native standards, not web defaults:
 *   · every interactive element is >= var(--size-3) (48dp) in both axes
 *   · :active is the real feedback state; :hover is decoration and never the
 *     only signal, because a touch device has no persistent hover
 *   · focus-visible only, so a tap never leaves a ring behind
 *   · no tooltips, no right-click, no mouse-only affordance
 *
 * Each block carries a /* Compose: */ note naming its Android counterpart, so
 * this file doubles as the handoff spec.
 *
 * Palette rule: the chrome is monochrome, the media carries the colour.
 */

/* ── base ──────────────────────────────────────────────────────────────── */

*, *::before, *::after { box-sizing: border-box; }

/* [hidden] must win. The UA stylesheet's `[hidden] { display: none }` has the
   lowest possible specificity, so ANY author display rule beats it — and this
   system sets display on nearly every component (.ml-btn is inline-flex,
   .ml-otp is flex). Without this, app-shell.js's state switching sets the
   attribute correctly and every state still renders at once, which is exactly
   what happened on first run. */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--color-paper);
  color: var(--color-ink);
  font-family: var(--font-body);
  font-size: var(--type-3);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;   /* we draw our own pressed state */
}

/* The UA stylesheet's bold defaults must be reset, or the weight scale is a
   claim the browser ignores.

   On 2026-08-31 every font-weight DECLARATION in this system was moved onto
   --weight-*, and 700 was removed from the scale entirely. Grepping the
   stylesheets then showed zero 700s — and the rendered guide still computed
   font-weight 700 on 219 elements: 195 <b> and 24 bare <h3>. Nothing declared
   it. `b, strong { font-weight: bolder }` and `h1..h6 { font-weight: bold }`
   come from the user agent, and no author rule had ever overridden them.

   Only a live render catches this; it is invisible to any amount of reading
   the CSS. :where() keeps specificity at zero so every component rule still
   wins without needing to restate the weight. */
:where(b, strong) { font-weight: var(--weight-3); }
:where(h1, h2, h3, h4, h5, h6) { font-weight: var(--weight-3); }

/* Hit-area expander. Resolves a contradiction this system shipped with:
   the header above claims "every interactive element is >= var(--size-3)
   (48dp) in both axes", while meta.tokenMap in tokens.json sets chip height to
   --size-1 (32). Both cannot be true, and measuring the rendered catalogue on
   2026-08-31 found 49 controls under the floor — .ml-help at 20x20, four
   .ml-check-ctl at 28x28, nine .ml-mosaic-tile__pin and four .ml-mixer__mute
   at 32x32. Eleven were under even WCAG 2.2's looser 24x24 minimum.

   The fix is the one iOS and Material both use: the control keeps its VISUAL
   size and grows an invisible TARGET to 48dp. Growing the visual size instead
   would break the 32dp chip the token map deliberately specifies, and would
   change the look of every dense surface in the app.

   Applied via a pseudo-element so it costs no layout: the expander is
   position:absolute and centred, so it overhangs without pushing neighbours.
   The control must be positioned for it to anchor — hence the position rule.
   Pointer-events stay on the expander, not the parent, so overlapping targets
   in a dense row still resolve to the nearest control. */
.ml-hit { position: relative; }
.ml-hit::after {
  content: "";
  position: absolute;
  top: 50%; left: 50%;
  translate: -50% -50%;
  min-width: var(--size-3);
  min-height: var(--size-3);
  width: 100%; height: 100%;
  /* purely a target — never draws, never intercepts hover styling */
  background: none;
}

/* One focus treatment for the whole system. Pink, because a black ring on
   black text is invisible — the same reasoning tokens.json records. */
:where(a, button, input, textarea, select, [tabindex]):focus-visible {
  outline: 2px solid var(--color-pink);
  outline-offset: 2px;
}

/* Honour the OS setting. Android exposes this as ANIMATOR_DURATION_SCALE and
   Compose reads it the same way — a pattern with no reduced-motion path is
   not shippable. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    scroll-behavior: auto !important;
  }
}

/* ── state transitions ────────────────────────────────────────────────── */
/* Motion plan Phase 1 (docs/09-Roadmap/motion-plan.md). app-shell.js's state
   switch is `el.hidden = !states.includes(state)` — 518 of these across 184
   screens, every one an instant blink. This rule animates the discrete
   [hidden] change without touching app-shell.js (a concurrent session owns
   that file, and the Cancellable rule says state correctness must not depend
   on an animation event — `hidden` stays the source of truth throughout).

   Known asymmetry, tested live rather than assumed from the CSS: ENTER
   (hidden removed) animates correctly — @starting-style plus the display
   allow-discrete transition is exactly what it's for, and nothing else
   overrides display while [hidden] is absent. EXIT (hidden added) does NOT
   fade/slide out — [hidden]{display:none!important} above (line ~30) forces
   display:none in the same style recalculation the attribute is added in,
   before any transition can run, since a display:none box generates no
   transitionable frame. That !important is load-bearing for a real prior bug
   (components' own `display` rules beating [hidden]'s near-zero UA
   specificity) and is not being touched here — exit stays an instant cut
   until that's solved separately. Shipping the half that verifiably works
   rather than a rule that looks complete but silently does nothing on exit. */
[data-state] {
  transition:
    opacity var(--duration-2) var(--ease-standard),
    transform var(--duration-2) var(--ease-standard),
    overlay var(--duration-2) allow-discrete,
    display var(--duration-2) allow-discrete;
}
@starting-style {
  [data-state]:not([hidden]) { opacity: 0; transform: translateY(var(--space-2)); }
}

/* ── screen transitions ───────────────────────────────────────────────── */
/* Motion plan Phase 4 — base tier only (docs/09-Roadmap/motion-plan.md has
   the full note on what's deliberately not done here yet: directional
   forward/back detection and shared-element card->detail artwork
   continuity, which need a small new JS file plus naming ~184 screens in
   matched pairs — scoped as a separate follow-up, not folded into this
   pass, per the same Excessive-Motion discipline as every other phase).

   This is the CROSS-DOCUMENT View Transition API: /preview screens are
   separate static HTML files loaded via full <a href> navigation, not a
   single-page app, so this is the zero-JS variant (`@view-transition`),
   not `document.startViewTransition()`. CSS's forward-compatible parsing
   means a browser that doesn't recognise this at-rule just ignores it —
   no @supports guard needed, and navigation falls back to its normal
   instant swap, never a broken or half-applied state. */
@view-transition {
  navigation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--duration-3);
  animation-timing-function: var(--ease-standard);
}

/* ── button ────────────────────────────────────────────────────────────── */
/* Compose: Button / FilledTonalButton / OutlinedButton, MLSize.s3 minHeight  */

.ml-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--size-3);          /* 48dp — the accessibility floor */
  padding: 0 var(--space-4);
  border: var(--border-1) solid transparent;
  border-radius: var(--radius-4);
  font-family: var(--font-body);
  font-size: var(--type-3);
  font-weight: var(--weight-3);
  line-height: 1;
  text-decoration: none;
  cursor: pointer;
  transition: background-color var(--duration-1) var(--ease-standard),
              border-color var(--duration-1) var(--ease-standard),
              color var(--duration-1) var(--ease-standard),
              transform var(--duration-1) var(--ease-standard);
}
/* Scale-on-press rather than a colour flash: it reads as physical, survives
   on any background, and is what Compose's tapScaleAnimation does. */
.ml-btn:active { transform: scale(0.97); }
.ml-btn:disabled,
.ml-btn[aria-disabled="true"] {
  opacity: var(--opacity-3);
  pointer-events: none;
}

/* PRIMARY IS BLACK. Pink is the accent and is never the default CTA —
   corrected 2026-08-16 by the brand owner, re-confirmed 2026-08-29. */
.ml-btn--primary  { background: var(--color-ink); color: var(--color-white); }
.ml-btn--primary:active { background: var(--color-ink-pressed); }

.ml-btn--secondary { background: var(--color-white); color: var(--color-ink); border-color: var(--color-line); }
.ml-btn--secondary:active { background: var(--color-surface); }

/* Reserved for high-intent commerce moments, not general actions. */
/* RULE, set by the brand owner 2026-08-30: when a button is pink, the label
   is WHITE. Always. It used to be ink-on-pink, which passed contrast but made
   the accent button read as a third neutral rather than as the brand.

   Honouring that costs one change of pink. White on --color-pink #EA4C89 is
   3.56:1 and fails AA at button-label size; white on --color-pink-pressed
   #C7295F is 5.37:1 and passes. So the accent BUTTON fills with the pressed
   pink and presses to a deeper mix. --color-pink itself is unchanged and
   still fills chips, marks and waveforms where nothing is reversed out of it.
   The rule is now "pink button = white label" with no exception to remember. */
.ml-btn--accent  { background: var(--color-pink-pressed); color: var(--color-white); }
.ml-btn--accent:active { background: color-mix(in srgb, var(--color-pink-pressed) 84%, black); }

.ml-btn--ghost   { background: transparent; color: var(--color-ink); }
.ml-btn--ghost:active { background: var(--color-surface); }

.ml-btn--block   { display: flex; width: 100%; }
.ml-btn--lg      { min-height: var(--size-4); font-size: var(--type-3); }
/* --sm is NARROWER, never SHORTER. Two call sites (the IME bar's Send and the
   inline editor's Save/Cancel) had been asking for .ml-btn--sm since before it
   existed — the class was never defined, so both rendered at full width and
   the guide's own class check found it on 2026-08-31. The obvious reading of
   "small" would drop min-height below --size-3, which is the 48dp
   accessibility floor this system states two rules above; a button in a
   cramped bar needs less horizontal room, not a smaller tap target. */
.ml-btn--sm      { padding: 0 var(--space-3); }

/* ── icon button ───────────────────────────────────────────────────────── */
/* Compose: IconButton — note the 48dp box around a 20dp glyph               */

.ml-icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* The box is the touch target; the icon inside is visually smaller. This is
     the single most common native a11y failure and the reason the old
     .icon-btn (44px) failed the audit. */
  min-width: var(--size-3);
  min-height: var(--size-3);
  padding: 0;
  border: var(--border-1) solid var(--color-line);
  border-radius: var(--radius-4);
  background: var(--color-white);
  color: var(--color-ink);            /* was inheriting link blue — a real bug */
  cursor: pointer;
  transition: background-color var(--duration-1) var(--ease-standard),
              border-color var(--duration-1) var(--ease-standard),
              color var(--duration-1) var(--ease-standard),
              transform var(--duration-1) var(--ease-standard);
}
.ml-icon-btn:active { transform: scale(0.94); background: var(--color-surface); }
.ml-icon-btn--bare  { border-color: transparent; background: transparent; }
/* Filled variants. Communication and opportunities had grown these locally as
   .icon-btn.accept / .icon-btn.decline at 32-36px — below the 48dp minimum,
   which is the exact failure .ml-icon-btn was built to end. Promoting them
   here rather than repeating the fix per screen. */
.ml-icon-btn--primary { background: var(--color-ink); border-color: var(--color-ink); color: var(--color-white); }
.ml-icon-btn--primary:active { background: var(--color-ink-pressed); }
.ml-icon-btn--muted   { background: var(--color-surface); border-color: transparent; color: var(--color-muted); }
.ml-icon-btn svg    { width: var(--icon-2); height: var(--icon-2); display: block; }

/* ── field ─────────────────────────────────────────────────────────────── */
/* Compose: OutlinedTextField                                                */

.ml-field { display: flex; flex-direction: column; gap: var(--space-1); text-align: left; }
.ml-field > label {
  font-size: var(--type-1);
  font-weight: var(--weight-3);
  color: var(--color-muted);
}
.ml-field input,
.ml-field textarea,
.ml-field select {
  width: 100%;
  min-height: var(--size-3);
  padding: 0 var(--space-3);
  border: var(--border-1) solid var(--color-line);
  border-radius: var(--radius-4);
  background: var(--color-white);
  color: var(--color-ink);
  font-family: var(--font-body);
  font-size: var(--type-3);
  transition: border-color var(--duration-1) var(--ease-standard);
}
.ml-field textarea { padding: var(--space-2) var(--space-3); min-height: calc(var(--size-4) * 2); border-radius: var(--radius-2); resize: vertical; }
.ml-field input::placeholder, .ml-field textarea::placeholder { color: var(--color-muted); }
.ml-field input:focus, .ml-field textarea:focus { border-color: var(--color-pink); outline: none; }
.ml-field .ml-field__error { font-size: var(--type-1); color: var(--color-error); }
.ml-field.is-invalid input,
.ml-field.is-invalid textarea { border-color: var(--color-error); }

/* ── search ────────────────────────────────────────────────────────────── */
/* Pill, leading glyph, optional trailing voice affordance.                  */
/* Compose: SearchBar                                                        */

.ml-search {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--size-3);
  padding: 0 var(--space-3);
  border: var(--border-1) solid var(--color-line);
  border-radius: var(--radius-4);
  background: var(--color-surface);
}
.ml-search__icon { flex: none; width: var(--icon-2); height: var(--icon-2); color: var(--color-muted); }
.ml-search input {
  flex: 1;
  min-width: 0;
  height: var(--size-3);
  border: 0;
  background: none;
  color: var(--color-ink);
  font-family: var(--font-body);
  font-size: var(--type-3);
}
.ml-search input:focus { outline: none; }
.ml-search__divider { width: 1px; align-self: stretch; margin: var(--space-2) 0; background: var(--color-line); }

/* ── chip ──────────────────────────────────────────────────────────────── */
/* A chip is a CONTROL (filter, selection). For a read-only state label use
   .ml-status below — conflating the two is why screens grew both a .chip and
   a .status-pill that looked alike but behaved differently. */
/* Compose: FilterChip                                                       */

.ml-chip {
  display: inline-flex;
  align-items: center;
  /* Icon-to-text balance, corrected 2026-08-30. Was gap --space-1 (4px) with
     a flat --space-3 (16px) padding on both sides, which reads unbalanced two
     ways: 4px is too tight for a 16px glyph beside 12px text, and a leading
     icon then sits 16px from the edge while the text it labels sits 36px in,
     so the chip looks left-heavy. Now 8px between glyph and label, and the
     padding is pulled in on whichever side carries an icon (see the
     :has() rules below) so optical weight is even. */
  gap: var(--space-2);
  min-height: var(--size-1);          /* 32dp visual… */
  padding: 0 var(--space-3);
  border: var(--border-1) solid var(--color-line);
  border-radius: var(--radius-4);
  background: var(--color-white);
  color: var(--color-muted);
  font-size: var(--type-1);
  font-weight: var(--weight-3);
  white-space: nowrap;
  cursor: pointer;
  /* A chip may be an <a> — state-selector chips are links so they work with
     no JavaScript and can be deep-linked. Without this they inherit the
     underline from .ml-* link styling and every filter row reads as body
     copy. */
  text-decoration: none;
  /* REQUIRED: the ::after tap band below is position:absolute, so without a
     positioned ancestor here it resolves against a far-up container and the
     band lands nowhere near the chip. */
  position: relative;
  /* colour and border-color ride WITH background-color. Transitioning the
     background alone means a control that changes both snaps its label to the
     new colour instantly while the new background eases in over 125ms — on
     .ml-chip that made a newly selected chip white-on-white, an invisible
     label, for the whole transition (seen on marketplace_browse, where the
     "All" chip rendered as an empty pill). Same shape on .ml-btn/.ml-icon-btn
     variants that swap fill and text together. */
  transition: background-color var(--duration-1) var(--ease-standard),
              border-color var(--duration-1) var(--ease-standard),
              color var(--duration-1) var(--ease-standard),
              transform var(--duration-1) var(--ease-standard);
}
/* …with the tap target expanded to 48dp without changing the visual size.
   A pseudo-element hit-tests as part of its parent, so a tap 8dp above or
   below the 32dp chip still activates it — verified with elementFromPoint,
   not assumed. This is why a chip can look small and still be reachable. */
.ml-chip::after {
  content: "";
  position: absolute;
  inset: 50% 0 auto;
  height: var(--size-3);
  transform: translateY(-50%);
}
.ml-chip:active {
  transform: scale(0.96);
  /* Accent on press. The chip is a FILTER — pressing it is an intent, not yet
     a commitment, so the press reads pink and the committed state reads ink.
     Two different colours for two different meanings. */
  border-color: var(--color-pink);
  color: var(--color-pink-pressed);
  background: var(--color-pink-container);
}
/* A chip may carry a leading icon or a trailing dismiss. The icon inherits
   currentColor so it follows the chip through rest / press / selected without
   a second rule per state. */
.ml-chip svg { width: var(--icon-1); height: var(--icon-1); flex: none; }
/* A glyph is optically lighter than a run of text, so the side carrying one
   takes less padding — 8px against the text side's 16px. :has() does this
   without asking the author to add a modifier class they will forget. */
.ml-chip:has(> svg:first-child)          { padding-left: var(--space-2); }
.ml-chip:has(> .ml-chip__icon)           { padding-left: var(--space-2); }
.ml-chip:has(> .ml-chip__dismiss)        { padding-right: var(--space-2); }
.ml-chip__icon { display: inline-flex; }
.ml-chip__dismiss {
  display: inline-flex;
  border: 0; padding: 0; background: none; color: inherit; cursor: pointer;
  opacity: 0.6;
}
.ml-chip__dismiss:hover { opacity: 1; }
.ml-chip[aria-pressed="true"],
.ml-chip.is-selected {
  background: var(--color-ink);
  border-color: var(--color-ink);
  color: var(--color-white);
}
/* A chip carrying data-chip="<state> [<state>…]" is a STATE SELECTOR: the
   shared shell sets its aria-pressed from the current state, so the rule
   above styles it with nothing screen-specific. Screens used to each ship a
   click handler that rewrote inline background/border/colour instead — and
   marketplace_browse's queried ".chip" while its markup said ".ml-chip", so
   it matched nothing and every category rendered identically. See
   applyState() in shared/app-shell.js. */

/* ── status ────────────────────────────────────────────────────────────── */
/* Read-only state label. The container and text colours are DERIVED from the
   three status tokens with color-mix, so adding "available / pending / live"
   costs zero new tokens — screens were hardcoding #DCFCE7/#166534 pairs
   (89 off-palette hex values across the app) purely because these did not
   exist. */
/* Compose: AssistChip(enabled=false) / Badge                                */

.ml-status {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 3px 9px;
  border-radius: var(--radius-4);
  font-size: var(--type-1);
  font-weight: var(--weight-3);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  white-space: nowrap;
}
.ml-status::before {
  content: "";
  width: 6px; height: 6px;
  border-radius: var(--radius-4);
  background: currentColor;
}
.ml-status--neutral { background: var(--color-surface); color: var(--color-muted); }
.ml-status--neutral::before { display: none; }

.ml-status--success { background: var(--status-success-bg);
                      color:      var(--status-success-text); }
.ml-status--warning { background: var(--status-warning-bg);
                      color:      var(--status-warning-text); }
.ml-status--error   { background: var(--status-error-bg);
                      color:      var(--status-error-text); }
.ml-status--accent  { background: var(--color-pink-container); color: var(--color-pink-pressed); }
/* Added 2026-08-30 to absorb the two hand-rolled blue pills the screens grew
   because no info variant existed: #E0E7FF/#3730A3 (indigo, 11 uses) and
   #DBEAFE/#1E40AF (blue, 2). Indigo is not in the palette at all and was not
   derivable from it, which is exactly why those screens hardcoded a hex. */
.ml-status--info    { background: color-mix(in srgb, var(--color-info) 12%, var(--color-white));
                      color:      color-mix(in srgb, var(--color-info) 72%, black); }

/* ── badge ─────────────────────────────────────────────────────────────── */

.ml-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 2px var(--space-2);
  border-radius: var(--radius-4);
  background: var(--color-surface);
  color: var(--color-muted);
  font-size: var(--type-1);
  font-weight: var(--weight-3);
}
.ml-badge--accent { background: var(--color-pink-container); color: var(--color-pink-pressed); }
/* CATEGORY, not state. Purple is scoped to this one modifier by the palette
   rule "Pink=Brand. Black=Primary. Purple=Badges only" — reinstated 2026-08-31.
   A category badge says what KIND of thing this is (genre, service, role); the
   status colours say how it is GOING. Those were being conflated, which is why
   genre labels were rendering in accent pink and reading as selected. */
.ml-badge--category { background: var(--status-category-bg); color: var(--status-category-text); }

/* Notification count, pinned to the corner of an icon button. Added when the
   Home dashboard needed one and there was no overlay badge in the system —
   the base .ml-badge is an inline pill, not something positioned absolutely
   over a sibling. Sized from --icon-1 (16px, already on the scale) rather
   than an invented dimension; the parent icon button must set
   position:relative for this to anchor correctly, same requirement as
   .ml-chip and .ml-tabbar__beta. */
.ml-badge--overlay {
  position: absolute;
  top: var(--space-1); right: var(--space-1);
  min-width: var(--icon-1); height: var(--icon-1);
  padding: 0 3px;
  font-size: 10px;                    /* below --type-1 on purpose: this is a
                                          digit inside a 16px circle, not body
                                          text, and --type-1 would overflow it */
}

/* Removable badge (a chosen collaborator, an applied filter). The × is a real
   control, so it carries its own expanded target rather than being a 24dp
   tap-and-miss. */
.ml-badge--removable { padding-right: var(--space-1); }
.ml-badge__remove {
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  width: 20px; height: 20px; margin-left: var(--space-1);
  border: 0; border-radius: var(--radius-4);
  background: var(--color-white);
  color: inherit; font-size: var(--type-1); line-height: 1; cursor: pointer;
}
.ml-badge__remove::after {
  content: "";
  position: absolute;
  inset: 50% 50% auto auto;
  width: var(--size-3); height: var(--size-3);
  transform: translate(50%, -50%);
}

/* ── card ──────────────────────────────────────────────────────────────── */
/* A hairline border is the default separator. Elevation is opt-in, because a
   flat monochrome system separates by contrast, not by stacked shadows —
   which is why the elevation scale is 2 levels rather than 4. */
/* Compose: Card(border = BorderStroke(1.dp, MLColor.line))                  */

/* A card is a SURFACE ON A GROUND, not a boxed rectangle.

   Rewritten 2026-08-31 against the references the palette rule already names —
   Apple Music, Shazam, Tinder, Airbnb, Pinterest. Every one of them separates
   a card from the canvas with radius plus a soft wide shadow, and none of them
   draws a border around it. This system did the opposite: a hairline border,
   no elevation, and radius-2. rules/cards.md even said "zero shadow noise,
   rely on the hairline border" — a rule that produced a flat page and, once
   --color-line collided with --color-paper, a card with no visible edge at all.

   The border stays as a fallback for surfaces that sit ON white (a card inside
   a sheet has no ground to lift off), but elevation is what does the work. */
.ml-card {
  background: var(--color-white);
  border-radius: var(--radius-3);
  box-shadow: var(--elevation-2);
  overflow: hidden;
}
/* Media INSET inside the card, with its own radius, rather than bleeding to
   the card edge. This is the move that makes the Nike and Apple-style cards
   read as physical: the artwork is an object resting in the card, not a
   texture painted onto its top half. Opt-in, because a full-bleed hero card
   is a legitimate different thing. */
.ml-card--inset { padding: var(--space-2); }
.ml-card--inset > .ml-media,
.ml-card--inset > img {
  border-radius: calc(var(--radius-3) - var(--space-2));
  overflow: hidden;
}
/* On white, elevation reads as dirt. Fall back to the hairline. */
.ml-card--flat { box-shadow: none; }
.ml-card--lg      { border-radius: var(--radius-3); }
.ml-card--padded  { padding: var(--space-3); }
.ml-card--raised  { box-shadow: var(--elevation-1); }
.ml-card--flat    { border-color: transparent; background: var(--color-surface); }

/* Tappable card — the whole surface is the target. */
a.ml-card, button.ml-card {
  display: block;
  color: inherit;
  text-decoration: none;
  cursor: pointer;
  transition: transform var(--duration-1) var(--ease-standard);
}
a.ml-card:active, button.ml-card:active { transform: scale(0.98); }

/* ── media ─────────────────────────────────────────────────────────────── */
/* The one element allowed to carry saturation. Any text laid over artwork
   MUST sit on the scrim, never on raw imagery. */
/* Compose: AsyncImage + Brush.verticalGradient(MLColor.scrim)               */

.ml-media { position: relative; overflow: hidden; background: var(--color-surface); }
.ml-media > img,
.ml-media > video {
  display: block;
  width: 100%; height: 100%;
  object-fit: cover;
}
.ml-media--square { aspect-ratio: var(--media-aspect-square); }
.ml-media--wide   { aspect-ratio: var(--media-aspect-wide); }
.ml-media--scrim::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--media-scrim);
  pointer-events: none;
}
/* Content laid over the scrim. */
.ml-media__overlay {
  position: absolute;
  z-index: 1;
  left: var(--space-3);
  right: var(--space-3);
  bottom: var(--space-3);
  color: var(--color-white);
}
/* Top-corner slot for a status label or a save control. */
.ml-media__tag { position: absolute; z-index: 1; top: var(--space-2); left: var(--space-2); }
.ml-media__act { position: absolute; z-index: 1; top: var(--space-2); right: var(--space-2); }

/* ── avatar ────────────────────────────────────────────────────────────── */
/* Replaces the 14 broken var(--r-avatar) references: an avatar is radius-4.  */

.ml-avatar {
  display: block;
  width: var(--size-2); height: var(--size-2);
  border-radius: var(--radius-4);
  object-fit: cover;
  background: var(--color-surface);
  flex: none;
}
.ml-avatar--sm { width: var(--size-1); height: var(--size-1); }
.ml-avatar--lg { width: var(--size-4); height: var(--size-4); }

/* An avatar is often the tap target for "your profile". At --size-2 (40dp)
   it fails the 48dp rule on its own, so a tappable avatar gets a wrapper
   that supplies the target without changing how big the image looks. */
.ml-avatar-btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: var(--size-3); min-height: var(--size-3);
  border: 0; padding: 0; background: none; cursor: pointer;
  border-radius: var(--radius-4);
}
.ml-avatar-btn:active { transform: scale(0.94); }

/* ── section header + rail ─────────────────────────────────────────────── */
/* "Featured talent … See all" + a horizontally scrolling row. The single most
   repeated layout in the app and in the reference build. */

.ml-section {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  margin: var(--space-4) 0 var(--space-2);
}
/* A section heading is a signpost inside a screen, not the screen's subject,
   so it sits one step BELOW the title. It was type-3 while 123 of the screen
   titles were type-2, which inverted the hierarchy outright: the label for a
   part of the screen was larger than the name of the screen. Same size as body
   copy now, separated by weight and tracking rather than by scale. */
.ml-section > h2, .ml-section > h3 {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--type-3);
  font-weight: var(--weight-3);
  letter-spacing: -0.01em;
  text-wrap: balance;
}
.ml-section__link {
  flex: none;
  /* A 48dp target needs BOTH axes — this measured 48x32 and failed the audit
     until the inline padding was added. Negative margin keeps the link
     optically flush with the content edge despite the larger hit area. */
  display: inline-flex; align-items: center; justify-content: flex-end;
  min-height: var(--size-3);
  min-width: var(--size-3);
  padding-inline: var(--space-2);
  margin-inline-end: calc(var(--space-2) * -1);
  color: var(--color-pink-pressed);   /* pink TEXT rule — see tokens.json */
  font-size: var(--type-1);
  font-weight: var(--weight-3);
  text-decoration: none;
}

.ml-rail {
  display: flex;
  gap: var(--space-2);
  overflow-x: auto;
  /* bleed to the screen edge, then re-pad — a rail that stops at the gutter
     looks clipped on a phone */
  margin-inline: calc(var(--space-3) * -1);
  padding-inline: var(--space-3);
  padding-bottom: var(--space-1);
  scroll-snap-type: x proximity;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-x: contain;
}
.ml-rail::-webkit-scrollbar { display: none; }
.ml-rail > * { flex: none; scroll-snap-align: start; }

/* ── list row ──────────────────────────────────────────────────────────── */
/* Compose: ListItem                                                         */

.ml-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: var(--size-3);
  padding: var(--space-2) 0;
  color: inherit;
  text-decoration: none;
}
.ml-row__body  { flex: 1; min-width: 0; }
.ml-row__title { display: block; font-size: var(--type-3); font-weight: var(--weight-3); }
.ml-row__meta  { display: block; font-size: var(--type-1); color: var(--color-muted); }
.ml-row + .ml-row { border-top: var(--border-1) solid var(--color-line); }
a.ml-row:active { background: var(--color-surface); }

/* ── divider ───────────────────────────────────────────────────────────── */

.ml-divider { height: 1px; border: 0; margin: var(--space-3) 0; background: var(--color-line); }
.ml-divider--labelled {
  display: flex; align-items: center; gap: var(--space-2);
  height: auto; background: none;
  color: var(--color-muted); font-size: var(--type-1);
}
.ml-divider--labelled::before,
.ml-divider--labelled::after { content: ""; flex: 1; height: 1px; background: var(--color-line); }

/* ── loading ───────────────────────────────────────────────────────────── */
/* Skeletons, not spinners, for content — a spinner tells the user nothing
   about what is arriving. tokens.json v8 recorded state.loading as
   "no dedicated token"; this is that gap closed. */

.ml-skeleton {
  background: linear-gradient(90deg,
    var(--color-surface) 25%,
    var(--color-line) 37%,
    var(--color-surface) 63%);
  background-size: 400% 100%;
  animation: ml-shimmer 1.4s ease infinite;
  border-radius: var(--radius-1);
}
@keyframes ml-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }

.ml-spinner {
  width: 24px; height: 24px;
  border: 3px solid var(--color-line);
  border-top-color: var(--color-pink);
  border-radius: var(--radius-4);
  animation: ml-spin var(--duration-4) linear infinite;
}
@keyframes ml-spin { to { transform: rotate(360deg); } }

/* ── brand ─────────────────────────────────────────────────────────────── */
/* This section used to SET the brand in type — Inter Tight 800 with a pink
   "d" — which is not the logo. The logo is drawn artwork and lives in
   brand/assets as nine variants. A typographic stand-in beside a real mark
   guarantees the two drift, so the mark is now an <img> of the real file and
   the type is only the fallback for places that cannot load one.
   The variant is chosen by GROUND, not by taste: white lockup on ink, black
   lockup on paper. Picking the wrong one is the only way to misuse this. */

.ml-logo { display: inline-flex; align-items: center; }
.ml-logo img, .ml-logo svg { display: block; height: var(--size-1); width: auto; }
.ml-logo--sm img, .ml-logo--sm svg { height: 20px; }
.ml-logo--lg img, .ml-logo--lg svg { height: var(--size-3); }
/* Clear space: the lockup keeps one cap-height of air on every side. Stated
   as padding so it cannot be forgotten at a call site. */
.ml-logo--clear { padding: var(--space-2); }

/* Type fallback only — favicons, plain-text contexts, and anywhere the SVG
   cannot be fetched. Never place this next to the real mark.

   Set by the brand owner 2026-08-30: Unica One, UPPERCASE, -2% tracking,
   weight 500, MUSIC in brand black and LINKD in brand warm pink. This is the
   one place the EXPRESSION palette appears in the stylesheet, and correctly
   so — a wordmark IS brand expression rather than product chrome, which is
   exactly the boundary --color-brand-* was separated out to mark.

   Weight 500 against a single-weight (400) family is safe and deliberate:
   CSS font matching resolves a requested 500 DOWN to 400 when no 500 face
   exists, so the real face renders and nothing is synthesised. Only 600+
   would trigger a fake bold on this family.

   The split is MUSIC | LINKD, so <em> now wraps LINKD — not the trailing "d"
   it used to wrap. Every call site was updated with it.

   Contrast: MUSIC #141621 is 18.0:1 on paper. LINKD #FF005C is 3.88:1 on
   paper and 5.0:1 on ink. Both are exempt under WCAG 1.4.3, which carves out
   text that is part of a brand name — and the pink is only ever the second
   half of a logotype, never a label the user has to read to operate anything. */
.ml-wordmark {
  font-family: var(--font-eyebrow);
  font-weight: var(--weight-2);
  font-size: var(--type-4);
  letter-spacing: -0.02em;
  text-transform: uppercase;
  color: var(--color-brand-black);
}
.ml-wordmark em { font-style: normal; color: var(--color-brand-warm-pink); }
/* On media or ink, MUSIC takes white; LINKD keeps the brand pink, which reads
   stronger on ink (5.0:1) than it does on paper. */
.ml-wordmark--on-media { color: var(--color-white); }

/* Small tracked uppercase label above a screen title. Set in Unica One as of
   2026-08-30 — the third family, brought in for eyebrows, kickers and branded
   labels. Unica One ships ONE weight (400), so no font-weight is declared
   here: asking for 700 would make the browser synthesise a fake bold, which
   on a face with strokes this fine reads as a rendering fault. The size floor
   is --type-1; Unica One's tall narrow caps become unreadable below it. */
.ml-eyebrow {
  font-family: var(--font-eyebrow);
  font-size: var(--type-1);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--color-muted);
}
/* On a dark or media ground the eyebrow carries the brand rather than the
   metadata role, so it takes full contrast instead of muted. */
.ml-eyebrow--on-media { color: var(--color-white); }
.ml-eyebrow--accent   { color: var(--color-pink-pressed); }

/* ── tabular numbers ───────────────────────────────────────────────────── */
/* Compose: TextStyle(fontFeatureSettings = "tnum")                           */
/* The spec lists "Number Style: Tabular" and "Tabular Numbers: Metrics,
   counts" as tokens. They are not values — they are a font-feature switch, so
   they ship as a class. Use it anywhere numbers are compared down a column or
   change in place: stats, tables, timers, counters, prices. Without it,
   proportional digits make a ticking timer jitter horizontally. */

.ml-tabular { font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; }

/* ── readable measure ──────────────────────────────────────────────────── */
/* The spec's "Max Line Width". Prose past ~75 characters loses the return
   sweep; --layout-measure is 64ch, which lands mid-range for Inter. */

.ml-measure { max-width: var(--layout-measure); }

/* The page container. --layout-max and --chrome-scrollbar both existed as
   tokens with no consumer: the Gaps table claimed --layout-max was "kept as a
   max-width" while nothing on web applied it, and the scrollbar width was a
   number with nowhere to go. The system had .ml-measure for line length and
   nothing for page width, so every full-width layout capped itself by hand.
   Subtracting the scrollbar keeps the cap honest on desktop, where a visible
   scrollbar means 100vw is wider than the space actually available. */
.ml-page {
  width: 100%;
  max-width: min(var(--layout-max), 100vw - var(--chrome-scrollbar));
  margin-inline: auto;
}

/* ── nav app bar ───────────────────────────────────────────────────────── */
/* Back-arrow bar for anything inside a flow. Separate from .ml-appbar,
   which is the tab-root large-title bar. */
/* Compose: TopAppBar(navigationIcon = ...)                                  */

.ml-navbar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--size-4);
  padding: var(--space-2) var(--space-2);
  padding-top: calc(var(--space-2) + var(--safe-top));
  flex: none;
}
/* an <h1> on flow screens, so the UA margin has to go */
.ml-navbar__title { margin: 0; font-size: var(--type-3); font-weight: var(--weight-3); }
.ml-navbar__spacer { flex: 1; }

/* ── sticky footer ─────────────────────────────────────────────────────── */
/* Primary action parked at the thumb. Every flow screen that commits to
   something uses this rather than letting the CTA scroll away. */

.ml-app__footer {
  flex: none;
  position: sticky; bottom: 0; z-index: var(--z-2);
  /* It is called a sticky footer and it was neither sticky nor stacked: with
     no position and no z-index it sat in flow and scrolling content rode over
     it. The shell gives it a row, sticky pins it, z-index puts it above the
     content it is meant to float over. */
  padding: var(--space-3);
  padding-bottom: calc(var(--space-3) + var(--safe-bottom));
  background: var(--color-paper);
  border-top: var(--border-1) solid var(--color-line);
}
.ml-app__footer--bare { border-top: 0; background: none; }

/* ── segmented control ─────────────────────────────────────────────────── */
/* Compose: SingleChoiceSegmentedButtonRow                                   */

.ml-segmented {
  display: flex;
  gap: var(--space-1);
  padding: var(--space-1);
  border-radius: var(--radius-4);
  background: var(--color-surface);
}
.ml-segmented__btn {
  position: relative;
  flex: 1;
  min-height: var(--size-2);
  border: 0;
  border-radius: var(--radius-4);
  background: none;
  color: var(--color-muted);
  font-family: var(--font-body);
  font-size: var(--type-1);
  font-weight: var(--weight-3);
  cursor: pointer;
  transition: background-color var(--duration-1) var(--ease-standard);
}
.ml-segmented__btn[aria-selected="true"] {
  background: var(--color-white);
  color: var(--color-ink);
  box-shadow: var(--elevation-1);
}
/* Visual height stays 40dp (Material's segmented-button height); the tap
   target is expanded to 48dp by an overlay box. Pseudo-elements hit-test as
   part of their parent, so the tap still lands on the button — verified in
   the browser with elementFromPoint, not assumed. */
.ml-segmented__btn::after {
  content: "";
  position: absolute;
  inset: 50% 0 auto;
  height: var(--size-3);
  transform: translateY(-50%);
}

/* ── OTP ───────────────────────────────────────────────────────────────── */
/* One box per digit. The row is a single logical input for a screen reader. */

.ml-otp { display: flex; gap: var(--space-2); justify-content: center; }
.ml-otp__box {
  width: var(--size-3);
  height: 64px;
  display: flex; align-items: center; justify-content: center;
  border: var(--border-1) solid var(--color-line);
  border-radius: var(--radius-2);
  background: var(--color-white);
  color: var(--color-muted);
  font-size: var(--type-4);
  font-weight: var(--weight-3);
  font-variant-numeric: tabular-nums;
}
.ml-otp__box.is-filled { border-color: var(--color-pink); border-width: var(--border-2); color: var(--color-ink); }
.ml-otp__box.is-invalid { border-color: var(--color-error); color: var(--color-error);
  background: var(--status-error-bg); }

/* ── phone entry ───────────────────────────────────────────────────────── */

.ml-phone { display: flex; gap: var(--space-2); }
.ml-phone__prefix {
  display: flex; align-items: center; gap: var(--space-1);
  min-height: var(--size-3);
  padding: 0 var(--space-3);
  border: var(--border-1) solid var(--color-line);
  border-radius: var(--radius-4);
  background: var(--color-white);
  font-size: var(--type-3); font-weight: var(--weight-3);
  flex: none;
}
.ml-phone input { flex: 1; min-width: 0; }

/* ── banner ────────────────────────────────────────────────────────────── */
/* Inline, in-flow message. Not a toast — this one stays until resolved. */

.ml-banner {
  display: flex; align-items: flex-start; gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-2);
  font-size: var(--type-1);
}
.ml-banner--error   { background: var(--status-error-bg);
                      color: var(--status-error-text); }
.ml-banner--info    { background: var(--color-surface); color: var(--color-muted); }
.ml-banner--success { background: var(--status-success-bg);
                      color: var(--status-success-text); }
/* Added 2026-08-30. The marketplace offer flow had a hand-rolled amber banner
   because this variant did not exist, which is how #FEF3C7/#92400E reached
   three screens. --info above is deliberately NEUTRAL rather than blue: an
   informational banner is not a status, and painting it blue competes with
   .ml-status--info, which IS one. */
.ml-banner--warning { background: var(--status-warning-bg);
                      color: var(--status-warning-text); }

/* ── confirmation mark ─────────────────────────────────────────────────── */

.ml-check {
  display: flex; align-items: center; justify-content: center;
  width: 64px; height: 64px; margin-inline: auto;
  border-radius: var(--radius-4);
  background: var(--status-success-bg);
  /* The raw green on its own 14% tint is 2.02:1, under the 3:1 floor for a
     graphic (1.4.11). Same darkening the status pills already use. */
  color: var(--status-success-text);
}

/* ── step progress ─────────────────────────────────────────────────────── */
/* Onboarding is 13 screens; without a visible position people abandon it.
   .onb-progress / .onb-step / .step-dots were redefined in 12, 12 and 7
   screens respectively before this existed. */

.ml-steps { display: flex; align-items: center; gap: var(--space-2); }
.ml-steps__track { flex: 1; height: 4px; border-radius: var(--radius-4); background: var(--color-line); overflow: hidden; }
.ml-steps__fill  { height: 100%; border-radius: var(--radius-4); background: var(--color-ink);
                   transition: width var(--duration-3) var(--ease-enter); }
.ml-steps__label { font-size: var(--type-1); color: var(--color-muted); font-weight: var(--weight-3); flex: none;
                   font-variant-numeric: tabular-nums; }

.ml-dots { display: flex; gap: var(--space-1); justify-content: center; }
.ml-dots__dot { width: 6px; height: 6px; border-radius: var(--radius-4); background: var(--color-line); }
.ml-dots__dot.is-active { background: var(--color-ink); width: 18px; }

/* ── choice list ───────────────────────────────────────────────────────── */
/* Single- or multi-select rows. The whole row is the target, not the box. */
/* Compose: ListItem + RadioButton / Checkbox                                */

.ml-choice {
  display: flex; align-items: center; gap: var(--space-3);
  width: 100%;
  min-height: var(--size-4);
  padding: var(--space-3);
  border: var(--border-1) solid var(--color-line);
  border-radius: var(--radius-2);
  background: var(--color-white);
  color: inherit; text-align: left; text-decoration: none;
  font-family: var(--font-body); font-size: var(--type-3);
  cursor: pointer;
  transition: border-color var(--duration-1) var(--ease-standard);
}
.ml-choice + .ml-choice { margin-top: var(--space-2); }
.ml-choice:active { background: var(--color-surface); }
.ml-choice[aria-checked="true"], .ml-choice.is-selected {
  border-color: var(--color-ink); border-width: var(--border-2);
}
.ml-choice__body { flex: 1; min-width: 0; }
/* display:block is required, not cosmetic: these are <span> (an <a>/<button>
   cannot legally contain a <div>), so without it the meta line runs on inline
   after the title instead of sitting under it. */
.ml-choice__title { display: block; font-weight: var(--weight-3); }
.ml-choice__meta  { display: block; font-size: var(--type-1); color: var(--color-muted); }
.ml-choice__mark {
  width: var(--icon-3); height: var(--icon-3); flex: none;
  border: var(--border-2) solid var(--color-line);
  border-radius: var(--radius-4);
  display: flex; align-items: center; justify-content: center;
  color: transparent;
}
.ml-choice[aria-checked="true"] .ml-choice__mark {
  background: var(--color-ink); border-color: var(--color-ink); color: var(--color-white);
}

/* Wrapping group of chips — genres, instruments, languages.
   ROW gap is --space-3 (16dp), not --space-2, and that is load-bearing: a
   chip is 32dp tall with its tap band expanded to 48dp, so rows must sit at
   least 48dp apart or adjacent rows' bands overlap and a tap near the
   boundary activates the wrong chip. 32 + 16 = 48. Column gap stays 8dp
   because horizontal neighbours do not expand sideways. */
.ml-chip-wrap { display: flex; flex-wrap: wrap; gap: var(--space-3) var(--space-2); }

/* ── emoji glyph ───────────────────────────────────────────────────────── */
/* Emoji appear as CONTENT on role/instrument/category choices — the same call
   the approved mobile reference makes on its category tiles. They are sized
   here rather than with an inline font-size so 21 screens stop hardcoding
   22px. Not typography: this is an icon slot that happens to hold an emoji,
   so it does not use a --type-* step. */
.ml-emoji {
  flex: none;
  width: 24px;
  font-size: 22px;
  line-height: 1;
  text-align: center;
}

/* ── visually hidden ───────────────────────────────────────────────────── */
/* Available to assistive tech, absent from the visual layout. For the cases
   where a screen genuinely needs a heading for structure but showing one
   would duplicate what the UI already says — a search screen whose subject
   IS the focused search field, for instance. Not for hiding real content. */
.ml-visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ── verified mark ─────────────────────────────────────────────────────── */
/* The icon library's check-circle is a solid glyph: tinted pink at 14px it
   reads as a featureless blob. A disc with the check knocked out in white
   stays legible at the size this actually renders. */
.ml-verified {
  display: inline-flex; align-items: center; justify-content: center;
  width: 15px; height: 15px; flex: none;
  border-radius: var(--radius-4);
  /* White on --color-pink is 3.56:1. It scrapes the 3:1 graphic floor but
     breaks the rule this system states on the token itself, and a verified
     mark is the last thing that should be borderline. Pressed is 5.37. */
  background: var(--color-pink-pressed);
  color: var(--color-white);
}

/* ── inline link ───────────────────────────────────────────────────────── */
/* Pink text link. Uses --color-pink-pressed, not --color-pink: the brand pink
   measures 3.48:1 on paper and fails AA as text. See the pink-text rule in
   tokens.json. Replaces the pre-v9 .link-text. */
.ml-link {
  /* inline-flex with a 48dp min-height, not a bare <a>: a text link is a real
     control and was measuring 193x21. Baseline alignment is preserved by
     vertical-align, so it still sits correctly inside a paragraph. */
  display: inline-flex;
  align-items: center;
  min-height: var(--size-3);
  vertical-align: middle;
  color: var(--color-pink-pressed);
  font-weight: var(--weight-3);
  text-decoration: none;
}

/* ── switch ────────────────────────────────────────────────────────────── */
/* The single most-needed missing control: user_settings is 13 screens of
   preferences with nothing to render them. The track is the target — 48dp tall
   even though it looks 28dp — and the state is carried by aria-checked so a
   screen reader gets it without a parallel class. */
/* Compose: Switch                                                           */

.ml-switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  flex: none;
  width: 48px; height: var(--size-1);
  padding: 0; border: 0;
  border-radius: var(--radius-4);
  background: var(--color-line);
  cursor: pointer;
  transition: background-color var(--duration-2) var(--ease-standard);
}
.ml-switch::after {
  content: "";
  position: absolute;
  inset: 50% auto auto 3px;
  width: 26px; height: 26px;
  transform: translateY(-50%);
  border-radius: var(--radius-4);
  background: var(--color-white);
  box-shadow: var(--elevation-1);
  transition: transform var(--duration-2) var(--ease-standard);
}
.ml-switch[aria-checked="true"] { background: var(--color-ink); }
.ml-switch[aria-checked="true"]::after { transform: translate(19px, -50%); }
.ml-switch:disabled { opacity: var(--opacity-3); pointer-events: none; }
/* expand the tap band to 48dp without growing the track */
.ml-switch::before { content: ""; position: absolute; inset: 50% 0 auto; height: var(--size-3); transform: translateY(-50%); }

/* ── checkbox / radio ──────────────────────────────────────────────────── */
/* Standalone controls. .ml-choice covers the whole-row pattern; these are for
   a control that sits beside other content — a filter list, a consent line. */
/* Compose: Checkbox · RadioButton                                            */

.ml-check-ctl {
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  flex: none;
  width: var(--icon-3); height: var(--icon-3);
  border: var(--border-2) solid var(--color-line);
  border-radius: var(--radius-1);
  background: var(--color-white);
  color: transparent;
  cursor: pointer;
  transition: background-color var(--duration-1) var(--ease-standard),
              border-color var(--duration-1) var(--ease-standard);
}
.ml-check-ctl--radio { border-radius: var(--radius-4); }
.ml-check-ctl::before { content: ""; position: absolute; inset: 50% 50% auto auto;
  width: var(--size-3); height: var(--size-3); transform: translate(50%, -50%); }
.ml-check-ctl[aria-checked="true"] {
  background: var(--color-ink); border-color: var(--color-ink); color: var(--color-white);
}
.ml-check-ctl--radio[aria-checked="true"] { background: var(--color-white); }
.ml-check-ctl--radio[aria-checked="true"]::after {
  content: ""; width: 12px; height: 12px; border-radius: var(--radius-4); background: var(--color-ink);
}
/* label + control as one row, so the label is part of the target */
.ml-check-row {
  display: flex; align-items: center; gap: var(--space-3);
  min-height: var(--size-3);
  cursor: pointer;
}

/* ── select ────────────────────────────────────────────────────────────── */
/* A native <select> styled to match .ml-field. Deliberately native rather than
   a custom dropdown: the OS picker is better on a phone than anything we would
   draw, and it is accessible for free. */
/* Compose: ExposedDropdownMenuBox                                            */

.ml-select {
  position: relative;
  display: block;
}
.ml-select select {
  width: 100%;
  min-height: var(--size-4);
  padding: var(--space-3) var(--size-3) var(--space-3) var(--space-3);
  border: var(--border-1) solid var(--color-line);
  border-radius: var(--radius-4);
  background: var(--color-white);
  color: var(--color-ink);
  font-family: var(--font-body);
  font-size: var(--type-3);
  appearance: none;
  cursor: pointer;
}
.ml-select::after {
  content: "";
  position: absolute;
  right: var(--space-3); top: 50%;
  width: 8px; height: 8px;
  margin-top: -6px;
  border-right: var(--border-2) solid var(--color-muted);
  border-bottom: var(--border-2) solid var(--color-muted);
  transform: rotate(45deg);
  pointer-events: none;
}

/* ── slider ────────────────────────────────────────────────────────────── */
/* Range input, restyled. Both vendor thumb pseudo-elements are needed — there
   is no shared selector, and omitting one leaves that browser unstyled. */
/* Compose: Slider · RangeSlider                                              */

.ml-slider {
  width: 100%;
  height: var(--size-3);            /* the target; the track is drawn thinner */
  appearance: none;
  background: none;
  cursor: pointer;
}
.ml-slider::-webkit-slider-runnable-track {
  height: 4px; border-radius: var(--radius-4); background: var(--color-line);
}
.ml-slider::-moz-range-track {
  height: 4px; border-radius: var(--radius-4); background: var(--color-line);
}
.ml-slider::-webkit-slider-thumb {
  appearance: none;
  width: var(--icon-3); height: var(--icon-3);
  /* Centres the thumb on the 4px track. Derived, so changing --icon-3
     cannot silently un-centre it — the old -9px was tied to a 22px thumb. */
  margin-top: calc((var(--icon-3) - 4px) / -2);
  border: 0; border-radius: var(--radius-4);
  background: var(--color-ink);
  box-shadow: var(--elevation-1);
}
.ml-slider::-moz-range-thumb {
  width: var(--icon-3); height: var(--icon-3);
  border: 0; border-radius: var(--radius-4);
  background: var(--color-ink);
  box-shadow: var(--elevation-1);
}

/* ── accordion ─────────────────────────────────────────────────────────── */
/* Built on <details>/<summary> so it works with no JavaScript and is
   keyboard-accessible for free. FAQ and settings screens need it. */
/* Compose: ExpandableCard                                                    */

.ml-accordion { border-top: var(--border-1) solid var(--color-line); }
.ml-accordion:last-of-type { border-bottom: var(--border-1) solid var(--color-line); }
.ml-accordion > summary {
  display: flex; align-items: center; gap: var(--space-3);
  min-height: var(--size-4);
  padding: var(--space-2) 0;
  font-size: var(--type-3); font-weight: var(--weight-3);
  cursor: pointer; list-style: none;
}
.ml-accordion > summary::-webkit-details-marker { display: none; }
.ml-accordion > summary::after {
  content: ""; flex: none; margin-left: auto;
  width: 8px; height: 8px;
  border-right: var(--border-2) solid var(--color-muted);
  border-bottom: var(--border-2) solid var(--color-muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform var(--duration-2) var(--ease-standard);
}
.ml-accordion[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.ml-accordion__body { padding: 0 0 var(--space-3); color: var(--color-muted); font-size: var(--type-3); }

/* ── dialog ────────────────────────────────────────────────────────────── */
/* Centre-screen decision. Distinct from .ml-sheet, which is for choosing among
   options; a dialog is for confirming or refusing one. Uses <dialog> so focus
   trapping and Esc are the platform's job, not ours. */
/* Compose: AlertDialog                                                       */

.ml-dialog {
  width: min(340px, calc(100vw - var(--space-4)));
  padding: var(--space-4);
  border: 0;
  border-radius: var(--radius-3);
  background: var(--color-white);
  color: var(--color-ink);
  box-shadow: var(--elevation-2);
}
.ml-dialog::backdrop { background: color-mix(in srgb, var(--color-ink) 64%, transparent); }
.ml-dialog__title { margin: 0 0 var(--space-2); font-family: var(--font-display);
  font-size: var(--type-4); font-weight: var(--weight-3); letter-spacing: -0.01em; }
.ml-dialog__body { margin: 0; color: var(--color-muted); font-size: var(--type-3); }
.ml-dialog__actions { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-4); }
/* destructive confirmations put the safe choice first */
.ml-dialog--danger .ml-dialog__title { color: var(--color-error); }

/* ── floating action button ────────────────────────────────────────────── */
/* Compose: FloatingActionButton                                             */

.ml-fab {
  position: fixed;
  right: var(--space-3);
  bottom: calc(var(--size-4) + var(--space-3) + var(--safe-bottom));
  z-index: var(--z-2);
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
  min-width: var(--size-4); min-height: var(--size-4);
  padding: 0 var(--space-3);
  border: 0; border-radius: var(--radius-4);
  background: var(--color-ink); color: var(--color-white);
  box-shadow: var(--elevation-2);
  font-family: var(--font-body); font-size: var(--type-3); font-weight: var(--weight-3);
  cursor: pointer;
  transition: transform var(--duration-1) var(--ease-standard);
}
.ml-fab:active { transform: scale(0.94); }
.ml-fab--accent { background: var(--color-pink); color: var(--color-ink); }

/* ── button group ──────────────────────────────────────────────────────── */
/* Two or three actions of equal weight on one row. Not a segmented control —
   these fire actions, they do not select a mode. */

.ml-btn-group { display: flex; gap: var(--space-2); }
.ml-btn-group > * { flex: 1; }

/* ── touch targets ─────────────────────────────────────────────────────────
   Measured on the rendered catalogue 2026-08-31: 49 controls sat under the
   48dp floor this system's own header claims for "every interactive element",
   eleven of them under even WCAG 2.2's looser 24x24. The contradiction was
   real — meta.tokenMap deliberately sets chip height to --size-1 (32), so the
   claim and the tokens disagreed.

   Resolved the way iOS and Material both resolve it: the control keeps its
   VISUAL size and gains an invisible 48dp TARGET. Growing the boxes instead
   would break the 32dp chip on purpose-built dense surfaces.

   ::before, not ::after — every one of these controls already uses ::after for
   its ripple, tick or dismiss glyph, and a blanket ::after rule would have
   silently replaced them. .ml-check-ctl is the exception: its ::before is the
   checkmark, so it takes ::after.

   The expander is absolutely positioned and centred, so it overhangs without
   affecting layout, and sits at z-index 0 so it never covers the control's own
   content. */
.ml-help,
.ml-check-ctl,
.ml-chip,
.ml-crumbs__item,
.ml-segmented__btn { position: relative; }
.ml-help::before,
.ml-check-ctl::after,
.ml-chip::before,
.ml-crumbs__item::before,
.ml-segmented__btn::before {
  content: "";
  position: absolute;
  top: 50%; left: 50%;
  translate: -50% -50%;
  min-width: var(--size-3);
  min-height: var(--size-3);
  width: 100%;
  height: 100%;
  z-index: 0;
  background: none;
  pointer-events: auto;
}
