/* Learning by Building — portfolio framework
   Tokens transcribed from Figma "Canonical Blueprint" (68tFztO0VSpm0hhn3y82A0).
   Figma is authoritative for appearance; this repository is authoritative for the site. */

:root {
  /* Palette */
  --paper:      #fefdfa;  /* page ground */
  --ink:        #090c11;  /* headings, dark sections */
  --body:       #333d4f;  /* body copy on light */
  --mist:       #f0f2f7;  /* tinted panels, pills */
  --muted:      #626d80;  /* captions, placeholders, provenance */
  --line:       #d6dbe3;  /* hairlines, card borders */
  --accent:     #2e6bf2;  /* accent ground and decoration */
  --accent-text:#245bd1;  /* blue text on light surfaces */
  --on-dark:    #c7cfdb;  /* body copy on --ink */
  --on-accent:  #ffffff;  /* body copy on --accent */

  /* Type scale */
  --t-hero:   clamp(2.75rem, 6.1vw, 5.5rem);   /* 88px */
  --t-sect:   clamp(2rem,   3.9vw, 3.5rem);    /* 56px */
  --t-card:   clamp(1.5rem, 2.2vw, 2rem);      /* 32px */
  --t-sub:    clamp(1.15rem, 1.5vw, 1.375rem); /* 22px */
  --t-lead:   1.25rem;                          /* 20px */
  --t-base:   1rem;                             /* 16px */
  --t-small:  0.9375rem;                        /* 15px */
  --t-eyebrow:0.8125rem;                        /* 13px */
  --t-pill:   0.75rem;                          /* 12px */

  /* Rhythm */
  --gutter:   clamp(1.5rem, 6.7vw, 6rem);      /* 96px */
  --section:  clamp(4rem, 7.8vw, 7rem);        /* 112px */
  --r-card:   24px;
  --r-panel:  20px;
  --r-inner:  18px;
  --r-pill:   999px;

  --measure:  80ch;
}

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

/* The ground is declared on the root as well as on body, because Safari 26
   samples CSS rather than theme-color when it tints its own chrome, and a
   transparent root can fall back to white. It is the colour the browser lands
   on when nothing else decides.

   An earlier version of this comment explained the declaration as fixing a
   race between paint and a toolbar sampler. That was wrong, and the meta tag
   it credited does nothing here: Safari 26 does not read theme-color for
   Liquid Glass tinting at all. What actually decides the tint is which
   elements near the viewport edges carry a surface -- see .nav. */
html { -webkit-text-size-adjust: 100%; background: var(--paper); }

