/*! @imx/ui v0.1.0 — the IMX design system.
 * GENERATED: tokens from tokens/tokens.json, rules concatenated from src/.
 * Edit those, then run `node pkgs/ui/build.mjs`. Do not edit dist/ by hand.
 * Docs: docs/ui/README.md
 */
/* GENERATED by pkgs/ui/build.mjs from tokens/tokens.json — do not edit.
 *
 * THREE states, not two. An explicit viewer choice stamps an attribute on the
 * root element; the DEFAULT "system" setting stamps NOTHING, and there only
 * prefers-color-scheme separates light from dark. So: bare :root carries the
 * complete light palette, the media query re-values under a :not(...="light")
 * guard so an explicit light choice beats a dark OS, and the [...="dark"]
 * block re-values again so a toggle wins in the other direction. A colour
 * whose only definition sits inside a media or attribute block never applies
 * in the un-stamped state — that is the classic unreadable-page bug.
 *
 * TWO attributes are honoured. `data-imx-theme` is ours; plain `data-theme`
 * is accepted because that is what an app author reaches for by reflex. The
 * prefixed one exists because the portal already stamps data-theme="imx" for
 * daisyUI during the migration, and "imx" is neither "light" nor "dark" — a
 * viewer on a dark OS would otherwise get the dark palette on a portal that
 * is deliberately light-only until the plan's last phase. The portal stamps
 * data-imx-theme="light" to opt out; an app that wants to follow the system
 * stamps nothing.
 */
:root {
  color-scheme: light;
  --imx-bg: #FBFBFC;
  --imx-bg-panel: #F6F6F9;
  --imx-surface: #FFFFFF;
  --imx-surface-raised: #FFFFFF;
  --imx-surface-sunken: #F4F4F7;
  --imx-surface-hover: #F0F0F5;
  --imx-surface-active: #E8E7EF;
  --imx-surface-selected: #EEE9FD;
  --imx-scrim: rgba(24,21,38,.45);
  --imx-text: #232030;
  --imx-text-muted: #6B6880;
  --imx-text-faint: #9C99AE;
  --imx-on-solid: #FFFFFF;
  --imx-border: #E4E3EB;
  --imx-border-strong: #D2D0DD;
  --imx-action: #6D46E8;
  --imx-action-hover: #5C36D6;
  --imx-action-active: #4E2CBC;
  --imx-action-soft: #8B6FF0;
  --imx-action-text: #6D46E8;
  --imx-action-weak: #EEE9FD;
  --imx-action-weak-brd: #DCD2FA;
  --imx-action-disabled: #B7B2C6;
  --imx-on-action: #FFFFFF;
  --imx-neutral: #2F2C3D;
  --imx-on-neutral: #FFFFFF;
  --imx-success: #3E9A63;
  --imx-success-weak: #E6F3EA;
  --imx-success-text: #2B7047;
  --imx-info: #3B82F6;
  --imx-info-weak: #E5EEFE;
  --imx-info-text: #1D4FB0;
  --imx-warning: #F5A623;
  --imx-warning-weak: #FDF0D9;
  --imx-warning-text: #8A5800;
  --imx-error: #E5484D;
  --imx-error-weak: #FCE9EA;
  --imx-error-text: #B02A2F;
  --imx-code-bg: #232030;
  --imx-code-text: #E9E7F2;
  --imx-kbd-bg: #F0F0F5;
  --imx-doc-bg: #EEE9FD;
  --imx-doc-fg: #5C36D6;
  --imx-sheet-bg: #E4F3EA;
  --imx-sheet-fg: #2B7047;
  --imx-deck-bg: #FCEEE2;
  --imx-deck-fg: #B4570C;
  --imx-pdf-bg: #FCE9EA;
  --imx-pdf-fg: #B02A2F;
  --imx-file-bg: #ECEBF1;
  --imx-file-fg: #5D5A70;
  --imx-folder-bg: #E7EDF6;
  --imx-folder-fg: #3A628F;
  --imx-archive-bg: #F3EEE2;
  --imx-archive-fg: #7F5E14;
  --imx-image-bg: #E6EFFA;
  --imx-image-fg: #2C67AC;
  --imx-video-bg: #F1E9FB;
  --imx-video-fg: #6F35B0;
  --imx-audio-bg: #E4F2F0;
  --imx-audio-fg: #12756A;
  --imx-code-role-bg: #ECEDF1;
  --imx-code-role-fg: #4C5563;
  --imx-person-bg: #F0E9FC;
  --imx-person-fg: #7135D6;
  --imx-shadow-1: 0 1px 2px rgba(24,21,38,.05), 0 1px 3px rgba(24,21,38,.06);
  --imx-shadow-2: 0 2px 4px rgba(24,21,38,.05), 0 4px 12px rgba(24,21,38,.09);
  --imx-shadow-3: 0 4px 8px rgba(24,21,38,.06), 0 12px 32px rgba(24,21,38,.14);
  --imx-ring: 0 0 0 3px var(--imx-action-weak-brd);

  /* scale + type + motion + layout — theme-independent */
  --imx-font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  --imx-font-display: 'Source Serif 4', Georgia, 'Times New Roman', serif;
  --imx-font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
  --imx-fs-2xs: 11px;
  --imx-fs-xs: 12px;
  --imx-fs-sm: 13px;
  --imx-fs-md: 14px;
  --imx-fs-lg: 15px;
  --imx-fs-xl: 17px;
  --imx-fs-2xl: 21px;
  --imx-fs-3xl: 24px;
  --imx-lh-tight: 1.25;
  --imx-lh-base: 1.5;
  --imx-lh-prose: 1.65;
  --imx-lh-chat: 1.4;
  --imx-fw-normal: 400;
  --imx-fw-medium: 500;
  --imx-fw-semi: 600;
  --imx-fw-bold: 700;
  --imx-sp-1: 4px;
  --imx-sp-2: 8px;
  --imx-sp-3: 12px;
  --imx-sp-4: 16px;
  --imx-sp-5: 20px;
  --imx-sp-6: 24px;
  --imx-sp-8: 32px;
  --imx-sp-10: 40px;
  --imx-sp-12: 48px;
  --imx-r-xs: 6px;
  --imx-r-sm: 8px;
  --imx-r-md: 12px;
  --imx-r-lg: 18px;
  --imx-r-pill: 26px;
  --imx-r-full: 999px;
  --imx-dur-fast: 100ms;
  --imx-dur-base: 160ms;
  --imx-dur-slow: 260ms;
  --imx-ease: cubic-bezier(.2,0,.2,1);
  --imx-rail-w: 16rem;
  --imx-topbar-h: 3.5rem;
  --imx-content-max: 64rem;
  --imx-prose-max: 44rem;
  --imx-chat-col: 42rem;
  --imx-rail-recents-min: 9rem;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-imx-theme="light"]):not([data-theme="light"]):not([data-theme="imx"]) {
    color-scheme: dark;
    --imx-bg: #16151C;
    --imx-bg-panel: #1A1922;
    --imx-surface: #22212B;
    --imx-surface-raised: #2A2934;
    --imx-surface-sunken: #121118;
    --imx-surface-hover: #2A2833;
    --imx-surface-active: #33313E;
    --imx-surface-selected: #2C2547;
    --imx-scrim: rgba(6,5,10,.62);
    --imx-text: #ECEAF3;
    --imx-text-muted: #A5A2B6;
    --imx-text-faint: #74718A;
    --imx-on-solid: #FFFFFF;
    --imx-border: #32303D;
    --imx-border-strong: #46434F;
    --imx-action: #6D46E8;
    --imx-action-hover: #7E5CEC;
    --imx-action-active: #8E71F0;
    --imx-action-soft: #8B6FF0;
    --imx-action-text: #A58EF1;
    --imx-action-weak: #2C2547;
    --imx-action-weak-brd: #443A66;
    --imx-action-disabled: #55516A;
    --imx-on-action: #FFFFFF;
    --imx-neutral: #E8E5F2;
    --imx-on-neutral: #1B1A22;
    --imx-success: #6FC08C;
    --imx-success-weak: #1E3327;
    --imx-success-text: #8ED4A6;
    --imx-info: #6FA8FA;
    --imx-info-weak: #16294A;
    --imx-info-text: #9CC4FC;
    --imx-warning: #E7B04E;
    --imx-warning-weak: #38290F;
    --imx-warning-text: #F0C879;
    --imx-error: #F3767A;
    --imx-error-weak: #3B1D20;
    --imx-error-text: #F79A9D;
    --imx-code-bg: #121118;
    --imx-code-text: #E9E7F2;
    --imx-kbd-bg: #2A2833;
    --imx-doc-bg: #2C2547;
    --imx-doc-fg: #B3A0F6;
    --imx-sheet-bg: #14301F;
    --imx-sheet-fg: #7BC79A;
    --imx-deck-bg: #37260F;
    --imx-deck-fg: #EFA765;
    --imx-pdf-bg: #3B1D20;
    --imx-pdf-fg: #F0888C;
    --imx-file-bg: #282631;
    --imx-file-fg: #A9A5B9;
    --imx-folder-bg: #182333;
    --imx-folder-fg: #8FB6E2;
    --imx-archive-bg: #2E2717;
    --imx-archive-fg: #D8BC7A;
    --imx-image-bg: #152538;
    --imx-image-fg: #88B6E8;
    --imx-video-bg: #251838;
    --imx-video-fg: #BE96EC;
    --imx-audio-bg: #12302C;
    --imx-audio-fg: #78C8BE;
    --imx-code-role-bg: #252831;
    --imx-code-role-fg: #A6AEBD;
    --imx-person-bg: #2A1E42;
    --imx-person-fg: #C3A5F2;
    --imx-shadow-1: 0 1px 2px rgba(0,0,0,.4), 0 1px 3px rgba(0,0,0,.34);
    --imx-shadow-2: 0 2px 4px rgba(0,0,0,.42), 0 4px 12px rgba(0,0,0,.46);
    --imx-shadow-3: 0 4px 8px rgba(0,0,0,.44), 0 12px 32px rgba(0,0,0,.56);
    --imx-ring: 0 0 0 3px var(--imx-action-weak-brd);
  }
}

