/* ── Fonts ─────────────────────────────────────────────────────────── */
@font-face {
  font-family: "EB Garamond";
  src: url("fonts/eb-garamond-regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "EB Garamond";
  src: url("fonts/eb-garamond-italic.woff2") format("woff2");
  font-weight: 400; font-style: italic; font-display: swap;
}
@font-face {
  font-family: "EB Garamond";
  src: url("fonts/eb-garamond-semibold.woff2") format("woff2");
  font-weight: 600; font-style: normal; font-display: swap;
}

/* ── Tokens ────────────────────────────────────────────────────────── */
:root {
  /* Sampled from the source deck — do not invent new values. */
  --bg: #16241C;
  --bg-edge: #101914;
  --sage: #87A96B;
  --sage-light: #A8C09A;
  --panel: #4F6F52;
  --coral: #EA805E;
  --fg: #F5F3EC;
  --fg-muted: #A8BC95;

  --serif: "EB Garamond", Georgia, "Times New Roman", serif;
  --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;

  /* Slides are composed on a fixed 1600x900 stage and scaled to fit, so
     every size below is in stage pixels and holds its proportion at any
     viewport. */
  --stage-w: 1600;
  --stage-h: 900;

  --step-duration: 420ms;
  --step-ease: cubic-bezier(0.22, 1, 0.36, 1);
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--serif);
  overflow: hidden;
}

/* ── Background canvas ─────────────────────────────────────────────── */
#bg-canvas {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  /* Keeps the centre clear so drifting particles never fight slide text. */
  mask-image: radial-gradient(ellipse 52% 58% at 50% 50%, transparent 0%, transparent 18%, black 68%);
  -webkit-mask-image: radial-gradient(ellipse 52% 58% at 50% 50%, transparent 0%, transparent 18%, black 68%);
}

/* ── Stage ─────────────────────────────────────────────────────────── */
#deck {
  position: relative;
  z-index: 1;
  height: 100dvh;
  display: flex;
  flex-direction: column;
}

#slide-host {
  flex: 1;
  display: grid;
  /* `start`, NOT `center`. fitStage() computes an explicit centering
     translate, which assumes the stage's untransformed box starts at the
     host's top-left corner. `place-items: center` also centers it — but only
     when the host is bigger than the stage's 1600x900 box, so the two
     centerings silently agreed on any viewport under 1600px wide and
     double-counted on anything larger. At 1920x1080 that pushed the stage
     220px right and 100px off the right edge; at 2560x1440, 540px right.
     Fullscreen on a normal monitor is exactly where it bit. */
  place-items: start;
  min-height: 0;
  cursor: pointer;
}

/* The stage is a fixed 1600x900 box scaled by JS to fit the viewport. Fixing
   the coordinate space means a slide's composition is identical everywhere
   instead of reflowing at each breakpoint. */
.stage {
  position: relative;
  width: 1600px;
  height: 900px;
  /* fitStage() (deck.js) now computes an explicit translate to center the
     scaled box, so scaling must happen from the stage's own top-left corner
     first — center-origin scaling combined with a top-left-anchored
     translate would double-count the centering and misplace the stage. See
     the comment above fitStage() for why `place-items: center` on
     `#slide-host` can't do this centering on its own. */
  transform-origin: top left;
  background: radial-gradient(ellipse at center, var(--bg) 55%, var(--bg-edge) 100%);
}

.slide {
  position: absolute;
  inset: 0;
  padding: 70px 110px;
  display: none;
  flex-direction: column;
}
.slide.is-current { display: flex; }

/* ── Type scale (stage pixels) ─────────────────────────────────────── */
.slide h2 {
  margin: 0 0 48px;
  font-weight: 400;
  font-size: 68px;
  line-height: 1.1;
  text-align: center;
  letter-spacing: 0.01em;
}

.slide .lead { font-size: 40px; line-height: 1.5; }
.slide ul { list-style: none; margin: 0; padding: 0; font-size: 36px; line-height: 1.9; }
.slide li { padding-left: 52px; position: relative; }
/* Drawn, not typed: U+25C7 (WHITE DIAMOND) sits outside the self-hosted EB
   Garamond's Latin subset, so it fell back through Georgia and Times New
   Roman — both Microsoft fonts absent from most Android/Linux systems — to
   generic `serif`, which doesn't guarantee Geometric Shapes coverage. Renders
   fine on the author's Windows machine; risks tofu for the QR-scanning phone
   audience. A rotated, bordered box has no font dependency at all. */
.slide li::before {
  content: "";
  position: absolute; left: 4px; top: 0.34em;
  width: 0.4em; height: 0.4em;
  border: 2px solid var(--sage);
  box-sizing: border-box;
  transform: rotate(45deg);
}
/* Three slides carry only a few short lines each and were filling well under
   three-quarters of the stage — measured: Human in the Loop 46%, Conclusion
   66%, Keep Learning 72%. On a projector that spare height is worth more as
   legible type than as empty space, so each gets a size matched to how much
   room it actually has rather than all sharing the 36px base.

   Two classes each (0,2,1) so they outrank the base `.slide ul` (0,1,1)
   outright instead of tying it and falling back to source order. */