body {
  margin: 0;
  /* One element that cannot shrink below its content drags the whole document
     wider than the phone viewport, and every other block then reads as clipped.
     This clamp goes on body, not html: an overflow other than visible on html
     would make it the scroll container and stop the sticky nav sticking.

     clip rather than hidden. Both clip identically; clip additionally forbids
     programmatic scrolling, which nothing here does horizontally. hidden left
     body scrollable along an axis that never scrolls, and a page translated a
     full viewport height during a transition was cut off at that box. */
  overflow-x: clip;
  background: var(--paper);
  color: var(--body);
  font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  font-size: var(--t-base);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3 { color: var(--ink); font-weight: 600; line-height: 1.08; margin: 0; letter-spacing: -0.02em; }
p { margin: 0; }
a { color: inherit; }

/* ---- Layout ------------------------------------------------------------ */

.section { padding: var(--section) var(--gutter); }

/* The first section on a page sits directly under the header's divider rule,
   so its top padding is measured against a line rather than against the
   preceding section's padding -- the same value that reads as separation
   between two sections reads as a gap under the rule. 60% of it closes that
   gap without touching the rhythm anywhere else on the page. */
main > .section:first-child { padding-top: calc(var(--section) * 0.6); }

/* The insights strip sets padding-block: 0 <bottom> below, so it contributes
   no top padding: the whole gap between the hero's buttons and LATEST INSIGHTS
   is the hero's own padding-bottom. Trimming a quarter off it is the entire
   change -- adding a rule to .latest-insights here would be overridden by that
   later declaration and do nothing. */
main > .section:first-child { padding-bottom: calc(var(--section) * 0.75); }
/* Vertical rhythm inside a section. This previously targeted .section's own
   children, but every section wraps its content in .wrap, so it matched
   nothing and the visible spacing was only line-box leading -- the pill
   collided with the headline and the subhead ran into the buttons. */
.section > * + *,
.wrap > * + * { margin-top: 1.5rem; }

/* Headings set their own, tighter, space to the text they introduce, so an
   eyebrow reads as attached to its title rather than floating between two. */
/* The hero marks its eyebrow as a pill in a bare <p>; the other sections use
   .eyebrow. Both are the same thing to a reader, so both attach the same way. */
.eyebrow + .hero-title,
.eyebrow + .sect-title,
:has(> .pill) + .hero-title,
:has(> .pill) + .sect-title { margin-top: 1.25rem; }
.hero-title + .lead,
.sect-title + .lead,
.sect-title + .body-lg { margin-top: 1rem; }

.wrap { max-width: 1248px; margin-inline: auto; }

/* ---- Latest insights ---------------------------------------------------- */

/* A strip under the hero. Lighter than a section of its own: less vertical
   padding, a hairline above and below, so it reads as a shelf between the
   claim and the rest of the page rather than as another full section. */
/* The hero already closes with its own section padding; adding a full
   measure on top of it left about 180px of empty page. */
.latest-insights { padding-block: 0 clamp(2rem, 3.5vw, 3rem); }
.latest-insights .eyebrow { margin-bottom: 1rem; }

.latest-grid {
  display: grid; grid-template-columns: 1.4fr 1fr 1fr;
  gap: clamp(1.5rem, 3vw, 3rem);
  margin: 0; padding: 0; list-style: none;
  border-top: 1px solid var(--line);
  padding-top: 2rem;
}
.latest-item > * + * { margin-top: 0.5rem; }
.latest-category {
  font-size: var(--t-eyebrow); font-weight: 600; color: var(--accent-text);
  letter-spacing: 0.06em; text-transform: uppercase;
}
.latest-item h2 { font-size: 1.25rem; line-height: 1.3; letter-spacing: -0.01em; }
/* The newest piece is already the subject by width -- 1.4x the column, so its
   title sits on fewer lines and reads calmer. The type only has to agree with
   that, not repeat it. At 2rem the two signals compounded to 1.44x and the lead
   stopped looking like the same treatment with more room. */
.latest-item--lead h2 { font-size: 1.333rem; }
.latest-item h2 a { text-decoration: none; }
.latest-item h2 a:hover { color: var(--accent-text); text-decoration: underline; text-underline-offset: 0.2em; }
.latest-note { font-size: var(--t-small); color: var(--body); }

.latest-all {
  margin-top: 2rem; padding-top: 1.25rem;
  border-top: 1px solid var(--line);
  text-align: right; font-size: var(--t-small); font-weight: 600;
}
.latest-all a { color: var(--accent-text); text-decoration: none; }
.latest-all a:hover { text-decoration: underline; text-underline-offset: 0.2em; }

/* Three narrow columns stop being three columns. The lead keeps its size, so
   the order still reads as an order. */
@media (max-width: 56rem) {
  .latest-grid { grid-template-columns: minmax(0, 1fr); gap: 2rem; }
  .latest-item + .latest-item { padding-top: 2rem; border-top: 1px solid var(--line); }
}

.section--ink    { background: var(--ink);    color: var(--on-dark); }
.section--ink h1, .section--ink h2 { color: var(--paper); }
.section--mist   { background: var(--mist); }
.section--accent { background: var(--accent); color: var(--on-accent); }
.section--accent h1, .section--accent h2 { color: #fff; }

/* ---- Type -------------------------------------------------------------- */

.eyebrow {
  font-size: var(--t-eyebrow); font-weight: 600; color: var(--accent-text);
  letter-spacing: 0.06em; text-transform: uppercase;
}
.hero-title { font-size: var(--t-hero); max-width: 18ch; text-wrap: balance; }

/* The three verbs the sentence turns on. <b> rather than <strong>: this is
   stylistic prominence, not added importance -- a screen reader should read
   the line exactly as it reads every other heading, and <strong> would give
   "build" an emphasis the sentence does not intend when spoken.
   800 against the heading's 600 is one step, not two; at 88px a larger jump
   reads as two different typefaces sharing a line. */
/* The three verbs stay at --ink; everything joining them steps back to
   --body, the same colour the paragraphs below already use. Weight alone was
   too quiet at this size, and a hardcoded midpoint would be a colour Figma
   does not know about -- the palette is canonical, so the variance comes from
   two tokens that already exist rather than a third invented here. */
.hero-title { color: var(--body); }
.hero-title b { font-weight: 800; color: var(--ink); }
.sect-title { font-size: var(--t-sect); max-width: 22ch; }
.lead    { font-size: var(--t-sub);  max-width: var(--measure); }
.body-lg { font-size: var(--t-lead); max-width: var(--measure); }
/* The five points were one 60-word sentence. As a list they stay scannable;
   the accent rule replaces the bullet, which reads as decoration on --ink. */
.thesis-list { list-style: none; padding: 0; margin: 1.25rem 0 0; max-width: var(--measure); font-size: var(--t-lead); }
.thesis-list li { padding: 0.5rem 0 0.5rem 1.25rem; border-left: 2px solid var(--accent); }
.thesis-list li + li { margin-top: 0.5rem; }

/* ---- Pills ------------------------------------------------------------- */

/* nowrap keeps a short badge from breaking across two lines, but it also makes
   the pill's minimum width its full text width. A long pill then pushes the
   document wider than the phone viewport, and every other block reads as
   clipped -- the symptom appears nowhere near the cause. Allow the wrap once
   the line no longer fits. */
.pill {
  display: inline-block; max-width: 100%; padding: 8px 14px; border-radius: var(--r-pill);
  font-size: var(--t-pill); font-weight: 600; letter-spacing: 0.04em;
  background: var(--mist); color: var(--accent-text); white-space: nowrap;
}
@media (max-width: 30rem) {
  .pill { white-space: normal; border-radius: var(--r-panel); }
}
.pill--paper  { background: #fff; color: var(--accent-text); }
.pill--quiet  { background: var(--mist); color: var(--body); }

/* ---- Buttons ----------------------------------------------------------- */

.actions { display: flex; flex-wrap: wrap; gap: 16px; }
.actions .btn { min-width: 15rem; text-align: center; }

/* Once the row wraps the buttons sit directly above one another, where
   different intrinsic widths read as a mistake rather than as sizing. Below
   the wrap point they share the full column width; above it they keep their
   shared minimum width side by side. */
@media (max-width: 30rem) {
  .actions { flex-direction: column; align-items: stretch; }
  .actions .btn { width: 100%; min-width: 0; }
}
.btn {
  display: inline-block; padding: 16px 32px; border-radius: var(--r-pill);
  font-size: var(--t-small); font-weight: 600; text-decoration: none;
  border: 1px solid transparent; transition: opacity .15s ease, background-color .15s ease;
}
.btn--primary   { background: var(--ink); color: var(--paper); }
.btn--secondary { background: transparent; color: var(--ink); border-color: var(--line); }
/* The two light-surface variants invert on --ink, where the primary's dark
   ground would disappear and the secondary's hairline would fall below
   contrast. Used by the closing call to action. */
.btn--on-ink        { background: var(--paper); color: var(--ink); }
.btn--on-ink-quiet  { background: transparent; color: var(--paper); border-color: rgba(255,255,255,.4); }
.btn--on-ink-quiet:hover { background: rgba(255,255,255,.08); opacity: 1; }
.btn:hover      { opacity: .85; }
.btn:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* Spacing for the 404 page's return link, below the entrance list. */
.error-back { margin-top: 2rem; }

/* ---- Navigation -------------------------------------------------------- */

/* Every page carries the skip link, so it belongs with the shared navigation
   rather than in a page-type stylesheet. Moving it offscreen keeps it in the
   focus order; display:none or visibility:hidden would make it unreachable by
   the keyboard users it exists for. */
.skip-link { position: fixed; top: 0.5rem; left: 1rem; z-index: 100; padding: 0.75rem 1rem; background: var(--paper); transform: translateY(-200%); }
.skip-link:focus { transform: translateY(0); }

.nav {
  position: sticky; top: 0; z-index: 50;
  display: flex; align-items: center; justify-content: space-between;
  gap: 1.5rem; flex-wrap: wrap;
  /* Five pixels of scroll runway, and the shift it would cause cancelled.
     Safari composites the page behind its own toolbar only once the document
     has scrolled: at scrollY 0 the bar is flat, whatever the page paints. With
     the header flush at the top there is nothing to scroll before it pins, so
     a reader who never scrolls never sees the bar do anything.

     So the header's box starts 5px down -- that is the runway -- while its
     contents and its divider start 5px up, which puts everything visible
     exactly where it would have been. Below, a scroll-driven animation walks
     them back as the box travels up to pin. Net motion of anything a reader
     can see: zero. The only thing that moves is the header's own background,
     which is the same colour as the page.

     Found by measurement: 5px works, 4px does not. Nothing else did -- not
     theme-color, which this Safari ignores entirely, not the root background,
     not layer-promotion hints, and not anything declared on the sticky element
     itself. The variable was never what the page painted; it was whether the
     page had scrolled. */
  margin-top: 5px;
  padding: 9px var(--gutter) 19px;
  /* Align header content with the centred 1248px page wrap. */
  padding-inline: max(var(--gutter), calc((100% - 1248px) / 2));
  row-gap: 10px;
  /* No background and no backdrop-filter on the sticky element itself. Safari
     samples what a sticky box paints when it decides how to tint its own
     toolbar, and a surface declared here makes the bar part of that decision:
     iOS 26 reads it as chrome to tint rather than as content to show through,
     and on macOS the blur simply never composites on a freshly rendered
     document. It comes back on a back/forward restore, because that returns an
     already-composited layer tree -- which is the whole of the launch-versus-
     return asymmetry, and none of it is visible in the computed styles.
     The surface moves to ::before, which is an ordinary painted child. */
}

/* The contents start 5px up, cancelling the box's 5px of runway. */
.nav > * { transform: translateY(-5px); }

/* The divider, which was border-bottom before the runway. It has to be a
   painted child rather than a border so it can be moved independently of the
   box: like the header's contents it starts 5px up and settles as the page
   scrolls. */
.nav::after {
  content: ""; position: absolute; left: 0; right: 0;
  bottom: 5px; height: 1px; background: var(--line);
}

/* The counter-shift. Progressive: without scroll-driven animations the
   contents simply stay where they start, which is where they belong -- the
   header then sits 5px lower than the divider suggests and nothing else
   changes. The runway still works; only the concealment needs this. */
@supports (animation-timeline: scroll()) {
  .nav > *,
  .nav::after {
    animation: nav-settle linear both;
    animation-timeline: scroll(root block);
    animation-range: 0 5px;
  }
  @keyframes nav-settle {
    from { transform: translateY(-5px); }
    to   { transform: translateY(0); }
  }
  .nav::after { animation-name: nav-line-settle; }
  @keyframes nav-line-settle {
    from { bottom: 5px; }
    to   { bottom: 0; }
  }
}

/* The bar's actual surface: slightly lifted off --paper so the header reads as
   chrome rather than as the top of the page. Inset rather than inherited, so
   it covers the border box while the sticky element above stays unpainted. */
.nav::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: color-mix(in srgb, var(--paper) 82%, #fff);
}

/* Translucency is progressive: without backdrop-filter the bar stays opaque
   and legible, which matters more than the effect. */
@supports (backdrop-filter: saturate(180%) blur(20px)) {
  .nav::before {
    background: color-mix(in srgb, var(--paper) 72%, transparent);
    backdrop-filter: saturate(180%) blur(20px);
    -webkit-backdrop-filter: saturate(180%) blur(20px);
  }
}

/* The mark rides with the wordmark rather than replacing it. The name is the
   identity that spans irq9.net and nyfor.me, so it stays; the mark is the
   sibling of those two families, drawn on the same 48-unit grid in the site's
   own blue. */
.brand { display: flex; align-items: center; gap: .5rem; flex-shrink: 0; white-space: nowrap; font-size: 14px; font-weight: 600; color: var(--ink); text-decoration: none; }
.brand-mark { flex: 0 0 auto; display: block; width: 22px; height: 22px; }
/* The links sit with search on the right rather than floating between the
   brand and it: space-between only centres a middle item when the two
   outside it match in width, and a wordmark never matches an icon. */
.nav-links { display: flex; gap: 28px; font-size: 14px; font-weight: 500; flex-wrap: wrap;
  margin-left: auto; }

/* Initials and tighter spacing keep the mobile header on one row.
   Wrapping remains available for enlarged text or unusually narrow screens. */
@media (max-width: 34rem) {
  .nav { padding-block: 12px; gap: 12px; }
  .nav-links { gap: clamp(0.5rem, 2vw, 0.875rem); flex-wrap: nowrap; }
  /* The wordmark yields to the mark so the four labels keep one row. The
     brand link's aria-label still carries the full name. */
  .brand-full { display: none; }
}
.nav-links a {
  text-decoration: none; padding: 4px 0;
  border-bottom: 2px solid transparent;
  transition: color .15s ease, border-color .15s ease;
}
.nav-links a:hover { color: var(--ink); border-bottom-color: var(--accent); }

/* The four links stay visible: a reader should be able to see where the site
   goes without opening anything. Search is the one control in the bar. */
.nav-enhanced .nav { flex-wrap: nowrap; }
.nav-actions { display: flex; align-items: center; gap: 6px; }
.nav-icon {
  width: 44px; height: 44px; display: inline-grid; place-items: center;
  border: 0; border-radius: 50%; background: transparent; color: var(--ink);
  cursor: pointer; transition: background-color .15s ease, color .15s ease;
}
.nav-icon:hover { background: var(--mist); color: var(--accent-text); }
.nav-icon:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.nav-icon svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }

/* The dialog is only a positioning box: the paper surface and shadow live on
   the inner wrapper, which is also what animates. Keeping the visible panel
   and the transform on the same element means the surface travels with its
   contents instead of staying behind them. */
.site-dialog { padding: 0; border: 0; background: transparent; color: var(--body); }
/* The search sheet meets the top edge, so it takes a hairline and a shallow
   drop rather than the menu's floating-panel shadow. */
.site-dialog::backdrop { background: rgb(9 12 17 / .52); backdrop-filter: blur(3px); }
.site-search-dialog[open]::backdrop { animation-duration: .42s; }
.site-search-dialog.is-closing::backdrop { animation-duration: .28s; }
.site-dialog.is-closing::backdrop { animation: note-backdrop-out .16s ease-in both; }
/* The search sheet is slower than the menu: it covers the top of the page
   rather than sliding in beside it, so it reads better arriving unhurried.
   closeDialog reads these durations from the computed style. */
.site-search-dialog[open] .site-search-inner { animation: sheet-down .42s cubic-bezier(.2, .8, .2, 1) both; }
.site-search-dialog.is-closing .site-search-inner { animation: sheet-up .3s cubic-bezier(.4, 0, 1, 1) both; }
/* Both directions are a pure slide: the sheet keeps its opacity and travels its
   own full height, arriving from and leaving past the top edge. Fading while
   moving did neither cleanly -- the old 20px was too little to register as
   travel, and enough to muddy the fade. */
@keyframes sheet-down {
  from { transform: translateY(-100%); }
  to { transform: translateY(0); }
}
@keyframes sheet-up {
  from { transform: translateY(0); }
  to { transform: translateY(-100%); }
}
@keyframes sheet-in {
  from { opacity: 0; transform: translateX(100%); }
  to { opacity: 1; transform: translateX(0); }
}
@keyframes sheet-out {
  from { opacity: 1; transform: translateX(0); }
  to { opacity: 0; transform: translateX(100%); }
}
.site-dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.site-dialog-heading .eyebrow { margin: 0; }
.site-dialog-close { flex: 0 0 auto; margin: -10px -10px 0 0; }
/* A viewport-sized transparent dialog so the dim
   behind it is never interrupted by the dialog's own box, with the panel
   centred inside it. */
/* Anchored near the top rather than centred. When the on-screen keyboard opens
   it takes the lower half of the viewport: a centred panel is measured against
   the full height and ends up pushed off the top edge, and its input can land
   under the keyboard. Anchoring keeps the field where it was and lets the
   keyboard cover the results, which is the part the person is scrolling. */
/* A sheet attached to the top edge, not a floating card. Nothing is centred,
   so there is no height for the on-screen keyboard to invalidate: the field
   stays where it opened and the keyboard covers the results below it. */
/* The dialog still spans the viewport -- the backdrop needs it, and
   closeFromBackdrop tests for clicks landing on the dialog itself -- but it is
   padded down by the height of the sticky bar so the sheet starts below it.
   A modal <dialog> sits in the top layer and paints above the nav whatever its
   z-index, so without this the panel's full-height slide travels straight
   across the bar and covers it. Inset instead of a shorter dialog: the panel's
   translateY(-100%) is then measured from under the bar, and the sheet reads as
   emerging from behind the chrome rather than over it.

   --nav-h is measured and set by navigation.js, since the bar has no fixed
   height -- it is padding plus content, and both change with the breakpoint. */
.site-search-dialog {
  /* dvh, not svh. svh is the viewport with the browser toolbar *expanded* --
     the smallest it gets. When Safari collapses the toolbar on scroll the real
     viewport grows to lvh, the sheet keeps its shorter svh height, and the
     difference is a strip at the bottom with no panel over it: dimmed page
     showing through beneath the sheet. dvh tracks the toolbar as it moves. */
  width: 100%; max-width: none; height: 100dvh; max-height: none; margin: 0;
  padding: min(var(--nav-h, 0px), 25dvh) 0 0;
  /* The inset alone only fixes where the sheet comes to rest; the travel still
     crossed the bar. Clip that away too -- clip-path, not overflow: overflow
     clips to the padding box, and padding sits inside it, so padding-top never
     moves the boundary down. clip-path measures from the border box, which is
     where the bar actually ends.

     Clamped: --nav-h is measured by JS, and a value larger than the bar takes
     that much off the top of the sheet. Left unbounded, a bad measurement
     clips the sheet away entirely and the dim is all that renders. 25svh is
     far above any real bar height and far below anything that could hide the
     panel. */
  clip-path: inset(min(var(--nav-h, 0px), 25dvh) 0 0 0);
}

/* The scrim has to stop where the sheet does. ::backdrop paints in the top
   layer, above every positioned element in the document including the sticky
   bar, so a full-viewport dim tints the chrome no matter how opaque the bar
   itself is made. Inset it by the same measured bar height the sheet uses, and
   the bar stays paper while the page below it dims. */
.site-search-dialog::backdrop {
  clip-path: inset(min(var(--nav-h, 0px), 25dvh) 0 0 0);
}
.site-search-dialog[open] { display: block; }

/* The magnifier fades out while its own panel is open. The panel carries a
   magnifier of its own beside the field, so a second one in the bar is the
   same icon twice, one of which now means "close". The wordmark and the menu
   stay: they still do what they look like they do.

   pointer-events go with the opacity, or an invisible button keeps taking
   taps. The X and the backdrop remain the way out. Timed to the sheet's own
   entry easing so the icon dissolves as the panel arrives, and out on the
   shorter exit curve so it is back before the sheet has finished leaving. */
.nav-icon[aria-controls="site-search"] {
  transition: opacity .3s cubic-bezier(.4, 0, 1, 1);
}
html:has(.site-search-dialog[open]) .nav-icon[aria-controls="site-search"] {
  opacity: 0;
  pointer-events: none;
  transition: opacity .42s cubic-bezier(.2, .8, .2, 1);
}
@media (prefers-reduced-motion: reduce) {
  .nav-icon[aria-controls="site-search"],
  html:has(.site-search-dialog[open]) .nav-icon[aria-controls="site-search"] {
    transition: none;
  }
}
/* The rounding belongs with the surface it rounds, now that the dialog itself
   is transparent. The menu is a full-height sheet and stays square. */
/* svh is the viewport with the browser UI shown -- the small, keyboard-safe
   measure. dvh grows and shrinks as the keyboard opens, which resizes the panel
   mid-interaction. */
/* The search sheet meets the top edge, so it takes a hairline and a shallow
   drop rather than the menu's floating-panel shadow. Height comes from content:
   as tall as the field plus whatever is below it, never the full screen. */
.site-search-inner {
  /* Fills the space under the bar rather than sizing to content. A sheet that
     stops mid-screen leaves the page dimmed above and below it, and the eye
     reads three competing layers instead of one panel over one page. */
  width: 100%; height: calc(100dvh - var(--nav-h, 0px));
  /* No separate max-height: it repeated the height in svh, which would cap the
     panel at the toolbar-expanded size and reopen the gap the dvh height just
     closed. One measurement, one unit. */
  overflow-y: auto;
  padding: clamp(1.25rem, 4vw, 2.5rem) var(--gutter) clamp(2rem, 5vw, 3.5rem);
  border-radius: 0;
  background: var(--paper); border-bottom: 1px solid var(--line);
  box-shadow: 0 12px 40px rgb(9 12 17 / .18);
}
.site-search-top { display: flex; align-items: center; gap: 1rem; }
/* The field and the panel both sit tighter than they did: on a phone the
   modal has to clear the keyboard, and the status line below the input is now
   screen-reader only, so the visible panel is the heading and the field. */
/* The input is the interface: no border, no heading above it. The placeholder
   is the label, which is why it reads as a sentence rather than a hint. */
.site-search-field {
  display: flex; align-items: center; gap: .75rem; flex: 1 1 auto; min-width: 0;
  margin: 0; padding: 0; border: 0; background: transparent;
}
.site-search-field svg { width: clamp(22px, 3vw, 30px); height: clamp(22px, 3vw, 30px); flex: 0 0 auto; fill: none; stroke: var(--ink); stroke-width: 1.8; stroke-linecap: round; }
.site-search-field input {
  width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--ink);
  font: inherit; font-size: clamp(1.5rem, 4.5vw, 2.75rem); font-weight: 600; letter-spacing: -.02em;
}
.site-search-field input::placeholder { color: var(--muted); opacity: 1; }
.site-search-quick { display: grid; margin-top: clamp(1.75rem, 5vw, 2.75rem); }
.site-search-quick .eyebrow { margin-bottom: .5rem; color: var(--muted); }
.site-search-quick a {
  display: flex; align-items: center; gap: .75rem; padding: .7rem 0;
  color: var(--ink); font-size: 1.0625rem; font-weight: 600; text-decoration: none;
}
.site-search-quick a span { color: var(--accent-text); font-weight: 400; }
.site-search-quick a:hover { color: var(--accent-text); }
.site-search-status { margin-top: 1rem; font-size: var(--t-small); color: var(--muted); }
.site-search-results { display: grid; gap: .5rem; max-height: min(25rem, 45svh); margin-top: 1rem; padding: 0; overflow: auto; list-style: none; }
.site-search-results a {
  display: grid; gap: .25rem; padding: 1rem; border-radius: 12px; color: var(--body);
  text-decoration: none; transition: background-color .15s ease;
}
.site-search-results a:hover { background: var(--mist); }
.site-search-results strong { color: var(--ink); font-size: 1rem; line-height: 1.3; }
.site-search-results a > span:last-child { font-size: var(--t-small); line-height: 1.45; }
.site-search-section { color: var(--accent-text); font-size: var(--t-eyebrow); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
.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; }

@media (max-width: 34rem) {
  .nav-enhanced .nav { padding-block: 8px; }
  .site-search-inner { padding: 1rem var(--gutter) 2rem; }
}

/* A sticky bar hides an anchor target it scrolls over. Set the offset on the
   root so every in-page anchor clears the bar, rather than relying on a
   selector that only matches elements that happen to carry an id. */
html { scroll-padding-top: 80px; }

/* The system map lands closer than the root offset alone would put it. The
   section carries 112px of its own padding above the pill, so clearing the
   sticky bar by 80px and then crossing that padding left the first thing a
   reader looks for a quarter of the way down the viewport. Negative
   scroll-margin subtracts from the root's offset for this one target and
   changes nothing else: #content and #references keep the full 80px.

   The section's top edge ends up behind the bar rather than below it, which
   reads as the accent band continuing under the chrome. The pill still clears
   the bar by roughly 49px. */
#system-map { scroll-margin-top: -70px; }

/* ---- Grids ------------------------------------------------------------- */

.grid { display: grid; gap: 24px; }
/* min() caps the track at the container width so a bare minmax() floor cannot
   push content past the gutter on a narrow viewport. Without it, grid--2's
   420px floor overflowed a 325px container and shifted Featured Work right
   while every other section stayed centred. */
.grid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); }

