/* Components on top of tokens.css. Sidebar/shell here; later tasks append
   graph, cube, drawer, badge sections. */
* { box-sizing: border-box; }
/* The user-agent's `[hidden] { display: none }` has UA priority, so ANY author rule
   that sets `display` on the same element silently defeats it — `.wf-nav{display:flex}`
   did exactly that, leaving the tree's disclosure control visibly dead. Restating it
   at author level with !important closes the whole class of bug, not one instance. */
[hidden] { display: none !important; }
html, body { height: 100%; margin: 0; }
body {
  font-family: -apple-system, system-ui, "Segoe UI", sans-serif;
  color: var(--ink);
  /* barely-there gradient mesh so glass reads as glass */
  background:
    radial-gradient(1100px 700px at 12% -8%, var(--glow) 0%, rgba(255,255,255,0) 60%),
    radial-gradient(900px 600px at 108% 18%, var(--accent-weak) 0%, rgba(255,255,255,0) 55%),
    var(--bg);
  line-height: 1.5;
}
.glass {
  background: var(--glass-bg);
  border: 1px solid var(--glass-border);
  box-shadow: var(--shadow-md);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
}

/* shell: sidebar | center | drawer */
/* The sidebar and drawer are fixed glass OVERLAYS; the canvas spans full width
   and passes UNDER them (blurred through the glass) — neither clips it. */
.shell { display: block; min-height: 100vh; }
/* Inset convention: the LAYOUT CONTAINER owns the padding that keeps content off
   its edges (never rely on a child's ad-hoc margin for that). So #center always
   insets its children (header, graph) — text can't hug the column wall. */
/* Full-bleed canvas: #center is the whole viewport; the graph fills it edge-to-edge
   and all chrome (header, sidebar, drawer) floats over it as glass overlays — so
   nothing constrains the canvas top or bottom. */
#center { position: relative; min-width: 0; height: 100vh; }

/* sidebar — fixed full-height glass overlay on the left (mirrors #drawer on the
   right); the canvas passes under it, blurred through the glass. */
#sidebar { position: fixed; top: var(--space-3); left: var(--space-3); bottom: var(--space-3);
  width: var(--sidebar-w); border-radius: var(--radius-lg); padding: var(--space-4);
  overflow-y: auto; z-index: 20; }
.brand { font-weight: 700; font-size: var(--text-md); letter-spacing: -.01em;
  display: block; margin-bottom: var(--space-3); color: var(--ink); text-decoration: none; }
.wf-section-label { margin: var(--space-3) var(--space-2) var(--space-2);
  color: var(--gray-500); font-size: var(--text-sm); font-weight: 600; }
.wf-components { margin-top: var(--space-4); border-top: 1px solid var(--gray-200);
  padding-top: var(--space-3); }
.wf-components summary { cursor: pointer; color: var(--gray-600); font-size: var(--text-sm);
  font-weight: 600; padding: var(--space-2); border-radius: var(--radius-md); }
.wf-components summary:hover { background: var(--gray-100); }
.wf-components summary span { font-weight: 400; color: var(--gray-500); }
.wf-components > .wf-nav { margin-top: var(--space-2); }
.wf-nav { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column;
  gap: var(--space-1); }
.wf-nav a { display: block; padding: var(--space-2) var(--space-3); border-radius: var(--radius-md);
  text-decoration: none; color: var(--gray-700); font-size: var(--text-sm); cursor: pointer; }
.wf-nav a:hover { background: var(--gray-100); color: var(--ink); }
.wf-nav a.active { background: var(--accent-weak); color: var(--accent-ink); font-weight: 600; }
/* ---- catalog tree ----
   A row is [twisty][link]; nesting is expressed by inline padding on the row, so the
   hover/active background still spans the full sidebar width at every depth. */
/* min-height, not padding: a two-line nested row is already tall enough, a
   one-line root row is not — and both must be comfortably tappable. */
.wf-row { display: flex; align-items: center; gap: 2px; min-height: 40px; }
.wf-row .wf-link { flex: 1; min-width: 0; padding: 6px var(--space-2); }
/* 28px, not the 16px glyph box: this is the primary control of the tree and the
   product is used from a phone. Still below the 44px ideal, but the row itself is
   44px tall, so the miss lands on the link rather than on nothing. */
.twisty { flex: 0 0 28px; width: 28px; height: 28px; padding: 0; border: 0;
  background: none; border-radius: var(--radius-sm);
  color: var(--gray-600); font-size: 13px; line-height: 1; cursor: pointer;
  display: grid; place-items: center;
  transition: transform .15s var(--ease), background .15s var(--ease); }
.twisty:hover { color: var(--ink); background: var(--gray-100); }
.twisty[aria-expanded="true"] { transform: rotate(90deg); }
.twisty-gap { flex: 0 0 28px; }
/* The step id leads; the workflow it refers to is secondary — the step belongs to the
   parent, the workflow is shared between call sites. */
.wf-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wf-ref { display: block; font-size: 12px; color: var(--gray-500);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A guide line makes depth readable at a glance; without it a tree with small
   indents is just a list with ragged left edges. Sits under the twisty column. */
.wf-children { margin: 0; padding: 0; position: relative; }
.wf-children::before { content: ""; position: absolute; top: 2px; bottom: 2px;
  left: 13px; width: 1px; background: var(--gray-200); }
.wf-children .wf-children::before { left: 29px; }
.wf-children .wf-children .wf-children::before { left: 45px; }

/* The section icon in a drawer heading sits with the label, not as an emoji blob. */
.kv-head-icon { margin-right: 6px; color: var(--gray-500); }

/* ---- focus, once, for everything interactive ----
   Keyboard users get the same affordances as mouse users. :focus-visible keeps the
   ring off mouse clicks, so it costs the pointer experience nothing. */
/* `summary` is in the list because the run picker is one: a <summary> is focusable
   without being an <a>, a <button> or a [tabindex], so it was the one control on the
   page with no ring on it. */
a:focus-visible, button:focus-visible, input:focus-visible,
textarea:focus-visible, summary:focus-visible, [tabindex]:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm);
}

/* center placeholder */
.center-empty { display: grid; place-items: center; height: 60vh; color: var(--gray-500); }

/* drawer host (off by default; sizing in Task 3) */
#drawer:empty { display: none; }

/* ---- block graph ---- */
/* Floating glass chip (title, run status, view switch, Run) over the canvas:
   content-width (NOT full width), clears the sidebar on the left; the canvas passes
   under it, blurred. */
.wf-header { position: absolute; z-index: 30; top: var(--space-3);
  left: calc(var(--sidebar-w) + var(--space-3) * 2); width: max-content; margin: 0;
  /* …but never so wide that it slides over the drawer. The header sits above the
     drawer in the stack, so anything that reaches that far is drawn ON TOP of the
     panel the operator is working in — including, since the view switch moved in
     here, controls. Wrap instead of overlap. */
  /* …but never narrower than a legible title row either. Unfloored, this arithmetic
     goes NEGATIVE below 772px of viewport, and the rule was never overridden in the
     mobile block — which is the whole reason the phone header rendered its <h1> at
     0px wide and wrapped "needs review" inside its own pill. The floor only binds
     between 1025 and ~1092px, where the drawer's left edge is still clear of it. */
  max-width: max(320px,
    calc(100vw - var(--sidebar-w) - min(380px, 90vw) - var(--space-4) * 4));
  display: flex; align-items: center; gap: var(--space-3);
  flex-wrap: wrap; row-gap: var(--space-1);
  padding: var(--space-2) var(--space-4); border-radius: var(--radius-lg); }