/* Only three short lines, so even at 46px this one still leaves ~320px over.
   `margin: auto 0` centres the list in that leftover space rather than
   letting it all pool beneath — the same treatment slide 13 gets. Inflating
   the type further to fill the gap would just look shouty. */
.slide.slide--loop ul { font-size: 46px; margin: auto 0; }
.slide.slide--conclusion ul { font-size: 42px; }
.slide ul.paired { font-size: 42px; }

.slide em { color: var(--sage-light); font-style: italic; }
.slide strong { color: var(--sage-light); font-weight: 600; }
.slide code { font-family: var(--mono); font-size: 0.85em; color: var(--sage-light); }

/* ── Build steps ───────────────────────────────────────────────────── */
/* Not-yet-revealed content stays in the accessibility tree and in the layout;
   only its paint is suppressed. A screen reader user gets the whole slide
   rather than being paced by the presenter's clicks. */
[data-step] {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity var(--step-duration) var(--step-ease),
              transform var(--step-duration) var(--step-ease);
}
[data-step].is-revealed { opacity: 1; transform: none; }

/* The mirror of the above: visible from the start, hidden once its step is
   reached. Slide 8's messy kitchen fades out to reveal the clean one behind
   it. Like unrevealed content, it stays in the accessibility tree — a screen
   reader user gets both photographs described, which is exactly the
   before-and-after the slide is making. */
[data-step-out] {
  opacity: 1;
  transition: opacity var(--step-duration) var(--step-ease);
}
[data-step-out].is-stepped-out { opacity: 0; }

/* ── Controls ──────────────────────────────────────────────────────── */
.controls {
  display: flex; align-items: center; justify-content: center;
  gap: 1rem; padding: 0.75rem; flex-wrap: wrap;
}
.controls button, .controls .download {
  min-width: 44px; min-height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0.5rem 0.9rem;
  background: rgb(255 255 255 / 0.06);
  color: var(--fg);
  border: 1px solid rgb(255 255 255 / 0.12);
  border-radius: 6px;
  font: inherit; font-size: 0.95rem;
  text-decoration: none; cursor: pointer;
}
.controls button:hover, .controls .download:hover { background: rgb(255 255 255 / 0.12); }
.controls button[disabled] { opacity: 0.3; cursor: default; }

:where(a, button):focus-visible { outline: 3px solid var(--sage); outline-offset: 3px; }

.visually-hidden:where(:not(:focus-within, :active)) {
  position: absolute !important;
  clip-path: inset(50%) !important;
  overflow: hidden !important;
  width: 1px !important; height: 1px !important;
  margin: -1px !important; padding: 0 !important; border: 0 !important;
  white-space: nowrap !important;
}

/* ── Reduced motion ────────────────────────────────────────────────── */
/* Removes animation, never information: revealed content still appears, it
   simply appears instantly. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
  [data-step] { transform: none; }
}

/* ── Progress dots ─────────────────────────────────────────────────── */
/* The progress indicator uses the background's own vocabulary: dots on a
   line, lit as the deck advances. */
#progress {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 0.75rem;
  position: relative;
}
#progress::before {
  content: "";
  position: absolute;
  left: 0.75rem; right: 0.75rem; top: 50%;
  height: 1px;
  background: rgb(163 177 138 / 0.25);
}
/* `.progress-dot` alone (0-1-0) loses to the pre-existing `.controls button`
   rule (0-1-1) on every shared property — min-width/height, border-radius,
   border, background — since these buttons live inside <nav class="controls">.
   Scoping to `.controls .progress-dot` (0-2-0) wins instead. The 24px box is
   the touch target (meets WCAG 2.2's 24x24 minimum); the 9px dot people
   actually see is a separate ::after so the hit area and the paint can differ
   without fighting `.controls button`'s 44px minimum, which stays intact for
   Prev/Next. */
.controls .progress-dot {
  position: relative;
  width: 24px; height: 24px;
  min-width: 24px; min-height: 24px;
  padding: 0; border: 0; border-radius: 50%;
  background: none;
  cursor: pointer;
}
.controls .progress-dot::after {
  content: "";
  display: block;
  margin: auto;
  width: 9px; height: 9px;
  border-radius: 50%;
  background: rgb(163 177 138 / 0.3);
  transition: background var(--step-duration) var(--step-ease),
              box-shadow var(--step-duration) var(--step-ease);
}
.controls .progress-dot.is-passed::after {
  background: var(--sage);
  box-shadow: 0 0 8px rgb(135 169 107 / 0.7);
}
.controls .progress-dot[aria-current="true"]::after { transform: scale(1.5); }
.controls .progress-dot:focus-visible { outline: 2px solid var(--sage); outline-offset: 3px; }