:root[data-imx-theme="dark"],
:root[data-theme="dark"] {
  color-scheme: dark;
  --imx-bg: #16151C;
  --imx-bg-panel: #1A1922;
  --imx-surface: #22212B;
  --imx-surface-raised: #2A2934;
  --imx-surface-sunken: #121118;
  --imx-surface-hover: #2A2833;
  --imx-surface-active: #33313E;
  --imx-surface-selected: #2C2547;
  --imx-scrim: rgba(6,5,10,.62);
  --imx-text: #ECEAF3;
  --imx-text-muted: #A5A2B6;
  --imx-text-faint: #74718A;
  --imx-on-solid: #FFFFFF;
  --imx-border: #32303D;
  --imx-border-strong: #46434F;
  --imx-action: #6D46E8;
  --imx-action-hover: #7E5CEC;
  --imx-action-active: #8E71F0;
  --imx-action-soft: #8B6FF0;
  --imx-action-text: #A58EF1;
  --imx-action-weak: #2C2547;
  --imx-action-weak-brd: #443A66;
  --imx-action-disabled: #55516A;
  --imx-on-action: #FFFFFF;
  --imx-neutral: #E8E5F2;
  --imx-on-neutral: #1B1A22;
  --imx-success: #6FC08C;
  --imx-success-weak: #1E3327;
  --imx-success-text: #8ED4A6;
  --imx-info: #6FA8FA;
  --imx-info-weak: #16294A;
  --imx-info-text: #9CC4FC;
  --imx-warning: #E7B04E;
  --imx-warning-weak: #38290F;
  --imx-warning-text: #F0C879;
  --imx-error: #F3767A;
  --imx-error-weak: #3B1D20;
  --imx-error-text: #F79A9D;
  --imx-code-bg: #121118;
  --imx-code-text: #E9E7F2;
  --imx-kbd-bg: #2A2833;
  --imx-doc-bg: #2C2547;
  --imx-doc-fg: #B3A0F6;
  --imx-sheet-bg: #14301F;
  --imx-sheet-fg: #7BC79A;
  --imx-deck-bg: #37260F;
  --imx-deck-fg: #EFA765;
  --imx-pdf-bg: #3B1D20;
  --imx-pdf-fg: #F0888C;
  --imx-file-bg: #282631;
  --imx-file-fg: #A9A5B9;
  --imx-folder-bg: #182333;
  --imx-folder-fg: #8FB6E2;
  --imx-archive-bg: #2E2717;
  --imx-archive-fg: #D8BC7A;
  --imx-image-bg: #152538;
  --imx-image-fg: #88B6E8;
  --imx-video-bg: #251838;
  --imx-video-fg: #BE96EC;
  --imx-audio-bg: #12302C;
  --imx-audio-fg: #78C8BE;
  --imx-code-role-bg: #252831;
  --imx-code-role-fg: #A6AEBD;
  --imx-person-bg: #2A1E42;
  --imx-person-fg: #C3A5F2;
  --imx-shadow-1: 0 1px 2px rgba(0,0,0,.4), 0 1px 3px rgba(0,0,0,.34);
  --imx-shadow-2: 0 2px 4px rgba(0,0,0,.42), 0 4px 12px rgba(0,0,0,.46);
  --imx-shadow-3: 0 4px 8px rgba(0,0,0,.44), 0 12px 32px rgba(0,0,0,.56);
  --imx-ring: 0 0 0 3px var(--imx-action-weak-brd);
}

/* ===== src/base.css ===================================================== */
/* The base layer: the safe floor, the one focus treatment, and the prose
   rules.
 *
 * TWO TIERS, and the split is load-bearing.
 *
 * Tier 1 — global. Rules that cannot surprise an existing page: box-sizing
 * (which Tailwind's preflight already sets identically), the focus ring,
 * ::selection, [hidden], [x-cloak], the sr-only/skip utilities, .imx-icon,
 * .imx-prose, and reduced-motion. All of these are either idempotent with
 * preflight or scoped to a class.
 *
 * Tier 2 — scoped to `.imx-base` on <body> (or <html>). The opinionated
 * ELEMENT defaults: body ground and type, bare <a> colour, <hr>, media.
 * These are what an app consuming imx.css standalone needs so a plain page
 * does not look broken — and they are exactly what would silently restyle the
 * portal, which loads this stylesheet AFTER Tailwind. Preflight sets
 * `a { color: inherit }`; an unscoped `a { color: var(--imx-action) }` here
 * would turn every unstyled link in the portal violet, including the rail's
 * account row. Scoping is what lets the portal adopt the components in Phase
 * 1 without adopting a restyle it did not ask for. It opts in by adding the
 * class, one page at a time.
 */

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

.imx-base {
  margin: 0;
  /* A transparent body borrows whatever ground the host paints — inside the
     connect shell, in dark mode, that is the wrong one. Always paint. */
  background: var(--imx-bg);
  color: var(--imx-text);
  font-family: var(--imx-font-ui);
  font-size: var(--imx-fs-lg);
  line-height: var(--imx-lh-base);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Just the ground, for a full-page surface that does NOT want tier 2's
 * element restyle. The portal is the case: it already loads Tailwind's
 * preflight, so adopting `.imx-base` would resize its type — but its <body>
 * was left transparent, and a transparent body means the CANVAS is unpainted.
 * That is what an overscroll rubber-band and a mobile browser's chrome tint
 * show, so a dark portal flashed white at the edges. A background on <body>
 * propagates to the canvas when <html> has none, which is why this is enough.
 *
 * The colour is here too, for the text nobody classed. There is none in the
 * portal today (measured, every visible string is tokenised) — it is the
 * default that keeps it that way. */
.imx-ground { margin: 0; background: var(--imx-bg); color: var(--imx-text); }

/* One focus treatment for the whole system. Before this the portal had seven
   focus-style occurrences across ~50 templates and relied on UA defaults for
   everything else, which on a custom-background control is invisible. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--imx-action);
  outline-offset: 2px;
  border-radius: var(--imx-r-xs);
}
:where(a, button, input, select, textarea, summary):focus:not(:focus-visible) { outline: none; }

::selection { background: var(--imx-action-weak); color: var(--imx-text); }

[hidden] { display: none !important; }

/* Alpine's x-cloak. Every portal document had to declare this by hand and one
   of them forgot; it belongs with the framework. */
[x-cloak] { display: none !important; }

/* `:not([class])` — the base layer styles BARE elements only.
 *
 * Without it this rule is specificity (0,1,1) and beats every component that
 * happens to be an anchor: `.imx-artifact { color: inherit }` is (0,1,0), so
 * an artifact card's title came out link-violet on any page carrying
 * `.imx-base`. Raising each component's specificity to win would be a losing
 * arms race; declining to style anything that already has a class is the rule
 * that actually holds.
 *
 * This is what "a floor, not a theme" means: the base layer exists so a plain
 * <a> in prose is legible, and it should have no opinion about an element a
 * component already owns. */
.imx-base a:not([class]) { color: var(--imx-action); text-decoration: none; }
.imx-base a:not([class]):hover { text-decoration: underline; }

.imx-base hr:not([class]) { border: 0; border-top: 1px solid var(--imx-border); margin: var(--imx-sp-4) 0; }

.imx-base code, .imx-base kbd, .imx-base samp, .imx-base pre { font-family: var(--imx-font-mono); }

.imx-base img, .imx-base svg, .imx-base video, .imx-base canvas { max-width: 100%; }

/* The screen-reader-only utility. The portal had zero of these; a status dot
   whose only signal is colour needs one. */
.imx-sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* Skip link: visually hidden until focused, then a real control. */
.imx-skip {
  position: absolute; left: var(--imx-sp-2); top: var(--imx-sp-2);
  transform: translateY(-200%);
  z-index: 100;
  background: var(--imx-surface-raised); color: var(--imx-text);
  border: 1px solid var(--imx-border-strong); border-radius: var(--imx-r-sm);
  padding: var(--imx-sp-2) var(--imx-sp-3);
  box-shadow: var(--imx-shadow-2);
  transition: transform var(--imx-dur-base) var(--imx-ease);
}
.imx-skip:focus { transform: translateY(0); text-decoration: none; }

.imx-icon { width: 1.25em; height: 1.25em; flex: none; }
.imx-icon--sm { width: 1em; height: 1em; }
.imx-icon--lg { width: 1.5em; height: 1.5em; }

/* Rendered agent/markdown prose. Kept small on purpose — a transcript, not an
   article. Every value is a token, including the deliberately-dark code block
   (approved in the chat mockups: a fenced block reads as a terminal excerpt). */
.imx-prose { line-height: var(--imx-lh-prose); }
.imx-prose > :first-child { margin-top: 0; }
.imx-prose > :last-child { margin-bottom: 0; }
.imx-prose p { margin: 0 0 .75em; }
.imx-prose h1, .imx-prose h2, .imx-prose h3,
.imx-prose h4, .imx-prose h5, .imx-prose h6 {
  font-family: var(--imx-font-display);
  font-weight: var(--imx-fw-semi);
  line-height: var(--imx-lh-tight);
  margin: 1.2em 0 .5em;
}
.imx-prose h1 { font-size: 1.25em; }
.imx-prose h2 { font-size: 1.15em; }
.imx-prose h3 { font-size: 1.05em; }
.imx-prose ul, .imx-prose ol { margin: 0 0 .75em; padding-left: 1.4em; }
.imx-prose ul { list-style: disc; }
.imx-prose ol { list-style: decimal; }
.imx-prose li { margin: .15em 0; }
.imx-prose li::marker { color: var(--imx-text-faint); }
.imx-prose li > ul, .imx-prose li > ol { margin-bottom: 0; }
.imx-prose a { color: var(--imx-action); text-decoration: underline; }
.imx-prose strong { font-weight: var(--imx-fw-semi); }
.imx-prose blockquote {
  margin: 0 0 .75em; padding-left: .9em;
  border-left: 2px solid var(--imx-border-strong);
  color: var(--imx-text-muted);
}
.imx-prose code {
  font-size: .9em;
  background: var(--imx-surface-hover);
  color: var(--imx-action);
  padding: .1em .35em;
  border-radius: var(--imx-r-xs);
  /* A backticked path or id has no break opportunity, so on a phone one of
     them widens the whole message past the column. break-word alone will not
     split a single unbroken token; anywhere will. */
  overflow-wrap: anywhere;
}
.imx-prose pre {
  margin: 0 0 .75em; padding: .75em .9em;
  background: var(--imx-code-bg); color: var(--imx-code-text);
  border-radius: var(--imx-r-sm);
  overflow-x: auto;
}
.imx-prose pre code { background: none; padding: 0; font-size: .8125em; color: inherit; }
.imx-prose table {
  /* width:100%, NOT max-content. max-content is the width at which no cell
     wraps, which for prose cells is enormous — measured on a phone, a
     two-column answer table wanted 442px inside a 341px column. Filling the
     column and letting cells wrap is the right trade in a transcript;
     overflow-x stays as the fallback for genuinely too many columns. */
  display: block; overflow-x: auto; width: 100%;
  margin: 0 0 .75em; border-collapse: collapse; font-size: .9em;
}
.imx-prose th, .imx-prose td {
  border: 1px solid var(--imx-border);
  padding: .35em .6em; text-align: left; vertical-align: top;
  overflow-wrap: anywhere;
}
.imx-prose th { background: var(--imx-surface-hover); font-weight: var(--imx-fw-semi); }
.imx-prose hr { border-top: 1px solid var(--imx-border); margin: 1em 0; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ===== src/components.css ===================================================== */
/* The component vocabulary.
 *
 * Naming: .imx-<component>[--variant][--size]. State is expressed with the
 * attribute that already carries it — [aria-busy], [aria-invalid],
 * [aria-current], :disabled, [open] — never a second class, so a server
 * template and a React binding produce identical markup without agreeing on a
 * state-class convention.
 *
 * Every colour here is a token. A hardcoded hex in this file is a bug, and
 * test/tokens.test.mjs fails the build on one.
 */

/* ============================================================ button */

.imx-btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--imx-sp-2);
  /* nowrap is load-bearing: daisyUI's .btn sets flex-wrap:wrap, which broke a
     button carrying an avatar plus a name onto two lines. */
  flex-wrap: nowrap; white-space: nowrap;
  padding: 0 var(--imx-sp-4); height: 2.25rem;
  font-family: inherit; font-size: var(--imx-fs-md); font-weight: var(--imx-fw-medium);
  line-height: 1;
  border: 1px solid transparent; border-radius: var(--imx-r-sm);
  background: var(--imx-surface-hover); color: var(--imx-text);
  cursor: pointer; text-decoration: none;
  transition: background var(--imx-dur-fast) var(--imx-ease),
              border-color var(--imx-dur-fast) var(--imx-ease),
              color var(--imx-dur-fast) var(--imx-ease);
}
.imx-btn:hover { background: var(--imx-surface-active); text-decoration: none; }
.imx-btn:disabled, .imx-btn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
.imx-btn--primary { background: var(--imx-action); color: var(--imx-on-action); }
.imx-btn--primary:hover { background: var(--imx-action-hover); }
.imx-btn--secondary { background: var(--imx-surface); color: var(--imx-text); border-color: var(--imx-border-strong); }
.imx-btn--secondary:hover { background: var(--imx-surface-hover); }
/* The high-contrast NON-accent action: near-black on light, near-white on
   dark. The accent is scarce by rule, and the sign-in pages' one button was
   slate-900 long before there was an accent — this is that button, inverted
   correctly instead of frozen at one colour. The rail's current entry uses
   the same token pair. */
