/* ================================================================
   PORTFOLIO. One stylesheet for every page.
   Previously this block was pasted into all seven HTML files, which
   had already drifted apart; it now lives here and is cached once
   for the whole site.

   Order: tokens -> base -> sidebar shell (rail/topbar/controls) -> home ->
   case study -> prose -> footer -> motion -> highlighted labels -> lock
   badges + unlock dialog -> locked study -> CV (entries + timeline).

   NAMING. Two classes here are older than the sidebar and keep their
   meaning: .rail is the horizontal cover scroller on the playground, and
   .shell is the max-width wrapper used inside every page. The sidebar is
   .sidenav and its grid is .layout - do not rename either back.

   All text/background pairs verified >= 7:1 (WCAG AAA), both themes;
   control borders and focus rings >= 3:1 (1.4.11 / 2.4.13).
================================================================= */
:root{
  color-scheme:light dark;
  /* Light theme: warm paper + pine */
  --paper:#FAF8F3;
  --surface:#F0EBE1;
  --ink:#201B14;
  --ink-soft:#4E463A;
  --accent:#201B14;         /* monochrome: same warm ink as --ink, no hue */
  --accent-strong:#201B14;
  --line:#D9D2C4;
  --line-strong:#8A8071;   /* borders of controls - 1.4.11 needs >=3:1 */
  /* The one colour on the site with a hue. It was declared three times over,
     once each on .calc, #pw-modal and .gate, with a matching pair of dark
     overrides apiece - nine blocks saying the same two values. It is a
     palette colour like any other, so it lives here with the rest and the
     three error messages read it the way everything else reads --ink.
     7.4:1 light, 8.9:1 dark, on --paper and --surface both (1.4.6). */
  --error:#8C2318;

  /* The yellow, written once. Two things use it: the shape behind the
     home page's heading (assets/js/hero.js reads this token off the root
     every frame) and the rail's three group labels on hover. It was
     --hero-shape while the hero was the only caller; the name follows
     what it does, not where it turned up first.

     IT IS A GROUND, NOT A FOREGROUND. At 0.93 relative luminance it is
     1.01:1 against light --paper, so yellow text or a yellow focus ring
     would be invisible on this page. Both callers use it as a fill.

     IT DOES NOT FLIP WITH THE THEME, deliberately, and that is worth
     knowing rather than discovering: on the dark theme it is 17.2:1
     against --paper and unmissable, and --ink over it is 1.13:1, which is
     below every contrast floor this site otherwise holds. That is why the
     rail's labels pair it with --on-highlight and never with --ink, and
     it is the open question about the hero shape - see .hero-canvas. */
  --highlight:#ffff05;
  /* What goes ON the yellow. A fixed dark ink, NOT var(--ink), because
     --ink is cream in the dark theme and lands 1.13:1 on this. This value
     is 15.9:1 on --highlight in BOTH themes, which is why it is declared
     once here and deliberately not repeated in the dark block below: it
     is the one token that does not flip, because the ground it answers to
     does not flip either. Change --highlight and re-check this pair. */
  --on-highlight:#201B14;

  /* ---- How the hero shape is painted ----
     hero.js reads these three and does as it is told. It has no idea a
     theme exists, which is the point: whether the shape is a solid or an
     outline is a decision about colour, so it is made here with the rest
     of the palette rather than in a branch in the script.

     LIGHT IS A FILL, DARK IS AN OUTLINE, and that is not a style choice -
     it is forced. In the dark theme --ink is cream at 0.82 luminance, and
     anything cream can sit on at 7:1 has to be under 0.074, which is
     within 2.2:1 of --paper. A vivid yellow field and readable cream text
     over it cannot both exist there; the arithmetic says pick one. An
     outline picks both, near enough: the yellow stays at full strength and
     only a 3px line ever crosses a glyph instead of a solid field behind
     it. Where the line does cross a letter it is still 1.13:1 - a stroke
     interrupts a letter, it does not bury a paragraph.

     The light theme has no such problem (--ink over the yellow is 15.9:1)
     and keeps the fill, which is also the only thing that reads there: a
     3px yellow line at 1.01:1 against --paper would be invisible.

     transparent, not a missing value, on whichever one is off. Canvas
     treats it as a no-op, so both paints run every frame and neither the
     script nor this block needs an exception. */
  --hero-fill:var(--highlight);
  --hero-stroke:transparent;
  --hero-stroke-width:3px;

  --font-serif:"Newsreader",Georgia,"Times New Roman",serif;
  --font-mono:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,Consolas,monospace;
  /* Fluid type: everything in rem so the A/A+/A++ control scales the whole page */
  --step--1:clamp(0.79rem, 0.76rem + 0.15vw, 0.875rem);
  --step-0:clamp(1.05rem, 1rem + 0.25vw, 1.1875rem);
  --step-1:clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem);
  --step-2:clamp(1.55rem, 1.35rem + 1vw, 2.1rem);
  --step-3:clamp(2rem, 1.6rem + 2vw, 3rem);
  --step-4:clamp(2.5rem, 1.8rem + 3.5vw, 4.4rem);
  --shell:72rem;
  /* Sidebar width in rem, not px, so the A / A+ / A++ control scales the rail
     with everything else. 17.5rem is the mockup's 280px at default size. */
  --sidenav-w:17.5rem;

  /* ---- One left edge for the whole pane ----
     The topbar and the page content used to run on two different systems:
     the topbar flush to the pane on one padding scale, the content centred
     inside a 72rem column on another. They could only line up at a single
     window width, and the gap between them grew as the window widened and
     jumped again whenever the rail was collapsed - which is what made the
     pages look like they had different margins.

     Now there is one edge. --pane-edge is where the breadcrumb starts AND
     where every page's content starts; the toggle sits in the gutter to the
     left of it. The edge is DERIVED from the toggle and the topbar gap, so
     changing either keeps the two in step instead of drifting apart. */
  --pane-gutter:clamp(1rem,3vw,1.5rem);   /* pane edge -> toggle */
  --topbar-gap:0.75rem;                   /* toggle -> breadcrumb */
  --nav-toggle:44px;                      /* 2.5.5 floor, and the gutter's width */
  /* Narrow screens keep the plain gutter: below the breakpoint the topbar
     wraps, and spending 44px+ of a 320px screen indenting past a button
     nothing lines up with any more would just cost reading width (1.4.10). */
  --pane-edge:var(--pane-gutter);

  /* Height of the sticky topbar, so anything else that sticks can clear it
     instead of sliding underneath. The value here is the one-row case
     (0.5rem padding twice + a 44px row + the 1px rule); nav.js measures the
     real thing and overwrites it, because the breadcrumb wraps the bar onto
     a second row between roughly 830 and 950px - which is inside the range
     where the case-study context panel is sticky. No JS, no wrap worth
     worrying about at those widths, and this fallback is exact. */
  --topbar-h:calc(1rem + 45px);
  /* First block on every page starts here, so the eyebrow never jumps
     between Work / About / a case study. Halved from clamp(3rem,8vw,6rem):
     the topbar already separates the content from the top of the window, so
     the old gap was doing that job twice.

     Every page's opening block reads this, and only this - .hero on the home
     page, .cs-header on About, the playground, the three toys and an unlocked
     case study, .gate on a locked one. Change it here and the whole site
     moves together; set a top padding on any of those directly and you have
     broken the one thing this token exists to guarantee. */
  --page-top:clamp(1.5rem,4vw,3rem);
}
/* Dark theme: warm charcoal + mist pine. Keep in sync with the media query below. */
[data-theme="dark"]{
  --paper:#171310; --surface:#211C17; --ink:#EFE9DD; --ink-soft:#C6BCAB;
  --accent:#EFE9DD; --accent-strong:#EFE9DD; --line:#3A332B; --line-strong:#7A7160;
  --error:#F2A196;
  --hero-fill:transparent; --hero-stroke:var(--highlight);
}
@media (prefers-color-scheme:dark){
  :root:not([data-theme="light"]){
    --paper:#171310; --surface:#211C17; --ink:#EFE9DD; --ink-soft:#C6BCAB;
    --accent:#EFE9DD; --accent-strong:#EFE9DD; --line:#3A332B; --line-strong:#7A7160;
    --error:#F2A196;
    --hero-fill:transparent; --hero-stroke:var(--highlight);
  }
}

*,*::before,*::after{box-sizing:border-box}
html{
  -webkit-text-size-adjust:100%;
  /* Keep anything scrolled into view, including a keyboard-focused link,
     clear of the fixed back-to-top control in the corner (2.4.12) and of
     the sticky topbar at the head of the pane.

     THE TOP OFFSET IS --page-top, NOT A NUMBER OF ITS OWN, so that landing
     on an anchor puts its heading exactly where a page's opening block
     puts one. This is the rail's three group labels: CV and PLAYGROUND
     load pages whose .cs-header opens at --page-top under the topbar,
     while CASE STUDIES is an anchor on the home page and used to land on
     a flat 1rem - up to 32px tighter at a wide window, which is what made
     the three look like different pages when they are meant to look like
     one. --page-top already carries the promise that the eyebrow never
     jumps between pages; this is what extends it to jumping WITHIN one.

     It costs nothing elsewhere: every other anchor and every keyboard
     focus scroll simply gets the same clearance under the topbar that
     content gets at the top of a page, which is the number that was
     wanted in both places anyway. */
  scroll-padding-bottom:5.5rem;
  scroll-padding-top:calc(var(--topbar-h) + var(--page-top));
}
body{
  margin:0;
  background:var(--paper);
  color:var(--ink);
  font-family:var(--font-serif);
  font-optical-sizing:auto;
  font-size:var(--step-0);
  line-height:1.55;
}
h1,h2,h3{font-weight:500;line-height:1.12;margin:0;letter-spacing:-0.01em}
p{margin:0}
a{
  color:var(--accent);
  text-decoration-line:underline;
  text-decoration-thickness:0.055em;
  text-underline-offset:0.16em;
}
a:hover{color:var(--accent-strong);text-decoration-thickness:0.12em}
::selection{background:var(--accent);color:var(--paper)}

/* Focus: thick, offset, visible in both themes (≥15:1 on both grounds) */
:focus-visible{outline:3px solid var(--accent);outline-offset:3px;border-radius:2px}

/* ...except on things that only a script ever focuses. tabindex="-1" is
   exactly that contract - "not in the tab order, reachable only from code" -
   and the site has three:

     main#main        where the skip link lands
     .scrim           the drawer's dismiss target
     the case study h1  unlock.js moves focus here once a study decrypts,
                        so the reader is put at the top of the content they
                        just unlocked rather than left at the top of the page

   The focus MOVE is the accessible behaviour and it stays; a ring drawn
   round a heading nobody tabbed to just reads as an error state. Ringing it
   is not required either: 2.4.7 and 2.4.13 govern the indicator for
   KEYBOARD-operable elements, and none of these can be reached by Tab.

   Scoped to -1 on purpose. tabindex="0" is untouched, so the playground's
   cover rail - a scrollable region that IS tab-reachable (2.1.1) - keeps its
   ring. Never widen this to a bare :focus. */