.wf-header h1 { font-size: var(--text-lg); font-weight: 700; letter-spacing: -.02em; margin: 0; }
.hdr-run { padding: var(--space-1) var(--space-3); border: 0; border-radius: var(--radius-md);
  background: var(--accent); color: var(--on-accent); font-size: var(--text-sm); font-weight: 700;
  cursor: pointer; white-space: nowrap; transition: filter .15s var(--ease); }
.hdr-run:hover { filter: brightness(1.06); }
/* Breadcrumb trail of a drilled-in call. Sits before the title in the same glass
   chip; the header is a flex row, so it reads as one line. */
.crumbs { display: flex; align-items: center; gap: 6px; font-size: var(--text-sm);
  color: var(--gray-500); white-space: nowrap; max-width: 42vw; overflow: hidden; }
.crumbs a { color: var(--gray-700); text-decoration: none; }
.crumbs a:hover { color: var(--accent); text-decoration: underline; }
.crumb-current { color: var(--ink); font-weight: 600; }
/* --gray-500, not --gray-300: a drilled-in level had never been in the contrast sweep
   until for_each items gave it one to visit, and the separator measured 1.56:1. It is
   a glyph a reader parses as "inside", so it is text, and it is held to text's floor. */
.crumb-sep { color: var(--gray-500); }
/* "Used by: …" — the reverse index, in the header of a library workflow. */
.used-by { font-size: var(--text-sm); color: var(--gray-500); white-space: nowrap; }
.used-by a { color: var(--accent); text-decoration: none; }
.used-by a:hover { text-decoration: underline; }
.used-by-step { color: var(--gray-500); margin-left: 4px; }
/* Pannable canvas (Google-Maps style): the viewport clips, .graph-canvas is
   dragged via CSS transform by ui.js — no scrollbars. */
.graph { padding: 0; position: absolute; inset: 0; z-index: 0;
  overflow: hidden; cursor: grab; touch-action: none; }
.graph.panning { cursor: grabbing; user-select: none; }
.graph-canvas { position: absolute; top: 0; left: 0;
  /* Just breathing room. Clearing the floating chrome (header, sidebar, drawer) used
     to be done here, with a 60px top and a sidebar-wide left padding — but this box
     is INSIDE the scaled transform, so fit-to-screen shrank the clearance along with
     the content: at the 0.22 scale a ten-step graph needs on a phone, 60px of header
     clearance became 13px and six of ten nodes ended up under the header. Clearance
     is now a pan offset computed from the chrome's measured rects (ui.js safeRect),
     which is correct at every zoom level. */
  padding: var(--space-3);
  transform-origin: 0 0; will-change: transform; }
/* Fixed-size cards on one horizontal track; connectors are drawn as an SVG
   overlay by ui.js (graphConnectors) — no CSS pseudo-arrows. align-items:center
   keeps cards their natural height (no vertical stretch) and aligns their centres
   so connector lines stay horizontal. */
.chain { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: nowrap;
  align-items: center; gap: var(--space-4) 60px; position: relative; z-index: 1; }
.cube { position: relative; display: flex; flex-direction: column; gap: var(--space-1);
  width: 150px; min-height: 74px; box-sizing: border-box; flex: 0 0 auto;
  padding: var(--space-3); border-radius: var(--radius-md);
  background: var(--glass-bg); border: 1px solid var(--gray-200); box-shadow: var(--shadow-sm);
  cursor: pointer; transition: transform .18s var(--ease), box-shadow .18s var(--ease),
    border-color .18s var(--ease); }
.cube:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.cube.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
/* A `use:` step (a whole sub-workflow used as one block): dashed "nested" outline
   + a corner drill-in link to open the sub-workflow's own graph. */
.cube-workflow { border-style: dashed; border-color: var(--accent); }
.cube-workflow .cube-icon { color: var(--accent); }
.drill-link { position: absolute; top: 6px; right: 8px; font-size: 15px; line-height: 1;
  text-decoration: none; color: var(--gray-500); border-radius: var(--radius-sm);
  /* A control, so a real target: 20x17 was a glyph you had to aim at. */
  min-width: 26px; min-height: 26px; display: grid; place-items: center;
  transition: color .15s var(--ease), background .15s var(--ease); }
.drill-link:hover { color: var(--accent); background: var(--accent-weak); }
/* SVG connector overlay (drawn by ui.js) sits behind the cards. */
.graph-connectors { position: absolute; top: 0; left: 0; pointer-events: none;
  z-index: 0; overflow: visible; }
.graph-connectors path { fill: none; stroke: var(--gray-300); stroke-width: 1.5; }
.cube-icon { font-size: 1.15rem; color: var(--gray-700); }
.cube-id { font-size: var(--text-sm); font-weight: 600; min-width: 0;
  overflow-wrap: anywhere; }  /* long ids (write_telegram_post) wrap inside the card */
.cube-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--gray-300); }
.cube-done   { border-color: var(--ok); }
.cube-done   .cube-dot { background: var(--ok); }
.cube-failed { border-color: var(--err); }
.cube-failed .cube-dot { background: var(--err); }
.cube-running .cube-dot { background: var(--accent); }

/* ---- secret badge (key on blocks that reference {secret.NAME}) ---- */
.secret-badge { position: absolute; top: -7px; right: -7px; width: 20px; height: 20px;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 11px; line-height: 1; border-radius: 50%;
  border: 1px solid var(--gray-200); box-shadow: var(--shadow-sm); }
/* Ink must match the tint it sits on: this chip is --warn-weak, so it takes
   --warn-ink. Paired with --err-ink (cut for the red tint) it measured 4.11:1 on
   dark. The red border stays — the border is a fill, not text. */
.secret-missing { background: var(--warn-weak); border-color: var(--err);
  color: var(--warn-ink); }
/* Instance-wide notice above the workflow, not a chip beside one secret: the
   rotated-key state is true of every secret at once, and it is the explanation for
   the .secret-missing chips below it. Same --warn-weak/--warn-ink pair as those
   chips, so the banner reads as their cause rather than as a fourth status. */
.store-banner { margin: 0 0 var(--space-4); padding: var(--space-3) var(--space-4);
  border: 1px solid var(--warn); border-radius: var(--radius-sm);
  background: var(--warn-weak); color: var(--warn-ink); line-height: 1.5; }
.secret-set { background: var(--gray-100); border-color: var(--gray-300);
  color: var(--gray-500); }
.secret-section-head { margin-top: var(--space-3); }
.secret-msg { margin: var(--space-1) 0; padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-sm); font-size: var(--text-sm); }
/* Text on its own tint takes the -ink cut: plain --ok measured 2.80:1 and plain
   --err 4.01:1 on these chips. Only a secret save renders them, which is why the
   page sweep never saw them. */
.secret-msg-ok { background: rgba(22,163,74,.12); color: var(--ok-ink); }
.secret-msg-err { background: rgba(220,38,38,.10); color: var(--err-ink); }
.secret-msg-info { background: var(--gray-100); color: var(--gray-700); }
/* ---- popup modal (long drawer elements open here) ---- */
#modal:empty { display: none; }
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center;
  background: rgba(15,18,25,.38); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
  padding: var(--space-4); }
.modal-panel { width: min(760px, 96vw); max-height: 88vh; display: flex; flex-direction: column;
  border-radius: var(--radius-lg); padding: var(--space-4); overflow: hidden;
  /* A reading surface must not inherit the dimmed canvas through glass. */
  background: var(--surface-solid);
  -webkit-backdrop-filter: none; backdrop-filter: none; }
.modal-head { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3); }
.modal-title { font-size: var(--text-md); font-weight: 700; overflow-wrap: anywhere; }
.modal-body { overflow: auto; }
/* buttons that open a popup for a long element */
.chip-open { display: inline-flex; align-items: center; gap: var(--space-1); margin: var(--space-1) 0;
  padding: var(--space-2) var(--space-3); border: 1px solid var(--gray-300); border-radius: var(--radius-md);
  background: var(--surface-chip); color: var(--gray-800); font-size: var(--text-sm); font-weight: 600;
  cursor: pointer; }