.imx-btn--neutral { background: var(--imx-neutral); color: var(--imx-on-neutral); border-color: transparent; }
.imx-btn--neutral:hover { opacity: .9; }
.imx-btn--ghost { background: transparent; color: var(--imx-text-muted); }
.imx-btn--ghost:hover { background: var(--imx-surface-hover); color: var(--imx-text); }
.imx-btn--danger { background: var(--imx-error); color: var(--imx-on-solid); }
.imx-btn--danger:hover { filter: brightness(.93); }
.imx-btn--link { background: transparent; color: var(--imx-action-text); padding: 0; height: auto; border: 0; }
.imx-btn--link:hover { background: transparent; text-decoration: underline; }

/* A link INSIDE a sentence, which `.imx-btn--link` cannot be: that one is
   `inline-flex` and `white-space: nowrap`, so mid-paragraph it refuses to
   wrap and pushes the line. Use `.imx-btn--link` for a control that happens
   to look like a link; use this for prose. */
.imx-link { color: var(--imx-action-text); text-decoration: underline; }
.imx-link:hover { color: var(--imx-action-hover); }
.imx-btn--sm { height: 1.875rem; padding: 0 var(--imx-sp-3); font-size: var(--imx-fs-sm); }
.imx-btn--lg { height: 2.75rem; padding: 0 var(--imx-sp-5); font-size: var(--imx-fs-lg); }
.imx-btn--block { width: 100%; }
.imx-btn--pill { border-radius: var(--imx-r-full); }

/* Loading. aria-busy is the state; the spinner is its rendering, so a screen
   reader and the eye learn the same thing from one attribute. */
.imx-btn[aria-busy="true"] { pointer-events: none; }
.imx-btn[aria-busy="true"]::before {
  content: ""; width: 1em; height: 1em; flex: none;
  border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%;
  animation: imx-spin .7s linear infinite;
}
@keyframes imx-spin { to { transform: rotate(360deg); } }

.imx-icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem; flex: none;
  border: 1px solid transparent; border-radius: var(--imx-r-sm);
  background: transparent; color: var(--imx-text-muted);
  cursor: pointer; padding: 0;
  transition: background var(--imx-dur-fast) var(--imx-ease), color var(--imx-dur-fast) var(--imx-ease);
}
.imx-icon-btn:hover { background: var(--imx-surface-hover); color: var(--imx-text); }
.imx-icon-btn--sm { width: 1.75rem; height: 1.75rem; }
.imx-icon-btn--solid { background: var(--imx-action); color: var(--imx-on-action); }
.imx-icon-btn--solid:hover { background: var(--imx-action-hover); color: var(--imx-on-action); }
.imx-icon-btn--round { border-radius: var(--imx-r-full); }
.imx-icon-btn:disabled { opacity: .5; cursor: not-allowed; }
/* A tap target under 44px is the most common mobile defect in the portal. */
@media (pointer: coarse) {
  .imx-icon-btn { min-width: 2.75rem; min-height: 2.75rem; }
  .imx-btn { min-height: 2.75rem; }
  .imx-btn--sm { min-height: 2.5rem; }
}

/* ============================================================= form */

.imx-field { display: flex; flex-direction: column; gap: var(--imx-sp-1); }
.imx-label { font-size: var(--imx-fs-sm); font-weight: var(--imx-fw-medium); color: var(--imx-text); }
.imx-hint { font-size: var(--imx-fs-xs); color: var(--imx-text-muted); }
.imx-error-text { font-size: var(--imx-fs-xs); color: var(--imx-error-text); }
.imx-success-text { font-size: var(--imx-fs-xs); color: var(--imx-success-text); }

.imx-input, .imx-select, .imx-textarea {
  width: 100%;
  font-family: inherit; font-size: var(--imx-fs-md); color: var(--imx-text);
  background: var(--imx-surface);
  border: 1px solid var(--imx-border-strong); border-radius: var(--imx-r-sm);
  padding: 0 var(--imx-sp-3); height: 2.375rem;
  transition: border-color var(--imx-dur-fast) var(--imx-ease), box-shadow var(--imx-dur-fast) var(--imx-ease);
}
.imx-textarea { height: auto; min-height: 5rem; padding: var(--imx-sp-2) var(--imx-sp-3); line-height: var(--imx-lh-base); resize: vertical; }
.imx-select {
  appearance: none;
  /* Two gradients rather than a background-image data URI, so the chevron
     takes a token colour and follows the theme with no dark-mode rule. */
  background-image: linear-gradient(45deg, transparent 50%, var(--imx-text-muted) 50%),
                    linear-gradient(135deg, var(--imx-text-muted) 50%, transparent 50%);
  background-position: calc(100% - 16px) 52%, calc(100% - 11px) 52%;
  background-size: 5px 5px, 5px 5px; background-repeat: no-repeat;
  padding-right: var(--imx-sp-8);
}
.imx-input::placeholder, .imx-textarea::placeholder { color: var(--imx-text-faint); }
.imx-input:focus, .imx-select:focus, .imx-textarea:focus {
  outline: none; border-color: var(--imx-action); box-shadow: var(--imx-ring);
}
.imx-input[aria-invalid="true"], .imx-textarea[aria-invalid="true"], .imx-select[aria-invalid="true"] { border-color: var(--imx-error); }
.imx-input:disabled, .imx-select:disabled, .imx-textarea:disabled {
  background: var(--imx-surface-hover); color: var(--imx-text-faint); cursor: not-allowed;
}
.imx-input--mono { font-family: var(--imx-font-mono); font-size: var(--imx-fs-sm); }

/* A compact field, for a control that sits inline in a toolbar row next to an
   `.imx-btn--sm` rather than in a stacked `.imx-field`. Height and padding
   only — the font size is deliberately untouched, because the 16px floor
   below is Safari's zoom threshold and a "small" field is exactly the one a
   phone would zoom into. */
.imx-input--sm, .imx-select--sm, .imx-textarea--sm {
  height: 1.875rem; padding-left: var(--imx-sp-2); padding-right: var(--imx-sp-2);
  font-size: var(--imx-fs-sm);
}
.imx-select--sm { padding-right: var(--imx-sp-6); background-position: calc(100% - 12px) 52%, calc(100% - 7px) 52%; }
.imx-textarea--sm { height: auto; min-height: 3.5rem; padding-top: var(--imx-sp-1); padding-bottom: var(--imx-sp-1); }

/* iOS Safari zooms the page when a field smaller than 16px takes focus, and
   it does not zoom back out. Our chrome size is 14px, so every text field in
   the portal would do it. The templates this framework replaced each carried
   `text-base sm:text-sm` for exactly this reason — one rule here is that
   comment, made structural. Not a token, deliberately: 16px is Safari's
   threshold, not a size in our scale. */
@media (max-width: 640px) {
  .imx-input, .imx-select, .imx-textarea, .imx-composer__input,
  .imx-input--sm, .imx-select--sm, .imx-textarea--sm { font-size: 16px; }
}

.imx-toggle {
  appearance: none; flex: none; cursor: pointer;
  width: 2.25rem; height: 1.25rem; border-radius: var(--imx-r-full);
  background: var(--imx-border-strong); position: relative;
  transition: background var(--imx-dur-base) var(--imx-ease);
}
.imx-toggle::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 1rem; height: 1rem; border-radius: 50%;
  background: var(--imx-surface); box-shadow: var(--imx-shadow-1);
  transition: transform var(--imx-dur-base) var(--imx-ease);
}
.imx-toggle:checked { background: var(--imx-success); }
.imx-toggle:checked::after { transform: translateX(1rem); }
.imx-toggle:disabled { opacity: .5; cursor: not-allowed; }

.imx-check {
  appearance: none; flex: none; cursor: pointer;
  width: 1.125rem; height: 1.125rem;
  border: 1.5px solid var(--imx-border-strong); border-radius: var(--imx-r-xs);
  background: var(--imx-surface); position: relative;
  transition: background var(--imx-dur-fast) var(--imx-ease), border-color var(--imx-dur-fast) var(--imx-ease);
}
.imx-check--radio { border-radius: 50%; }
.imx-check:checked { background: var(--imx-action); border-color: var(--imx-action); }
.imx-check:checked::after {
  content: ""; position: absolute; inset: 0; background: var(--imx-on-action);
  clip-path: polygon(19% 45%, 8% 57%, 41% 88%, 92% 27%, 80% 17%, 40% 65%);
}
.imx-check--radio:checked::after { clip-path: circle(24% at 50% 50%); }
.imx-switch-row { display: flex; align-items: center; gap: var(--imx-sp-3); cursor: pointer; }

/* ============================================================ surfaces */

.imx-card {
  background: var(--imx-surface);
  /* Paint the ink, not just the ground. A surface that sets a background and
     inherits its text colour renders correctly only while the page around it
     agrees — and during the migration the page is still daisyUI's light
     theme, so a tokenised card in a dark context came out light-on-light.
     A component that owns its background owns the text on it. */
  color: var(--imx-text);
  border: 1px solid var(--imx-border);
  border-radius: var(--imx-r-md);
  overflow: hidden;
}
.imx-card__header {
  display: flex; align-items: center; justify-content: space-between; gap: var(--imx-sp-3);
  padding: var(--imx-sp-3) var(--imx-sp-4);
  border-bottom: 1px solid var(--imx-border);
  font-weight: var(--imx-fw-semi);
}
.imx-card__body { padding: var(--imx-sp-4); }
.imx-card__footer { padding: var(--imx-sp-3) var(--imx-sp-4); border-top: 1px solid var(--imx-border); background: var(--imx-surface-sunken); }
.imx-card--flat { border-color: transparent; background: var(--imx-surface-hover); }
.imx-card--interactive { cursor: pointer; transition: border-color var(--imx-dur-fast) var(--imx-ease), background var(--imx-dur-fast) var(--imx-ease); }
.imx-card--interactive:hover { border-color: var(--imx-border-strong); background: var(--imx-surface-hover); }

/* "Eyebrow + optional action + content" — a pattern the portal repeats on
   every page and had never named. */
.imx-section { display: flex; flex-direction: column; gap: var(--imx-sp-3); }
.imx-section__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--imx-sp-3); }
.imx-section__label {
  font-size: var(--imx-fs-2xs); font-weight: var(--imx-fw-semi);
  letter-spacing: .07em; text-transform: uppercase; color: var(--imx-text-faint);
}
.imx-section__title { font-size: var(--imx-fs-xl); font-weight: var(--imx-fw-semi); margin: 0; }

.imx-page-title {
  font-family: var(--imx-font-display);
  font-size: var(--imx-fs-3xl); font-weight: var(--imx-fw-semi);
  line-height: var(--imx-lh-tight); margin: 0; text-wrap: balance;
}
.imx-page-sub { color: var(--imx-text-muted); font-size: var(--imx-fs-md); margin: 0; }

/* ============================================================== status */