[tabindex="-1"]:focus{outline:none}

.visually-hidden{
  position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;
}
.skip-link{
  /* Above the drawer and its scrim (26 / 25): the link sits outside the pane,
     so it stays focusable while the drawer is open and must not land under it. */
  position:absolute;top:-100vh;left:1rem;z-index:40;
  background:var(--ink);color:var(--paper);
  font-family:var(--font-mono);font-size:var(--step--1);
  padding:0.9rem 1.2rem;border-radius:4px;text-decoration:none;
}
.skip-link:focus{top:1rem}

/* The page column. Pinned to the left of the pane rather than centred in it,
   so its left edge is --pane-edge at every window width and does not shuffle
   sideways as the pane grows or the rail collapses. */
.shell{
  max-width:var(--shell);
  margin-inline:0;
  padding-inline:var(--pane-edge) var(--pane-gutter);
}

/* Above the breakpoint the breadcrumb sits after the toggle, so the shared
   edge has to clear both. Everything on the page follows it. */
@media (min-width:50rem){
  :root{--pane-edge:calc(var(--pane-gutter) + var(--nav-toggle) + var(--topbar-gap))}
}

/* ================================================================
   SIDEBAR SHELL: .layout > .sidenav + .pane > .topbar + main + footer

   A persistent left rail listing every case study, the way a file
   explorer or a chat list does, so the whole site map is on screen at
   once. Below 50rem it becomes an off-canvas drawer with a scrim.

   Three things this has to survive:

   1. NO JAVASCRIPT. The rail is the only navigation on the site now, so
      it can never depend on a script to be reachable. The head snippet
      on every page stamps data-js on <html> before first paint; the
      drawer, the collapse and the toggle button are all scoped to that
      attribute. Without it the rail is a plain block - a column beside
      the content on wide screens, stacked above it on narrow ones - and
      every link still works.
   2. NO FLASH. The open/closed state is persisted in localStorage and
      re-applied by that same pre-paint snippet as data-nav="closed", not
      by nav.js. Left to the script, every navigation would paint the rail
      open and then snap it shut.
   3. NO FOCUS IN A HIDDEN RAIL. Collapsed to a 0-width column or slid
      off-canvas, the links are still in the tab order unless something
      takes them out. visibility:hidden does it in CSS, delayed by the
      length of the slide so the rail does not vanish mid-transition, and
      it survives a script that dies half way.

   Colour is all site tokens, so both themes and the AAA floors hold. The
   rows carry no filled ground in any state, so everything in the list sits
   on --surface: --ink text at 14.4:1 light / 14.0:1 dark, --ink-soft
   padlocks at 7.8:1 / 9.0:1. Only the three group labels take a swatch,
   --paper on --accent, 16.1:1 / 15.3:1 and the same again inverted.
================================================================= */
.layout{
  display:grid;
  grid-template-columns:var(--sidenav-w) minmax(0,1fr);
  min-height:100vh;
}
:root[data-js][data-nav="closed"] .layout{grid-template-columns:0 minmax(0,1fr)}

.sidenav{
  /* Where rail text starts, derived so the nav rows and the foot cannot drift
     apart: the nav's own inline padding plus each row's. The foot had 1.25rem
     hard-coded against the rows' 1.35rem, which is why it sat 1.6px adrift. */
  --rail-pad:0.75rem;
  --rail-row-pad:0.6rem;
  --rail-text:calc(var(--rail-pad) + var(--rail-row-pad));
  background:var(--surface);
  border-inline-end:1px solid var(--line);
  position:sticky;top:0;height:100vh;
  overflow:hidden;               /* clips the fixed-width inner as the column collapses */
  visibility:visible;
}
:root[data-js][data-nav="closed"] .sidenav{visibility:hidden;border-inline-end-color:transparent}
/* Fixed width, so the rail's contents never reflow while the column animates */
.sidenav-inner{width:var(--sidenav-w);height:100%;display:flex;flex-direction:column}

.sidenav nav{flex:1;overflow-y:auto;padding:0.5rem var(--rail-pad) 1.25rem}

/* ---- Group labels ----
   The three of them are the page eyebrow's permanent highlight swatch, and
   each is a link to the section it heads: Work to the home page, Playground
   and About to theirs. The swatch itself is inherited from the shared
   .eyebrow rule further down, so it cannot drift from the eyebrows.

   HOVER IS THE YELLOW, and these three labels are the only place on the
   site that uses it for a hover. It turns the swatch from --accent to
   --highlight, keeping --on-highlight over it at 15.9:1 in both themes -
   comparable to the 16.1/15.3 of the inversion it replaced, so nothing is
   given up on readability. --on-highlight rather than --ink because --ink
   is cream in the dark theme and would sit 1.13:1 on the yellow.

   Hover used to turn the swatch over instead - paper ground, ink text -
   which is still the pair ::selection uses on these labels a few rules
   down. That is no longer the same gesture as a hover, and it does not
   need to be: a selection is something the reader made, a hover is the
   rail answering the pointer.

   WORTH KNOWING, on the state change rather than the reading. On the
   light theme the swatch goes from near-black to yellow, a 15.3:1 jump -
   about as loud as a hover gets. On the dark theme it goes from cream to
   yellow, which is 1.12:1: the same lightness, a different hue. The label
   stays perfectly readable either way, but on a greyscale screen the dark
   theme's hover is close to invisible.

   The swatch stays tight to its text; the invisible ::after stretches the
   clickable area to the full 44px row (2.5.5) without inflating the block
   into a heavy bar. It is inset:0 on a min-height row, so it never bleeds
   into the margins and cannot overlap the target above or below it. */
.sidenav-label{
  position:relative;                                  /* anchors the ::after */
  display:flex;align-items:center;min-height:44px;
  margin:1.15rem 0 0.4rem;padding-inline:var(--rail-row-pad);
}
/* Nothing above it any more, so the first label's own margin comes off and
   the nav's 0.5rem padding is all that is left. That is deliberate: it is the
   same 0.5rem the topbar uses, and both open with a 44px row, so the WORK
   swatch and the breadcrumb beside it share a centre line 30px down. */
.sidenav-label:first-child{margin-block-start:0}
.sidenav-label a{
  font-family:var(--font-mono);font-size:var(--step--1);font-weight:500;
  letter-spacing:0.06em;text-transform:uppercase;
  text-decoration:none;
}
.sidenav-label a:hover{background:var(--highlight);color:var(--on-highlight)}
/* The swatch is already ink-on-paper, so current is marked by rule, not fill */
.sidenav-label a[aria-current]{
  text-decoration:underline;text-decoration-thickness:0.1em;text-underline-offset:0.15em;
}
.sidenav-label a::after{content:"";position:absolute;inset:0}
.sidenav ul{list-style:none;margin:0;padding:0}
/* Serif, like the work titles on the home page, because that is what these
   are. Mono stays where it is everywhere else on the site: labels and meta.

   They take the home page's treatment exactly: an underline that thickens,
   and no filled ground in any state. The row used to light up with a block
   of colour on hover and a solid ink fill when current, which made the rail
   read as a set of buttons rather than a list of work, and stated the
   current page in a different language from every other current marker on
   the site. Those are all underlines - the name at the top of the rail, the
   group labels - and now so is this. */
.sidenav li a{
  display:flex;align-items:flex-start;gap:0.5rem;
  min-height:44px;padding:0.55rem var(--rail-row-pad);
  color:var(--ink);text-decoration:none;
  font-size:var(--step--1);line-height:1.35;
}
/* At rest a row is plain text - no underline, no fill. The underline is
   reserved for the two states that mean something: the row under the
   pointer, and the page you are on.

   This is a navigation list, not prose, so 1.4.1 Use of Colour is not in
   play: every row here is a link, and there is no body text alongside them
   that an underline would be distinguishing them from. The old top nav
   worked the same way. In reading copy, links stay underlined - see the
   global a{} rule and .prose.

   The underline sits on the label, not the whole row, so it runs under the
   title and stops - never under the padlock riding the right-hand edge.
   Thickness and offset match .work-title a on the home page.

   All three rules key off .sidenav li a, so any row added to the rail later
   - another case study, another experiment - gets this for nothing. */
.sidenav-text{text-underline-offset:0.18em}
.sidenav li a:hover{color:var(--accent-strong)}
.sidenav li a:hover .sidenav-text{
  text-decoration-line:underline;text-decoration-thickness:0.09em;
}
/* [aria-current], not [aria-current="page"]: the studies and the two section
   indexes carry "page", while a child page marks its parent with "true" -
   you are in it, but it is not this page. Both are the current place in the
   rail, so both take the marker. 0.1em is the site's current-page weight,
   the same as the group labels and the name at the top of the rail. */
.sidenav li a[aria-current]{font-weight:500}
.sidenav li a[aria-current] .sidenav-text{
  text-decoration-line:underline;
  text-decoration-thickness:0.1em;text-underline-offset:0.25em;
}

/* The padlock rides the right edge of its row. Same icon pair and same
   body[data-unlocked] swap as the home page badges, so one unlock flips
   the rail and the work list together. */
/* No --paper override for the current row any more: nothing in the rail has
   a filled ground, so every padlock sits on --surface and keeps --ink-soft
   at 7.8:1 light / 9.0:1 dark, or --accent once unlocked. */
.sidenav .lock-badge{margin-inline-start:auto;flex:none;color:var(--ink-soft);padding-block-start:0.2em}

/* Foot: identity, then the two contact links. All three rows are the same
   44px, all three start on the rail's single text edge, and the alignment is
   stated outright rather than left to flex defaults. 44px is the floor here,
   not a spacing choice - it is 2.5.5, and it is what stops the rows getting
   any tighter than this. */
.sidenav-foot{
  border-block-start:1px solid var(--line);
  padding:0.6rem var(--rail-text) 0.75rem;
  display:grid;
}
/* Serif, at the same --step--1 as the rows above. The size was already the
   same; the family was not, and that was the whole of the difference. IBM
   Plex Mono sets wider and with a taller x-height than Newsreader, so the
   foot read a size larger than the study rows even though both computed to
   the same px - the kind of mismatch you see immediately and cannot find in
   the numbers.

   So the rule is now: in this rail, mono means highlighted. The three group
   labels take the swatch and the mono; everything else - every study, every
   experiment, the name, the role, the two contact links - is serif at
   --step--1 and looks it. */