.chip-open:hover { border-color: var(--accent); color: var(--accent-ink); }
.drill-chip { text-decoration: none; border-color: var(--accent); color: var(--accent-ink); }
.chip-preview { display: block; color: var(--gray-500); font-size: var(--text-sm); margin: var(--space-1) 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- editable prompt (AI block) ---- */
.prompt-hint { font-weight: 400; font-size: var(--text-sm); color: var(--gray-500); }
.prompt-edit { width: 100%; padding: var(--space-2); border: 1px solid var(--gray-300);
  border-radius: var(--radius-sm); background: rgba(255,255,255,.7); font-size: 16px;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; line-height: 1.5;
  resize: vertical; margin-bottom: var(--space-2); }
.prompt-edit:focus { outline: 2px solid var(--accent-weak); border-color: var(--accent); }

/* ---- drawer ---- */
#drawer { position: fixed; top: var(--space-3); right: var(--space-3); bottom: var(--space-3);
  width: min(380px, 90vw); border-radius: var(--radius-lg); padding: var(--space-4);
  overflow: auto; z-index: 20; transition: transform .26s var(--ease), opacity .26s var(--ease); }
.drawer-head { display: flex; align-items: center; gap: var(--space-2);
  margin-bottom: var(--space-3); }
.drawer-head .cube-icon { font-size: 1.3rem; }
.drawer-title { font-size: var(--text-md); font-weight: 700; }
.drawer-close { margin-left: auto; border: 0; background: var(--gray-100); cursor: pointer;
  width: 28px; height: 28px; border-radius: 50%; color: var(--gray-700); font-size: 1rem; }
.kv { margin: var(--space-2) 0; }
.kv-key { font-size: var(--text-sm); color: var(--gray-500); }
.kv-val { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--text-sm);
  white-space: pre-wrap; word-break: break-word; }
.kv-head { font-size: var(--text-sm); font-weight: 600; color: var(--gray-700);
  margin: var(--space-3) 0 var(--space-1); }
.kv-sub { display: flex; gap: var(--space-3);
  margin: var(--space-1) 0 var(--space-1) var(--space-3); }
.kv-sub .kv-key { min-width: 76px; flex: none; }
.kv-text { margin-top: var(--space-1); }
/* Inter-step dependency chips in a block's drawer ("Depends on"). */
.dep-list { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: var(--space-2); }
.dep-chip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .78rem; color: var(--accent-ink); background: var(--accent-weak);
  border-radius: 999px; padding: 2px var(--space-2); white-space: nowrap; }
.field { display: block; margin: var(--space-3) 0; }
.field span { display: block; font-size: var(--text-sm); color: var(--gray-700);
  margin-bottom: var(--space-1); }
.field input { width: 100%; padding: var(--space-2); border: 1px solid var(--gray-300);
  border-radius: var(--radius-sm); background: var(--glass-bg); font-size: var(--text-md); }
.field input:focus { outline: 2px solid var(--accent-weak); border-color: var(--accent); }
.btn-run { margin-top: var(--space-3); width: 100%; padding: var(--space-2) var(--space-3);
  /* --on-accent, not #fff: the accent lightens on the dark theme and white text on it
     measures 3.22:1. Same token .hdr-run already uses — this one was simply missed. */
  border: 0; border-radius: var(--radius-md); background: var(--accent); color: var(--on-accent);
  font-size: var(--text-md); font-weight: 600; cursor: pointer; }
.btn-run:hover { filter: brightness(1.05); }
.drawer-state { font-size: var(--text-sm); color: var(--gray-500); }
/* Same story as .secret-msg-err: plain --err on its own tint is 4.01:1. A failed
   run's message is the one thing you read when something breaks. */
.drawer-error { margin: var(--space-2) 0; padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm); background: rgba(220,38,38,.10); color: var(--err-ink);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--text-sm);
  white-space: pre-wrap; word-break: break-word; }
/* A URL inside an error inherits the error's ink: the default link blue is measured
   against a red tint nobody chose it for, and it reads as a stray bit of chrome. */
.drawer-error a { color: inherit; text-decoration: underline; }
.err { color: var(--err); }

/* ---- run status badge + running pulse ---- */
#run-status:empty { display: none; }  /* no run yet → no gap in the header */
/* nowrap: the pill is a single-word-shaped object. Squeezed by a narrow header it
   used to break "needs review" over two lines INSIDE the rounded rectangle, which
   reads as a broken shape rather than as a status. Let the header wrap instead. */
.badge { display: inline-block; margin: 0; padding: var(--space-1) var(--space-3);
  border-radius: 999px; font-size: var(--text-sm); font-weight: 600; background: var(--gray-100);
  color: var(--gray-700); white-space: nowrap; }
.badge-running { background: var(--accent-weak); color: var(--accent-ink); }
.badge-completed { background: rgba(22,163,74,.14); color: var(--ok-ink); }
.badge-failed { background: rgba(220,38,38,.12); color: var(--err-ink); }
.status-completed { color: var(--ok); } .status-failed { color: var(--err); }
.status-running { color: var(--accent); } .status-pending { color: var(--gray-500); }
@keyframes cube-pulse {
  0%,100% { box-shadow: 0 0 0 0 var(--accent-weak); }
  50%     { box-shadow: 0 0 0 6px var(--accent-weak); }
}
.cube-running { border-color: var(--accent); animation: cube-pulse 1.4s var(--ease) infinite; }
@media (prefers-reduced-motion: reduce) { .cube-running { animation: none; } }
.cube-waiting_manual { border-color: var(--warn); }
.cube-waiting_manual .cube-dot { background: var(--warn); }
.badge-waiting_manual { background: var(--warn-weak); color: var(--warn-ink); }
/* Stopped runs read NEUTRAL, not red: a cancelled or timed-out run is an outcome
   somebody chose (or a limit doing its job), not a thing that went wrong — the
   default grey .badge is exactly right and is named here so it stays deliberate. */
.badge-cancelled, .badge-timed_out { background: var(--gray-100); color: var(--gray-700); }

/* ---- zoom / fit cluster ----
   The canvas has `overflow: hidden` and is moved by a transform, so there are no
   scrollbars and nothing on screen says "there is more of this graph to the right".
   Panning by dragging empty background is not discoverable, and at 768x1024 eight of
   ten nodes were off-viewport with no way to learn that. This is the affordance: the
   same three controls every map has, and the only keyboard route to zoom.
   Bottom-LEFT on purpose — the drawer owns the right edge, and the sidebar's own
   bottom edge is above this one, so nothing collides in either layout. */
.graph-controls { position: absolute; z-index: 2; bottom: var(--space-3);
  left: calc(var(--sidebar-w) + var(--space-3) * 2);
  display: flex; gap: var(--space-1); padding: var(--space-1);
  border-radius: 999px; }
.gc-btn { width: 40px; height: 40px; border: 0; border-radius: 50%; padding: 0;
  background: transparent; color: var(--gray-700); cursor: pointer;
  font-size: var(--text-md); font-weight: 600; line-height: 1;
  display: grid; place-items: center;
  transition: background .15s var(--ease), color .15s var(--ease); }
.gc-btn:hover { background: var(--gray-100); color: var(--ink); }
.gc-fit { width: auto; padding: 0 var(--space-3); border-radius: 999px;
  font-size: var(--text-sm); }

/* ---- stop control ---- */
.run-stop { position: absolute; top: var(--space-2); right: var(--space-2); margin: 0; z-index: 2; }
.btn-stop { font-size: var(--text-sm); padding: var(--space-1) var(--space-3);
  border-radius: 999px; border: 1px solid var(--err); color: var(--err-ink);
  background: transparent; cursor: pointer; }