/* Four-stage sequences stay balanced instead of falling into a 3 + 1 layout
   at intermediate desktop widths. */
@media (max-width: 72rem) {
  .grid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 38rem) {
  .grid--4 { grid-template-columns: 1fr; }
}

/* ---- Cards ------------------------------------------------------------- */

.stage {
  background: var(--mist); border-radius: var(--r-panel); padding: 28px 24px;
  display: flex; flex-direction: column; gap: 18px;
  width: 100%; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer;
  transition: transform .18s ease, box-shadow .18s ease;
}
.stage:hover { transform: translateY(-2px); box-shadow: 0 12px 32px rgb(9 12 17 / .09); }
.stage:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.stage-num  { font-size: var(--t-eyebrow); font-weight: 600; color: var(--accent-text); }
.stage-name { font-size: 1.5rem; line-height: 1.08; font-weight: 600; color: var(--ink); letter-spacing: -0.02em; }
.stage-description { font-size: var(--t-small); }
.stage-link { margin-top: auto; font-size: var(--t-small); font-weight: 600; color: var(--accent-text); }

.card-dialog {
  width: min(36rem, calc(100% - 3rem)); max-height: calc(100dvh - 3rem);
  padding: 0; border: 0; border-radius: var(--r-card); background: var(--paper);
  color: var(--body); box-shadow: 0 24px 80px rgb(9 12 17 / .28);
}
.card-dialog::backdrop { background: rgb(9 12 17 / .58); backdrop-filter: blur(3px); }
.card-dialog[open]::backdrop { animation: note-backdrop-in .24s ease-out both; }
.card-dialog[open] .card-dialog-inner { transform-origin: 50% 60%; animation: note-pop-in .28s cubic-bezier(.2, .8, .2, 1) both; }
.card-dialog.is-closing::backdrop { animation: note-backdrop-out .16s ease-in both; }
.card-dialog.is-closing .card-dialog-inner { animation: note-pop-out .16s ease-in both; }
/* Following a link out of a dialog, rather than dismissing it. The panel goes
   the same way it does on a close, but the backdrop is deliberately left up:
   the page is about to be replaced, and taking the tint away first exposes a
   bare frame between the dialog leaving and the next page arriving. It leaves
   with the old page instead, inside the page transition's own snapshot. */
