/* app tokens (080): node-type accents
 * The editor-v2 canvas (mock 2026-09-05-editor-canvas-v2.html) runs on these. The mock
 * declares light AND dark literals; the app themes by swapping the design-system
 * stylesheet, which has no per-theme hook an app stylesheet could key on — so every
 * token the mock itself derived from the design system BRIDGES to it (and matches the
 * mock's hex exactly in both themes: accent-info/-bg = dql, accent-warning/-bg = dml,
 * accent-success/-bg = pipeline, text-link = brand, accent-success = edge-done), and
 * the rest are color-mix bridges off theme tokens, which puts every one of the nine
 * themes within a few percent of the mock instead of light/dark alone. The two hues
 * with no design-system counterpart (ddl slate, calc violet) are category colours and
 * stay constant: the icon and its tile travel as a pair whose internal contrast
 * (7.0:1 and 5.9:1) holds on every theme's card surface. 079 (shell v2) is told this
 * block exists and must not redeclare it. */
:root {
  --type-dql: var(--accent-info);
  --type-dql-bg: var(--accent-info-bg);
  --type-dml: var(--accent-warning);
  --type-dml-bg: var(--accent-warning-bg);
  --type-ddl: #475569;
  --type-ddl-bg: #e2e8f0;
  --type-pipeline: var(--accent-success);
  --type-pipeline-bg: var(--accent-success-bg);
  --type-calc: #7c3aed;
  --type-calc-bg: #ede9fe;
  --brand: var(--text-link);
  --brand-soft: color-mix(in srgb, var(--brand) 16%, var(--surface-default));
  --border-faint: color-mix(in srgb, var(--border-subtle) 40%, var(--surface-default));
  --grid-dot: color-mix(in srgb, var(--text-muted) 22%, var(--surface-page));
  --edge: color-mix(in srgb, var(--text-muted) 65%, var(--surface-page));
  --edge-active: var(--brand);
  --edge-done: var(--accent-success);
}

