/* ---------------------------------------------------------------------------
 * Dashboard Portal
 *
 * Precision-instrument aesthetic, following CSNL Module Picker — the most
 * disciplined piece in the family. Its own header comment set the terms:
 * "neutral zinc, one disciplined blue accent, Geist Sans + Geist Mono,
 * restrained motion." This page takes the neutrality and the restraint, and
 * pushes the accent one step further: there is no coloured interface at all.
 *
 * THE RULE THAT DECIDES MOST OF THIS FILE
 *
 *   Ink is the only interface colour. The four row tones are the only colour on
 *   the page, and they appear in exactly two places: the index numeral, and the
 *   icon on a preview that cannot be framed. Everything interactive — links,
 *   focus rings, selection, the "preview is open" state — is ink on ground.
 *
 *   A coloured accent was tried and removed. Every candidate collided with a
 *   row's own identity colour (the house green reads as Irish Visa, a blue
 *   reads as Module Picker), so an accent made the page say "this row is
 *   special" when it meant "this control is interactive". Monochrome cannot
 *   make that mistake, and it leaves the four identities as the only thing
 *   colour is used for.
 *
 * Two token layers, kept deliberately separate:
 *
 *   1. SCALE      Open Props (MIT, vendored). Spacing, type ramp, radii,
 *                 easing. Proven numbers instead of invented ones. Its spacing
 *                 ramp skips 12px: 4 / 8 / 16 / 20 / 24 / 28 / 32 / 48 / 64.
 *                 Where another value is genuinely needed it is a literal,
 *                 called out at the rule that needs it.
 *   2. COLOUR     Hand-authored. Contrast is measured, never inherited: a
 *                 palette taken on trust is how AA failures ship.
 *
 * What the scrape established (tools/scrape-design.mjs), and how it shaped this:
 *
 *   Type      Inter is in all five frontends; Geist + Geist Mono are the faces
 *             of the two newest. All three are self-hosted here.
 *   Radii     The family's own named pair, verbatim: containers 14px, controls
 *             6px. This page has almost no containers left to spend 14px on.
 *   Surfaces  Module Picker's --bg is the most neutral of the five, so the
 *             ground is zinc rather than a tinted white.
 *   Tones     Each row's numeral is that project's own brand token, including
 *             its own light/dark pair. Nothing is invented.
 *
 * Locked decisions:
 *   Layout   A specification index, not a card list: column labels, hairline
 *            rules between entries, and no container around them. The list is
 *            flush with the page because that is what an index is.
 *   Shape    Controls 6px, preview frame 4px. No 14px-radius card, no drop
 *            shadow, no gradient ambience, no backdrop blur. A large radius on
 *            a soft-shadowed panel is the single most recognisable template
 *            shape there is, and it was carrying no meaning.
 *   Type     Mono for every piece of metadata — numerals, column labels, hosts,
 *            stacks, the bar's wordmark. The display face carries the title and
 *            the row names, which is the only place type raises its voice.
 *   Theme    Both first class, driven by [data-theme].
 *   Motion   Transform and opacity only, all of it off under
 *            prefers-reduced-motion.
 *   Figures  A figure read live is a .fig; a recorded one carries .fig-stale so
 *            the two can never be mistaken for each other.
 *   Preview  Opens inside the row it belongs to, never as an overlay.
 *
 * Column geometry is declared once, in --grid-cols, and consumed by both the
 * header band and every row. Two grids that must line up but are defined in two
 * places is how a table drifts.
 * ------------------------------------------------------------------------ */

/* Self-hosted variable fonts. Same declarations the sister projects use, so
   type renders identically. Inter is the 100-900 latin variable subset already
   self-hosted by the portfolio. */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('fonts/inter.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Geist';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('fonts/geist-sans.woff2') format('woff2');
}

@font-face {
  font-family: 'Geist Mono';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('fonts/geist-mono.woff2') format('woff2');
}

/* --------------------------------------------------------------- tokens -- */

