/* ==========================================================================
   Francis Wilfred Antiporda, portfolio

   Editorial, evidence-led. Fraunces carries the voice, IBM Plex Sans the
   prose, IBM Plex Mono every measurement. Deep teal is structural, burnt
   sienna marks what a number means. The page argues that he measures rather
   than claims, so the ledger is the signature and everything else stays quiet.

   No build step. Plain CSS, custom properties, no framework.
   ========================================================================== */

:root {
  /* --- surface -------------------------------------------------------
     Ink with a teal cast rather than a neutral black, so the deep teal
     accent reads as belonging to the ground instead of sitting on top. */
  --bg:        #0C1110;
  --surface:   #121917;
  --surface-2: #18211F;
  --line:      #242E2B;
  --line-soft: #1A2320;

  /* --- ink ----------------------------------------------------------- */
  --text: #E3EAE6;
  --dim:  #9BA9A4;
  --faint:#7E8C87;

  /* --- accents --------------------------------------------------------
     --green is the structural accent, kept under its old name so every
     rule in this file and the sub-pages follows without edits. */
  --green: #7FC9BC;
  --amber: #E3A268;
  --cyan:  #7FB6C7;
  --red:   #E38B7F;

  --on-green: #06201B;
  --green-bright: #9BDACE;
  --edge: #2F433D;
  --accent-soft: #16211E;

  /* --- composed surfaces --------------------------------------------- */
  --nav-bg:   rgb(12 17 16 / 84%);
  --float-bg: rgb(18 25 23 / 93%);
  --overlay:  rgb(8 12 11 / 90%);
  --scan:     rgb(255 255 255 / 1.4%);
  --glow:     rgb(127 201 188 / 7%);
  --shadow:   0 24px 60px -30px rgb(0 0 0 / 88%);
  --stripe-a: #0E1413;
  --stripe-b: #0B100F;
  --sunk:     #0A0E0D;
  --term-top: #101715;
  --term-bottom: #0C1211;
  --term-bar: #0D1312;
  --cert-mat: #0F1615;

  /* --- type -----------------------------------------------------------
     Fraunces carries the voice, Plex Sans the prose, Plex Mono every
     measurement. Keeping numbers in one face is what lets the ledger
     line up. */
  --display: "Fraunces", Georgia, "Times New Roman", serif;
  --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

  /* --- rhythm -------------------------------------------------------- */
  --page: 1120px;
  --gap: clamp(1rem, 2.5vw, 1.75rem);
  --section-y: clamp(4.5rem, 11vw, 8rem);
  --radius: 3px;
}


/* ==========================================================================
   Light theme.

   Dark is the default. These values apply either because the visitor asked
   for light, or because their system prefers it and they have not asked for
   anything. Both selectors carry the same declarations, because CSS gives no
   way to share one block between them.
   ========================================================================== */

:root[data-theme="light"] {
  --bg:        #E9EDE8;
  --surface:   #F3F6F2;
  --surface-2: #DEE5DC;
  --line:      #C4CCC2;
  --line-soft: #D6DCD4;

  --text:  #0D1310;
  --dim:   #47534D;
  --faint: #5F6B65;

  --green: #17423E;
  --amber: #8F4413;
  --cyan:  #1A5163;
  --red:   #9C2A1C;

  --on-green: #F3F6F2;
  --green-bright: #0F312E;
  --edge: #9BB2AB;
  --accent-soft: #DCE7E3;

  --nav-bg:   rgb(233 237 232 / 88%);
  --float-bg: rgb(243 246 242 / 95%);
  --overlay:  rgb(13 19 16 / 78%);
  --scan:     rgb(13 19 16 / 2.2%);
  --glow:     rgb(23 66 62 / 6%);
  --shadow:   0 20px 45px -28px rgb(13 19 16 / 38%);
  --cert-mat: #E2E8E0;
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    --bg:        #E9EDE8;
    --surface:   #F3F6F2;
    --surface-2: #DEE5DC;
    --line:      #C4CCC2;
    --line-soft: #D6DCD4;
  
    --text:  #0D1310;
    --dim:   #47534D;
    --faint: #5F6B65;
  
    --green: #17423E;
    --amber: #8F4413;
    --cyan:  #1A5163;
    --red:   #9C2A1C;
  
    --on-green: #F3F6F2;
    --green-bright: #0F312E;
    --edge: #9BB2AB;
    --accent-soft: #DCE7E3;
  
    --nav-bg:   rgb(233 237 232 / 88%);
    --float-bg: rgb(243 246 242 / 95%);
    --overlay:  rgb(13 19 16 / 78%);
    --scan:     rgb(13 19 16 / 2.2%);
    --glow:     rgb(23 66 62 / 6%);
    --shadow:   0 20px 45px -28px rgb(13 19 16 / 38%);
    --cert-mat: #E2E8E0;
  }
}

/* The terminal window stays dark in both themes, the way an embedded editor
   does. Redefining the tokens on the wrapper means everything inside follows
   without a single override. */
.term {
  --text:      #dbe3d8;
  --dim:       #93a08f;
  --faint:     #7f8b76;
  --line:      #232922;
  --line-soft: #1a1f19;
  --surface:   #101310;
  --surface-2: #161a16;
  --green:     #7ee787;
  --amber:     #ffb757;
  --cyan:      #79c0ff;
  --red:       #ff7b72;
  --edge:      #33422f;
  color: var(--text);
}

/* --------------------------------------------------------------- reset -- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }

/* Author rules like `.line { display: block }` beat the browser's own
   [hidden] rule, so state it once here and stop that whole class of bug. */
[hidden] { display: none !important; }

/* The nav is sticky, so an anchored heading would otherwise land underneath
   it. scroll-padding offsets every in-page jump, including the skip link. */
html {
  scroll-behavior: smooth;
  scroll-padding-top: 5.5rem;
}
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.7;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

/* Paragraph rag. pretty asks the browser to avoid leaving one short word
   alone on the last line. It costs nothing where it is unsupported: those
   browsers wrap exactly as they did before. Headings are handled separately
   with balance, which is a different thing: it evens every line rather than
   only fixing the last one. */
p { text-wrap: pretty; }

/* A hyphenated compound a line break must not split. Without this, "full-stack"
   wraps after the hyphen on a narrow phone and reads as two words. */
.nb { white-space: nowrap; }

/* A faint scanline wash + a green glow behind the fold. Purely decorative. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  background:
    radial-gradient(60rem 40rem at 78% -10%, var(--glow), transparent 60%),
    repeating-linear-gradient(
      to bottom,
      var(--scan) 0 1px,
      transparent 1px 3px
    );
}
@media (prefers-reduced-motion: reduce) { body::before { background: none; } }

img { max-width: 100%; display: block; height: auto; }

a { color: var(--green); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 3px; }

:focus-visible {
  outline: 2px solid var(--green);
  outline-offset: 3px;
  border-radius: 3px;
}

::selection { background: color-mix(in srgb, var(--green) 28%, transparent); }

/* ------------------------------------------------------------- helpers -- */
.wrap {
  width: min(100% - 2.5rem, var(--page));
  margin-inline: auto;
  position: relative;
  z-index: 1;
}

.mono { font-family: var(--mono); }
.dim { color: var(--dim); }
.faint { color: var(--faint); }

.skip {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--green);
  color: var(--on-green);
  padding: 0.6rem 1rem;
  font-family: var(--mono);
  font-weight: 700;
  z-index: 100;
}
.skip:focus { left: 1rem; top: 1rem; }