/* `#progress` is a fixed ~558px row (16 dots, gaps, padding) with its own
   internal `flex-wrap: nowrap` — at a phone width (measured: clipped from
   x=-91.5 to x=466.5 at a 375px viewport) it overflows both edges and is
   clipped, not wrapped, by `html, body { overflow: hidden }`. Wrapping it
   onto multiple rows would still leave 44px-adjacent touch targets fighting
   for space at 24px each; the dots are a navigation aid, not content — Prev/
   Next and the "All slides" overview already reach every slide, so hiding
   the row below the breakpoint loses no capability. `display: none` (not
   just visually hiding) also drops the 16 dots from tab order, so a phone's
   screen-reader/keyboard user isn't made to Tab past controls that render
   nothing. #progress (1,0,0) has no other rule at the same specificity
   setting `display`, so this doesn't fight anything already in the file. */
@media (max-width: 640px) {
  #progress { display: none; }
}

/* ── Load error ────────────────────────────────────────────────────── */
/* Shown on the stage (in stage-pixel coordinates, like everything else in
   .stage) when init() throws — see deck.js's init().catch handler. */
.load-error {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  padding: 0 160px;
  color: var(--fg);
  font-size: 32px;
  line-height: 1.5;
  text-align: center;
}

/* ── Text slides (3, 4, 6, 9, 10, 15, 16) ─────────────────────────────── */
/* Statement slides (3, 4): a question, then the answer lands alone. The
   held beat before the answer is the point of these slides. */
.slide--statement { justify-content: center; align-items: center; text-align: center; }
.slide--statement h2 { font-size: 84px; margin-bottom: 0; }
.slide--statement .answer {
  margin: 56px 0 0;
  font-size: 110px;
  font-style: italic;
  color: var(--sage);
}

.slide .aside { margin-top: auto; font-size: 30px; }
.slide .aside a { color: var(--sage-light); text-decoration-thickness: 1px; text-underline-offset: 4px; }

/* Keep Learning pairs a prompt with its consequence, revealed in turn. */
.slide ul.paired li { margin-bottom: 28px; }
.slide ul.paired span { display: block; padding-left: 1.5em; color: var(--fg-muted); font-style: italic; }

/* The closing slide carries the deck's own QR, so the room can scan it while
   the presenter is taking questions — this is the slide that stays up longest.
   The URL is spelled out underneath as a real link: not everyone scans, and a
   readable address is the fallback. `alt=""` on the QR because that link is
   right beneath it, so a screen reader announcing both would just repeat
   itself. */
.slide--closing { justify-content: center; align-items: center; }
.slide--closing h2 { font-size: 110px; margin: 0 0 44px; }
.slide--closing .closing-qr { display: grid; justify-items: center; gap: 22px; }
/* Bigger than the in-slide QRs on slides 6 and 7 (300px): those sit beside a
   readable URL on a slide the presenter talks over, whereas this one is meant
   to be scanned from the back of the room while the deck sits on screen.
   Angular module size is what decides whether that works. */
.slide--closing .card-qr { width: 400px; height: 400px; }
.slide--closing .closing-qr a {
  color: var(--sage-light);
  font-size: 34px;
  text-decoration-thickness: 1px;
  text-underline-offset: 5px;
}

/* ── Slide 1 ───────────────────────────────────────────────────────── */
.slide--title { justify-content: center; }
.slide--title h2 { text-align: left; font-size: 92px; margin: 0 0 18px; }
.slide--title .title-sub {
  margin: 0; padding-left: 24px;
  font-size: 40px; font-style: italic; color: var(--fg-muted);
}
.slide--title .title-credits {
  margin-top: auto; text-align: right;
  font-size: 30px; line-height: 1.5;
}
.slide--title .title-credits p { margin: 0; }
.slide--title .title-credits .name { color: var(--sage-light); font-size: 34px; }
.slide--title .title-credits .doctor { font-style: italic; color: var(--fg-muted); }

/* ── Slide 5: the workflow ─────────────────────────────────────────── */
/* An ordered list because the content genuinely is a sequence — the
   numbering carries information a reader needs. */
/* Fix round 1: the source deck's diagram is a two-row serpentine (down the
   left, along the bottom, up, along the top, down, along the bottom, up to
   Ship) — not one flat row. A flat row makes the up/down arrows meaningless.
   7 columns (node, arrow, node, arrow, node, arrow, node) x 3 rows (top
   nodes, vertical arrows, bottom nodes). The HTML stays in build order
   (node, arrow, node, ... — that order is correct and unchanged); the grid
   placement below is what maps that flat build sequence onto the two-row
   shape, via :nth-child so markup order and visual position can differ. */
/* This slide is the tightest vertical budget in the deck: two rows of circles
   plus an arrow row, and the two large nodes visually overflow their rows by
   14% each because they are scaled rather than sized. Trimming the heading
   gap here buys the ~24px that lets every circle grow. */
.slide--workflow h2 { margin-bottom: 24px; }