:root {
  /* Ground. Module Picker's --bg (#f4f5f7) is the most neutral of the five;
     this keeps its zinc character and drops the blue cast, so the only chroma
     on the page is the tone numerals. */
  --bg: #ffffff;
  --bg-sunken: #fafafa;
  --line: #e7e7ea;
  --line-strong: #d0d0d6;

  /* Ink. One ramp, three steps, contrast measured on --bg and --bg-sunken. */
  --ink: #0e0e11; /* 19.0:1 */
  --ink-mid: #55565e; /* 7.6:1 */
  --ink-soft: #6b6c75; /* 5.2:1 — the worst case, on 10px mono labels */

  --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
  --font-display: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
  --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  /* The family's own named pair, taken verbatim, plus one frame step for the
     preview. There is deliberately no container radius. */
  --r-container: 14px;
  --r-control: 6px;
  --r-frame: 4px;

  --ease: var(--ease-3);
  --dur: 150ms;

  --maxw: 1080px;
  --bar-h: 52px;

  /* The index grid: numeral / subject / host / actions. Read by the header band
     and by every row, so the two can never drift apart. */
  --grid-cols: 28px minmax(0, 1fr) 200px 88px;
  --grid-gap: 24px;
  /* Hover fills bleed past the text without moving any column. */
  --bleed: 12px;
}

/* `color-scheme` follows the *chosen* theme, not the operating system's. The
   meta tag alone (`dark light`) only declares that both are supported, so a
   visitor who picks the theme opposite to their OS setting gets scrollbars,
   form controls and the caret drawn for the other one — a white scrollbar down
   the side of a black page. Declaring it here ties all of that to what the page
   is actually rendering. */
:root[data-theme='light'] {
  color-scheme: light;
}

:root[data-theme='dark'] {
  color-scheme: dark;
}

:root[data-theme='dark'] {
  --bg: #0e0e11;
  --bg-sunken: #17171b;
  --line: #26262c;
  --line-strong: #3a3a42;

  --ink: #f4f4f6; /* 17.5:1 */
  --ink-mid: #a9a9b2; /* 8.3:1 */
  --ink-soft: #83838d; /* 5.7:1 */
}

/* ------------------------------------------------------------------ tones --
 * Each tone is one dashboard's own brand token, quoted straight from the
 * scrape. There is a single --tone per row rather than a --tone/--tone-ink
 * pair: the previous pair existed because a tone had to work both as a fill and
 * as small text, and the values diverged. Colour is now used on exactly one
 * small text element (the numeral) and one glyph, so only the text-safe variant
 * is ever needed — which removes the whole class of bug where the fill leaks
 * into text.
 *
 *   forest   Content Tracker      --green       #245c42 / #7cc4a0
 *   emerald  Irish Visa Tracker   --green-text  #166534 / #6ee7b7
 *   amber    RBI Weekly           --brand       #8a5205 / #e9b949
 *   cobalt   CSNL Module Picker   --accent      #1d4ed8 / #60a5fa
 *
 * The light amber is the project's *text* token, not its fill: #e9b949 is a
 * fill and clears only 1.5:1 as small text.
 */
[data-tone='forest'] {
  --tone: #245c42;
}

[data-tone='emerald'] {
  --tone: #166534;
}

[data-tone='amber'] {
  --tone: #8a5205;
}

[data-tone='cobalt'] {
  --tone: #1d4ed8;
}

:root[data-theme='dark'] [data-tone='forest'] {
  --tone: #7cc4a0;
}

:root[data-theme='dark'] [data-tone='emerald'] {
  --tone: #6ee7b7;
}

:root[data-theme='dark'] [data-tone='amber'] {
  --tone: #e9b949;
}

:root[data-theme='dark'] [data-tone='cobalt'] {
  --tone: #60a5fa;
}

/* ----------------------------------------------------------------- base -- */