/* Application-level semantic tokens — derive from design system, never hardcoded hex */
:root {
  --app-sidebar-width: 280px;
  --app-sidebar-collapsed: 0px;
  --app-detail-width: 320px;
  /* Banner state fills (pipeline-editor.css .pe-banner) — the graph itself now uses
     the --node-*-accent tokens below; a theme overriding these re-themes both. */
  --node-success-bg: var(--accent-success, #16a34a);
  --node-success-text: var(--accent-primary-text, #ffffff);
  --node-failed-bg: var(--accent-danger, #dc2626);
  --node-failed-text: var(--accent-danger-text, #ffffff);
  --node-aborted-bg: var(--accent-warning, #f59e0b);
  --node-aborted-text: var(--accent-primary-text, #000000);
  --edge-active-stroke: var(--accent-primary, #2563eb);
  --edge-idle-stroke: var(--text-secondary, #6b7280);
  /* Graph node cards (pipeline-editor.md §5.3). The card is a neutral surface; TYPE is
     carried by shape and STATE by an accent border — colour never has to carry both. */
  --node-surface: var(--surface-raised, #f9fafb);
  --node-border: var(--border-default, #d1d5db);
  --node-label-text: var(--text-primary, #111827);
  --node-selected-ring: var(--accent-primary, #2563eb);
  --node-selected-halo: var(--accent-primary, #2563eb);
  --node-running-accent: var(--accent-primary, #2563eb);
  --node-success-accent: var(--accent-success, #16a34a);
  --node-failed-accent: var(--accent-danger, #dc2626);
  --node-aborted-accent: var(--accent-warning, #f59e0b);
}

/* ---------------------------------------------------------------------------
   079 §G — VENDORED WEBFONTS (Inter, JetBrains Mono; both SIL OFL-1.1).

   The design system NAMES both faces but ships neither: `saas`, `ocean`,
   `professional`, `auto`, `forest` and `healthcare` set `--_font-sans` to
   "Inter", and four of them set `--_font-mono` to "JetBrains Mono" — while
   `light`, `dark` and `minimal` name NEITHER. So the app rendered in whatever
   the machine happened to have, and the two themes the mode toggle switches
   between (light/dark) were exactly the two with no brand face at all. That is
   why the app never looked like the mocks anywhere but a designer's laptop.

   Vendored from the projects' own GitHub release assets, hashes recorded in
   vendor/design-system/vendor-manifest.json (one manifest for every vendored
   asset — DEVELOPMENT.md §"design system"). NO Google Fonts, no CDN: a webfont
   from a third-party host is a runtime dependency AND a per-visitor request to
   someone else's log.  `VendoredFontsAuditTest` fails the build if a file goes
   missing, a hash drifts, an OFL.txt disappears, or any stylesheet/template
   starts referencing a font host over the network.

   URLs are RELATIVE (`../vendor/...`), not absolute: this file is served at
   /css/app.css, so a relative URL survives a non-root context path — the same
   reason partials/theme-swap.html renders `@{...}` instead of a built string.

   090 §B — `font-display: optional`, replacing 079's `swap`.

   `swap` paints in the fallback and REFLOWS when the face lands. Measured (090,
   chromium, every font response held 1.2s, geometry sampled at the first frame
   after DOMContentLoaded and again after `document.fonts.ready`):

     /dashboard  the page heading  243.5px -> 251.7px   (+8.2px, +3.4%)
     /templates  the page heading  611.7px -> 618.2px   (+6.5px, +1.1%)

   Cumulative layout shift stayed low (0 and 0.0075) because the reflow is
   HORIZONTAL inside a block whose box does not move — which is precisely why CLS
   was the wrong instrument here and the first-frame geometry is the right one.
   The owner did not report a jumping page; they reported text that changes after
   it has already been read ("css is applied after data load"), and 6-8px of
   re-flowed measure on every heading is that.

   `optional` gives the browser a ~100ms block period and then NO swap window: the
   face is either ready in time and used from the first paint, or dropped for that
   page load and used from the next one (the file is already in the HTTP cache by
   then). Post-paint reflow becomes structurally impossible rather than small. The
   faces are vendored, same-origin and `<link rel=preload>`ed in both layouts, so
   the ready-in-time branch is the normal one; the cost is that a first visit over
   a slow link renders in the fallback stack.

   `fallback` (100ms block + a 3s swap window) is the alternative if that cost is
   judged too high — it restores the reflow inside a bounded window. Choosing it
   would want size-adjusted fallback faces first; the ratios are measured and
   ready (Inter / system-ui = 102.59%, JetBrains Mono / ui-monospace = 109.48%),
   but the `--_font-sans` stack they must be inserted into lives in the vendored
   design-system themes, outside this round's fence.

   `unicode-range` is left at its default (the full coverage of each file).
   --------------------------------------------------------------------------- */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  font-display: optional;
  src: url("../vendor/fonts/inter/InterVariable.woff2") format("woff2");
}

@font-face {
  font-family: "Inter";
  font-style: italic;
  font-weight: 100 900;
  font-display: optional;
  src: url("../vendor/fonts/inter/InterVariable-Italic.woff2") format("woff2");
}

@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 400;
  font-display: optional;
  src: url("../vendor/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2") format("woff2");
}

@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 500;
  font-display: optional;
  src: url("../vendor/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2") format("woff2");
}

/* app tokens (080) — this block is OWNED BY ROUND 080 (`static/css/app.css`,
   the `--type-*` scale and the brand tints). 079 needs three of its values
   before 080 merges, so it declares them here under the same label and with
   the same names; the merge dedupes the block. Do not rename these.

   Every value DERIVES from a design-system token, so a theme swap re-tints the
   shell for free and no literal enters this file (the standing rule,
   ui-screens.md §2.3 / pipeline-editor.md §3.4):

   - `--brand`       the theme's own accent — the active nav item, links, focus.
   - `--brand-soft`  `--surface-selected`, the token the design system's contrast
                     audit sizes FOR a selected row (ui-screens §2.8: a tint
                     alone cannot reach 3:1, so selection also carries a 3px
                     `--border-focus` bar — see .app-nav-link[aria-current]).
   - `--border-faint` `--border-subtle`, the 2:1 separator floor. The rail edge,
                     card headers and table rules are separators, not component
                     boundaries; `--border-default` (3:1) makes every rule heavy.
   --------------------------------------------------------------------------- */
:root {
  --brand: var(--accent-primary);
  --brand-soft: var(--surface-selected);
  --border-faint: var(--border-subtle);

  /* 079 §G: the vendored faces lead every stack, whatever the theme said. The
     design system's own fallbacks follow, so a blocked/failed font download
     degrades to exactly today's rendering. */
  --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}

/* `[hidden]` must WIN. The UA stylesheet's `[hidden] { display: none }` is a
   user-agent rule, so ANY author class that sets `display` beats it — including
   the design system's own `.ds-icon { display: inline-block }`. The first
   screenshot pass caught it: the top bar's sun AND moon rendered together,
   because `hidden` on a `.ds-icon` did nothing at all. This is the one place in
   this file `!important` is correct — the whole point of the attribute is that
   it outranks presentation, and every element hidden by an attribute in this app
   (the avatar menu, the mode icons, the flash bins) depends on it. */
[hidden] {
  display: none !important;
}

/* The body canvas. base.css paints <html> with these tokens but leaves <body> to
   inherit; this rule paints the body itself, so the canvas survives any reset
   layered between the design system and this file (this file loads last, §3.4).
   Bootstrap's reboot used to be that reset — it painted <body> concrete white
   under every theme (024 T40 cause 2). Bootstrap is gone (076 §C); the rule
   stays as the standing guard over whatever ships hereafter. */
/* An ANCHOR styled as a button. base.css's `a:hover` (specificity 0,1,1 — element +
   pseudo-class) outranks `.ds-button-primary` (0,1,0), so on hover the link colour and
   an underline won: on the primary variant the label turned accent-on-accent and
   vanished (owner, the login page's "Continue with Google", 2026-09-11). The design
   system is vendored (sync-design-system.sh), so the correction lives here, at a
   specificity that wins, one rule per variant that sets its own text colour. */
a.ds-button:hover,
a.ds-button:focus-visible {
  text-decoration: none;
}

a.ds-button-primary:hover {
  color: var(--accent-primary-text);
}

a.ds-button-secondary:hover {
  color: var(--accent-secondary-text);
}

a.ds-button-danger:hover {
  color: var(--accent-danger-text);
}

a.ds-button-ghost:hover {
  color: var(--text-primary);
}

body {
  background-color: var(--surface-page);
  color: var(--text-primary);
}

/* ---------------------------------------------------------------------------
   App shell (layouts/default.html). The vendored design system is synced from
   design-system-starter (scripts/sync-design-system.sh) — do NOT edit vendor
   files; app-level chrome and component polish layer on top here.
   --------------------------------------------------------------------------- */

/* 076 §A — one width policy (ui-screens.md §3.x "Shell and width policy").
   EVERY app screen is full-bleed: the nav and <main> span the viewport minus
   one gutter (the editor's 065 opt-in became the rule; --app-content-max and
   .app-main-bleed are gone). Reading content — docs prose, settings cards,
   empty states, forms — gets .app-reading instead of a different container. */
.app-container {
  width: 100%;
  padding-inline: var(--gap-lg);
}

.app-main {
  padding-block: var(--gap-lg);
}

/* The reading column: prose and cards sit in a 90ch measure, LEFT-aligned
   inside the full-bleed main — never centred, never a second container.
   Tables and trees never use it. */
.app-reading {
  max-width: 90ch;
}

/* Stacked page sections (settings pages' former inline grid). */
.app-stack {
  display: grid;
  gap: var(--gap-lg);
}

/* Dashboard stat cards (the former inline grid-template-columns). */
.app-stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
  gap: var(--gap-md);
}

/* Centred single-card pages (login, error pages). */
.app-center {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 70vh;
}

.app-card-auth {
  max-width: 26rem;
  width: 100%;
}

/* Hand-rolled modals (register datasource, create template, create API key):
   the width the inline max-width used to carry, on tokens. */
.app-modal {
  position: relative;
  width: 100%;
  max-height: 90vh;
  overflow: auto;
  padding: var(--gap-lg);
}

.app-modal-sm {
  max-width: 30rem;
}

.app-modal-md {
  max-width: 32rem;
}

.app-modal-lg {
  max-width: 35rem;
}

/* List-page search boxes (pipelines, templates, datasources). */
.app-search-input {
  flex: 1 1 15rem;
  min-width: 11rem;
  max-width: 22rem;
}

/* Inline-width form inputs (admin user forms). */
.app-input-inline {
  flex: 1;
  max-width: 17.5rem;
}

.app-input-inline-lg {
  flex: 1;
  max-width: 20rem;
}

/* The 076 horizontal nav (.app-nav-inner / .app-nav-left / .app-nav-links and
   their brand, switcher and logout rules) was DELETED here in 079 §A — the nav
   moved into the left rail and its rules live in the shell section at the foot of
   this file. `.app-logout-form` survives there as the avatar menu's last row; it
   is still the `hx-boost="false"` full navigation `ShellRenderTest` pins. */

/* ---------------------------------------------------------------------------
   Data tables — boxed, header fill, closed rounded corners. Layered over the
   vendored .ds-table primitives (this file loads last, §3.4; row hover, the
   last-row border removal and the `.num` alignment already live there).
   Border model switches to `separate` so the outer border + radius can close;
   corners are clipped via the corner cells (the thead fill and row-hover fill
   would otherwise paint over the rounded border).
   --------------------------------------------------------------------------- */
.ds-table {
  border-collapse: separate;
  border-spacing: 0;
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--surface-default);
  /* 076 §D: every table aligns figures — dates and counts share a column's rhythm.
     `.num` cells keep their mono face on top of this; dates render proportional
     but tabular, so a column of timestamps still scans. */
  font-variant-numeric: tabular-nums;
}

.ds-table thead th {
  background: var(--surface-inset);
  /* Vendored rule hangs the header separator on <thead>, which the separate
     border model ignores — restate it on the cells. */
  border-bottom: 1px solid var(--border-default);
}

.ds-table thead th:first-child {
  border-top-left-radius: calc(var(--radius-md) - 1px);
}

.ds-table thead th:last-child {
  border-top-right-radius: calc(var(--radius-md) - 1px);
}

.ds-table tbody tr:last-child td:first-child {
  border-bottom-left-radius: calc(var(--radius-md) - 1px);
}

.ds-table tbody tr:last-child td:last-child {
  border-bottom-right-radius: calc(var(--radius-md) - 1px);
}

/* ---------------------------------------------------------------------------
   076 §B — boosted-navigation progress bar (layouts/default.html #app-progress,
   shell.js toggles .active off htmx:beforeRequest/htmx:afterSettle). The bar is
   the swap's "loading" signal: boosted swaps must not flash the document, so
   this does the talking instead — 2px under the sticky nav, tokens only.
   --------------------------------------------------------------------------- */
.app-progress {
  position: fixed;
  top: calc(var(--header-height) + var(--space-px));
  left: 0;
  right: 0;
  height: calc(var(--space-px) * 2);
  z-index: var(--z-fixed);
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--duration-fast) var(--ease-out);
}

.app-progress.active {
  opacity: 1;
}

.app-progress::before {
  content: "";
  display: block;
  height: 100%;
  width: 33%;
  background: var(--accent-primary);
  animation: app-progress-slide var(--duration-slower) var(--ease-linear) infinite;
}

@keyframes app-progress-slide {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(400%);
  }
}

/* Reduced motion: the show/hide stays (it is information, not decoration);
   the slide goes. */
@media (prefers-reduced-motion: reduce) {
  .app-progress::before {
    animation: none;
    width: 100%;
  }
}

/* ---------------------------------------------------------------------------
   085 §D — the busy originating control (shell.js's companion to the
   every-request progress bar above; ui-screens.md §5.1).

   `.app-busy` is the SHELL's own marker: shell.js adds it to the requesting
   element on htmx:beforeRequest and removes it on the terminal event. Not
   htmx's `.htmx-request` — htmx 2.0.10 applies that class to the hx-indicator
   TARGET instead when the element carries hx-indicator
   (addRequestIndicatorClasses in the vendored dist), so the tree leaves, which
   point at #template-detail-spinner, would never be marked on themselves.

   A BUTTON in that state gets two things: non-interactive (pointer-events
   off — shell.js adds aria-disabled="true" for the AT half of the same fact;
   the disabled property is deliberately NOT used — htmx already guards
   re-triggering, and a really-disabled button would drop focus mid-flight)
   and a spinner affordance as an ::after ring drawn in the .ds-spinner idiom
   (primitives.css:1196 — 2px ring, --accent-primary top, the ds-spin keyframes
   from motion.css; the vendored literal 1s becomes the house duration token,
   which is why this is a restatement and not the .ds-spinner class).

   The ring is ABSOLUTELY positioned at the control's trailing edge, so the
   control's box never changes — ui-screens.md §5.1's no-layout-shift rule. For
   a .ds-button it lands inside the trailing padding; for a full-width tree row
   (.tpl-leaf, whose trailing badges would sit under the ring) the badges are
   visibility:hidden for the duration — visibility, not display, so their space
   is kept and the row still does not shift.

   <summary> is deliberately NOT in this rule: a folder summary must stay
   operable while its level loads (collapse/re-expand mid-fetch is pinned
   behaviour, ExplorerStressBrowserTest), and its busy signal is the spinning
   chevron in template-tree.css. Elements that carry their own hx-indicator
   spinner keep it — this state is additive, never a replacement.
   --------------------------------------------------------------------------- */
button.app-busy {
  position: relative;
  pointer-events: none;
}

button.app-busy::after {
  content: "";
  position: absolute;
  inset-inline-end: var(--gap-xs);
  inset-block: 0;
  margin-block: auto;
  width: var(--space-4);
  height: var(--space-4);
  border: 2px solid var(--border-subtle);
  border-top-color: var(--accent-primary);
  border-radius: var(--radius-full);
  animation: ds-spin var(--duration-slower) var(--ease-linear) infinite;
}

.tpl-leaf.app-busy .ds-badge {
  visibility: hidden;
}

/* Reduced motion, the .ds-spinner precedent (primitives.css:1451): the busy
   state stays — it is information — the spin goes, and the ring reads as a
   static dashed marker instead. */
@media (prefers-reduced-motion: reduce) {
  button.app-busy::after {
    animation: none;
    border-style: dashed;
    opacity: 0.7;
  }
}

/* 085 §D — the delayed swap-target skeleton (shell.js appends ONE of these to
   a request's target still in flight after 150ms; a fast swap never sees it).
   The look is entirely the design system's skeleton family
   (primitives.css:345 — shimmer, and its own reduced-motion static surface);
   these three declarations only seat ONE row inside a pane: .ds-skeleton-table-row's
   width:100% would overflow these margins, and in a centering flex pane
   (#template-detail's quiet state) an unstretched row would collapse to
   content width — zero, since the row is empty. */
.app-target-skeleton {
  width: auto;
  align-self: stretch;
  margin: var(--gap-md);
}

/* ---------------------------------------------------------------------------
   076 §C — element defaults Bootstrap's reboot used to provide.

   Bootstrap 5.3.8 is removed from the app; base.css zeroes margins/padding
   globally but styles no elements. A grounded sweep of everything rendering
   inside .app-main (pages AND the partials htmx swaps into it) found these
   bare elements had been leaning on the reboot:

   - <code> — unclassed inline code on the promotion screen, pipelines list
     and tree, workspaces, and the node inspector's pe-value cells. The reboot
     made it small monospace on a tinted chip; without it the code rendered in
     body sans. Restored below, mirroring the docs.css / .ds-mono idiom.
   - <pre> — every <pre> rendered today carries a class (.pe-sql, .pe-error-*,
     .te-preview-pane) or inline styles, so those win by specificity; the rule
     below is the standing default for any future bare <pre>.
   - <strong> / <small> — <strong> appears unclassed on the promotion screen;
     <small> is not rendered anywhere yet. Without Bootstrap both fall back to
     the UA stylesheet (bolder / smaller); the rules below pin them to tokens
     instead of browser defaults.

   Deliberately NOT restored — the sweep showed nothing relying on them:
   - h1-h6 / p margins: every app-chrome heading and paragraph carries a ds-*
     class, an inline margin, or a rule in pipeline-editor.css /
     template-editor.css (.te-panel-head h2 and .pe-modal-header h2 already
     set margin:0; the modal-body and expired-card <p>s sit inside padded
     containers, so the reboot's bottom margin was invisible there).
   - dl / blockquote / bare tables: only rendered markdown emits those, and
     markdown lands in .doc-body, which docs.css styles in full.
   --------------------------------------------------------------------------- */
.app-main code {
  font-family: var(--font-mono);
  font-size: 0.9em;
  background: var(--surface-inset);
  border-radius: var(--radius-sm);
  padding-inline: var(--gap-xs);
}

.app-main pre {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  background: var(--surface-inset);
  border-radius: var(--radius-md);
  padding: var(--gap-md);
  overflow-x: auto;
  margin-bottom: var(--gap-md);
}

/* Inline-code chip must not double-paint inside a code block. */
.app-main pre code {
  background: none;
  padding: 0;
}

.app-main strong {
  font-weight: var(--weight-semibold);
}

.app-main small {
  font-size: var(--text-xs);
}
/* ---------------------------------------------------------------------------
   079 §A/§B — THE SHELL: a collapsible left rail and a top bar, replacing the
   076 horizontal nav (design-records/mocks/2026-09-05-app-shell-v2.html, owner
   approved 2026-09-05).

   Layout is ONE grid on <div class="app-shell">: the rail spans both rows, the
   top bar is row 1 of the content column, <main> is row 2.

   THE SHELL IS THE VIEWPORT (owner, 2026-09-11: "Global page has a vertical
   scroll bar which it should not. Each pane is responsible for its contents.").
   The shell is exactly 100dvh tall and <main> is the ONE scroll container under
   the bar; the document itself never scrolls, so the rail and the bar stay put
   and a screen built from panes (the explorers, the editors) never drags the
   whole application when one pane runs a few pixels long. Long single-column
   screens (/workspaces, /docs, /settings) scroll INSIDE <main> instead of the
   page — same motion, header and rail fixed. 079 kept the document scrolling
   because four stylesheets size their panes with `calc(100dvh -
   var(--header-height) - …)`; that arithmetic is unchanged and still correct —
   <main> still starts at the bottom of the bar and its padding is the same —
   it only stops being a reason to let the page move.

   --app-rail-width is a GEOMETRY value, not a colour: the same kind of literal
   --app-sidebar-width has carried since 076. Every colour, radius, shadow and
   type size below resolves through a design-system token.
   --------------------------------------------------------------------------- */
:root {
  /* 110: --app-rail-width keeps meaning "how wide the rail is right now" (the shell
     overrides it to --app-rail-collapsed when the collapse class is on); the expanded
     value is its own token so the phone drawer can always be full-size without
     re-declaring the geometry literal. */
  --app-rail-expanded: 232px;
  --app-rail-width: var(--app-rail-expanded);
  --app-rail-collapsed: 60px;
}

.app-shell {
  display: grid;
  grid-template-columns: var(--app-rail-width) 1fr;
  grid-template-rows: var(--header-height) minmax(0, 1fr);
  height: 100dvh;
  overflow: hidden;
}

/* <main> is the scroll container (see the shell note above). `min-height: 0`
   lets the grid row shrink below its content so the overflow is main's, not the
   shell's; without it the row grows with the content and nothing scrolls. */
.app-shell > .app-main {
  min-height: 0;
  overflow-y: auto;
  overflow-x: auto;
}

/* Signed out (login, the public docs, error pages): no rail, no top bar, one
   column. This is also what keeps `nav.app-nav` at zero occurrences for an
   anonymous request — NavigationGoldenPathBrowserTest asserts exactly that. */
.app-shell-anon {
  grid-template-columns: 1fr;
}

/* Signed out: the bar keeps the brand and drops everything that describes a
   session. Its border is the only chrome an anonymous screen has. */
.app-topbar-anon .app-brand {
  border-bottom: 0;
  padding-inline: 0;
}

/* The collapsed state is a class on <html>, set by the layout's one inline
   script BEFORE the first paint and toggled by shell.js afterwards, so the rail
   never renders at one width and snaps to the other in front of the reader. */
:root.rail-collapsed .app-shell {
  --app-rail-width: var(--app-rail-collapsed);
}

.app-rail {
  grid-row: 1 / 3;
  position: sticky;
  top: 0;
  align-self: start;
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--surface-default);
  border-right: 1px solid var(--border-faint);
}

/* Everything that is TEXT in the rail disappears when it collapses; the icons,
   the brand tile and the workspace avatar stay. One class does it, so a row
   added later collapses correctly by carrying `.app-rail-label` on its label.
   110: the collapse is a DESKTOP-and-tablet state — below 768px the rail is the
   drawer and always shows its full content, so every visual collapse rule is
   scoped to the widths where the drawer cannot exist. */
@media (min-width: 768px) {
  :root.rail-collapsed .app-rail-label {
    display: none;
  }
}

.app-brand {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  height: var(--header-height);
  padding-inline: var(--gap-md);
  border-bottom: 1px solid var(--border-faint);
  white-space: nowrap;
  font-weight: var(--weight-bold);
  font-size: var(--text-lg);
  letter-spacing: var(--tracking-tight);
  color: var(--text-primary);
  text-decoration: none;
  flex: none;
}

@media (min-width: 768px) {
  :root.rail-collapsed .app-brand {
    justify-content: center;
    padding-inline: 0;
  }
}

/* The mark ("the join", 2026-09-04) sits on a brand tile now: the strokes are
   the inverted text colour, the tile is the accent, so the glyph reads on
   either theme without the mark itself knowing which one is active. */
.app-brand-tile {
  width: var(--icon-xl);
  height: var(--icon-xl);
  border-radius: var(--radius-md);
  background: var(--brand);
  color: var(--accent-primary-text);
  display: grid;
  place-items: center;
  flex: none;
}

.app-brand-mark {
  width: var(--icon-md);
  height: var(--icon-md);
  color: inherit;
  flex: none;
}

/* ---- workspace switcher (the card under the brand) ---- */
.app-workspace-form {
  position: relative;
  margin: var(--gap-sm) var(--gap-sm) var(--gap-xs);
  flex: none;
}

.app-ws {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  padding: var(--gap-sm);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
}

.app-ws:hover {
  border-color: var(--border-hover);
}

@media (min-width: 768px) {
  :root.rail-collapsed .app-ws {
    justify-content: center;
    padding-inline: 0;
  }
}

.app-ws-avatar {
  width: var(--icon-lg);
  height: var(--icon-lg);
  border-radius: var(--radius-base);
  background: var(--brand-soft);
  color: var(--brand);
  display: grid;
  place-items: center;
  font-size: var(--text-xs);
  font-weight: var(--weight-bold);
  flex: none;
}

.app-ws-text {
  min-width: 0;
}

.app-ws-text b {
  display: block;
  font-weight: var(--weight-semibold);
  font-size: var(--text-sm);
  overflow: hidden;
  text-overflow: ellipsis;
}

/* 114 §C.4 — the role badge under the workspace name. Its own line rather than beside
   the name: a long workspace name already ellipses, and a badge competing for that row
   would be the first thing to disappear. The size follows the label above it. */
.app-ws-role {
  display: inline-block;
  margin-top: 2px;
  font-size: var(--text-xs);
  line-height: 1.4;
  text-transform: lowercase;
}

.app-ws-caret {
  margin-left: auto;
  color: var(--text-muted);
}

.app-workspace-label {
  display: block;
  color: var(--text-muted);
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

/* The real control sits invisibly over its card: a <select> that is present to
   the accessibility tree and to Playwright, styled away so the card shows. It
   is NOT `display:none` — a hidden select cannot be operated by keyboard, and
   `WorkspaceSwitcherBrowserTest` drives this exact element. */
.app-workspace-select {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: pointer;
  border: 0;
  font: inherit;
}

/* ---- nav ---- */
.app-nav {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: var(--gap-xs) var(--gap-sm);
  display: grid;
  gap: var(--space-0-5);
  align-content: start;
}

.app-nav-section {
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-muted);
  font-weight: var(--weight-semibold);
  padding: var(--gap-sm) var(--gap-sm) var(--space-1);
  white-space: nowrap;
}

.app-nav-link {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  padding: var(--space-1-5) var(--gap-sm);
  border-radius: var(--radius-base);
  color: var(--text-secondary);
  text-decoration: none;
  font-weight: var(--weight-medium);
  font-size: var(--text-sm);
  white-space: nowrap;
  overflow: hidden;
  /* The selected item's 3px accent bar (below) is drawn as a border so the row
     never shifts when it becomes active. */
  border-left: var(--focus-ring-offset, 3px) solid transparent;
}

.app-nav-link svg {
  color: var(--text-muted);
}

.app-nav-link:hover {
  background: var(--surface-hover);
  color: var(--text-primary);
}

/* The active item. ui-screens.md §2.8 is normative here: `surface-selected` is
   the token the design system's contrast audit sizes FOR a selected row, and it
   reaches only 1.5:1 — "a tint alone cannot reach 3:1 without turning grey" —
   so selection also carries a 3px `border-focus` bar. The mock paints the tint
   and the brand text only; the bar is the accessible half of the same signal
   and is the one deliberate addition to it. */
.app-nav-link.active,
.app-nav-link[aria-current="page"] {
  background: var(--brand-soft);
  color: var(--brand);
  border-left-color: var(--border-focus);
}

.app-nav-link.active svg,
.app-nav-link[aria-current="page"] svg {
  color: var(--brand);
}

.app-nav-badge {
  margin-left: auto;
  font-size: var(--text-xs);
  padding: 0 var(--gap-xs);
  border-radius: var(--radius-pill);
  background: var(--surface-inset);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

@media (min-width: 768px) {
  :root.rail-collapsed .app-nav-link {
    justify-content: center;
    padding-inline: 0;
  }
}

.app-rail-foot {
  flex: none;
  padding: var(--gap-sm);
  border-top: 1px solid var(--border-faint);
}

.app-rail-collapse {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  width: 100%;
  padding: var(--space-1-5) var(--gap-sm);
  border: 0;
  border-radius: var(--radius-base);
  background: none;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--text-sm);
  white-space: nowrap;
  cursor: pointer;
}

.app-rail-collapse:hover {
  background: var(--surface-hover);
  color: var(--text-primary);
}

@media (min-width: 768px) {
  :root.rail-collapsed .app-rail-collapse {
    justify-content: center;
  }

  :root.rail-collapsed .app-rail-chevron {
    transform: rotate(180deg);
  }
}

/* ---- top bar ---- */
.app-topbar {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  padding-inline: var(--gap-lg);
  background: var(--surface-default);
  border-bottom: 1px solid var(--border-faint);
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
}

.app-crumbs {
  display: flex;
  align-items: baseline;
  gap: var(--gap-sm);
  font-size: var(--text-sm);
  color: var(--text-muted);
  min-width: 0;
}

.app-crumb-page {
  color: var(--text-primary);
  font-weight: var(--weight-semibold);
  font-size: var(--text-lg);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Visual only this round — a <div>, never an <input>, so nobody types into a
   box that cannot answer (ui-screens.md §3.4). */
.app-search {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  padding: var(--gap-xs) var(--gap-sm);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  color: var(--text-muted);
  font-size: var(--text-sm);
  width: 17.5rem;
  user-select: none;
}

.app-search-text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.app-kbd {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
  padding-inline: var(--gap-xs);
  background: var(--surface-inset);
}

/* 110 §A — was `max-width: 900px`; the search hides from the topbar's 1100px down,
   because from 768 to 1099 the rail packs to icons and the bar must fit beside it.
   (Below 768 the search is not hidden — it MOVES into the drawer's head:
   .app-rail-search in the layout.) The crumbs take over the bar's free space and,
   at both narrow bands, collapse to the leaf with the full path on title. */
@media (max-width: 1099.98px) {
  .app-topbar .app-search {
    display: none;
  }

  .app-crumbs {
    margin-right: auto;
  }

  .app-crumbs .app-crumb-group,
  .app-crumbs .app-crumb-sep {
    display: none;
  }
}

.app-icon-button {
  width: var(--field-height-sm);
  height: var(--field-height-sm);
  border: 0;
  border-radius: var(--radius-md);
  background: none;
  color: var(--text-secondary);
  display: grid;
  place-items: center;
  cursor: pointer;
  flex: none;
}

.app-icon-button:hover {
  background: var(--surface-hover);
  color: var(--text-primary);
}

/* ---- 110 §A — the phone shell's chrome: opener, drawer close, scrim. ----
   All three are inert at desktop widths by their BASE rules; only the <768px
   media section at the foot of this file lights them. The opener is hidden by
   CSS rather than JS so the drawer simply cannot exist above the breakpoint. */

/* The topbar hamburger, first control in the bar below 768px. */
.app-rail-open {
  display: none;
  width: var(--field-height-lg);
  height: var(--field-height-lg);
  border: 0;
  border-radius: var(--radius-md);
  background: none;
  color: var(--text-secondary);
  place-items: center;
  cursor: pointer;
  flex: none;
}

.app-rail-open:hover {
  background: var(--surface-hover);
  color: var(--text-primary);
}

/* The close control inside the open drawer, over the brand row's trailing edge
   (the rail is position: fixed below 768px, which is what this anchors to). */
.app-rail-close {
  display: none;
  position: absolute;
  top: var(--gap-sm);
  right: var(--gap-sm);
  width: var(--field-height-lg);
  height: var(--field-height-lg);
  border: 0;
  border-radius: var(--radius-md);
  background: none;
  color: var(--text-secondary);
  place-items: center;
  cursor: pointer;
  z-index: 1;
}

.app-rail-close:hover {
  background: var(--surface-hover);
  color: var(--text-primary);
}

/* The search's second seat, inside the rail: hidden everywhere except the drawer
   widths, where the topbar copy is the one hidden (the 1099.98px rule above). */
.app-search.app-rail-search {
  display: none;
}

/* The drawer's scrim. Same color-mix idiom as the modals' .u-backdrop; invisible
   and non-interactive until `html.rail-open` lights it below 768px. */
.app-rail-backdrop {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-drawer) - 1);
  background: color-mix(in srgb, var(--text-primary) 55%, transparent);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition:
    opacity var(--duration-base) var(--ease-out),
    visibility 0s linear var(--duration-base);
}

/* ---- avatar + menu ---- */
.app-user {
  position: relative;
  display: flex;
  align-items: center;
  flex: none;
}

.app-avatar {
  width: var(--icon-xl);
  height: var(--icon-xl);
  border-radius: var(--radius-full);
  background: var(--brand-soft);
  color: var(--brand);
  border: 1px solid var(--border-subtle);
  display: grid;
  place-items: center;
  font-weight: var(--weight-bold);
  font-size: var(--text-xs);
  cursor: pointer;
  padding: 0;
  overflow: hidden;
}

.app-avatar-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.app-menu {
  position: absolute;
  right: 0;
  top: calc(100% + var(--gap-xs));
  min-width: 14rem;
  background: var(--surface-overlay);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  padding: var(--gap-xs);
  z-index: var(--z-popover);
  text-align: left;
}

.app-menu[hidden] {
  display: none;
}

.app-menu-who {
  padding: var(--gap-sm);
  border-bottom: 1px solid var(--border-faint);
  margin-bottom: var(--gap-xs);
}

.app-menu-who b {
  display: block;
  font-weight: var(--weight-semibold);
  font-size: var(--text-sm);
}

.app-menu-who span {
  font-size: var(--text-xs);
  color: var(--text-muted);
  word-break: break-all;
}

.app-menu a,
.app-menu > form > button {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  width: 100%;
  padding: var(--space-1-5) var(--gap-sm);
  border: 0;
  border-radius: var(--radius-base);
  background: none;
  color: var(--text-secondary);
  text-decoration: none;
  font: inherit;
  font-size: var(--text-sm);
  cursor: pointer;
}

.app-menu a:hover,
.app-menu > form > button:hover {
  background: var(--surface-hover);
  color: var(--text-primary);
}

.app-menu-sep {
  border-top: 1px solid var(--border-faint);
  margin-block: var(--gap-xs);
}

.app-menu-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-sm);
  padding: var(--space-1-5) var(--gap-sm);
  font-size: var(--text-sm);
  color: var(--text-secondary);
}

.app-menu-value {
  color: var(--text-muted);
  font-size: var(--text-xs);
}

/* The segmented control (Appearance, and Settings' Mode/Density rows). */
.app-seg {
  display: inline-flex;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-pill);
  padding: var(--space-0-5);
  background: var(--surface-inset);
}

.app-seg button {
  padding: var(--space-0-5) var(--gap-sm);
  border: 0;
  border-radius: var(--radius-pill);
  background: none;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--text-xs);
  cursor: pointer;
}

.app-seg button[aria-pressed="true"] {
  background: var(--surface-raised);
  color: var(--text-primary);
  box-shadow: var(--shadow-sm);
}

.app-swatches {
  display: flex;
  gap: var(--gap-xs);
  flex-wrap: wrap;
  padding: var(--space-1) var(--gap-sm) var(--gap-sm);
}

.app-swatch {
  width: var(--icon-md);
  height: var(--icon-md);
  border-radius: var(--radius-full);
  border: 2px solid transparent;
  cursor: pointer;
  padding: 0;
  background: var(--surface-inset);
}

.app-swatch[aria-pressed="true"] {
  border-color: var(--brand);
}

/* A swatch has to show a theme the page is NOT wearing, so its colour cannot
   come from a live token — a `var(--accent-primary)` swatch would paint all six
   the same. These six values are each theme's own `--_accent-primary`, read out
   of the vendored stylesheet it belongs to, and they are the ONLY literals in
   this file. They are a PICTURE OF a theme rather than a use of one; a sync
   that changes a palette leaves a stale dot, which VendoredThemeScopingTest's
   sibling guard (AppThemeSwatchAuditTest) fails the build on. */
.app-swatch[data-swatch="saas"] { background: #4f46e5; }
.app-swatch[data-swatch="ocean"] { background: #0369a1; }
.app-swatch[data-swatch="forest"] { background: #2d4a22; }
.app-swatch[data-swatch="healthcare"] { background: #1a6b4a; }
.app-swatch[data-swatch="minimal"] { background: #000000; }
.app-swatch[data-swatch="professional"] { background: #0f1d40; }

/* The toast stack's offset, moved off an inline style attribute (§D). */
.app-toast-stack {
  top: var(--header-height);
  right: var(--gap-md);
}

/* ---------------------------------------------------------------------------
   079 §E — THE PAGE FRAME: one page header, one card, one table, one chip.

   These are app-level rules over the VENDORED primitives (this file loads last,
   §3.4), never a second set of classes: a screen still writes `.ds-card` and
   `.ds-table` and gets the shell's density for free. The two exceptions are
   `.app-page-h` (a layout the design system has no primitive for) and
   `.app-chip` (a status pill with a dot — `.ds-badge` has no dot, and the dot
   is what makes status readable without relying on colour alone, WCAG 1.4.1).
   --------------------------------------------------------------------------- */

/* The page title's ONE size, restated for the denser shell (ui-screens.md §3.3
   is amended to this value). It stays a property of `.ds-headline`, so no
   screen picks its own and TypeScaleAuditTest's ban on inline font-size keeps
   holding. Scoped to .app-main so the marketing site, which never loads this
   file, is untouched either way. */
.app-main .ds-headline {
  /* 103 §D: one step up the scale, with the weight/tracking/leading that make
     Inter read as a title rather than as large body text. The owner ruled Inter
     and no second face (2026-09-08), so this is the whole instrument. */
  font-size: var(--text-2xl);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-tight);
  line-height: var(--leading-tight);
}

.app-main .ds-title {
  font-size: var(--text-base);
  font-weight: var(--weight-semibold);
}

.app-page-h {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--gap-md);
  margin-bottom: var(--gap-lg);
}

.app-page-h h1 {
  margin: 0;
}

/* The subtitle: one line of prose telling a reader what the screen is for. The
   70ch measure is the same reasoning as `.app-reading` — a sentence that runs
   the width of a 2560px window is not read, it is skipped. */
.app-page-sub {
  margin: var(--space-1) 0 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
  max-width: 70ch;
}

.app-page-actions {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  flex: none;
}

/* Cards: the mock's larger radius, on the design system's own token. */
.app-main .ds-card {
  border-radius: var(--radius-lg);
  border-color: var(--border-subtle);
}

/* A card whose body is a full-bleed table: the header keeps the padding, the
   table runs to the card's edges and its own cell padding does the rest.

   `overflow-x: auto`, NOT `hidden`. The first screenshot pass of /api-console
   showed the endpoints table's last column sliced off at the card's edge: the
   table is wider than its grid column (a mono path plus a nowrap chip), and
   `hidden` made that look like a design choice instead of a defect. Scrolling
   inside the card keeps the content reachable AND keeps the document from
   widening, which is what the overflow assertion is really asking for. */
.app-card-table {
  padding: 0;
  overflow-x: auto;
  overflow-y: hidden;
}

.app-card-h {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-sm);
  padding: var(--gap-md) var(--gap-md) 0;
}

.app-card-h h2 {
  margin: 0;
}

/* In a full-bleed table card the header band is followed by the <thead> band directly;
   without its own bottom padding the title sat ON the column labels (owner's
   /api-console screenshot, 2026-09-08). The dashboard's table cards share the rule. */
.app-card-table > .app-card-h {
  padding-bottom: var(--gap-sm);
}

.app-card-b {
  padding: var(--gap-md);
  min-width: 0;
}

.app-card-note {
  color: var(--text-muted);
  font-size: var(--text-xs);
}

.app-grid {
  display: grid;
  gap: var(--gap-md);
}

.app-grid-2 {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.app-grid-3 {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

/* The API console's two columns: the endpoints table earns the wider one. */
.app-grid-wide-left {
  grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
}

.app-grid-stack {
  display: grid;
  /* minmax(0, 1fr), never a bare 1fr: a grid item's default `min-width: auto` lets its
     content push the track WIDER than the track's share, and the MCP card's <pre> is
     `white-space: pre`. AppShellBrowserTest caught exactly that — /api-console overflowed
     the document by 9px at BOTH review widths. `overflow-x: auto` on the <pre> only helps
     once something upstream is willing to be narrower than its content. */
  grid-template-columns: minmax(0, 1fr);
  gap: var(--gap-md);
  align-content: start;
}

/* §D's ruling (option a): the settings cards use the width. One column below
   1100px, which is also where the design system's own layouts fold. */
@media (max-width: 1100px) {
  .app-grid-2,
  .app-grid-3,
  .app-grid-wide-left {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Tables inside a card have already got the card's border and radius; the
   076 boxed-table treatment would draw a second one inside the first. */
.app-card-table {
  /* The card clips; the table below scrolls inside it. Together these keep a long mono
     path from widening the page — the failure the overflow assertion exists for. */
  min-width: 0;
}

.app-card-table .ds-table {
  border: 0;
  border-radius: 0;
  background: none;
}

.app-main .ds-table tbody tr:hover td {
  background: var(--surface-hover);
}

/* The machine path under a display name: mono, muted, one size down — the
   §3.3 "mono is for identifiers only" rule, given a place to live. */
.app-path {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

/* Identifiers in a table cell must be allowed to BREAK. A folder path or a
   published route has no spaces, so without this a single cell sets the table's
   minimum width and the whole table stops fitting its card — which is what
   sliced the endpoints table's last column off its first screenshot. Breaking
   an identifier mid-string is ugly; hiding a column is worse. */
.app-card-table td .ds-mono,
.app-card-table td .app-path {
  overflow-wrap: anywhere;
}

/* 098 §A — the same rule where the identifier IS the cell rather than a span inside it.
   /datasources renders the JDBC URL and the username as `<td class="u-mono">`, so the two
   selectors above (which address a CHILD of the cell) never reached them: one
   `jdbc:postgresql://postgres:5432/datapipelines` set the table's minimum width, the table
   stopped fitting its column, and the overflow escaped to <html> — measured 1618px on a
   1440px viewport with eight rows. Breaking a URL mid-string is ugly; a page that scrolls
   sideways and hides the Actions column is worse (the 091/093 ruling). */
.app-card-table td.u-mono,
.app-card-table td.ds-mono {
  overflow-wrap: anywhere;
}

/* A header may wrap inside a table that has to fit a card column; the vendored
   `white-space: nowrap` is right for a full-width table and wrong here. */
.app-card-table .ds-table th {
  white-space: normal;
}

.app-name {
  font-weight: var(--weight-medium);
  color: var(--text-primary);
}

/* Numerics right and tabular; dates stay proportional (§3.3.2) and never take
   this class. */
.app-main .ds-table td.num,
.app-main .ds-table th.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

.app-main .ds-table td.num {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}

/* The status chip. The DOT is not decoration: colour alone cannot carry state
   (WCAG 1.4.1), and a 6px dot plus a word reads at a glance and in greyscale.
   `.ds-status` is the design system's own dot; this positions it. */
.app-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--gap-xs);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  padding: var(--space-0-5) var(--gap-sm);
  border-radius: var(--radius-pill);
  white-space: nowrap;
  background: var(--surface-inset);
  color: var(--text-secondary);
}

.app-chip .ds-status {
  background: currentColor;
}

.app-chip-ok {
  background: var(--accent-success-bg);
  color: var(--accent-success);
}

.app-chip-bad {
  background: var(--accent-danger-bg);
  color: var(--accent-danger);
}

.app-chip-run {
  background: var(--brand-soft);
  color: var(--brand);
}

.app-chip-warn {
  background: var(--accent-warning-bg);
  color: var(--accent-warning);
}

.app-chip-mono {
  font-family: var(--font-mono);
  font-weight: var(--weight-normal);
}

/* Scope/permission chips (§D: the Session card's four spans became chips). */
.app-chips {
  display: flex;
  gap: var(--gap-xs);
  flex-wrap: wrap;
}

/* The HTTP method tag on a published endpoint's path. Always GET this round —
   PublishedEndpointController refuses anything else with `Allow: GET`. */
.app-method {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  padding: 0 var(--gap-xs);
  border-radius: var(--radius-sm);
  background: var(--accent-info-bg);
  color: var(--accent-info);
}

/* A path segment that is a {variable}, so the shape of a route reads without
   the reader parsing braces. */
.app-var {
  color: var(--accent-info);
}

/* The connection JSON: a read-only code block, never an input. */
.app-code {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  background: var(--surface-inset);
  border: 1px solid var(--border-faint);
  border-radius: var(--radius-base);
  padding: var(--gap-sm);
  white-space: pre;
  overflow-x: auto;
  color: var(--text-primary);
  margin: 0;
}

/* An informational aside inside a card — a fact about the system, not a toast. */
.app-note {
  display: flex;
  gap: var(--gap-sm);
  align-items: flex-start;
  padding: var(--gap-sm);
  border-radius: var(--radius-md);
  background: var(--accent-info-bg);
  color: var(--text-secondary);
  font-size: var(--text-xs);
  margin-top: var(--gap-sm);
}

.app-note svg {
  color: var(--accent-info);
  flex: none;
}

/* Empty state: a bordered, dashed box that says what would be here. */
.app-empty {
  padding: var(--gap-xl);
  text-align: center;
  color: var(--text-muted);
  font-size: var(--text-sm);
  border: 1px dashed var(--border-subtle);
  border-radius: var(--radius-lg);
}

/* Only the empty state's TITLE is a block — the first bold child. A bold word later
   in the sentence ("Create one with <strong>New key</strong>.") stays inline; as `b`
   it was breaking the sentence over three lines with the full stop on its own. */
.app-empty > b:first-child {
  display: block;
  color: var(--text-primary);
  font-weight: var(--weight-semibold);
  margin-bottom: var(--space-1);
}

/* 091 — the API-key form's KIND cards. A radio whose choice changes what the rest of
   the form means deserves more than a label: each card carries the sentence that says
   what that kind IS, so the decision is made from the form rather than from the docs. */
.app-kinds {
  border: none;
  margin: 0;
  padding: 0;
}

.app-kind {
  display: flex;
  gap: var(--gap-sm);
  align-items: flex-start;
  padding: var(--gap-sm);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  margin-bottom: var(--gap-xs);
  cursor: pointer;
}

.app-kind:hover {
  border-color: var(--border-default);
  background: var(--surface-inset);
}

/* The checked card is the one carrying the form's meaning, so it is marked on the CARD,
   not only on the dot — `:has()` is the one selector that can say so without a script. */
.app-kind:has(input:checked) {
  border-color: var(--brand);
  background: var(--brand-soft);
}

.app-kind input {
  margin-top: 2px;
  flex: none;
}

.app-kind-b {
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: var(--text-xs);
  color: var(--text-secondary);
}

.app-kind-b b {
  font-size: var(--text-sm);
  color: var(--text-primary);
  font-weight: var(--weight-semibold);
}

/* 091 — the endpoint-binding picker: a scrollable list of the tree nodes this workspace
   actually publishes. Bounded height, because a deployment with fifty endpoints must not
   push the submit button off the modal. */
.app-picker {
  max-height: 180px;
  overflow-y: auto;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  padding: var(--gap-xs);
}

.app-picker-row {
  display: flex;
  gap: var(--gap-sm);
  align-items: center;
  padding: var(--space-1) var(--gap-xs);
  font-size: var(--text-xs);
  cursor: pointer;
}

.app-picker-row:hover {
  background: var(--surface-inset);
}

/* A definition list of settings rows (the detail pane's key/value pairs). */
.app-rows {
  font-size: var(--text-sm);
}

.app-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-sm);
  padding: var(--gap-sm) 0;
  border-bottom: 1px solid var(--border-faint);
}

.app-row:last-child {
  border-bottom: 0;
}

.app-row-k {
  color: var(--text-muted);
}

.app-row-v {
  font-weight: var(--weight-medium);
  text-align: right;
}

/* A card that IS its content (the MCP card): its header and body sit flush with
   the card's own padding instead of adding a second inset. */
.app-card-h-flush {
  padding: 0;
}

.app-card-b-flush {
  padding: var(--gap-sm) 0 0;
}

.app-flush {
  margin: 0;
}

/* §D — the Settings identity block (avatar + name + email). */
.app-identity {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  margin-bottom: var(--gap-sm);
}

.app-identity-avatar {
  width: var(--space-10);
  height: var(--space-10);
  border-radius: var(--radius-full);
  object-fit: cover;
  flex: none;
}

.app-identity-initials {
  background: var(--brand-soft);
  color: var(--brand);
  display: grid;
  place-items: center;
  font-weight: var(--weight-bold);
}

.app-identity-text b {
  display: block;
  font-weight: var(--weight-semibold);
}

.app-identity-text span {
  color: var(--text-muted);
  font-size: var(--text-xs);
  word-break: break-all;
}

/* A select sitting in a settings row: sized by its content, not by the row. */
.app-select-inline {
  max-width: 14rem;
}

.app-note-block {
  margin: var(--gap-sm) 0 0;
}

.app-note-flush {
  margin: 0 0 var(--gap-sm);
}

/* The datasources filter row's search box and dialect select. These predate
   `.ds-input` on that screen and carry its look at the `--gap-*` scale rather
   than the `--space-*` one; kept as a class so the row renders exactly as it
   did before 079 §D lifted the inline copies off it. Converging them onto
   `.ds-input` (as pipelines/ and templates/ already are) is a visual change and
   belongs to a round that owns the look, not to this one. */
.app-filter-control {
  padding: var(--gap-sm) var(--gap-md);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-base);
  background: var(--surface-raised);
  color: var(--text-primary);
  font-size: var(--text-sm);
}

/* A `.ds-input` select in a filter row. `.ds-input` is `width: 100%`, so a bare
   select grows without bound and starves the search box beside it (the note in
   templates/list.html); these are sized by their own content instead. (Named
   `-auto`, not `-filter-select`: the executions filter bar already owns that
   name for a MIN-width, and two rules under one name is how a class quietly
   changes meaning between screens.) */
.app-select-auto {
  flex: 0 0 auto;
  width: auto;
}

/* The hand-rolled modals' close button, pinned to the card's top-right corner.
   `.app-modal` is `position: relative`, which is what this anchors to. */
.app-modal-close {
  position: absolute;
  top: var(--gap-sm);
  right: var(--gap-sm);
}

/* The create-template modal's body field: a mono editing surface tall enough to
   show a short statement without scrolling. 8.75rem is the 140px the inline
   style carried — a control's initial height is geometry, and the spacing scale
   has no token for it. Width and size come from `.ds-input`. */
.app-template-body-input {
  min-height: 8.75rem;
  font-family: var(--font-mono);
}

/* The template editor's render-context tabs (Key/Value | JSON). The 2px rule IS
   the tab indicator — a geometry constant with no border-width token behind it.
   switchTab() rewrites `borderBottom` and `fontWeight` inline as the selection
   moves, and an inline declaration beats these, which is what makes the pair
   safe to state as classes. */
.app-tab-active {
  border-bottom: 2px solid var(--accent-primary);
}

.app-tab-idle {
  border-bottom: 2px solid transparent;
}

/* The template preview pane's failure card: a `.ds-card` wearing the danger edge.
   Built by the editor's fetch `catch` and by TemplateEditorController's
   server-side twin, which must keep looking the same. */
.app-error-card {
  border-color: var(--accent-danger);
}

/* ---------------------------------------------------------------------------
   079 §D — THE UTILITY LAYER, and why it exists.

   076 banned inline `max-width` / `grid-template-columns` on app templates,
   because four screens had each picked their own pixel count and the single
   width policy was gone. This round widens that ban to EVERY inline `style=`
   with an allowlist that must stay empty (`InlineWidthAuditTest`). The reason
   is the same one, one level up: an inline declaration is a decision made in a
   template, invisible to every other template, and thirty of them is how a
   design system quietly becomes thirty designs. It also makes a Content
   Security Policy with no `style-src 'unsafe-inline'` reachable later, which an
   inline-style-carrying app can never adopt.

   The 330 attributes that had accumulated were overwhelmingly the SAME dozen
   declarations, all already on tokens. So the replacement is a small, closed
   utility vocabulary rather than a semantic class per site: naming
   `.app-datasource-list-header-spacing` thirty times would be ceremony, not
   design. Utilities are `u-` prefixed so they are never mistaken for the
   semantic `app-*` and `ds-*` layers, and every value here resolves through a
   design-system token — a utility that hard-coded a pixel would reintroduce
   exactly what the ban exists to stop.

   ADD to this list rather than reaching for `style=`. If a rule is genuinely
   one screen's own, it gets an `app-*` class in the section above instead.
   --------------------------------------------------------------------------- */

/* type */
.u-text-xs { font-size: var(--text-xs); }
.u-text-sm { font-size: var(--text-sm); }
.u-text-base { font-size: var(--text-base); }
.u-text-lg { font-size: var(--text-lg); }
.u-text-xl { font-size: var(--text-xl); }
.u-text-2xl { font-size: var(--text-2xl); }
.u-mono { font-family: var(--font-mono); }
.u-medium { font-weight: var(--weight-medium); }
.u-semibold { font-weight: var(--weight-semibold); }
.u-bold { font-weight: var(--weight-bold); }
.u-nowrap { white-space: nowrap; }
/* 138 §E — a description is a document, not a paragraph: blank lines become paragraphs and
   single breaks hold, while the text stays th:text-escaped (no markdown, never th:utext). */
.u-pre-line { white-space: pre-line; }
.u-break { word-break: break-all; }
.u-center { text-align: center; }
.u-right { text-align: right; }
.u-no-underline { text-decoration: none; }

/* colour — semantic roles only, never a literal */
.u-secondary { color: var(--text-secondary); }
.u-muted { color: var(--text-muted); }
.u-primary { color: var(--text-primary); }
.u-danger { color: var(--accent-danger); }
.u-success { color: var(--accent-success); }
.u-warning { color: var(--accent-warning); }

/* 097 §C — the admin-users row actions carried their colour as an inline `style` written in
   Kotlin. As classes they need the hover state spelled out: `.ds-button-ghost:hover` (0,2,0)
   would otherwise repaint them `--text-primary`, which the inline style used to win against
   by being inline. Same colour, same states, no attribute. */
.ds-button-ghost.u-danger:hover { color: var(--accent-danger); }
.ds-button-ghost.u-success:hover { color: var(--accent-success); }
.ds-button-ghost.u-warning:hover { color: var(--accent-warning); }

/* layout */
.u-row { display: flex; align-items: center; gap: var(--gap-sm); }
.u-row-md { display: flex; align-items: center; gap: var(--gap-md); }
.u-row-top { display: flex; align-items: flex-start; gap: var(--gap-sm); }
.u-between { justify-content: space-between; }
.u-center-x { justify-content: center; }
.u-wrap { flex-wrap: wrap; }
.u-col { display: flex; flex-direction: column; gap: var(--gap-sm); }
.u-grow { flex: 1; min-width: 0; }
.u-none { flex: none; }
.u-block { display: block; }
.u-inline-block { display: inline-block; }
.u-hidden { display: none; }
.u-full { width: 100%; }
.u-scroll-x { overflow-x: auto; }

/* A scroll viewport whose direct child is a framed table: the frame moves onto the viewport,
   so a table wider than the box keeps a visible right edge and its corners (the editor's
   result dock had the same defect — pipeline-editor.css `.pe-result-table-container`). */
.u-scroll-x:has(> .ds-table) {
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--surface-default);
}

.u-scroll-x > .ds-table {
  border: 0;
  border-radius: 0;
  background: none;
}
.u-flex { display: flex; }

/* Gap overrides for the row/col utilities above. Declared AFTER them so the
   override wins on the cascade rather than on `!important`; `.u-flex` pairs with
   one of these where a row wants a gap but not `align-items: center`. */
.u-gap-xs { gap: var(--gap-xs); }
.u-gap-md { gap: var(--gap-md); }
.u-gap-lg { gap: var(--gap-lg); }

/* spacing — the gap scale, never a raw length */
.u-m0 { margin: 0; }
.u-mb-xs { margin-bottom: var(--gap-xs); }
.u-mb-sm { margin-bottom: var(--gap-sm); }
.u-mb-md { margin-bottom: var(--gap-md); }
.u-mb-lg { margin-bottom: var(--gap-lg); }
.u-mb-xl { margin-bottom: var(--gap-xl); }
.u-mt-xs { margin-top: var(--gap-xs); }
.u-mt-sm { margin-top: var(--gap-sm); }
.u-mt-md { margin-top: var(--gap-md); }
.u-mt-lg { margin-top: var(--gap-lg); }
.u-p-sm { padding: var(--gap-sm); }
.u-p-md { padding: var(--gap-md); }
.u-p-lg { padding: var(--gap-lg); }
.u-p0 { padding: 0; }

/* surfaces */
.u-inset { background: var(--surface-inset); border-radius: var(--radius-base); }
.u-raised { background: var(--surface-raised); }
.u-bordered { border: 1px solid var(--border-default); border-radius: var(--radius-base); }
.u-rounded { border-radius: var(--radius-base); }

/* The hand-rolled modal backdrop. It carried `rgba(0,0,0,0.5)` inline on three
   screens — the only literal COLOUR left in an app template, and one that reads
   as a black wash over a dark theme's already-black page. `--z-modal-backdrop`
   and `color-mix` over the design system's own inverted text colour keep it a
   scrim in both themes. */
.u-backdrop {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal-backdrop);
  display: flex;
  align-items: center;
  justify-content: center;
  background: color-mix(in srgb, var(--text-primary) 55%, transparent);
}

/* The hand-rolled modals start CLOSED and are opened by their screen's script
   setting an inline `display: flex`, which beats any class — so the closed state
   is safe to hold here. Declared after `.u-backdrop` because it overrides that
   rule's own `display: flex`, and without `!important` so the inline set wins. */
.u-backdrop-hidden { display: none; }

.u-modal-tall { min-height: 60vh; }

/* 079 §E/§D — classes for the declarations the utility layer could not express:
   each one is genuinely a decision about ONE thing, not a reusable primitive. */

/* A clickable table row (the execution lists' whole-row navigation). */
.app-row-clickable {
  cursor: pointer;
}

/* Dashboard stat cards. The mock puts a 7-day sparkline under each figure; there is
   no 7-day series in this system to draw one from (see partials/dashboard-stats.html),
   so the card is label / figure / one line of breakdown. */
.app-stat .app-stat-v {
  font-size: var(--text-2xl);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-tight);
  margin-top: var(--gap-xs);
  font-variant-numeric: tabular-nums;
}

.app-stat-unit {
  font-size: var(--text-base);
  color: var(--text-muted);
}

.app-stat-d {
  font-size: var(--text-xs);
  color: var(--text-muted);
  margin-top: var(--space-0-5);
}

/* The execution list's filter bar: a row of controls that wraps and bottom-aligns
   so labels above inputs still line up on the baseline of the controls. */
.app-filters {
  display: flex;
  gap: var(--gap-md);
  flex-wrap: wrap;
  align-items: flex-end;
}

.app-filter-select {
  min-width: 12.5rem;
}

.app-filter-date {
  min-width: 10rem;
}

/* A checkbox column: wide enough for the box and its focus ring, no wider. */
.app-col-check {
  width: 3rem;
}

/* A card whose border states failure (the execution detail's error card). */
.app-card-danger {
  border-color: var(--accent-danger);
}

.app-gap-md {
  gap: var(--gap-md);
}

.app-row-baseline {
  display: flex;
  align-items: baseline;
  gap: var(--gap-sm);
}

.app-pad-tight {
  padding: var(--gap-xs) var(--gap-sm);
}

.app-break-word {
  word-break: break-word;
}

/* A <summary> in the execution error's disclosure. */
.app-summary {
  cursor: pointer;
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  color: var(--text-secondary);
}

/* The three error blocks on the execution detail. They differ ONLY in how much of a
   long error they show before scrolling, which is a per-block decision about how much
   room that particular text deserves — hence three height modifiers rather than one
   size for all of them. */
.app-errblock {
  background: var(--surface-inset);
  border-radius: var(--radius-base);
  padding: var(--gap-sm);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  white-space: pre;
  overflow: auto;
  margin: 0;
}

.app-errblock-tight {
  padding: var(--gap-xs);
  line-height: var(--leading-normal);
  margin-top: var(--gap-xs);
}

.app-errblock-wrap {
  white-space: pre-wrap;
  word-break: break-all;
  padding: var(--gap-xs);
}

.app-errblock-12 { max-height: 12vh; }
.app-errblock-20 { max-height: 20vh; }
.app-errblock-30 { max-height: 30vh; }

/* ---------------------------------------------------------------------------
   079 §D — batch 2 additions (login, workspaces, admin/users, api-keys, docs,
   the error pages, settings/password and the one-time-key partial).

   Every declaration below is a verbatim lift of an inline `style=` those files
   carried, so the conversion is a rename and not a restyle. Grouped as the
   layer above is: utilities first, then the handful of `app-*` rules for
   decisions that belong to one component.
   --------------------------------------------------------------------------- */

/* layout — `.u-row*` bundle `align-items: center` WITH a gap; several sites had
   one without the other, so the two halves are also available separately. */
.u-items-center { align-items: center; }
.u-inline { display: inline; }
.u-gap-sm { gap: var(--gap-sm); }

/* type/spacing gaps in the layer */
.u-left { text-align: left; }
.u-my-md { margin: var(--gap-md) 0; }
.u-py-sm { padding: var(--gap-sm) 0; }
.u-list-plain { list-style: none; }

/* Hairline separators. The two differ in token because the sites do: a divider
   BETWEEN two controls is a component boundary (`--border-default`, 3:1), a rule
   between list rows is a separator (`--border-subtle`, the 2:1 floor) — the same
   distinction the `--border-faint` note above draws for table rules. */
.u-divider-top { border-top: 1px solid var(--border-default); }
.u-divider-bottom { border-bottom: 1px solid var(--border-subtle); }

/* `.ds-table td` (0,1,1) outranks a bare utility (0,1,0), so `.u-p0` alone could
   not clear the cell padding a full-bleed skeleton row needs. Declared at table
   scope rather than with `!important`. */
.ds-table td.u-p0 { padding: 0; }

/* Same reason, the other direction: the admin-users empty row is a roomier cell than a data
   row, which it used to say with an inline padding (097 §C). */
.ds-table td.u-p-md { padding: var(--gap-md); }

/* 097 §C — the inline refusal a modal renders in place of a toast (the 022 review F9 rule:
   form-level feedback belongs in the form). It was three Kotlin string builders carrying
   inline styles, and two of them had drifted to different hand-picked widths (480px and
   520px) — the 076 failure exactly. One box, one width, from the stylesheet. */
.app-inline-refusal {
  border: 1px solid var(--accent-danger);
  border-radius: var(--radius-base);
  padding: var(--gap-sm);
  color: var(--text-primary);
  font-size: var(--text-sm);
  max-width: 30rem;
}

/* The login/forced-password notice boxes. One base, three role fills — the fill
   is the whole signal, so the roles never share a border or an icon. The
   success variant is the api-keys one-time-secret panel, which is roomier and
   outlined rather than filled, so it carries its own box. */
.app-alert {
  padding: var(--gap-sm);
  border-radius: var(--radius-base);
  font-size: var(--text-sm);
}

.app-alert-warning {
  background: var(--accent-warning-bg);
  color: var(--accent-warning);
}

.app-alert-danger {
  background: var(--accent-danger);
  color: var(--accent-danger-text);
}

.app-alert-success {
  background: var(--accent-success-bg);
  border: 1px solid var(--accent-success);
  padding: var(--gap-md);
  border-radius: var(--radius-base);
}

/* workspaces/index.html's inline form controls. `.ds-input` already supplies the
   border, radius, colour and type size those styles restated; what actually
   differed was a roomier gutter over a raised fill, and a fixed track width per
   field. The widths are rem, the house form for a width (`.app-input-inline`,
   `.app-search-input`, `.app-card-auth`) — the token scale covers gaps and type,
   not track widths. */
.app-input-raised {
  padding: var(--gap-sm) var(--gap-md);
  background: var(--surface-raised);
}

.app-input-fixed-sm { width: 13.75rem; }
.app-input-fixed-md { width: 16.25rem; }
.app-input-fixed-lg { width: 17.5rem; }

/* The one-time API key field (partials/api-key-created.html): a read-only input
   outlined in the success accent so the secret reads as the thing to copy. The
   fill is `transparent` so the panel's own `--accent-success-bg` shows through —
   the inline style asked for `var(--surface-secondary)`, a custom property this
   design system has never defined, which resolves to exactly that. Naming a real
   surface token here would DARKEN the field; that is a design decision, not part
   of an inline-style migration. */
.app-secret-field {
  flex: 1;
  padding: var(--gap-xs) var(--gap-sm);
  border: 1px solid var(--accent-success);
  border-radius: var(--radius-base);
  background: transparent;
}

/* ---------------------------------------------------------------------------
   090 §B — [x-cloak], APP-WIDE.

   The rule `[x-cloak]{display:none}` is what makes the attribute mean anything:
   Alpine strips it once `x-data` has initialised, so an element carrying it is
   hidden exactly for the window in which its bindings are still literal markup.
   It lived only in `pipeline-editor.css` (924) — a page-scoped sheet — so the
   attribute was inert on every other screen, and an Alpine root added anywhere
   else would have flashed its unbound content with no warning. Declaring it here
   costs one rule and makes the attribute a property of the app rather than of
   one page. The editor's copy is identical and stays where it is (that sheet is
   090's out-of-fence surface); duplicate identical declarations cannot disagree.
   --------------------------------------------------------------------------- */
[x-cloak] {
  display: none !important;
}

/* ---------------------------------------------------------------------------
   090 §C — THE AUTHENTICATION LAYOUT (`layouts/auth.html`).

   The ceremony screens' whole chrome: a brand line and a centred card on the
   page surface. There is no rail and no top bar to reserve space for, so the
   geometry is a single column rather than the shell's grid — which is the point
   of the layout existing at all.

   `.app-auth-main` deliberately does NOT reuse `.app-container`: that class is
   the full-bleed content gutter for a screen sitting beside the rail, and a
   ceremony screen has nothing to sit beside. `.app-center` (215) still does the
   vertical centring inside it, so `login.html` and `settings/password.html` keep
   the markup they had.
   --------------------------------------------------------------------------- */
.app-auth-body {
  background: var(--surface-page);
  color: var(--text-primary);
}

.app-auth {
  min-height: 100vh;
  display: grid;
  grid-template-rows: var(--header-height) 1fr;
  padding-inline: var(--gap-lg);
}

/* The brand line reads as a signature, not as navigation: it is the one link on
   the page that is not the ceremony, so it carries the mark and the wordmark at
   the rail's own size and nothing else. */
.app-auth-brand {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  align-self: center;
  justify-self: start;
  font-weight: var(--weight-bold);
  font-size: var(--text-lg);
  letter-spacing: var(--tracking-tight);
  color: var(--text-primary);
  text-decoration: none;
}

.app-auth-main {
  /* The card centres in the REMAINING height, under the brand line — the row
     the grid above gives it. `.app-center`'s 70vh floor would otherwise centre
     against the viewport and push the card below the fold on a short window. */
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 0;
  padding-block: var(--gap-lg);
}

/* `.app-center` inside the auth layout has already been centred by the row above;
   its own 70vh floor would add a second, taller box and reintroduce the scroll it
   exists to prevent. Scoped so the class keeps its meaning everywhere else. */
.app-auth-main .app-center {
  min-height: 0;
  width: 100%;
}

/* ---------------------------------------------------------------------------
   The sign-in ceremony (2026-09-08) — the product's own visual language, not a
   stock card. LEFT: the editor's dot-grid ground (the same --grid-dot the canvas
   draws, pipeline-editor.css) with a four-engine DAG as an inline SVG in the
   editor's card style; the edges carry a travelling dash so the graph reads as
   RUNNING. RIGHT: the sign-in panel, floating like an inspector. Everything is a
   token — the SVG is styled from here, so both themes and every palette apply —
   and the motion stops under prefers-reduced-motion. Below 900px the stage is
   gone and the panel stands alone.
   --------------------------------------------------------------------------- */
.app-auth-split {
  width: 100%;
  /* Capped and centred, generously: the stage is the picture and gets the width; the
     story and the sign-in card share the right column. */
  max-width: 110rem;
  margin-inline: auto;
  min-height: 100%;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(20rem, 26rem);
  gap: var(--gap-xl);
  align-items: center;
}

.app-auth-stage {
  position: relative;
  /* The stage is exactly as tall as its picture: the SVG keeps its viewBox aspect and
     sizes by width, so the frame hugs the diagram instead of standing as a tall empty
     panel beside the card (owner, 2026-09-08). On a short window the viewport still
     caps it and the picture scales down inside. */
  align-self: center;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border-subtle);
  background-color: var(--surface-default);
  background-image: radial-gradient(var(--grid-dot) 1.1px, transparent 1.2px);
  background-size: 22px 22px;
  overflow: hidden;
  padding: var(--gap-lg);
  box-sizing: border-box;
}

.app-auth-dag {
  display: block;
  width: 100%;
  height: auto;
  max-height: calc(100vh - 10rem);
}

.app-auth-edge {
  fill: none;
  stroke: var(--edge);
  stroke-width: 1.5;
  stroke-linecap: round;
}

/* The running pulse: a dashed brand stroke drawn OVER the idle edge and moved along it. */
.app-auth-dag-edges .app-auth-edge {
  stroke-dasharray: 7 230;
  stroke-dashoffset: 0;
  animation: app-auth-flow 2.6s linear infinite;
  stroke: var(--brand);
  opacity: 0.85;
}

.app-auth-dag-edges .app-auth-edge:nth-child(2) { animation-delay: -0.6s; }
.app-auth-dag-edges .app-auth-edge:nth-child(3) { animation-delay: -1.2s; }
.app-auth-dag-edges .app-auth-edge:nth-child(4) { animation-delay: -1.8s; }
.app-auth-dag-edges .app-auth-edge:nth-child(5) { animation-delay: -0.3s; }
.app-auth-dag-edges .app-auth-edge:nth-child(6) { animation-delay: -0.9s; }
.app-auth-dag-edges .app-auth-edge:nth-child(7) { animation-delay: -1.5s; }
.app-auth-dag-edges .app-auth-edge:nth-child(8) { animation-delay: -2.1s; }
.app-auth-dag-edges .app-auth-edge:nth-child(9) { animation-delay: -1.1s; }
.app-auth-dag-edges .app-auth-edge:nth-child(10) { animation-delay: -0.5s; }
.app-auth-dag-edges .app-auth-edge:nth-child(11) { animation-delay: -1.7s; }

@keyframes app-auth-flow {
  from { stroke-dashoffset: 237; }
  to   { stroke-dashoffset: 0; }
}

/* The idle rail under each pulse: the same five paths drawn once more, first, in the
   edge colour — the pulse group above rides on top of them. */
.app-auth-rail {
  fill: none;
  stroke: var(--edge);
  stroke-width: 1.5;
  stroke-linecap: round;
}

.app-auth-node-box {
  fill: var(--surface-raised);
  stroke: var(--border-subtle);
  stroke-width: 1;
}

/* Node kinds read at a glance: a source is a plain card, a tempdb step carries the brand
   outline, a calculator the warning hue (the editor's own colour for CALCULATOR), and the
   two OUTPUTS — the API and the dashboard — are filled brand-soft: that is where the data
   arrives, and the eye should land there last. */
.app-auth-node-step .app-auth-node-box {
  stroke: var(--brand);
}

.app-auth-node-calc .app-auth-node-box {
  stroke: var(--accent-warning);
}

/* The two OUTPUT SCREENS — the API answering and the dashboard reading — are small
   pictures of the thing, not labelled boxes: a request line with its 200, three rows of the
   JSON it returns; a KPI, a sparkline and a bar chart. Still every colour a token. */
.app-auth-screen-box {
  fill: var(--surface-raised);
  stroke: var(--brand);
  stroke-width: 1;
}

.app-auth-screen-bar {
  fill: var(--brand-soft);
}

.app-auth-pill {
  fill: var(--brand);
}

.app-auth-pill-text {
  font-family: var(--font-mono);
  font-size: 8.5px;
  font-weight: var(--weight-bold);
  fill: var(--accent-primary-text);
}

.app-auth-screen-path,
.app-auth-screen-title {
  font-family: var(--font-mono);
  font-size: 9.5px;
  font-weight: var(--weight-semibold);
  fill: var(--text-primary);
}

.app-auth-screen-title {
  font-family: var(--font-sans);
  font-size: 10px;
}

.app-auth-screen-meta {
  font-family: var(--font-mono);
  font-size: 8.5px;
  fill: var(--text-muted);
}

.app-auth-ok {
  fill: color-mix(in srgb, var(--accent-success) 18%, var(--surface-raised));
}

.app-auth-ok-text {
  font-family: var(--font-mono);
  font-size: 8px;
  font-weight: var(--weight-semibold);
  fill: var(--accent-success);
}

.app-auth-code {
  font-family: var(--font-mono);
  font-size: 9px;
  fill: var(--text-secondary);
  white-space: pre;
}

.app-auth-code-num {
  fill: var(--brand);
  font-weight: var(--weight-semibold);
}

.app-auth-code-dim {
  fill: var(--text-muted);
}

.app-auth-kpi-label {
  font-family: var(--font-sans);
  font-size: 7.5px;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  fill: var(--text-muted);
}

.app-auth-kpi {
  font-family: var(--font-sans);
  font-size: 20px;
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-tight);
  fill: var(--text-primary);
}

.app-auth-kpi-delta {
  font-family: var(--font-sans);
  font-size: 9px;
  font-weight: var(--weight-semibold);
  fill: var(--accent-success);
}

.app-auth-spark {
  fill: none;
  stroke: var(--brand);
  stroke-width: 1.5;
  stroke-linejoin: round;
  stroke-linecap: round;
}

.app-auth-axis {
  stroke: var(--border-subtle);
  stroke-width: 1;
}

.app-auth-bar {
  fill: color-mix(in srgb, var(--brand) 45%, var(--surface-raised));
}

.app-auth-bar-hi {
  fill: var(--brand);
}

/* The first act: the PERSON who asks and the AGENT that builds, outside the platform box;
   the box is the workspace, its edge is where MCP enters. */
.app-auth-box {
  fill: color-mix(in srgb, var(--surface-raised) 55%, transparent);
  stroke: var(--border-subtle);
  stroke-width: 1;
  stroke-dasharray: 5 5;
}

.app-auth-box-label {
  font-family: var(--font-sans);
  font-size: 9px;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  fill: var(--text-muted);
}

.app-auth-person-head {
  fill: var(--brand-soft);
  stroke: var(--brand);
  stroke-width: 1.2;
}

.app-auth-person-body {
  fill: var(--brand-soft);
  stroke: var(--brand);
  stroke-width: 1.2;
}

.app-auth-chip {
  fill: var(--brand-soft);
  stroke: var(--brand);
  stroke-width: 0.8;
}

.app-auth-chip-text {
  font-family: var(--font-sans);
  font-size: 8px;
  font-weight: var(--weight-semibold);
  fill: var(--brand);
}

.app-auth-node-name {
  font-family: var(--font-sans);
  font-size: 12px;
  font-weight: var(--weight-semibold);
  fill: var(--text-primary);
}


.app-auth-node-meta {
  font-family: var(--font-mono);
  font-size: 9.5px;
  fill: var(--text-muted);
}

.app-auth-node-dot {
  fill: var(--accent-success);
}

.app-auth-story {
  max-width: 26rem;
  margin-bottom: var(--gap-xl);
}

.app-auth-eyebrow {
  margin: 0 0 var(--gap-xs);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--brand);
}

.app-auth-stage-title {
  margin: 0 0 var(--gap-md);
  font-size: var(--text-2xl);
  font-weight: var(--weight-bold);
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
  color: var(--text-primary);
  text-wrap: balance;
}

.app-auth-steps {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--gap-sm);
  counter-reset: app-auth-step;
}