.sidenav-foot a{
  font-size:var(--step--1);color:var(--ink);
  min-height:44px;                                    /* 2.5.5 */
  display:flex;align-items:center;justify-content:flex-start;
  text-align:left;
}
/* The name and role: a wordmark, not a link in a list, so no underline at
   rest and the name takes it on hover.

   The two lines are set at 1.3 rather than the body's 1.55 and carry no
   padding of their own, which brings the block to the same 44px as the two
   links under it. It used to run ~60px, so the three rows were vertically
   centred at three different rhythms - that unevenness is what read as
   "some of it centred". */
/* SPECIFICITY TRAP, and the reason this reads .sidenav-foot .sidenav-id and
   not just .sidenav-id: the shared rule above is `.sidenav-foot a`, which at
   (0,1,1) outranks a lone class at (0,1,0). Its align-items:center therefore
   won here - and because this box is flex-direction:column, align-items is
   the HORIZONTAL axis, so the name and role came out centred while the two
   links beside them stayed left.

   Turning the box on its side swaps what the two properties mean, so both
   have to be restated together:
     column  ->  align-items     = horizontal  -> flex-start (left)
                 justify-content = vertical    -> center */
.sidenav-foot .sidenav-id{
  flex-direction:column;
  align-items:flex-start;
  justify-content:center;
  line-height:1.3;
  text-decoration:none;margin-block-end:0.15rem;
}
.sidenav-id:hover .sidenav-name{text-decoration:underline;text-underline-offset:0.2em}
/* The name is the home link, so on the home page it is the one thing in the
   rail carrying aria-current - the top group points at /case-studies/ now,
   and nothing else in here points at /.

   Marked the same way every other current row is: an underline at 0.1em, on
   the name rather than the whole block so it does not run under the role
   beneath it. Same language as the group labels and the study rows, which is
   the point - a reader should not have to learn a second marker. */
.sidenav-id[aria-current] .sidenav-name{
  text-decoration:underline;text-decoration-thickness:0.1em;text-underline-offset:0.2em;
}
.sidenav-name{font-weight:500;letter-spacing:0.02em}
.sidenav-role{color:var(--ink-soft);margin-top:0.1rem}

/* ---- The pane: topbar, page, footer ---- */
.pane{min-width:0;display:flex;flex-direction:column;min-height:100vh}
.pane > main{flex:1 0 auto}

.topbar{
  position:sticky;top:0;z-index:10;
  display:flex;flex-wrap:wrap;align-items:center;gap:0.4rem var(--topbar-gap);   /* 1.4.10: wraps at 320px */
  padding:0.5rem var(--pane-gutter);
  background:var(--paper);border-block-end:1px solid var(--line);
}
.topbar-end{margin-inline-start:auto;display:flex;flex-wrap:wrap;align-items:center;gap:0.4rem var(--topbar-gap)}

.nav-toggle{
  flex:none;min-width:var(--nav-toggle);min-height:var(--nav-toggle);   /* also sets --pane-edge */
  display:none;align-items:center;justify-content:center;
  color:var(--ink);background:none;cursor:pointer;
  border:1px solid var(--line-strong);border-radius:999px;
}
:root[data-js] .nav-toggle{display:inline-flex}   /* nothing to toggle without a script */
.nav-toggle:hover{background:var(--surface)}
.nav-toggle svg{width:1.15rem;height:1.15rem}

/* Open rail: the icon's left panel fills in, the way a sidebar toggle does
   in VS Code or Finder, so the button shows the state rather than only the
   action. Closed, it is the plain outline it always was.

   currentColor, so it is --ink in light and --paper-ish in dark and can
   never drift from the frame it sits inside - same trick as the sun/moon
   pair beside it. 0.5 is the lowest opacity that still clears 3:1 against
   both the resting and the hovered ground in both themes: this is the only
   thing distinguishing the two states of the control, so 1.4.11 applies to
   it. Measured 3.26:1 light / 4.58:1 dark at rest, 3.17 / 4.46 hovered.
   Lower it and the open state stops being legible. */
.nav-toggle-panel{fill:currentColor;stroke:none;fill-opacity:0}
.nav-toggle[aria-expanded="true"] .nav-toggle-panel{fill-opacity:0.5}

.crumbs{min-width:0;font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink-soft)}
.crumbs ol{display:flex;flex-wrap:wrap;align-items:center;gap:0 0.4rem;list-style:none;margin:0;padding:0}
/* Two passes on purpose. The second gives the separator empty alt text so it
   stays out of the accessible name; engines that don't know that syntax drop
   the whole declaration, so the plain one has to come first or they would be
   left with no separator at all. */
.crumbs li + li::before{content:"/";margin-inline-end:0.4rem;color:var(--ink-soft)}
.crumbs li + li::before{content:"/" / ""}
.crumbs a,.crumbs [aria-current="page"]{min-height:44px;display:inline-flex;align-items:center}
.crumbs a{color:var(--ink-soft)}
.crumbs a:hover{color:var(--ink)}
.crumbs [aria-current="page"]{color:var(--ink)}

.controls{display:flex;align-items:center;gap:0.6rem}
.seg{display:inline-flex;gap:2px;padding:3px;border:1px solid var(--line-strong);border-radius:999px}
.seg button,.theme-toggle{
  font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink);
  background:none;border:0;border-radius:999px;cursor:pointer;
  min-width:44px;min-height:44px;padding:0 0.4rem;   /* 44px targets: WCAG 2.5.5 AAA */
  display:inline-flex;align-items:center;justify-content:center;
}
.seg button[aria-pressed="true"]{background:var(--ink);color:var(--paper);box-shadow:0 0 0 2px var(--paper) inset}
.seg button[aria-pressed="false"]:hover{background:var(--surface)}
.theme-toggle{border:1px solid var(--line-strong)}
.theme-toggle:hover{background:var(--surface)}
.theme-toggle svg{width:1.15rem;height:1.15rem}
.icon-moon{display:none}
[data-theme="dark"] .icon-moon{display:block}
[data-theme="dark"] .icon-sun{display:none}
@media (prefers-color-scheme:dark){
  :root:not([data-theme="light"]) .icon-moon{display:block}
  :root:not([data-theme="light"]) .icon-sun{display:none}
}

/* ---- Below 50rem (800px at default text size): off-canvas drawer ----
   The rail leaves the grid, sits over the page and slides in from the left
   behind a scrim. nav.js marks .pane inert while it is open, so the browser
   itself keeps Tab inside the drawer and there is no hand-rolled focus trap
   to get wrong.

   Everything here is scoped to :root[data-js]. With no script the rail
   simply stacks above the content as an ordinary block, which is the only
   navigation a no-JS visitor would otherwise have. */
.scrim{display:none}

@media (max-width:50rem){
  .layout,
  :root[data-js][data-nav="closed"] .layout{grid-template-columns:minmax(0,1fr)}

  .sidenav{
    position:static;height:auto;
    border-inline-end:0;border-block-end:1px solid var(--line);
  }
  .sidenav-inner{width:100%}
  .sidenav nav{overflow-y:visible}

  :root[data-js] .sidenav{
    position:fixed;inset-block:0;left:0;z-index:26;
    width:var(--sidenav-w);max-width:85vw;height:100%;
    border-inline-end:1px solid var(--line);border-block-end:0;
    transform:translateX(-100%);visibility:hidden;
  }
  :root[data-js] .sidenav nav{overflow-y:auto}
  /* Restates the border because the collapsed-rail rule above sets it
     transparent, and it outranks the drawer's own rule if the visitor last
     left the rail closed on a wide screen. */
  :root[data-js] .layout.drawer-open .sidenav{
    transform:translateX(0);visibility:visible;border-inline-end-color:var(--line);
  }

  :root[data-js] .scrim{
    display:block;position:fixed;inset:0;z-index:25;
    background:rgb(0 0 0 / 45%);border:0;padding:0;
    opacity:0;pointer-events:none;
  }
  :root[data-js] .layout.drawer-open .scrim{opacity:1;pointer-events:auto}
}

/* Home: hero + editorial work list */

/* The shape's canvas, behind the heading. Decorative and aria-hidden, so
   nothing here carries meaning the copy does not already carry.

   .hero has to be positioned for inset:0 to mean the hero rather than the
   page, and isolation:isolate keeps the two z-indexes below from being
   read against anything outside this block.

   THE CONTENT HAS TO BE RAISED EXPLICITLY. An absolutely positioned
   element paints above its static siblings whatever the source order, so
   without the second rule the canvas would sit on top of the type rather
   than behind it - z-index:0 on the canvas alone would not save it, since
   0 and auto paint in the same pass. This is the same pairing the timer
   uses for .pom-full-inner.

   IT FILLS THE PANE, NOT THE TEXT COLUMN, and that is why the numbers
   are not here. The hero is a block inside .shell, which caps at 72rem
   and pads its own edges, so inset:0 would box the shape into the column
   and leave bare paper beside it on a wide window. Reaching the pane's
   left edge is a negative --pane-edge and could be written here; reaching
   its RIGHT edge cannot, because once the pane out-runs .shell there is
   no length in CSS for the distance between the two. hero.js measures
   both boxes and sets left/width/height on the element, so the canvas
   spans the pane exactly - flush at both edges, no overflow past it and
   so no stray horizontal scrollbar.

   Which leaves this rule with the two things that are genuinely static:
   where the canvas hangs from, and what it hangs behind.

   ON CONTRAST. The type sits over the shape rather than being masked by
   it, which was the ask, and the two themes needed different answers.

   The light theme fills: --ink over --highlight is 15.9:1, better than
   ink on paper, so a solid yellow field can pass under the heading and
   nothing is lost. The dark theme strokes, because there --ink is cream
   and lands 1.13:1 on the yellow - a solid field would bury whatever it
   crossed. An outline crosses a glyph instead of sitting behind a
   paragraph. It is not free: where the 3px line does cross a letter it is
   still 1.13:1 and that stroke of the letter is interrupted. It is the
   best available answer rather than a clean one, and the reason there is
   no clean one is arithmetic - see --hero-fill.

   The other two routes are closed, and both were walked. Making the
   yellow dark enough for cream to sit on drops it to within 2.2:1 of
   --paper, at which point there is no yellow left to look at. Masking the
   type with a blend mode is what pomodoro.js records failing in Safari
   against this exact animated, GPU-composited layer - it is why the timer
   draws its count on canvas, which the hero cannot do and stay real
   text. */
.hero-canvas{
  position:absolute;top:0;left:0;z-index:0;display:block;
  /* No width, height or inset: hero.js owns all three. A <canvas> is a
     replaced element, so with none of them set it would fall back to its
     intrinsic 300x150 - which is exactly what a visitor with JS off gets,
     an empty transparent box painting nothing. */
}
.hero .eyebrow,
.hero h1,
.hero .lede{position:relative;z-index:1}