/* ----------------------------------------------------------------- nav -- */
.nav {
  position: sticky;
  top: 0;
  z-index: 40;
  background: var(--nav-bg);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--line-soft);
  font-family: var(--mono);
  font-size: 0.82rem;
}

.nav-inner {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  min-height: 52px;
}

/* Padding rather than a bigger font. .nav-inner is 52px tall and centres its
   children, so a 44px tap target fits inside it without moving a pixel of what
   you can see. Measured before this: the brand was 20px tall and the section
   links 17px, both under the 24px floor a pointer target needs, and well under
   the 44px a thumb wants. */
.nav-brand {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--text);
  font-family: var(--mono);
  font-size: 0.74rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  white-space: nowrap;
}
.nav-brand:hover { text-decoration: none; color: var(--green); }
.nav-brand .caret { color: var(--green); }

.nav-links {
  display: flex;
  gap: 1.1rem;
  margin-left: auto;
  list-style: none;
  padding: 0;
  flex-wrap: wrap;
}
.nav-links a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--dim);
}
.nav-links a::before { content: "#"; color: var(--faint); margin-right: 1px; }
.nav-links a:hover,
.nav-links a[aria-current="true"] { color: var(--green); text-decoration: none; }

@media (max-width: 640px) {
  .nav-links { gap: 0.75rem; font-size: 0.75rem; }
  /* 44px of tap target plus 0.5rem either side would make the bar 60px, where
     it has always been 52. The padding gives back exactly what the taller
     targets take, so the nav is the height it was designed to be. */
  .nav-inner { padding-block: 0.25rem; }
}

/* On the narrowest phones the full name, the section links and the theme
   button cannot share one row. The links are duplicated by the page itself,
   so they are the ones that give way. */
@media (max-width: 520px) {
  .nav-links { display: none; }
  .nav-brand { letter-spacing: 0.1em; }
}

/* ------------------------------------------------------------ terminal -- */
.term {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: linear-gradient(180deg, var(--term-top), var(--term-bottom));
  box-shadow: var(--shadow), 0 0 0 1px rgb(126 231 135 / 4%);
  overflow: hidden;
}

.term-bar {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.6rem 0.85rem;
  border-bottom: 1px solid var(--line);
  background: var(--term-bar);
}

.dot { width: 11px; height: 11px; border-radius: 50%; flex: none; }
.dot-r { background: #ff5f57; }
.dot-y { background: #febc2e; }
.dot-g { background: #28c840; }

.term-title {
  margin-left: 0.5rem;
  font-family: var(--mono);
  font-size: 0.75rem;
  color: var(--faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.term-body {
  padding: clamp(1.1rem, 3vw, 1.9rem);
  font-family: var(--mono);
  font-size: clamp(0.83rem, 1.6vw, 0.95rem);
  line-height: 1.85;
}

.line { display: block; }
.line + .line { margin-top: 0.15rem; }
.prompt { color: var(--green); user-select: none; }
.prompt::after { content: " "; }
.cmdtext { color: var(--text); }
.out { color: var(--dim); }
.out-strong { color: var(--text); }

.cursor {
  display: inline-block;
  width: 0.6em;
  height: 1.05em;
  background: var(--green);
  vertical-align: -0.18em;
  animation: blink 1.1s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .cursor { animation: none; } }

/* ---------------------------------------------------------------- hero -- */
.hero { padding-block: clamp(3rem, 8vw, 6rem) clamp(2rem, 4vw, 3rem); }

.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.5fr) minmax(0, 0.9fr);
  gap: clamp(1.5rem, 5vw, 3.5rem);
  align-items: center;
}
@media (max-width: 860px) {
  .hero-grid { grid-template-columns: 1fr; }
}

.hero h1 {
  font-family: var(--display);
  font-optical-sizing: auto;
  font-size: clamp(2.3rem, 6.4vw, 4.4rem);
  line-height: 0.99;
  letter-spacing: -0.022em;
  font-weight: 300;
  color: var(--green);
  margin-block: 0.35rem 1.1rem;
  text-wrap: balance;
}
/* The second clause is the claim the rest of the page answers, so it gets the
   italic and the wonk axis rather than a colour change. */
.hero h1 em {
  font-style: italic;
  font-variation-settings: "SOFT" 40, "WONK" 1;
}

.hero-blurb {
  color: var(--dim);
  max-width: 46ch;
  margin-top: 1rem;
}

.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.65rem;
  margin-top: 1.6rem;
}

/* Portrait: framed, and left in its own colours.

   It used to sit at grayscale(0.9) and warm up on hover. That was a nice
   effect on a decorative image and the wrong one on a photograph of the
   person being hired: it greyed out the only face on the page, and the hover
   reveal is invisible on a phone, where there is no hover at all.

   The cap matters more than it looks. Below 860px the hero collapses to one
   column, and without a max-width the portrait stretched to the full 820px
   measure, which is what made it feel oversized. */
.portrait {
  position: relative;
  max-width: 320px;
  margin-inline: auto;
}
.portrait img {
  width: 100%;
  display: block;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.portrait figcaption {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 0.7rem;
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--faint);
}
.portrait figcaption b { color: var(--green); font-weight: 500; }

/* The name sits above the headline rather than being it: a recruiter already
   knows whose site this is from the tab, and the sentence is the argument. */
.hero-kicker {
  font-family: var(--mono);
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--amber);
}

.shell-lead {
  color: var(--dim);
  max-width: 58ch;
  margin-bottom: 1.5rem;
}
.shell-lead b {
  font-family: var(--mono);
  font-size: 0.88em;
  color: var(--green);
  font-weight: 500;
}

/* The portrait caption follows the theme now that it is no longer inside the
   forced-dark group. */
.portrait figcaption {
  font-family: var(--mono);
  font-size: 0.7rem;
  letter-spacing: 0.04em;
}

/* -------------------------------------------------------------- button -- */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--mono);
  font-size: 0.82rem;
  font-weight: 500;
  padding: 0.6rem 1rem;
  border-radius: 7px;
  border: 1px solid var(--line);
  background: var(--surface-2);
  color: var(--text);
  transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
  white-space: nowrap;
}
.btn:hover {
  text-decoration: none;
  border-color: var(--green);
  background: var(--accent-soft);
  transform: translateY(-1px);
}
.btn-primary {
  background: var(--green);
  border-color: var(--green);
  color: var(--on-green);
  font-weight: 700;
}
.btn-primary:hover { background: var(--green-bright); border-color: var(--green-bright); }
.btn svg { width: 15px; height: 15px; flex: none; }

/* ------------------------------------------------------------- section -- */
.section { padding-block: var(--section-y); }
.section + .section { padding-top: 0; }
.hero + .section { padding-top: clamp(2.5rem, 5vw, 4rem); }

.section-head {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  flex-wrap: wrap;
  padding-bottom: 0.85rem;
  border-bottom: 1px solid var(--line-soft);
  margin-bottom: clamp(1.75rem, 4vw, 2.75rem);
}

.section-head h2 {
  /* Short headings look wrong broken 4-words-then-1. balance evens the lines;
     browsers without it simply wrap as before. */
  text-wrap: balance;
  font-family: var(--display);
  font-optical-sizing: auto;
  font-size: clamp(1.5rem, 3.2vw, 2.05rem);
  font-weight: 400;
  letter-spacing: -0.018em;
  line-height: 1.1;
  color: var(--green);
}
.eyebrow {
  display: block;
  font-family: var(--mono);
  font-size: 0.68rem;
  font-weight: 500;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--amber);
  margin-bottom: 0.55rem;
}
/* Inside the flex row it has to claim the whole line so the heading drops
   below it rather than sitting alongside. */