.imx-badge {
  display: inline-flex; align-items: center; gap: var(--imx-sp-1);
  padding: .18rem .5rem;
  font-size: var(--imx-fs-2xs); font-weight: var(--imx-fw-medium); line-height: 1.3;
  border-radius: var(--imx-r-full);
  background: var(--imx-surface-hover); color: var(--imx-text-muted);
  border: 1px solid transparent; white-space: nowrap;
}
.imx-badge--success { background: var(--imx-success-weak); color: var(--imx-success-text); }
.imx-badge--info    { background: var(--imx-info-weak);    color: var(--imx-info-text); }
.imx-badge--warning { background: var(--imx-warning-weak); color: var(--imx-warning-text); }
.imx-badge--error   { background: var(--imx-error-weak);   color: var(--imx-error-text); }
.imx-badge--accent  { background: var(--imx-action-weak);  color: var(--imx-action); }
.imx-badge--outline { background: transparent; border-color: var(--imx-border-strong); }
.imx-badge--solid   { background: var(--imx-action); color: var(--imx-on-action); }

.imx-pill {
  display: inline-flex; align-items: center; gap: var(--imx-sp-2);
  padding: .28rem .7rem; font-size: var(--imx-fs-sm); line-height: 1.3;
  border: 1px solid var(--imx-border-strong); border-radius: var(--imx-r-full);
  background: var(--imx-surface); color: var(--imx-text);
}

/* An object chip: the role token tints the plate and the glyph together,
   which is why no dark-mode icon rule exists anywhere. */
.imx-chip {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2rem; height: 2rem; flex: none;
  border-radius: var(--imx-r-sm);
  background: var(--imx-file-bg); color: var(--imx-file-fg);
}
.imx-chip--lg { width: 2.75rem; height: 2.75rem; border-radius: var(--imx-r-md); }
.imx-chip--doc     { background: var(--imx-doc-bg);       color: var(--imx-doc-fg); }
.imx-chip--sheet   { background: var(--imx-sheet-bg);     color: var(--imx-sheet-fg); }
.imx-chip--deck    { background: var(--imx-deck-bg);      color: var(--imx-deck-fg); }
.imx-chip--pdf     { background: var(--imx-pdf-bg);       color: var(--imx-pdf-fg); }
.imx-chip--folder  { background: var(--imx-folder-bg);    color: var(--imx-folder-fg); }
.imx-chip--archive { background: var(--imx-archive-bg);   color: var(--imx-archive-fg); }
.imx-chip--image   { background: var(--imx-image-bg);     color: var(--imx-image-fg); }
.imx-chip--video   { background: var(--imx-video-bg);     color: var(--imx-video-fg); }
.imx-chip--audio   { background: var(--imx-audio-bg);     color: var(--imx-audio-fg); }
.imx-chip--code    { background: var(--imx-code-role-bg); color: var(--imx-code-role-fg); }
.imx-chip--person  { background: var(--imx-person-bg);    color: var(--imx-person-fg); }

/* Status dot. Always paired with a word — the portal's device switcher
   signalled online/offline by colour alone, which is unreadable to a
   screen reader and to viewers with a colour vision deficiency. */
.imx-dot { display: inline-block; width: .5rem; height: .5rem; flex: none; border-radius: 50%; background: var(--imx-border-strong); }
.imx-dot--online  { background: var(--imx-success); }
.imx-dot--offline { background: var(--imx-text-faint); }
.imx-dot--warning { background: var(--imx-warning); }
.imx-dot--error   { background: var(--imx-error); }
.imx-status { display: inline-flex; align-items: center; gap: var(--imx-sp-2); font-size: var(--imx-fs-sm); color: var(--imx-text-muted); }

.imx-avatar {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2rem; height: 2rem; flex: none;
  border-radius: 50%; overflow: hidden;
  background: var(--imx-person-bg); color: var(--imx-person-fg);
  font-size: var(--imx-fs-xs); font-weight: var(--imx-fw-semi);
  /* line-height:1 — the portal shipped a bug where the initial sat
     off-centre because the flex parent's 1.5 line box came along. */
  line-height: 1; text-transform: uppercase; letter-spacing: .02em; user-select: none;
}
.imx-avatar--sm { width: 1.5rem; height: 1.5rem; font-size: var(--imx-fs-2xs); }
.imx-avatar--lg { width: 2.5rem; height: 2.5rem; font-size: var(--imx-fs-md); }
.imx-avatar--owner { box-shadow: 0 0 0 2px var(--imx-surface), 0 0 0 3.5px var(--imx-action); }
.imx-avatar img { width: 100%; height: 100%; object-fit: cover; }

.imx-kbd {
  display: inline-block; padding: .1em .4em; font-size: .85em;
  background: var(--imx-kbd-bg);
  border: 1px solid var(--imx-border); border-bottom-width: 2px;
  border-radius: var(--imx-r-xs);
}

/* ============================================================ feedback */

.imx-banner {
  display: flex; align-items: flex-start; gap: var(--imx-sp-3);
  padding: var(--imx-sp-3) var(--imx-sp-4);
  border-radius: var(--imx-r-md);
  background: var(--imx-surface-hover); color: var(--imx-text);
  border: 1px solid transparent;
}
.imx-banner__icon { flex: none; margin-top: .1em; }
.imx-banner__body { flex: 1 1 auto; min-width: 0; }
.imx-banner__title { font-weight: var(--imx-fw-semi); }
.imx-banner__msg { font-size: var(--imx-fs-md); }
/* A banner action takes the banner's OWN ink, not the button's. A ghost button
   inside a warning banner reaches for --imx-text-muted, which on the warning
   tint reads as disabled text rather than the one thing the banner is asking
   you to do. Tying it to currentColor makes it read as an action in all four
   kinds without four rules. */
.imx-banner__action {
  flex: none; align-self: center;
  color: currentColor; font-weight: var(--imx-fw-semi);
  border-color: currentColor;
  background: transparent;
}
.imx-banner__action:hover {
  color: currentColor;
  background: color-mix(in srgb, currentColor 12%, transparent);
}
.imx-banner--info    { background: var(--imx-info-weak);    color: var(--imx-info-text); }
.imx-banner--success { background: var(--imx-success-weak); color: var(--imx-success-text); }
.imx-banner--warning { background: var(--imx-warning-weak); color: var(--imx-warning-text); }
.imx-banner--error   { background: var(--imx-error-weak);   color: var(--imx-error-text); }

/* Toasts. The portal had NO toast primitive — every mutation succeeded
   silently. One host: bottom-centre on a phone, bottom-right on a desktop.
   role="status" announces without stealing focus. */
.imx-toasts {
  position: fixed; z-index: 90;
  left: var(--imx-sp-4); right: var(--imx-sp-4);
  bottom: calc(var(--imx-sp-4) + env(safe-area-inset-bottom, 0px));
  display: flex; flex-direction: column; align-items: center; gap: var(--imx-sp-2);
  pointer-events: none;
}
@media (min-width: 640px) { .imx-toasts { left: auto; align-items: flex-end; max-width: 26rem; } }
.imx-toast {
  pointer-events: auto;
  display: flex; align-items: center; gap: var(--imx-sp-3);
  padding: var(--imx-sp-3) var(--imx-sp-4);
  border-radius: var(--imx-r-md);
  background: var(--imx-surface-raised); color: var(--imx-text);
  border: 1px solid var(--imx-border); box-shadow: var(--imx-shadow-3);
  font-size: var(--imx-fs-md);
  animation: imx-toast-in var(--imx-dur-base) var(--imx-ease);
}
.imx-toast--success .imx-toast__icon { color: var(--imx-success); }
.imx-toast--error   .imx-toast__icon { color: var(--imx-error); }
.imx-toast__icon { flex: none; }
.imx-toast__body { flex: 1 1 auto; min-width: 0; }
@keyframes imx-toast-in { from { opacity: 0; transform: translateY(6px); } }

/* The empty state is a real screen with a contract: mark, title, one line,
   one action. The portal had twelve bespoke ones that disagreed about
   everything, including how faint "faint" is. */
.imx-empty {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: var(--imx-sp-2); padding: var(--imx-sp-10) var(--imx-sp-6);
  color: var(--imx-text-muted);
}
.imx-empty__mark {
  display: flex; align-items: center; justify-content: center;
  width: 2.75rem; height: 2.75rem; margin-bottom: var(--imx-sp-1);
  border-radius: var(--imx-r-md);
  background: var(--imx-surface-hover); color: var(--imx-text-faint);
}
.imx-empty__title { font-size: var(--imx-fs-lg); font-weight: var(--imx-fw-semi); color: var(--imx-text); }
.imx-empty__desc { max-width: 34rem; font-size: var(--imx-fs-md); }
.imx-empty__action { margin-top: var(--imx-sp-2); }
.imx-empty--error .imx-empty__mark { background: var(--imx-error-weak); color: var(--imx-error-text); }
.imx-empty--inset { padding: var(--imx-sp-6) var(--imx-sp-4); }