.slide--workflow .flow {
  list-style: none; margin: 0; padding: 0;
  flex: 1;
  display: grid;
  /* Node columns are a fixed 182 stage-px (matched to the source deck's own
     proportions — see the fix-round-1 report for how that number was
     derived from the source PNG), not a content-driven `fr`: a bare 1fr
     (= minmax(auto, 1fr)) lets a long label's min-content width push its
     own column wider than its neighbours', which is why the six "same
     size" circles previously rendered at different sizes (measured
     114/84/100/99/82/82/82/82px). A fixed px width makes every node column
     exactly equal regardless of label length; overflow is handled inside
     the node (font-size/line-height/padding), never by the track. The
     three arrow columns share whatever width is left over (minmax(0, 1fr)
     floors at 0 so, again, nothing can push a track wider than its share). */
  grid-template-columns: 230px minmax(0, 1fr) 230px minmax(0, 1fr) 230px minmax(0, 1fr) 230px;
  grid-template-rows: auto 68px auto;
  align-items: center;
  /* The grid's own content (~637 stage-px tall) is shorter than the
     flex-grown .flow box (~685 stage-px, whatever's left under the
     heading), so without this the three rows pin to the top of that box
     and leave a dead gap at the bottom instead of sitting centered under
     "My Workflow". */
  align-content: center;
  row-gap: 4px;
}
/* Build order (markup) -> diagram position (grid), per the source layout:
   1 Problem/Goal, 2 down, 3 Research, 4 right, 5 Brainstorm, 6 up,
   7 Implement, 8 right, 9 Test, 10 down, 11 PR Review, 12 both, 13 Fix,
   14 up, 15 Ship. */
.slide--workflow .flow > :nth-child(1)  { grid-column: 1; grid-row: 1; }
.slide--workflow .flow > :nth-child(2)  { grid-column: 1; grid-row: 2; }
.slide--workflow .flow > :nth-child(3)  { grid-column: 1; grid-row: 3; }
.slide--workflow .flow > :nth-child(4)  { grid-column: 2; grid-row: 3; }
.slide--workflow .flow > :nth-child(5)  { grid-column: 3; grid-row: 3; }
.slide--workflow .flow > :nth-child(6)  { grid-column: 3; grid-row: 2; }
.slide--workflow .flow > :nth-child(7)  { grid-column: 3; grid-row: 1; }
.slide--workflow .flow > :nth-child(8)  { grid-column: 4; grid-row: 1; }
.slide--workflow .flow > :nth-child(9)  { grid-column: 5; grid-row: 1; }
.slide--workflow .flow > :nth-child(10) { grid-column: 5; grid-row: 2; }
.slide--workflow .flow > :nth-child(11) { grid-column: 5; grid-row: 3; }
.slide--workflow .flow > :nth-child(12) { grid-column: 6; grid-row: 3; }
.slide--workflow .flow > :nth-child(13) { grid-column: 7; grid-row: 3; }
.slide--workflow .flow > :nth-child(14) { grid-column: 7; grid-row: 2; }
.slide--workflow .flow > :nth-child(15) { grid-column: 7; grid-row: 1; }
.slide--workflow .node {
  aspect-ratio: 1;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--sage);
  border: 3px solid var(--fg);
  color: var(--bg);
  text-align: center;
  /* Sized up from 19px: at 182px node width this is the largest that still
     fits "Pull Request Review" on three lines without touching the circle. */
  font-size: 32px; line-height: 1.15;
  padding: 6px;
}
/* .node--lg (0,2,0) and the base [data-step] (0,1,0) both set `transform`.
   .node--lg wins pre-reveal purely on specificity, so the two big nodes
   would pop straight to their final scale with no translateY entrance —
   fine on its own, except [data-step].is-revealed (0,2,0) ties .node--lg
   (0,2,0) on specificity, and appears earlier in the cascade, so source
   order alone would decide the post-reveal state and is one file-reorder
   away from silently dropping the scale. Setting both states explicitly
   here (translateY folded into the pre-reveal scale, dropped on reveal
   the same way the base rule drops it for every other element) keeps the
   entrance motion AND the final scale, regardless of rule order elsewhere
   in the file. */
/* Sized, not scaled. `transform: scale()` doesn't affect layout, so the two
   large nodes used to overflow their grid row and paint over the vertical
   arrows beneath them. Explicit width/height makes the row grow to fit, and
   `justify-self: center` lets them spill sideways into the neighbouring arrow
   column — harmless, because the arrows there sit in the *other* row.

   This also deletes a specificity hack: the old scale had to be restated on
   `.is-revealed`, and again for reduced motion, purely to stop the shared
   `[data-step]` transform from wiping it. With the emphasis carried by size
   instead, the large nodes animate like every other element and keep their
   prominence when motion is off. */
.slide--workflow .node--lg {
  width: 290px; height: 290px;
  justify-self: center;
  font-size: 50px;
}
/* Arrows get their own fixed footprint instead of stretching to fill a
   grid cell (`justify-self: center` overrides the grid's default stretch),
   so each renders as a compact, solid glyph — roughly as tall as wide —
   at any column/row size, rather than a long thin wedge. */