.section-head .eyebrow { width: 100%; }
.section-head p {
  color: var(--faint);
  font-family: var(--mono);
  font-size: 0.78rem;
  margin-left: auto;
}

/* -------------------------------------------------------------- ledger --
   The signature of this page. Every claim carries the measurement that backs
   it, on one baseline, in one face, so a reader can scan the right-hand
   column on its own. The numbers are re-derived by running the suites; see
   README, "The ledger is measured".                                        */
.ledger {
  border-top: 1px solid var(--text);
}

.ledger-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: baseline;
  gap: clamp(1rem, 4vw, 3rem);
  padding: 0.78rem 0;
  border-bottom: 1px solid var(--line-soft);
}

.ledger-row p {
  font-size: 0.97rem;
  color: var(--text);
}

.ledger-row b {
  font-family: var(--mono);
  font-size: 0.95rem;
  font-weight: 500;
  color: var(--green);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* The verb after a figure carries the amber, so the eye lands on what the
   number means rather than on the number twice. */
.ledger-row b i {
  font-style: normal;
  color: var(--amber);
}


@media (max-width: 560px) {
  .ledger-row { grid-template-columns: 1fr; gap: 0.2rem; }
  .ledger-row b { font-size: 0.88rem; }
}

/* --------------------------------------------------------------- about -- */
.about-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 3rem);
  align-items: start;
}
@media (max-width: 800px) { .about-grid { grid-template-columns: 1fr; } }

.prose p + p { margin-top: 1.05rem; }
.prose { max-width: 62ch; color: var(--dim); }
.prose strong { color: var(--text); font-weight: 600; }

.facts {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 1.1rem 1.25rem;
  font-family: var(--mono);
  font-size: 0.8rem;
}
.facts dl { display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 1rem; margin: 0; }
.facts dt { color: var(--faint); }
.facts dd { margin: 0; color: var(--text); text-align: right; }

/* ------------------------------------------------- the contact sheet --
   Seven photographs off one event, laid out the way a photographer lays out
   a roll: a single uniform-height strip on a mat, each frame numbered and
   captioned in the edge type. It is the only horizontal thing on a wholly
   vertical page, which is the point; it is also the only place on the site
   where a person appears at work rather than a screenshot of the work.

   The ground is dark in both themes, which is the one bold move on the page.
   Two reasons it is not an import: a contact sheet is a print where the film
   base between the frames comes out black, so the dark ground is what the
   object actually looks like; and this site already puts a full-bleed dark
   block in a light page, in the terminal section below. The band is scoped
   its own tokens rather than reading the theme, because its foreground has to
   stay legible on that ground whichever theme is on. */
.sheet {
  --sheet-ground: #131A18;
  --sheet-edge: #2C3A36;
  --sheet-ink: #9DACA7;
  --sheet-ink-bright: #EEF3F0;

  margin-top: clamp(2rem, 5vw, 3rem);
  padding-block: clamp(1.4rem, 3.5vw, 2.25rem);
  background: var(--sheet-ground);
  border-block: 1px solid var(--edge);
}

/* Native horizontal scrolling is the baseline: it works with no JavaScript,
   with a trackpad, with a touch swipe and with the keyboard. The scroll-linked
   pan below is added on top of it, and only where it suits the input. */
.sheet-rail {
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  /* Line the first frame up with the page gutter instead of the viewport
     edge, so the strip starts where every other section starts. */
  padding-inline: max(1.25rem, calc((100vw - var(--page)) / 2));
  scrollbar-width: thin;
}

.sheet-strip {
  display: flex;
  align-items: flex-start;
  gap: 0.9rem;
  width: max-content;
  margin: 0;
  padding: 0;
  list-style: none;
}

.frame {
  flex: none;
  scroll-snap-align: center;
  transition: transform 0.25s ease;
}
.frame img {
  display: block;
  /* Uniform height, natural width. The source frames are different shapes
     and cropping them to one ratio would cut people out of the group shots. */
  height: clamp(118px, 17vw, 176px);
  width: auto;
  border: 1px solid var(--sheet-edge);
  background: var(--sheet-ground);
  filter: saturate(0.94);
  transition: border-color 0.25s ease, filter 0.25s ease;
}
.frame figcaption {
  display: flex;
  gap: 0.5rem;
  max-width: 26ch;
  margin-top: 0.6rem;
  font-family: var(--mono);
  font-size: 0.66rem;
  line-height: 1.5;
  color: var(--sheet-ink);
  transition: color 0.25s ease;
}
.frame-no {
  flex: none;
  color: #E3A268;
  font-weight: 700;
  letter-spacing: 0.08em;
}

