/* Structural half of the token contract: everything that is not colour.
   Themes override what differs; these are the defaults both build on. */

/* The layer order, stated once so it never depends on which file loads first.
   Extensions sit above components: they build on the library, and a project
   that declares one gets the last word inside its own attributes. */
@layer ulex.theme, ulex.base, ulex.components, ulex.extensions;

@layer ulex.theme {
  :root {
    --ulex-space-1: 0.25rem;  --ulex-space-2: 0.5rem;   --ulex-space-3: 0.75rem;
    --ulex-space-4: 1rem;     --ulex-space-5: 1.25rem;  --ulex-space-6: 1.5rem;
    --ulex-space-8: 2rem;     --ulex-space-10: 2.5rem;  --ulex-space-12: 3rem;
    --ulex-space-16: 4rem;

    /* rem throughout: px font sizes break 1.4.4 Resize Text */
    --ulex-text-1: clamp(1.75rem, 1.5rem + 1.1vw, 2.25rem);
    --ulex-text-2: clamp(1.5rem, 1.3rem + 0.8vw, 1.875rem);
    --ulex-text-3: clamp(1.25rem, 1.1rem + 0.5vw, 1.5rem);
    --ulex-text-4: 1.125rem;
    --ulex-text-5: 1rem;
    --ulex-text-6: 0.9375rem;
    --ulex-text-7: 0.875rem;
    --ulex-text-8: 0.8125rem;

    --ulex-leading: 1.5;              /* 1.4.8 floor */
    --ulex-leading-prose: 1.6;
    --ulex-weight-normal: 400;
    --ulex-weight-medium: 500;
    --ulex-weight-semibold: 600;

    --ulex-transition-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
    --ulex-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);

    --ulex-z-nav: 10;
    --ulex-z-dropdown: 50;
    --ulex-z-modal: 200;

    /* Published by the shell so sticky offsets never need measuring. */
    --ulex-nav-h: 3rem;
    --ulex-sidebar-w: 15rem;
    --ulex-toc-w: 13rem;

    --ulex-radius-full: 9999px;

    /* Stacked bar. --ulex-bar-part is set per segment, inline, as its
       proportion; the default keeps a segment visible if nothing sets it. */
    --ulex-bar-h: 0.4375rem;
    --ulex-bar-part: 1;

    /* 2.5.8 Target Size (Minimum), AA. CSS px, deliberately: the success
       criterion is defined in CSS px, not in the user's font size. */
    --ulex-target-min: 24px;

    --ulex-ring-width: 2px;           /* 2.4.13 floor */
    --ulex-ring-offset: 2px;
  }

  /* 2.5.5 Target Size (Enhanced) */
  [data-ulex-level="aaa"] { --ulex-target-min: 44px; }

}
/* Base. Applies to bare semantic markup with no attributes at all --
   zero-attribute HTML must look right and be conformant on its own. */

@layer ulex.base {
  *, *::before, *::after { box-sizing: border-box; }

  body {
    margin: 0;
    background: var(--ulex-bg);
    color: var(--ulex-text);
    font-family: var(--ulex-font-sans);
    font-size: var(--ulex-text-5);
    line-height: var(--ulex-leading);
    -webkit-font-smoothing: antialiased;
  }

  /* 2.4.7 / 2.4.13. One ring, everywhere, never removed without replacement.
     --ulex-ring is contrast-checked at >= 3:1 against every surface. */
  :focus-visible {
    outline: var(--ulex-ring-width) solid var(--ulex-ring);
    outline-offset: var(--ulex-ring-offset);
    border-radius: var(--ulex-radius-sm);
  }
  /* Only suppress the default ring where :focus-visible is honoured. */
  :focus:not(:focus-visible) { outline: none; }

  /* 2.5.8 -- every interactive target clears the floor, at every size.
     min-block-size rather than height so 1.4.12 Text Spacing still holds. */
  :is(a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])) {
    min-block-size: var(--ulex-target-min);
  }
  :is(button, input, select, textarea) {
    min-inline-size: var(--ulex-target-min);
    font: inherit;
    color: inherit;
  }

  /* 2.3.3 -- honour the user's setting globally rather than per component. */
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.01ms !important;
      scroll-behavior: auto !important;
    }
  }

  a { color: var(--ulex-accent); }
  a:hover { text-decoration-thickness: 2px; }

  /* Headings take their sizes from the scale, so a theme retunes the whole
     hierarchy by retuning the scale rather than by patching headings. */
  :is(h1, h2, h3, h4, h5, h6) {
    line-height: 1.25;
    text-wrap: balance;
    margin-block: var(--ulex-space-6) var(--ulex-space-3);
    font-weight: var(--ulex-weight-semibold);
  }
  :is(h1, h2, h3, h4, h5, h6):first-child { margin-block-start: 0; }
  h1 { font-size: var(--ulex-text-2); }
  h2 { font-size: var(--ulex-text-3); }
  h3 { font-size: var(--ulex-text-4); }
  h4 { font-size: var(--ulex-text-5); }
  :is(h5, h6) { font-size: var(--ulex-text-6); }
  p { margin-block: 0 var(--ulex-space-4); }

  /* Inline code reads as code without an attribute: the meta face and a
     raised chip. Text keeps its inherited colour, and every text level is
     verified on surface-hi, so the chip cannot cost contrast. Code inside a
     <pre> is a block's business, not this rule's. */
  :where(code, samp):not(pre > *) {
    font-family: var(--ulex-font-mono);
    font-size: 0.875em;
    padding: 0.1em 0.35em;
    background: var(--ulex-surface-hi);
    border: 1px solid var(--ulex-edge-subtle);
    border-radius: var(--ulex-radius-sm);
    overflow-wrap: anywhere;   /* 1.4.10 -- a long identifier wraps, never scrolls the page */
  }
  /* A key, from the element: <kbd> already means this. */

  :where(kbd) {
    display: inline-block;
    padding: 0.1em 0.4em;
    font-family: var(--ulex-font-mono);
    font-size: 0.85em;
    line-height: 1.4;
    color: var(--ulex-text);
    background: var(--ulex-surface-hi);
    border: 1px solid var(--ulex-edge-control);
    border-block-end-width: 2px;
    border-radius: var(--ulex-radius-sm);
    white-space: nowrap;
  }

  /* A bare <pre> is a block of code or output, and looks like one with no
     attribute. It scrolls itself rather than the page (1.4.10). */
  :where(pre) {
    margin: 0;
    padding: var(--ulex-space-3);
    overflow-x: auto;
    font-family: var(--ulex-font-mono);
    font-size: var(--ulex-text-8);
    line-height: 1.5;
    background: var(--ulex-surface);
    border: 1px solid var(--ulex-edge-subtle);
    border-radius: var(--ulex-radius-md);
  }
  /* Inside a code block the block draws the frame. */
  :where([data-ulex-code]) > pre { border: 0; border-radius: 0; background: none; }

  img, svg, video { max-inline-size: 100%; height: auto; }

  /* 1.1.1 -- a decorative graphic must say so; anything else needs a name.
     Made visible rather than silent, because a missing alt is invisible. */
  img:not([alt]) { outline: 2px dashed var(--ulex-tone-danger); }
}

@layer ulex.components {
  /* Clipped, never display:none -- it must stay in the accessibility tree. */
  [data-ulex-visually-hidden]:not(:focus):not(:active) {
    position: absolute;
    inline-size: 1px; block-size: 1px;
    margin: -1px; padding: 0; border: 0;
    overflow: hidden; white-space: nowrap;
    clip-path: inset(50%);
  }

  /* 1.4.8 -- measure, leading, spacing, and never justified. */
  [data-ulex-prose] {
    max-inline-size: var(--ulex-measure);
    line-height: var(--ulex-leading-prose);
    text-align: start;
  }
  [data-ulex-prose] > * + * { margin-block-start: var(--ulex-space-4); }
  [data-ulex-prose] :is(p, li) { text-align: start !important; }
  /* A quieter or louder voice for a whole passage -- a caption, a note, a
     lede. Inherited, so everything inside the passage follows. */
  [data-ulex-prose][data-ulex-emphasis] {
    color: var(--_emph, inherit);
    font-weight: var(--_emph-weight, inherit);
  }
}
/* The tone axis.
 *
 * Lives here rather than inside a component because an axis is cross-cutting:
 * button, alert, badge and tag all read the same four internal properties, so
 * `tone` means exactly the same thing on each. That is what lets an agent move
 * a value it has seen on one component onto another and be right.
 *
 * These rules only set custom properties. On an element whose component does
 * not consume them they are inert, which is why the linter -- not the CSS --
 * is what rejects an axis on a component that does not support it.
 *
 *   --_ink   text and border colour
 *   --_fill  background for fill="solid"
 *   --_on    label colour on that fill
 *   --_edge  border colour for the default fill
 */

@layer ulex.components {
  [data-ulex-tone="accent"]  { --_ink: var(--ulex-tone-accent);
                               --_fill: var(--ulex-tone-accent);
                               --_on: var(--ulex-on-tone); --_edge: var(--_fill); }
  [data-ulex-tone="danger"]  { --_ink: var(--ulex-tone-danger);
                               --_fill: var(--ulex-tone-danger);
                               --_on: var(--ulex-on-tone); --_edge: var(--_fill); }
  [data-ulex-tone="success"] { --_ink: var(--ulex-tone-success);
                               --_fill: var(--ulex-tone-success);
                               --_on: var(--ulex-on-tone); --_edge: var(--_fill); }
  [data-ulex-tone="warning"] { --_ink: var(--ulex-tone-warning);
                               --_fill: var(--ulex-tone-warning);
                               --_on: var(--ulex-on-tone); --_edge: var(--_fill); }
  [data-ulex-tone="info"]    { --_ink: var(--ulex-tone-info);
                               --_fill: var(--ulex-tone-info);
                               --_on: var(--ulex-on-tone); --_edge: var(--_fill); }
  [data-ulex-tone="neutral"] { --_ink: var(--ulex-tone-neutral);
                               --_fill: var(--ulex-neutral-fill);
                               --_on: var(--ulex-neutral-on);
                               --_edge: var(--ulex-edge-control); }
}