/* Skeletons shaped like the content that replaces them, so nothing jumps. */
.imx-skel {
  background: linear-gradient(90deg, var(--imx-surface-hover) 0%, var(--imx-surface-active) 50%, var(--imx-surface-hover) 100%);
  background-size: 200% 100%;
  border-radius: var(--imx-r-xs);
  animation: imx-shimmer 1.4s ease-in-out infinite;
}
.imx-skel--text { height: .7rem; }
.imx-skel--title { height: 1rem; width: 40%; }
.imx-skel--avatar { width: 2rem; height: 2rem; border-radius: 50%; }
.imx-skel--row { height: 3rem; border-radius: var(--imx-r-sm); }
.imx-skel--card { height: 6rem; border-radius: var(--imx-r-md); }
@keyframes imx-shimmer { to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { .imx-skel { animation: none; } }

/* ============================================================== lists */

.imx-list { display: flex; flex-direction: column; }
.imx-row {
  display: flex; align-items: center; gap: var(--imx-sp-3);
  padding: var(--imx-sp-3) var(--imx-sp-4);
  border-radius: var(--imx-r-sm);
  color: inherit; text-decoration: none;
  transition: background var(--imx-dur-fast) var(--imx-ease);
}
/* Rows FILL on hover, they do not lift. A twelve-row list of cards that each
   raise onto a shadow makes the page look like it is twitching. */
.imx-row:hover { background: var(--imx-surface-hover); text-decoration: none; }
.imx-row[aria-current], .imx-row--active { background: var(--imx-surface-selected); }
/* A column, not just a flex child. `__title` and `__meta` are the row's two
   lines and the component owes them that arrangement: left inline they run
   together as "Hardware plans4 items · 2 days ago", which is how every call
   site rendered until the styleguide's own list demo showed it. Setting it
   here rather than asking each call site to add `flex flex-col` is the
   difference between a component and a naming convention. */
.imx-row__main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.imx-row__title { font-weight: var(--imx-fw-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imx-row__meta { font-size: var(--imx-fs-xs); color: var(--imx-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imx-row__aside { flex: none; color: var(--imx-text-faint); font-size: var(--imx-fs-xs); }
.imx-divider { height: 1px; background: var(--imx-border); }

.imx-table-wrap { overflow-x: auto; border: 1px solid var(--imx-border); border-radius: var(--imx-r-md); background: var(--imx-surface); }
.imx-table { width: 100%; border-collapse: collapse; font-size: var(--imx-fs-md); }
.imx-table th, .imx-table td { text-align: left; padding: var(--imx-sp-2) var(--imx-sp-3); border-bottom: 1px solid var(--imx-border); vertical-align: top; }
.imx-table th {
  font-size: var(--imx-fs-2xs); letter-spacing: .06em; text-transform: uppercase;
  color: var(--imx-text-faint); font-weight: var(--imx-fw-semi);
  background: var(--imx-surface-sunken);
}
.imx-table tr:last-child td { border-bottom: 0; }

/* ============================================================== overlay */

.imx-menu {
  min-width: 12rem; padding: var(--imx-sp-1); margin: 0; list-style: none;
  background: var(--imx-surface-raised);
  /* Same reason as .imx-card: a popover that paints a ground and inherits its
     ink renders correctly only while the page behind it agrees. */
  color: var(--imx-text);
  border: 1px solid var(--imx-border); border-radius: var(--imx-r-md);
  box-shadow: var(--imx-shadow-3);
}
.imx-menu__item {
  display: flex; align-items: center; gap: var(--imx-sp-2); width: 100%;
  padding: var(--imx-sp-2) var(--imx-sp-3);
  border: 0; background: transparent; border-radius: var(--imx-r-xs);
  font: inherit; font-size: var(--imx-fs-md); color: var(--imx-text);
  text-align: left; text-decoration: none; cursor: pointer;
}
.imx-menu__item:hover { background: var(--imx-surface-hover); text-decoration: none; }
.imx-menu__item--danger { color: var(--imx-error-text); }
.imx-menu__sep { height: 1px; margin: var(--imx-sp-1) 0; background: var(--imx-border); }
.imx-menu__label {
  padding: var(--imx-sp-2) var(--imx-sp-3) var(--imx-sp-1);
  font-size: var(--imx-fs-2xs); letter-spacing: .06em; text-transform: uppercase;
  color: var(--imx-text-faint); font-weight: var(--imx-fw-semi);
}

/* A <details>-driven dropdown: no JS, keyboard-operable for free. */
.imx-dropdown { position: relative; }
.imx-dropdown > summary { list-style: none; cursor: pointer; }
.imx-dropdown > summary::-webkit-details-marker { display: none; }
.imx-dropdown__panel { position: absolute; z-index: 50; top: calc(100% + var(--imx-sp-1)); right: 0; }
.imx-dropdown__panel--left { right: auto; left: 0; }

/* Checkbox-toggled, so opening and closing needs no JS — which is what lets a
   dialog live inside an htmx fragment that may be swapped out from under any
   listener. `display: contents` rather than `block` so the fixed-position
   scrim and box still position against the viewport. */
.imx-modal-wrap { display: none; }
.imx-modal-toggle:checked ~ .imx-modal-wrap { display: contents; }

.imx-scrim { position: fixed; inset: 0; z-index: 60; background: var(--imx-scrim); }
.imx-modal {
  position: fixed; z-index: 61; inset: 0;
  display: flex; align-items: center; justify-content: center;
  padding: var(--imx-sp-4); pointer-events: none;
}
.imx-modal__box {
  pointer-events: auto;
  width: 100%; max-width: 32rem; max-height: 85vh; overflow-y: auto;
  background: var(--imx-surface-raised); color: var(--imx-text);
  border: 1px solid var(--imx-border); border-radius: var(--imx-r-lg);
  box-shadow: var(--imx-shadow-3);
}
.imx-modal__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--imx-sp-3); padding: var(--imx-sp-4) var(--imx-sp-5) var(--imx-sp-2); }
.imx-modal__title { font-size: var(--imx-fs-xl); font-weight: var(--imx-fw-semi); margin: 0; }
.imx-modal__body { padding: 0 var(--imx-sp-5) var(--imx-sp-4); }
.imx-modal__foot { display: flex; justify-content: flex-end; gap: var(--imx-sp-2); padding: var(--imx-sp-3) var(--imx-sp-5) var(--imx-sp-4); }
.imx-modal--danger .imx-modal__title { color: var(--imx-error-text); }
@media (max-width: 640px) {
  /* On a phone a centred dialog fights the keyboard; dock it. */
  .imx-modal { align-items: flex-end; padding: 0; }
  .imx-modal__box { max-width: none; border-radius: var(--imx-r-lg) var(--imx-r-lg) 0 0; max-height: 90vh; }
}

/* ============================================================== tabs */

.imx-tabs { display: flex; gap: var(--imx-sp-1); align-items: center; overflow-x: auto; scrollbar-width: none; }
.imx-tabs::-webkit-scrollbar { display: none; }
.imx-tab {
  padding: var(--imx-sp-1) var(--imx-sp-3);
  font-size: var(--imx-fs-sm); font-weight: var(--imx-fw-medium); color: var(--imx-text-muted);
  background: transparent; border: 1px solid transparent; border-radius: var(--imx-r-full);
  cursor: pointer; white-space: nowrap; text-decoration: none;
}
.imx-tab:hover { background: var(--imx-surface-hover); color: var(--imx-text); text-decoration: none; }
.imx-tab[aria-selected="true"], .imx-tab--active { background: var(--imx-action); color: var(--imx-on-action); }

.imx-segmented { display: inline-flex; padding: 2px; gap: 2px; background: var(--imx-surface-hover); border-radius: var(--imx-r-sm); }
.imx-segmented__item {
  padding: var(--imx-sp-1) var(--imx-sp-3);
  font-size: var(--imx-fs-sm); color: var(--imx-text-muted);
  background: transparent; border: 0; border-radius: calc(var(--imx-r-sm) - 2px); cursor: pointer;
}
.imx-segmented__item[aria-selected="true"] { background: var(--imx-surface); color: var(--imx-text); box-shadow: var(--imx-shadow-1); }
/* Fills its container and splits evenly — for a segmented control that is a
   row in a menu or a card rather than a chip beside a heading. */
.imx-segmented--block { display: flex; width: 100%; }
.imx-segmented--block .imx-segmented__item {
  flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: var(--imx-sp-1);
  padding-left: var(--imx-sp-2); padding-right: var(--imx-sp-2);
}

/* ============================================================ utility */

.imx-scroll-x { overflow-x: auto; }
.imx-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imx-num { font-variant-numeric: tabular-nums; }
.imx-mono { font-family: var(--imx-font-mono); font-size: .92em; }
.imx-muted { color: var(--imx-text-muted); }
.imx-faint { color: var(--imx-text-faint); }
.imx-serif { font-family: var(--imx-font-display); }

/* ===== src/shell.css ===================================================== */
/* The application shell: top bar, rail, drawer, main column.
 *
 * This replaces the daisyUI `drawer`/`navbar`/`menu` trio. Same behaviour —
 * a checkbox toggles an off-canvas rail below the breakpoint and the rail is
 * always open above it — but expressed in tokens, so the shell can be dark
 * and so an app that never loads Tailwind can still render it.
 *
 * One breakpoint, --imx-bp-rail (1024px). Below it the rail is a drawer;
 * above it a column. It is a media query rather than a container query
 * because the shell IS the viewport.
 */

.imx-shell { min-height: 100dvh; background: var(--imx-bg); }

.imx-drawer-toggle { position: absolute; opacity: 0; width: 0; height: 0; }

.imx-shell__body { display: flex; flex-direction: column; min-height: 100dvh; }

/* ------------------------------------------------------------- top bar */

.imx-topbar {
  position: sticky; top: 0; z-index: 30;
  display: flex; align-items: center; gap: var(--imx-sp-2);
  min-height: var(--imx-topbar-h);
  padding: 0 var(--imx-sp-2);
  background: var(--imx-bg-panel);
  border-bottom: 1px solid var(--imx-border);
}
.imx-topbar__start { display: flex; align-items: center; gap: var(--imx-sp-2); min-width: 0; }
/* flex-1 on the centre is load-bearing below the breakpoint: without
   something claiming the slack, all three groups pack left and the avatar
   floats mid-row with dead space beside it. It also puts the device chip
   where an app bar puts its title. */
.imx-topbar__center { flex: 1 1 auto; display: flex; justify-content: center; min-width: 0; }
.imx-topbar__end { display: flex; align-items: center; gap: var(--imx-sp-2); margin-left: auto; }
.imx-topbar__brand { display: none; padding: 0 var(--imx-sp-2); }
.imx-topbar__brand img { height: 1.75rem; display: block; }

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

.imx-main {
  flex: 1 1 auto; width: 100%;
  max-width: var(--imx-content-max);
  margin-inline: auto;
  padding: var(--imx-sp-4);
}
@media (min-width: 640px) { .imx-main { padding: var(--imx-sp-6); } }
@media (min-width: 1024px) { .imx-main { padding-inline: var(--imx-sp-8); } }
.imx-main--wide { max-width: none; }
.imx-main--flush { padding: 0; max-width: none; }

.imx-page-head {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: var(--imx-sp-3); margin-bottom: var(--imx-sp-4);
}
.imx-page-head__text { display: flex; flex-direction: column; gap: var(--imx-sp-1); min-width: 0; }

/* --------------------------------------------------------------- rail */

.imx-drawer-side {
  position: fixed; inset: 0; z-index: 40;
  display: grid; grid-template-columns: auto 1fr;
  /* `visibility` rather than `display:none` so the panel can transition. */
  visibility: hidden;
}
.imx-drawer-toggle:checked ~ .imx-drawer-side { visibility: visible; }
.imx-drawer-overlay {
  grid-column: 2; background: var(--imx-scrim);
  opacity: 0; transition: opacity var(--imx-dur-base) var(--imx-ease);
  cursor: pointer;
}
.imx-drawer-toggle:checked ~ .imx-drawer-side .imx-drawer-overlay { opacity: 1; }

.imx-rail-panel {
  grid-column: 1;
  display: flex; flex-direction: column;
  width: 18rem; height: 100%;
  background: var(--imx-bg-panel);
  border-right: 1px solid var(--imx-border);
  transform: translateX(-100%);
  transition: transform var(--imx-dur-base) var(--imx-ease);
}
.imx-drawer-toggle:checked ~ .imx-drawer-side .imx-rail-panel { transform: translateX(0); }

/* `height: 100%`, not min-height: the footer is pinned with flex, and a
   min-height gives the column no definite height to distribute — the footer
   then renders past the bottom of the viewport. */
/* Two scrollers, and which one moves is the point.
 *
 * The sections are navigation: where you can go does not change as you read,
 * so it should not slide away while you look for a conversation. The
 * conversation list is the long thing, so it gets its own scroll and the
 * sections stay put above it.
 *
 * `.imx-rail-scroll` stays the OUTER scroller rather than being replaced by
 * the inner one, because on a short window the fixed part alone can exceed the
 * viewport — and then everything has to scroll or the footer and the last
 * sections become unreachable. `--imx-rail-recents-min` is what hands control
 * back: the list can give up space down to that floor, and past it the outer
 * box overflows and the whole rail scrolls, which is the wanted behaviour and
 * not a fallback.
 *
 * `display: flex` here (and `flex: 1 1 auto; min-height: 0` on the nav) is
 * what gives the list a definite height to flex within. Without it the nav
 * sizes to its content, the list has nothing to shrink against, and the inner
 * `overflow-y` never engages. */
.imx-rail-scroll {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column;
  padding: var(--imx-sp-4) var(--imx-sp-4) var(--imx-sp-2);
}
.imx-rail-recents {
  flex: 1 1 auto; min-height: var(--imx-rail-recents-min); overflow-y: auto;
}
.imx-rail-foot {
  flex: none;
  display: flex; align-items: center; gap: var(--imx-sp-2);
  padding: var(--imx-sp-3);
  border-top: 1px solid var(--imx-border);
}

.imx-rail { display: flex; flex-direction: column; gap: 2px; list-style: none; margin: 0; padding: 0; flex: 1 1 auto; min-height: 0; }
.imx-rail__group {
  padding: var(--imx-sp-4) var(--imx-sp-2) var(--imx-sp-1);
  font-size: var(--imx-fs-2xs); font-weight: var(--imx-fw-semi);
  letter-spacing: .07em; text-transform: uppercase; color: var(--imx-text-faint);
}
.imx-rail__group:first-child { padding-top: 0; }
/* `--imx-sp-1` vertical, not `--imx-sp-2`. Measured against the reference at
   the same display scale (2026-09-01): its rail runs a 24.4pt item pitch, ours
   ran 29.2. The rail is a list you scan, and the extra step bought nothing but
   fewer conversations on screen. Horizontal padding is unchanged. */
.imx-rail-item {
  display: flex; align-items: center; gap: var(--imx-sp-3);
  padding: var(--imx-sp-1) var(--imx-sp-3);
  border-radius: var(--imx-r-sm);
  font-size: var(--imx-fs-md); color: var(--imx-text-muted);
  text-decoration: none; cursor: pointer;
  transition: background var(--imx-dur-fast) var(--imx-ease), color var(--imx-dur-fast) var(--imx-ease);
}
.imx-rail-item:hover { background: var(--imx-surface-hover); color: var(--imx-text); text-decoration: none; }
/* The current entry is the NEUTRAL chip, not the accent.
   It briefly became a violet fill, which put the logo colour on a whole block
   of chrome — the opposite of the "accent is scarce" rule the mockups were
   approved on, and the one visible difference against the shipped rail. The
   accent's job here is the New chat button below, and nothing else. */
.imx-rail-item[aria-current="page"], .imx-rail-item--active {
  background: var(--imx-neutral); color: var(--imx-on-neutral); font-weight: var(--imx-fw-medium);
}
.imx-rail-item[aria-current="page"]:hover, .imx-rail-item--active:hover {
  background: var(--imx-neutral); color: var(--imx-on-neutral);
}
.imx-rail-item__label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imx-rail-item--child { padding-left: var(--imx-sp-8); font-size: var(--imx-fs-sm); }
.imx-rail-sub { display: flex; flex-direction: column; gap: 2px; }
.imx-rail__chevron { flex: none; transition: transform var(--imx-dur-base) var(--imx-ease); }
details[open] > summary .imx-rail__chevron { transform: rotate(90deg); }
.imx-rail-details > summary { list-style: none; }
.imx-rail-details > summary::-webkit-details-marker { display: none; }

/* The account link in the rail footer is deliberately NOT a button: a button
   wraps its content, which puts the name on a second line and pushes it out
   of the pinned footer. */
.imx-rail-account {
  display: flex; align-items: center; gap: var(--imx-sp-2);
  flex: 1 1 auto; min-width: 0;
  padding: var(--imx-sp-1); border-radius: var(--imx-r-sm);
  color: inherit; text-decoration: none;
  transition: background var(--imx-dur-fast) var(--imx-ease);
}
.imx-rail-account:hover { background: var(--imx-surface-hover); text-decoration: none; }
.imx-rail-account__name { font-size: var(--imx-fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* --------------------------------------------------- desktop: rail open */

@media (min-width: 1024px) {
  .imx-shell { display: grid; grid-template-columns: var(--imx-rail-w) 1fr; }
  .imx-drawer-side {
    position: static; visibility: visible;
    grid-column: 1; grid-row: 1;
    display: block; height: 100dvh; position: sticky; top: 0;
  }
  .imx-drawer-overlay { display: none; }
  .imx-rail-panel { width: 100%; transform: none; transition: none; }
  .imx-shell__body { grid-column: 2; grid-row: 1; }
  .imx-topbar__brand { display: block; }
  .imx-topbar__center { display: none; }
  .imx-topbar__start { width: 50%; }
  .imx-topbar__end { width: 50%; justify-content: flex-end; }
  .imx-drawer-btn { display: none; }
  .imx-lg-only { display: revert; }
  .imx-sm-only { display: none; }
}
@media (max-width: 1023.98px) {
  .imx-lg-only { display: none; }
}

/* WHY THESE EXIST, rather than Tailwind's `lg:hidden` / `hidden lg:block`.
 *
 * The design system's stylesheet loads AFTER the Tailwind bundle, and a
 * component like `.imx-btn { display: inline-flex }` is exactly as specific as
 * `.hidden { display: none }` — so on an `.imx-*` element the utility LOSES and
 * the thing stays visible. It is silent, it only shows at one breakpoint, and
 * it shipped twice: the chat back link and the floating compose button both
 * sat on every desktop screen.
 *
 * These two live in shell.css, which concatenates after components.css, so
 * they win. On an element with no `.imx-*` component (a bare span, a wrapper
 * div) Tailwind's utilities are fine and preferable — the rule is only about
 * elements that carry a component class.

/* ------------------------------------------------------ device switcher */

.imx-switcher {
  display: inline-flex; align-items: center; gap: var(--imx-sp-2);
  max-width: 100%;
  padding: var(--imx-sp-1) var(--imx-sp-2);
  border-radius: var(--imx-r-sm);
  background: transparent; border: 0; cursor: pointer;
  font: inherit; font-size: var(--imx-fs-md); color: var(--imx-text);
}
.imx-switcher:hover { background: var(--imx-surface-hover); }
.imx-switcher__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: var(--imx-fw-medium); }
.imx-switcher__state { font-size: var(--imx-fs-xs); color: var(--imx-text-muted); }

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

/* Full-screen loader shown between a click and the next paint. Arms
   synchronously on click capture because iOS Safari freezes timers during a
   provisional navigation, so a setTimeout-armed overlay never appears.
   Compositor-only properties (opacity) so it cannot cost a layout on the way
   out of a page that is already busy. */
.imx-nav-loading {
  position: fixed; inset: 0; z-index: 9999;
  display: none; align-items: center; justify-content: center; flex-direction: column;
  gap: var(--imx-sp-4);
  background: color-mix(in srgb, var(--imx-bg) 82%, transparent);
  backdrop-filter: blur(3px);
}
.imx-nav-loading[data-on] { display: flex; }
.imx-nav-loading__label { font-size: var(--imx-fs-sm); color: var(--imx-text-muted); }

/* The conversation's name in the top bar.
 *
 * The reference names the open conversation top-left; ours named it nowhere —
 * the rail showed a highlighted row and the pane showed the text, and the
 * thing you were reading had no title on screen at all. Truncates rather than
 * wraps: the bar is one row and a long title must not push the account menu. */
.imx-topbar__title {
  min-width: 0; max-width: 22rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-weight: var(--imx-fw-medium); color: var(--imx-text);
}

/* "New chat" as the rail's first row (B6). A plain item, not a filled button:
   it was violet in the footer, which put the logo colour on the loudest
   control on the page in the last place anyone looks for it. The `+` and a
   slightly stronger label are enough to read as an action. */
.imx-rail-item--new { color: var(--imx-text); font-weight: var(--imx-fw-medium); }
.imx-rail-item--new .imx-icon { opacity: .7; }

/* A section's own phone header, delivered through the top bar's slot.
 *
 * On a phone the reference shows one row for an open conversation: back ·
 * title · account. Ours showed the shell's row (menu · logo · account) AND a
 * second "All chats" row under it — ~200px of a 844px screen before a word of
 * the conversation. When a section drops `.imx-topbar__phone` into
 * `topbar_extra`, the bar hands the row over: the menu and logo step aside
 * and the slot takes the width. Keyed on the slot's CONTENT, so the shell
 * template still knows nothing about which section did it; the list view has
 * no open conversation, renders no phone header, and keeps its menu. */
.imx-topbar__phone { display: none; }
@media (max-width: 639.98px) {
  .imx-topbar__phone {
    display: flex; align-items: center; gap: var(--imx-sp-2);
    flex: 1 1 auto; min-width: 0;
  }
  .imx-topbar:has(.imx-topbar__phone) .imx-topbar__start { display: none; }
  /* The device switcher too. Below `lg` it lives in `__center` (the ≥lg
     block hides it there and shows the one in `__start` instead), and with
     `__end` taking the row it was left underneath the title — the first
     render showed "…56390f Online" bleeding through the conversation's name.
     The reference's conversation row has no device on it; the device is the
     list's business, one tap back. */
  .imx-topbar:has(.imx-topbar__phone) .imx-topbar__center { display: none; }
  .imx-topbar:has(.imx-topbar__phone) .imx-topbar__end {
    width: 100%; flex: 1 1 auto; justify-content: flex-start;
  }
  .imx-topbar__phone .imx-topbar__title { max-width: none; flex: 1 1 auto; }
}

/* ===== src/chat.css ===================================================== */
/* The chat vocabulary — the one place the portal and the chat app agree.
 *
 * WHY THIS FILE EXISTS. Chat is rendered twice in this repo: the portal
 * renders it server-side from Minijinja (crates/portal-ui/templates/portal/
 * chat_pane.html, view model in crates/hub/src/chat_view.rs) and the imx-chat
 * app renders it client-side through assistant-ui's headless primitives. The
 * plan's decision D1 keeps both. What makes that survivable is that neither
 * owns the look: both emit THESE class names on the same structure, so a
 * change here moves both, and the styleguide renders one fixture transcript
 * through both renderers side by side to prove they still agree.
 *
 * Structure, in both renderers:
 *   .imx-thread
 *     .imx-turn--user       one soft bubble, right-aligned
 *     .imx-turn--user-long  a prompt, not a message: full width, folded
 *     .imx-turn--assistant  unboxed prose (.imx-prose does the typography)
 *     .imx-tool-row         one quiet line of provenance; expandable when a
 *                           result paired in
 *     .imx-artifact         the one clickable turn, so the one that lifts
 *     .imx-working          dots + Stop
 *   .imx-composer
 *
 * Depth is information here and there are exactly three levels: the artifact
 * card has a shadow (it is clickable), a tool row has a fill only, the
 * assistant turn has neither. Adding a fourth flattens the meaning.
 */

/* THE READING COLUMN. Measured off Claude's desktop app (pixel analysis,
 * scale-anchored on the macOS traffic lights): a 672px container centred in
 * the pane, 640px of ink, a 21px line pitch. The portal had no cap at all —
 * `.imx-chat-cols`' second track is `minmax(0, 1fr)`, so on a wide window the
 * prose ran the full width of the pane, which is the single biggest reason it
 * did not read like the reference. The thread and the composer both take this
 * width so they share one axis; centring is `margin-inline: auto` on each, and
 * because the padding around them is symmetric their centres coincide even
 * though the scroll box has `lg:p-3` and the composer's form does not.
 *
 * `--imx-lh-chat` is chat's own rhythm, deliberately NOT the global
 * `--imx-lh-prose` (1.65): that token is the design system's prose voice and
 * any page may adopt it. 15px x 1.4 = the reference's 21px pitch. Match the
 * PITCH, not the ratio — the ratio is only correct for the font size it was
 * derived at. */
.imx-thread {
  display: flex; flex-direction: column;
  width: 100%; max-width: var(--imx-chat-col); margin-inline: auto;
}
/* Beats `.imx-prose`'s own line-height (0,2,0 over 0,1,0), which inheritance
   from `.imx-thread` could not do — `.imx-prose` sets it on the element. */
.imx-thread .imx-prose { line-height: var(--imx-lh-chat); }

/* Only the user's turn gets a surface. Both roles in identical boxes is what
   flattened the conversation's rhythm in the first pass. The hairline is what
   makes it a bubble: on a panel-coloured canvas the fill alone matches the
   background and the bubble disappears. */
.imx-turn--user {
  align-self: flex-end; max-width: 85%;
  line-height: var(--imx-lh-chat);
  margin-bottom: var(--imx-sp-5);
  padding: var(--imx-sp-2) var(--imx-sp-4);
  background: var(--imx-surface-hover);
  border: 1px solid var(--imx-border);
  border-radius: var(--imx-r-lg); border-bottom-right-radius: var(--imx-r-sm);
  white-space: pre-wrap; overflow-wrap: break-word;
}
.imx-turn--assistant {
  margin-bottom: var(--imx-sp-5);
  overflow-wrap: break-word;
}

/* A long prompt is context that was given, not dialogue — see
   chat_view::is_long_prompt for the cut. Full width, the tool row's quiet
   fill, no bubble asymmetry; folded to its first lines until asked for. */
.imx-turn--user-long {
  align-self: stretch; width: 100%;
  margin-bottom: var(--imx-sp-5);
  border-radius: var(--imx-r-md);
  background: var(--imx-surface-hover);
  color: var(--imx-text-muted);
  font-size: var(--imx-fs-sm); line-height: var(--imx-lh-chat);
}
.imx-turn--user-long > summary {
  list-style: none; cursor: pointer;
  padding: var(--imx-sp-2) var(--imx-sp-3);
  display: flex; flex-direction: column; gap: var(--imx-sp-1);
}
.imx-turn--user-long > summary::-webkit-details-marker { display: none; }
.imx-turn--user-long__text {
  white-space: pre-wrap; overflow-wrap: break-word;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4;
  overflow: hidden;
}
.imx-turn--user-long[open] .imx-turn--user-long__text {
  display: block; -webkit-line-clamp: unset; overflow: visible;
}
.imx-turn--user-long__toggle { font-size: var(--imx-fs-xs); color: var(--imx-text-faint); }
.imx-turn--user-long__toggle::after { content: "Show prompt"; }
.imx-turn--user-long[open] .imx-turn--user-long__toggle::after { content: "Hide prompt"; }

.imx-tool-row {
  display: flex; align-items: center; gap: var(--imx-sp-2);
  padding: var(--imx-sp-1) var(--imx-sp-3);
  border-radius: var(--imx-r-sm);
  font-size: var(--imx-fs-xs);
  color: var(--imx-text-muted);
}
.imx-tool { margin-bottom: var(--imx-sp-1); }
.imx-tool > summary { list-style: none; cursor: pointer; border-radius: var(--imx-r-sm); }
.imx-tool > summary::-webkit-details-marker { display: none; }
.imx-tool > summary:hover { background: var(--imx-surface-hover); }
.imx-tool__caret { flex: none; opacity: .5; transition: transform var(--imx-dur-base) var(--imx-ease); }
.imx-tool[open] .imx-tool__caret { transform: rotate(90deg); }
.imx-tool__spacer { width: .625rem; flex: none; }
.imx-tool__name { font-weight: var(--imx-fw-semi); flex: none; }
.imx-tool__args { font-family: var(--imx-font-mono); color: var(--imx-text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imx-tool__result {
  margin: 0 var(--imx-sp-3) var(--imx-sp-2);
  padding: var(--imx-sp-2) var(--imx-sp-3);
  border-radius: var(--imx-r-sm);
  background: var(--imx-surface-hover);
  font-family: var(--imx-font-mono); font-size: var(--imx-fs-xs);
  color: var(--imx-text-muted);
  white-space: pre-wrap; overflow-wrap: break-word;
  max-height: 18rem; overflow: auto;
}

/* What the agent made, and where. The one turn meant to be clicked. */
.imx-artifact {
  display: flex; align-items: center; gap: var(--imx-sp-3);
  margin: var(--imx-sp-2) 0 var(--imx-sp-5);
  padding: var(--imx-sp-3) var(--imx-sp-4);
  background: var(--imx-surface);
  border: 1px solid var(--imx-border); border-radius: var(--imx-r-md);
  box-shadow: var(--imx-shadow-1);
  color: inherit; text-decoration: none;
  transition: box-shadow var(--imx-dur-base) var(--imx-ease), border-color var(--imx-dur-base) var(--imx-ease);
}
.imx-artifact:hover { box-shadow: var(--imx-shadow-2); border-color: var(--imx-action); text-decoration: none; }
.imx-artifact__main { flex: 1 1 auto; min-width: 0; }
.imx-artifact__title { display: block; font-weight: var(--imx-fw-semi); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imx-artifact__sub { display: block; font-size: var(--imx-fs-sm); color: var(--imx-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imx-artifact__cta {
  flex: none; display: inline-flex; align-items: center; gap: var(--imx-sp-1);
  font-size: var(--imx-fs-sm); font-weight: var(--imx-fw-semi); color: var(--imx-action);
}

/* The working indicator: the accent's ONE appearance inside a transcript,
   per the mockups' "accent used scarcely" rule. Stop lives inside it because
   stopping is what you want the instant you notice it is still going. */
.imx-working { display: flex; align-items: center; gap: var(--imx-sp-2); padding: 0 var(--imx-sp-1); font-size: var(--imx-fs-sm); color: var(--imx-text-muted); }
.imx-working__dots { display: inline-flex; gap: 3px; }
.imx-working__dots i {
  width: 5px; height: 5px; border-radius: 50%; background: var(--imx-action);
  animation: imx-dots 1.1s ease-in-out infinite;
}
.imx-working__dots i:nth-child(2) { animation-delay: .15s; }
.imx-working__dots i:nth-child(3) { animation-delay: .3s; }
@keyframes imx-dots { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .imx-working__dots i { animation: none; opacity: .6; } }
.imx-turn-end {
  width: 100%; max-width: var(--imx-chat-col); margin: 0 auto var(--imx-sp-4);
  color: var(--imx-action); opacity: .85;
}

/* ------------------------------------------------------------- composer */

.imx-composer {
  display: flex; align-items: flex-end; gap: var(--imx-sp-2);
  width: 100%; max-width: var(--imx-chat-col); margin-inline: auto;
  padding: var(--imx-sp-2) var(--imx-sp-2) var(--imx-sp-2) var(--imx-sp-4);
  background: var(--imx-surface);
  border: 1px solid var(--imx-border-strong);
  border-radius: var(--imx-r-pill);
  box-shadow: var(--imx-shadow-1);
  transition: border-color var(--imx-dur-fast) var(--imx-ease), box-shadow var(--imx-dur-fast) var(--imx-ease);
}
.imx-composer:focus-within { border-color: var(--imx-action); box-shadow: var(--imx-ring); }
/* Quieter at rest. The reference's composer is a hairline on the page; ours
   carried `--imx-border-strong` plus a shadow, which read as a raised control
   competing with the conversation above it. The focus ring above still says
   where the caret is. */
.imx-composer { border-color: var(--imx-border); box-shadow: none; }
/* Send only once there is something to send — see chat.html. `:has()` is
   supported by every browser the portal targets; where it is not, the button
   simply stays visible, which is today's behaviour and not a broken one. */
.imx-composer:not(:focus-within) .imx-composer__actions { opacity: 0; pointer-events: none; }
.imx-composer:has(.imx-composer__input:not(:placeholder-shown)) .imx-composer__actions {
  opacity: 1; pointer-events: auto;
}
.imx-composer__actions { transition: opacity var(--imx-dur-fast) var(--imx-ease); }
.imx-composer__input {
  flex: 1 1 auto; min-width: 0;
  border: 0; outline: none; background: transparent; resize: none;
  font: inherit; font-size: var(--imx-fs-lg); color: var(--imx-text);
  padding: var(--imx-sp-2) 0; max-height: 12rem; line-height: var(--imx-lh-base);
}
.imx-composer__input::placeholder { color: var(--imx-text-faint); }
.imx-composer__actions { display: flex; align-items: center; gap: var(--imx-sp-1); flex: none; }
/* Disclaimer left, the conversation's control right — the reference's shape.
   The hint was centred when it was alone in the row; with something opposite
   it, centring would leave it floating between two edges. */
/* Same axis as the composer, or the hint starts left of the box it belongs to
   — the composer is capped at --imx-chat-col and centred, and a footer without
   the same cap spans the whole pane. Measured on the live portal at 110px of
   drift before this line existed. */
/* `space-between` is deliberate with ONE child too: it resolves to flex-start,
   so the lone hint keeps the composer's left edge instead of drifting to the
   middle — and the agent/effort selector will drop into the right-hand slot
   later without the hint moving. Re-centring now would only have to be undone. */
.imx-composer__foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--imx-sp-3); margin-top: var(--imx-sp-2);
  width: 100%; max-width: var(--imx-chat-col); margin-inline: auto;
}
.imx-composer__hint { margin: 0; font-size: var(--imx-fs-xs); color: var(--imx-text-faint); }
/* Still hidden on a phone, but the reason changed with M4 and the old one was
   about to become a lie. It used to be "the hint is the first thing worth
   dropping: the control is not" — there is no control in this row any more.
   It stays hidden because the text is about a hardware keyboard: "Enter to
   send, Shift+Enter for a new line" is not advice a soft keyboard can take.
   The row is then empty on a phone, which costs the 8px `margin-top` and
   nothing else — an empty flex container has no height of its own. */
@media (max-width: 640px) { .imx-composer__hint { display: none; } }
.imx-composer--boxed { border-radius: var(--imx-r-lg); }

/* --------------------------------------------------------- session list */

.imx-day-group {
  padding: var(--imx-sp-4) var(--imx-sp-2) var(--imx-sp-1);
  font-size: var(--imx-fs-2xs); font-weight: var(--imx-fw-semi);
  letter-spacing: .07em; text-transform: uppercase; color: var(--imx-text-faint);
}
.imx-day-group:first-child { padding-top: 0; }
.imx-session {
  display: flex; align-items: flex-start; gap: var(--imx-sp-3);
  padding: var(--imx-sp-2) var(--imx-sp-3);
  border-radius: var(--imx-r-sm);
  color: inherit; text-decoration: none;
  transition: background var(--imx-dur-fast) var(--imx-ease);
}
.imx-session:hover { background: var(--imx-surface-hover); text-decoration: none; }
/* The active row is a neutral raised card, not an accent bar — the approved
   mockup choice, pinned by a parity test (border-primary must stay absent). */
.imx-session[aria-current], .imx-session--active {
  background: var(--imx-surface); border: 1px solid var(--imx-border); box-shadow: var(--imx-shadow-1);
}
.imx-session__main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.imx-session__title { font-size: var(--imx-fs-md); font-weight: var(--imx-fw-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imx-session__meta { font-size: var(--imx-fs-xs); color: var(--imx-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imx-session__time { flex: none; font-size: var(--imx-fs-2xs); color: var(--imx-text-faint); font-variant-numeric: tabular-nums; }

/* ------------------------------------------------------------ greeting */

.imx-greeting {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: var(--imx-sp-2); padding: var(--imx-sp-10) var(--imx-sp-4);
}
.imx-greeting__mark { color: var(--imx-action); }
.imx-greeting__title {
  font-family: var(--imx-font-display);
  font-size: var(--imx-fs-3xl); font-weight: var(--imx-fw-semi);
  margin: 0; text-wrap: balance;
}
.imx-greeting__sub { color: var(--imx-text-muted); max-width: 30rem; margin: 0; }

/* ---------------------------------------------------------- two panes */

.imx-chat-cols { display: flex; flex-direction: column; min-height: 0; }
/* Thin, and out of the reading column's way.
 *
 * The reference scrolls the PAGE, so its bar sits on the window edge. Ours
 * scrolls this box — deliberately: the 2s poll swaps a fragment INSIDE it and
 * the box is what preserves the reader's position (see chat.html). The cost is
 * a scrollbar three-quarters across the screen, beside the text. Restyling it
 * is the cheap half of that trade; moving to page scroll is a separate change
 * with a real regression risk, and is deferred rather than forgotten.
 *
 * `scrollbar-gutter: stable` so the reading column does not shift by the bar's
 * width when a short conversation grows past one screen. */
.imx-chat-scroll {
  overflow-y: auto; overscroll-behavior: contain;
  scrollbar-width: thin; scrollbar-gutter: stable;
  scrollbar-color: var(--imx-border-strong) transparent;
}
.imx-chat-scroll::-webkit-scrollbar { width: 6px; }
.imx-chat-scroll::-webkit-scrollbar-track { background: transparent; }
.imx-chat-scroll::-webkit-scrollbar-thumb {
  background: var(--imx-border-strong); border-radius: 3px;
}
@media (min-width: 1024px) {
  /* One column. This was a `15.5rem + rest` grid for the page's own session
     list; that list is the rail's now, so there is no second track to size. */
  .imx-chat-cols { display: flex; }
}

/* ------------------------------------------------------------ the phone
 *
 * Under 640px the conversation is one surface with the composer floating over
 * it, the way the reference reads on a phone: text visibly passes underneath
 * the input. Three things make that, and none of them touches what scrolls.
 *
 * `#chat-scroll` STAYS the scroller. chat.html's contract — the 2s poll swaps
 * a fragment inside it, so the reader's position survives — is untouched;
 * the box simply fills the column and stops drawing a scrollbar track of its
 * own. Moving scroll to <body> was considered and rejected: it would need a
 * second preservation mechanism, and this page already has one that works.
 *
 * The composer is docked with `position: absolute` inside the pane rather
 * than `sticky`, because sticky only works for a child OF the scroller and
 * the composer is deliberately outside the swapped region. The thread gets
 * bottom padding equal to the dock's height so the last turn is never left
 * under it. */
@media (max-width: 639.98px) {
  .imx-chat-pane { position: relative; }
  .imx-chat-scroll {
    /* A phone had NO horizontal gutter: measured at 390px, the agent's prose
       ran 0 → 390, touching both edges, and the owner's bubble touched the
       right one. Text against the glass is the first thing that reads as
       unfinished. */
    padding-inline: var(--imx-sp-4);
    scrollbar-width: none; scrollbar-gutter: auto;
    /* The dock's REAL height, published by the composer's ResizeObserver.
       A fixed value is a guess that goes stale as soon as an attachment chip
       or the upload bar grows the dock, and the dock then covers the newest
       lines. 5.5rem stays as the pre-measurement fallback.

       Deliberately outside the design-token namespace: a token is a design
       decision defined in tokens.json, and this is a measurement written by
       JS at runtime. Keeping it out means the token guard's "referenced but
       undefined" check keeps meaning exactly what it says. */
    padding-bottom: calc(var(--dock-h, 5.5rem) + var(--imx-sp-2));
  }
  .imx-chat-scroll::-webkit-scrollbar { width: 0; }
  .imx-composer-dock {
    position: absolute; left: 0; right: 0; bottom: 0; margin: 0;
    /* Shares the conversation's gutter — the composer and the thread must sit
       on one axis, the same rule the reading column follows above 640. */
    padding: var(--imx-sp-2) var(--imx-sp-4);
    background: color-mix(in srgb, var(--imx-bg) 72%, transparent);
    -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
  }
  /* The second header. Below 640 the back arrow lives in the top bar
     (chat_topbar.html), so this row is redundant chrome — ~200px of the first
     screenful shared with the bar above it. */
  .imx-chat-back { display: none; }
}

/* A conversation in the rail, marked as one.
 *
 * The reference puts a small ring before every chat so the list reads as
 * items rather than as more navigation — the rail's sections and its
 * conversations are different kinds of thing sharing one column, and nothing
 * said so. Hollow, `currentColor`, and it inherits the row's muted/active
 * colour, so it never becomes a second accent. */
.imx-rail-item--chat .imx-rail-item__mark {
  flex: none; width: .5rem; height: .5rem; border-radius: 50%;
  border: 1px solid currentColor; opacity: .45;
}
.imx-rail-item--chat[aria-current="page"] .imx-rail-item__mark { opacity: .9; }
/* The last row of the list, not one of its items: it goes somewhere else. */
.imx-rail-item--more { font-size: var(--imx-fs-sm); opacity: .75; }

/* A folded run of tool work. The summary is one quiet line like a tool row;
   what it hides is indented so an expanded group reads as belonging to it
   rather than as the transcript resuming. */
.imx-tool-group > summary { list-style: none; cursor: pointer; }
.imx-tool-group > summary::-webkit-details-marker { display: none; }
.imx-tool-group[open] .imx-tool__caret { transform: rotate(90deg); }
.imx-tool-group__body {
  margin-left: var(--imx-sp-3);
  padding-left: var(--imx-sp-3);
  border-left: 1px solid var(--imx-border);
}
/* Narration is not an answer and must not be set like one: no serif, and the
   muted colour of provenance. */
.imx-tool-group__note .imx-prose {
  font-family: var(--imx-font-ui);
  font-size: var(--imx-fs-sm);
  color: var(--imx-text-muted);
}

/* ---------------------------------------------------- attachments in flight

   What is staged, and how far it got. Both live between the transcript and
   the composer box, inside the form, so they share the composer's axis and
   move with it — including the phone's docked position.

   The chips carry name AND size because the two rejections a reader can hit
   (over 1 MB, more than five) are both size-shaped, and finding out after a
   round trip is the worst moment to learn it. `--over` states the reason at
   pick time instead. */
.imx-attach-row {
  display: flex; flex-wrap: wrap; gap: var(--imx-sp-2);
  width: 100%; max-width: var(--imx-chat-col); margin: 0 auto var(--imx-sp-2);
}
.imx-attach-chip {
  display: inline-flex; align-items: center; gap: var(--imx-sp-2);
  max-width: 100%;
  padding: var(--imx-sp-1) var(--imx-sp-1) var(--imx-sp-1) var(--imx-sp-3);
  background: var(--imx-surface-hover);
  border: 1px solid var(--imx-border);
  border-radius: var(--imx-r-pill);
  font-size: var(--imx-fs-xs); color: var(--imx-text-muted);
}
.imx-attach-chip__name {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--imx-text);
}
.imx-attach-chip__size { flex: none; color: var(--imx-text-faint); font-variant-numeric: tabular-nums; }
.imx-attach-chip__x {
  flex: none; border: 0; background: none; cursor: pointer; line-height: 1;
  padding: 0 var(--imx-sp-2); color: var(--imx-text-faint); font-size: var(--imx-fs-md);
}
.imx-attach-chip__x:hover { color: var(--imx-text); }
/* `--imx-error` for the edge and `--imx-error-text` for the words — both are
   real tokens. The first draft named an `error-border` token that does not
   exist and gave it a fallback, which rendered fine and was still wrong — the
   fallback hid the invented name. `tokens.test.mjs` refuses a reference to an
   undefined token precisely so a typo cannot survive behind a default that
   happens to look right.

   That guard scans the source WITHOUT stripping comments, so naming the bad
   token in prose fails the build too. Arguably over-strict; it is also why
   this paragraph describes it instead of quoting it, and a comment that
   cannot mention a token is a cheaper price than a scan that skips them. */
.imx-attach-chip--over {
  border-color: var(--imx-error);
  color: var(--imx-error-text);
}
.imx-attach-chip--over .imx-attach-chip__size { color: inherit; }

/* The bar is determinate — htmx's XHR reports loaded/total — so it says how
   far, not merely that something is happening. It tracks the SEND, because on
   this path the files ride the message. */
.imx-upload {
  display: flex; align-items: center; gap: var(--imx-sp-3);
  width: 100%; max-width: var(--imx-chat-col); margin: 0 auto var(--imx-sp-2);
}
.imx-upload__track {
  flex: 1 1 auto; height: 4px; border-radius: 2px;
  background: var(--imx-surface-hover); overflow: hidden;
}
.imx-upload__fill {
  height: 100%; width: 0; border-radius: 2px;
  background: var(--imx-action);
  transition: width var(--imx-dur-fast) var(--imx-ease);
}
.imx-upload__label {
  flex: none; font-size: var(--imx-fs-xs); color: var(--imx-text-muted);
  font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) { .imx-upload__fill { transition: none; } }

/* New chat uses the exact same composer primitive as a live conversation.
   The page only supplies the vertical relationship between greeting and dock. */
.imx-new-chat {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; justify-content: center;
  gap: var(--imx-sp-3); width: 100%;
}
.imx-new-chat .imx-greeting { margin-bottom: var(--imx-sp-5); }
.imx-execution-trigger {
  display: inline-flex; align-items: center; gap: var(--imx-sp-1);
  border: 0; background: transparent; color: var(--imx-text-muted);
  font: inherit; font-size: var(--imx-fs-sm); cursor: pointer;
  white-space: nowrap; padding: var(--imx-sp-1); border-radius: var(--imx-r-sm);
}
.imx-execution-trigger:hover { color: var(--imx-text); background: var(--imx-surface-hover); }

/* Profile + runtime picker. Radios remain native form controls for submission
   and accessibility, but the row is the visual/interactive target. */
.imx-picker-label {
  margin: var(--imx-sp-4) 0 var(--imx-sp-2);
  color: var(--imx-text-faint); font-size: var(--imx-fs-2xs);
  font-weight: var(--imx-fw-semi); letter-spacing: .07em; text-transform: uppercase;
}
.imx-picker-label:first-child { margin-top: 0; }
.imx-picker-row {
  display: flex; align-items: center; gap: var(--imx-sp-3);
  min-height: 3.45rem; padding: var(--imx-sp-2) var(--imx-sp-3);
  border-radius: var(--imx-r-sm); cursor: pointer;
}
.imx-picker-row:hover { background: var(--imx-surface-hover); }
.imx-picker-row:has(input:checked) { background: var(--imx-surface-selected); }
.imx-picker-row input { position: absolute; opacity: 0; pointer-events: none; }
.imx-picker-row > span:nth-of-type(2),
.imx-picker-row > span:first-of-type:not(.imx-profile-mark) {
  display: flex; flex: 1 1 auto; min-width: 0; flex-direction: column;
}
.imx-picker-row strong { font-size: var(--imx-fs-md); font-weight: var(--imx-fw-medium); }
.imx-picker-row small { color: var(--imx-text-muted); font-size: var(--imx-fs-xs); }
.imx-picker-check { margin-left: auto; color: var(--imx-action); opacity: 0; }
.imx-picker-row:has(input:checked) .imx-picker-check { opacity: 1; }
.imx-profile-mark {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 1.75rem; height: 1.75rem; border-radius: 999px;
  background: var(--imx-action-weak); color: var(--imx-action);
  font-size: var(--imx-fs-xs); font-weight: var(--imx-fw-semi);
}
.imx-sheet-handle { display: none; }

@media (max-width: 640px) {
  .imx-new-chat { justify-content: center; padding-bottom: 5rem; }
  .imx-new-chat .imx-composer__foot { justify-content: flex-end; }
  .imx-new-chat .imx-composer__hint { display: none; }
  .imx-runtime-sheet .imx-modal__box {
    padding-bottom: env(safe-area-inset-bottom, 0px);
    border-radius: var(--imx-r-lg) var(--imx-r-lg) 0 0;
  }
  .imx-sheet-handle {
    display: block; width: 2.25rem; height: .25rem; margin: .75rem auto .15rem;
    border-radius: 999px; background: var(--imx-border-strong);
  }
}
