/* nilam — components.
 *
 * ── the scope, stated honestly ────────────────────────────────────────────────
 *
 * This does not compete with Radix or React Aria on breadth and it never will. React
 * Aria is years of screen-reader, touch and IME work by a team; a stylesheet cannot
 * contain that. What a stylesheet CAN do in 2026 is much more than it could in 2020,
 * because the platform grew the hard parts:
 *
 *   <dialog>              modal, focus trap, Esc to close, inert background, ::backdrop
 *   popover / popovertarget   top layer, light dismiss, Esc, implicit anchor reference
 *   CSS anchor positioning    Baseline 2026 — tooltips and menus without Floating UI
 *   <details name>        exclusive accordion, no JS at all
 *   :has()                parent selectors, so a field can style itself from its input
 *   field-sizing          textareas that grow, without a scroll-height listener
 *
 * So every overlay below is native. The dialog needs no focus-trap library, the menu
 * needs no positioning library, the accordion needs no state. What is left for JS is
 * genuinely stateful widgets — combobox, date picker, virtualised table — and those are
 * deliberately absent rather than badly approximated. Use React Aria for them. Pairing
 * is the honest recommendation, not a fallback.
 *
 * ── the rule every status component follows ───────────────────────────────────
 *
 * prove.mjs measures which status colours collapse under each dichromacy and, at hue
 * 285, reports that danger/warn, danger/ok and warn/ok all become indistinguishable to
 * a deuteranope. So every status component here carries a GLYPH as well as a hue, and
 * proveStatusChannels() fails the build if one does not. WCAG 1.4.1 has required this
 * for years; this is the first system I know of that enforces it.
 */