/* The emphasis axis.
 *
 * tone selects *meaning* -- danger, success. emphasis selects *loudness*, and
 * the two are orthogonal: a figure can be quiet without being neutral, and
 * danger stays danger whether it is shouted or muttered. Collapsing loudness
 * into tone was the alternative considered in ULX-2 and rejected, because it
 * would make the tone enum mean two things and the axes exist to each mean one.
 *
 * `quiet` replaces the text colour with the subtle level, whatever the tone;
 * `strong` only adds weight, so a toned figure keeps its colour and gets
 * louder. Unlike tone this sets `--_emph`, not `--_ink`, so a component opts in by
 * reading `var(--_emph, <its own default>)`. On a component that does not, the
 * property is inert -- the linter, not the cascade, is what rejects it.
 *
 * `normal` sets the guaranteed-invalid value, so `var(--_emph, x)` falls back
 * to `x`. That is what lets the default level mean "whatever this component
 * already does" without this file having to know what that is.
 *
 * Every level is one of the three verified text tokens. Nothing here mixes or
 * fades a colour, because a mixed colour is not a measured one, and
 * tools/contrast.mjs can only hold the library to pairs it can name. At AAA
 * --ulex-text-dim and --ulex-text-subtle converge, so `quiet` gets quieter in
 * weight but not in contrast -- which is the correct behaviour, not a bug.
 */

@layer ulex.components {
  /* strong is weight alone. Setting a colour here replaced a tone's ink with
     plain text, so danger + strong rendered as an ordinary figure -- louder in
     weight, but no longer saying danger. quiet does replace the colour, because
     muting is what it is for. */
  [data-ulex-emphasis="strong"] { --_emph: initial;
                                  --_emph-weight: var(--ulex-weight-semibold); }
  [data-ulex-emphasis="normal"] { --_emph: initial;
                                  --_emph-weight: initial; }
  [data-ulex-emphasis="quiet"]  { --_emph: var(--ulex-text-subtle);
                                  --_emph-weight: var(--ulex-weight-normal); }
}
/* Layout primitives -- the whole utility surface, and it stays this size.
 *
 * Justified by evidence rather than preference: both example pages
 * independently hand-rolled the same two things in page CSS, a vertical stack
 * and a wrapping cluster. That is the gap. A spacing-utility scale
 * (.mt-2, .mb-6, .p-4) is not, so it is not here.
 *
 * Neither invents an axis. Gap comes from `size` and horizontal placement from
 * `align`, both meaning what they already mean elsewhere -- which is also the
 * first consumer the align axis has had.
 */

@layer ulex.components {

  :where([data-ulex-stack]) {
    display: flex;
    flex-direction: column;
    gap: var(--ulex-space-4);
  }

  :where([data-ulex-row]) {
    display: flex;
    flex-wrap: wrap;          /* 1.4.10 -- a row never forces sideways scroll */
    align-items: center;
    gap: var(--ulex-space-2);
  }

  /* The gap is the spacing. A child's own block margins -- a heading's, a
     code block's -- would add to it and double the rhythm, so inside a stack
     or row they are zeroed. :where keeps this at zero specificity, so any
     component that genuinely needs a margin inside a stack still wins. */
  :where([data-ulex-stack], [data-ulex-row]) > * { margin-block: 0; }

  :is([data-ulex-stack], [data-ulex-row])[data-ulex-size="sm"] { gap: var(--ulex-space-1); }
  :is([data-ulex-stack], [data-ulex-row])[data-ulex-size="lg"] { gap: var(--ulex-space-6); }

  /* `align` is horizontal placement in both, which is what keeps it one idea:
     the main axis of a row, the cross axis of a stack. */
  [data-ulex-row][data-ulex-align="center"] { justify-content: center; }
  [data-ulex-row][data-ulex-align="end"]    { justify-content: flex-end; }
  [data-ulex-stack][data-ulex-align="center"] { align-items: center; }
  [data-ulex-stack][data-ulex-align="end"]    { align-items: flex-end; }
}
/* Shell -- nav, sidebar and main as one coordinated primitive.
 *
 * Three components that each assume they are alone is the classic bug: a
 * topnav and a sidebar styled separately, publishing neither's height, so
 * the app has to measure the nav in JavaScript into a --nav-h of its own.
 * A measured height is a missing token, and here it is a conformance
 * problem too -- 2.4.11 Focus Not Obscured needs the sticky offset to be
 * known in CSS.
 *
 * So the shell publishes --ulex-nav-h. Keeping that honest is the whole
 * design problem, because a nav that WRAPS is taller than its token says,
 * and a stale offset hides the focused element behind the header. Rather
 * than measure, the shell removes the case: above the breakpoint the nav is
 * one row and sticky, and below it the nav may wrap and is not sticky. The
 * offset is therefore only ever consulted when it is known to be correct.
 *
 * The breakpoint is 48rem, written literally: media queries cannot read
 * custom properties, so a --ulex-shell-bp token would be a token that only
 * pretends to control the layout.
 */

@layer ulex.components {

  [data-ulex-shell] {
    display: grid;
    grid-template-columns: 1fr;
    grid-template-rows: auto 1fr;
    min-block-size: 100dvh;
  }

  [data-ulex-shell]:has(> [data-ulex-sidebar]) {
    grid-template-columns: var(--ulex-sidebar-w) 1fr;
  }

  /* The documentation shell: tree, document, contents. Still one primitive —
     a third column, not a second component — so --ulex-nav-h and the sticky
     rules below go on holding for all three. */
  [data-ulex-shell]:has(> [data-ulex-toc]) {
    grid-template-columns: var(--ulex-sidebar-w) 1fr var(--ulex-toc-w);
  }
  [data-ulex-shell]:has(> [data-ulex-toc]):not(:has(> [data-ulex-sidebar])) {
    grid-template-columns: 1fr var(--ulex-toc-w);
  }
  [data-ulex-toc] { padding: var(--ulex-space-4); min-inline-size: 0; }

  /* ---------- skip link (2.4.1) ---------- */

  [data-ulex-skip-link] {
    position: absolute;
    z-index: calc(var(--ulex-z-nav) + 1);
    margin: var(--ulex-space-2);
    padding: var(--ulex-space-2) var(--ulex-space-4);
    background: var(--ulex-surface);
    color: var(--ulex-text);
    border: 1px solid var(--ulex-edge-control);
    border-radius: var(--ulex-radius-md);
    text-decoration: none;
    transform: translateY(-200%);
  }
  [data-ulex-skip-link]:focus-visible {
    transform: none;   /* 2.4.7 -- it must become visible, not just focusable */
  }

  /* ---------- nav ---------- */

  [data-ulex-nav] {
    grid-column: 1 / -1;
    display: flex;
    flex-wrap: wrap;                     /* 1.4.10 -- never scroll sideways */
    align-items: center;
    gap: var(--ulex-space-1) var(--ulex-space-3);
    min-block-size: var(--ulex-nav-h);   /* min-, not height: 1.4.12 */
    padding-inline: var(--ulex-space-4);
    padding-block: var(--ulex-space-2);
    background: var(--ulex-surface);
    border-block-end: 1px solid var(--ulex-edge-subtle);
    box-shadow: var(--ulex-shadow-sm);
  }

  [data-ulex-nav] a {
    display: inline-flex;
    align-items: center;
    min-block-size: var(--ulex-target-min);
    padding-inline: var(--ulex-space-3);
    border-radius: var(--ulex-radius-md);
    color: var(--ulex-text-dim);
    text-decoration: none;
    white-space: nowrap;
    transition: background-color var(--ulex-transition-fast),
                color var(--ulex-transition-fast);
  }
  [data-ulex-nav] a:hover {
    background: var(--ulex-surface-hi);
    color: var(--ulex-text);
  }

  /* The current item is marked by aria-current -- the same hook that
     announces it. 1.4.1: colour is not the only channel, so it also carries
     a weight change and a rule beneath it. */
  [data-ulex-nav] a[aria-current] {
    background: var(--ulex-surface-hi);
    color: var(--ulex-text);
    font-weight: var(--ulex-weight-semibold);
    box-shadow: inset 0 -2px 0 0 var(--ulex-accent);
  }

  [data-ulex-nav] [data-ulex-nav-spacer] { margin-inline-start: auto; }

  /* ---------- sidebar ---------- */

  [data-ulex-sidebar] {
    display: flex;
    flex-direction: column;
    gap: var(--ulex-space-1);
    padding: var(--ulex-space-4);
    border-inline-end: 1px solid var(--ulex-edge-subtle);
    background: var(--ulex-surface);
  }
  [data-ulex-sidebar] a {
    min-block-size: var(--ulex-target-min);
    display: flex;
    align-items: center;
    padding-inline: var(--ulex-space-3);
    border-radius: var(--ulex-radius-md);
    color: var(--ulex-text-dim);
    text-decoration: none;
  }
  [data-ulex-sidebar] a:hover { background: var(--ulex-surface-hi); color: var(--ulex-text); }
  [data-ulex-sidebar] a[aria-current] {
    background: var(--ulex-surface-hi);
    color: var(--ulex-text);
    font-weight: var(--ulex-weight-semibold);
    box-shadow: inset 2px 0 0 0 var(--ulex-accent);
  }

  /* ---------- main ---------- */

  [data-ulex-main] {
    min-inline-size: 0;                  /* 1.4.10 -- let wide children shrink */
    padding: var(--ulex-space-6) var(--ulex-space-4);
  }
  [data-ulex-main]:focus { outline: none; }   /* skip-link target, not a control */

  /* Wide content scrolls inside itself rather than scrolling the page. */
  [data-ulex-main] :is(table, pre) {
    display: block;
    max-inline-size: 100%;
    overflow-x: auto;
  }

  /* ---------- sticky, and the offset it publishes ---------- */

  @media (min-width: 48rem) {
    [data-ulex-nav] {
      position: sticky;
      inset-block-start: 0;
      z-index: var(--ulex-z-nav);
      flex-wrap: nowrap;                 /* one row, so --ulex-nav-h is true */
      overflow-x: auto;                  /* a long nav scrolls itself, not the page */
    }

    /* 2.4.11 Focus Not Obscured -- anything scrolled to under the sticky nav
       clears it. This is what --ulex-nav-h exists for. */
    [data-ulex-main] :is(:target, [id], a[href], button, input, select, textarea,
                         summary, [tabindex]:not([tabindex="-1"])) {
      scroll-margin-block-start: calc(var(--ulex-nav-h) + var(--ulex-space-3));
    }

    [data-ulex-sidebar] {
      position: sticky;
      inset-block-start: var(--ulex-nav-h);
      block-size: calc(100dvh - var(--ulex-nav-h));
      overflow-y: auto;
    }
  }

  /* Below the breakpoint the nav wraps and is static, so no stale offset can
     exist, and the sidebar stacks above main instead of beside it. */
  @media (max-width: 47.999rem) {
    [data-ulex-shell]:has(> [data-ulex-sidebar]) { grid-template-columns: 1fr; }
    [data-ulex-shell]:has(> [data-ulex-toc]) { grid-template-columns: 1fr; }
    [data-ulex-sidebar] {
      flex-direction: row;
      flex-wrap: wrap;
      border-inline-end: 0;
      border-block-end: 1px solid var(--ulex-edge-subtle);
    }
  }
}
/* Breadcrumb -- where you are, as a trail.
 *
 * A <nav> holding an <ol>, because the order is the meaning. The separator is
 * a pseudo-element, not a "/" in an aria-hidden <li>, which would be markup
 * doing a stylesheet's job and one more list item to skip past.
 *
 * The last crumb is aria-current="page" and the linter requires it. Styling
 * the current position without marking it is a 4.1.2 failure -- the highlight
 * is invisible to anyone not looking at it.
 */