.hero{
  position:relative;isolation:isolate;
  padding-block:var(--page-top) clamp(2.5rem,7vw,5rem);
  /* THE FIRST SCREEN IS THE HERO AND NOTHING ELSE. The studies below it
     have to be scrolled to, so this block is sized to fill the viewport
     rather than to fit its copy.

     min-height, not a taller bottom padding. A padding is a guess that
     is wrong on every screen it was not measured on; this lets the
     VIEWPORT decide, and it grows with the window instead of leaving the
     section head half a fold short on a tall monitor.

     100vh, not 100svh. On a phone vh is the LARGE viewport - the one you
     get once the URL bar has retracted - so the section head sits under
     the fold with the bar showing and stays under it after the bar goes.
     100svh would be exactly right on load and then let the head creep
     into view as the bar slid away, which is the one moment it must not.

     Minus the topbar, because the topbar is sticky AND in flow: it takes
     var(--topbar-h) off the first screen before the hero starts. It can
     be taller than that value if it wraps at 320px, never shorter, since
     every row in it is a 44px target - so the arithmetic can only push
     the studies further down, never pull them up.

     min-height only ever adds. Where the copy is already taller than a
     screen this does nothing, and the studies are below the fold on
     their own. The content stays at the TOP of the block, where it has
     always been - centring it would drop the eyebrow off the topbar and
     that is a redesign, not a scroll. The lede's "case studies" link is
     the way past it for anyone who would rather not scroll. */
  min-height:calc(100vh - var(--topbar-h));
}
/* Identical to .section-title in every respect but size: same mono face,
   same 500 weight, same 0.06em tracking, one step up the scale.

   SIZE IS THE ONLY THING THAT SEPARATES THEM, so which one a label takes
   says what rank it holds, not where it sits in the markup. .eyebrow is
   the page-level label - SENIOR INTERACTION DESIGNER on the home page,
   PLAYGROUND on the playground - and .section-title is a label inside a
   page, like EXPERIMENTS. The home page's CASE STUDIES takes .eyebrow
   even though it heads a section: the six studies are a body of work in
   their own right, the peer of the playground, and they had a page and a
   page eyebrow of their own until that page folded into this one. Sizing
   it down to a subsection would demote the work, not just the label. */
.eyebrow{
  font-family:var(--font-mono);font-size:var(--step-0);font-weight:500;
  letter-spacing:0.06em;text-transform:uppercase;color:var(--ink-soft);
  margin-bottom:1.4rem;
  /* Says out loud what a <p class="eyebrow"> gets for free, so the label
     looks the same on any element. Every eyebrow but one is a <p>, which
     inherits the page's 1.55; the home page's CASE STUDIES is an <h2>,
     and h1,h2,h3 sets line-height:1.12 further up. Without this that one
     label would carry a swatch some 8px shorter than the identical label
     beside it - the swatch is an inline-block, so its height is the line
     box, and a heading's tighter leading shrinks the box with it. */
  line-height:inherit;
}
.hero h1{font-size:var(--step-4);max-width:24ch}
.lede{font-size:var(--step-2);color:var(--ink-soft);max-width:52ch;margin-top:1.6rem;line-height:1.5}

.section-head{display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem 1.25rem;padding-bottom:1rem}
/* An .eyebrow used as a section head's label, which is what the home page's
   CASE STUDIES is, drops its bottom margin. The head already ends with the
   1rem of padding every label on the site sits above, and inside this flex
   row a margin-bottom would knock the label off the button's centre line -
   align-items:center measures the margin box, not the text. */
.section-head .eyebrow{margin-bottom:0}
.section-title{
  font-family:var(--font-mono);font-size:var(--step--1);font-weight:500;
  text-transform:uppercase;letter-spacing:0.06em;color:var(--ink-soft);
}

/* ---- The six studies, two across ----
   auto-fit rather than repeat(2, …) behind a media query, because a media
   query asks the WINDOW how wide it is and the grid needs to ask its own
   column. The pane is the window minus the rail, and the rail collapses, so
   one window width means two different content widths and any breakpoint
   would be wrong in one of them. auto-fit reads the container, so both
   states are right without either being named.

   IT CANNOT BECOME THREE COLUMNS. Three 24rem tracks and their two gaps
   need 79rem; .shell caps the content at 72rem less its padding, about
   65.5rem. Both numbers are rem, so they scale together with the A/A+/A++
   control and that stays true at every text size. Two columns need 52.5rem
   of content, which is a 1280px window with the rail open, or 1024 with it
   shut; below that a track would be too narrow for a --step-2 title and it
   drops to one.

   min(100%, 24rem) so the track can shrink under 24rem in a container
   narrower than that, rather than overflowing it.

   NO ROW GAP, deliberately. .work-item opens and closes with 2.75rem of
   padding of its own, so consecutive rows already stand 5.5rem apart -
   that IS the vertical rhythm here, and adding a gap on top would space
   the rows twice. It reads as air rather than as a missing rule because
   each entry now opens with a picture, which is its own top edge.

   The hairline that used to close each entry has gone - see .work-item
   below - and the padding it framed has deliberately stayed. */
.work-grid{
  display:grid;
  grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr));
  column-gap:clamp(2rem,4vw,3.5rem);
}
/* NO DIVIDING RULES, on either list that uses this class. Entries are
   held apart by their own padding: 2.75rem top and bottom, so consecutive
   rows stand 5.5rem apart. That spacing was drawn around a hairline
   between them and has deliberately stayed without it, which is what
   makes the space read as room rather than as entries running together.

   It was a border-bottom until the home page's studies grew 16:9 frames
   and a picture turned out to be its own top edge. For a while the border
   lived on here and .work-grid .work-item turned it off, so the
   playground kept its rules; both lists are ruleless now, and a base rule
   with an override that cancels it everywhere is just a rule with extra
   steps. Wanting one back is one declaration, here.

   The playground's experiments carry no picture, so over there the space
   is doing this on its own. */
.work-item{padding-block:2.75rem}

/* The 16:9 still above each study. Right now every one of them is empty -
   a placeholder frame, not a picture - and it is a <div> for that reason:
   an <img> with no file behind it draws a broken-image icon in every
   browser, which is worse than an honest blank.

   THE SAME FRAME AS THE GAME COVERS: 8px, a --line hairline, --surface
   inside. That is not a coincidence to be tidied away later - it is the
   one image treatment on the site, and the fill is the same one .game-shot
   uses to hold a lazy cover's place. An empty one reads as a picture that
   has not arrived rather than as a box someone drew.

   ONE CLASS, BOTH STATES. Drop the real picture in and nothing here needs
   to change:

     <img class="work-shot" src="/assets/img/one-login.jpg"
          alt="..." width="1280" height="720" loading="lazy" decoding="async">

   aspect-ratio holds the frame, object-fit crops the file into it however
   it was cut, and the width/height attributes stop the row reflowing as it
   loads. Paths are root-relative; build.py works out the ../ and appends
   the cache-busting hash. Write alt for what the picture SHOWS about the
   work - the title underneath already gives its name, so repeating that
   would say it twice.

   NOT A LINK, deliberately. The title above is already the link into the
   study and carries data-protected, which is what wires it to the unlock
   dialog. Making the frame a second link to the same place would put two
   tab stops on one entry and have a keyboard reader pass the same
   destination twice. If a picture should be clickable, the whole entry
   wants to become the link, not the image on its own. */
.work-shot{
  display:block;
  width:100%;
  /* height:auto IS LOAD-BEARING, and its absence is a real bug this rule
     shipped with. An <img>'s width/height attributes map to CSS width and
     height as presentational hints. width:100% above beats the width hint,
     but with no height declared the height="630" hint stands - and a box
     with both dimensions definite has no ratio left to honour, so
     aspect-ratio below is ignored and the picture renders full-width by a
     flat 630px. The placeholders looked right throughout because a <div>
     has no height attribute to leak in; only the one frame with a real
     picture in it was wrong. .game-cover carries this for the same reason.
     Do not drop it to "tidy up" - the attributes have to stay too, since
     they are what holds the row's height before the file arrives. */
  height:auto;
  aspect-ratio:16/9;
  margin-bottom:1.6rem;
  border-radius:8px;
  border:1px solid var(--line);
  background:var(--surface);
  object-fit:cover;          /* only bites once it is an <img> */
}

/* Grey at rest, colour when you point at the entry. The same bargain the
   playground's game covers strike, and for the same reason: a page with two
   colours in it does not want a row of full-volume screenshots shouting over
   the type, and pointing at one is asking for it.

   img.work-shot, NOT .work-shot. The other five entries are still empty
   placeholder divs, and greyscaling one would quietly shift its --surface
   fill and then shift it back on hover - a frame that flickers with nothing
   in it. Scoping to the element means a placeholder simply has no picture to
   drain, and picks this up for free on the day it gets one.

   THE WHOLE ENTRY IS THE TRIGGER, not just the frame. :focus-within is what
   earns it: the title above is a link, so tabbing to it colours the picture
   exactly as pointing at it does, and a keyboard reader gets the same page a
   mouse does. Hovering the title does it too, which is how a card should
   behave anyway.

   NOTHING IS CONVEYED BY THE COLOUR. The title, the outcome and the role are
   printed under every frame whether you point at one or not, so a touch
   screen - which has no hover at all - misses a flourish and no meaning.
   That is what keeps this out of 1.4.1 and 1.4.13. */
img.work-shot{filter:grayscale(1)}
.work-item:hover img.work-shot,
.work-item:focus-within img.work-shot{filter:none}

/* ---- The two frames that move ----
   Fandom and Search are animated GIFs. Neither is in the markup as the
   picture: what the page loads is a still poster, frame one of each, and the
   animation is this element's background - only reached on hover.

   THREE PROBLEMS, ONE MECHANISM.

   2.2.2 Pause, Stop, Hide. A GIF starts by itself, runs past five seconds,
   loops forever and cannot be stopped by the reader. Nothing moves here
   until someone asks for it and it stops when they look away, so the
   guideline has nothing to bite on.

   prefers-reduced-motion. Everything else on the site honours it and two
   autoplaying GIFs could not. The swap lives in a no-preference query
   below, so asking for less motion gets the poster and nothing else - and
   the GIF is never even fetched.

   5.9MB. A background-image held in a custom property is not fetched until
   something resolves it, so both files stay on the server until a pointer
   or a Tab key arrives. The posters that load instead are 107KB and 85KB.

   WHY THE URL IS HERE AND NOT IN THE MARKUP. It has to be a background to
   be deferred, and a background lives in CSS. That turns out to be the
   sturdier place anyway: a url() in this file resolves against this file,
   so ../img/... is right from every page at every depth - which is the job
   build.py does by hand for every href and src in the HTML. */