.btn-stop:hover:not(:disabled) { background: rgba(220,38,38,.10); }
.btn-stop:disabled { border-color: var(--gray-300); color: var(--gray-500); cursor: default; }

/* ---- glass fallback ---- */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .glass, .cube { background: var(--surface-solid); }
}
/* ---- mobile menu button (hidden on desktop) ---- */
.menu-btn { display: none; }
.md-body { font-size: var(--text-md); line-height: 1.55; color: var(--gray-800);
  max-height: 46vh; overflow-y: auto; padding-right: var(--space-2); }
.md-body h1 { font-size: 1.3rem; margin: var(--space-3) 0 var(--space-2); }
.md-body h2 { font-size: 1.1rem; margin: var(--space-3) 0 var(--space-1); }
.md-body p, .md-body ul, .md-body ol { margin: var(--space-2) 0; }
.md-body li { margin-left: var(--space-3); }
.review-actions { display: flex; gap: var(--space-2); margin-top: var(--space-2); }
textarea[name="feedback"] { width: 100%; padding: var(--space-2);
  border: 1px solid var(--gray-300); border-radius: var(--radius-sm);
  background: var(--glass-bg); font: inherit; font-size: var(--text-md); resize: vertical; }

/* ---- loop container ---- */
/* The loop must occupy EXACTLY its inner content (cube/sub-chain) width in layout
   flow so cubes line up by their actual cubes across fan-out branches — NOT by the
   loop's padding/border. So the box itself is flow-transparent (no padding, border
   or background); the dashed rounded box is painted by ::before as an overlay that
   expands outward without affecting layout, and the header floats above the cubes. */
.loop { position: relative; padding: 0; border: none; background: none;
  cursor: pointer; }
.loop::before { content: ""; position: absolute; inset: -30px -10px -10px -10px;
  border: 2px dashed var(--gray-300); border-radius: var(--radius-md);
  background: var(--glass-bg); pointer-events: none; z-index: -1;
  transition: border-color .18s var(--ease); }
.loop:hover::before { border-color: var(--accent); }
.loop.active::before { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.loop-done::before   { border-color: var(--ok);  border-style: solid; }
.loop-failed::before { border-color: var(--err); border-style: solid; }
.loop-running::before { border-color: var(--accent); border-style: solid;
  animation: cube-pulse 1.4s var(--ease) infinite; }
/* Header floats absolutely just above the cubes so it adds no horizontal flow width
   (loop flow width == cube width) and stays inside the overlay's top band. */
.loop-header { position: absolute; bottom: 100%; left: 0; margin: 0 0 4px 0;
  display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm);
  white-space: nowrap; line-height: 1; pointer-events: none; }
.loop-icon { font-size: 1rem; color: var(--accent); }
.loop-id { font-weight: 700; color: var(--ink); }
.loop-condition { color: var(--gray-600); flex: 1; }
.loop-counter { color: var(--accent); font-weight: 600; white-space: nowrap; }
.chain-nested { margin-left: var(--space-3); }

/* ---- fan_out container: stacked parallel branches (one track per row) ---- */
.fan-out { border: 1px dashed var(--gray-300); border-radius: var(--radius-md);
  padding: var(--space-4); background: rgba(120,130,150,.04); flex: 0 0 auto; }
/* min-height, not padding: one line of 16px text is a 24px box, which is the WCAG
   2.5.8 floor to the pixel and misses on any font that rounds down. This is the only
   way into a fan_out's own drawer, and it now announces itself as a button, so it has
   to be aimable with a finger rather than exactly-just-barely with a cursor. */
.fan-out-header { display: flex; gap: var(--space-2); align-items: center;
  min-height: 28px; cursor: pointer; font-weight: 700; color: var(--ink);
  margin-bottom: var(--space-3); }
.fan-out-icon { color: var(--accent); }
/* Extra inter-branch gap (--space-4, not --space-3) gives a loop's floating header
   room to sit above its cubes without colliding with the branch row above it. */
.fan-branches { display: flex; flex-direction: column; gap: var(--space-4); }
.fan-branch { display: flex; flex-direction: row; align-items: center; gap: var(--space-3); }
/* No text-transform: a branch name is the author's own value from the YAML
   ("publish", "social"). Upper-casing it shows something the file does not say. */
.fan-branch-name { min-width: 68px; font-size: 11px;
  letter-spacing: .02em; color: var(--gray-600); font-weight: 600; }
.fan-branch .chain-nested { margin-left: 0; }
.fan-out.fan-out-running { border-color: var(--accent); }

/* ---- for_each container: the body once, then one tile per item ----
   Built on the fan_out box (same frame, same clickable header) because it is the
   same kind of thing to look at — a container whose inside runs more than once —
   and a third container look is a third thing to learn. What differs is below the
   body: the items, as tiles, each a link into that item's own level. */
.for-each { display: flex; flex-direction: column; }
/* The header carries three things; on a phone they wrap as whole phrases. */
.for-each > .fan-out-header { flex-wrap: wrap; row-gap: 2px; }
.for-each-over { white-space: nowrap; }
.for-each-over { font-weight: 400; color: var(--gray-600); font-size: var(--text-sm);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.for-each-counter { font-size: var(--text-sm); color: var(--accent-ink); font-weight: 600;
  white-space: nowrap; }
.for-each-done    { border-color: var(--ok);  border-style: solid; }
.for-each-failed  { border-color: var(--err); border-style: solid; }
.for-each-running { border-color: var(--accent); }
.for-each-waiting_manual { border-color: var(--warn); border-style: solid; }
.for-each > .chain-nested { margin-left: 0; }
/* Tiles wrap inside a bounded width rather than stretching the container: a list of
   a hundred photos must not turn the graph into a 3000px-wide strip. 28px is above
   the 24px WCAG 2.5.8 floor, because every tile is a link somebody taps. */
.item-strip { list-style: none; margin: var(--space-3) 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: 4px; max-width: 340px; }
.item-tile { display: grid; place-items: center; min-width: 28px; height: 28px;
  padding: 0 4px; border-radius: var(--radius-sm); border: 1px solid var(--gray-300);
  background: var(--surface-chip); color: var(--gray-700); text-decoration: none;
  font-size: 12px; font-variant-numeric: tabular-nums; }
.item-tile:hover { border-color: var(--accent); color: var(--ink); }
/* Done and failed keep the chip surface and say it with the border and the ink: a
   14% tint of either under its own -ink measured 4.0:1, under the 4.5 the number
   inside needs. */
.item-done    { border-color: var(--ok);  color: var(--ok-ink); }
.item-failed  { border-color: var(--err); color: var(--err-ink); }
.item-running { border-color: var(--accent); color: var(--accent-ink); animation: cube-pulse 1.4s var(--ease) infinite; }
.item-waiting_manual { border-color: var(--warn); background: var(--warn-weak); color: var(--warn-ink); }
@media (prefers-reduced-motion: reduce) { .item-running { animation: none; } }
/* The same items as rows, in the drawer: index, the item's own name, its state. */
.item-rows { list-style: none; margin: var(--space-1) 0; padding: 0; }
.item-row { display: flex; align-items: center; gap: var(--space-2); min-height: 32px;
  padding: 0 var(--space-2); border-radius: var(--radius-sm); color: var(--gray-800);
  text-decoration: none; font-size: var(--text-sm); }
.item-row:hover { background: var(--gray-100); }
.item-index { min-width: 2.2em; color: var(--gray-600); font-variant-numeric: tabular-nums; }
.item-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.item-state { white-space: nowrap; }
.item-state.status-done { color: var(--ok-ink); }
.item-state.status-failed { color: var(--err-ink); }
.item-state.status-waiting_manual { color: var(--warn-ink); }

/* ---- cycle badge on plain cubes ---- */
.cycle-badge { display: inline-block; font-size: .72rem; font-weight: 600;
  color: var(--accent); background: var(--accent-weak);
  border-radius: 999px; padding: 0 var(--space-1); margin-left: var(--space-1);
  vertical-align: middle; }

/* ---- the run picker and the run history (in the workflow header) ----
   Replaces a `Definition | Last run` tab pair. One control that names the view you
   are in and opens the list of every other one; the two tabs are its first two rows.
   Deliberately the same weight and the same chip shape the tabs had — this is not a
   restyle, it is the same plate with a list behind it. */
.run-picker { position: relative; }
.run-picker-btn { display: inline-flex; align-items: center; gap: var(--space-1);
  /* Aimable: 26px minimum (WCAG 2.5.8), the floor the tabs already carried. */
  min-height: 26px; padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-md); background: var(--gray-100);
  font-size: var(--text-sm); font-weight: 600; color: var(--gray-700);
  white-space: nowrap; cursor: pointer; list-style: none;
  transition: background .15s var(--ease), color .15s var(--ease); }