body {
  min-height: 100dvh;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: 1rem;
  line-height: var(--font-lineheight-3);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  font-feature-settings: 'cv11' 1, 'ss01' 1; /* Inter's more open a/g */
}

/* Ink on ground, inverted. A selection highlight is the one place the interface
   can afford to be loud, because nothing else on the page is competing. */
::selection {
  background: var(--ink);
  color: var(--bg);
}

/* `display: grid`/`flex` below would otherwise beat the user agent's
   `[hidden] { display: none }`, leaving "hidden" elements visible. */
[hidden] {
  display: none !important;
}

.ico {
  flex: none;
  display: block;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* One focus treatment for the entire page: 2px of ink, offset so it reads as a
   ring around the control rather than a border on it. Nothing overrides it —
   the search field used to replace it with a tinted glow, which made the one
   control you type into the only control that looked different when focused. */
:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

/* The skip link's target is a container, not a control, so it takes focus (it
   needs tabindex="-1" to be focusable at all) without painting a ring around
   the entire index. */
#index:focus-visible {
  outline: none;
}

.skip-link {
  position: absolute;
  left: var(--size-4);
  top: var(--size-2);
  z-index: 20;
  padding: var(--size-2) var(--size-4);
  border-radius: var(--r-control);
  background: var(--ink);
  color: var(--bg);
  font-size: 13px;
  font-weight: 500;
  text-decoration: none;
  /* Off-screen with a transform, not with `top`. Animating `top` runs layout
     on every frame for an element that only ever moves on focus; a transform
     is composited, and `transition: top` is the one property in this file that
     did not follow its own rule about which properties are animated. */
  transform: translateY(calc(-100% - var(--size-4)));
  transition: transform var(--dur) var(--ease);
}

.skip-link:focus {
  transform: none;
}

/* ------------------------------------------------------------------ bar --
 * Solid rather than blurred. The blur was a compositor layer and a tell; a
 * hairline under an opaque bar does the same job with none of the cost.
 */

.bar {
  position: sticky;
  top: 0;
  z-index: 5;
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}

.bar-inner {
  max-width: var(--maxw);
  min-height: var(--bar-h);
  margin: 0 auto;
  padding: 0 var(--size-5);
  display: flex;
  align-items: center;
  gap: var(--size-4);
  /* Every child below can shrink, so the bar can never be wider than the
     viewport. Without this the bar's fixed contents overflowed a 323px screen
     by 1px, which was enough to give the whole page a horizontal scrollbar. */
  min-width: 0;
}

/* The site name, set as a label rather than a headline: the document's heading
   is below, and two titles competing in the first 100px is how a page ends up
   looking assembled rather than designed. */
.wordmark {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink);
  white-space: nowrap;
  /* Never shrinks. It is the site's own name, and the field beside it gives up
     its width far more gracefully than a truncated wordmark reads. */
  flex: none;
}

.bar-tools {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--size-2);
  min-width: 0;
}

.icon-btn {
  flex: none;
  width: 28px;
  height: 28px;
  display: grid;
  place-items: center;
  border: 1px solid transparent;
  border-radius: var(--r-control);
  background: none;
  color: var(--ink-soft);
  text-decoration: none;
  cursor: pointer;
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease),
    background-color var(--dur) var(--ease);
}

/* Outlined on hover, not filled. A fill needs a surface to sit on and there is
   no second surface here: every ground is either the page or a hovered row. */
.icon-btn:hover {
  color: var(--ink);
  border-color: var(--line-strong);
}

/* Declared here, before `.preview-btn.is-open`, so the later rule still wins on
   an open button: a pressed *and* open control should read as open. */
.icon-btn:active {
  background: var(--bg-sunken);
}

.theme-glyph {
  display: none;
}

:root[data-theme='dark'] .theme-glyph-moon,
:root[data-theme='light'] .theme-glyph-sun {
  display: grid;
  place-items: center;
}

/* --------------------------------------------------------------- search -- */