.work-shot-motion{
  position:relative;
  overflow:hidden;                 /* clips the background to the 8px frame */
  background-size:cover;
  background-position:center;
}
.work-shot-motion img{
  display:block;
  /* Both, and explicitly: the poster carries width/height attributes, and
     those land as presentational hints that would otherwise size it. The
     same trap .work-shot fell into. */
  width:100%;height:100%;
  object-fit:cover;
  filter:grayscale(1);
}
/* Colour on hover happens either way - it is not motion, and holding it back
   would make the reduced-motion frames the only dead ones in the grid. */
.work-item:hover .work-shot-motion img,
.work-item:focus-within .work-shot-motion img{filter:none}

/* Which file each frame plays. A custom property, so the URL sits with the
   study it belongs to while the rule that resolves it is written once. */
.work-shot-fandom{--motion:url(../img/case-studies/fandom.gif)}
.work-shot-search{--motion:url(../img/case-studies/search.gif)}

/* The swap, and the whole of it is gated - not just the transition but the
   background-image too, which is what keeps the file unfetched rather than
   merely unanimated. That is why this is its own block rather than a line in
   the shared transitions query further down. */
@media (prefers-reduced-motion:no-preference){
  .work-item:hover .work-shot-motion,
  .work-item:focus-within .work-shot-motion{background-image:var(--motion)}
  .work-item:hover .work-shot-motion img,
  .work-item:focus-within .work-shot-motion img{opacity:0}
  .work-shot-motion img{transition:opacity .25s ease,filter .3s ease}
}
.work-title{font-size:var(--step-2);max-width:26ch}
.work-title a{color:var(--ink);text-decoration-thickness:0.045em;text-underline-offset:0.18em;padding-block:0.45rem}   /* 2.5.5: 44px tall target */
.work-title a:hover{color:var(--accent-strong);text-decoration-thickness:0.09em}
.work-outcome{margin-top:0.9rem;max-width:58ch}
.work-meta{margin-top:0.8rem;font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink-soft)}

/* The playground's experiments used to carry a preview column: an empty
   .thumb div per entry, hidden by default, switched on by adding
   "has-previews" to the section. It was never switched on, so the whole
   thing - five empty divs in the markup and about twenty lines here -
   rendered nothing on any page. Both ends have been taken out. An
   experiment that earns a picture can have an ordinary <img> in its entry. */

/* ---- Now playing (playground) ----
   A rail of cover art, newest first. The overflow does the scrolling, so it
   works with JS off, on touch, and with a trackpad; games.js only adds the
   arrows. The rail carries tabindex="0" because a scrollable region has to be
   reachable by keyboard (2.1.1), hence the padding, which gives its focus
   ring clear of the covers, which is what the padding is for. */
.now-playing{margin-block-start:clamp(3rem,7vw,4.5rem)}
.rail-nav{display:flex;gap:0.4rem}
.rail-nav[hidden]{display:none}   /* the flex above would otherwise beat [hidden] */
.rail-btn{
  color:var(--ink);background:none;cursor:pointer;
  border:1px solid var(--line-strong);border-radius:999px;
  min-width:44px;min-height:44px;   /* 2.5.5 AAA, same as the header controls */
  display:inline-flex;align-items:center;justify-content:center;
}
.rail-btn:hover:not(:disabled){background:var(--surface)}
.rail-btn:disabled{cursor:default;color:var(--ink-soft);border-color:var(--line);opacity:0.55}
.rail-btn svg{width:1.15rem;height:1.15rem}
/* The line under a section head, introducing what follows it. Was .now-note,
   named after the now-playing rail it started on; the 6174 page wanted the
   same thing under its Play chip, and one rule beats two that drift.

   NO top margin. .section-head already ends with 1rem of padding, and that is
   the gap under every chip on the site - a .prose paragraph after one takes
   it and adds nothing. This carried its own 1.8rem on top, so the Play chip
   sat 2.8rem off its line while The rule and The maths sat at 1rem, on the
   same page, a screen apart. The distance under a chip is the chip's to set. */
.section-note{max-width:58ch;color:var(--ink-soft)}
.rail{
  /* .shell's padding is asymmetric now - --pane-edge on the left, a plain
     --pane-gutter on the right - so the breakout has to cancel each side
     separately or the rail stops being flush with the pane. */
  overflow-x:auto;overscroll-behavior-x:contain;
  /* proximity, not mandatory: mandatory pulls a half-swipe back to the cover
     it started on, which reads as a rail that will not move. */
  scroll-snap-type:x proximity;
  scroll-padding-inline-start:var(--pane-edge);
  margin-top:1.9rem;
  /* Break out of the page margin so the row runs off the edge of the page
     rather than stopping neatly inside it. The scroll becomes something you
     can see rather than something you have to discover. Only the shell's own
     padding is cancelled, so this can never push the page itself sideways. */
  margin-inline:calc(-1 * var(--pane-edge)) calc(-1 * var(--pane-gutter));
  padding-inline:var(--pane-edge) var(--pane-gutter);
  padding-block:0.35rem 0.8rem;
  /* macOS hides overlay scrollbars until something scrolls, so the rail looked
     like a static row that had been cropped. Styling the bar keeps it on show,
     which is the plainest possible "this moves". */
  scrollbar-width:thin;scrollbar-color:var(--line-strong) transparent;
}
.rail::-webkit-scrollbar{height:0.55rem}
.rail::-webkit-scrollbar-track{background:transparent}
.rail::-webkit-scrollbar-thumb{background:var(--line-strong);border-radius:999px}
.rail::-webkit-scrollbar-thumb:hover{background:var(--ink-soft)}
.game-list{display:flex;flex-wrap:nowrap;gap:clamp(0.9rem,2.2vw,1.4rem);list-style:none;margin:0;padding:0}
.game-item{flex:0 0 auto;width:clamp(9.5rem,24vw,14.5rem);scroll-snap-align:start}
/* ---- Cover art, duotoned to the site's own ink ----
   Box art is loud - saturated reds and golds, eight different palettes in a
   row - next to a page that has exactly two colours in it. These are toned
   down to match rather than left to fight it.

   Two steps. grayscale(1) takes the original colour out of the photograph;
   the overlay then puts ONE hue back with mix-blend-mode:color, which takes
   hue and saturation from the top layer and luminance from the one beneath,
   so the picture keeps all of its detail and changes only its colour.

   The hue is --ink itself, not a baked-in sepia() - measured at 35 degrees
   and 23% saturation, which is the same warm brown-black as the body text.
   Flat grayscale would have read cold against #FAF8F3; this sits in the same
   warmth as the paper. And because it is the token doing the work, the tint
   re-mixes itself when the theme flips: --ink is a warm near-white in dark
   mode, so the covers warm the other way without a second rule.

   isolation:isolate is load-bearing. mix-blend-mode blends with whatever is
   behind it in the stacking context, so without this the overlay would reach
   past the cover and tint the page. Isolating makes the image its own
   backdrop, which is the only thing it should be blending with.

   The border and the radius moved up here from the image so overflow:hidden
   can clip the tint to the same rounded corners. */
.game-shot{
  position:relative;
  display:block;
  isolation:isolate;
  border-radius:8px;
  overflow:hidden;
  border:1px solid var(--line);
  background:var(--surface);       /* stands in while a lazy cover loads */
}
.game-shot::after{
  content:"";
  position:absolute;
  inset:0;
  background:var(--ink);
  mix-blend-mode:color;
  /* Full strength reads as sepia. Half lands on "monochrome, warm". */
  opacity:0.55;
  pointer-events:none;
}
.game-cover{
  display:block;width:100%;height:auto;aspect-ratio:2/3;object-fit:cover;
  filter:grayscale(1);
}
/* Point at a cover and it comes back in full colour. The tone is the resting
   state - eight box arts at full volume would shout down a page with two
   colours in it - and hovering is asking for one of them.

   Nothing is CONVEYED by this. The title and the genre are printed under
   every cover whether you point at one or not, which is why the images carry
   alt="" in the first place, so a reader who never hovers - or is on a touch
   screen, or using a keyboard - misses nothing but a flourish. That is what
   keeps it out of 1.4.1 and 1.4.13.

   :focus-within is here for the day something focusable goes inside one of
   these. Nothing in a .game-item takes focus today: the rail as a whole is
   the tab stop, not the individual covers. */
.game-item:hover .game-cover,
.game-item:focus-within .game-cover{filter:none}
.game-item:hover .game-shot::after,
.game-item:focus-within .game-shot::after{opacity:0}
.game-title{margin-top:0.9rem;font-size:var(--step-0)}
.game-meta{margin-top:0.35rem;font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink-soft)}
/* Smooth only when the reader has not asked for less motion. The arrows call
   scrollBy() without a behaviour of their own, so this one rule covers both. */
@media (prefers-reduced-motion:no-preference){.rail{scroll-behavior:smooth}}

/* ================================================================
   FORM FURNITURE. Four forms on this site once wrote out their own copy of
   these rules: the 6174 field, the Pomodoro minutes, the unlock dialog and
   the password form each case study used to carry.

   Only the dialog and the guilloché dials still PRINT a label. The case
   study's form went when the dialog became the only way to unlock anything,
   and the two single-field toys have since hidden theirs - one field is
   named by the copy around it, so the word over the box was repeating what
   the sentence beside it already said. Their labels are still there in the
   markup, just not on screen. Four sliders in a row cannot be named that
   way, which is why that page keeps its words.

   48px is over the 44px target floor (2.5.5), and --line-strong is the
   token that clears 3:1 for a control border (1.4.11). Both are the
   reason these rules exist rather than being left to the browser.
================================================================= */
#pw-modal label{
  display:block;font-family:var(--font-mono);font-size:var(--step--1);
  text-transform:uppercase;letter-spacing:0.06em;color:var(--ink-soft);margin-bottom:0.6rem;
}
/* The 6174 and Pomodoro fields. The password inputs are laid out by
   .pw-row instead, further down: they sit in a row beside their buttons
   rather than filling the width, which is the one thing they do
   differently. The guilloché dials are ranges, not text boxes, and are
   styled with the rest of that page. */
.calc input,
.pom input{
  width:100%;min-height:48px;padding:0 0.9rem;
  font-family:var(--font-mono);font-size:var(--step-0);color:var(--ink);
  background:var(--paper);border:1px solid var(--line-strong);border-radius:8px;
}