@layer ulex.components {

  :where(nav[data-ulex-breadcrumb]) > ol {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ulex-space-1);
    margin: 0;
    padding: 0;
    list-style: none;
    font-size: var(--ulex-text-8);
  }

  nav[data-ulex-breadcrumb] li {
    display: flex;
    align-items: center;
    gap: var(--ulex-space-1);
    min-inline-size: 0;
  }

  nav[data-ulex-breadcrumb] li + li::before {
    content: "/";
    color: var(--ulex-text-subtle);
    /* Decorative, and never selected or announced. */
    user-select: none;
  }

  nav[data-ulex-breadcrumb] a {
    color: var(--ulex-text-dim);
    text-decoration: none;
    border-radius: var(--ulex-radius-sm);
    /* 2.5.8: a crumb is a target, so it gets the floor like any other link. */
    min-block-size: var(--ulex-target-min);
    display: inline-flex;
    align-items: center;
    padding-inline: var(--ulex-space-1);
  }
  nav[data-ulex-breadcrumb] a:hover { color: var(--ulex-text); text-decoration: underline; }

  nav[data-ulex-breadcrumb] [aria-current="page"] {
    color: var(--ulex-text);
    font-weight: var(--ulex-weight-medium);
  }

  /* A long trail truncates the crumb, not the page. 1.4.10 -- no horizontal
     scrollbar at 320px because someone has a deep hierarchy. */
  nav[data-ulex-breadcrumb] li > :is(a, span) {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}
/* Card -- a bounded surface holding related content.
 *
 * No new slot attributes: a card's parts are <header>, <footer> and the flow
 * between them, which are the elements that already mean that. Attributes are
 * only introduced where no element carries the meaning, as with the field's
 * hint and error.
 */

@layer ulex.components {

  :where([data-ulex-card]) {
    --_ink: var(--ulex-text);

    display: flex;
    flex-direction: column;
    gap: var(--ulex-space-3);
    padding: var(--ulex-space-4);
    background: var(--ulex-surface);
    border: 1px solid var(--ulex-edge-subtle);
    border-radius: var(--ulex-radius-lg);
    box-shadow: var(--ulex-shadow-sm);
  }

  [data-ulex-card] > header {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--ulex-space-3);
  }
  [data-ulex-card] > header > :is(h1, h2, h3, h4, h5, h6) {
    margin: 0;
    font-size: var(--ulex-text-4);
  }
  [data-ulex-card] > footer {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ulex-space-2);
    padding-block-start: var(--ulex-space-3);
    border-block-start: 1px solid var(--ulex-edge-subtle);
  }
  [data-ulex-card] > p { margin: 0; }

  /* A toned card marks itself with an edge rather than a wash, so the text on
     it keeps the full contrast of ordinary body copy. */
  [data-ulex-card][data-ulex-tone] { border-inline-start: 3px solid var(--_ink); }

  [data-ulex-card][data-ulex-fill="soft"] {
    background: color-mix(in srgb, var(--_ink) var(--ulex-fill-soft-mix), var(--ulex-surface));
  }
  [data-ulex-card][data-ulex-fill="outline"] {
    background: transparent;
    border-color: var(--ulex-edge-control);
    box-shadow: none;
  }
  [data-ulex-card][data-ulex-fill="ghost"] {
    background: transparent;
    border-color: transparent;
    box-shadow: none;
    padding-inline: 0;
  }

  [data-ulex-card][data-ulex-size="sm"] { padding: var(--ulex-space-3); gap: var(--ulex-space-2); }
  [data-ulex-card][data-ulex-size="lg"] { padding: var(--ulex-space-6); gap: var(--ulex-space-4); }

  /* A whole card that is a link keeps its own focus ring and target. */
  a[data-ulex-card] {
    text-decoration: none;
    color: inherit;
    min-block-size: var(--ulex-target-min);
  }
  a[data-ulex-card]:hover { border-color: var(--ulex-edge-control); }
}
/* Avatar -- a person or thing, as a picture or as initials.
 *
 * The accessible name lives on the avatar, and the <img> inside is always
 * alt="". Naming both makes a screen reader say the person twice, which is
 * the most common way this component is got wrong.
 *
 * A group reuses data-ulex-group, the same attribute buttonGroup uses. A new
 * attribute per kind of grouping would be several ways to say one thing.
 */

@layer ulex.components {

  :where([data-ulex-avatar]) {
    --_avatar-size: 2rem;

    display: inline-flex;
    flex: none;
    align-items: center;
    justify-content: center;
    inline-size: var(--_avatar-size);
    block-size: var(--_avatar-size);
    margin: 0;
    overflow: hidden;

    /* surface-hi with text-dim is a pair contrast.mjs verifies; the solid
       neutral fill is black in plain and would put dim text on it. */
    background: var(--ulex-surface-hi);
    color: var(--ulex-text-dim);
    border: 1px solid var(--ulex-edge-subtle);
    border-radius: var(--ulex-radius-full);

    font-family: var(--ulex-font-meta);
    font-size: calc(var(--_avatar-size) * 0.4);
    font-weight: var(--ulex-weight-medium);
    line-height: 1;
    text-transform: uppercase;
    user-select: none;
  }

  [data-ulex-avatar] > img {
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
    display: block;
  }

  [data-ulex-avatar][data-ulex-size="sm"] { --_avatar-size: 1.5rem; }
  [data-ulex-avatar][data-ulex-size="lg"] { --_avatar-size: 3rem; }

  /* Round is the default because a face is round. `box` is the escape hatch
     for a thing rather than a person -- a collection, an institution. */
  [data-ulex-avatar][data-ulex-shape="box"]    { border-radius: var(--ulex-radius-md); }
  [data-ulex-avatar][data-ulex-shape="square"] { border-radius: 0; }

  /* ---------- the group ---------- */

  :where([data-ulex-group="avatar"]) {
    display: inline-flex;
    align-items: center;
  }
  /* Overlap, with a ring cut out of the one behind so the edge stays legible
     on any surface. margin, not a negative gap: gap cannot go negative. */
  [data-ulex-group="avatar"] > [data-ulex-avatar] + [data-ulex-avatar] {
    margin-inline-start: calc(var(--_avatar-size, 2rem) * -0.3);
  }
  [data-ulex-group="avatar"] > [data-ulex-avatar] {
    box-shadow: 0 0 0 2px var(--ulex-surface);
  }
  [data-ulex-group="avatar"][data-ulex-size="sm"] > [data-ulex-avatar] { --_avatar-size: 1.5rem; }
  [data-ulex-group="avatar"][data-ulex-size="lg"] > [data-ulex-avatar] { --_avatar-size: 3rem; }
}
/* Button.
 *
 * Four axes, all independent: tone x fill x size x shape. Every one of the
 * 216 combinations is legal and contrast-verified, which is the point -- an
 * agent that has seen `tone` and `fill` separately must be able to write them
 * together and be right.
 *
 * Each axis sets internal properties rather than declaring its own complete
 * appearance, so the axes compose instead of overriding one another. Only
 * --ulex-* is public API; --_* is not.
 */