/* Shrinks before anything else does: it is the one control whose contents
   survive being narrow, and the alternative is a bar that does not fit. */
.field {
  position: relative;
  display: flex;
  align-items: center;
  /* Gives up its width first, down to a floor that keeps it usable: below this
     the bar would be narrower than any phone and the field anyway too small to
     read what you typed. */
  flex: 0 1 15rem;
  min-width: 4rem;
}

.field-ico {
  position: absolute;
  left: 8px;
  display: grid;
  place-items: center;
  color: var(--ink-soft);
  pointer-events: none;
}

.field-input {
  width: 100%;
  height: 28px;
  padding: 0 var(--size-6) 0 28px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-control);
  background: var(--bg);
  color: var(--ink);
  font-family: inherit;
  font-size: 13px;
  transition: border-color var(--dur) var(--ease);
}

.field-input::placeholder {
  color: var(--ink-soft);
}

.field-input:focus {
  border-color: var(--ink);
}

/* The key hint, set like a key: hairline, mono, small. It fades out on focus
   because at that point the shortcut is already known. */
.field-hint {
  position: absolute;
  right: 6px;
  padding: 0 4px;
  border: 1px solid var(--line);
  border-radius: 3px;
  background: var(--bg-sunken);
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 10px;
  line-height: 14px;
  pointer-events: none;
  transition: opacity var(--dur) var(--ease);
}

.field-input:focus ~ .field-hint {
  opacity: 0;
}

/* Chromium paints a native clear button inside type="search" that would sit
   underneath the "/" hint. Escape already clears the field. */
.field-input::-webkit-search-cancel-button,
.field-input::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
}

/* ----------------------------------------------------------------- page -- */

.page {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 0 var(--size-5) var(--size-8);
}

/* A document opening: eyebrow, title, lede. Three sizes doing three jobs, which
   is the whole reason the index below can stay as quiet as it is. The padding
   is deliberately tighter than the rhythm below it: a masthead that breathes
   pushes the first entry — the thing you came for — down the page. */
.masthead {
  padding: var(--size-6) 0 var(--size-4);
}