.app-auth-steps li {
  position: relative;
  padding-left: calc(var(--icon-md) + var(--gap-sm));
  font-size: var(--text-sm);
  color: var(--text-secondary);
  line-height: var(--leading-normal);
  counter-increment: app-auth-step;
}

.app-auth-steps li::before {
  content: counter(app-auth-step);
  position: absolute;
  left: 0;
  top: 0.1em;
  width: var(--icon-md);
  height: var(--icon-md);
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  color: var(--brand);
  background: var(--brand-soft);
}

.app-auth-steps b {
  color: var(--text-primary);
  font-weight: var(--weight-semibold);
}

.app-auth-panel {
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.app-auth-card {
  padding: var(--gap-xl);
  box-shadow: var(--shadow-lg);
}

.app-auth-card-brand {
  margin-bottom: var(--gap-md);
}

.app-auth-title {
  margin: 0 0 var(--gap-xs);
  font-size: var(--text-xl);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-tight);
  line-height: var(--leading-tight);
}

.app-auth-sub {
  margin: 0 0 var(--gap-lg);
  font-size: var(--text-sm);
  color: var(--text-secondary);
}

.app-auth-foot {
  margin: var(--gap-lg) 0 0;
  font-size: var(--text-xs);
  color: var(--text-muted);
  line-height: var(--leading-normal);
}