@layer ulex.components {

  /* :where(), not :is() -- :is() takes the specificity of its most specific
     argument, so `input[type="submit"]` (0,1,1) would outrank the tone rules
     at (0,1,0) and the tone axis would silently do nothing. :where() is 0,
     which is also what we want against app CSS. */
  :where(button, a[data-ulex-button], input[type="submit"],
         input[type="button"], input[type="reset"]) {
    --_ink:  var(--ulex-tone-neutral);
    --_fill: var(--ulex-neutral-fill);
    --_on:   var(--ulex-neutral-on);
    /* A neutral fill sits close to the page, so it cannot rely on its own
       colour for a boundary (1.4.11). Semantic tones can: they are already
       >= 4.5:1 against every surface. */
    --_edge: var(--ulex-edge-control);

    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--ulex-space-2);

    /* min-, never fixed: 1.4.12 Text Spacing. Never below the target floor,
       at any size: 2.5.8. */
    min-block-size: max(2.25rem, var(--ulex-target-min));
    min-inline-size: var(--ulex-target-min);
    padding-block: var(--ulex-space-2);
    padding-inline: var(--ulex-space-4);

    font: inherit;
    font-size: var(--ulex-text-7);
    font-weight: var(--ulex-weight-medium);
    line-height: var(--ulex-leading);
    text-align: center;
    text-decoration: none;
    white-space: nowrap;

    background: var(--_fill);
    color: var(--_on);
    border: 1px solid var(--_edge);
    border-radius: var(--ulex-radius-md);

    transition: background-color var(--ulex-transition-fast),
                border-color var(--ulex-transition-fast),
                color var(--ulex-transition-fast);
  }

  :where(button, a[data-ulex-button], input[type="submit"],
         input[type="button"], input[type="reset"]):not(:disabled) { cursor: pointer; }

  /* The tone -> ink/fill/on mapping is shared: see 02-axes.css. */

  /* ---------- fill ------------------------------------------------------- */

  /* The tint is a token because its ceiling is a property of the palette, not
     a design preference: tinting moves the background toward the text, so it
     erodes the contrast it depends on. Measured per theme; 0% at AAA, where
     soft reads as outline. */
  :is(button, a[data-ulex-button])[data-ulex-fill="soft"] {
    background: color-mix(in srgb, var(--_ink) var(--ulex-fill-soft-mix), var(--ulex-surface));
    color: var(--_ink);
    border-color: var(--ulex-edge-control);
  }

  :is(button, a[data-ulex-button])[data-ulex-fill="outline"] {
    background: transparent;
    color: var(--_ink);
    border-color: var(--_ink);
  }

  :is(button, a[data-ulex-button])[data-ulex-fill="ghost"] {
    background: transparent;
    color: var(--_ink);
    border-color: transparent;
  }

  /* ---------- size ------------------------------------------------------- */

  :is(button, a[data-ulex-button])[data-ulex-size="sm"] {
    min-block-size: max(1.75rem, var(--ulex-target-min));
    padding-block: var(--ulex-space-1);
    padding-inline: var(--ulex-space-3);
    font-size: var(--ulex-text-8);
  }
  :is(button, a[data-ulex-button])[data-ulex-size="lg"] {
    min-block-size: max(2.75rem, var(--ulex-target-min));
    padding-inline: var(--ulex-space-6);
    font-size: var(--ulex-text-5);
  }

  /* ---------- shape ------------------------------------------------------ */

  :is(button, a[data-ulex-button])[data-ulex-shape="pill"] {
    border-radius: var(--ulex-radius-full);
  }
  /* Icon-only. The linter requires an accessible name here, since a glyph is
     not a label. */
  :is(button, a[data-ulex-button])[data-ulex-shape="square"] {
    padding-inline: 0;
    aspect-ratio: 1;
    min-inline-size: max(2.25rem, var(--ulex-target-min));
  }
  [data-ulex-shape="square"][data-ulex-size="sm"] { min-inline-size: max(1.75rem, var(--ulex-target-min)); }
  [data-ulex-shape="square"][data-ulex-size="lg"] { min-inline-size: max(2.75rem, var(--ulex-target-min)); }

  /* ---------- states ----------------------------------------------------- */

  :is(button, a[data-ulex-button]):hover:not(:disabled) {
    background: color-mix(in srgb, var(--_ink) 14%, var(--ulex-surface));
    color: var(--_ink);
  }
  /* A solid fill darkens rather than tints, so the label stays on its tone. */
  :is(button, a[data-ulex-button]):not([data-ulex-fill]):hover:not(:disabled),
  :is(button, a[data-ulex-button])[data-ulex-fill="solid"]:hover:not(:disabled) {
    background: color-mix(in srgb, var(--_fill) 88%, var(--_on));
    color: var(--_on);
  }

  :is(button, a[data-ulex-button]):active:not(:disabled) { translate: 0 1px; }

  /* 1.4.3 and 1.4.11 both exempt inactive controls, so this is the one place
     low contrast is legitimate. */
  :is(button, input):disabled,
  [data-ulex-button][aria-disabled="true"] {
    opacity: 0.5;
    cursor: not-allowed;
  }

  /* ---------- group ------------------------------------------------------ */

  /* Children are the controls themselves -- no <li> wrapper, which would
     add a list level for a screen reader to announce and skip past. */
  [data-ulex-group="button"] {
    display: inline-flex;
    flex-wrap: wrap;
    gap: 0;
  }
  [data-ulex-group="button"] > :is(button, a[data-ulex-button]) { border-radius: 0; }
  /* The ends repeat the :is() above so they match its specificity and win on
     source order. As bare :first-child they lost to it, and every group was
     square at both ends. */
  [data-ulex-group="button"] > :is(button, a[data-ulex-button]):first-child {
    border-start-start-radius: var(--ulex-radius-md);
    border-end-start-radius: var(--ulex-radius-md);
  }
  [data-ulex-group="button"] > :is(button, a[data-ulex-button]):last-child {
    border-start-end-radius: var(--ulex-radius-md);
    border-end-end-radius: var(--ulex-radius-md);
  }
  [data-ulex-group="button"] > * + * { margin-inline-start: -1px; }
  [data-ulex-group="button"] > :focus-visible,
  [data-ulex-group="button"] > :hover { z-index: 1; }
}
/* Switch -- a checkbox with role="switch", never a div.
 *
 * The platform gives keyboard operation, the checked state, form participation
 * and the announced role for free. All four are commonly reimplemented and
 * none of them is reimplemented correctly, so this file only paints: the state
 * read is :checked, and there is no data attribute for it.
 *
 * The track is the target. It is held at --ulex-target-min on both axes so
 * no size can take it below the 2.5.8 floor.
 */

@layer ulex.components {

  :where(input[type="checkbox"][role="switch"]) {
    --_track-w: 2.25rem;
    --_track-h: 1.25rem;
    --_gap: 2px;

    appearance: none;
    flex: none;
    inline-size: var(--_track-w);
    block-size: var(--_track-h);
    /* The box is the painted track, at every size. It used to carry
       min-block-size: 24px, which stretched the small and medium tracks to
       the large one's height while the thumb kept its real size. The 2.5.8
       floor is met instead by its spacing exception: the block margin below
       reserves room for a 24px target circle centred on the track, so no
       neighbouring target can intrude on it. The width already clears 24px
       at every size. min-block-size is reset because the base layer gives
       every input the floor. */
    min-inline-size: var(--ulex-target-min);
    min-block-size: 0;
    margin-block: max(0px, (var(--ulex-target-min) - var(--_track-h)) / 2);

    display: inline-grid;
    grid-template-columns: 1fr;
    align-items: center;
    padding: var(--_gap);
    margin-inline: 0;
    vertical-align: middle;
    cursor: pointer;

    /* Off is a raised surface with a control edge. The solid neutral fill
       is black in plain, which reads as on. */
    background: var(--ulex-surface-hi);
    border: 1px solid var(--ulex-edge-control);
    border-radius: var(--ulex-radius-full);
    transition: background var(--ulex-transition-fast),
                border-color var(--ulex-transition-fast);
  }

  /* The thumb. A pseudo-element, so the markup stays one element. */
  input[type="checkbox"][role="switch"]::before {
    content: "";
    inline-size: calc(var(--_track-h) - var(--_gap) * 2 - 2px);
    block-size: calc(var(--_track-h) - var(--_gap) * 2 - 2px);
    border-radius: var(--ulex-radius-full);
    background: var(--ulex-text-dim);
    transform: translateX(0);
    transition: transform var(--ulex-transition-fast), background var(--ulex-transition-fast);
  }

  input[type="checkbox"][role="switch"]:checked {
    background: var(--ulex-tone-accent);
    border-color: var(--ulex-tone-accent);
  }
  input[type="checkbox"][role="switch"]:checked::before {
    background: var(--ulex-on-tone);
    transform: translateX(calc(var(--_track-w) - var(--_track-h)));
  }

  input[type="checkbox"][role="switch"]:disabled {
    opacity: 0.55;
    cursor: not-allowed;
  }

  /* 1.4.11 -- the on/off difference must survive forced colours, where our
     background is replaced. The thumb position carries it, but a border on
     the thumb is what makes the position visible against a flat canvas. */
  @media (forced-colors: active) {
    input[type="checkbox"][role="switch"]::before {
      background: ButtonText;
      border: 1px solid ButtonText;
    }
    input[type="checkbox"][role="switch"]:checked::before { background: Highlight; }
  }

  @media (prefers-reduced-motion: reduce) {
    input[type="checkbox"][role="switch"],
    input[type="checkbox"][role="switch"]::before { transition: none; }
  }

  input[type="checkbox"][role="switch"][data-ulex-size="sm"] { --_track-w: 1.875rem; --_track-h: 1rem; }
  input[type="checkbox"][role="switch"][data-ulex-size="lg"] { --_track-w: 2.75rem; --_track-h: 1.5rem; }
}
/* Dialog -- the native <dialog>, opened with showModal().
 *
 * Using the element rather than a div means Escape-to-close, the top layer,
 * the inert background and focus return are the platform's job, not ours --
 * three of the four accessibility rules the manifest states are satisfied by
 * choosing the right element. The fourth, an accessible name, is the one the
 * linter has to enforce, because nothing in the platform supplies it.
 */

@layer ulex.components {

  :where(dialog) {
    max-inline-size: min(32rem, calc(100vw - var(--ulex-space-8)));
    max-block-size: calc(100dvh - var(--ulex-space-12));
    margin: auto;                       /* centred in the top layer */
    padding: 0;
    background: var(--ulex-surface);
    color: var(--ulex-text);
    border: 1px solid var(--ulex-edge-control);
    border-radius: var(--ulex-radius-lg);
    box-shadow: var(--ulex-shadow-lg);
    overflow: hidden;
  }

  dialog::backdrop {
    background: rgb(0 0 0 / 0.55);
    backdrop-filter: blur(2px);
  }

  /* Both shapes: children of the dialog, and children of the
     <form method="dialog"> that is the usual thing inside it. */
  dialog > header, dialog > form > header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--ulex-space-4);
    padding: var(--ulex-space-4);
    border-block-end: 1px solid var(--ulex-edge-subtle);
  }
  dialog :is(h1, h2, h3) { margin: 0; font-size: var(--ulex-text-4); }

  /* The body scrolls, the header and footer stay put, so a long dialog never
     pushes its actions off-screen. */
  dialog > :is(section, p, div), dialog > form > :is(section, p, div) {
    padding: var(--ulex-space-4);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  dialog > footer, dialog > form > footer {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--ulex-space-2);
    padding: var(--ulex-space-4);
    border-block-start: 1px solid var(--ulex-edge-subtle);
  }

  dialog[data-ulex-size="sm"] { max-inline-size: min(24rem, calc(100vw - var(--ulex-space-8))); }
  dialog[data-ulex-size="lg"] { max-inline-size: min(48rem, calc(100vw - var(--ulex-space-8))); }

  /* 2.3.3 -- the entry transition is gated, and @starting-style means there is
     no JS involved in it either way. */
  @media (prefers-reduced-motion: no-preference) {
    dialog {
      opacity: 1;
      translate: 0 0;
      transition: opacity var(--ulex-transition), translate var(--ulex-transition), overlay var(--ulex-transition) allow-discrete, display var(--ulex-transition) allow-discrete;
    }
    dialog:not([open]) { opacity: 0; translate: 0 0.5rem; }
    @starting-style { dialog[open] { opacity: 0; translate: 0 0.5rem; } }
  }
}
/* Pagination -- moving between pages of a result set.
 *
 * Links, not buttons: each page is a location with a URL, and a button that
 * navigates is a button lying about what it does. The current page is
 * aria-current="page" and is deliberately not a link to itself.
 *
 * Every target is held at --ulex-target-min on both axes (2.5.8). A pager is
 * exactly where small targets are traditionally worst, and it is also where
 * mis-taps cost the most.
 */