.eyebrow,
.index-head span {
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.eyebrow {
  margin: 0 0 var(--size-2);
}

.title {
  margin: 0;
  /* Keeps a two-word heading off two lines and stops a one-word last line. */
  text-wrap: balance;
  font-family: var(--font-display);
  font-size: 32px;
  font-weight: 600;
  letter-spacing: -0.035em;
  line-height: 1.05;
}

.lede {
  margin: var(--size-2) 0 0;
  max-width: 52ch;
  color: var(--ink-mid);
  font-size: 13.5px;
  text-wrap: pretty;
}

.lede strong {
  color: var(--ink);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

/* Touch behaviour, once for every control on the page.
   `manipulation` removes the double-tap-to-zoom wait: without it a tap on a row
   stalls for roughly 300ms while the browser decides whether a second tap is
   coming, which is the difference between a page that feels native and one that
   feels laggy. The platform's grey tap flash is replaced rather than kept,
   because each of these already draws its own pressed state (a row background,
   a button surface) and two flashes for one tap is worse than one. */
a,
button,
input,
.row {
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}

/* The skip link's target is a container, so scrolling to it would otherwise put
   its first line behind the 52px sticky bar. */
#index {
  scroll-margin-top: calc(var(--bar-h) + var(--size-3));
}

/* ------------------------------------------------------------ the index --
 * Column labels over the columns they name. The header band and every row
 * consume the same --grid-cols and the same --bleed, which is what makes the
 * labels sit exactly over their columns at any width.
 */

.index-head,
.row {
  display: grid;
  grid-template-columns: var(--grid-cols);
  column-gap: var(--grid-gap);
  padding-inline: var(--bleed);
  margin-inline: calc(var(--bleed) * -1);
}

.index-head {
  padding-bottom: 9px;
  border-bottom: 1px solid var(--line);
}

.registry {
  margin: 0;
  padding: 0;
  list-style: none;
  /* Closes the table. Between-row rules alone would leave the last entry open. */
  border-bottom: 1px solid var(--line);
}

.row {
  position: relative;
  align-items: start;
  padding-block: var(--size-4);
  transition: background-color var(--dur) var(--ease);
  /* Landing on a row from a deep link must not put its title under the sticky
     bar. Same reason as #index below. */
  scroll-margin-top: calc(var(--bar-h) + var(--size-3));
}

.row + .row {
  border-top: 1px solid var(--line);
}

/* The pressed state mirrors hover rather than inventing a second colour: on a
   touch screen there is no hover, so this is the only feedback a row has. */
.row:hover,
.row:active {
  background: var(--bg-sunken);
}

/* An INSET ring on the row, because the anchor inside it is a stretched-link
   overlay: an outline on the anchor would trace the <a> box, not the row. */
.row:focus-within {
  outline: 2px solid var(--ink);
  outline-offset: -2px;
}

/* An entry with no deploy URL: still listed, visibly not a link. */
.row.is-pending .row-title,
.row.is-pending .row-index {
  color: var(--ink-mid);
}

/* The numeral is the only colour in the index. It carries the row's identity,
   which is what the four tones were always for — and at 11px it reads as a tab
   number rather than a status light. */
.row-index {
  padding-top: 2px;
  color: var(--tone, var(--ink-soft));
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
}

.row-main {
  min-width: 0;
}

/* `overflow-wrap: anywhere` on both text blocks, because the manifest is
   hand-written and a description can contain a long unbroken token (a URL, a
   hash). Without it the token exceeded the row and was silently clipped — no
   scrollbar, no ellipsis, content simply gone. `anywhere` rather than
   `break-word` so it also reduces the grid track's min-content size, which is
   what lets the column actually shrink. */
.row-title,
.row-desc,
.row-tags {
  overflow-wrap: anywhere;
}

.row-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 17px;
  font-weight: 600;
  letter-spacing: -0.022em;
  line-height: 1.25;
}

.row-title a {
  color: inherit;
  text-decoration: none;
}

.row-title a:hover {
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
}

/* Stretched link: the whole row opens the dashboard, but only the title is the
   anchor, so the action buttons stay independently clickable and focus order
   stays sane. */
.row-title a::after {
  content: '';
  position: absolute;
  inset: 0;
}

/* The row ring is the focus affordance; suppress the anchor's own outline. */
.row-title a:focus-visible {
  outline: none;
}

.row-desc {
  margin: var(--size-1) 0 0;
  max-width: 68ch;
  /* `pretty` rather than `balance`: this is a paragraph, so the goal is only to
     avoid a stranded last word, not to even out the lines. */
  text-wrap: pretty;
  color: var(--ink-mid);
  font-size: 13.5px;
  line-height: 1.55;
}

/* A figure read from the dashboard's own data. Tabular figures stop the
   sentence reflowing when the number changes, and the dotted rule on a recorded
   one is the whole signal that it is not the live value — the hover text says
   the rest. Mono rather than the display face: these are readings. */