.slide--workflow .arrow { justify-self: center; background: var(--sage-light); }
.slide--workflow .arrow--right { width: 60px; height: 58px; clip-path: polygon(0 0, 100% 50%, 0 100%); }
.slide--workflow .arrow--down  { width: 54px; height: 52px; clip-path: polygon(0 0, 100% 0, 50% 100%); }
.slide--workflow .arrow--up    { width: 54px; height: 52px; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
/* The original polygon connected the four arrowhead corners directly
   (0 50%, 22% 15%, 22% 85%, 78% 15%, 78% 85%, 100% 50%) with no shaft
   points in between, so the outline crossed itself between the two
   arrowheads and rendered as a zigzag instead of a double-headed arrow.
   This version adds a shaft band (35%/65%) and traces the outline in
   winding order — tip, head corner, shaft corner, shaft corner, head
   corner, tip, head corner, shaft corner, shaft corner, head corner —
   so the two triangular heads are joined by a rectangular shaft with no
   self-intersection. */
.slide--workflow .arrow--both  {
  width: 130px; height: 58px;
  clip-path: polygon(
    0 50%, 22% 12%, 22% 35%, 78% 35%, 78% 12%, 100% 50%,
    78% 88%, 78% 65%, 22% 65%, 22% 88%
  );
}
/* `.slide li::before` paints the ◇ bullet used by the plain bullet lists
   (slides 6, 9, 10, 15) onto every <li> in .slide, including these nodes
   and arrows — an unrelated list style bleeding onto a diagram that isn't
   a bulleted list. (0,2,2) beats the base rule's (0,1,2) outright, so this
   wins regardless of source order. */
.slide--workflow .flow li::before { content: none; }

/* ── Slide 2: the agenda ───────────────────────────────────────────── */
/* Numbered because an agenda genuinely is a sequence — these are the three
   acts of the talk, in the order they run, so the numerals carry information
   rather than decorate. An <ol> for the same reason.

   Named `agenda`, not `overview`, deliberately: `.overview` and
   `.overview-grid` already belong to the thumbnail-grid overlay, and reusing
   that word here would make two unrelated things look related. */
.slide--agenda .agenda {
  list-style: none;
  margin: 0; padding: 0;
  flex: 1;
  display: flex; flex-direction: column; justify-content: center;
  gap: 52px;
}
/* `.slide li` (0,1,1) sets the 52px indent that makes room for the diamond
   bullet, and `.slide li::before` (0,1,2) draws it. Both are outranked here
   (0,2,1 and 0,2,2), so the number takes the marker's place outright rather
   than sitting next to an orphaned diamond. */
.slide--agenda .agenda li {
  padding-left: 0;
  display: grid;
  grid-template-columns: 150px 1fr;
  align-items: baseline;
  font-size: 54px;
  line-height: 1.25;
}
.slide--agenda .agenda li::before { content: none; }
.slide--agenda .agenda-num {
  font-size: 44px;
  color: var(--sage);
  font-variant-numeric: tabular-nums;
}

/* ── Slides 12, 13, 14: the side project ──────────────────────────────── */
.slide .project { font-size: 34px; }
.slide .project-name { font-size: 46px; color: var(--sage-light); margin: 0 0 18px; }
.slide .project-label { margin: 28px 0 8px; color: var(--fg-muted); }
/* 0.82em of an already-nested 0.82em list left this at 24px — the smallest
   real content anywhere in the deck, on a slide that only had ~60px of spare
   height to give back. 0.92em lifts it to ~27px without changing how the
   line wraps. */
.slide .detail { color: var(--fg-muted); font-size: 0.92em; }

/* Nested sub-bullets (slides 12, 14): source PNGs nest "Migrate…"/"Fix…"
   under "Requirements:" and four items under "UI/UX design". `.slide li > ul`
   is (0,1,2), which beats the base `.slide ul` (0,1,1) on specificity alone —
   deterministic regardless of where this rule sits in the file, same
   reasoning as the slide 13 transform fix above. Font-size is relative (em)
   so it composes with the parent list's own size instead of hard-coding a
   px value that would silently stop matching if a parent size ever changes.
   Line-height is overridden too: slide 14's fourth sub-bullet ("Page Designs
   for Different Content Types (Listings, articles, events, venue hire)") is
   long enough to wrap regardless of spacing (measured: it needs ~1245
   stage-px of text at ~384 stage-px available, so line-height can't change
   the wrap count) — but the base `.slide ul` line-height of 1.9, inherited
   here, spaced those wrapped lines far apart, where the author's original
   has them tight. This only tightens the vertical gap between the wrapped
   lines, which is the actual defect; it doesn't and can't change how many
   lines it wraps to. */
.slide li > ul {
  margin: 8px 0 0 44px;
  font-size: 0.82em;
  line-height: 1.3;
}

/* Colour: the source deck renders slide 12's bullets gold. The author has
   confirmed sage instead, for consistency with the other fifteen slides —
   an authorised, deliberate departure from the source, not an oversight.
   No new colour literal is introduced: `.slide li::before` already paints
   every bullet marker `var(--sage)` and body text already inherits `--fg`,
   so slides 12 and 14 need no colour rule of their own to be on-palette. */

/* Both screenshots sit on the dark stage background, so a subtle frame
   (reusing the same --sage-light border the slide 13 cells use) keeps their
   bright rectangles from looking like they're floating loose, and a small
   border-radius softens the hard edges against the surrounding dark space. */
.slide .project-shot,
.slide .wip-shots img {
  border-radius: 12px;
  border: 1px solid var(--sage-light);
}

/* ── Slide 12 ──────────────────────────────────────────────────────── */
/* Centred, not top-aligned: the content is shorter than the stage, and
   pinning it to the top dumped all the slack into one large empty band under
   it. Centring splits that slack evenly above and below so it reads as
   deliberate margin instead of a gap where something is missing. */
.slide--project .project-body {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 56px;
}
/* A little under half the content width, matching the source proportions.
   `height: auto` is required, not optional: the `width`/`height` HTML
   attributes on the <img> map to actual CSS `width`/`height` (not `auto`),
   so without this the browser holds the 719px attribute height literally
   while shrinking only the width to 620px, distorting the image and
   pushing it well past the bottom of the stage. */
.slide--project .project-shot {
  width: 700px;
  height: auto;
  flex: none;
}
.slide--project .project { flex: 1; }

/* ── Slide 14 ──────────────────────────────────────────────────────── */
/* Two real columns — list left, screenshots right, both vertically centred —
   rather than the screenshots being absolutely positioned into the bottom
   right corner, where they read as jammed against the edge.

   Specificity note: `.slide.is-current` (0,2,0) sets `display: flex`, so a
   bare `.slide--wip` (0,1,0) could never override it and a
   `.slide--wip.is-current` (0,2,0) would only win on source order. Three
   classes puts this at (0,3,0) so it wins outright, whatever moves. */
.slide.slide--wip.is-current {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-rows: auto 1fr;
  column-gap: 70px;
  align-items: center;
}
.slide--wip h2 { grid-column: 1 / -1; grid-row: 1; }
.slide--wip .wip-list { grid-column: 1; grid-row: 2; max-width: 620px; }
/* The mobile mockup overlaps the desktop one's right edge slightly, as in
   the source slide; later-painted siblings sit on top with no z-index. */
.slide--wip .wip-shots {
  grid-column: 2; grid-row: 2;
  display: flex;
  align-items: center;
  justify-self: end;
}
.slide--wip .shot--desktop { width: 540px; height: auto; }
.slide--wip .shot--mobile { width: 185px; height: auto; margin-left: -32px; }

/* ── Slide 13 ──────────────────────────────────────────────────────── */
/* Problems enter from the left, solutions from the right, meeting at the
   starburst — the motion carries the problem-to-solution relationship. */
.slide--migration .migration {
  flex: 1;
  display: grid;
  grid-template-columns: 1fr 130px 1fr;
  gap: 26px 0;
  align-content: center;
  font-size: 34px;
}
.slide--migration .col-head {
  grid-column: 1; margin: 0 0 10px; text-align: center;
  font-size: 36px; color: var(--fg-muted);
}
.slide--migration .col-head--right { grid-column: 3; }
.slide--migration .cell {
  padding: 22px 26px;
  background: var(--panel);
  border: 1px solid var(--sage-light);
  text-align: center;
  display: grid; align-content: center;
}
/* This subtext measures 4.37:1 against --panel — under the 4.5:1 AA floor for
   normal-weight text below WCAG's 24px "large text" cutoff, but comfortably
   over the 3.0 floor that applies above it. At 0.9em of the 34px base it
   renders at 30.6px, so it clears the cutoff with real headroom rather than
   the 1.25% it had when the base was 27px, where an unrelated size change
   could have silently dropped it back under. */
.slide--migration .cell span {
  display: block; margin-top: 6px;
  font-size: 0.9em; color: #DCE6D4;
}
/* Pre-reveal, `.cell--problem`/`.cell--solution` (0,2,0) already outrank the
   base `[data-step]` rule (0,1,0), so the horizontal offset below wins over
   the base's `translateY(14px)` on specificity alone — deterministic
   regardless of where in the file this block sits. The state that actually
   needs pinning down is the settled one: once revealed, the element matches
   both `.cell--problem`/`.cell--solution` (0,2,0, still says translateX) and
   the base `[data-step].is-revealed` (0,2,0, says none) — a tie at equal
   specificity that would otherwise fall back to source order, exactly what
   bit slide 5's large nodes. `.cell.is-revealed` below is three classes
   (0,3,0), which outranks both (0,2,0) rules outright, so the settled
   `transform: none` is fixed by specificity, not by appending this block
   after the base rules. */
.slide--migration .cell--problem { transform: translateX(-28px); }
.slide--migration .cell--solution { transform: translateX(28px); }
.slide--migration .cell.is-revealed { transform: none; }

/* ── Slides 2, 7, 8: image slides and the hybrid Tools slide ──────────── */
/* Same `height: auto` requirement as slides 12/14 above: the <img> width/
   height HTML attributes map to literal CSS width/height, not `auto`, so
   without this the box would hold its attribute-derived height regardless
   of how max-width shrinks it. `object-fit: contain` alone guards against
   the box overflowing its own bounds, but not against the box's aspect
   ratio itself going wrong, so both belt and braces are kept here. */
.slide-image {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;       /* contain, never cover — cropping cuts content */
  margin: auto;
  border-radius: 4px;
}

/* Two real photographs, not one composite raster — no crop, no baked-in
   title. Each image keeps its own natural aspect ratio, so the crowd photo
   (1340x892) and the dog crop (1340x619, much shorter) sit at different
   heights next to each other regardless of alignment. `align-items: center`
   (this rule's previous value) only shares their vertical *midpoint* — since
   the two heights differ, that leaves the crowd photo's top edge higher and
   bottom edge lower than the dog photo's on both sides, which reads as
   randomly offset rather than "level". `flex-end` bottom-aligns both photos
   on a shared edge instead — the two behave like they're standing on the
   same shelf, with the height difference showing up as one consistent gap
   above the shorter (dog) photo rather than mismatched gaps on both photos
   at both ends. */
.slide--photos .photo-pair {
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 40px;
}
.slide--photos .photo-pair .slide-image { max-width: calc(50% - 20px); }

.slide--full { padding: 40px; }

/* The white frame around slide 8's photo is drawn here, not baked into the
   image — the photo-frame div supplies the border as padding + background,
   so re-exporting the source photo never needs to bake a border back in.
   var(--fg) (#F5F3EC) is the deck's existing off-white token, not a new
   literal — same substitution already used for the Tools QR backing below. */
.slide--full .photo-frame {
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: var(--fg);
  border-radius: 4px;
}
.slide--full .photo-frame .slide-image { border-radius: 2px; }

/* Slide 8 stacks two photographs of the same kitchen in one frame: clean
   underneath, messy on top. The first click fades the messy one out, which
   is the slide's argument — a codebase after cleanup, not just a mess.
   Both are cropped to identical dimensions by the build script, so the
   frame keeps its size from the clean layer while the messy layer is taken
   out of flow and pinned exactly over it; nothing reflows during the fade. */
.slide--full .kitchen { position: relative; }
.slide--full .kitchen .kitchen-messy {
  position: absolute;
  inset: 20px;                /* matches .photo-frame's padding */
  width: calc(100% - 40px);
  height: calc(100% - 40px);
  object-fit: contain;
}

/* ── Card slides (6 and 7) ─────────────────────────────────────────── */
/* Two equal columns of bordered, faintly-lit panels. Deliberately defined on
   a plain `.card` rather than scoped under one slide's modifier: slides 6 and
   7 are meant to look like siblings, and sharing the rule keeps them
   identical by construction instead of by copy-paste that drifts. */
.cards-grid {
  flex: 1;
  display: grid; grid-template-columns: 1fr 1fr; gap: 40px;
  align-content: center;
}
.card {
  padding: 32px;
  background: rgb(255 255 255 / 0.04);
  border: 1px solid rgb(168 192 154 / 0.25);
  border-radius: 8px;
  display: flex; flex-direction: column; align-items: center; gap: 22px;
}
.card a { color: var(--sage-light); font-size: 28px; word-break: break-all; }
.card-head { margin: 0; font-size: 34px; }
/* The QR needs a light, high-contrast backing to stay scannable. A literal
   #fff would be a brand-new colour outside the token set; --fg (#F5F3EC) is
   already the deck's off-white and gives the same >15:1 contrast against the
   QR's dark modules, so it does the job without introducing a new literal. */
.card-qr { width: 300px; height: 300px; background: var(--fg); padding: 12px; border-radius: 4px; }
.card-qr img { width: 100%; height: 100%; display: block; }
/* `.slide ul` (0,1,1) sets 36px, which is too wide for a half-width card, but
   30px left the `code` runs inside these lists at 25.5px (0.85em) — the
   smallest text on either slide, on slides that both had ~100px of unused
   height. 34px lifts the code to ~29px and still fits.
   Three components here (0,2,1) wins outright rather than on source order. */
.slide .card ul { font-size: 34px; }

/* The Claude logo, sitting between each problem and its solution — Claude
   Code is what turns one into the other, so the mark belongs there rather
   than a generic ornament. This is the author's own asset lifted from their
   .pptx; a previous pass approximated it as a hand-tuned 12-blade polygon,
   which was close but was not the real logo. */
.slide--migration .star {
  place-self: center;
  width: 84px; height: 84px;
  display: block;
}

/* ── Slide 11: Uncle Bob Martin tweet card ────────────────────────────── */
/* Native HTML, not a screenshot — the source "screenshot" was actually a
   tweet card composed from real PowerPoint text boxes (the giveaway: the
   handle wrapped mid-word, which a real screenshot never does), so there
   was no genuine photographic content to preserve. Real text is strictly
   better here: crisp at any projector resolution, selectable, and doesn't
   need the whole quote duplicated into an alt attribute.
   White background, near-black body text, the platform verified-badge blue,
   and a system sans stack are a deliberate, documented exception to the
   deck's sage/forest palette and serif type: this slide quotes a screenshot
   of a different medium, and using the deck's own tokens/typeface would
   misrepresent what's being quoted. These are the only new colour literals
   in the deck, and the only place a non-serif face is used. */
.slide--tweet { align-items: center; justify-content: center; }
.slide--tweet .tweet-card {
  width: 100%;
  max-width: 1340px;
  display: grid;
  grid-template-columns: 84px 1fr;
  column-gap: 26px;
  background: #fff;
  color: #15181c;
  border-radius: 22px;
  padding: 52px 60px;
  box-shadow: 0 24px 60px rgb(0 0 0 / 0.35);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* The author's real avatar, cropped out of the source screenshot where it is
   only ~27px square. Displayed at 68px it is soft, but it is genuinely his
   avatar rather than a stand-in. alt="" because the name sits right beside
   it — announcing it twice would be noise. */
.slide--tweet .tweet-avatar {
  grid-row: 1; grid-column: 1;
  width: 84px; height: 84px;
  border-radius: 50%;
  object-fit: cover;
  background: #e6ecf0;
}
.slide--tweet .tweet-head { grid-row: 1; grid-column: 2; align-self: center; }
.slide--tweet .tweet-name {
  margin: 0 0 4px;
  font-size: 32px;
  font-weight: 700;
  display: flex; align-items: center; gap: 6px;
}
.slide--tweet .tweet-check { color: #1d9bf0; flex: none; }
.slide--tweet .tweet-meta { margin: 0; font-size: 25px; color: #536471; }
.slide--tweet .tweet-reply {
  grid-column: 1 / -1;
  margin: 14px 0 0;
  font-size: 27px;
  color: #536471;
}
.slide--tweet .tweet-handle { color: #1d9bf0; }
.slide--tweet .tweet-body {
  grid-column: 1 / -1;
  margin: 20px 0 0;
  font-size: 34px;
  line-height: 1.5;
}

/* ── Overview grid ("g" / All slides) ─────────────────────────────────── */
/* `.thumb` buttons live inside `#overview-grid`, not `.controls` (that nav
   only wraps prev/next/progress/overview-button/download), so the existing
   `.controls button` rule (0-1-1) never reaches them and doesn't need to be
   out-specced here. */
.overview {
  position: fixed; inset: 0; z-index: 20;
  overflow-y: auto; padding: clamp(1rem, 4vw, 2.5rem);
  background: rgb(22 36 28 / 0.97);
}
.overview[hidden] { display: none; }
.overview-grid {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1rem;
  /* Clears the fixed "All slides" button pinned to the same top-right corner
     (see `body.overview-open #overview-button` below) so it never overlaps
     slide 1's card — the button's own height plus a gap, added on top of the
     padding-top the grid already inherits from `.overview`. */
  margin-top: calc(44px + 0.75rem);
}
.thumb {
  width: 100%; padding: 1.1rem 1.2rem;
  display: flex; align-items: baseline; gap: 0.8rem; text-align: left;
  background: rgb(255 255 255 / 0.04);
  border: 1px solid rgb(168 192 154 / 0.2);
  border-radius: 8px;
  color: var(--fg); font: inherit; cursor: pointer;
}
.thumb:hover { background: rgb(255 255 255 / 0.09); }
.thumb[aria-current="true"] { border-color: var(--sage); }
.thumb-num { color: var(--sage); font-size: 0.85rem; min-width: 1.6em; }
.thumb-title { font-size: 1.02rem; line-height: 1.3; }

/* `.overview` is a fixed, viewport-covering layer (z-index: 20) stacked above
   `.controls`, which is a plain static-flow element with no z-index of its
   own. Left alone, the overview panel physically occludes the very button
   that opened it: Esc still closes it, and keyboard activation still works
   (focus isn't hit-tested), but a real mouse click on "All slides" while the
   overview is open hits the overview panel instead and is silently
   swallowed.
   Simply raising `#overview-button`'s z-index isn't enough on its own: the
   button stays in `.controls`'s normal document flow, and since `.overview`
   is a scrollable, viewport-covering grid, the button's fixed flow position
   can land mid-scroll on top of a thumbnail card, overlapping its text.
   Instead, `toggleOverview()` toggles `.overview-open` on `<body>`, and this
   pins the button to a fixed top-right corner — off the grid, always
   reachable, never overlapping a card — only while the overview is open. The
   `#id` selector's specificity is already far above `.controls button`
   (0-1-1), so nothing here needs `!important`. */
body.overview-open #overview-button {
  position: fixed;
  top: clamp(1rem, 4vw, 2.5rem);
  right: clamp(1rem, 4vw, 2.5rem);
  z-index: 21;
}