@layer ulex.components {

  :where(nav[data-ulex-pagination]) > ul {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--ulex-space-1);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  nav[data-ulex-pagination] :is(a, span) {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-inline-size: var(--ulex-target-min);
    min-block-size: var(--ulex-target-min);
    padding-inline: var(--ulex-space-2);

    font-family: var(--ulex-font-meta);
    font-size: var(--ulex-text-8);
    font-variant-numeric: tabular-nums;
    line-height: 1;
    text-decoration: none;

    color: var(--ulex-text-dim);
    background: transparent;
    border: 1px solid var(--ulex-edge-subtle);
    border-radius: var(--ulex-radius-sm);
  }

  nav[data-ulex-pagination] a:hover {
    color: var(--ulex-text);
    border-color: var(--ulex-edge-control);
  }

  nav[data-ulex-pagination] [aria-current="page"] {
    color: var(--ulex-on-tone);
    background: var(--ulex-tone-accent);
    border-color: var(--ulex-tone-accent);
    font-weight: var(--ulex-weight-medium);
  }

  /* A gap in the sequence. Marked in the markup as an <li> with no link, and
     announced as nothing useful, so it is hidden rather than read as "dot dot
     dot" between every page. */
  nav[data-ulex-pagination] li[aria-hidden="true"] {
    color: var(--ulex-text-subtle);
    padding-inline: var(--ulex-space-1);
    user-select: none;
  }

  nav[data-ulex-pagination][data-ulex-size="sm"] :is(a, span) { font-size: 0.6875rem; }
  nav[data-ulex-pagination][data-ulex-size="lg"] :is(a, span) {
    font-size: var(--ulex-text-7);
    min-inline-size: 2rem;
    min-block-size: 2rem;
  }
}
/* Field -- label, control, hint, error, as one unit.
 *
 * The label is not decoration around an input; the association is the
 * component, which is why the linter treats an unassociated label as an error
 * rather than a warning (3.3.2), and why a placeholder standing in for a label
 * is rejected outright.
 */

@layer ulex.components {

  [data-ulex-field] {
    display: flex;
    flex-direction: column;
    gap: var(--ulex-space-2);
    /* A field is a form row, not a page-width element. */
    max-inline-size: 28rem;
  }

  [data-ulex-field] > label {
    font-size: var(--ulex-text-7);
    font-weight: var(--ulex-weight-medium);
    color: var(--ulex-text);
    line-height: var(--ulex-leading);
  }

  [data-ulex-field] :is(input:not([type="checkbox"]):not([type="radio"]),
                        select, textarea) {
    inline-size: 100%;
    min-block-size: max(2.25rem, var(--ulex-target-min));
    padding-block: var(--ulex-space-2);
    padding-inline: var(--ulex-space-3);
    font: inherit;
    font-size: var(--ulex-text-7);
    color: var(--ulex-text);
    background: var(--ulex-bg);
    /* 1.4.11 -- a control boundary, so --ulex-edge-control, never
       --ulex-edge-subtle. That distinction is why both source palettes
       failed this criterion. */
    border: 1px solid var(--ulex-edge-control);
    border-radius: var(--ulex-radius-md);
    transition: border-color var(--ulex-transition-fast);
  }

  [data-ulex-field] textarea {
    min-block-size: 5rem;
    resize: vertical;
    line-height: var(--ulex-leading);
  }

  [data-ulex-field] select {
    appearance: none;
    padding-inline-end: var(--ulex-space-8);
    background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
                      linear-gradient(135deg, currentColor 50%, transparent 50%);
    background-position: right var(--ulex-space-4) center,
                         right calc(var(--ulex-space-4) - 4px) center;
    background-size: 4px 4px, 4px 4px;
    background-repeat: no-repeat;
  }

  /* Placeholders are live text, so 1.4.3 applies -- hence --ulex-text-subtle,
     which is contrast-verified, and not a "faint" token. A placeholder is
     never a substitute for a label. */
  [data-ulex-field] ::placeholder {
    color: var(--ulex-text-subtle);
    opacity: 1;   /* Firefox dims placeholders by default */
  }

  [data-ulex-field] :is(input, select, textarea):hover:not(:disabled) {
    border-color: var(--ulex-edge-strong);
  }

  /* ---------- checkbox and radio ---------- */

  [data-ulex-field] :is(input[type="checkbox"], input[type="radio"]) {
    inline-size: 1rem;
    block-size: 1rem;
    min-block-size: 0;
    min-inline-size: 0;
    margin: 0;
    accent-color: var(--ulex-accent);
    flex: none;
  }

  /* The control is small, so the LABEL carries the target: the whole row is
     clickable and clears 24px, rather than the 16px box alone (2.5.8). */
  [data-ulex-field] [data-ulex-field-row] {
    display: flex;
    align-items: center;
    gap: var(--ulex-space-3);
    min-block-size: var(--ulex-target-min);
    cursor: pointer;
  }
  [data-ulex-field] [data-ulex-field-row] > label {
    font-weight: var(--ulex-weight-normal);
    cursor: pointer;
  }

  /* ---------- hint and error ---------- */

  [data-ulex-hint] {
    font-size: var(--ulex-text-8);
    color: var(--ulex-text-dim);
  }

  /* The message is text, which is what satisfies 1.4.1: the error is never
     signalled by the red border alone. aria-describedby links it to the
     control, which the linter enforces alongside aria-invalid (3.3.1). */
  [data-ulex-error] {
    display: flex;
    align-items: flex-start;
    gap: var(--ulex-space-2);
    font-size: var(--ulex-text-8);
    font-weight: var(--ulex-weight-medium);
    color: var(--ulex-tone-danger);
  }
  [data-ulex-error]::before {
    content: "!";
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    inline-size: 1rem;
    block-size: 1rem;
    border: 1px solid currentColor;
    border-radius: var(--ulex-radius-full);
    font-size: var(--ulex-text-8);
    line-height: 1;
  }

  [data-ulex-field] :is(input, select, textarea)[aria-invalid="true"] {
    border-color: var(--ulex-tone-danger);
  }

  /* ---------- size ---------- */

  [data-ulex-field][data-ulex-size="sm"] :is(input:not([type="checkbox"]):not([type="radio"]),
                                             select, textarea) {
    min-block-size: max(1.75rem, var(--ulex-target-min));
    padding-block: var(--ulex-space-1);
    font-size: var(--ulex-text-8);
  }
  [data-ulex-field][data-ulex-size="lg"] :is(input:not([type="checkbox"]):not([type="radio"]),
                                             select, textarea) {
    min-block-size: max(2.75rem, var(--ulex-target-min));
    font-size: var(--ulex-text-5);
  }

  /* ---------- fieldset ---------- */

  [data-ulex-field] fieldset {
    margin: 0;
    padding: 0;
    border: 0;
    display: flex;
    flex-direction: column;
    gap: var(--ulex-space-2);
  }
  [data-ulex-field] legend {
    padding: 0;
    font-size: var(--ulex-text-7);
    font-weight: var(--ulex-weight-medium);
  }

  [data-ulex-field] :is(input, select, textarea):disabled { opacity: 0.5; cursor: not-allowed; }
}
/* Accordion -- native <details>/<summary>.
 *
 * The disclosure state is [open], which is both the styling hook and what the
 * platform announces. That is the whole reason the vocabulary forbids
 * data-ulex-open: the thing that makes it look expanded is the same thing that
 * says it is expanded.
 */

@layer ulex.components {

  :where(details) {
    border: 1px solid var(--ulex-edge-subtle);
    border-radius: var(--ulex-radius-md);
    background: var(--ulex-surface);
  }
  details + details { margin-block-start: var(--ulex-space-2); }

  details > summary {
    display: flex;
    align-items: center;
    gap: var(--ulex-space-2);
    min-block-size: var(--ulex-target-min);   /* 2.5.8 -- summary is the control */
    padding: var(--ulex-space-2) var(--ulex-space-3);
    font-weight: var(--ulex-weight-medium);
    cursor: pointer;
    list-style: none;
  }
  details > summary::-webkit-details-marker { display: none; }

  /* Our own marker, so it can rotate and match the type colour. */
  details > summary::before {
    content: "";
    flex: none;
    inline-size: 0; block-size: 0;
    border-block: 4px solid transparent;
    border-inline-start: 5px solid currentColor;
    transition: rotate var(--ulex-transition-fast);
  }
  details[open] > summary::before { rotate: 90deg; }
  details > summary:hover { background: var(--ulex-surface-hi); }

  details > :not(summary) {
    padding: 0 var(--ulex-space-3) var(--ulex-space-3);
  }
  details[open] > summary { border-block-end: 1px solid var(--ulex-edge-subtle); }
  details[open] > :not(summary):first-of-type { padding-block-start: var(--ulex-space-3); }

  details[data-ulex-size="sm"] > summary { padding: var(--ulex-space-1) var(--ulex-space-2); font-size: var(--ulex-text-8); }
  details[data-ulex-size="lg"] > summary { padding: var(--ulex-space-3) var(--ulex-space-4); font-size: var(--ulex-text-5); }
}
/* Alert -- a message about the state of the page or an action.
 *
 * The tone chooses the colour AND the live-region role: danger and warning
 * are interruptive (role="alert"), everything else is polite (role="status").
 * The linter enforces that pairing from the manifest, because a tone that
 * looks urgent while announcing politely is a lie to one set of users.
 */

@layer ulex.components {

  :where([data-ulex-alert]) {
    --_ink:  var(--ulex-text);
    --_fill: var(--ulex-neutral-fill);
    --_on:   var(--ulex-neutral-on);

    display: flex;
    align-items: flex-start;
    gap: var(--ulex-space-3);
    padding: var(--ulex-space-3) var(--ulex-space-4);

    font-size: var(--ulex-text-7);
    line-height: var(--ulex-leading);
    color: var(--ulex-text);

    /* Default is the soft tint: an alert is a region, not a control, so it
       reads better as a wash than as a block of tone. */
    background: color-mix(in srgb, var(--_ink) var(--ulex-fill-soft-mix), var(--ulex-surface));
    border: 1px solid var(--ulex-edge-control);
    border-inline-start: 3px solid var(--_ink);
    border-radius: var(--ulex-radius-md);
  }

  /* The text is what carries the meaning (1.4.1); the bar and the tint only
     reinforce it. An alert with no text is a lint error, not a design choice. */
  [data-ulex-alert] > :is(p, div, span):last-child { margin-block: 0; }
  [data-ulex-alert] strong { color: var(--_ink); }

  [data-ulex-alert][data-ulex-fill="solid"] {
    background: var(--_fill);
    color: var(--_on);
    border-color: var(--_fill);
  }
  [data-ulex-alert][data-ulex-fill="solid"] strong { color: inherit; }

  [data-ulex-alert][data-ulex-fill="outline"] {
    background: transparent;
    border-color: var(--_ink);
  }

  [data-ulex-alert][data-ulex-fill="ghost"] {
    background: transparent;
    border-color: transparent;
    border-inline-start-color: var(--_ink);
    padding-inline: var(--ulex-space-3);
  }
}
/* Progress and meter -- the native elements.
 *
 * <progress> is a task advancing; <meter> is a measurement within a known
 * range. Both already carry their value in the accessibility tree, so the only
 * rule the linter adds is the accessible name, which the platform cannot infer.
 *
 * The fill takes the tone axis, and every tone is verified at >= 4.5:1 against
 * every surface -- comfortably past the 3:1 that 1.4.11 asks of the indicator
 * against its track.
 */