.fig {
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: 0.95em;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

.fig-stale {
  border-bottom: 1px dotted var(--ink-soft);
  cursor: help;
}

.row-tags {
  margin: var(--size-2) 0 0;
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.01em;
}

/* The host is a column, not a caption: left-aligned so it sits under its own
   label, truncated rather than wrapped so a long token cannot change the row's
   height, with the full value in the tooltip. */
.row-host {
  align-self: start;
  min-width: 0;
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 10.5px;
  line-height: 1.5;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  /* Optical nudge: 10.5px mono needs a little more than nothing to sit level
     with the 17px title's cap line. Open Props' ramp has no 4px step below
     --size-1 for this purpose, so it is a literal. */
  padding-top: 3px;
}

.row-actions {
  position: relative; /* above the stretched-link overlay */
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 6px;
}

/* Smaller than the bar's buttons: these sit inside a row, where the text column
   sets the height. Still above the 24px WCAG target floor. */
.row .icon-btn {
  width: 26px;
  height: 26px;
}

/* The open state is the one filled control on the page, which is what makes it
   read as a state rather than a hover. */
.preview-btn.is-open {
  color: var(--bg);
  background: var(--ink);
  border-color: var(--ink);
}

.row-go {
  flex: none;
  display: grid;
  place-items: center;
  color: var(--ink-soft);
  pointer-events: none; /* must not swallow the stretched link's click */
  transition: color var(--dur) var(--ease), transform var(--dur) var(--ease);
}

.row:hover .row-go {
  color: var(--ink);
  transform: translate(2px, -2px);
}

/* -------------------------------------------------------------- preview --
 * Opened inside its own row, so it cannot cover another row and needs no
 * anchoring. The frame is laid out at a desktop width and scaled down, which is
 * what makes it read as a miniature of the real page rather than as a reflowed
 * mobile column.
 */

.row-preview {
  /* Spans the numeral's column too, so the frame lines up with the title. */
  grid-column: 1 / -1;
  /* Above the title's stretched-link overlay: that overlay reaches `inset: 0`
     of the entire row and would otherwise swallow every click in the frame. */
  position: relative;
  z-index: 1;
  margin-top: var(--size-4);
}

/* The width the preview is laid out at, scaled down to fit. Kept in step with
   PREVIEW_DOC_W in app.js; the ratio here must match the frame's 1280 x 800. */
.preview-viewport {
  position: relative;
  max-width: 36rem;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-frame);
  background: var(--bg-sunken);
}

.preview-doc {
  width: 1280px;
  height: 800px;
  border: 0;
  background: var(--bg);
  transform: scale(var(--preview-scale, 0.4));
  transform-origin: 0 0;
}

/* Covers the frame until it has loaded, and for a beat after: these dashboards
   paint their figures only once they have fetched their own data, so revealing
   the frame the instant `load` fires shows a blank box for that gap. */
.preview-state {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: var(--bg-sunken);
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.02em;
}

.preview-cap {
  max-width: 36rem;
  margin: var(--size-2) 0 0;
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 10.5px;
}

.preview-open {
  color: var(--ink-mid);
  text-decoration: none;
  border-bottom: 1px solid var(--line-strong);
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

.preview-open:hover {
  color: var(--ink);
  border-bottom-color: var(--ink);
}

/* Shown instead of a frame when there is nothing a frame could display: either
   the dashboard refuses to be embedded, or it did not arrive in time. */
.preview-note {
  display: flex;
  align-items: flex-start;
  gap: var(--size-2);
  max-width: 62ch;
  margin: 0;
  padding: var(--size-3) var(--size-4);
  border: 1px solid var(--line);
  border-radius: var(--r-frame);
  color: var(--ink-mid);
  font-size: 12.5px;
  line-height: 1.55;
  overflow-wrap: anywhere;
}

.preview-note strong {
  color: var(--ink);
  font-weight: 500;
}

/* The second and last use of a tone colour: the note is about this dashboard,
   so it wears that dashboard's identity rather than introducing a warning hue
   the page does not otherwise have. */
.preview-note-ico {
  flex: none;
  display: grid;
  place-items: center;
  margin-top: 1px;
  color: var(--tone, var(--ink-soft));
}

/* -------------------------------------------------------- empty / notes -- */

.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--size-2);
  padding: var(--size-8) var(--size-5);
  border: 1px solid var(--line);
  border-radius: var(--r-container);
  text-align: center;
}

.empty-ico {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border: 1px solid var(--line);
  border-radius: var(--r-control);
  color: var(--ink-soft);
}

.empty-title {
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.015em;
}

.empty-body {
  max-width: 44ch;
  color: var(--ink-soft);
  font-size: 13px;
  overflow-wrap: anywhere;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 30px;
  margin-top: var(--size-1);
  padding: 0 var(--size-4);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-control);
  background: none;
  color: var(--ink);
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  white-space: nowrap;
  text-decoration: none;
  cursor: pointer;
  transition: border-color var(--dur) var(--ease), background-color var(--dur) var(--ease);
}