@media (max-width: 900px) {
  .app-auth-split {
    grid-template-columns: minmax(0, 1fr);
  }

  .app-auth-stage {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .app-auth-dag-edges .app-auth-edge {
    animation: none;
    stroke-dasharray: none;
    stroke: var(--edge);
  }
}

/* ---------------------------------------------------------------------------
   103 — THE SHELL FEELS NATIVE (ui-screens.md §3.5).

   Measured against algoschool.app (owner, 2026-09-08; notes T195): that app has
   ZERO hx-boost — every navigation is a full document load — and still reads as
   the more native of the two. What it has that we did not is a FEEDBACK AND
   ATMOSPHERE layer: a click-time pending state, a fixed radial-glow ground,
   entrance motion on arrival, heading weight, and consistent elevation. We were
   already the more SPA of the two architecturally and gave none of it. Nothing
   below changes the architecture: no router, no view-transitions dependency, no
   library — CSS plus the state machine in shell.js.
   --------------------------------------------------------------------------- */

/* --- §A the click is acknowledged ---------------------------------------- */

/* The pended link: dimmed, non-interactive, and saying so with the cursor. The
   `aria-disabled` is shell.js's (the attribute, never the property — §D's
   reasoning: a really-disabled control drops focus mid-flight), and the dim is
   this rule rather than the design system's `.ds-button[aria-disabled]` rule,
   which a nav <a> never matches. */
.is-pending {
  opacity: 0.55;
  cursor: progress;
  pointer-events: none;
}

/* The group the pended link belongs to (the rail's <nav>, the breadcrumb, or
   #app-main for an in-content link). A navigation is already under way, so the
   whole group reads as busy rather than inviting a second click — the cursor is
   the only signal, because dimming ten siblings to say one of them was clicked
   is noise, not feedback. */
.is-pending-scope {
  cursor: progress;
}

/* The status pill. Fixed under the top bar on the CONTENT side (it clears the
   rail, collapsed or not — see the second rule below), and `hidden` when off — see the layout. */
.app-status-pill {
  position: fixed;
  top: calc(var(--header-height) + var(--gap-md));
  left: calc(var(--app-rail-width, 0px) + var(--gap-lg));
  z-index: var(--z-fixed);
  display: flex;
  align-items: center;
  gap: var(--gap-xs);
  padding: var(--space-1-5) var(--gap-sm);
  border-radius: var(--radius-pill);
  border: 1px solid var(--border-subtle);
  background: var(--surface-default);
  box-shadow: var(--shadow-md);
  color: var(--text-secondary);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  pointer-events: none;
  animation: ds-fade-in var(--duration-fast) var(--ease-out);
}

/* `--app-rail-width` is overridden on `.app-shell`, and the pill is a fixed
   sibling OUTSIDE the shell — so it cannot inherit the collapse and is told
   about it here, on the same `<html>` class the collapse itself lives on. */
:root.rail-collapsed .app-status-pill {
  left: calc(var(--app-rail-collapsed) + var(--gap-lg));
}

/* The anonymous shell (the public docs, the error pages) has no rail at all, and its
   `#app-main` still boosts — so without this the pill would appear 232px from the left of a
   page with nothing there. Same reason as the rule above: `--app-rail-width` is a `:root`
   value the shell overrides on ITSELF, and the pill is a fixed sibling outside it. */
:root:has(.app-shell-anon) .app-status-pill {
  left: var(--gap-lg);
}

.app-status-pill-spinner {
  width: var(--gap-sm);
  height: var(--gap-sm);
  border-radius: var(--radius-pill);
  border: 2px solid var(--border-default);
  border-top-color: var(--accent-primary);
  animation: ds-spin 700ms linear infinite;
}

/* --- §B the swap arrives with motion ------------------------------------- */

/* 150ms, fade plus a 4px slide-up. TRANSFORM and OPACITY only: neither is laid
   out, so the entrance cannot move a box and cannot score a layout shift —
   ExplorerPaneGeometryBrowserTest's 0.05 CLS budget on boosted navigation is
   what a margin or height animation here would spend. */
@keyframes app-enter {
  from {
    opacity: 0;
    transform: translateY(4px);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.app-enter {
  animation: app-enter var(--duration-base) var(--ease-out);
}

/* --- §C the ground has depth --------------------------------------------- */

/* The backdrop element (layouts/default.html). Fixed, inert, and painted at the
   base layer: the rail, the top bar and every card are opaque surfaces above
   it, so it shows exactly where the app has nothing — which is the only place
   atmosphere belongs. The dot grid is the editor's own `--grid-dot` mixed
   FURTHER toward the page surface (the canvas's grid is a working surface and
   is meant to be seen; this one is meant to be felt), and the glow is one soft
   brand radial at the top left, the login stage's idiom. Both are token
   expressions, so all nine themes get them by construction. */
.app-backdrop {
  position: fixed;
  inset: 0;
  z-index: var(--z-base);
  pointer-events: none;
  background-color: var(--surface-page);
  background-image:
    radial-gradient(circle at 12% 0%, color-mix(in srgb, var(--brand) 10%, transparent), transparent 55%),
    radial-gradient(color-mix(in srgb, var(--grid-dot) 45%, var(--surface-page)) 1.1px, transparent 1.2px);
  background-size: 100% 100%, 22px 22px;
}

/* The shell rides above the backdrop. One positioned ancestor is all it takes;
   nothing inside needs a z-index of its own (the top bar keeps its own
   `--z-sticky`, the toast stack and the progress bar are fixed siblings
   OUTSIDE the shell and keep `--z-fixed`/`--z-toast`). */
.app-shell {
  position: relative;
  z-index: 1;
}

/* Cards read as raised rather than drawn. `--shadow-sm` is the design system's
   own smallest elevation; the border stays, because ui-screens.md §2 principle
   8 is normative — a card boundary is a `border-default`-family line, never a
   shadow alone. */
.app-main .ds-card {
  box-shadow: var(--shadow-sm);
}

/* The hover lift is for cards that DO something: a card that is itself a link,
   and a card carrying a primary action. A lift on a static card is noise — it
   promises an affordance that is not there. */
.app-main a.ds-card,
.app-main .ds-card:has(.ds-button-primary) {
  transition:
    transform var(--duration-base) var(--ease-out),
    box-shadow var(--duration-base) var(--ease-out);
}

.app-main a.ds-card:hover,
.app-main .ds-card:has(.ds-button-primary):hover {
  transform: translateY(-1px);
  box-shadow: var(--shadow-md);
}

/* --- §D headings carry weight -------------------------------------------- */

/* Inter, bold, tight — one step up the scale from 076's `--text-xl`. The owner
   ruled Inter and no second face (2026-09-08), so weight, tracking and leading
   are the whole instrument. It stays a property of `.ds-headline` inside
   `.app-main`, so no screen picks its own size and TypeScaleAuditTest's ban on
   inline font-size keeps holding. */
/* Card and section titles: the size is 076's (a card title must not compete
   with the page title), the weight and tracking are this round's. */
.app-main .ds-title,
.app-main .app-card-h h2 {
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-tight);
  line-height: var(--leading-tight);
}

/* The eyebrow, generalised out of the login ceremony's `.app-auth-eyebrow` (its
   exemplar, unchanged): a small uppercase brand line naming the section a
   heading belongs to. 0.08em is a literal because it is a TRACKING value for
   one size, and the design system's `--tracking-wide` (0.025em) is sized for
   body text — at `--text-xs` uppercase it does not open the word at all. */
.app-eyebrow {
  margin: 0 0 var(--space-1);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--brand);
}

/* --- §E inputs and controls transition ----------------------------------- */

/* The design system already transitions `.ds-button` and `.ds-input` colour,
   border and shadow (primitives.css) — the brief's premise that "nothing in the
   app applies it" is true of the `.ds-transition` CLASS and not of the effect.
   What is genuinely missing is here: the press, the rail, and every control in
   the app that is a bare <select>/<textarea> rather than a `.ds-input`. */
.app-main .ds-button {
  transition:
    color var(--duration-fast) var(--ease-out),
    background-color var(--duration-fast) var(--ease-out),
    border-color var(--duration-fast) var(--ease-out),
    box-shadow var(--duration-fast) var(--ease-out),
    transform var(--duration-fast) var(--ease-out);
}

/* The press. 1px is the whole gesture: a button that travels further reads as a
   toy, and one that does not move at all reads as a picture of a button. */
.app-main .ds-button:active:not([aria-disabled="true"]):not(:disabled) {
  transform: translateY(1px);
}

.app-main select:not(.ds-input),
.app-main textarea:not(.ds-input) {
  transition:
    border-color var(--duration-fast) var(--ease-out),
    box-shadow var(--duration-fast) var(--ease-out);
}

/* The rail. 100ms, background only: the rows are dense and a slower fade makes
   a pointer crossing the rail leave a comet trail behind it. */
.app-nav-link {
  transition:
    color var(--duration-fast) var(--ease-out),
    background-color var(--duration-fast) var(--ease-out);
}

/* Reduced motion. motion.css's own block already collapses every duration token
   to 0ms and clamps `*` animations to 0.01ms, which is enough to STOP all of
   the above — but "0.01ms of app-enter" still computes an animation-name, and
   the entrance's cleanup hangs off `animationend`. Naming `animation: none`
   here is what makes the reduced-motion state assertable (ShellFeelBrowserTest
   reads the computed animation-name) and is why shell.js carries a fallback
   timer for a class whose animation will never end. */
@media (prefers-reduced-motion: reduce) {
  .app-enter,
  .app-status-pill,
  .app-status-pill-spinner {
    animation: none;
  }

  .app-main a.ds-card:hover,
  .app-main .ds-card:has(.ds-button-primary):hover,
  .app-main .ds-button:active {
    transform: none;
  }
}

/* ---------------------------------------------------------------------------
   110 — THE SHELL FITS A PHONE. ONE breakpoint table (ui-screens.md §3), and
   every rule below is a line of it:

   | Width     | Rail                                            | Topbar                                          | Main gutter |
   |-----------|-------------------------------------------------|-------------------------------------------------|-------------|
   | ≥ 1100 px | as today (expanded; `rail-collapsed` on         | as today                                        | --gap-lg    |
   |           | user choice, remembered)                        |                                                 |             |
   | 768–1099  | starts collapsed (icons only) unless the user   | search hidden (the old 900px rule moved to      | --gap-lg    |
   |           | expanded it — same class, same localStorage     | this breakpoint); crumbs truncate to the LEAF   |             |
   |           | key — the DEFAULT flips at this width           | with the full path in title                     |             |
   | < 768 px  | off-canvas drawer: not in the grid, position    | brand tile → hamburger, crumbs (leaf only),     | --gap-md    |
   |           | fixed, --z-drawer, translated off-screen;       | mode toggle, user menu; nothing wraps; the      |             |
   |           | opened by #rail-open, closed by Escape, the     | search moves INTO the drawer's head             |             |
   |           | backdrop or any boosted navigation              |                                                 |             |

   The drawer is the SAME <aside class="app-rail"> element — no second nav. Its open
   state is the `rail-open` class on <html>, set and cleared by shell.js and NEVER
   persisted: a drawer is closed on every load. The 768–1099 default is pure CSS (so
   it survives a live resize), and a user's explicit choice rides the SAME class and
   storage key as before: `rail-collapsed` = a stored "1", `rail-expanded` (110's one
   new class, stamped pre-paint by the layout's inline script) = a stored "0". Breakpoint
   values use .98 ceilings so a viewport reported at exactly 768 or 1100 always gets
   the wider band's layout.
   --------------------------------------------------------------------------- */

@media (min-width: 768px) and (max-width: 1099.98px) {
  /* The default flips: icons-only, unless the user explicitly expanded. A stored
     "1" reaches the same visual through the unscoped rail-collapsed rules; a
     stored "0" carries `rail-expanded` and opts out of this default. */
  :root:not(.rail-expanded) .app-shell {
    --app-rail-width: var(--app-rail-collapsed);
  }

  :root:not(.rail-expanded) .app-rail-label {
    display: none;
  }

  /* The status pill is a fixed SIBLING of the shell and reads the rail width off
     the same classes the shell does (see 103 §A); the classless default has to be
     spelled out for it too, or it would clear a rail that is only 60px wide. */
  :root:not(.rail-expanded) .app-status-pill {
    left: calc(var(--app-rail-collapsed) + var(--gap-lg));
  }

  /* The collapse chevron points the way the rail will GO: in the classless
     default the rail already sits collapsed, so the chevron rotates as it does
     under the class. */
  :root:not(.rail-expanded) .app-rail-chevron {
    transform: rotate(180deg);
  }
}

/* §C — the editors' phone band (pipelines/editor.html, templates/editor.html):
   desktop-first by decision, not omission. Hidden everywhere, shown below 768. */
.app-wide-screen-note {
  display: none;
}

/* §B — every table scrolls INSIDE its own box instead of widening the document —
   the .app-card-table treatment (overflow-x: auto), one rule for all of them, at
   every width (a wide table is a document-widener at 768 exactly as at 390; the
   walk proved it on /admin/users). `display: block` turns each table into its own
   scroll container, which is what lets wrapper-less tables (admin, workspaces,
   promotion, the execution fragments) get it without new markup — and what keeps
   the oob-swapped keys table (api/console.html) safe: its fragment root must stay
   a bare <table> (ui-screens.md §4.5 — a wrapped table partial is silently
   discarded by the fragment parser). */
.app-main .ds-table {
  display: block;
  overflow-x: auto;
}

/* §B — the shell's own shared rule. `.app-shell` lays its content column out as
   a bare `1fr`, whose automatic minimum is the item's min-content — so ANY page
   whose content has a wide intrinsic minimum (a 7-column table, a nowrap form
   row) widened the whole track and dragged the topbar with it: /admin/users at
   768px measured 376px over with the table already scrolling internally, because
   `main` itself refused to shrink. `min-width: 0` lets the column be the width
   the viewport gives it; the table/pre/form scroll-and-wrap rules above then do
   their jobs. This is the same lesson as the `minmax(0, 1fr)` note on
   .app-grid-stack, one level up. */
.app-main {
  min-width: 0;
}

/* §B — below 1100 the shell's content column is narrow enough that a nowrap
   control row IS a document-widener: /admin/users' create-user form (two inputs,
   a button and a note in one .u-row) pushed the page 374px past a 768px viewport
   with the rail packed away. The rows wrap, as .app-filters always has. */
@media (max-width: 1099.98px) {
  .app-main .u-row {
    flex-wrap: wrap;
  }
}

@media (max-width: 767.98px) {
  /* One content column; the rail leaves the grid entirely. minmax(0, 1fr), never a
     bare 1fr — a grid item's default min-width:auto is how documents get widened
     by their content (the .app-grid-stack precedent). */
  .app-shell {
    grid-template-columns: minmax(0, 1fr);
  }

  .app-container,
  .app-topbar {
    padding-inline: var(--gap-md);
  }

  /* --- the drawer --- */

  .app-rail {
    position: fixed;
    top: 0;
    bottom: 0;
    left: 0;
    /* Always the FULL rail here: a collapse chosen on a desktop must not make a
       60px-wide drawer on a phone, so this reads the expanded token directly
       rather than the shell's overridable --app-rail-width. */
    width: var(--app-rail-expanded);
    height: auto;
    z-index: var(--z-drawer);
    transform: translateX(-100%);
    visibility: hidden;
    transition:
      transform var(--duration-base) var(--ease-out),
      visibility 0s linear var(--duration-base);
  }

  :root.rail-open .app-rail {
    transform: none;
    visibility: visible;
    transition: transform var(--duration-base) var(--ease-out);
  }

  /* Body scroll lock, on the same class — no inline style anywhere. */
  :root.rail-open {
    overflow: hidden;
  }

  :root.rail-open .app-rail-backdrop {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
    transition: opacity var(--duration-base) var(--ease-out);
  }

  /* The chrome lights up: opener first in the bar, close in the drawer's head,
     and the search's second seat under the brand. */
  .app-rail-open {
    display: grid;
  }

  .app-rail-close {
    display: grid;
  }

  .app-search.app-rail-search {
    display: flex;
    margin: 0 var(--gap-md) var(--gap-sm);
    width: auto;
  }

  /* Nothing in the bar wraps: the crumbs absorb the squeeze (min-width: 0 +
     ellipsis) so the controls keep their boxes. */
  .app-topbar {
    flex-wrap: nowrap;
  }

  /* Touch targets: everything operable in the drawer and the bar is at least
     --field-height-lg tall at this width. */
  .app-nav-link {
    min-height: var(--field-height-lg);
  }

  .app-rail-collapse {
    min-height: var(--field-height-lg);
  }

  .app-topbar .app-icon-button,
  .app-topbar .app-avatar {
    width: var(--field-height-lg);
    height: var(--field-height-lg);
  }

  /* --- §B: every screen fits --- */

  /* The page header: actions wrap UNDER the title, never off-screen. */
  .app-page-h {
    flex-wrap: wrap;
  }

  .app-page-h .app-page-actions {
    width: 100%;
    flex-wrap: wrap;
  }

  /* Modals: full width, the tighter gutter, the full viewport — one rule that the
     -sm/-md/-lg max-widths lose to (this block is later in the file). The close
     button rides the scroll instead of being clipped by it. */
  .app-modal {
    max-width: none;
    max-height: 100dvh;
    padding: var(--gap-md);
  }

  .app-modal-close {
    position: sticky;
    top: 0;
    display: flex;
    width: fit-content;
    margin-left: auto;
    /* The modal's own surface, so scrolled content does not show through. */
    background: inherit;
    z-index: 1;
  }

  /* Toasts: the stack sits at the BOTTOM, full width minus the gutter — it can
     never cover the opener in the topbar. */
  .app-toast-stack {
    top: auto;
    bottom: var(--gap-md);
    left: var(--gap-md);
    right: var(--gap-md);
    max-width: none;
    padding: 0;
  }

  /* §C: the editor band shows; the editor stays rendered underneath it. */
  .app-wide-screen-note {
    display: flex;
    margin-bottom: var(--gap-md);
  }
}

/* Motion only, silenced the house way — the drawer and its scrim keep their
   states (visibility is information here) and lose their transitions. The
   `!important` is forced, not stylistic: motion.css's reduced-motion block clamps
   `* { transition-duration: 0.01ms !important }`, and without matching weight a
   plain `transition: none` here would lose to the vendored sheet and compute as
   1e-05s — measurably (MobileShellBrowserTest reads the computed duration). */
@media (prefers-reduced-motion: reduce) and (max-width: 767.98px) {
  .app-rail,
  :root.rail-open .app-rail,
  .app-rail-backdrop,
  :root.rail-open .app-rail-backdrop {
    transition: none !important;
  }
}