.card-dialog.is-leaving .card-dialog-inner { animation: note-pop-out .16s ease-in both; }
.card-dialog.is-leaving::backdrop { animation: note-backdrop-in .24s ease-out both; }
@keyframes note-pop-in {
  from { opacity: 0; transform: translateY(18px) scale(.97); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes note-pop-out {
  from { opacity: 1; transform: translateY(0) scale(1); }
  to { opacity: 0; transform: translateY(10px) scale(.985); }
}
@keyframes note-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes note-backdrop-out { from { opacity: 1; } to { opacity: 0; } }
.card-dialog-inner { padding: clamp(1.5rem, 5vw, 3rem); }
.card-dialog-inner > * + * { margin-top: 1rem; }
.card-dialog h2 { font-size: clamp(1.75rem, 4vw, 2.5rem); line-height: 1.12; }
.card-dialog form { display: flex; justify-content: flex-end; margin: 0; }
.dialog-close {
  width: 2.5rem; height: 2.5rem; border: 1px solid var(--line); border-radius: 50%;
  background: var(--paper); color: var(--ink); font: inherit; font-size: 1.5rem;
  line-height: 1; cursor: pointer;
}
.dialog-close:hover { background: var(--mist); }
.dialog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.dialog-link { display: inline-block; color: var(--accent-text); font-weight: 600; text-underline-offset: 0.2em; }
.dialog-links { display: flex; flex-wrap: wrap; gap: 0.75rem 1.5rem; }

.model {
  border: 1px solid var(--line); border-radius: var(--r-panel); padding: 30px;
  width: 100%; display: flex; flex-direction: column; gap: 18px; background: var(--paper);
  color: inherit; font: inherit; text-align: left; cursor: pointer;
  transition: transform .18s ease, box-shadow .18s ease;
}
.model:hover { transform: translateY(-2px); box-shadow: 0 12px 32px rgb(9 12 17 / .09); }
.model:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.model-title { font-size: 1.5rem; line-height: 1.08; font-weight: 600; color: var(--ink); letter-spacing: -0.02em; }
.model-description { font-size: var(--t-base); line-height: 1.55; }
.model-link { margin-top: auto; font-size: var(--t-small); font-weight: 600; color: var(--accent-text); }

.project {
  background: #fff; border-radius: var(--r-card); padding: 32px;
  display: flex; flex-direction: column; gap: 22px;
  text-decoration: none; color: inherit;
  transition: transform .18s ease, box-shadow .18s ease;
}
.project:hover { transform: translateY(-2px); box-shadow: 0 12px 32px rgb(9 12 17 / .09); }
.project-top { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.project-num { font-size: var(--t-eyebrow); font-weight: 600; color: var(--accent-text); }
.project-visual { height: 240px; border-radius: var(--r-inner); background: var(--ink); }
.project-visual--accent { background: var(--accent); }
.project h3 { font-size: var(--t-card); }
.project-tags { font-size: var(--t-pill); font-weight: 600; color: var(--accent-text); letter-spacing: 0.04em; }

/* Featured work reads as a rail rather than a grid: seven entries plus a link
   to the index is more than a grid can show without becoming the page.

   No JavaScript. Scroll snapping does the whole job natively -- trackpad,
   touch, and keyboard all work, focus moves in DOM order, and there is no
   autoplay or index state to fall out of sync. The scrollbar is hidden because
   the next card showing at the edge is the affordance; a card sized to the
   full track would leave nothing to suggest more. */
.project-rail {
  display: flex; gap: 24px;
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  /* The gutter would otherwise clip the lift on hover and the focus ring. */
  padding: 8px 4px 20px;
  margin-inline: -4px;
  scrollbar-width: none;
}
.project-rail::-webkit-scrollbar { display: none; }
.project-rail > .project {
  flex: 0 0 min(30rem, 82vw);
  scroll-snap-align: start;
}
/* Snapping to the last card would leave it against the left edge with empty
   track beside it. Stopping the snap at the end lets the rail settle with the
   final card fully in view. */
.project-rail > .project:last-child { scroll-snap-align: end; }


/* ---- System map -------------------------------------------------------- */

.relationship-board {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px;
  overflow: hidden; border-radius: var(--r-card); background: rgb(255 255 255 / .45);
}
.relationship-track { display: flex; flex-direction: column; gap: 1.25rem; padding: clamp(1.5rem, 3vw, 2.25rem); background: #fff; color: var(--body); }
.relationship-axis { display: flex; flex-direction: column; gap: 0.3rem; text-decoration: none; }
.relationship-axis span, .relationship-convergence span {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.7rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent-text);
}
.relationship-axis strong { font-size: 1.25rem; color: var(--ink); }
/* The axis is a label, not a link. It names the question a track answers --
   where it runs, who may act, what moves -- and the cards beneath it are the
   destinations. It used to link to a page per axis, and those pages described
   the same artifacts the cards do, which gave a reader three routes to one
   thing with nothing to say they were the same. */
.relationship-track > p { margin-top: auto; font-size: var(--t-small); }
/* Three questions, three shapes. A shared box-and-arrow grammar made every
   track look like a dependency graph, which was true of two of them and a
   plain misreading of the third. */

.track-note { font-size: var(--t-small); margin-top: .35rem; }
.relationship-track > .tier-stack,
.relationship-track > .perimeter,
.relationship-track > .chain { margin-top: 1.25rem; }

/* Where it runs: a stack. Each tier rests on the one beneath it. */
.tier-stack { display: grid; gap: .4rem; }
.tier { padding: .7rem .85rem; border-radius: 10px; border: 1px solid var(--line); }
.tier-name { margin: 0 0 .45rem; font-size: .75rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--body); }
.tier ul { display: flex; flex-wrap: wrap; gap: .35rem; margin: 0; padding: 0; list-style: none; }
.tier li { padding: .25rem .6rem; border-radius: 999px; background: #fff; border: 1px solid var(--line); font-size: .8125rem; font-weight: 500; }
.tier li a { text-decoration: none; color: inherit; }
.tier li a:hover { color: var(--accent-text); }
.tier--app   { background: #e8effc; }
.tier--svc   { background: #ede9fb; margin-inline: .5rem; }
.tier--infra { background: #e6f4ec; margin-inline: 1rem; }

/* Who may act: a perimeter. Requests reach a target only by crossing it. */
.perimeter { display: grid; grid-template-columns: auto 1fr auto; gap: .75rem; align-items: center; }
.perimeter-in, .perimeter-out { display: grid; gap: .9rem; margin: 0; padding: 0; list-style: none; font-size: .8125rem; color: var(--body); }
.perimeter-in li::after { content: " \2192"; color: var(--accent-text); }
.perimeter-out li::before { content: "\2192 "; color: var(--accent-text); }
.perimeter-box { padding: .8rem; border: 1px dashed var(--accent); border-radius: 12px; display: grid; gap: .5rem; }
.perimeter-label { margin: 0; font-size: .75rem; font-weight: 600; letter-spacing: .05em; color: var(--accent-text); text-align: center; }
.perimeter-actor { display: grid; gap: .1rem; padding: .55rem .7rem; border-radius: 8px; background: var(--ink); color: var(--on-dark); text-decoration: none; }
.perimeter-actor strong { font-size: .875rem; }
.perimeter-actor span { font-size: .75rem; opacity: .8; }
.perimeter-actor--quiet { background: var(--mist); color: var(--ink); border: 1px solid var(--line); }
.perimeter-actor--quiet span { opacity: 1; color: var(--body); }
.perimeter-actor:hover { outline: 2px solid var(--accent); outline-offset: 1px; }
.perimeter-rule { margin: 0; font-size: .75rem; font-weight: 600; letter-spacing: .04em; color: var(--accent-text); text-align: center; }

/* What moves: a chain. Stages, not dependencies -- the essay is not
   downstream of the application, it is the far end of the same path. */
.chain { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: .35rem; margin: 0; padding: 0; list-style: none; }
.chain-step { display: grid; gap: .2rem; padding: .6rem .5rem; border-radius: 8px; background: var(--mist); border: 1px solid var(--line); font-size: .75rem; text-align: center; }
.chain-stage { font-weight: 600; color: var(--ink); font-size: .8125rem; }
.chain-step a { color: var(--accent-text); text-decoration: none; }
.chain-step a:hover { text-decoration: underline; }
.chain-caption { margin: .5rem 0 0; font-size: .75rem; font-weight: 600; letter-spacing: .04em; color: var(--accent-text); text-align: center; }

.convergence-line { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8125rem; letter-spacing: .04em; text-transform: uppercase; }
.convergence-link { display: inline-flex; align-items: center; margin-left: auto; }
.convergence-link svg { width: 20px; height: 20px; fill: var(--paper); }
.convergence-link:hover svg { fill: var(--accent); }

@media (max-width: 60rem) {
  .chain { grid-auto-flow: row; grid-auto-columns: auto; }
  .perimeter { grid-template-columns: 1fr; }
  .perimeter-in, .perimeter-out { grid-auto-flow: column; justify-content: start; gap: 1rem; }
}
.relationship-convergence { grid-column: 1 / -1; display: flex; gap: 0.75rem 1.5rem; align-items: baseline; flex-wrap: wrap; padding: 1.25rem 1.5rem; background: var(--ink); color: var(--on-dark); }
.relationship-convergence a { color: var(--paper); text-underline-offset: 0.2em; }
.map-more { color: #fff; font-weight: 600; text-underline-offset: 0.2em; }
.map-more:hover { opacity: 0.82; }
@media (max-width: 64rem) {
  .relationship-board { grid-template-columns: 1fr; }
  .relationship-convergence { grid-column: auto; }
}
/* The flow is a fixed row of fixed-width boxes: on a phone the second column
   runs past the card edge and clips its label. Let the row wrap and let the
   boxes shrink, so a long name like HomeLabRemote stays readable. */
@media (max-width: 30rem) {
  /* Centred, because these stopped hugging their text. At full width the
     boxes are ~324px against a ~100px label, so start-alignment leaves the
     name pinned to one edge with 200px of empty box beside it. On wider
     screens the box shrink-wraps and the two are the same thing. */
  /* The arrow carries the relationship, so it turns to match the stack rather
     than disappearing. */
}

/* ---- Footer ------------------------------------------------------------ */

.footer {
  padding: 32px var(--gutter); border-top: 1px solid var(--line);
  display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between;
  font-size: var(--t-small); color: var(--body);
}

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
}

/* ---- Page transitions -------------------------------------------------- */

/* A project page opens as a sheet over the page that linked to it: it slides
   up to cover, and the page underneath holds still.

   Entry only, deliberately. The reverse would need the back link to run a
   transition, and the back link runs history.back() so the reader lands at the
   card they left rather than at the top of the page. A traversal is served
   from the back/forward cache and sets up no transition, so there is nowhere
   to hang the reverse; losing your place was the worse trade.

   @view-transition cannot be declared per-link, so it is enabled for the whole
   document and every rule here is gated on vt-enter, which page-transition.js
   sets only when a card was clicked. Everything else matches nothing and stays
   instant.

   The arriving page has to be stacked in front, and a z-index alone will not
   do it: ::view-transition-image-pair isolates its two children, and while it
   does neither can be reordered. isolation:auto lifts that, and only then does
   the stacking apply. Without it the sheet slides up behind the page it should
   be covering, and the same animation reads as the old page dropping away. */
@view-transition { navigation: auto; }

/* The nav is 28% transparent over a backdrop-filter, which is invisible in
   normal use because it sits over its own opaque page. During a transition
   both pages are painted into one snapshot tree, so what the bar samples is no
   longer the page it belongs to -- it is the other page, showing straight
   through the chrome as the sheet moves. Opaque for the duration, using the
   same value the no-backdrop-filter fallback already ships. */
html:active-view-transition .nav::before {
  background: color-mix(in srgb, var(--paper) 82%, #fff);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

/* Neither half cross-fades: both pages are opaque and one covers the other.
   The UA's group animation smooths between the two snapshots and fights a full
   viewport move. */
html.vt-enter::view-transition-old(root),
html.vt-enter::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
html.vt-enter::view-transition-image-pair(root) { isolation: auto; }
html.vt-enter::view-transition-group(root) { animation: none; }

/* The page that opened the sheet, frozen in place and behind. */
html.vt-enter::view-transition-old(root) { z-index: 0; }

/* The sheet, rising from the bottom edge over it. */
html.vt-enter::view-transition-new(root) {
  animation: sheet-rise .62s cubic-bezier(0.32, 0.72, 0, 1);
  z-index: 1;
}

@keyframes sheet-rise { from { transform: translateY(100%); } }

/* The blanket reduced-motion rule earlier disables `animation`, but a view
   transition animates pseudo-elements that rule never names. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root),
  ::view-transition-group(root) { animation: none !important; }
}

/* ---- Build badge ------------------------------------------------------- */



/* A closing aside, last thing in the dark field and well below the buttons.
   Small and dimmed: it is a joke to be found rather than announced, so it
   takes none of the treatment the section above it gets. On --ink the muting
   is a colour rather than an opacity -- fading white on near-black loses the
   text before it looks quiet. */
.aside-note {
  margin-top: 5rem;
  max-width: var(--measure);
  font-size: 0.8125rem;
  color: rgb(199 207 219 / .55);
}
.section--ink .aside-note { color: rgb(199 207 219 / .5); }
.aside-note a { color: inherit; text-underline-offset: 0.2em; }
.aside-note a:hover { color: var(--paper); }

/* The system board is one drawing rather than three CSS diagrams: the shapes
   carry the argument -- a stack that rests, a boundary that is crossed, a
   chain of stages -- and hand-positioned SVG holds them steadier than flex
   ever did. Structural colours come from the tokens so it belongs to the
   page; the tier and stage palettes stay literal because they are the
   drawing's own vocabulary. */
.system-board { overflow-x: auto; }
.system-board svg { display: block; width: 100%; height: auto; }
/* Scaled to a phone the pill labels reach about two pixels, so below this
   width the board keeps a legible size and pans instead. A diagram that asks
   for a swipe is better than one that fits and cannot be read. */
@media (max-width: 60rem) {
  .system-board svg { width: 1540px; max-width: none; }
}
.system-board a:hover path { fill: var(--accent); }

/* The rail hides its scrollbar and snaps, which leaves nothing to say how far
   along a reader is or that there is more. The active position is a pill and
   the rest are dots, so the shape itself reports position rather than colour
   alone. */
.rail-indicator { display: flex; justify-content: center; gap: 8px; margin-top: 4px; }
.rail-dot {
  width: 8px; height: 8px; padding: 0; border: 0; border-radius: 999px;
  background: var(--line); cursor: pointer;
  transition: width .2s ease, background-color .2s ease;
}
.rail-dot.is-active { width: 24px; background: var(--accent); }
.rail-dot:hover { background: var(--muted); }
.rail-dot.is-active:hover { background: var(--accent-text); }
.rail-dot:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .rail-dot { transition: none; } }

/* The board holds design size on a narrow screen and pans. These say so --
   ghosted, non-interactive, and gone once there is nothing left to scroll.
   They hang off the frame rather than the board: an absolutely positioned
   child of a scrolling box is placed against the scrolled content, not the
   part of it you can see, so cues inside the board would sit at the far ends
   of 1540px and never appear. */
.system-board-frame { position: relative; }
.board-cue {
  position: absolute; top: 0; bottom: 0; width: 44px; pointer-events: none;
  display: grid; place-items: center; color: var(--paper);
  opacity: 0; transition: opacity .2s ease;
}
.board-cue--start { left: 0; background: linear-gradient(to right, rgb(46 107 242 / .9), transparent); }
.board-cue--end   { right: 0; background: linear-gradient(to left,  rgb(46 107 242 / .9), transparent); }
.system-board-frame.can-scroll-start .board-cue--start,
.system-board-frame.can-scroll-end   .board-cue--end { opacity: .85; }
.board-cue svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.5; }

/* The HomeLabRemote prototype, framed in the page. It is a built React bundle
   under /prototype/ that draws its own phone bezel, so the frame adds no
   border or corner of its own -- doing so would sit a second device outline
   around the first. 430x900 is the size it was designed at; below that the
   page is narrower than the phone, so it scales down rather than clipping. */
.device { margin: 28px 0; text-align: center; }
/* The prototype opens from its title card, drawn at the same 1200x630 the
   arcade cabinets use so the two read as the same kind of object. One card
   rather than a gallery, so it takes the full measure instead of a column. */
.device-open {
  position: relative;
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.device-open img {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: #050505;
}
/* One word, centred and large enough to read as the thing to press. A silent
   card reads as a screenshot; this says the card responds. */
.device-open span {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  padding: 3.5rem 8rem;
  /* A halo rather than a box: the card is a photograph and a hard-edged pill
     read as a sticker pasted on it. A radial wash darkens what is directly
     behind the word and fades out, so the word separates from the icons it
     sits among without drawing a shape of its own. */
  background: radial-gradient(ellipse at center,
    rgb(9 12 17 / .97) 0%,
    rgb(9 12 17 / .94) 30%,
    rgb(9 12 17 / .75) 55%,
    rgb(9 12 17 / .35) 78%,
    rgb(9 12 17 / 0) 100%);
  font: inherit;
  font-size: clamp(1rem, 2.4vw, 1.5rem);
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--paper);
  transition: background-color .18s ease;
}
.device-open:hover span {
  background: radial-gradient(ellipse at center,
    rgb(9 12 17 / 1) 0%,
    rgb(9 12 17 / .97) 30%,
    rgb(9 12 17 / .82) 55%,
    rgb(9 12 17 / .42) 78%,
    rgb(9 12 17 / 0) 100%);
}
.device-open:hover img,
.device-open:focus-visible img { border-color: var(--accent); }
.device-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .device-open span { transition: none; } }

/* The dialog. A transform paints smaller but lays out at full size, so a
   900px box in a 700px window is centred as a 900px box and hangs off both
   ends -- the bottom third of the phone went under the fold. The outer
   element takes the scaled size so the dialog is laid out at what is actually
   drawn, and the inner frame keeps the size the prototype renders at. */
.device-dialog {
  padding: 0;
  border: 0;
  background: none;
  max-width: none;
  max-height: none;
  overflow: visible;
}
/* Lighter than the usual scrim and blurred instead: the phone is nearly
   black, so a heavy dark backdrop left the two indistinguishable. The blur
   does the separating that the darkness was failing to do. */
.device-dialog::backdrop {
  background: rgb(9 12 17 / .38);
  backdrop-filter: blur(10px);
}
.device-dialog-inner {
  position: relative;
  width: calc(430px * var(--device-scale, 1));
  height: calc(900px * var(--device-scale, 1));
}
.device-dialog-frame {
  position: absolute;
  top: 0;
  left: 0;
  width: 430px;
  height: 900px;
  transform: scale(var(--device-scale, 1));
  transform-origin: top left;
  /* The corner is clipped here rather than on the iframe: iOS ignores
     overflow on a frame element and has been known to drop the whole layer
     when asked to round one, which showed as a blank white panel. The ground
     is opaque for the same reason -- a transparent frame composited against
     the dialog's own nothing is white if the prototype's paint is deferred. */
  border-radius: 54px;
  overflow: hidden;
  background: #050505;
}
.device-dialog iframe {
  display: block;
  width: 430px;
  height: 900px;
  border: 0;
  background: transparent;
  color-scheme: dark;
}
.device-dialog-close {
  position: absolute;
  top: -14px; right: -14px;
  width: 34px; height: 34px;
  display: grid; place-items: center;
  border: 0; border-radius: 50%;
  background: var(--paper);
  color: var(--ink);
  font-size: 1.25rem; line-height: 1;
  cursor: pointer;
}
.device-dialog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* closeDialog reads the closing animation's duration off the panel and waits
   for it, so a panel with no animation is one it waits on forever. */
.device-dialog.is-closing .device-dialog-inner {
  animation: device-out .16s ease forwards;
}
@keyframes device-out {
  to { opacity: 0; transform: scale(.97); }
}
@media (prefers-reduced-motion: reduce) {
  .device-dialog.is-closing .device-dialog-inner { animation-duration: 0s; }
}

/* The page is held still behind an open dialog by pinning body at its current
   offset, not by overflow: hidden. Either element taking an overflow other
   than visible becomes the scroll container, and the sticky nav then has
   nothing to stick to. */
body.is-dialog-held {
  position: fixed;
  left: 0;
  right: 0;
  width: 100%;
}

.device figcaption {
  margin-top: .5rem;
  color: var(--muted);
  font-size: 0.9375rem;
  text-align: center;
}

/* ── Infrastructure schematic ─────────────────────────────────────────────
   The diagram's palette. These live here rather than in a <style> block
   inside the SVG because the site's CSP is style-src 'self': an inline
   stylesheet is blocked, and the drawing renders as a black rectangle with
   nothing but its icons, which carry their colour as presentation
   attributes. Served from a file, the same rules are allowed, and the
   policy keeps its promise that the site renders no inline style.

   Scoped to .infra-svg so two-letter names like .bd and .al cannot collide
   with anything else. The .ci-* rules set colour, not fill: the icon shapes
   inside <symbol> are stroked with currentColor and inherit it. */
.infra-svg .bg-page { fill: #F7F6F2 }
.infra-svg .bg-white { fill: #ffffff }
.infra-svg .bg-gray { fill: #F0F0F8 }
.infra-svg .bg-bl { fill: #EAF2FF }
.infra-svg .bg-gr { fill: #E4F4E9 }
.infra-svg .bg-pu { fill: #EDE4FB }
.infra-svg .bg-ye { fill: #FEF5DC }
.infra-svg .bg-re { fill: #FDE8E8 }
.infra-svg .bg-rb { fill: #EBEBF5 }
.infra-svg .bg-ups { fill: #D4B840 }

.infra-svg .bd { fill: none; stroke: #E0DED8; stroke-width: 1 }
.infra-svg .bd-bl { fill: none; stroke: #B8D4FC; stroke-width: 1 }
.infra-svg .bd-gr { fill: none; stroke: #9DCBA9; stroke-width: 1 }
.infra-svg .bd-pu { fill: none; stroke: #C4A8F5; stroke-width: 1 }
.infra-svg .bd-ye { fill: none; stroke: #F0D878; stroke-width: 1 }
.infra-svg .bd-re { fill: none; stroke: #F0AAAA; stroke-width: 1 }
.infra-svg .bd-ds { fill: none; stroke: #B8D4FC; stroke-width: 1; stroke-dasharray: 4 3 }

.infra-svg .tc { fill: #111111 }
.infra-svg .tc-b { fill: #2B4EF7 }
.infra-svg .tc-mu { fill: #888888 }
.infra-svg .tc-bd { fill: #1a3a7a }
.infra-svg .tc-gd { fill: #1a5230 }
.infra-svg .tc-gm { fill: #3a7250 }
.infra-svg .tc-pd { fill: #3a1a78 }
.infra-svg .tc-yd { fill: #6b4a00 }
.infra-svg .tc-ym { fill: #8a6020 }
.infra-svg .tc-rd { fill: #780000 }
.infra-svg .tc-wh { fill: #ffffff }
.infra-svg .tc-whd { fill: rgba(255,255,255,0.65) }

.infra-svg .ci-gy { color: #555555 }
.infra-svg .ci-lt { color: #999999 }
.infra-svg .ci-bd { color: #1a3a7a }
.infra-svg .ci-gd { color: #1a5230 }
.infra-svg .ci-pd { color: #3a1a78 }
.infra-svg .ci-yd { color: #6b4a00 }
.infra-svg .ci-rd { color: #780000 }

.infra-svg .al { stroke: #C8C4BC; stroke-width: 1.5; fill: none }
.infra-svg .ah { fill: #C8C4BC }
.infra-svg .hr { stroke: #E0DED8; stroke-width: 1; fill: none }
.infra-svg .hr-g { stroke: #9DCBA9; stroke-width: 1; fill: none }

.infra-svg .dep-1 { fill: #D4880A } .infra-svg .dep-2 { fill: #1E8A3A }
.infra-svg .dep-3 { fill: #1B5FCC } .infra-svg .dep-4 { fill: #6B3EC8 }
.infra-svg .dep-5 { fill: #2B4EF7 } .infra-svg .dep-6 { fill: #1868C8 }
.infra-svg .dep-7 { fill: #7040C8 }

/* The rail holds both sections, so it ends with one link to each rather than
   a single count that only ever matched one of them. Spaced apart because two
   underlined phrases separated by a word space read as one broken sentence. */
.rail-more { display: flex; flex-wrap: wrap; gap: .5rem 2rem; }