@layer ulex.components {

  :where(progress, meter) {
    --_ink: var(--ulex-accent);

    appearance: none;
    inline-size: 100%;
    block-size: var(--ulex-bar-h);
    border: 0;
    border-radius: var(--ulex-radius-full);
    background: var(--ulex-surface-hi);
    overflow: hidden;
  }

  /* Vendor pseudo-elements cannot be grouped: a selector list containing one
     the engine does not know is dropped whole, so each gets its own rule. */
  progress::-webkit-progress-bar { background: var(--ulex-surface-hi); border-radius: var(--ulex-radius-full); }
  progress::-webkit-progress-value { background: var(--_ink); border-radius: var(--ulex-radius-full); }
  progress::-moz-progress-bar { background: var(--_ink); border-radius: var(--ulex-radius-full); }

  meter::-webkit-meter-bar { background: var(--ulex-surface-hi); border: 0; border-radius: var(--ulex-radius-full); }
  meter::-webkit-meter-optimum-value { background: var(--ulex-tone-success); border-radius: var(--ulex-radius-full); }
  meter::-webkit-meter-suboptimum-value { background: var(--ulex-tone-warning); border-radius: var(--ulex-radius-full); }
  meter::-webkit-meter-even-less-good-value { background: var(--ulex-tone-danger); border-radius: var(--ulex-radius-full); }
  meter:-moz-meter-optimum::-moz-meter-bar { background: var(--ulex-tone-success); }
  meter:-moz-meter-sub-optimum::-moz-meter-bar { background: var(--ulex-tone-warning); }
  meter:-moz-meter-sub-sub-optimum::-moz-meter-bar { background: var(--ulex-tone-danger); }

  :is(progress, meter)[data-ulex-size="sm"] { block-size: 0.25rem; }
  :is(progress, meter)[data-ulex-size="lg"] { block-size: 0.75rem; }

  /* An indeterminate <progress> has no value to announce, so it animates --
     and stops animating when the user has asked for less motion. */
  @media (prefers-reduced-motion: no-preference) {
    progress:not([value])::-webkit-progress-bar {
      background: linear-gradient(90deg, var(--ulex-surface-hi) 40%, var(--_ink) 50%, var(--ulex-surface-hi) 60%);
      background-size: 300% 100%;
      animation: ulex-indeterminate 1.4s linear infinite;
    }
    @keyframes ulex-indeterminate { from { background-position: 150% 0; } to { background-position: -50% 0; } }
  }
}
/* Skeleton -- the shape of content that has not arrived.
 *
 * Decorative by contract, and the linter enforces it: every skeleton is
 * aria-hidden="true". Marking each one role="status" instead announces
 * nothing at all (the element is empty) and creates one live region per
 * placeholder, so a loading card could register five.
 *
 * The announcement belongs to the region that is loading, once:
 *
 *   <div aria-busy="true" aria-describedby="l">
 *     <p id="l" data-ulex-visually-hidden>Loading results</p>
 *     <div data-ulex-skeleton="line" aria-hidden="true"></div>
 *   </div>
 */

@layer ulex.components {

  :where([data-ulex-skeleton]) {
    display: block;
    /* The one per-element value a placeholder needs, declared as an input so
       it is the only thing style may set here. */
    inline-size: var(--ulex-skeleton-w, 100%);
    /* A raised surface, not --ulex-neutral-fill: that is the solid neutral
       fill, which in plain is near-black. */
    background: var(--ulex-surface-hi);
    border-radius: var(--ulex-radius-sm);
    /* Never a target and never selectable: there is nothing there yet. */
    user-select: none;
    pointer-events: none;
  }

  [data-ulex-skeleton="line"] {
    block-size: 0.75em;
    margin-block: 0.25em;
    border-radius: var(--ulex-radius-full);
  }
  [data-ulex-skeleton="block"] {
    block-size: 100%;
    min-block-size: 3rem;
    border-radius: var(--ulex-radius-md);
  }

  /* The shimmer. A background sweep rather than an opacity pulse, because a
     pulse flashes the whole block and 2.3.1 counts flashes. */
  @media (prefers-reduced-motion: no-preference) {
    [data-ulex-skeleton] {
      background-image: linear-gradient(90deg,
        transparent 0%,
        color-mix(in srgb, var(--ulex-text-subtle) 12%, transparent) 50%,
        transparent 100%);
      background-size: 200% 100%;
      animation: ulex-skeleton 1.4s ease-in-out infinite;
    }
    @keyframes ulex-skeleton {
      from { background-position: 200% 0; }
      to   { background-position: -200% 0; }
    }
  }
}
/* Badge -- a small non-interactive status marker ("Passed", "3 open").
 *
 * The difference from tag is not visual, it is behavioural: a badge states a
 * fact about the thing beside it and is never clickable, so it carries no
 * target-size floor. A tag is a label you can act on. Keeping them apart
 * means neither has to grow an "interactive" flag.
 */

@layer ulex.components {

  :where([data-ulex-badge]) {
    --_ink:  var(--ulex-text-dim);
    --_fill: var(--ulex-neutral-fill);
    --_on:   var(--ulex-neutral-on);

    display: inline-flex;
    align-items: center;
    gap: var(--ulex-space-1);
    padding-block: 0.125rem;
    padding-inline: var(--ulex-space-2);

    font-size: var(--ulex-text-8);
    font-weight: var(--ulex-weight-medium);
    font-variant-numeric: tabular-nums;
    line-height: var(--ulex-leading);
    white-space: nowrap;

    background: var(--_fill);
    color: var(--_on);
    border: 1px solid transparent;
    border-radius: var(--ulex-radius-sm);
  }

  [data-ulex-badge][data-ulex-fill="soft"] {
    background: color-mix(in srgb, var(--_ink) var(--ulex-fill-soft-mix), var(--ulex-surface));
    color: var(--_ink);
    border-color: var(--ulex-edge-subtle);
  }
  [data-ulex-badge][data-ulex-fill="outline"] {
    background: transparent;
    color: var(--_ink);
    border-color: var(--_ink);
  }
  [data-ulex-badge][data-ulex-fill="ghost"] {
    background: transparent;
    color: var(--_ink);
    border-color: transparent;
    padding-inline: 0;
  }

  [data-ulex-badge][data-ulex-size="sm"] { font-size: 0.6875rem; padding-inline: var(--ulex-space-1); }
  [data-ulex-badge][data-ulex-size="lg"] { font-size: var(--ulex-text-7); padding-inline: var(--ulex-space-3); }

  [data-ulex-badge][data-ulex-shape="pill"]   { border-radius: var(--ulex-radius-full); }
  /* A count marker: square, centred, equal on both axes. */
  [data-ulex-badge][data-ulex-shape="square"] {
    padding-inline: 0;
    min-inline-size: 1.25rem;
    aspect-ratio: 1;
    justify-content: center;
  }
}
/* Spinner -- a picture of waiting.
 *
 * Always aria-hidden, and the linter enforces it. The spinner says only that
 * something is happening; what is happening is the job of the text beside it
 * and of aria-busy on the region. A spinner that announces itself announces
 * "image", which helps nobody.
 *
 *   <span data-ulex-row>
 *     <span data-ulex-spinner aria-hidden="true"></span>
 *     <span>Fetching results</span>
 *   </span>
 *
 * 2.2.2 is why there is no full-page blocking variant: an indefinite spinner
 * over the whole page is unstoppable moving content with nothing behind it.
 */

@layer ulex.components {

  :where([data-ulex-spinner]) {
    --_spinner-size: 1rem;
    --_ink: var(--ulex-text-dim);

    display: inline-block;
    flex: none;
    inline-size: var(--_spinner-size);
    block-size: var(--_spinner-size);
    vertical-align: middle;

    border: 2px solid color-mix(in srgb, var(--_ink) 25%, transparent);
    border-block-start-color: var(--_ink);
    border-radius: var(--ulex-radius-full);
    animation: ulex-spin 700ms linear infinite;
  }

  @keyframes ulex-spin { to { transform: rotate(1turn); } }

  /* 2.3.3 -- reduced motion gets a pulse in place of rotation, not nothing:
     the element must still say "working". The pulse is opacity between two
     values well short of invisible, so it never reads as a flash. */
  @media (prefers-reduced-motion: reduce) {
    [data-ulex-spinner] {
      animation: ulex-spin-still 1.6s ease-in-out infinite;
      border-block-start-color: var(--_ink);
    }
    @keyframes ulex-spin-still {
      0%, 100% { opacity: 1; }
      50%      { opacity: 0.45; }
    }
  }

  [data-ulex-spinner][data-ulex-size="sm"] { --_spinner-size: 0.75rem; border-width: 1.5px; }
  [data-ulex-spinner][data-ulex-size="lg"] { --_spinner-size: 1.5rem; border-width: 3px; }
}
/* Tabs.
 *
 * The state is entirely ARIA: aria-selected on the tab, [hidden] on the panel.
 * There is no data-ulex-active, because the thing that makes a tab look
 * chosen has to be the thing that announces it as chosen.
 *
 * Without JavaScript every panel stays visible, stacked under its tab. That is
 * degraded but whole -- no content is hidden behind a control that no longer
 * works, which is the failure mode of hiding panels in the markup.
 */