/* The UA disclosure marker, removed in both spellings: Chrome/Firefox honour
   `list-style: none` on the summary, WebKit needs the pseudo-element. Without both,
   a second triangle appears next to ours. */
.run-picker-btn::-webkit-details-marker { display: none; }
.run-picker-btn::marker { content: ""; }
.run-picker-btn:hover { color: var(--ink); background: var(--surface-chip); }
.run-picker[open] > .run-picker-btn { background: var(--accent-weak);
  color: var(--accent-ink); }
.rp-caret { font-size: .7em; line-height: 1; transition: transform .15s var(--ease); }
.run-picker[open] .rp-caret { transform: rotate(180deg); }

/* The panel. Absolute, so opening it never changes .wf-header's own box — ui.js
   measures that box to decide the band the graph may be fitted into, and a panel
   that grew the header would shove the graph down on every glance at the history.

   OPAQUE, not glass, for the reason the phone sheet's action bar is (see the mobile
   block): this is a surface with its own content sliding underneath it — a live
   graph, panned and zoomed — and two lines of small text at .62 alpha read straight
   through a cube passing behind them. Glass needs a background it is not competing
   with. The border, radius and shadow are the glass system's, so it still reads as
   part of the same plate; it is the fill that has to hold still. */
.run-history { position: absolute; z-index: 40; top: calc(100% + var(--space-2));
  left: 0; width: max-content; min-width: 260px; max-width: min(420px, 86vw);
  max-height: min(60vh, 420px); overflow-y: auto; overscroll-behavior: contain;
  padding: var(--space-1) var(--space-2); border-radius: var(--radius-md);
  background: var(--surface-solid); border: 1px solid var(--glass-border);
  box-shadow: var(--shadow-lg); }
.run-row-wrap { display: flex; align-items: center; gap: var(--space-1); }
.run-row { flex: 1 1 auto; min-width: 0; display: block; text-decoration: none;
  padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm);
  color: var(--ink); transition: background .15s var(--ease); }
.run-row:hover { background: var(--surface-chip); }
/* "You are here", marked with an accent BAR and accent ink rather than with the
   accent tint the active sidebar item uses. Not a style preference — a measured
   one. Every row in this list carries a status pill, and a pill is a 12% tint that
   assumes a near-white surface: over `--accent-weak` the composite came out
   (224, 210, 227) and `failed` measured 4.456:1, below AA. The sweep found it. A
   tint works in the sidebar because nothing coloured is laid on top of it there. */
.run-row-on { box-shadow: inset 3px 0 0 var(--accent); }

/* ---- the schedule control (in the workflow header, beside the run picker) ----
   Same chip, same disclosure, same absolutely-positioned panel as the run picker
   above: the two controls answer "when did it last run" and "when does it run next",
   and a header that dressed them differently would be claiming they are different
   kinds of thing. The panel is a FORM rather than a list, so it is wider and its own
   scroll is the one that matters on a phone. */
.sched-picker { position: relative; }
.sched-btn { display: inline-flex; align-items: center; gap: var(--space-1);
  min-height: 26px; padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-md); background: var(--gray-100);
  font-size: var(--text-sm); font-weight: 600; color: var(--gray-700);
  white-space: nowrap; cursor: pointer; list-style: none;
  transition: background .15s var(--ease), color .15s var(--ease); }
.sched-btn::-webkit-details-marker { display: none; }
.sched-btn::marker { content: ""; }
.sched-btn:hover { color: var(--ink); background: var(--surface-chip); }
.sched-picker[open] > .sched-btn { background: var(--accent-weak);
  color: var(--accent-ink); }
/* A schedule that is ON is the one state worth seeing from across the header: this
   workflow does things while nobody is looking at it. */
.sched-on > .sched-btn { color: var(--accent-ink); background: var(--accent-weak); }
.sp-days + .sp-time { margin-left: .3em; }

/* Opaque and absolute, for the two reasons .run-history is (see above): it must not
   grow .wf-header's box, which ui.js measures to fit the graph, and it has a live
   graph sliding underneath it. */
.sched-body { position: absolute; z-index: 40; top: calc(100% + var(--space-2));
  left: 0; width: max-content; min-width: 280px; max-width: min(420px, 86vw);
  max-height: min(64vh, 520px); overflow-y: auto; overscroll-behavior: contain;
  padding: var(--space-2) var(--space-3); border-radius: var(--radius-md);
  background: var(--surface-solid); border: 1px solid var(--glass-border);
  box-shadow: var(--shadow-lg); }
.sched-toggle { display: flex; align-items: center; gap: var(--space-2);
  margin: var(--space-2) 0; font-size: var(--text-sm); cursor: pointer; }
.sched-toggle input { width: 18px; height: 18px; flex: none; }
.sched-next { margin: var(--space-1) 0; font-size: var(--text-sm); color: var(--ink); }
/* The skip reason is the one thing in here that is news, so it gets the weight of a
   notice rather than the weight of a caption. */
.sched-skip { margin: var(--space-2) 0; padding: var(--space-2);
  border-radius: var(--radius-sm); background: var(--surface-chip);
  font-size: var(--text-sm); color: var(--gray-700); }

/* Seven chips on one line at 390px: a checkbox styled as its own label, so the whole
   chip is the target rather than a 13px box next to some text. */
.sched-days { display: flex; flex-wrap: wrap; gap: var(--space-1);
  margin: var(--space-2) 0; }
/* `flex: 1 1 0` and `min-width: 0`: seven chips SHARE the row rather than each
   taking the width of its own word, so "Wed" cannot push "Sun" onto a second line.
   Measured at 390px, where the panel has 242px of content box to divide. */
.day-chip { position: relative; flex: 1 1 0; min-width: 0; }
.day-chip input { position: absolute; inset: 0; width: 100%; height: 100%;
  margin: 0; opacity: 0; cursor: pointer; }
.day-chip span { display: block; padding: var(--space-2) var(--space-1);
  border: 1px solid var(--gray-300); border-radius: var(--radius-sm);
  background: var(--gray-100); color: var(--gray-700);
  font-size: var(--text-sm); font-weight: 600; text-align: center;
  white-space: nowrap; transition: background .15s var(--ease); }
.day-chip input:checked + span { background: var(--accent-weak);
  border-color: var(--accent); color: var(--accent-ink); }
.day-chip input:focus-visible + span { outline: 2px solid var(--accent);
  outline-offset: 2px; }