.frame:hover { transform: translateY(-4px); }
.frame:hover img { border-color: #7FC9BC; filter: saturate(1); }
.frame:hover figcaption { color: var(--sheet-ink-bright); }

/* The deal-in. Frames arrive from the right with a degree of rotation, one
   after another, the way a set of prints lands on a desk. --i comes off the
   markup so the order is the sheet's order, not the order they intersect. */
.js .sheet .frame {
  opacity: 0;
  translate: 26px 0;
  rotate: -1.4deg;
}
.js .sheet.in .frame {
  opacity: 1;
  translate: none;
  rotate: none;
  transition: opacity 0.5s ease calc(var(--i) * 80ms),
              translate 0.6s cubic-bezier(0.2, 0.8, 0.3, 1) calc(var(--i) * 80ms),
              rotate 0.6s cubic-bezier(0.2, 0.8, 0.3, 1) calc(var(--i) * 80ms);
}

/* While the scroll-linked pan is driving the strip, the rail stops being a
   scroller: the page's own scrolling is what moves it. Everything in the
   strip still passes the viewport, so nothing is hidden by this. */
.sheet-rail.is-panning {
  overflow-x: hidden;
  scroll-snap-type: none;
}
.sheet-rail.is-panning .sheet-strip { will-change: transform; }

@media (prefers-reduced-motion: reduce) {
  .js .sheet .frame,
  .js .sheet.in .frame {
    opacity: 1;
    translate: none;
    rotate: none;
    transition: none;
  }
  .frame { transition: none; }
  .frame:hover { transform: none; }
}

/* The credit belongs to whoever took the photographs, and it also gives the
   band somewhere to land rather than ending into the section's bottom air. */
.sheet-credit {
  max-width: 52ch;
  margin-top: 1rem;
  font-family: var(--mono);
  font-size: 0.7rem;
  line-height: 1.6;
  color: var(--faint);
}

/* ---------------------------------------------------------- card award --
   One line, in the type the rest of the card metadata uses. A result belongs
   next to the thing that earned it, not in a trophy cabinet of its own. */
.card-award {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  margin-top: 0.5rem;
  font-family: var(--mono);
  font-size: 0.72rem;
  line-height: 1.45;
  color: var(--amber);
}
.card-award svg { width: 14px; height: 14px; flex: none; }

/* ---------------------------------------------------------- card quote --
   A client's own words, or a reaction reported in mine. The two look alike on
   purpose but are marked differently: a quotation carries quotation marks and
   a named speaker, a reported reaction carries neither, because putting quote
   marks around wording the client may not have used would invent a quote. */
.card-quote {
  margin: 1rem 0 1.1rem;
  padding: 0.2rem 0 0.2rem 0.9rem;
  border-left: 2px solid var(--amber);
}
.card-quote p {
  font-family: var(--display);
  font-size: 1.12rem;
  line-height: 1.4;
  color: var(--text);
}
.card-quote footer {
  margin-top: 0.35rem;
  font-family: var(--mono);
  font-size: 0.7rem;
  line-height: 1.5;
  color: var(--dim);
}
.card-quote-reported {
  font-size: 0.9rem;
  line-height: 1.6;
  color: var(--dim);
}
.page-quote { margin-block: 2rem 2.5rem; max-width: 60ch; }

/* -------------------------------------------------------------- skills -- */
/* align-items: start, not the grid default of stretch. These groups hold
   different numbers of chips, so stretching every card to the tallest in its
   row left up to 67px of empty box under the short ones, which reads as a
   missing item rather than as breathing room. Each card is now its own height.

   A CSS multi-column layout would pack the odd group out of a row of its own,
   but Chrome's column balancer leaves a whole empty column at around 1024px
   wide, which is a worse hole than the one it fixes. Measured, not assumed. */
.skills {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  align-items: start;
  gap: 1rem;
}
.skill-group {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 1.15rem 1.25rem 1.35rem;
}
.skill-group h3 {
  font-family: var(--mono);
  font-size: 0.78rem;
  font-weight: 700;
  color: var(--amber);
  text-transform: lowercase;
  letter-spacing: 0.02em;
  margin-bottom: 0.85rem;
}
.skill-group h3::before { content: "› "; color: var(--faint); }

.tags { display: flex; flex-wrap: wrap; gap: 0.4rem; list-style: none; padding: 0; }
.tags li {
  font-family: var(--mono);
  font-size: 0.73rem;
  color: var(--dim);
  border: 1px solid var(--line);
  background: var(--surface-2);
  border-radius: 5px;
  padding: 0.2rem 0.5rem;
  line-height: 1.6;
}

/* ------------------------------------------------------------ projects -- */
.projects { display: grid; gap: clamp(1.1rem, 3vw, 1.6rem); }

.card {
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
  gap: clamp(1rem, 3vw, 2rem);
  align-items: center;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  padding: clamp(1rem, 2.5vw, 1.4rem);
  transition: border-color 0.2s ease, transform 0.2s ease;
}
.card:hover { border-color: var(--edge); transform: translateY(-2px); }
@media (max-width: 820px) { .card { grid-template-columns: 1fr; } }

.card-shot {
  display: block;
  border-radius: 8px;
  overflow: hidden;
  border: 1px solid var(--line-soft);
  background: var(--sunk);
}
.card-shot img { transition: transform 0.5s ease; }
.card-shot:hover img { transform: scale(1.02); }

.card-shot.is-empty {
  display: grid;
  place-items: center;
  min-height: 220px;
  font-family: var(--mono);
  font-size: 0.75rem;
  color: var(--faint);
  text-align: center;
  padding: 1.5rem;
  background:
    repeating-linear-gradient(45deg, var(--stripe-a) 0 10px, var(--stripe-b) 10px 20px);
  line-height: 1.9;
}

.card h3 {
  font-family: var(--mono);
  font-size: clamp(1.1rem, 2.6vw, 1.4rem);
  font-weight: 700;
  letter-spacing: -0.02em;
}
.card h3 a { color: var(--text); }
.card h3 a:hover { color: var(--green); text-decoration: none; }
.card h3 .slash { color: var(--faint); font-weight: 400; }

.card-kicker {
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--amber);
  text-transform: uppercase;
  letter-spacing: 0.09em;
  margin-bottom: 0.4rem;
}
.card p { color: var(--dim); margin-block: 0.6rem 0.9rem; font-size: 0.95rem; }
.card-links { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }

/* ------------------------------------------------------------ timeline -- */
.timeline { list-style: none; padding: 0; font-family: var(--mono); font-size: 0.85rem; }
.timeline li {
  position: relative;
  padding: 0 0 1.45rem 1.65rem;
  border-left: 1px solid var(--line);
}
.timeline li:last-child { border-left-color: transparent; padding-bottom: 0; }
.timeline li::before {
  content: "";
  position: absolute;
  left: -4.5px;
  top: 0.55rem;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--bg);
  border: 1.5px solid var(--green);
}
.tl-when { color: var(--amber); }
.tl-what { color: var(--text); font-weight: 500; margin-left: 0.6rem; }

/* A deployed project is the thing an employer is scanning for, so it gets the
   one badge on this list rather than being buried in the sentence. */
.tl-live {
  margin-left: 0.5rem;
  padding: 0.05rem 0.4rem;
  border: 1px solid var(--edge);
  border-radius: 999px;
  font-size: 0.66rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--green);
  vertical-align: 0.1em;
}
.tl-note {
  display: block;
  color: var(--dim);
  font-family: var(--sans);
  font-size: 0.9rem;
  margin-top: 0.25rem;
}

/* ------------------------------------------------------------- contact -- */
.contact-box {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: linear-gradient(160deg, var(--surface), var(--surface-2));
  padding: clamp(1.5rem, 5vw, 2.75rem);
  text-align: center;
}
.contact-box h2 {
  font-family: var(--display);
  font-optical-sizing: auto;
  font-weight: 400;
  font-size: clamp(1.7rem, 4.4vw, 2.4rem);
  letter-spacing: -0.02em;
  line-height: 1.1;
  color: var(--green);
  margin-bottom: 0.6rem;
}
.contact-box p { color: var(--dim); max-width: 48ch; margin-inline: auto; }
.contact-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.65rem;
  justify-content: center;
  margin-top: 1.6rem;
}

/* -------------------------------------------------------------- footer -- */
.footer {
  border-top: 1px solid var(--line-soft);
  margin-top: var(--section-y);
  padding-block: 1.6rem 2.5rem;
  font-family: var(--mono);
  font-size: 0.75rem;
  color: var(--faint);
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 1.5rem;
  justify-content: space-between;
}

/* --------------------------------------------------- project sub-pages -- */
/* Measured at 22px tall, under the 24px a pointer target needs. */
.back {
  font-family: var(--mono);
  font-size: 0.8rem;
  color: var(--dim);
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  margin-bottom: 1rem;
}
.page-head { padding-block: clamp(2rem, 6vw, 3.5rem) clamp(1.5rem, 4vw, 2.5rem); }
.page-head h1 {
  font-family: var(--mono);
  font-size: clamp(1.8rem, 5vw, 2.8rem);
  letter-spacing: -0.03em;
  line-height: 1.15;
}
.page-head .lede { color: var(--dim); max-width: 60ch; margin-top: 0.9rem; font-size: 1.02rem; }