/* ---- 6174 calculator (kaprekar.html) ---- */
.calc form{display:flex;flex-wrap:wrap;gap:0.6rem;align-items:flex-end;margin-top:1.8rem}
.calc-field{flex:1 1 11rem}
.calc input[aria-invalid="true"]{border-color:var(--error)}
.calc-hint{margin-top:0.9rem;font-size:0.95em;color:var(--ink-soft);max-width:62ch}
.calc-error{
  margin-top:0.9rem;color:var(--error);
  font-family:var(--font-mono);font-size:var(--step--1);max-width:62ch;
}
.calc-summary{margin-top:2.2rem;font-size:var(--step-1);max-width:62ch}
.calc-steps{
  list-style:none;margin:1.2rem 0 0;padding:0;
  font-family:var(--font-mono);font-size:var(--step-0);
}
.calc-steps li{padding:0.7rem 0;border-block-start:1px solid var(--line)}
.calc-steps li.is-constant{font-weight:500;color:var(--ink)}
.calc-steps li.is-constant::after{
  content:" ← Kaprekar's constant";
  font-size:var(--step--1);color:var(--ink-soft);
}

/* ---- Pomodoro timer (playground/pomodoro/) ----
   The setup page stays plain --paper: it used to carry its own rising
   fill behind the copy, which has been taken out. The session only shows
   on the screen in focus mode now, which pomodoro.js paints onto a
   canvas, and nothing on this page reads a progress variable any more. */
.pom-display{
  margin-top:2.4rem;
  font-family:var(--font-mono);font-size:var(--step-4);font-weight:500;
  font-variant-numeric:tabular-nums;   /* fixed-width digits: no jitter per second */
  line-height:1;letter-spacing:-0.02em;
}
/* nowrap, deliberately. The form had flex-wrap:wrap, so when the row ran out
   of width its own children broke apart and Reset dropped under Start - two
   buttons that belong side by side, split down the middle.

   The wrapping belongs one level up, on .pom-bar: when things stop fitting,
   the whole form should drop to its own line intact rather than come apart.
   So the bar wraps and the form never does.

   The field is the only part that gives - flex:0 1 keeps it shrinking ahead
   of the buttons - down to a floor that still shows two digits. The buttons
   cannot be squeezed below their text either way: a flex item's automatic
   minimum size is its min-content width, which for these is the word plus its
   padding. */
.pom form{display:flex;flex-wrap:nowrap;gap:0.6rem;align-items:center;margin-top:1.6rem}
.pom-field{flex:0 1 7rem;min-width:4.25rem}
.pom input:disabled{color:var(--ink-soft);background:var(--surface);cursor:not-allowed}
.pom-hint{margin-top:0.9rem;font-size:0.95em;color:var(--ink-soft);max-width:62ch}
/* Every <kbd> on the site, not just the two contexts that happened to have
   one when this was written. It read .pom-hint kbd,.prose kbd, so the moment
   an Esc key moved into a .section-note it came out as plain text - a keycap
   with no cap. There is nothing about a <kbd> that wants to look different
   depending on the paragraph it sits in. */
kbd{
  font-family:var(--font-mono);font-size:0.85em;
  border:1px solid var(--line-strong);border-radius:4px;padding:0.1em 0.4em;
}

/* Focus mode: the running timer takes the whole screen.

   The screen itself is a canvas now: ground, shape and count are painted
   by pomodoro.js, which also rolls a fresh shape and path for every
   session. The note above stage() there records why: three CSS versions
   of this effect died on blend modes, stacking contexts and the top
   layer, and the canvas has no opinion about any of them.

   What is left here is the frame around it: the fixed full-screen box,
   the exit, and the two states the stylesheet still owns.

   NO AAA EXCEPTION, unlike every earlier version of this screen. Those
   filled the display and swallowed the count as they went, which had to
   be argued against 1.4.6. The painted count switches between ink and
   paper per pixel, whichever its backdrop is not, so it holds 15.6:1
   everywhere, including the glyphs the shape's edge runs through.

   2.2.2: the movement is decorative, slow, and stopped by
   prefers-reduced-motion (pomodoro.js draws one still frame instead);
   Esc leaves the whole view. */
.pom-full{
  position:fixed;inset:0;width:100%;height:100%;
  z-index:30;                 /* over .to-top (15) and the skip link (20) */
  margin:0;padding:0;border:0;overflow:auto;
  background:var(--paper);color:var(--ink);
}
.pom-full[hidden]{display:none}   /* the rules above would beat [hidden] */
/* .pom-canvas, not .pom-field: that name already belongs to the minutes
   field on the setup form above, and a full-screen rule landing on it turns
   a small form div into a sheet over the whole page. Anything full-screen
   here needs a name nothing on the page shares. */
.pom-canvas{
  position:fixed;inset:0;display:block;width:100%;height:100%;
}
/* With the canvas drawing it, the count is there to be heard, not seen.
   No canvas context and the attribute is never set, so the <p> stays on
   screen and the timer still reads. It simply loses its backdrop. */
.pom-full[data-canvas] .pom-full-clock{
  position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;
}
.pom-full-inner{
  position:relative;z-index:1;min-height:100%;   /* over the canvas */
  display:flex;flex-direction:column;align-items:center;justify-content:center;
  gap:1.2rem;padding:clamp(1.5rem,5vw,3rem);text-align:center;
}
.pom-full-clock{
  font-family:var(--font-mono);font-weight:500;
  font-variant-numeric:tabular-nums;
  /* rem ends, so the A / A+ / A++ control still moves it */
  font-size:clamp(4rem,18vw,15rem);
  line-height:1;letter-spacing:-0.03em;
}
.pom-full-note{
  font-family:var(--font-mono);font-size:var(--step--1);
  text-transform:uppercase;letter-spacing:0.06em;
}
/* Zero. The canvas floods with --ink at that point, so the note turns over
   with it. The count is painted, so it looks after itself. */
.pom-full.is-done .pom-full-note{color:var(--paper)}
.pom-close{
  position:absolute;z-index:2;
  top:clamp(1rem,3vw,2rem);right:clamp(1rem,3vw,2rem);
  width:3rem;height:3rem;                      /* 48px, over the 44px floor (2.5.5) */
  display:inline-flex;align-items:center;justify-content:center;
  background:var(--paper);color:var(--ink);
  border:1px solid var(--line-strong);border-radius:999px;cursor:pointer;
}
.pom-close:hover{background:var(--surface)}
.pom-close svg{width:1.3rem;height:1.3rem}


/* ---- Guilloché (playground/guilloche/) ----
   The picture is a canvas because it is thousands of strokes: the same
   pattern as SVG is thousands of elements the browser then has to keep,
   lay out and hit-test, none of which anything here asks it to do. The
   Export SVG button writes those elements when they are wanted, which
   is once, on purpose, and not on every frame of a slider drag.

   The CSS box is what decides the size, not the canvas's own width and
   height attributes. guilloche.js reads clientWidth/clientHeight to
   size the backing store and centres the pattern on whatever it finds,
   so the box has to have a size before the script runs, and it has to
   keep that size while the script rewrites the attributes underneath.
   A ResizeObserver redraws it whenever the box moves. */
/* THE PLATE DOES NOT FOLLOW THE THEME, and that is the one thing about
   it worth knowing. Everything else on this site repaints when the
   toggle moves; this stays white with dark line work in both.

   It is a picture, and the site already treats pictures this way - a
   case-study screenshot and a game cover do not invert either. Engine
   turning is ink on paper, so the plate is paper, and a rosette that
   went from dark-on-white to light-on-black halfway down the page would
   be showing you two different objects.

   Both colours are tokens rather than literals in guilloche.js, because
   the script paints the canvas AND writes the exported SVG, and those
   two agreeing about what colour the plate is should not depend on two
   files being edited together. If they are ever made theme-aware, the
   redraw-on-theme-change that used to live in that file has to come
   back with them - see the note at the foot of it. */
.guil-stage{
  --guil-paper:#ffffff;
  --guil-ink:#201B14;   /* the light theme's --ink: 16:1 on white */
  margin-top:1.8rem;
  border:1px solid var(--line);border-radius:8px;
  background:var(--guil-paper);overflow:hidden;
}
/* The square is set HERE and not on the box around it. A percentage
   height on the canvas would have to resolve against a parent height,
   and the parent has none of its own; aspect-ratio against a definite
   width does not need one. It also settles the other way round: a
   canvas has an intrinsic size from its width/height attributes, and a
   render rewrites those, so a height left to the intrinsic ratio would
   be a render feeding its own next measurement. */
.guil-canvas{
  display:block;width:100%;aspect-ratio:1/1;
  /* A square that runs the whole 72rem column is over 1100px tall, which
     is a picture you scroll rather than look at. The cap wins over the
     ratio when it bites, leaving a box wider than it is tall - which
     costs the pattern nothing, because the families that draw a circle
     size it off min(W,H) and stay centred in whatever they are given.

     Two limits, not one: vh so it always fits the screen it is on, rem
     so it stops growing on a tall monitor rather than becoming the only
     thing on the page.

     The vh figure is not a free choice. These families draw a circle, so
     the height is the ONLY thing setting how big the pattern comes out -
     the extra width past it is margin. Anything under about 72vh makes
     the picture smaller on a laptop than the old 36rem box did, which
     would be a full-width frame around a shrunken pattern.

     ONE BOX FOR ALL THREE FAMILIES. The band had a letterbox of its own
     for a while, on the reasoning that it runs across rather than
     around. What that actually bought was a frame that jumped every
     time you touched the picker - and the picker is there to compare
     the three, which you cannot do while the thing under it resizes.
     Each family already fits itself to whatever box it is handed: the
     circular two off min(W,H), the band off the height. So the box
     stops being a per-family decision and the comparison holds still. */
  max-height:min(75vh,44rem);
}

/* ---- The family picker ----
   Radios, so exactly-one-of-three is what it is rather than what some
   script maintains: arrow keys, "2 of 3" and the group name all come
   with the element. The inputs are moved off screen rather than
   display:none, which would take them out of the tab order, and the
   ring is drawn on the label instead. */
.guil-modes{border:0;padding:0;margin:1.6rem 0 0;min-width:0}
.guil-seg{
  position:relative;display:flex;gap:2px;padding:3px;
  border:1px solid var(--line-strong);border-radius:999px;
}
.guil-seg input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
.guil-seg label{
  flex:1;min-height:44px;                      /* 2.5.5 target floor */
  display:inline-flex;align-items:center;justify-content:center;
  border-radius:999px;cursor:pointer;text-align:center;
  font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink-soft);
}
.guil-seg input:not(:checked) + label:hover{background:var(--surface);color:var(--ink)}
.guil-seg input:checked + label{background:var(--ink);color:var(--paper)}
.guil-seg input:focus-visible + label{outline:3px solid var(--accent);outline-offset:3px}