.run-row-on .run-row-age { color: var(--accent-ink); }
/* Stop sits OUTSIDE the row's <a> (a button inside a link is not a button), so the
   marker is painted on the wrapper too — otherwise it stops short of the button and
   the row reads as two separate things. */
.run-row-wrap:has(> .run-row-on) { border-radius: var(--radius-sm);
  box-shadow: inset 3px 0 0 var(--accent); }
.run-row-wrap:has(> .run-row-on) > .run-row-on { box-shadow: none; }
.run-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
/* WRAPS, and that is the fix for a measured defect rather than a preference. Stop
   sits to the right of the row, so at 390px the row shrinks to 188px — and a
   `needs review` pill (134px) beside `41 min ago` (85px) does not fit in it. With
   nowrap and no wrapping the age overflowed the row box and was painted straight
   across the Stop button (measured: age right edge 340px, button left edge 289px).
   Wrapping puts the age under the pill instead; the alternative was truncating the
   one field that identifies the run. `overflow: hidden` is the backstop for a
   status label longer than any we ship. */
.run-row-title { display: flex; align-items: center; flex-wrap: wrap;
  gap: 2px var(--space-2); font-size: var(--text-sm); font-weight: 600;
  min-width: 0; overflow: hidden; }
.run-row-title > .badge { flex: 0 0 auto; }
.run-row-age { color: var(--ink); white-space: nowrap; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; }
/* Second line: how long it took, then what it was asked to do. Both were stored on
   every run since the input block existed and shown nowhere, which is why two runs
   of one workflow used to be indistinguishable in any list. */
.run-row-meta { display: flex; align-items: baseline; gap: var(--space-2);
  font-size: var(--text-sm); color: var(--gray-700); min-width: 0; }
.run-row-dur { white-space: nowrap; font-variant-numeric: tabular-nums; }
.run-row-inputs { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The definition sits at the same rank as a run, because it is the same kind of
   thing — a view of this workflow with its own address — and a hairline under it
   says "everything below is history" without a heading that would cost a row. */
.run-row-def { border-bottom: 1px solid var(--gray-200);
  border-radius: var(--radius-sm) var(--radius-sm) 0 0; margin-bottom: var(--space-1); }
.run-row-older { text-align: center; }
.run-row-older .run-row-main { align-items: center; }
.run-row-stop { margin: 0; flex: 0 0 auto; }
.btn-stop-sm { font-size: var(--text-sm); padding: var(--space-1) var(--space-2);
  white-space: nowrap; }

/* ---- busy states ----
   htmx puts .htmx-request on the element that issued a request, for the life of that
   request; paired with hx-disabled-elt that is both the "working…" signal and the
   thing that stops a second click starting a second run. Buttons only: the graph's
   one-second poll is an htmx request too, and must not make the page flicker. */
@keyframes spin { to { transform: rotate(360deg); } }
.inline-spinner,
button.htmx-request::after,
form.htmx-request button[type="submit"]::after {
  content: ""; display: inline-block; width: 1em; height: 1em;
  vertical-align: -.15em; border: 2px solid currentColor;
  border-right-color: transparent; border-radius: 50%;
  animation: spin .7s linear infinite; }
button.htmx-request::after,
form.htmx-request button[type="submit"]::after { margin-left: .5em; }
.inline-spinner { margin-right: .5em; }
button.htmx-request, form.htmx-request button[type="submit"] { cursor: progress; }
@media (prefers-reduced-motion: reduce) {
  .inline-spinner, button.htmx-request::after,
  form.htmx-request button[type="submit"]::after { animation: none; }
}

/* ---- secret row ----
   These had no rules at all: the row ran into the next one and "Get →" was a bare
   browser-default link — the most important control on a first run, styled by nobody
   and too small to aim at. */
.secret-row { margin: var(--space-2) 0 var(--space-3); padding: var(--space-3);
  border: 1px solid var(--gray-200); border-radius: var(--radius-md);
  background: var(--surface-chip); }
.secret-row .kv-head { margin-top: 0; display: flex; align-items: center;
  gap: var(--space-2); flex-wrap: wrap; }
.secret-row .field { margin-bottom: var(--space-2); }
.secret-row .btn-run { margin-top: var(--space-2); }
/* `background: transparent` and `font-family: inherit` are not tidying: this class
   was only ever on an <a> until the account screen put it on a <button>, and a
   button with no background paints the user agent's `buttonface` — a mid grey that
   took --accent-ink from 5.25:1 to 2.16:1 in the dark theme. A browser measured it;
   no markup assertion could have. */
.btn-link { display: inline-flex; align-items: center; min-height: 28px;
  padding: var(--space-1) var(--space-3); margin: var(--space-1) 0;
  border: 1px solid var(--accent); border-radius: var(--radius-md);
  background: transparent; font-family: inherit; cursor: pointer;
  color: var(--accent-ink); text-decoration: none; font-size: var(--text-sm);
  font-weight: 600; }
.btn-link:hover { background: var(--accent-weak); }

/* ---- container drawers (loop / fan_out) ----
   A container has no params, so its drawer explains what it does and lists the way
   in: its steps, each opening its own drawer. */
.drawer-note { font-size: var(--text-sm); color: var(--gray-600); margin: var(--space-2) 0; }
.step-list { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: var(--space-1) 0; }
.step-chip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.step-chip .cube-icon { font-size: 1rem; }

/* ---- empty catalog + error page ----
   The two screens a first run reaches by accident. Both are one glass card: what
   happened, where, and the one thing that fixes it. */
.empty-state, .error-card { max-width: 620px; margin: 0 auto; padding: var(--space-5);
  border-radius: var(--radius-lg); color: var(--gray-800); }
.empty-state { margin-top: 12vh; margin-left: calc(var(--sidebar-w) + var(--space-4) * 2); }
.empty-state h2, .error-card h1 { font-size: var(--text-lg); letter-spacing: -.02em;
  margin: 0 0 var(--space-3); }
.empty-state p, .error-card p { margin: var(--space-2) 0; }
.empty-state code, .error-card code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--text-sm); background: var(--gray-100); border-radius: var(--radius-sm);
  padding: 1px 5px; }
.empty-path code { font-size: var(--text-md); }
/* One accent, including here: the browser's default link blue is a second one. */
.empty-state a { color: var(--accent-ink); }
.empty-sample { margin: var(--space-2) 0 var(--space-3); padding: var(--space-3);
  border: 1px solid var(--gray-200); border-radius: var(--radius-md);
  background: var(--surface-chip); overflow-x: auto; }
.empty-sample code { background: none; padding: 0; line-height: 1.45; }
.error-page { min-height: 100vh; display: grid; place-items: center; padding: var(--space-4); }
.error-status { font-size: var(--text-sm); font-weight: 700; color: var(--gray-600);
  letter-spacing: .04em; }
.error-detail { color: var(--gray-700); }
.error-path code { word-break: break-all; }
.error-list { list-style: none; margin: var(--space-1) 0 var(--space-3); padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--space-2); }
.error-list a { display: inline-flex; align-items: center; min-height: 28px;
  padding: var(--space-1) var(--space-3); border-radius: var(--radius-md);
  background: var(--gray-100); color: var(--gray-800); text-decoration: none;
  font-size: var(--text-sm); }
.error-list a:hover { background: var(--accent-weak); color: var(--accent-ink); }
.error-home { margin-top: var(--space-3); }


/* ---- accounts, tokens and the sign-in screens -------------------------------
   Reuses .error-card for the two standalone pages (setup and login), because they
   are the same shape: one card, centred, nothing behind it. Only the panels that
   live INSIDE the shell need their own box. */
.acct-page { padding: var(--space-4); max-width: 760px; margin: 0 auto;
  overflow-y: auto; max-height: 100%; }
.acct-panel { padding: var(--space-4); border-radius: var(--radius-lg);
  margin-bottom: var(--space-4); }