.meta {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 1px;
  background: var(--line-soft);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  overflow: hidden;
  margin-block: clamp(1.5rem, 4vw, 2.25rem);
}
.meta div { background: var(--surface); padding: 0.9rem 1.1rem; font-family: var(--mono); font-size: 0.8rem; }
.meta dt { color: var(--faint); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
.meta dd { margin: 0.25rem 0 0; color: var(--text); }

.shot-full {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  margin-block: clamp(1.5rem, 4vw, 2.25rem);
}
.shot-full figcaption {
  padding: 0.7rem 1rem;
  border-top: 1px solid var(--line);
  background: var(--surface);
  font-family: var(--mono);
  font-size: 0.73rem;
  color: var(--faint);
}

/* Two screenshots of different shapes side by side: a wide phone notification
   and a tall phone screen. Each keeps its own proportions; stacked on phones. */
.shot-pair {
  display: grid;
  grid-template-columns: 1.25fr 1fr;
  align-items: start;
  gap: 1rem;
  margin-block: clamp(1.5rem, 4vw, 2.25rem);
}
.shot-pair figure {
  margin: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
}
.shot-pair img { display: block; width: 100%; height: auto; }
.shot-pair figcaption {
  padding: 0.7rem 1rem;
  border-top: 1px solid var(--line);
  background: var(--surface);
  font-family: var(--mono);
  font-size: 0.73rem;
  color: var(--faint);
}
@media (max-width: 640px) { .shot-pair { grid-template-columns: 1fr; } }

/* A phone screen recording. Narrow and centred, with the caption beside it on
   wide screens so a 390px-wide video does not leave a column of empty page. */
.shot-video {
  display: grid;
  grid-template-columns: minmax(0, 300px) 1fr;
  align-items: center;
  gap: 1.25rem;
  margin-block: clamp(1.5rem, 4vw, 2.25rem);
}
.shot-video video {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
}
.shot-video figcaption {
  font-family: var(--mono);
  font-size: 0.73rem;
  line-height: 1.6;
  color: var(--faint);
  max-width: 44ch;
}
@media (max-width: 640px) {
  .shot-video { grid-template-columns: 1fr; justify-items: center; }
  .shot-video video { max-width: 300px; }
}

.detail-head {
  margin-top: clamp(2.25rem, 5vw, 3.25rem);
  margin-bottom: 1.1rem;
  padding-bottom: 0.7rem;
  border-bottom: 1px solid var(--line-soft);
}
.detail-head h2 {
  font-family: var(--display);
  font-optical-sizing: auto;
  font-size: clamp(1.3rem, 2.9vw, 1.8rem);
  font-weight: 400;
  letter-spacing: -0.018em;
  line-height: 1.14;
  color: var(--green);
  text-wrap: balance;
}
.detail p { color: var(--dim); max-width: 68ch; }
.detail p + p { margin-top: 1rem; }
.detail ul { color: var(--dim); padding-left: 1.15rem; max-width: 68ch; }
.detail ul li { margin-bottom: 0.55rem; }
.detail ul li::marker { color: var(--green); }
.detail strong { color: var(--text); font-weight: 600; }
.detail code {
  font-family: var(--mono);
  font-size: 0.85em;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 0.1em 0.35em;
  color: var(--cyan);
}

.callout {
  border: 1px solid var(--line);
  border-left: 3px solid var(--amber);
  border-radius: 0 8px 8px 0;
  background: var(--surface);
  padding: 1rem 1.2rem;
  margin-block: 1.5rem;
  font-size: 0.93rem;
  color: var(--dim);
  max-width: 68ch;
}

/* A callout can hold more than one paragraph. Inherit the callout's own colour
   and width rather than .detail p's, which would restate a 68ch cap inside a box
   that already has one. */
.callout p { color: inherit; max-width: none; }
.callout p + p { margin-top: 0.8rem; }

/* ---------------------------------------------- the live calculator ----
   AutoCare's own money function, running on the page. Built to work with
   JavaScript off: the inputs carry the default job and the output carries
   that job's real answer, so the two agree either way and nothing needs to
   be hidden and re-shown. */
.calc {
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
  gap: clamp(1rem, 3vw, 2rem);
  align-items: start;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  padding: clamp(1rem, 3vw, 1.5rem);
  margin-block: 1.5rem;
  max-width: 68ch;
}
@media (max-width: 640px) {
  .calc { grid-template-columns: minmax(0, 1fr); }
}

.calc-head {
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--amber);
  margin-bottom: 0.6rem;
}
.calc-head + .calc-line { margin-top: 0; }
.calc-lines .calc-head:not(:first-child) { margin-top: 1.25rem; }

.calc-line {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
}
.calc-name {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 0.9rem;
  color: var(--dim);
}
.calc-x { color: var(--faint); font-family: var(--mono); }

.calc input {
  font-family: var(--mono);
  font-size: 0.86rem;
  color: var(--text);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0.35rem 0.5rem;
  text-align: right;
  min-width: 0;
  /* A number that changes under the caret should not also change width. */
  font-variant-numeric: tabular-nums;
}
.calc .calc-qty { width: 3.6rem; }
.calc .calc-price { width: 6rem; }
.calc .calc-wide { width: 6rem; }
.calc input:focus-visible {
  outline: 2px solid var(--green);
  outline-offset: 1px;
  border-color: var(--green);
}

.calc-out {
  display: block;
  border-top: 1px solid var(--text);
  font-family: var(--mono);
  font-size: 0.86rem;
}
.calc-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: baseline;
  gap: 0.75rem;
  padding: 0.42rem 0;
  border-bottom: 1px solid var(--line-soft);
}
.calc-row span { color: var(--dim); font-family: var(--sans); font-size: 0.86rem; }
.calc-row b {
  color: var(--green);
  font-weight: 500;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.calc-rule { border-bottom-color: var(--line); }
.calc-total b { color: var(--text); font-weight: 700; }

/* The status is the one output that is a word rather than a number, so it is
   the one that earns a colour. */
.calc-row b[data-status="PAID"]    { color: var(--green); }
.calc-row b[data-status="PARTIAL"] { color: var(--amber); }
.calc-row b[data-status="UNPAID"]  { color: var(--red); }

.calc-note { color: var(--dim); max-width: 68ch; }
.calc-note b {
  font-family: var(--mono);
  color: var(--text);
  font-weight: 500;
  overflow-wrap: anywhere;
}
.calc-note b[data-drift="yes"] { color: var(--red); }
.calc-note b[data-drift="no"]  { color: var(--green); }

.pager {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  margin-top: clamp(2.5rem, 6vw, 4rem);
  padding-top: 0.75rem;
  border-top: 1px solid var(--line-soft);
  font-family: var(--mono);
  font-size: 0.8rem;
}

/* The two links measured 22px tall, under the 24px a pointer target needs.
   Half of the 22px they gain is taken back off the container's padding above
   (1.5rem down to 0.75rem), which keeps the rule close to the links without
   pretending the compensation is exact: the page ends up 21px taller. */
.pager a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}

/* --------------------------------------------------- reveal on scroll --- */
.js .reveal { opacity: 0; transform: translateY(14px); }
/* The delay has to live in this shorthand. Setting transition-delay in a
   less specific rule loses to it, and the stagger silently does nothing. */
.js .reveal.in {
  opacity: 1;
  transform: none;
  transition: opacity 0.6s ease var(--d, 0ms), transform 0.6s ease var(--d, 0ms);
}
@media (prefers-reduced-motion: reduce) {
  .js .reveal { opacity: 1; transform: none; }
}