/* ---- Groups of dials ----
   fieldset and legend, not a div and a heading: these ARE groups of
   form controls, and taking the element means a screen reader says
   which group a slider is in when it reaches it. Both need their
   browser furniture taken off first. */
.guil-group{border:0;padding:0;margin:1.6rem 0 0;min-width:0}
.guil-group legend{
  padding:0;margin-bottom:0.5rem;
  font-family:var(--font-mono);font-size:var(--step--1);
  text-transform:uppercase;letter-spacing:0.06em;color:var(--ink-soft);
}
/* auto-FILL, not auto-fit. auto-fit collapses the tracks nothing landed
   in and hands their width back to the rows that did, so the Rendering
   group - one dial - would get a slider the whole column wide while
   Geometry's two got half of it each. Keeping the empty tracks keeps
   every slider the same length whichever group it is in, which is the
   only reason a reader can compare two of them at a glance.

   A slider is also a control you aim at: past about 20rem the extra
   travel buys no precision and costs a longer drag. */
.guil-dials{
  display:grid;grid-template-columns:repeat(auto-fill,minmax(16rem,1fr));
  gap:0.6rem 2rem;
}
/* Name on the left, number on the right, slider under both. The number
   is aria-hidden in the markup: a range input announces its own value,
   so this is the sighted half of the same fact. */
.guil-dial{
  display:grid;grid-template-columns:1fr auto;align-items:baseline;
  column-gap:0.75rem;
}
.guil-dial[hidden]{display:none}   /* the grid above would beat [hidden] */
.guil-dial label{font-size:var(--step--1)}
.guil-val{
  font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink);
  font-variant-numeric:tabular-nums;   /* the row does not twitch while you drag */
}
/* accent-color rather than a rebuilt track and thumb: it paints the fill
   and the handle in both themes from one token, and leaves the control a
   real range input - keyboard stepping, page-up jumps and the platform's
   own focus ring all intact. A hand-drawn one loses those and has to put
   each of them back.

   44px of height, not 44px of thumb (2.5.5). The whole row is the target
   on a touch screen, which is what the pointer actually hits. */
.guil input[type="range"]{
  grid-column:1 / -1;
  width:100%;min-height:44px;margin:0;
  accent-color:var(--ink);cursor:pointer;
}
/* The colour field: a typed control sitting in a row of sliders. It
   spans the row the way a range does, because there is no readout span
   beside it - it prints its own value, which is the whole reason it is
   a text box and not a swatch.

   48px and --step-0, matching every other typed field on the site
   rather than the 44px of the sliders around it. The four pixels are
   worth more as consistency with the seed box, the 6174 field and the
   password dialog than as alignment with a neighbour it shares nothing
   else with. */
.guil-dial input[type="text"]{
  grid-column:1 / -1;
  width:100%;min-height:48px;padding:0 0.9rem;
  font-family:var(--font-mono);font-size:var(--step-0);color:var(--ink);
  background:var(--paper);border:1px solid var(--line-strong);border-radius:8px;
  text-transform:uppercase;   /* #201b14 and #201B14 are one colour */
}
/* Set by the script when the box holds something that is not a colour
   yet. The plate keeps the last one that parsed, so this marks a
   disagreement between the field and the picture rather than an error
   that has stopped anything. */
.guil-dial input[aria-invalid="true"]{border-color:var(--error)}

/* ---- Advanced ----
   A real <details>: it opens with JavaScript off, and the state, the
   keyboard and what a screen reader says about it are the browser's
   job rather than this file's. No display on the summary, deliberately
   - display:flex takes the disclosure marker away in WebKit. */
.guil-adv{
  margin-top:1.8rem;
  border-block-start:1px solid var(--line);padding-block-start:1.2rem;
}
.guil-adv summary{
  cursor:pointer;padding-block:0.6rem;
  font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink-soft);
}
.guil-adv summary:hover{color:var(--ink)}
.guil-adv .guil-group:first-of-type{margin-top:0.8rem}
.guil-seed{margin-top:1.6rem}
.guil-seed label{
  display:block;margin-bottom:0.5rem;
  font-family:var(--font-mono);font-size:var(--step--1);
  text-transform:uppercase;letter-spacing:0.06em;color:var(--ink-soft);
}
/* Not .guil input[type=range]'s neighbour by accident: this is the one
   typed control on the page, so it gets the text-box treatment the
   other two toys' fields get rather than a slider's. */
.guil-seed input{
  width:100%;max-width:12rem;min-height:48px;padding:0 0.9rem;
  font-family:var(--font-mono);font-size:var(--step-0);color:var(--ink);
  background:var(--paper);border:1px solid var(--line-strong);border-radius:8px;
}
.guil-actions{display:flex;flex-wrap:wrap;gap:0.6rem;margin-top:1.6rem}
.guil-hint{margin-top:0.9rem;font-size:0.95em;color:var(--ink-soft);max-width:62ch}

/* Case study */
.cs-header{padding-block:var(--page-top) 2rem}
.cs-header h1,
.cv-section > h1{font-size:var(--step-3);max-width:22ch}
.cs-grid{
  display:grid;grid-template-columns:minmax(13rem,16rem) minmax(0,1fr);
  gap:clamp(2rem,5vw,4.5rem);align-items:start;padding-block:1.5rem 4rem;
}
/* Sticks BELOW the topbar, not under it. top:1.5rem parked it 24px from the
   top of the viewport, which is inside the bar's first 61px, so the Role /
   Team panel scrolled up behind the breadcrumb. */
.context{
  position:sticky;top:calc(var(--topbar-h) + 1rem);
  border:1px solid var(--line);border-radius:8px;
  padding:1.4rem;background:var(--surface);
}
.context dl{margin:0}
.context dt{
  font-family:var(--font-mono);font-size:var(--step--1);
  text-transform:uppercase;letter-spacing:0.06em;color:var(--ink-soft);
}
.context dd{margin:0.35rem 0 1.15rem;font-size:0.95em}
.context dd:last-child{margin-bottom:0}
.context .big{font-weight:600}
@media (max-width:52rem){.cs-grid{grid-template-columns:1fr}.context{position:static}}

/* Long-form prose. WCAG 1.4.8 AAA: ≤80ch measure, 1.55 line spacing,
   paragraph spacing ≥ 1.5× line spacing, never justified */
.prose{max-width:62ch}
.prose p{margin:0 0 2.4em}
.prose h2{font-size:var(--step-2);margin:2.6em 0 1em}
.prose h3{font-size:var(--step-1);margin:2.2em 0 0.9em}
.prose ul{margin:0 0 2.4em;padding-left:1.2em}
.prose li{margin-bottom:0.9em}
.prose li::marker{color:var(--accent)}
/* A paragraph that introduces a list belongs to the list, so it sits at
   the list's own spacing rather than at the gap between paragraphs. It
   was inheriting the 2.4em above, which put nearly three times more air
   before the first bullet than between any two of them and read as a
   break where there is none.

   :has() rather than a class on the paragraph, so this is a rule about
   what the markup MEANS and not something an author has to remember. A
   browser without it simply keeps the old 2.4em - the paragraph is
   still a paragraph and the list is still a list. */
.prose p:has(+ ul){margin-bottom:0.9em}
.rejected{border-left:3px solid var(--accent);background:var(--surface);border-radius:0 8px 8px 0;padding:1.3rem 1.4rem;margin:0 0 2.4em}
.rejected p{margin:0 0 1em}
.rejected p:last-child{margin:0}
.flag{
  font-family:var(--font-mono);font-size:var(--step--1);
  text-transform:uppercase;letter-spacing:0.06em;color:var(--accent-strong);
  display:block;margin-bottom:0.6rem;
}
figure{margin:0 0 2.4em}
figcaption{font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink-soft);margin-top:0.8rem}

/* No .pagenav rule any more. Every page once ended in a "back / next" row -
   inside the encrypted content on the case studies, in the markup on the two
   playground toys. All of them are gone: the rail is on every page, lists
   every page, and marks where you are, so a second set of controls at the
   foot of the page was saying the same thing twice. If you ever want one
   back, it was a flex row with a top rule and 44px targets. */

/* The contact footer used to sit here, on every page: a Contact heading,
   the email and LinkedIn, and a colophon line. It is gone - those two links
   live in the rail now, on every page, which is where the footer's only job
   went. It took the site's one 1.4.8 exception with it (the block ran full
   width on an uncapped measure, at a 0.75rem paragraph gap); the Pomodoro
   overlay is now the only knowing exception left on the site.

   Nothing links to #contact any more. If you ever want the colophon back,
   it read: "Human-made and designed, ai-assisted build. HTML, CSS, a little
   vanilla JS. WCAG AAA-checked." */

/* With no footer to close the page, the pane's own bottom padding is what
   stops the last item sitting flush against the bottom of the viewport. */
.pane > main{padding-block-end:clamp(2rem,5vw,3.5rem)}

/* Highlighted labels: every mono label sits in a permanent selection
   swatch. Same ink/paper pair as ::selection, so it reads as text the
   visitor already highlighted. Both pairs verified AAA in both themes. */
.eyebrow,
.section-title,
.flag,
.sidenav-label a{
  display:inline-block;
  justify-self:start;      /* keeps the swatch hugging the text inside grids */
  background:var(--accent);
  color:var(--paper);
  padding:0.18em 0.45em;
  box-decoration-break:clone;
  -webkit-box-decoration-break:clone;
}
/* A real selection still has to read on top of the swatch */
.eyebrow::selection,
.section-title::selection,
.flag::selection,
.sidenav-label a::selection{background:var(--paper);color:var(--accent)}

/* ---- Back to top ----
   Fixed corner control, revealed once you have scrolled a screen. It is a
   real anchor to #top, so it uses the browser's own scroll + focus handling
   and honours the motion-safe scroll-behavior above. 48px target (2.5.5);
   ink on paper (>=15:1); border on --line-strong (>=3:1, 1.4.11). app.js
   hides it whenever it would overlap the focused element (2.4.12). */
.to-top{
  position:fixed;
  right:clamp(1rem,3vw,2rem);
  bottom:clamp(1rem,3vw,2rem);
  z-index:15;
  width:3rem;height:3rem;             /* 48px, comfortably over the 44px floor */
  display:inline-flex;align-items:center;justify-content:center;
  background:var(--paper);
  color:var(--ink);
  border:1px solid var(--line-strong);
  border-radius:999px;
  text-decoration:none;
  box-shadow:0 2px 10px rgb(18 15 11 / 0.10);
}
.to-top:hover{background:var(--surface);color:var(--ink);text-decoration:none}
.to-top svg{width:1.3rem;height:1.3rem}
.to-top[hidden]{display:none}