.acct-who { font-weight: 600; margin: var(--space-2) 0; }
.acct-who .wf-ref { font-weight: 400; margin-left: var(--space-2); }
.acct-list { list-style: none; padding: 0; margin: var(--space-3) 0; }
.acct-row { display: flex; align-items: center; gap: var(--space-2);
  flex-wrap: wrap; padding: var(--space-2) 0;
  border-bottom: 1px solid var(--gray-200); }
.acct-row:last-child { border-bottom: none; }
.acct-name { font-weight: 600; margin-right: auto; }
.acct-row form { margin: 0; }
.token-value { word-break: break-all; padding: var(--space-2);
  background: var(--gray-100); border-radius: var(--radius-sm); }
/* A saved/failed line, in the same two colours the secret rows already use, so a
   result reads the same wherever the operator meets one. */
.auth-msg { margin: var(--space-2) 0; font-size: var(--text-sm); }
/* The -ink cut, not plain --ok/--err: the same measurement that produced
   .secret-msg-ok applies here — plain --ok is 2.80:1 as text. */
.auth-ok { color: var(--ok-ink); }
.auth-err { color: var(--err-ink); }
/* What an action on this screen answered, scrolled to by htmx (`show:.acct-flash`).
   The margin is clearance for the fixed ☰ button, which floats over the top-left
   corner at the compact breakpoint: without it the answer lands underneath the one
   piece of chrome that is always on top of this column. */
.acct-flash { scroll-margin-top: 56px; }
/* The account link in the sidebar footer: pinned to the bottom so the catalog
   keeps the space it needs, and never scrolls out of reach. */
/* #sidebar is a plain scrolling block, not a flex column, so `margin-top: auto`
   would do nothing — the separator is what puts this below the catalog. */
.side-foot { margin-top: var(--space-4); padding-top: var(--space-3);
  border-top: 1px solid var(--gray-200); font-size: var(--text-sm); }
.side-foot a { color: var(--gray-700); text-decoration: none; }
.side-foot a:hover { color: var(--accent); }


/* ==== SMALL SCREENS ====================================================
   Last in the file ON PURPOSE. A media query adds no specificity, so a phone
   rule sitting above the component it overrides simply loses on source order —
   silently, and only at the width nobody is testing at. Five of these were dead
   that way: the stacked fan_out branch, the nested chain indent, the fan_out
   padding, the branch label and the view tabs all rendered their desktop values
   at 390px. Anything that has to win at a breakpoint belongs after everything it
   is correcting.
   ==================================================================== */
/* ---- compact: the 248px sidebar gutter is worth more as canvas ----
   There used to be exactly one breakpoint, at 720px, and 721-1024px got the worst of
   both layouts: a fixed 248px sidebar and a 380px drawer eating a 768px viewport, no
   ☰ to reclaim either, no fit-to-screen, and — because .graph is overflow:hidden with
   a transformed canvas — no scrollbar to hint that anything was missing. Measured at
   768x1024: 8 of 10 nodes off-viewport. Above 1024 the three-zone layout still has
   room to be itself, so it is left exactly as it was. */
@media (max-width: 1024px) {
  /* sidebar collapses to a slide-in overlay toggled by the ☰ button */
  #sidebar { z-index: 30; transform: translateX(-120%);
    transition: transform .26s var(--ease); }
  #sidebar.open { transform: none; box-shadow: var(--shadow-md); }
  .menu-btn { display: inline-flex; position: fixed; top: var(--space-3); left: var(--space-3);
    z-index: 31; width: 40px; height: 40px; border-radius: 50%; border: 0; cursor: pointer;
    align-items: center; justify-content: center; font-size: 1.1rem; }
  /* header clears the ☰ button on the left instead of the (hidden) sidebar, and the
     drawer-avoidance max-width goes with the sidebar it was subtracting. */
  .wf-header { left: calc(40px + var(--space-3) * 2); right: var(--space-3);
    width: auto; max-width: none; }
  /* The zoom cluster loses the sidebar gutter too. */
  .graph-controls { left: var(--space-3); }
  .empty-state { margin-left: 0; }
}
/* Between the two: the drawer is still a side panel, so the header has to stop short
   of it (it is drawn ABOVE the drawer — reaching that far hides controls the operator
   is using, which is the rule the desktop max-width existed to keep). */