.btn:hover {
  border-color: var(--ink);
}

/* --------------------------------------------------------------- footer -- */

.footer {
  border-top: 1px solid var(--line);
}

.footer-inner {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: var(--size-5);
  display: flex;
  align-items: center;
  gap: var(--size-4);
  flex-wrap: wrap;
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.02em;
}

.footer-inner a {
  color: var(--ink-mid);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

.footer-inner a:hover {
  color: var(--ink);
  border-bottom-color: var(--line-strong);
}

/* --------------------------------------------------------------- motion -- */

/* The entrance stagger is gated on .is-intro, which app.js applies for the
   first render only. Ungated it replayed on every keystroke, so the whole list
   flickered while filtering. */
@media (prefers-reduced-motion: no-preference) {
  .is-intro .row {
    animation: row-in 260ms var(--ease) both;
  }

  /* A short stagger down the index, capped so a long list never crawls. */
  .is-intro .row:nth-child(2) {
    animation-delay: 30ms;
  }
  .is-intro .row:nth-child(3) {
    animation-delay: 60ms;
  }
  .is-intro .row:nth-child(4) {
    animation-delay: 90ms;
  }
  .is-intro .row:nth-child(5) {
    animation-delay: 120ms;
  }
  .is-intro .row:nth-child(n + 6) {
    animation-delay: 150ms;
  }

  @keyframes row-in {
    from {
      opacity: 0;
      transform: translateY(4px);
    }
    to {
      opacity: 1;
      transform: none;
    }
  }
}

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

  .row:hover .row-go {
    transform: none;
  }
}

/* ----------------------------------------------------------- responsive --
 * Below 760px the four columns cannot hold their labels, so the header band is
 * dropped and each row re-flows to three bands: title beside the actions, then
 * the description, then stack and host on one meta line.
 */

@media (max-width: 760px) {
  .bar-inner {
    padding: 0 var(--size-4);
  }

  .wordmark {
    font-size: 10px;
    letter-spacing: 0.1em;
  }

  .page {
    padding: 0 var(--size-4) var(--size-7);
  }

  .masthead {
    padding: var(--size-6) 0 var(--size-4);
  }

  .title {
    font-size: 27px;
  }

  /* No columns to label once the row re-flows, so the band would be lying. */
  .index-head {
    display: none;
  }

  .registry {
    border-top: 1px solid var(--line);
  }

  .row {
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas:
      'num    title  actions'
      'desc   desc   desc'
      'tags   tags   host';
    column-gap: var(--size-3);
    row-gap: var(--size-2);
    padding-block: var(--size-4);
  }

  /* The numeral stays. It is the only colour on the page, and it is what makes
     four rows scannable — dropping it here would leave the whole mobile layout
     monochrome for no gain, since it costs one 11px column. */
  .row-index {
    grid-area: num;
  }

  /* The two wrappers dissolve, so their children can be placed on the row grid
     directly instead of needing a band of their own each. */
  .row-main {
    display: contents;
  }

  .row-title {
    grid-area: title;
  }

  .row-desc {
    grid-area: desc;
  }

  .row-tags {
    grid-area: tags;
    margin-top: 0;
    align-self: center;
  }

  .row-actions {
    grid-area: actions;
  }

  /* Redundant with the row being a link in the first place — but only then. A
     row with no URL is not a link, so its host line is the one thing saying why
     ("No deploy URL recorded", or the offending value of a url that was
     refused), and hiding it left the dimmed row with no explanation at all. */
  .row-host {
    grid-area: host;
    padding-top: 0;
    text-align: right;
  }

  .row:not(.is-pending) .row-host {
    display: none;
  }

  .preview-viewport,
  .preview-cap {
    max-width: none;
  }
}