@layer ulex.components {

  /* Styled from the ARIA roles themselves: role="tablist" already says what
     this is, so a second attribute saying it again would be a way to get it
     wrong. The tablist carries size and sync; its tabs reach their panels
     through aria-controls. */
  [role="tablist"] {
    display: flex;
    flex-wrap: wrap;              /* 1.4.10 -- tabs wrap, they never scroll the page */
    gap: var(--ulex-space-1);
    border-block-end: 1px solid var(--ulex-edge-subtle);
  }

  [role="tablist"] [role="tab"] {
    --_ink: var(--ulex-accent);

    min-block-size: max(2rem, var(--ulex-target-min));
    padding: var(--ulex-space-2) var(--ulex-space-3);
    background: transparent;
    color: var(--ulex-text-dim);
    font: inherit;
    font-size: var(--ulex-text-7);
    font-weight: var(--ulex-weight-medium);
    border: 0;
    border-block-end: 2px solid transparent;
    margin-block-end: -1px;
    border-radius: var(--ulex-radius-sm) var(--ulex-radius-sm) 0 0;
    cursor: pointer;
    white-space: nowrap;
  }
  [role="tablist"] [role="tab"]:hover { color: var(--ulex-text); background: var(--ulex-surface-hi); }

  /* Selected is marked twice over: colour, and a rule beneath. Colour alone
     would fail 1.4.1 for the same reason it does everywhere else. */
  [role="tablist"] [role="tab"][aria-selected="true"] {
    color: var(--ulex-text);
    border-block-end-color: var(--_ink);
    font-weight: var(--ulex-weight-semibold);
  }

  [role="tabpanel"] { padding-block-start: var(--ulex-space-4); }
  [role="tabpanel"]:focus { outline: none; }

  [role="tablist"][data-ulex-size="sm"] [role="tab"] {
    min-block-size: max(1.75rem, var(--ulex-target-min));
    padding: var(--ulex-space-1) var(--ulex-space-2);
    font-size: var(--ulex-text-8);
  }
  [role="tablist"][data-ulex-size="lg"] [role="tab"] {
    min-block-size: max(2.5rem, var(--ulex-target-min));
    padding: var(--ulex-space-3) var(--ulex-space-4);
    font-size: var(--ulex-text-5);
  }
}
/* Dropdown -- a menu hung off a trigger.
 *
 * Built on the popover API, which supplies the top layer, light dismiss and
 * Escape without a line of script. It is styled from what it already is -- a
 * popover with role="menu" -- so the markup needs no attribute of ulex's own,
 * and no wrapper: the trigger reaches it through popovertarget.
 *
 * The script adds only what the platform does not: arrow-key movement between
 * items, aria-expanded on the trigger, and placement that stays inside the
 * viewport. With no script at all the menu still opens and closes, because
 * popovertarget is markup.
 */

@layer ulex.components {

  :where([popover][role="menu"]) {
    /* Positioned by the script, which is why these start at zero rather than
       at a guess: an unplaced menu in the top layer would otherwise flash in
       the middle of the viewport before the first measurement. */
    position: fixed;
    inset: auto;
    margin: 0;
    z-index: var(--ulex-z-dropdown);

    min-inline-size: 10rem;
    max-inline-size: min(20rem, calc(100vw - var(--ulex-space-4)));
    padding: var(--ulex-space-1);

    background: var(--ulex-surface-hi);
    color: var(--ulex-text);
    border: 1px solid var(--ulex-edge-subtle);
    border-radius: var(--ulex-radius-md);
    box-shadow: var(--ulex-shadow-lg);
  }

  [popover][role="menu"]::backdrop { background: transparent; }

  [popover][role="menu"] > :is(button, a) {
    display: flex;
    align-items: center;
    gap: var(--ulex-space-2);
    inline-size: 100%;
    /* 2.5.8 -- a menu item is a target like any other. */
    min-block-size: var(--ulex-target-min);
    padding: var(--ulex-space-1) var(--ulex-space-2);

    font: inherit;
    font-size: var(--ulex-text-8);
    text-align: start;
    text-decoration: none;
    color: var(--ulex-text-dim);

    background: transparent;
    border: 0;
    border-radius: var(--ulex-radius-sm);
    cursor: pointer;
  }

  [popover][role="menu"] > :is(button, a):hover,
  [popover][role="menu"] > :is(button, a):focus-visible {
    background: var(--ulex-surface);
    color: var(--ulex-text);
  }

  [popover][role="menu"] > :is(button, a)[data-ulex-tone] { color: var(--_ink); }

  [popover][role="menu"] > hr {
    margin: var(--ulex-space-1) 0;
    border: 0;
    border-block-start: 1px solid var(--ulex-edge-subtle);
  }
}
/* Tag -- a label you can act on: a filter, a category, a removable chip.
 *
 * Unlike badge, a tag may be interactive, so an interactive one takes the
 * 2.5.8 target floor. A removable tag's close control is a real <button> with
 * its own accessible name -- the linter rejects the glyph-only version, since
 * a multiplication sign is not "remove Manuscripts".
 */

@layer ulex.components {

  :where([data-ulex-tag]) {
    --_ink:  var(--ulex-text-dim);
    --_fill: var(--ulex-neutral-fill);
    --_on:   var(--ulex-neutral-on);

    display: inline-flex;
    align-items: center;
    gap: var(--ulex-space-2);
    padding-block: var(--ulex-space-1);
    padding-inline: var(--ulex-space-3);

    font-size: var(--ulex-text-8);
    line-height: var(--ulex-leading);
    text-decoration: none;
    white-space: nowrap;

    background: transparent;
    color: var(--_ink);
    border: 1px solid var(--_ink);
    border-radius: var(--ulex-radius-full);
  }

  /* Interactive tags clear the target floor; static ones do not need to. */
  :where(a[data-ulex-tag], button[data-ulex-tag], label[data-ulex-tag]) {
    min-block-size: var(--ulex-target-min);
    cursor: pointer;
  }
  :is(a, button, label)[data-ulex-tag]:hover {
    background: color-mix(in srgb, var(--_ink) var(--ulex-fill-soft-mix), var(--ulex-surface));
  }

  [data-ulex-tag][data-ulex-fill="solid"] {
    background: var(--_fill);
    color: var(--_on);
    border-color: var(--_fill);
  }
  [data-ulex-tag][data-ulex-fill="soft"] {
    background: color-mix(in srgb, var(--_ink) var(--ulex-fill-soft-mix), var(--ulex-surface));
    border-color: var(--ulex-edge-control);
  }
  [data-ulex-tag][data-ulex-fill="ghost"] { border-color: transparent; }

  [data-ulex-tag][data-ulex-size="sm"] { font-size: 0.6875rem; padding-inline: var(--ulex-space-2); }
  [data-ulex-tag][data-ulex-size="lg"] { font-size: var(--ulex-text-7); padding-inline: var(--ulex-space-4); }

  [data-ulex-tag][data-ulex-shape="box"]    { border-radius: var(--ulex-radius-md); }
  [data-ulex-tag][data-ulex-shape="square"] {
    padding-inline: 0;
    min-inline-size: var(--ulex-target-min);
    aspect-ratio: 1;
    justify-content: center;
    border-radius: var(--ulex-radius-md);
  }

  /* Selected state comes from the platform: aria-pressed on a button tag,
     :checked on a wrapped input. There is no data-ulex-selected. */
  [data-ulex-tag][aria-pressed="true"],
  [data-ulex-tag]:has(input:checked) {
    background: color-mix(in srgb, var(--_ink) var(--ulex-fill-soft-mix), var(--ulex-surface));
    font-weight: var(--ulex-weight-semibold);
    box-shadow: inset 0 0 0 1px var(--_ink);
  }
  [data-ulex-tag] > input[type="checkbox"],
  [data-ulex-tag] > input[type="radio"] {
    position: absolute;
    inline-size: 1px; block-size: 1px;
    margin: -1px; overflow: hidden; clip-path: inset(50%);
  }
  [data-ulex-tag]:has(input:focus-visible) {
    outline: var(--ulex-ring-width) solid var(--ulex-ring);
    outline-offset: var(--ulex-ring-offset);
  }

  /* Remove control inside a tag. */
  [data-ulex-tag] > button:not([data-ulex-tag]) {
    min-block-size: var(--ulex-target-min);
    min-inline-size: var(--ulex-target-min);
    margin-inline-end: calc(var(--ulex-space-2) * -1);
    padding: 0;
    background: transparent;
    color: inherit;
    border: 0;
    border-radius: var(--ulex-radius-full);
    line-height: 1;
  }
  [data-ulex-tag] > button:not([data-ulex-tag]):hover {
    background: color-mix(in srgb, currentColor 20%, transparent);
  }
}
/* Toast -- a transient message, announced once.
 *
 * One live region per placement, created on first use. The tone chooses the
 * politeness exactly as it does for alert, so an urgent message is not queued
 * politely behind whatever the user was already hearing.
 *
 * 2.2.1 is the reason for three things that are not optional here: every toast
 * carries a close button, the timer pauses on hover and on focus, and a
 * duration of 0 means it stays until dismissed. Content that disappears on a
 * timer with no way to hold it is a failure, and a toast is the most common
 * place to commit it.
 */

@layer ulex.components {

  :where([data-ulex-toast-region]) {
    position: fixed;
    z-index: var(--ulex-z-modal);
    display: flex;
    flex-direction: column;
    gap: var(--ulex-space-2);
    padding: var(--ulex-space-4);
    /* The region spans the edge so toasts line up, but only the toasts
       themselves take the pointer -- the rest of the page stays clickable. */
    pointer-events: none;
    max-inline-size: min(26rem, 100vw);
  }
  [data-ulex-toast-region] > * { pointer-events: auto; }

  [data-ulex-toast-region][data-ulex-toast-placement^="top"]    { inset-block-start: 0; }
  [data-ulex-toast-region][data-ulex-toast-placement^="bottom"] { inset-block-end: 0;
                                                                  flex-direction: column-reverse; }
  [data-ulex-toast-region][data-ulex-toast-placement$="start"]  { inset-inline-start: 0; }
  [data-ulex-toast-region][data-ulex-toast-placement$="end"]    { inset-inline-end: 0; }
  [data-ulex-toast-region][data-ulex-toast-placement$="center"] {
    inset-inline: 0;
    margin-inline: auto;
    align-items: center;
  }

  :where([data-ulex-toast]) {
    --_ink: var(--ulex-text-dim);

    display: flex;
    align-items: flex-start;
    gap: var(--ulex-space-3);
    inline-size: 100%;
    padding: var(--ulex-space-3) var(--ulex-space-4);

    font-size: var(--ulex-text-8);
    line-height: var(--ulex-leading);

    background: var(--ulex-surface-hi);
    color: var(--ulex-text);
    border: 1px solid var(--ulex-edge-subtle);
    /* The tone as an edge rather than a wash: it must read at AA against the
       surface, and a coloured stripe is a second channel the text can keep. */
    border-inline-start: 3px solid var(--_ink);
    border-radius: var(--ulex-radius-md);
    box-shadow: var(--ulex-shadow-lg);
  }

  [data-ulex-toast] > [data-ulex-toast-close] {
    flex: none;
    margin-inline-start: auto;
    min-inline-size: var(--ulex-target-min);
    min-block-size: var(--ulex-target-min);
    display: inline-flex;
    align-items: center;
    justify-content: center;

    font: inherit;
    color: var(--ulex-text-subtle);
    background: transparent;
    border: 0;
    border-radius: var(--ulex-radius-sm);
    cursor: pointer;
  }
  [data-ulex-toast] > [data-ulex-toast-close]:hover { color: var(--ulex-text); }

  @media (prefers-reduced-motion: no-preference) {
    [data-ulex-toast] { animation: ulex-toast-in var(--ulex-transition) both; }
    @keyframes ulex-toast-in {
      from { opacity: 0; transform: translateY(-0.25rem); }
      to   { opacity: 1; transform: none; }
    }
  }
}
/* Tooltip -- opt-in, never derived from [title].
 *
 * Turning every [title] into a tooltip is two failures at once: a hidden
 * ::after still counts toward scrollWidth, giving a permanent horizontal
 * scrollbar (1.4.10), and a CSS-only tooltip cannot be dismissed with Escape
 * (1.4.13).
 *
 * So this is opt-in, and it is JavaScript, because 1.4.13 asks for three
 * behaviours CSS cannot provide: dismissible without moving focus, hoverable,
 * and persistent until dismissed.
 */