@media (min-width: 721px) and (max-width: 1024px) {
  .wf-header { right: calc(min(380px, 90vw) + var(--space-3) * 2); }
}
/* ---- phone ---- */
@media (max-width: 720px) {
  /* A 150px card on a 60px-gap horizontal track is a DESKTOP shape: ten of them are
     1815px wide, and fitting that into 390px puts every node at 34x23px — readable
     by nobody, tappable by nobody. Stacked, each step is full-width and legible at
     1:1, and the flow reads top-to-bottom the way a phone list does. graphConnectors
     already bails out on a column chain (the wiring has no horizontal meaning here),
     so this is the layout the drawing code was written to expect. */
  .chain, .fan-branch { flex-direction: column; align-items: stretch; }
  .chain { gap: var(--space-3); }
  .cube { width: auto; }
  .chain-nested { margin-left: var(--space-3); }
  /* A stacked chain has no arrows, so the gap between cards is the only thing left
     saying "then this one". A hairline in that gap keeps it reading as a pipeline. */
  .chain > .cube + .cube::after { content: ""; position: absolute; left: 50%;
    top: calc(var(--space-3) * -1); height: var(--space-3); width: 2px;
    background: var(--gray-300); }
  /* The loop's dashed frame is painted by ::before as an outward overlay whose top
     band holds a header floating on `bottom: 100%`. Stacked, that band has to clear
     the card above it instead of the row above it. */
  .loop { margin-top: var(--space-4); }
  .fan-out { padding: var(--space-3); }
  .fan-branch { align-items: stretch; }
  .fan-branch-name { min-width: 0; margin-bottom: var(--space-1); }
  /* Two rows: what this is (title + status), then what you can do (switch, Run).
     Sharing ONE line with all four, an <h1> carrying `min-width: 0` was the only
     item that could shrink, so it absorbed the entire shortfall and rendered
     `generate_seo_article` as "g.". The floor is what fixes that — it makes the
     badge wrap instead of the title vanish — and the floor is why the title must
     NOT simply be given a row of its own: four stacked rows of header ate 180px of
     an 844px screen, and that comes straight out of the graph.

     The plate's own padding and the tabs' are desktop-generous: at 390px the header
     has 254px of usable width and 48px of it was padding. Trimming both is what fits
     the switch and Run onto one line instead of two — 37px of header height that the
     graph gets back (135px -> 98px, measured). */
  .wf-header { gap: var(--space-2); row-gap: var(--space-2);
    padding: var(--space-2) var(--space-3); }
  .wf-header h1 { flex: 1 1 auto; min-width: 9ch; font-size: var(--text-md);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .run-picker-btn { padding: var(--space-1) var(--space-2); }
  /* "Run · " goes; the age stays. Row two of the header is 254px of usable width and
     has to hold the status badge, this control and "Run ▶" — the alternative is a
     third row, which is 25px straight out of the graph. */
  .rp-kind { display: none; }
  /* The header spans the full width at this breakpoint, so the panel does too —
     anchored to the header rather than to the control, which at 390px would put a
     420px panel off the right edge. `position: fixed` is wrong here (the header is
     absolute inside the scroll-free shell); stretching to both edges of the
     .run-picker's containing block is what keeps it on screen. */
  .run-picker { position: static; }
  .run-history { left: var(--space-3); right: var(--space-3); width: auto;
    max-width: none; top: calc(100% + var(--space-2));
    max-height: min(56vh, 420px); }
  /* The same treatment, for the same reason: the header spans the full width here,
     so a panel anchored to a chip near the right edge would hang off it. */
  .sched-picker { position: static; }
  /* Tall enough that the WHOLE form is on screen when it opens — measured: the
     panel starts 114px down and its content is 554px, so a 460px cap put Save
     below its own fold and a thumb could not reach it without scrolling a panel
     that does not look scrollable. `min()` keeps it inside a shorter phone, where
     it scrolls rather than running off the bottom. */
  .sched-body { left: var(--space-3); right: var(--space-3); width: auto;
    max-width: none; max-height: min(74vh, 600px); }
  /* The layer has to be raised on the HEADER, not on the panel. `.wf-header` is
     z-index 30 and therefore its own stacking context, so nothing inside it can
     paint above `#drawer` (z-index 40 at this width, where the drawer becomes a
     bottom sheet) however high its own z-index is. Measured: the run form's
     `keyword` input sat on top of Save, and the one button in the panel answered
     `elementFromPoint` with somebody else's field.

     Scoped to `:has(details[open])` rather than raised outright: the header must
     NOT outrank the off-canvas catalog (z-index 30) the rest of the time, and a
     popover is the only thing in the header that ever reaches the sheet. It lifts
     the run history beside it too, which had the same latent bug and only escaped
     it by being shorter. */
  .wf-header:has(details[open]) { z-index: 41; }
  .sched-btn { padding: var(--space-1) var(--space-2); }
  /* Row two of the header holds the status badge, the run picker, this and
     "Run ▶" in 254px. An OFF schedule is a clock face and nothing else — there is
     no state to read and the panel says the word; a schedule that is ON keeps its
     time, because that is the fact being checked. */
  /* "Schedule · Mon–Fri 09:00" becomes "09:00", and "Schedule · Off" becomes "Off".
     Row two of this header has 254px and already holds a status badge, the run picker
     and "Run ▶"; the time is the fact being checked, the rest is a label. */
  .sp-kind, .sp-days { display: none; }
  /* 242px of content box, divided seven ways: the gap and the horizontal padding
     both go, and the text with them if it must. The chips stay above the 24px hit
     floor (they measure ~32x31) — what shrinks is the whitespace, not the target. */
  .sched-days { gap: 3px; }
  .day-chip span { padding: var(--space-2) 0; font-size: .72rem; letter-spacing: -.02em; }
  /* The header is crowded on a phone: the trail truncates from the LEFT (the tail
     is where you are), and "Used by" drops out entirely — it is context,
     not a control. */
  .crumbs { flex: 1 0 100%; max-width: 100%; direction: rtl; text-align: left; }
  .crumbs > * { direction: ltr; }
  .used-by { display: none; }
  /* Stop moves off the top edge: the header now spans the full width and sits above
     it in the stack, so a top-right Stop would be drawn underneath the glass. It
     lands in the bottom strip the sheet leaves free, opposite the zoom cluster. */
  .run-stop { top: auto; bottom: var(--space-3); right: var(--space-3); }
  /* The drawer becomes a BOTTOM SHEET, not a full-screen cover.
     A workflow page auto-opens its first step's drawer on load (_workflow.html), so
     at 390px a full-height drawer meant the app OPENED as a lone "Inputs" panel:
     measured with elementFromPoint, 6 of 6 steps were under #drawer — the graph was
     not hard to reach, it was absent — and three quarters of the screen was empty.
     A sheet keeps the graph above it, so the operator can see which step they are
     reading and tap another one; that is also the answer to "an open drawer with
     nothing to pan the canvas out from under it". It stops short of the bottom so
     the zoom cluster and Stop keep their strip. safeRect() measures the band that is
     left and fits the graph into it, so this height is not duplicated in the JS. */
  #drawer { left: var(--space-3); width: auto; z-index: 40;
    /* max-height, not height: the sheet hugs its content and only grows to the cap.
       A fixed 52vh made the landing panel — one keyword field and a Run button — take
       half the screen as blank glass, and every pixel of that came out of the graph
       band above it, which is what squeezed a six-step workflow down to 31%. */
    top: auto; height: auto; max-height: 52vh;
    bottom: calc(var(--space-3) * 2 + 40px);
    /* A sheet over a canvas that swallows touch (`touch-action: none`): keep a flick
       that runs past the end of the drawer's own scroll from leaking out to it. */
    overscroll-behavior: contain; }
  .drawer-close { width: 36px; height: 36px; font-size: 1.15rem; } /* finger-sized close */
  /* The sheet is half a screen, so the artefact gets a share of THAT, not of the
     whole viewport. Scoped to the drawer on purpose: the same .md-body renders the
     artefact in the full-screen modal too, and that one is the place you go TO read,
     so it keeps the roomier cap. */
  #drawer .md-body { max-height: 26vh; }
  /* Approve / Send back are the two buttons the whole product exists for. Side by
     side at 390px they are ~150px each and the label of the wider one clips — so
     they stack. And on a half-height sheet they fall below a gate's evidence rows,
     its artefact chip and a four-row remarks box: measured at 390x844, the centre of
     Approve was off the bottom of the viewport entirely. Pinned to the sheet's own
     bottom edge they stay under the operator's thumb while the evidence above them
     scrolls, which is the point of reading the evidence at all.

     A sticky element cannot leave its containing block, and the review <form> starts
     764px into a 762px-tall scroll — so pinning the bar inside the form moved it by
     nothing at all and "Send back" stayed below the sheet's edge (measured: bar
     bottom 915px, sheet bottom 772px). `display: contents` drops the form's BOX
     while leaving it in the DOM, which hands the bar the whole panel as its
     containing block. The form still owns the fields and still posts: htmx reads the
     element tree, not the layout.
     Scoped by :has() to the review form alone — the run form and the secret rows are
     ordinary stacked forms whose boxes do real work. Where :has() is missing the rule
     is simply dropped and the bar scrolls with the panel, which is where it started. */
  #drawer form:has(.review-actions) { display: contents; }
  .review-actions { flex-direction: column; position: sticky; bottom: 0;
    /* Horizontal bleed only. A negative BOTTOM margin drags the bar past the sticky
       constraint box (the scrollport's padding box) and clips it. */
    margin: var(--space-3) calc(var(--space-4) * -1) 0;
    /* Negative `bottom`, not negative margin: it moves the sticky THRESHOLD rather
       than the box, so the bar lands flush on the sheet's bottom edge instead of
       leaving the drawer's 24px of padding for text to scroll through. */
    bottom: calc(var(--space-4) * -1);
    padding: var(--space-3) var(--space-4) var(--space-4);
    border-top: 1px solid var(--gray-200);
    /* Opaque, not glass. This is the one surface in the panel with its own content
       sliding underneath it: at .62 alpha the evidence rows read straight through the
       bar and the two buttons looked like they were floating on broken text. Glass
       needs a background it is not competing with, which is exactly what
       --surface-solid exists for. */
    background: var(--surface-solid); }
  /* The landing card loses the sidebar gutter with the sidebar. */
  .empty-state { margin-left: 0; margin-top: 72px; padding: var(--space-4); }
  /* Anything a thumb has to land on clears 44px. A mouse pointer is one pixel and
     a fingertip is about eight millimetres, so the padding that makes a comfortable
     desktop button makes a 37px one — measured on the SIGN-IN screen, where that
     button is the only control on the page and the page is now the first thing this
     product shows anyone. The account screen's Revoke / Grant cli buttons are the
     same case: .btn-link's 28px minimum is a desktop number. */
  .btn-run, .field input, .btn-link { min-height: 44px; }
  /* And the rows those buttons sit in stop being a single squeezed line: three
     controls and a 36-character account id do not share 390px. */
  .acct-row { align-items: flex-start; }
}