/* Motion: one quiet layer, fully wrapped in motion-safe */
@media (prefers-reduced-motion:no-preference){
  html{scroll-behavior:smooth}
  a,button{transition:color .15s ease,background-color .15s ease,border-color .15s ease}
  .seg button:active,.theme-toggle:active{transform:scale(0.96)}

  /* Sidebar. The column width carries the collapse; visibility waits out the
     slide on the way closed (so the rail is not blanked mid-animation) and
     flips back with no delay on the way open. */
  .layout{transition:grid-template-columns .2s ease}
  /* The panel fades in over the same 200ms the rail takes to open, so the
     icon and the thing it describes arrive together. */
  .nav-toggle-panel{transition:fill-opacity .2s ease}

  /* Cover art coming back to colour under the pointer. Slower than the
     controls at .15s - this is a picture developing, not a button
     acknowledging a click. With reduced motion the swap still happens, it
     just happens at once. */
  .game-cover,
  img.work-shot{transition:filter .3s ease}
  .game-shot::after{transition:opacity .3s ease}
  .sidenav{transition:transform .2s ease,visibility 0s}
  :root[data-js][data-nav="closed"] .sidenav{transition:transform .2s ease,visibility 0s linear .2s}
  .scrim{transition:opacity .2s ease}
  /* Same idea for the drawer, where closed is the default state rather than
     an attribute: hold the rail visible until it has finished sliding out. */
  @media (max-width:50rem){
    :root[data-js] .sidenav{transition:transform .2s ease,visibility 0s linear .2s}
    :root[data-js] .layout.drawer-open .sidenav{transition:transform .2s ease,visibility 0s}
  }

  /* Cross-page crossfade (View Transitions API): activates on a real host; harmless elsewhere */
  @view-transition{navigation:auto}
  ::view-transition-old(root),::view-transition-new(root){animation-duration:0.25s}

  /* The rail is identical on every page, so naming it lifts it out of the
     root snapshot and the crossfade above is left animating the pane alone.
     Its own group is then told to hold still, which is what makes the rail
     read as furniture the pages move behind rather than something that
     redraws on every navigation. */
  .sidenav{view-transition-name:sidenav}
  ::view-transition-group(sidenav),
  ::view-transition-old(sidenav),
  ::view-transition-new(sidenav){animation:none}
}
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
}

/* ---- Case-study media + quotes ---- */
.prose figure img{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:8px}
.prose figure.narrow{max-width:21rem}
.prose blockquote{margin:0 0 2.4em;padding:0.2rem 0 0.2rem 1.25rem;border-left:3px solid var(--line);font-style:italic;font-size:var(--step-1);line-height:1.45}
.prose blockquote p{margin:0 0 0.75em}
.prose blockquote footer{font-style:normal;font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink-soft)}

/* ---- Protected case studies: lock badges + password modal ---- */
.lock-badge{display:inline-flex;align-items:center;gap:0.4rem;vertical-align:-0.1em}
.lock-badge svg{width:0.95em;height:0.95em}
.icon-unlocked{display:none}
body[data-unlocked="true"] .icon-unlocked{display:inline}
body[data-unlocked="true"] .icon-locked{display:none}
body[data-unlocked="true"] .lock-badge{color:var(--accent-strong)}
.unlock-btn{display:inline-flex;align-items:center;gap:0.5rem}
.unlock-btn svg{width:1em;height:1em}
.unlock-btn[disabled]{color:var(--accent-strong);cursor:default}
.unlock-btn[disabled]:hover{background:none}

#pw-modal{
  background:var(--paper);color:var(--ink);
  border:1px solid var(--line);border-radius:12px;
  padding:1.75rem;max-width:26rem;width:calc(100% - 2.5rem);
}
#pw-modal::backdrop{background:rgba(18,15,11,0.55)}
.modal-title{font-size:var(--step-1);margin:0 0 0.75rem}
.modal-copy{color:var(--ink-soft);margin:0 0 1.5rem;font-size:0.95em}

/* ---- The password form ----
   There is one of these now, in the dialog, and it is the only way into a
   protected study from anywhere on the site. It was written out twice for
   a while - once for the modal, once for the password form each locked
   case study carried - byte for byte identical, because they were the same
   door drawn twice. The second door is gone; see src/templates/gate.html. */
.pw-row{display:flex;flex-wrap:wrap;gap:0.6rem}
.pw-row input{
  flex:1 1 9rem;min-height:48px;padding:0 0.9rem;
  font:inherit;font-size:var(--step-0);color:var(--ink);
  background:var(--paper);border:1px solid var(--line-strong);border-radius:8px;
}
.pw-row input[aria-invalid="true"]{border-color:var(--error)}
.btn{
  font-family:var(--font-mono);font-size:var(--step--1);color:var(--ink);
  min-height:48px;min-width:44px;padding:0 1.2rem;border-radius:999px;
  border:1px solid var(--line-strong);background:none;cursor:pointer;
}
.btn:hover{background:var(--surface)}
.btn-primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.btn-primary:hover{background:var(--paper);color:var(--ink)}   /* invert: no hue left to shift to */
.btn-primary:disabled{opacity:0.7;cursor:wait}
.pw-error{color:var(--error);font-family:var(--font-mono);font-size:var(--step--1);margin-top:0.9rem}
.modal-actions{display:flex;gap:0.6rem;margin-top:1.25rem}
.modal-note{margin:1.5rem 0 0;color:var(--ink-soft);font-size:0.95em}
@media (prefers-reduced-motion:no-preference){
  #pw-modal[open]{animation:modal-in 0.18s ease}
  @keyframes modal-in{from{opacity:0;transform:translateY(6px) scale(0.98)}}
}

/* ---- A locked case study ----
   No form here any more - the dialog above is the only one. What is left is
   the page a protected study shows before it is unlocked: what it is, why it
   is shut, and a button that opens the dialog. Page spacing and a heading at
   page scale, because unlike the dialog this IS the page. */
.gate{padding-block:var(--page-top) 4rem;max-width:62ch}
.gate h1{font-size:var(--step-3)}
.gate .lede{margin-top:1.4rem}
.gate-actions{margin-top:2.2rem}
.gate-note{margin-top:2rem;color:var(--ink-soft)}

/* ---- CV entries ----
   Full shell width, same inline padding as everything else (it sits inside
   .shell). Rows: label column + ≤62ch body. .cv-meta uses --ink-soft,
   AAA-verified ≥7:1 in both themes.

   Both halves of the CV use this grid and both hang off .timeline, so the
   columns line up the whole way down the page rather than restarting at
   Education. Nothing distinguishes the two lists in CSS any more - they are
   the same list with different rows in it, which is the only reliable way to
   keep two things aligned. */
/* Education's heading and the page header's are the same thing at the same
   size - two sections, two h1s - so the size lives on both selectors rather
   than being set twice and drifting. .cs-header supplies the page's top
   spacing; .cv-section supplies the gap between the two halves. */
.cv-section{margin-block-start:clamp(3rem,7vw,4.5rem)}
.cv-section > h1{margin-block-end:2rem}
.cv-entry{
  display:grid;
  grid-template-columns:1fr;
  gap:0.35rem clamp(2rem,5vw,4.5rem);
}
@media (min-width:44rem){
  .cv-entry{grid-template-columns:minmax(15rem,20rem) minmax(0,1fr)}
}
/* The role, or the institution: an h2 under its own section's h1.

   font-weight is stated because it used to be implied. This was an h4 once,
   and h4 is not in this sheet's h1,h2,h3 reset, so it was quietly taking the
   browser's bold; every promotion since would have lightened it to 500
   without anyone deciding to. The weight it has always rendered at is
   written down instead. It is doing real work at body size, where it is the
   one thing that has to be scannable down the column. */
.cv-entry h2{font-size:var(--step-0);font-weight:700;margin:0}
.cv-meta{
  font-family:var(--font-mono);
  font-size:var(--step--1);
  color:var(--ink-soft);
  margin:0.35rem 0 0;
}
.cv-entry .cv-body{margin:0;max-width:62ch}
.cv-entry .cv-body p{margin:0 0 2.4em}   /* 1.4.8: >= 1.5x the 1.55 line spacing */
.cv-entry .cv-body p:last-child{margin-bottom:0}

/* ---- The CV timeline ----
   One rail down the left, a node per entry. BOTH halves of the CV use it -
   Experience and Education are the same list with different rows in it -
   which is what keeps their columns in line: one class, one inset, one grid,
   so there is no second set of numbers to hold in step.

   The two got there the long way. Education was flush and rule-separated at
   first, which put its columns out of line with the roles above; then inset
   to match, which left two empty rem between the Education heading and the
   first institution and read as a mistake. The rail fills that space in both
   halves, for the same reason, and the alignment stops being something to
   maintain.

   padding-block-start clears the first node; the rest of the padding zeroes
   the indent a browser gives an <ol> by default. */
.timeline{
  --cv-rail:clamp(1.5rem,3.5vw,2.25rem);
  list-style:none;
  margin:0.6rem 0 0;
  padding:0.4rem 0 0 var(--cv-rail);
  border-inline-start:2px solid var(--line);
}
.timeline .cv-entry{
  position:relative;
  padding-block:0 clamp(2.1rem,4vw,3rem);
  gap:0.75rem clamp(2rem,5vw,4.5rem);
}
.timeline .cv-entry:last-child{padding-block-end:0}
.timeline .cv-entry::before{            /* the node, centred on the rail */
  content:"";
  position:absolute;
  left:calc(-1 * var(--cv-rail) - 1px);
  top:0.42em;
  width:0.72rem;
  height:0.72rem;
  border-radius:50%;
  background:var(--paper);
  border:2px solid var(--accent);
  transform:translateX(-50%);
}
/* Current role: the yellow node, halo of the same. --highlight is the site's
   one accent - the shape behind the home page's heading and the rail's group
   labels on hover - and this is the third thing it marks.

   THE BORDER STAYS --accent, and it is load-bearing rather than left over.
   Every node on the rail carries that 2px ring; on the light theme the
   yellow is 1.01:1 against --paper, so the ring is the only thing drawing
   this circle rather than letting it dissolve into the page.

   Which leaves one thing worth knowing. This node used to be a solid ink
   dot among hollow paper ones - 16.1:1 apart, unmistakable at a glance and
   in greyscale. Yellow and --paper are the same lightness, so on the LIGHT
   theme "which role is current" is now carried by hue alone: vivid to most
   people, invisible to anyone who does not see colour. The dark theme has
   no such problem, the fill being 17.2:1 against its paper. If that matters,
   the halo is the place to fix it - an --accent halo around a yellow node
   puts the lightness difference back without touching the dot itself. */
.timeline .cv-entry[data-current]::before{
  background:var(--highlight);
  box-shadow:0 0 0 4px color-mix(in srgb, var(--highlight) 20%, transparent);
}