@layer ulex.components {

  [data-ulex-tooltip-bubble] {
    position: fixed;
    z-index: var(--ulex-z-modal);
    max-inline-size: 22rem;
    margin: 0;
    padding: var(--ulex-space-2) var(--ulex-space-3);
    background: var(--ulex-surface-hi);
    color: var(--ulex-text);
    font-size: var(--ulex-text-8);
    line-height: var(--ulex-leading);
    border: 1px solid var(--ulex-edge-control);
    border-radius: var(--ulex-radius-md);
    box-shadow: var(--ulex-shadow-md);
    /* Placed by script, so it can be clamped into the viewport rather than
       overflowing it -- the whole point of not doing this in CSS. */
    inset-block-start: 0;
    inset-inline-start: 0;
  }

  [data-ulex-tooltip-bubble][hidden] { display: none; }
}
/* Table.
 *
 * The dense-data half of the library. Numeric columns are the reason
 * --ulex-font-meta is a semantic role rather than decoration: figures want
 * tabular numerals and a monospaced face so digits line up down the column,
 * and a theme that maps meta to mono gets that everywhere at once.
 */

@layer ulex.components {

  :where(table) {
    inline-size: 100%;
    border-collapse: collapse;
    font-size: var(--ulex-text-7);
    background: var(--ulex-surface);
    border: 1px solid var(--ulex-edge-subtle);
    border-radius: var(--ulex-radius-md);
  }

  :where(table) :is(th, td) {
    padding: var(--ulex-space-2) var(--ulex-space-3);
    text-align: start;
    vertical-align: top;
  }

  :where(table) thead th {
    font-size: var(--ulex-text-8);
    font-weight: var(--ulex-weight-semibold);
    color: var(--ulex-text-dim);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    white-space: nowrap;
    border-block-end: 1px solid var(--ulex-edge-control);
  }

  :where(table) tbody tr { border-block-end: 1px solid var(--ulex-edge-subtle); }
  :where(table) tbody tr:last-child { border-block-end: 0; }
  :where(table) tbody tr:hover { background: var(--ulex-surface-hi); }
  :where(table) th[scope="row"] { font-weight: var(--ulex-weight-semibold); }

  /* Padded like a cell. Every table in main is display:block so it can scroll
     sideways (1.4.10), and a block table draws its border around the caption
     too -- with only a bottom pad, the caption sat flush against that border. */
  :where(table) caption {
    padding: var(--ulex-space-2) var(--ulex-space-3);
    color: var(--ulex-text-dim);
    font-size: var(--ulex-text-8);
    text-align: start;
  }

  /* ---------- cell modifiers ---------- */

  /* Figures: right-aligned, tabular, and as narrow as their content, so the
     text column takes the remaining width. */
  [data-ulex-cell="numeric"] {
    text-align: end;
    font-family: var(--ulex-font-meta);
    font-variant-numeric: tabular-nums;
    inline-size: 1%;
    white-space: nowrap;
  }
  /* A numeric cell takes its colour from the tone axis, like everything else:
     <td data-ulex-cell="numeric" data-ulex-tone="danger">. */
  [data-ulex-cell="numeric"][data-ulex-tone] { color: var(--_ink); }

  /* Loudness, from the emphasis axis. It is declared after the tone rule and
     with equal specificity on purpose: on a cell that carries both, quiet
     wins, because a muttered figure is quiet before it is red. This is the
     `.num.dim` and `.num.nil` helical was hand-rolling. */
  [data-ulex-cell][data-ulex-emphasis] { font-weight: var(--_emph-weight, inherit); }
  /* Only quiet recolours, so a strong danger figure keeps the tone rule's ink. */
  [data-ulex-cell][data-ulex-emphasis="quiet"] { color: var(--_emph); }

  /* Identifiers, timestamps, shelfmarks -- quieter, and in the meta face. */
  [data-ulex-cell="meta"] {
    font-family: var(--ulex-font-meta);
    font-size: var(--ulex-text-8);
    color: var(--ulex-text-dim);
    white-space: nowrap;
  }

  /* ---------- sorting: aria-sort, not an invented attribute ---------- */

  th[aria-sort] { cursor: pointer; }
  th[aria-sort]::after {
    content: "";
    display: inline-block;
    inline-size: 0; block-size: 0;
    margin-inline-start: var(--ulex-space-2);
    border-inline: 3px solid transparent;
  }
  th[aria-sort="ascending"]::after  { border-block-end: 4px solid currentColor; }
  th[aria-sort="descending"]::after { border-block-start: 4px solid currentColor; }
  th[aria-sort="none"]::after       { border-block-end: 4px solid var(--ulex-edge-strong); }

  /* ---------- size ---------- */

  table[data-ulex-size="sm"] :is(th, td) {
    padding: var(--ulex-space-1) var(--ulex-space-2);
    font-size: var(--ulex-text-8);
  }
  table[data-ulex-size="lg"] :is(th, td) { padding: var(--ulex-space-3) var(--ulex-space-4); }

  /* A sticky header sticks inside the table's own box, not to the page.
     Every table in main is its own horizontal scroll container, for reflow
     (1.4.10), and a sticky cell inside a scroll container sticks to that
     container -- so offsetting it by --ulex-nav-h pushed the header 48px down
     inside the table, over the first row. Instead the table scrolls on both
     axes within a bounded height and the header sticks to its top. */
  table[data-ulex-sticky-head] {
    display: block;
    max-block-size: min(70dvh, 40rem);
    overflow: auto;
  }
  table[data-ulex-sticky-head] thead th {
    position: sticky;
    inset-block-start: 0;
    z-index: 1;
    background: var(--ulex-surface);
  }
}
/* Stacked proportional bar.
 *
 * A bar is a graphic, so 1.1.1 applies: it needs role="img" and a name that
 * states the values as text. The linter requires both -- a bar whose meaning
 * exists only as coloured widths is unreadable to anyone not looking at it,
 * and the segments are distinguished by colour alone, which is precisely what
 * 1.4.1 forbids as a sole channel.
 *
 *   <div data-ulex-bar role="img" aria-label="12 now, 5 next, 3 blocked">
 *     <span data-ulex-bar-segment data-ulex-tone="success" style="--ulex-bar-part: 12"></span>
 *     <span data-ulex-bar-segment data-ulex-tone="info"    style="--ulex-bar-part: 5"></span>
 *     <span data-ulex-bar-segment data-ulex-tone="warning" style="--ulex-bar-part: 3"></span>
 *   </div>
 */

@layer ulex.components {

  :where([data-ulex-bar]) {
    display: flex;
    block-size: var(--ulex-bar-h);
    overflow: hidden;
    background: var(--ulex-surface-hi);
    border-radius: var(--ulex-radius-full);
  }

  /* A segment carries an explicit attribute rather than being matched as "any
     child": it has no semantic element to stand on, which is the same reason
     field declares hint and error but styles bare <label> and <input>.
     Proportions come from a custom property rather than a width, so the
     segments always fill the track exactly whatever the totals are. */
  [data-ulex-bar-segment] {
    flex: var(--ulex-bar-part);
    min-inline-size: 0;
    background: var(--_ink, var(--ulex-edge-strong));
  }

  [data-ulex-bar][data-ulex-size="sm"] { block-size: 0.25rem; }
  [data-ulex-bar][data-ulex-size="lg"] { block-size: 0.75rem; }

  /* In a table, the bar column takes a fixed share so bars align down it. */
  [data-ulex-cell="bar"] { inline-size: 34%; min-inline-size: 6rem; }
}
/* Stat -- a single figure with its label.
 *
 * The label is not optional decoration: a bare number carries no meaning, and
 * a tone applied to the number would then be colour as the only channel
 * (1.4.1). The linter requires the visible label for that reason.
 */

@layer ulex.components {

  :where([data-ulex-stat]) {
    --_ink: var(--ulex-text);

    display: flex;
    flex-direction: column;
    gap: var(--ulex-space-1);
    flex: 1 1 7.5rem;
    padding: var(--ulex-space-3) var(--ulex-space-4);
    background: var(--ulex-surface);
    border: 1px solid var(--ulex-edge-subtle);
    border-radius: var(--ulex-radius-md);
  }

  [data-ulex-stat-value] {
    font-family: var(--ulex-font-meta);
    font-size: var(--ulex-text-3);
    font-weight: var(--ulex-weight-semibold);
    font-variant-numeric: tabular-nums;
    line-height: 1.1;
    /* emphasis before tone: a de-emphasised figure is the point of the axis,
       and `.figure.dim` is what helical wanted. --_emph is unset at the
       default level, so an untouched stat still takes its tone ink. */
    color: var(--_emph, var(--_ink));
    font-weight: var(--_emph-weight, var(--ulex-weight-semibold));
  }

  [data-ulex-stat-label] {
    font-size: var(--ulex-text-8);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--ulex-text-dim);
  }

  [data-ulex-stat][data-ulex-size="sm"] { padding: var(--ulex-space-2) var(--ulex-space-3); }
  [data-ulex-stat][data-ulex-size="sm"] [data-ulex-stat-value] { font-size: var(--ulex-text-4); }
  [data-ulex-stat][data-ulex-size="lg"] [data-ulex-stat-value] { font-size: var(--ulex-text-2); }
}