/* ---------------------------------------------------------------- print - */
@media print {
  body::before, .nav, .hero-actions, .contact-actions, .footer, .pager { display: none !important; }
  body { background: #fff; color: #111; }
  a { color: #111; }
}

/* Inline code, used sparingly in prose. */
code {
  font-family: var(--mono);
  font-size: 0.88em;
  color: var(--cyan);
}

/* ------------------------------------------------------- résumé page -- */
.cv { max-width: 820px; padding-block: clamp(2rem, 6vw, 3.5rem); }
.cv-head { display: flex; flex-wrap: wrap; gap: 1rem 2rem; align-items: flex-end; justify-content: space-between; padding-bottom: 1.25rem; border-bottom: 1px solid var(--line); }
/* The name carries the same voice here as on the homepage. Entry titles stay
   in the mono face: those are project names, the way the cards set them. */
.cv-head h1 { font-family: var(--display); font-optical-sizing: auto; font-weight: 400; font-size: clamp(1.9rem, 5vw, 2.6rem); letter-spacing: -0.02em; line-height: 1.1; }
.cv-head .role { color: var(--green); font-family: var(--mono); font-size: 0.9rem; margin-top: 0.35rem; }
.cv-contact { font-family: var(--mono); font-size: 0.8rem; color: var(--dim); text-align: right; line-height: 2; }
.cv h2 { font-family: var(--mono); font-size: 0.85rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--amber); margin-top: 2.25rem; margin-bottom: 0.9rem; }
.cv-entry { margin-bottom: 1.4rem; }
.cv-entry h3 { font-family: var(--mono); font-size: 1rem; letter-spacing: -0.01em; }
.cv-entry .when { font-family: var(--mono); font-size: 0.75rem; color: var(--faint); }
.cv-entry p { color: var(--dim); font-size: 0.95rem; margin-top: 0.3rem; max-width: 70ch; }
.cv-entry ul { color: var(--dim); font-size: 0.94rem; padding-left: 1.1rem; margin-top: 0.45rem; max-width: 70ch; }
.cv-entry ul li { margin-bottom: 0.3rem; }
.cv-entry ul li::marker { color: var(--green); }
.cv-skills { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 0.9rem 2rem; font-size: 0.92rem; color: var(--dim); }
.cv-skills b { font-family: var(--mono); font-size: 0.78rem; color: var(--text); display: block; margin-bottom: 0.15rem; }
.cv-actions { display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 2.5rem; }

@media print {
  body { background: #fff !important; color: #111 !important; }
  body::before { display: none; }
  .nav, .cv-actions, .footer { display: none !important; }
  .cv { max-width: none; padding: 0; }
  .cv-head { border-color: #ccc; }
  .cv-head h1, .cv-entry h3 { color: #111; }
  .cv-head .role { color: #17423E; }
  .cv h2 { color: #8F4413; margin-top: 1.3rem; }
  .cv-contact, .cv-entry p, .cv-entry ul, .cv-skills, .cv-entry .when { color: #333; }
  a { color: #111; text-decoration: none; }
  .cv-entry { page-break-inside: avoid; }
}

/* ----------------------------------------------------------- utilities -- */
.ok { color: var(--green); }
.lede-prose { color: var(--dim); max-width: 70ch; }
.card-links-end { margin-top: 2rem; }

/* The trailing slash in a project title reads as punctuation, not a word. */
.page-head h1 .slash { color: var(--faint); font-weight: 400; }

.contact-box p strong { color: var(--text); font-weight: 600; }

/* ==========================================================================
   Interactive pieces
   ========================================================================== */

/* ------------------------------------------------------ live terminal --- */
.term-hint {
  margin-left: auto;
  font-family: var(--mono);
  font-size: 0.7rem;
  color: var(--faint);
  white-space: nowrap;
}
.term-hint b { color: var(--green); font-weight: 500; }

.js .term-body { height: clamp(13rem, 34vh, 19rem); }

.term-body {
  max-height: min(58vh, 30rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--line) transparent;
}
.term-body::-webkit-scrollbar { width: 8px; }
.term-body::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; }

.term-out .line { white-space: pre-wrap; word-break: break-word; }
.term-out .err { color: var(--red); }
.term-out a { text-decoration: underline; text-underline-offset: 3px; }

.term-form {
  display: flex;
  align-items: baseline;
  gap: 0.1rem;
  margin-top: 0.15rem;
}
.term-form .prompt { cursor: text; }

#term-input {
  flex: 1;
  min-width: 0;
  border: 0;
  padding: 0;
  background: transparent;
  color: var(--text);
  font: inherit;
  caret-color: var(--green);
}
#term-input:focus { outline: none; }
#term-input::placeholder { color: var(--faint); }

.js .term:focus-within {
  border-color: var(--edge);
  box-shadow: var(--shadow), 0 0 0 1px rgb(126 231 135 / 18%);
}

/* --------------------------------------------------------- filter chips -- */
.filters { margin-bottom: 1.5rem; }

.filters-label {
  display: block;
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--faint);
  text-transform: uppercase;
  letter-spacing: 0.09em;
  margin-bottom: 0.6rem;
}

.filters-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }

.chip {
  font-family: var(--mono);
  font-size: 0.75rem;
  color: var(--dim);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.3rem 0.75rem;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.chip:hover { color: var(--text); border-color: var(--edge); }
.chip.is-on {
  color: var(--on-green);
  background: var(--green);
  border-color: var(--green);
  font-weight: 700;
}

.filter-status {
  font-family: var(--mono);
  font-size: 0.75rem;
  color: var(--faint);
  min-height: 1.2rem;
  margin-bottom: 0.6rem;
}
.filter-status:empty { margin-bottom: 0; }

.filter-empty {
  font-family: var(--mono);
  font-size: 0.85rem;
  color: var(--dim);
  border: 1px dashed var(--line);
  border-radius: var(--radius);
  padding: 1.5rem;
  text-align: center;
}

.linkish {
  font: inherit;
  color: var(--green);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
}

.card[hidden] { display: none; }

/* --------------------------------------------------------- copy button -- */
.btn.is-copied {
  border-color: var(--green);
  color: var(--green);
}

/* ------------------------------------------------------- reading bar ---- */
.progress {
  position: sticky;
  top: 52px;
  z-index: 35;
  height: 2px;
  background: transparent;
}
.progress span {
  display: block;
  height: 100%;
  background: var(--green);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

/* --------------------------------------------------------- back to top -- */
.to-top {
  position: fixed;
  right: clamp(1rem, 3vw, 2rem);
  bottom: clamp(1rem, 3vw, 2rem);
  z-index: 45;
  width: 42px;
  height: 42px;
  display: grid;
  place-items: center;
  border-radius: 10px;
  border: 1px solid var(--line);
  background: var(--float-bg);
  backdrop-filter: blur(8px);
  color: var(--dim);
  cursor: pointer;
  transition: color 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.to-top:hover { color: var(--green); border-color: var(--green); transform: translateY(-2px); }
.to-top svg { width: 18px; height: 18px; }
.to-top[hidden] { display: none; }

/* ------------------------------------------------------------ 404 page -- */
.notfound {
  min-height: 78vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1.25rem;
  padding-block: clamp(3rem, 10vw, 6rem);
  max-width: 720px;
}
.notfound h1 {
  font-family: var(--display);
  font-optical-sizing: auto;
  font-weight: 300;
  font-size: clamp(2rem, 6vw, 3.2rem);
  line-height: 1.02;
  letter-spacing: -0.02em;
  color: var(--green);
}
.notfound h1 em {
  font-style: italic;
  font-variation-settings: "SOFT" 40, "WONK" 1;
}

/* Direct children only. The ledger row inside carries its own colour, and a
   blanket `.notfound p` would win on source order and dim it. */
.notfound > p { color: var(--dim); }

/* The path the visitor actually asked for, set as the literal it is. */
.notfound-path {
  font-family: var(--mono);
  font-size: 0.9em;
  color: var(--text);
  overflow-wrap: anywhere;
}

/* `ls` output: keep wrapped descriptions under the description column rather
   than sliding back under the project name. */
.term-out .ls-row { padding-left: 20ch; text-indent: -20ch; }
@media (max-width: 520px) {
  .term-out .ls-row { padding-left: 0; text-indent: 0; }
}

/* A space between the prompt and what follows it, in the live input too. */
.term-form { gap: 0.55ch; }

/* -------------------------------------------------------- theme toggle -- */
/* The visible button stays 30px so the nav keeps its proportions. The pseudo
   element carries the tap target out to 44px without drawing anything, which
   is the one place where growing the box itself would have been felt. */
.theme-toggle {
  position: relative;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  flex: none;
  margin-left: 0.75rem;
  border: 1px solid var(--line);
  border-radius: 7px;
  background: var(--surface-2);
  color: var(--dim);
  cursor: pointer;
  transition: color 0.18s ease, border-color 0.18s ease;
}
.theme-toggle::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

.theme-toggle:hover { color: var(--green); border-color: var(--green); }
.theme-toggle svg { width: 15px; height: 15px; }

/* The icon shows what you will get if you press it, not what you have. */
.theme-toggle .i-moon { display: none; }
:root[data-theme="light"] .theme-toggle .i-sun { display: none; }
:root[data-theme="light"] .theme-toggle .i-moon { display: block; }

@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .theme-toggle .i-sun { display: none; }
  :root:not([data-theme="dark"]) .theme-toggle .i-moon { display: block; }
}

@media (max-width: 640px) {
  .theme-toggle { margin-left: 0.5rem; }
}

/* ----------------------------------------------------------------- certs -- */
.certs-lead {
  color: var(--dim);
  font-size: 0.93rem;
  max-width: 64ch;
  margin-bottom: 1.25rem;
}

.certs {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1rem;
  list-style: none;
  padding: 0;
}

.cert {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--line);
  border-left: 3px solid var(--green);
  border-radius: 0 var(--radius) var(--radius) 0;
  background: var(--surface);
  overflow: hidden;
}

.cert-body { padding: 1.1rem 1.25rem; }

/* The certificate itself. These are documents, not screenshots, so the whole
   page has to stay visible: object-fit is contain and the box carries its own
   pale ground rather than cropping to fill. */
.cert-shot {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  /* A fixed height, not aspect-ratio. As a column flex item the box's height
     is content-based, so aspect-ratio loses to the image and the one portrait
     certificate stretched its whole grid row. A definite height also gives the
     image's max-height something real to resolve against. */
  flex: none;
  height: 12.5rem;
  padding: 0.75rem;
  border: 0;
  border-bottom: 1px solid var(--line);
  background: var(--cert-mat);
  cursor: zoom-in;
  font: inherit;
  color: var(--faint);
}

.cert-shot img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 2px;
  box-shadow: 0 2px 10px -4px rgb(0 0 0 / 45%);
}

/* A badge is artwork with its own transparent edge, not a scanned page, so it
   takes neither the paper shadow nor the corner radius. Both trace the image's
   square bounds rather than the shield, which reads as a bug. */
.cert-shot img.is-badge {
  box-shadow: none;
  border-radius: 0;
}

/* The magnifier only appears on hover or focus, so the thumbnail reads as the
   document rather than as a control. */
.cert-zoom {
  position: absolute;
  right: 0.55rem;
  bottom: 0.55rem;
  display: grid;
  place-items: center;
  width: 1.9rem;
  height: 1.9rem;
  border-radius: 50%;
  background: var(--float-bg);
  border: 1px solid var(--line);
  color: var(--green);
  opacity: 0;
  transform: translateY(3px);
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.cert-zoom svg { width: 1rem; height: 1rem; }

.cert-shot:hover .cert-zoom,
.cert-shot:focus-visible .cert-zoom { opacity: 1; transform: none; }

.cert-shot:focus-visible {
  outline: 2px solid var(--green);
  outline-offset: -2px;
}

/* A certificate that has not been scanned says so, rather than showing a
   broken frame. Not a button: there is nothing to open. */
.cert-shot.is-empty {
  flex-direction: column;
  gap: 0.4rem;
  cursor: default;
  border-bottom: 1px dashed var(--line);
  background: var(--surface-2);
  font-family: var(--mono);
  font-size: 0.72rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.cert-shot.is-empty .empty-icon {
  width: 1.6rem;
  height: 1.6rem;
  opacity: 0.55;
}

.cert-issuer {
  text-wrap: balance;
  display: block;
  font-family: var(--mono);
  font-size: 0.7rem;
  color: var(--amber);
  text-transform: uppercase;
  letter-spacing: 0.09em;
  margin-bottom: 0.5rem;
}

.cert h3 {
  text-wrap: balance;
  font-family: var(--mono);
  font-size: 0.95rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.35;
}

.cert-meta {
  font-family: var(--mono);
  font-size: 0.73rem;
  color: var(--faint);
  margin-top: 0.5rem;
}

.certs-note {
  color: var(--dim);
  font-size: 0.93rem;
  max-width: 64ch;
  margin-top: 1.25rem;
}


/* ------------------------------------------------------------- lightbox -- */
/* One overlay, reused by every certificate. It is a plain element rather than
   <dialog>: showModal() puts the image in the top layer, where the ::backdrop
   cannot read our theme tokens, and the close animation gets awkward. This is
   less machinery for the same behaviour. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: grid;
  grid-template-rows: auto 1fr auto;
  gap: 0.75rem;
  padding: clamp(0.75rem, 2.5vw, 1.5rem);
  background: var(--overlay);
  backdrop-filter: blur(6px);
}

.lightbox-bar {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  max-width: var(--page);
  width: 100%;
  margin: 0 auto;
}

.lightbox-caption {
  font-family: var(--mono);
  font-size: 0.8rem;
  line-height: 1.5;
  color: var(--text);
}

.lightbox-caption b { display: block; color: var(--green); }
.lightbox-caption span { color: var(--dim); font-size: 0.74rem; }

.lightbox-close {
  flex: none;
  display: grid;
  place-items: center;
  width: 2.4rem;
  height: 2.4rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--float-bg);
  color: var(--text);
  cursor: pointer;
}

.lightbox-close svg { width: 1.15rem; height: 1.15rem; }
.lightbox-close:hover { border-color: var(--edge); color: var(--green); }

.lightbox-stage {
  display: grid;
  place-items: center;
  min-height: 0;
}

.lightbox-stage img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

.lightbox-hint {
  text-align: center;
  font-family: var(--mono);
  font-size: 0.7rem;
  color: var(--faint);
}

.lightbox-hint kbd {
  font-family: inherit;
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 0.05rem 0.3rem;
  background: var(--surface-2);
}

/* A touch visitor has no Esc key and no hover, so the hint is pointless there
   and the close button does the work. */
@media (hover: none) {
  .lightbox-hint { display: none; }
}

/* The page behind the lightbox must not scroll under it. */
body.is-locked { overflow: hidden; }

/* ==========================================================================
   Motion

   Three rules govern everything below.

   1. Nothing here is load-bearing. Every animated element is readable in its
      finished state, and `prefers-reduced-motion` turns the lot off.
   2. Only transform and opacity are animated, so the compositor does the work
      and nothing triggers layout.
   3. Movement is small and quick. A portfolio that makes a recruiter wait for
      a card to slide in is a portfolio they close.
   ========================================================================== */

/* Staggered reveal: `--d` is set from a data-i attribute in site.js and read
   by the .reveal.in shorthand above, so a row of cards arrives in sequence
   rather than all at once. */

/* Filtering swaps `hidden`, which display:none cancels transitions through.
   So a card that comes back plays a one-shot animation instead. */
@keyframes card-in {
  from { opacity: 0; transform: translateY(10px) scale(0.995); }
  to   { opacity: 1; transform: none; }
}
.js .card.is-entering { animation: card-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both; }

/* Buttons and chips acknowledge a press. Small enough to feel mechanical
   rather than bouncy. */
.btn:active,
.chip:active,
.theme-toggle:active,
.to-top:active { transform: translateY(0) scale(0.97); }

.btn, .chip, .theme-toggle, .to-top { will-change: transform; }

/* The active nav link grows an underline from the left. */
.nav-links a {
  position: relative;
  padding-bottom: 2px;
}
.nav-links a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 1px;
  background: var(--green);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
}
.nav-links a[aria-current="true"]::after,
.nav-links a:hover::after { transform: scaleX(1); }
.nav-links a:hover { text-decoration: none; }

/* Project cards already move on hover. The screenshot inside gets a slower
   ease so the two do not travel together. The portrait no longer drifts: a
   moving, scaled photograph fought the stillness the rest of the page needs. */
.card-shot img { transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1); }

/* Certificates pick up the same treatment as cards. */
.cert { transition: border-color 0.2s ease, transform 0.2s ease; }
.cert:hover { border-color: var(--edge); transform: translateY(-2px); }

/* ------------------------------------------------ the certificate drift --
   The certificates are the one section asked to feel like a showcase, so the
   cards drift rather than sit still.

   Three things keep it from being a nuisance. It animates `translate`, not
   `transform`, so the hover lift above still composes instead of fighting it.
   The amplitude is small and the periods are long and mutually prime, so the
   cards never fall into step and never draw the eye away from the text beside
   them. And it is a compositor-only property, so seven cards drifting costs no
   layout and no paint.

   It is behind .js because a visitor without JavaScript gets the static grid,
   which is the honest fallback rather than a degraded one. */
@keyframes cert-drift {
  50% { translate: 0 -8px; }
}

.js .cert {
  animation: cert-drift 9s ease-in-out infinite;
  will-change: translate;
}
.js .cert:nth-child(7n + 2) { animation-duration: 11s;   animation-delay: -1.7s; }
.js .cert:nth-child(7n + 3) { animation-duration: 13s;   animation-delay: -4.1s; }
.js .cert:nth-child(7n + 4) { animation-duration: 10s;   animation-delay: -6.3s; }
.js .cert:nth-child(7n + 5) { animation-duration: 12.5s; animation-delay: -2.9s; }
.js .cert:nth-child(7n + 6) { animation-duration: 9.5s;  animation-delay: -7.5s; }
.js .cert:nth-child(7n + 7) { animation-duration: 14s;   animation-delay: -5.2s; }

/* Hovering one should let you read it, so that card settles while its
   neighbours carry on. */
.js .cert:hover,
.js .cert:focus-within {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  .js .cert { animation: none; will-change: auto; }
}

/* Switching theme should look like a dimmer, not a strobe. The class is added
   for the length of the fade and then removed, so the transition never
   interferes with anything else. */
.theme-switching,
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
  transition: background-color 0.3s ease,
              color 0.3s ease,
              border-color 0.3s ease,
              box-shadow 0.3s ease !important;
}

@media (prefers-reduced-motion: reduce) {
  .js .card.is-entering { animation: none; }
  .js .reveal { transition-delay: 0ms; }
  .btn:active, .chip:active, .theme-toggle:active, .to-top:active { transform: none; }
  .nav-links a::after { transition: none; }
  .theme-switching, .theme-switching * { transition: none !important; }
}

/* ---------------------------------------------------------- printed CV -- */
@media print {
  @page { size: A4; margin: 13mm 14mm; }

  /* Denser than the screen version. A résumé that runs to three pages because
     of leading is a résumé nobody finishes. */
  body { font-size: 9.5pt; line-height: 1.3; }

  /* Name on the left, contact details on the right, on one line. */
  .cv-head { flex-wrap: nowrap; align-items: flex-start; gap: 2rem; }
  .cv-head h1 { font-size: 20pt; }
  .cv-head .role { font-size: 9.5pt; margin-top: 0.2rem; }

  /* Paper has no hover and no click, so the layout can be denser and the
     links have to say where they go. */
  .cv-head { padding-bottom: 0.55rem; }
  .cv h2 { margin-top: 0.5rem; margin-bottom: 0.22rem; font-size: 8pt; letter-spacing: 0.12em; }
  .cv-entry { margin-bottom: 0.32rem; break-inside: avoid; page-break-inside: avoid; }
  .cv-entry h3 { font-size: 10pt; }
  .cv-entry .when { font-size: 8pt; }
  .cv-entry p, .cv-entry ul { font-size: 9pt; margin-top: 0.15rem; }
  .cv-entry ul { padding-left: 0.9rem; }
  .cv-entry ul li { margin-bottom: 0.02rem; }
  /* The skills grid is allowed to break. Held together it is one 213pt block
     that will not fit in whatever is left of page two, so it was being pushed
     whole onto a third page. Its rows are short and self-contained, so a break
     between them costs nothing. */
  .cv-skills { gap: 0.25rem 1.4rem; font-size: 8.5pt; }
  .cv-skills > div { break-inside: avoid; page-break-inside: avoid; }

  /* Seven certifications set as one column is most of a page of short lines.
     Two columns on paper keeps the CV at two pages, which is the point of
     this whole block. */
  .cv-certs { columns: 2; column-gap: 1.4rem; }
  .cv-certs li { break-inside: avoid; page-break-inside: avoid; }
  .lede-prose { font-size: 9pt; max-width: none; }
  .cv-contact { line-height: 1.5; font-size: 8.5pt; }
  .cv h2, .cv-entry h3 { break-after: avoid; page-break-after: avoid; }
}

/* ------------------------------------------------------------- security -- */
.secgrid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1rem;
  margin-top: 1.75rem;
}

.seccard {
  border: 1px solid var(--line);
  border-top: 2px solid var(--green);
  border-radius: 0 0 var(--radius) var(--radius);
  background: var(--surface);
  padding: 1.15rem 1.25rem 1.25rem;
  transition: border-color 0.2s ease, transform 0.2s ease;
}
.seccard:hover { border-color: var(--edge); border-top-color: var(--green); transform: translateY(-2px); }

.seccard h3 {
  font-family: var(--mono);
  font-size: 0.92rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  margin-bottom: 0.6rem;
}

.seccard p { color: var(--dim); font-size: 0.92rem; }

.secmeta {
  font-family: var(--mono);
  font-size: 0.71rem;
  color: var(--faint);
  margin-top: 0.75rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--line-soft);
}

/* ---------------------------------------------- placeholder screenshots --
   Some projects genuinely cannot be screenshotted from here: no Flutter SDK,
   no Unity, and a capstone that is still being built. The tile should read as
   a deliberate panel rather than a picture that failed to load.             */
/* A tile for a project that genuinely cannot be screenshotted from here: no
   Flutter SDK, no Unity, a capstone still being built, and one site that is
   live but unreachable from this build environment. It has to read as a
   deliberate panel. A dark box inside a light card reads as a failed image. */
.card-shot.is-empty {
  gap: 0.35rem;
  padding: 2rem 1.5rem;
  border: 1px dashed var(--line);
  background: var(--surface-2);
  color: var(--dim);
  text-align: center;
  line-height: 1.6;
}
.card-shot.is-empty .empty-icon {
  width: 26px;
  height: 26px;
  color: var(--faint);
  margin-bottom: 0.35rem;
}
.card-shot.is-empty b {
  font-family: var(--mono);
  font-size: 0.85rem;
  font-weight: 700;
  color: var(--text);
}
.card-shot.is-empty span {
  font-family: var(--sans);
  font-size: 0.83rem;
  max-width: 34ch;
}

/* ------------------------------------------------- touch target sizes --
   Controls that read correctly at their drawn size under a mouse, where the
   floor is 24px and they clear it, but that a thumb would struggle with.
   They grow only where the pointer is coarse, so the desktop density the rest
   of this page depends on is untouched.

   Measured on a 390px phone before this: filter chips 26px tall, the
   calculator inputs 29px, the resume contact links 15px. */
@media (pointer: coarse) {
  .chip {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
  .calc input {
    min-height: 44px;
  }
  .cv-contact a {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
}

/* A printed resume has no pointer at all, so none of that applies to it. */
@media print {
  .cv-contact a { display: inline; min-height: 0; }
}