@layer nilam.components {
  /* ── button ─────────────────────────────────────────────────────────────── */

  .n-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    /* Padding is asymmetric on purpose: optical centring. Equal block padding on a
       control with a cap-height-only label reads bottom-heavy. */
    padding: 0.5rem 0.875rem;
    border: var(--line-1) solid var(--neutral-7);
    border-radius: var(--r-3);
    background: transparent;
    color: var(--neutral-12);
    font-size: var(--text-1);
    font-weight: var(--weight-medium);
    line-height: 1;
    /* 44px is the WCAG 2.5.8 target-size minimum. The control is 36px tall so it does
       not look chunky, and the shortfall is made up by a transparent pseudo-element —
       the pointer target is compliant while the painted box stays the size the design
       wants. */
    min-block-size: 2.25rem;
    position: relative;
    cursor: pointer;
    text-decoration: none;
    white-space: nowrap;
    user-select: none;
    transition:
      background var(--dur-1) var(--ease-out),
      border-color var(--dur-1) var(--ease-out),
      box-shadow var(--dur-1) var(--ease-out);
  }

  /* The hit-target extension. ::after rather than padding, so layout is untouched. */
  .n-btn::after {
    content: '';
    position: absolute;
    inset: 50% 0 auto;
    translate: 0 -50%;
    block-size: max(100%, 2.75rem);
  }

  /* The tint is not the state in dark mode: --neutral-3 on --surface is 1.0009:1, because
     dark --surface is step 3's lightness with the tint taken off, so the two differ by
     chroma alone. This button already owns a --neutral-7 border, so the cheapest honest
     second channel is to move that border rather than add a ring — --neutral-8 against
     --neutral-7 is a full solved step, and it costs no layout. */
  .n-btn:hover { background: var(--neutral-3); border-color: var(--neutral-8); }
  .n-btn:active { background: var(--neutral-4); border-color: var(--neutral-8); }
  .n-btn:disabled,
  .n-btn[aria-disabled='true'] {
    /* Not opacity. Fading a button fades its label too and drops it below 4.5:1 — the
       most common accessibility bug in every component library I checked. Disabled state
       is expressed with tokens that are still legible. */
    background: var(--neutral-2);
    border-color: var(--neutral-6);
    color: var(--neutral-11);
    cursor: not-allowed;
  }

  /* The brand moment. Step 9 flips polarity by mode and --brand-ink follows it, so this
     one rule is a dark button with white text on a light page and a glowing light button
     with dark text on a dark page. Writing `color: #fff` here would break dark mode. */
  .n-btn-fill {
    background: var(--brand-9);
    border-color: transparent;
    color: var(--brand-ink);
    box-shadow: var(--shadow-1);
  }
  .n-btn-fill:hover { background: var(--brand-10); }
  .n-btn-fill:active { background: var(--brand-10); box-shadow: none; }

  .n-btn-ink {
    background: var(--neutral-12);
    border-color: transparent;
    color: var(--neutral-1);
  }
  .n-btn-ink:hover { background: var(--neutral-11); }

  .n-btn-ghost { border-color: transparent; }
  /* A ghost button has given its border away, so the border trick above is not available
     and it needs the ring instead. --neutral-6, not --neutral-7: this is pointer
     affordance, which 1.4.11 does not govern, and a 3:1 ring would make a ghost button
     look bordered on hover — which is the thing it exists not to be. */
  .n-btn-ghost:hover {
    background: var(--neutral-3);
    box-shadow: inset 0 0 0 var(--line-1) var(--neutral-6);
  }

  .n-btn-danger {
    background: var(--danger-9);
    border-color: transparent;
    color: var(--danger-ink);
  }
  .n-btn-danger:hover { background: var(--danger-10); }

  /* ok and warn fills existed for badges, notes and meters but not for BUTTONS, so an app with
   * a matched affirmative/negative pair — a Yes/No vote, an approve/reject row — had to
   * hand-roll one of the two and lost the solved ink with it. Note the inks differ: at hue 285
   * the ok solid takes dark ink in both modes while danger flips, so hard-coding either is
   * wrong on one of them. */
  .n-btn-ok {
    background: var(--ok-9);
    border-color: transparent;
    color: var(--ok-ink);
  }
  .n-btn-ok:hover { background: var(--ok-10); }
  .n-btn-warn {
    background: var(--warn-9);
    border-color: transparent;
    color: var(--warn-ink);
  }
  .n-btn-warn:hover { background: var(--warn-10); }

  .n-btn-sm { padding: 0.3125rem 0.625rem; font-size: var(--text-0); min-block-size: 1.75rem; }
  .n-btn-lg { padding: 0.6875rem 1.125rem; font-size: var(--text-2); min-block-size: 2.75rem; }
  .n-btn-icon { padding: 0; inline-size: 2.25rem; }
  .n-btn-icon.n-btn-sm { inline-size: 1.75rem; }

  /* Loading. aria-busy is the accessible signal; the spinner is the visible one.
   *
   * The label goes transparent rather than being removed, so the button keeps its width
   * and the row does not reflow when it starts loading. */
  .n-btn[aria-busy='true'] { color: transparent; pointer-events: none; }
  .n-btn[aria-busy='true']::before {
    content: '';
    position: absolute;
    inline-size: 0.875rem;
    block-size: 0.875rem;
    /* border-color, NOT `color`, and this is the bug the first version had. `color` was set
     * to --brand-ink on the base rule, which is near-white in light mode — so a ghost or
     * outline button's spinner was white on a transparent background and simply invisible.
     * Each variant now names its own visible colour. */
    border: var(--line-2) solid var(--neutral-12);
    border-block-start-color: transparent;
    border-radius: var(--r-full);
    animation: n-spin 0.6s linear infinite;
  }
  .n-btn-fill[aria-busy='true']::before,
  .n-btn-danger[aria-busy='true']::before,
  .n-btn-ok[aria-busy='true']::before,
  .n-btn-warn[aria-busy='true']::before { border-color: var(--brand-ink); border-block-start-color: transparent; }

  /* Disabled AND busy at once, which is what a form does the instant it submits. The :disabled
   * rule wins the background on specificity while the spinner still took --brand-ink, so the
   * ring was ~1.09:1 on the disabled surface — invisible exactly when it is the only feedback
   * the user has. Named last so it beats the variant rules above. */
  .n-btn:disabled[aria-busy='true']::before,
  .n-btn[aria-disabled='true'][aria-busy='true']::before {
    border-color: var(--neutral-11);
    border-block-start-color: transparent;
  }
  .n-btn-ink[aria-busy='true']::before { border-color: var(--neutral-1); border-block-start-color: transparent; }

  /* ── loaders ─────────────────────────────────────────────────────────────
   *
   * Four, because they answer four different questions and substituting one for another is
   * a real usability error:
   *
   *   .n-spinner   "something is happening"       — unknown duration, small, inline
   *   .n-progress  "this much is done"            — known proportion, uses <progress>
   *   .n-bar       "something is happening"       — unknown duration, page or panel width
   *   .n-skeleton  "content shaped like THIS is coming"
   *
   * Prefer .n-skeleton wherever the shape is predictable. A spinner tells the reader
   * nothing about what is arriving; a skeleton tells them where to look when it does.
   */

  .n-spinner {
    display: inline-block;
    flex: none;
    inline-size: 1.125rem;
    block-size: 1.125rem;
    border: var(--line-2) solid var(--neutral-6);
    /* One arc in a stronger colour is what makes rotation legible. A ring of uniform
       colour rotating looks static. */
    border-block-start-color: var(--brand-9);
    border-radius: var(--r-full);
    animation: n-spin 0.6s linear infinite;
  }
  .n-spinner-sm { inline-size: 0.875rem; block-size: 0.875rem; border-width: var(--line-1); }
  .n-spinner-lg { inline-size: 1.75rem; block-size: 1.75rem; }
  /* On a filled surface the ring must come from the ink, not from the neutral ramp. */
  .n-spinner-on-fill { border-color: color-mix(in oklab, var(--brand-ink) 30%, transparent); border-block-start-color: var(--brand-ink); }

  @keyframes n-spin { to { rotate: 360deg; } }

  /* Determinate. The element is <progress>, so the value is in the accessibility tree for
     free and no aria-valuenow needs maintaining. appearance:none then rebuilds it, and
     both pseudo-element sets are needed because WebKit and Firefox disagree. */
  .n-progress {
    appearance: none;
    inline-size: 100%;
    block-size: 0.375rem;
    /* See .n-meter: without this the empty portion is 1.08:1 on a card in dark. */
    border: var(--line-1) solid var(--neutral-7);
    border-radius: var(--r-full);
    background: var(--neutral-4);
    overflow: hidden;
  }
  .n-progress::-webkit-progress-bar { background: var(--neutral-4); border-radius: var(--r-full); }
  .n-progress::-webkit-progress-value {
    background: var(--brand-9);
    border-radius: var(--r-full);
    transition: inline-size var(--dur-2) var(--ease-out);
  }
  .n-progress::-moz-progress-bar { background: var(--brand-9); border-radius: var(--r-full); }

  /* Indeterminate bar. A <progress> with no value attribute is already indeterminate to
     assistive tech, so this styles that state rather than inventing a widget. */
  .n-bar {
    position: relative;
    inline-size: 100%;
    block-size: 0.1875rem;
    border: var(--line-1) solid var(--neutral-7);
    border-radius: var(--r-full);
    background: var(--neutral-4);
    overflow: hidden;
  }
  .n-bar::after {
    content: '';
    position: absolute;
    inset-block: 0;
    inline-size: 40%;
    border-radius: inherit;
    background: var(--brand-9);
    animation: n-slide 1.4s var(--ease-in-out) infinite;
  }
  @keyframes n-slide {
    0%   { translate: -110% 0; }
    100% { translate: 260% 0; }
  }

  /* Three dots, for inline "typing"/thinking states where a ring is too heavy. */
  .n-dots { display: inline-flex; gap: 0.25rem; align-items: center; }
  .n-dots i {
    inline-size: 0.3125rem;
    block-size: 0.3125rem;
    border-radius: var(--r-full);
    background: var(--neutral-11);
    animation: n-pulse 1.2s var(--ease-in-out) infinite;
  }
  .n-dots i:nth-child(2) { animation-delay: 0.15s; }
  .n-dots i:nth-child(3) { animation-delay: 0.3s; }
  @keyframes n-pulse {
    0%, 60%, 100% { opacity: 0.3; scale: 0.8; }
    30%           { opacity: 1;   scale: 1; }
  }

  /* The reduced-motion exemption for loaders is NOT here. It lives in nilam.motion, the FIRST
   * layer, because !important declarations resolve in REVERSE layer order — a rule in this
   * layer therefore loses to nilam.base's blanket `animation-duration: 0.01ms !important` on *.
   * It sat here for three releases and every loader froze. See nilam.motion.css. */

  /* The accessible wrapper. role="status" has an implicit aria-live="polite", so the text
     inside is announced once without interrupting. The visible loader is decorative next
     to it, which is why examples pair them. */
  .n-loading {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    font-size: var(--text-0);
    color: var(--neutral-11);
  }

  /* ── field ──────────────────────────────────────────────────────────────── */

  .n-field { display: flex; flex-direction: column; gap: var(--space-1); }

  .n-label {
    font-size: var(--text-0);
    font-weight: var(--weight-medium);
    color: var(--neutral-12);
  }
  /* The required marker is a hue AND a glyph, and it is aria-hidden in markup because
     the input's own `required` is what assistive tech should read. */
  .n-label[data-required]::after {
    content: ' *';
    color: var(--danger-11);
  }

  .n-input,
  .n-textarea,
  .n-select {
    inline-size: 100%;
    padding: 0.5rem 0.6875rem;
    border: var(--line-1) solid var(--neutral-7);
    border-radius: var(--r-3);
    background: var(--neutral-1);
    color: var(--neutral-12);
    font-size: var(--text-1);
    min-block-size: 2.25rem;
    transition: border-color var(--dur-1) var(--ease-out), box-shadow var(--dur-1) var(--ease-out);
  }
  .n-input::placeholder,
  .n-textarea::placeholder {
    /* Step 11, not a lighter grey. Placeholder text is text and 1.4.3 applies to it;
       most systems ship placeholders around 2.5:1. */
    color: var(--neutral-11);
  }
  .n-input:hover, .n-textarea:hover, .n-select:hover { border-color: var(--neutral-8); }
  .n-input:focus-visible, .n-textarea:focus-visible, .n-select:focus-visible {
    outline: none;
    border-color: var(--brand-9);
    /* Ring drawn as a shadow rather than an outline so it hugs the border radius and
       does not clip inside a scroll container. */
    box-shadow: 0 0 0 var(--ring-width) var(--brand-6);
  }

  /* Invalid, but only once the user has finished — :invalid alone paints an empty
     required field red before it has been touched, which is hostile. user-invalid is
     the correct selector and it is Baseline. */
  .n-input:user-invalid, .n-textarea:user-invalid, .n-select:user-invalid,
  .n-input[aria-invalid='true'], .n-textarea[aria-invalid='true'], .n-select[aria-invalid='true'] {
    border-color: var(--danger-8);
  }
  .n-input:user-invalid:focus-visible,
  .n-input[aria-invalid='true']:focus-visible { box-shadow: 0 0 0 var(--ring-width) var(--danger-6); }

  /* [aria-invalid] is not a duplicate of :user-invalid, it is the case :user-invalid cannot
   * reach. :user-invalid only fires for constraints the BROWSER evaluates — required, type,
   * pattern. An application-level rule ("that is not a magnet URI", "this username is taken")
   * has no browser constraint to violate, so on a plain valid text field :user-invalid never
   * matches and the border stayed neutral while the error text below it said otherwise.
   * Since aria-invalid is the attribute assistive tech reads anyway, styling it means the
   * visual state and the announced state cannot disagree. */

  .n-textarea {
    /* Grows with content where supported, and stays a normal resizable textarea where
       not. Pure enhancement — nothing breaks without it. */
    field-sizing: content;
    min-block-size: 4.5rem;
    line-height: var(--leading-normal);
    resize: vertical;
  }

  /* The select arrow, drawn in currentColor so it follows the mode. A hard-coded SVG
     fill is the usual approach and it is invisible in dark mode. */
  .n-select {
    appearance: none;
    padding-inline-end: 2rem;
    background-image:
      linear-gradient(45deg, transparent 50%, currentColor 50%),
      linear-gradient(135deg, currentColor 50%, transparent 50%);
    background-position:
      calc(100% - 1.125rem) calc(50% + 0.1em),
      calc(100% - 0.75rem) calc(50% + 0.1em);
    background-size: 0.375rem 0.375rem;
    background-repeat: no-repeat;
  }

  /* Help and error text, wired with aria-describedby in markup. The error carries a
     glyph because danger is one of the colours the prover says collapses. */
  .n-hint { font-size: var(--text-00); color: var(--neutral-11); }
  /* NOT flex, for the same reason .n-summary is not — and this one survived the fix there.
   *
   * Flex makes every text node AND every inline element its own flex item, so the gap opens
   * a hole either side of any <code>, <strong> or <a> mid-sentence. The comment on
   * .n-summary warns about exactly this trap and this component sat one screenful away
   * still falling into it.
   *
   * Block layout with the glyph as an inline ::before keeps the message as prose. */
  .n-error {
    display: block;
    font-size: var(--text-00);
    color: var(--danger-11);
  }
  .n-error::before {
    content: '⚠';
    font-size: 0.9em;
    margin-inline-end: 0.35em;
  }

  /* ── checkbox, radio, switch ─────────────────────────────────────────────── */

  .n-check, .n-radio {
    appearance: none;
    flex: none;
    inline-size: 1.125rem;
    block-size: 1.125rem;
    margin: 0;
    border: var(--line-1) solid var(--neutral-7);
    background: var(--neutral-1);
    cursor: pointer;
    display: grid;
    place-content: center;
    transition: background var(--dur-0) var(--ease-out), border-color var(--dur-0) var(--ease-out);
  }
  .n-check { border-radius: var(--r-1); }
  .n-radio { border-radius: var(--r-full); }

  .n-check::before {
    content: '';
    inline-size: 0.625rem;
    block-size: 0.625rem;
    background: var(--brand-ink);
    clip-path: polygon(14% 44%, 0 65%, 43% 100%, 100% 16%, 85% 0, 41% 71%);
    scale: 0;
    transition: scale var(--dur-1) var(--ease-spring);
  }
  .n-radio::before {
    content: '';
    inline-size: 0.5rem;
    block-size: 0.5rem;
    border-radius: var(--r-full);
    background: var(--brand-ink);
    scale: 0;
    transition: scale var(--dur-1) var(--ease-spring);
  }
  .n-check:checked, .n-radio:checked { background: var(--brand-9); border-color: var(--brand-9); }
  .n-check:checked::before, .n-radio:checked::before { scale: 1; }

  /* Indeterminate is a real tri-state and most libraries forget it. */
  .n-check:indeterminate { background: var(--brand-9); border-color: var(--brand-9); }
  .n-check:indeterminate::before {
    clip-path: polygon(10% 40%, 90% 40%, 90% 60%, 10% 60%);
    scale: 1;
  }

  .n-switch {
    appearance: none;
    flex: none;
    inline-size: 2.25rem;
    block-size: 1.25rem;
    margin: 0;
    border-radius: var(--r-full);
    background: var(--neutral-6);
    /* THE THIRD TOGGLE THAT DID NOT GET THE BOUNDARY.
     *
     * .n-check and .n-radio both carry --neutral-7 and this had `border: none`, so an
     * unchecked switch was a --neutral-6 pill at 1.5500:1 against the page and 1.3235:1 on
     * a card in dark. Its STATE was never in doubt — checked is --brand-9 at 4.3107:1, a
     * different colour entirely — but a control you cannot find is 1.4.11 all the same, and
     * 3:1 for a control boundary is the reason step 7 exists.
     *
     * box-sizing is border-box globally, so the 1px edge takes the padding box from 36x20
     * to 34x18 and the knob's inset drops from 0.1875rem to 0.125rem to stay centred. The
     * checked translate is still exactly 1rem: 2 + 14 + 16 = 32, which leaves the same 2px
     * at the far end. */
    border: var(--line-1) solid var(--neutral-7);
    cursor: pointer;
    position: relative;
    transition: background var(--dur-1) var(--ease-out);
  }
  .n-switch::before {
    content: '';
    position: absolute;
    /* 0.125rem, not 0.1875rem: the track gained a 1px border above, so these are measured
       from a padding box 2px smaller in each axis. */
    inset-block-start: 0.125rem;
    inset-inline-start: 0.125rem;
    inline-size: 0.875rem;
    block-size: 0.875rem;
    border-radius: var(--r-full);
    background: var(--neutral-1);
    box-shadow: var(--shadow-1);
    transition: translate var(--dur-1) var(--ease-spring);
  }
  .n-switch:checked { background: var(--brand-9); }
  .n-switch:checked::before { translate: 1rem 0; }

  /* Label rows for all three. gap, not margin, so RTL works without overrides. */
  .n-choice {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    font-size: var(--text-1);
    cursor: pointer;
  }
  /* :has() lets the row respond to its own input. Before this, every library needed a
     wrapper class toggled by JS to do the same thing. */
  .n-choice:has(:disabled) { color: var(--neutral-11); cursor: not-allowed; }

  /* ── card ───────────────────────────────────────────────────────────────── */

  .n-card {
    /* --surface, not --neutral-1. In light mode the page is tinted and this is pure
       white; the tint reads as light because the card beside it has none. */
    background: var(--surface);
    border: var(--line-1) solid var(--neutral-6);
    border-radius: var(--r-5);
    box-shadow: var(--shadow-1), var(--rim);
  }
  .n-card-pad { padding: var(--space-5); }
  .n-card-float { box-shadow: var(--shadow-2), var(--rim); }
  .n-card-link {
    display: block;
    text-decoration: none;
    color: inherit;
    transition: box-shadow var(--dur-2) var(--ease-out), translate var(--dur-2) var(--ease-out);
  }
  .n-card-link:hover { box-shadow: var(--shadow-3), var(--rim); translate: 0 -2px; }

  /* ── badge, chip ────────────────────────────────────────────────────────── */

  .n-badge {
    display: inline-flex;
    align-items: center;
    gap: 0.3125rem;
    padding: 0.1875rem 0.5rem;
    border: var(--line-1) solid var(--neutral-6);
    border-radius: var(--r-full);
    background: var(--neutral-3);
    color: var(--neutral-12);
    font-size: var(--text-000);
    font-weight: var(--weight-medium);
    line-height: 1.4;
    white-space: nowrap;
  }
  /* The glyph slot. Required on every status variant — see the header note. */
  .n-badge-glyph {
    flex: none;
    inline-size: 0.875rem;
    block-size: 0.875rem;
    border-radius: var(--r-full);
    display: grid;
    place-items: center;
    font-size: 0.5rem;
    font-weight: var(--weight-bold);
    font-family: var(--font-mono);
  }

  .n-badge-brand { background: var(--brand-3); border-color: var(--brand-6); color: var(--brand-11); }
  .n-badge-brand .n-badge-glyph { background: var(--brand-9); color: var(--brand-ink); }
  .n-badge-ok { background: var(--ok-3); border-color: var(--ok-6); color: var(--ok-11); }
  .n-badge-ok .n-badge-glyph { background: var(--ok-9); color: var(--ok-ink); }
  .n-badge-warn { background: var(--warn-3); border-color: var(--warn-6); color: var(--warn-11); }
  .n-badge-warn .n-badge-glyph { background: var(--warn-9); color: var(--warn-ink); }
  .n-badge-danger { background: var(--danger-3); border-color: var(--danger-6); color: var(--danger-11); }
  .n-badge-danger .n-badge-glyph { background: var(--danger-9); color: var(--danger-ink); }
  .n-badge-info { background: var(--info-3); border-color: var(--info-6); color: var(--info-11); }
  .n-badge-info .n-badge-glyph { background: var(--info-9); color: var(--info-ink); }

  /* ── note / alert ───────────────────────────────────────────────────────── */

  .n-note {
    display: flex;
    gap: var(--space-3);
    align-items: flex-start;
    padding: var(--space-3) var(--space-4);
    border: var(--line-1) solid var(--neutral-6);
    border-radius: var(--r-4);
    background: var(--neutral-2);
    color: var(--neutral-12);
    font-size: var(--text-0);
  }
  .n-note-glyph {
    flex: none;
    inline-size: 1.25rem;
    block-size: 1.25rem;
    border-radius: var(--r-full);
    display: grid;
    place-items: center;
    font: var(--weight-bold) 0.75rem/1 var(--font-mono);
    /* Optical alignment with the first line of body text rather than the box top. */
    margin-block-start: 0.0625rem;
  }
  .n-note-title { font-weight: var(--weight-semibold); }
  .n-note-ok { background: var(--ok-2); border-color: var(--ok-6); color: var(--ok-12); }
  .n-note-ok .n-note-glyph { background: var(--ok-9); color: var(--ok-ink); }
  .n-note-warn { background: var(--warn-2); border-color: var(--warn-6); color: var(--warn-12); }
  .n-note-warn .n-note-glyph { background: var(--warn-9); color: var(--warn-ink); }
  .n-note-danger { background: var(--danger-2); border-color: var(--danger-6); color: var(--danger-12); }
  .n-note-danger .n-note-glyph { background: var(--danger-9); color: var(--danger-ink); }
  .n-note-info { background: var(--info-2); border-color: var(--info-6); color: var(--info-12); }
  .n-note-info .n-note-glyph { background: var(--info-9); color: var(--info-ink); }

  /* ── table ──────────────────────────────────────────────────────────────── */

  .n-table { inline-size: 100%; font-size: var(--text-0); }
  .n-table th {
    font: var(--weight-medium) var(--text-000) / 1 var(--font-mono);
    letter-spacing: var(--tracking-micro);
    text-transform: uppercase;
    color: var(--neutral-11);
    padding: 0 var(--space-3) var(--space-2) 0;
    border-block-end: var(--line-1) solid var(--neutral-7);
  }
  .n-table td {
    padding: var(--space-3) var(--space-3) var(--space-3) 0;
    border-block-end: var(--line-1) solid var(--neutral-6);
    color: var(--neutral-11);
  }
  .n-table td:last-child, .n-table th:last-child { padding-inline-end: 0; }
  .n-table-key { color: var(--neutral-12); font-weight: var(--weight-medium); }
  /* Tabular figures, so columns of numbers line up. Nothing else makes a table look
     as unfinished as proportional digits. */
  /* nowrap because a measurement is one token to a reader: "1.4 GB" breaking to "1.4 / GB"
   * at a narrow width destroys the column that tabular-nums exists to line up. */
  .n-table-num {
    text-align: end;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    color: var(--neutral-12);
  }
  /* Step 3, not step 2, and a top rule as the second channel.
   *
   * --neutral-2 is the one hover in this file that failed in LIGHT as well as dark: it
   * measures 1.0550:1 against the page and 1.1186:1 on a card, so the row a pointer was
   * over looked exactly like the rows it was not. Step 3 takes the page case to 1.1300:1
   * light and 1.1700:1 dark. Text is unaffected either way — .n-table td is --neutral-11
   * and that is 4.55:1 on step 3 by construction, since step 11 is solved against step 3.
   *
   * Only a top rule, because td already draws its own --neutral-6 border-block-end; adding
   * a bottom inset here would paint a second line on top of it. The pair reads as a band. */
  .n-table tbody tr:hover td {
    background: var(--neutral-3);
    box-shadow: inset 0 var(--line-1) 0 var(--neutral-6);
  }

  /* Wrapper that scrolls a wide table without breaking the page. tabindex="0" in markup
     makes it keyboard-scrollable, which is a real 2.1.1 requirement people miss. */
  .n-table-scroll { overflow-x: auto; overscroll-behavior-x: contain; }

  /* ── dialog: native, no focus-trap library ──────────────────────────────── */

  .n-dialog {
    /* margin: auto is what centres a modal <dialog>, and it comes from the UA sheet rather
     * than from anything here — so any reset that zeroes margins silently pins the modal to
     * the top-left corner. ANY margin-zeroing reset does it — Tailwind's preflight
     * (`margin: 0` on `*, ::after, ::before, ::backdrop`) and the hand-written
     * `* { margin: 0; padding: 0 }` that half the web starts with. Two separate apps hit it,
     * one Tailwind and one not, which is how the cause got correctly generalised: the first
     * fix here blamed Tailwind and would have left the other one broken.
     *
     * Found by looking at a screenshot rather than by reading either stylesheet, because
     * neither is wrong on its own. */
    margin: auto;
    padding: 0;
    border: var(--line-1) solid var(--neutral-6);
    border-radius: var(--r-6);
    background: var(--surface);
    color: var(--neutral-12);
    box-shadow: var(--shadow-3), var(--rim);
    inline-size: min(32rem, calc(100vw - 2rem));
    max-block-size: calc(100dvh - 4rem);
    /* The top layer already handles stacking, so no z-index. */
    overflow: visible;
  }
  .n-dialog::backdrop {
    background: var(--scrim);
    backdrop-filter: blur(2px);
  }
  .n-dialog-head { padding: var(--space-5) var(--space-5) var(--space-3); }
  .n-dialog-title { font-size: var(--text-3); }
  .n-dialog-body { padding-inline: var(--space-5); overflow-y: auto; }
  .n-dialog-foot {
    display: flex;
    justify-content: flex-end;
    gap: var(--space-2);
    padding: var(--space-4) var(--space-5) var(--space-5);
  }

  /* Enter and exit animation for an element that is display:none when closed. Needs all
     three parts: allow-discrete to animate display at all, @starting-style for the
     entry values, and overlay so it stays in the top layer while leaving. Miss any one
     and the exit animation silently does not play. */
  .n-dialog, .n-dialog::backdrop {
    transition:
      opacity var(--dur-2) var(--ease-out),
      translate var(--dur-2) var(--ease-out),
      display var(--dur-2) allow-discrete,
      overlay var(--dur-2) allow-discrete;
    opacity: 0;
  }
  .n-dialog { translate: 0 0.5rem; }
  .n-dialog[open], .n-dialog[open]::backdrop { opacity: 1; }
  .n-dialog[open] { translate: 0 0; }
  @starting-style {
    .n-dialog[open], .n-dialog[open]::backdrop { opacity: 0; }
    .n-dialog[open] { translate: 0 0.5rem; }
  }

  /* ── popover, menu, tooltip: native top layer + CSS anchor positioning ──── */

  .n-pop {
    /* [popover] is display:none until shown, so this is the shown state. */
    margin: 0;
    padding: var(--space-2);
    border: var(--line-1) solid var(--neutral-6);
    border-radius: var(--r-4);
    background: var(--surface);
    color: var(--neutral-12);
    box-shadow: var(--shadow-2), var(--rim);
    inline-size: max-content;
    max-inline-size: 18rem;

    /* Anchored to its own invoker. A popover opened by popovertarget gets an IMPLICIT
       anchor reference to the button that opened it, so no anchor-name is needed on the
       trigger — this positions itself with no JS and no Floating UI. */
    position-area: block-end span-inline-start;
    position-try-fallbacks: flip-block, flip-inline;
    margin-block-start: var(--space-1);
  }
  .n-pop:popover-open { opacity: 1; }
  .n-pop {
    opacity: 0;
    transition:
      opacity var(--dur-1) var(--ease-out),
      display var(--dur-1) allow-discrete,
      overlay var(--dur-1) allow-discrete;
  }
  @starting-style { .n-pop:popover-open { opacity: 0; } }

  .n-menu { display: flex; flex-direction: column; gap: 1px; min-inline-size: 11rem; }
  .n-menu-item {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-3);
    border: none;
    border-radius: var(--r-2);
    background: transparent;
    color: var(--neutral-12);
    font-size: var(--text-0);
    text-align: start;
    text-decoration: none;
    cursor: pointer;
    inline-size: 100%;
  }
  /* HOVER AND FOCUS ARE SPLIT, and they were not.
   *
   * One rule used to serve both and it ended with `outline: none`, so a focused menu item
   * gave up the global --brand-9 ring (4.9408:1 on a card in dark) and got a --neutral-3
   * tint instead. .n-pop is --surface by definition, so a menu item is ALWAYS on a card,
   * and in dark that tint measures 1.0009:1 — dark --surface is step 3's lightness with
   * the tint removed, so the two differ by chroma and nothing else. Keyboard focus in a
   * menu was therefore invisible in dark mode: WCAG 2.4.7, not a soft preference.
   *
   * So focus keeps the ring the rest of the system uses, and only hover is restyled. The
   * ring is --neutral-6 rather than --neutral-7 because pointer hover is affordance, which
   * 1.4.11 does not govern; it measures 1.3235:1 on a card in dark, which is above the
   * 1.1072:1 ceiling that ANY fill can reach there (see .n-slider-track for the sweep). */
  .n-menu-item:hover {
    background: var(--neutral-3);
    box-shadow: inset 0 0 0 var(--line-1) var(--neutral-6);
  }
  .n-menu-item-danger { color: var(--danger-11); }
  .n-menu-item-danger:hover {
    background: var(--danger-3);
    box-shadow: inset 0 0 0 var(--line-1) var(--danger-6);
  }
  .n-menu-sep { block-size: var(--line-1); background: var(--neutral-6); margin-block: var(--space-1); }

  /* Tooltip. popover="hint" is the right type — it does not close other popovers and it
     does not take focus. Falls back to a plain popover where hint is unsupported. */
  .n-tip {
    margin: 0;
    padding: 0.3125rem var(--space-2);
    border: none;
    border-radius: var(--r-2);
    background: var(--neutral-12);
    color: var(--neutral-1);
    font-size: var(--text-00);
    inline-size: max-content;
    max-inline-size: 16rem;
    position-area: block-start;
    position-try-fallbacks: flip-block;
    margin-block-end: var(--space-1);
  }

  /* ── accordion: <details name> does the state, with no JS ────────────────── */

  .n-accordion { border: var(--line-1) solid var(--neutral-6); border-radius: var(--r-4); overflow: hidden; }
  .n-accordion > details + details { border-block-start: var(--line-1) solid var(--neutral-6); }
  /* display:block, not flex, and the chevron absolutely positioned.
   *
   * Flex with space-between is the obvious way to push a marker to the end, and it is
   * wrong here: flex turns every text node AND every inline element into a separate flex
   * item, so a summary reading "Why is <code>info</code> achromatic?" came out as three
   * items spread across the full width with gaps in the middle of the sentence. Block
   * layout keeps inline content as prose, which is what it is. */
  .n-summary {
    display: block;
    position: relative;
    padding: var(--space-3) var(--space-4);
    padding-inline-end: calc(var(--space-4) + 1.25rem);
    font-size: var(--text-1);
    font-weight: var(--weight-medium);
    cursor: pointer;
    list-style: none;
  }
  .n-summary::-webkit-details-marker { display: none; }
  /* Step 3 for the same reason as the table row: --neutral-2 was 1.0550:1 against the page
     in LIGHT, not only in dark. .n-accordion already draws --neutral-6 rules between its
     items, so the tint is the whole signal here and it has to carry. */
  .n-summary:hover { background: var(--neutral-3); }
  .n-summary::after {
    content: '';
    position: absolute;
    inset-inline-end: var(--space-4);
    inset-block-start: 50%;
    inline-size: 0.5rem;
    block-size: 0.5rem;
    border-inline-end: var(--line-2) solid var(--neutral-11);
    border-block-end: var(--line-2) solid var(--neutral-11);
    /* -70% rather than -50%: rotating a square about its centre leaves the visual
       centre of a chevron below the geometric one. */
    translate: 0 -70%;
    rotate: 45deg;
    transition: rotate var(--dur-1) var(--ease-out);
  }
  details[open] > .n-summary::after { rotate: -135deg; }
  .n-accordion-body { padding: 0 var(--space-4) var(--space-4); font-size: var(--text-0); color: var(--neutral-11); }

  /* ── tabs ───────────────────────────────────────────────────────────────── */

  .n-tabs { display: flex; gap: var(--space-1); border-block-end: var(--line-1) solid var(--neutral-6); }
  .n-tab {
    padding: var(--space-2) var(--space-3);
    border: none;
    border-block-end: var(--line-2) solid transparent;
    background: transparent;
    color: var(--neutral-11);
    font-size: var(--text-0);
    font-weight: var(--weight-medium);
    cursor: pointer;
    margin-block-end: calc(-1 * var(--line-1));
  }
  .n-tab:hover { color: var(--neutral-12); }
  /* aria-selected is the state, so the selector reads it rather than a class. If the
     markup is not accessible the style does not apply — a useful forcing function. */
  .n-tab[aria-selected='true'] { color: var(--brand-11); border-block-end-color: var(--brand-9); }

  /* ── meter / progress ───────────────────────────────────────────────────── */

  /* The BORDER is the fix, and the alternative was worse.
   *
   * The track alone was --neutral-4, which measures 1.08:1 against a card in dark — the same
   * near-invisibility as the old skeleton, and for the same reason: the dark surface sits at
   * step 3's lightness, so anything from the low ramp drawn ON a card disappears. An empty
   * meter had no extent at all; you could not see how much was left.
   *
   * Darkening the track to --neutral-6 fixes that and breaks something else: the fill then
   * fails 3:1 against the track, at 1.98:1 for ok in light. A meter needs BOTH — an outline so
   * the whole control is findable, and a fill/track step so the value is readable — and one
   * lightness cannot serve both.
   *
   * So: step 7 as a border (3.65:1 light, 3.05:1 dark against a card, clearing 1.4.11 for the
   * control) and the quiet track kept for the fill to work against. Found by an app that had to
   * add the border itself.
   *
   * Honest residual: ok and warn fills are 2.56:1 and 2.54:1 against the track in LIGHT mode.
   * Those two hues are light by nature and the track is light, so the pair is thin. Use
   * --ok-10 / --warn-10 (3.0 and 2.98) where the exact extent matters, or put a numeric
   * readout beside the meter — which a dichromat needs anyway, since a continuous bar has
   * nowhere to put the glyph the status components rely on. */
  .n-meter {
    block-size: 0.375rem;
    border: var(--line-1) solid var(--neutral-7);
    border-radius: var(--r-full);
    background: var(--neutral-4);
    overflow: hidden;
  }
  .n-meter-fill {
    block-size: 100%;
    border-radius: inherit;
    background: var(--brand-9);
    transition: inline-size var(--dur-3) var(--ease-out);
  }
  .n-meter-ok .n-meter-fill { background: var(--ok-10); }
  .n-meter-warn .n-meter-fill { background: var(--warn-10); }
  .n-meter-danger .n-meter-fill { background: var(--danger-9); }

  /* ── avatar ─────────────────────────────────────────────────────────────── */

  .n-avatar {
    inline-size: 2rem;
    block-size: 2rem;
    flex: none;
    border-radius: var(--r-full);
    /* The ring is the shape. --brand-3 on a card measures 1.1973:1 in light and 1.0013:1
       in dark, so without it the disc vanishes on a card in dark mode and the initials are
       left floating in the layout with nothing round them. --brand-6 is 1.3235:1 there,
       which is the most a 1px edge can do without reading as a bordered button. */
    border: var(--line-1) solid var(--brand-6);
    background: var(--brand-3);
    color: var(--brand-11);
    display: grid;
    place-items: center;
    font-size: var(--text-00);
    font-weight: var(--weight-semibold);
    overflow: hidden;
    user-select: none;
  }
  .n-avatar img { inline-size: 100%; block-size: 100%; object-fit: cover; }
  .n-avatar-sm { inline-size: 1.5rem; block-size: 1.5rem; font-size: var(--text-000); }
  .n-avatar-lg { inline-size: 3rem; block-size: 3rem; font-size: var(--text-1); }

  /* ── skeleton ───────────────────────────────────────────────────────────── */

  /* Steps 5 and 7, not 3 and 4, and the reason is a measured defect.
   *
   * In DARK mode --surface and --neutral-3 have IDENTICAL lightness — 0.2455 both, differing
   * only by 0.007 of chroma, which is 1.0009:1. So a skeleton built from step 3 was
   * completely invisible on a card, and only the step-4 shimmer stop showed, and only while
   * it travelled. That is not a subtle miss: the component whose entire job is to say
   * "content is coming" said nothing on the surface it is most often placed on. It was true
   * in this file's own showcase for several releases.
   *
   * It happens because solveScale deliberately sets the dark surface to step 3's lightness
   * with the tint removed — the card is the untinted version of the component surface. Good
   * for cards, fatal for anything drawn ON one out of step 3.
   *
   * 5 -> 7 measures 1.18:1 against a dark card and 1.38:1 against the dark page, with a
   * 2.59:1 travelling stop. Still quiet on the page, and actually present on a card. */
  .n-skeleton {
    background: linear-gradient(90deg, var(--neutral-5) 25%, var(--neutral-7) 50%, var(--neutral-5) 75%);
    background-size: 200% 100%;
    border-radius: var(--r-2);
    animation: n-shimmer 1.4s ease-in-out infinite;
  }
  @keyframes n-shimmer { to { background-position: -200% 0; } }

  /* ── link ───────────────────────────────────────────────────────────────── */

  .n-link {
    color: var(--brand-11);
    text-decoration-line: underline;
    text-decoration-color: var(--brand-8);
    text-decoration-thickness: from-font;
    text-underline-offset: 0.15em;
  }
  .n-link:hover { text-decoration-color: currentColor; }

  /* ── layout ─────────────────────────────────────────────────────────────── */

  .n-container { inline-size: 100%; max-inline-size: 72rem; margin-inline: auto; padding-inline: var(--space-4); }
  .n-stack { display: flex; flex-direction: column; gap: var(--space-4); }
  .n-cluster { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
  .n-divider { block-size: var(--line-1); background: var(--neutral-6); border: none; margin: 0; }

  /* Prose. The one place headings get sized, because everywhere else the author decides. */
  .n-prose { max-inline-size: var(--measure); }
  .n-prose > * + * { margin-block-start: var(--space-4); }
  .n-prose h2 { font-size: var(--text-4); margin-block-start: var(--space-6); }
  .n-prose h3 { font-size: var(--text-3); margin-block-start: var(--space-5); }
  .n-prose :where(ul, ol) { padding-inline-start: var(--space-5); }
  .n-prose ul { list-style: disc; }
  .n-prose ol { list-style: decimal; }
  .n-prose li + li { margin-block-start: var(--space-2); }
  /* The border is here because <pre> below has one and inline code did not. --neutral-3 on
     a card is 1.0009:1 in dark, so the tint is doing nothing there and the mono face was
     carrying the whole distinction on its own. */
  .n-prose code {
    background: var(--neutral-3);
    border: var(--line-1) solid var(--neutral-6);
    border-radius: var(--r-1);
    padding: 0.1em 0.3em;
  }
  .n-prose pre {
    background: var(--neutral-2);
    border: var(--line-1) solid var(--neutral-6);
    border-radius: var(--r-4);
    padding: var(--space-4);
  }
  .n-prose pre code { background: none; padding: 0; }
  .n-prose blockquote {
    border-inline-start: var(--line-2) solid var(--brand-8);
    padding-inline-start: var(--space-4);
    color: var(--neutral-11);
  }

  /* ── kbd, num ───────────────────────────────────────────────────────────── */

  /* A keyboard hint that does not look like inline code.
   *
   * nilam.base.css already gives `code, kbd, samp, pre` the mono face at 0.9375em, which
   * is type and nothing else — so <kbd>I</kbd> and <code>I</code> rendered identically.
   * That is wrong in a way that matters for tool UIs: "press I" and "the value I" are
   * different claims, and nilam's OWN behaviours page has been writing <kbd>→</kbd> beside
   * inline code for five releases with no way to tell them apart. Found by someone
   * building a keyboard-driven editor on it and reaching for a class that was not there.
   *
   * The box, not just a tint: --neutral-3 is 1.0009:1 on a card in dark, so a background
   * alone would have shipped the same non-distinction it was added to fix. --neutral-7 at
   * 3.0469:1 is what makes it read as a key. */
  .n-kbd {
    display: inline-block;
    font-family: var(--font-mono);
    /* Not the 0.9375em from base: a key cap sits inside running text and one step down
       keeps the line from growing round it. */
    font-size: var(--text-00);
    line-height: 1;
    /* min-inline-size so a single character does not produce a key narrower than it is
       tall — "K" and "Esc" should read as the same kind of object. */
    min-inline-size: 1.5em;
    padding: 0.25em 0.4em;
    border: var(--line-1) solid var(--neutral-7);
    /* The bottom edge is the key travel. One border-width, not a blurred shadow, so it
       survives forced-colors and prints. */
    border-block-end-width: var(--line-2);
    border-radius: var(--r-1);
    background: var(--neutral-3);
    color: var(--neutral-12);
    text-align: center;
    /* A shortcut is one token. "Ctrl" breaking after "Ct" is not a key. */
    white-space: nowrap;
  }
  /* A combination is a sequence of keys, so the joiner lives outside them. */
  .n-kbd + .n-kbd { margin-inline-start: var(--space-1); }

  /* Tabular figures on their own, for numbers outside a table.
   *
   * .n-table-num does this and three other things — text-align: end, nowrap, and
   * --neutral-12 — because inside a table all four are right together. Outside one, the
   * alignment is wrong and the colour is a decision the caller should make, so reaching
   * for .n-table-num on a standalone figure imported two properties nobody asked for.
   *
   * Deliberately NO alignment and NO colour. The whole reason this exists rather than
   * `tabular-nums` at each call site is that a house rule — every number that updates gets
   * tabular figures — is only enforceable if it has a name. */
  .n-num { font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }

  /* ── utilities that are genuinely load-bearing ──────────────────────────── */

  /* The only correct visually-hidden. `display:none` removes it from the a11y tree and
     `text-indent:-9999px` breaks RTL and gets read as a huge empty box. */
  .n-sr-only {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
  /* Skip link: hidden until focused, then a real target. 2.4.1. */
  .n-skip {
    position: absolute;
    inset-block-start: var(--space-2);
    inset-inline-start: var(--space-2);
    padding: var(--space-2) var(--space-3);
    border-radius: var(--r-3);
    background: var(--brand-9);
    color: var(--brand-ink);
    text-decoration: none;
    translate: 0 -200%;
  }
  .n-skip:focus-visible { translate: 0 0; }
}
