
/* ============================================================================
   THEME: Paper — "Stationery"  (Phase 3.5 deep exploration)

   THESIS
   A real desk of physical stationery. The notes and cards stop being
   "yellow + blue rectangles" and become the signature designed object of the
   whole site: a considered 6-hue paper system, each note a distinct material
   with its own tint of fiber, layered contact+ambient shadows, a fixed hand
   tilt, and one tactile detail done well — a folded corner, a real fold rather
   than a triangle drawn on. The checkbox list and
   highlighter marks speak the same marker-and-paper language.

   PALETTE — "Graphite": neutral, no temperature cast, one cool accent.
   (all note hues carry AA-legible dark ink text; verified below)
   ─ paper base      #F6F6F5   body / canvas
   ─ card paper      #FFFFFF   index cards, figures, gate
   ─ ink             #1A1A19   primary text, marker fills
   ─ ink soft        #52524E   secondary text
   ─ body ink        #2F2F2C   body copy
   ─ pencil          #6E6E68   captions, metadata
   ─ hairline        rgb(var(--pp-shade-rgb) / .12)
   ─ accent          #0B84FF   selection CHROME only — non-text, 3.37:1
   ─ accent deep     #0A5FBF   accent as text / button fill — 5.71:1, 6.17 on white
   ─ accent press    #084A96   filled-button hover — 8.64:1 on white
   NOTE PAPERS (roles unchanged; hues desaturated onto the neutral axis):
   ─ white   #FFFFFF  primary note   → .about-me (plain stock, no hue)
   ─ sky     #C3DCEA  testimonial 1  → .collab .quote:nth(1), took blush's slot
   ─ canary  #F0E7A4  unused         → freed when .about-me was retinted
   ─ kraft   #DCD5C6  filed label    → .hero-note-2 (small annotation slip)
   ─ blush   #EBC9C6  unused         → freed when testimonial 1 went sky
   ─ mint    #C8E0D2  testimonial 2  → .collab .quote:nth(2)
   ─ lavender#D5CEE9  reserve/tab    → case-study card tab, add-item chip
   TAPE       rgba neutral translucent strip, low-alpha, matte

   FONTS  (one family for the whole site — Aeonik, default weight 400)
   ─ display  Aeonik        headlines, note titles, numbers
   ─ body     Aeonik        copy, UI, note text (default 300)
   ─ accent   Aeonik        At-a-glance lede, outcomes (was Fraunces italic)
   ─ mono     Aeonik Mono   eyebrows, metadata, the hint note

   WHAT'S NEW vs the locked base
   • A real 6-hue note SYSTEM with assigned roles (was just yellow + blue).
   • Every note gets: per-note fiber tint, layered contact+ambient shadow,
     a fixed hand --tilt, and a folded corner (::after) — zero layout shift.
   • Notes read as paper through material alone: grain, cut-paper edge, hand tilt,
     and a bottom-only lift. No fold, no tape.
   • Checkbox list restyled as hand-stamped ticks; marks read as marker strokes.
   • Collab testimonials become two filed index cards in blush + mint.
   • Case-study "At a glance" gets a lavender filed-tab + card-stock body.
   • Selection chrome retuned (kept): accent dots with white cores.

   JS HOOKS (CSS written assuming a shared script supplies these; listed out):
   • --rand-hue on a .draggable at pointerenter → random highlighter tint on
     hover (falls back to accent hue 12 if unset).
   • --px / --py (0–1 cursor pos) on <body> → very subtle warm light drift on
     the paper (falls back to a fixed centre if unset).
   ============================================================================ */

:root {
  /* inverted neutral-gray ramp: keeps semantic direction so consumers flip light */
  --gray-50:  #1A1A19;
  --gray-100: #262624;
  --gray-200: #3B3B38;
  --gray-300: #52524E;
  --gray-400: #6A6A65;
  --gray-600: #9C9C96;
  --gray-700: #D2D2CD;
  --gray-800: #EDEDEA;
  --gray-900: #F6F6F5;

  /* selection chrome, back to a literal selection blue — which is where this
     token's name came from before the red-pencil detour */
  --blue-400: #0B84FF;

  /* case-study consumes --mint for eyebrows/bullets/links (text — needs the
     deep tone, not the bright chrome one) */
  --mint: #0A5FBF;

  /* foundation tokens */
  --pp-paper: #F6F6F5;
  --pp-card: #FFFFFF;
  /* die-cut sticker edge — white in BOTH themes on purpose: a vinyl sticker's
     border stays white whatever it's stuck to, and that is what sells the
     material in dark mode. Not --pp-card, which goes near-black there. */
  --pp-sticker-edge: #FFFFFF;
  --pp-ink: #1A1A19;
  /* Channels for every shadow and scrim, so they vary alpha only and still
     follow the palette. Deliberately NOT tied to --pp-ink: in dark mode ink
     goes light, and mirroring it here would turn every shadow into a glow.
     Light = ink-coloured (the "shadow voice"); dark = pure black. */
  --pp-shade-rgb: 26 26 25;
  /* The ink's channels, for tints that must FOLLOW the theme. Separate from
     --pp-shade-rgb on purpose: that one is the shadow voice and goes pure black
     in dark so shadows do not turn into glows, which is the opposite of what a
     hover tint on a dark bar needs. */
  --pp-ink-rgb: 26 26 25;
  --pp-ink-soft: #52524E;
  --pp-body-ink: #2F2F2C; /* body copy — softer than ink; flips light in dark mode */
  --pp-pencil: #6E6E68;   /* 4.74:1 on paper — was #7A7168 at 4.35, an AA fail */
  /* Ink for the hand-drawn marks (arrows and the divider). Both themes are
     declared here so either can be previewed while the other is active; the
     resolved --mark-ink is what the marks actually read. */
  --mark-ink-light: var(--gray-600);
  --mark-ink-dark:  var(--gray-600);
  --mark-ink: var(--mark-ink-light);
  /* The hand-drawn underline under .about-lead mark. A tile, not one stretched
     stroke: the sentence wraps to three line fragments of 102 / 583 / 95px, so
     scaling one squiggle to each would render the same pen at three different
     weights and wave frequencies. repeat-x keeps one pen across all of them.
     The path starts and ends at the same y (5.4) with the same slope, so the
     tile seam does not read as a notch. The swing is deliberately shallow
     (1.3 units in a 9-unit box, four long undulations rather than six short
     ones): enough wobble to read as drawn by hand, not enough to read as a
     wave. An earlier pass swung 4.5 units and looked curly. It is a data URI, which bakes the
     stroke colour in, so it cannot read var(--mark-ink) and each theme
     declares its own copy — the dark one lives in the [data-theme] block. */
  --underline-mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 9'%3E%3Cpath d='M 0 5.4 C 20 5.9 38 6.3 60 6 C 82 5.7 98 5 120 5.2 C 140 5.4 152 5 166 5 C 172 5 176 5.2 180 5.4' fill='none' stroke='%239C9C96' stroke-width='2.6' stroke-linecap='round'/%3E%3C/svg%3E");
  --pp-hairline: rgb(var(--pp-shade-rgb) / 0.12);
  /* Two accents, two jobs. --pp-accent is CHROME: selection rings, handles,
     rules — non-text, so the 3:1 bar applies and it clears at 3.37:1.
     --pp-accent-deep is anything TEXT sits on or is: 5.71:1 on paper, and
     6.17:1 against white for button labels. Never fill a labelled button with
     --pp-accent; it only reaches 3.65:1 and fails AA. */
  --pp-accent: #0B84FF;
  /* channels of --pp-accent, for alpha-only derivations. Keep in sync. */
  --pp-accent-rgb: 11 132 255;
  --pp-accent-deep: #0A5FBF;
  --pp-accent-press: #084A96; /* hover/active on filled buttons — 8.64:1 on white */
  /* Highlighter ink stays YELLOW — it is a marker colour, not a palette colour,
     and it has to agree with the <mark> swipe below. Desaturated the same ~15%
     as the note papers so it sits on neutral stock. */
  --pp-highlight: #F7DA6E;

  /* One family across the whole site: Aeonik (CoType Foundry), with Aeonik
     Mono for the mono role. Default weight is 400 (set on body). The
     handwritten hint-note exception is gone: the hint sets in the mono too,
     annotation-as-metadata rather than annotation-as-scribble. */
  --pp-display: "Aeonik", sans-serif;
  --pp-body: "Aeonik", sans-serif;
  --pp-accent-face: "Aeonik", sans-serif;
  --pp-mono: "Aeonik Mono", ui-monospace, monospace;

  /* ── HINT NOTE FACE ──────────────────────────────────────────────────────
     The mono, deliberately (the ten handwritten candidates and their per-face
     metric pairs are gone with Stack Sans). Size/leading stay as tokens
     because the note's box in styles.css was tuned around them. */
  --hint-face: var(--pp-mono);
  --hint-size: 15px;
  --hint-leading: 1.5;

  /* ── THE NOTE SYSTEM ───────────────────────────────────────────────────── */
  /* Each hue ships with a matching darker "line" tone for ruling/edges/folds and
     a guaranteed-AA text ink. Hues are desaturated and pulled off the warm axis
     to sit on neutral paper; the ROLES below are unchanged.
     Contrast (WCAG) of --note-*-ink on its paper:
       canary  #2A2812 on #F0E7A4 = 11.8:1  sky   #14262F on #C3DCEA = 10.9:1
       kraft   #262218 on #DCD5C6 = 10.9:1  blush #2C1B1A on #EBC9C6 = 10.7:1
       mint    #16261D on #C8E0D2 = 11.3:1  lav   #1F1935 on #D5CEE9 = 11.1:1
       white   #2F2F2C on #FFFFFF = 13.4:1
     White is the one paper with no hue to carry, so its ink is the site's own
     --pp-body-ink value rather than a bespoke tone: plain paper takes plain ink.
     It reads higher than the rest of the ramp for the same reason — there is no
     tint under it pulling the ratio down. Its edge is a neutral gray a clear
     step darker than --pp-paper (#F6F6F5), or a white note on near-white paper
     would have no cut edge at all. */
  --note-canary:      #F0E7A4;
  --note-canary-edge: #D9CE7E;
  --note-canary-ink:  #2A2812;
  --note-sky:         #C3DCEA;
  --note-sky-edge:    #97BFD4;
  --note-sky-ink:     #14262F;
  --note-kraft:       #DCD5C6;
  --note-kraft-edge:  #BEB4A0;
  --note-kraft-ink:   #262218;
  --note-blush:       #EBC9C6;
  --note-blush-edge:  #D3A4A0;
  --note-blush-ink:   #2C1B1A;
  --note-mint:        #C8E0D2;
  --note-mint-edge:   #9DC4AC;
  --note-mint-ink:    #16261D;
  --note-lav:         #D5CEE9;
  --note-lav-edge:    #B0A6D3;
  --note-lav-ink:     #1F1935;
  --note-white:       #FFFFFF;
  --note-white-edge:  #D8D8D4;
  --note-white-ink:   #2F2F2C;

  /* paper-fiber tint, layered per note via background-blend */
  --note-grain:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.055'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");

  /* shadow voice — two-part per note: tight contact + soft ambient */
  --pp-shadow-sm: 0 1px 2px rgb(var(--pp-shade-rgb) / 0.10);
  --pp-shadow-md: 0 1px 2px rgb(var(--pp-shade-rgb) / 0.08), 0 8px 24px -6px rgb(var(--pp-shade-rgb) / 0.16);
  /* The plate shadow: far too faint to be elevation, it only stops a card's
     base meeting the paper dead flat. Half the alpha of -sm, which is why it is
     its own token and not that one. A token because .edge-card and the board
     window both wear it, and it lived as a literal inside .edge-card until the
     board needed it — two copies of an 0.05 alpha would be a drift waiting to
     happen. Deliberately NOT overridden in dark: the alpha rides
     --pp-shade-rgb, which already flips to pure black there. */
  --card-shadow: 0 1px 2px rgb(var(--pp-shade-rgb) / 0.05);
  /* Elevation, bottom only: the paper's weight, nothing more.
     The geometry matters. A negative spread shrinks the shadow rect on ALL four
     sides, so to keep the sides clean while still showing under the note you need
     spread >= blur/2 (sides stay tucked behind the paper) AND offset-y > 0 by at
     least as much (or the blur hides behind the note and you see nothing).
     Here: spread 8 = blur/2, offset 10 -> a ~10px band below the note and no halo
     left, right, or above. */
  --note-lift:
    0 1px 1px -1px rgb(var(--pp-shade-rgb) / 0.20),           /* contact */
    0 10px 16px -8px rgb(var(--pp-shade-rgb) / 0.34);         /* the lift */
}

/* ----------------------------------------------------------------------------
   Base atmosphere (index + case-study pages)
---------------------------------------------------------------------------- */

html { scrollbar-color: #BFBFBA #EAEAE7; }

body {
  background-color: var(--pp-paper);
  background-image: none;
  font-family: var(--pp-body);
  font-weight: 400; /* Aeonik default across the whole site */
  /* Aeonik's single-storey alternate a (ss01), site-wide. The property
     inherits, so one declaration here covers every element; ss02 is a
     different a and the other sets touch other glyphs — don't stack them. */
  font-feature-settings: "ss01";
  color: var(--pp-body-ink); /* was a one-off literal that drifted from the token */
  /* Establish a stacking context so the z-index:-1 grain (body::after) scopes to
     body and paints ABOVE body's own paper fill (but below content). Without this
     the grain escapes to the root context and body's in-flow background — no longer
     propagated to the canvas because the inline <head> script paints html — buries
     it. See body::after. */
  isolation: isolate;
}

/* paper grain only — static fiber texture (the cursor-follow glow was removed) */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.05'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-repeat: repeat;
  mix-blend-mode: multiply;
}

::selection { background: rgb(var(--pp-accent-rgb) / 0.28); color: var(--pp-ink); }
:focus-visible { outline: 2px solid var(--pp-accent); outline-offset: 2px; }
input[type="checkbox"] { accent-color: var(--pp-accent); }
input, textarea { caret-color: var(--pp-accent); }

/* Official Tabler icons inherit the surrounding ink. */
.tabler-icon {
  display: inline-block;
  width: 24px;
  height: 24px;
  flex: none;
  line-height: 0;
}
tabler-icon.tabler-icon > svg { display: block; width: 100%; height: 100%; }
.tabler-icon--right { rotate: 90deg; }
.tabler-icon--down { rotate: 180deg; }
.tabler-icon--left { rotate: -90deg; }

h1, h2, h3,
.hero-hello p,
.projects-title,
.about-title,
.collab-title,
.row-title { text-wrap: balance; }
p { text-wrap: pretty; }

/* Hero-scoped: this file loads last, so an unscoped rule here would re-assert
   absolute positioning on anything that ever regains the class below the hero
   and silently break the flow layout. */
.hero .draggable { position: absolute; }

/* ----------------------------------------------------------------------------
   Index — hero
---------------------------------------------------------------------------- */

.hero-hello {
  font-family: var(--pp-display);
  font-weight: 800;
  letter-spacing: -0.025em;
}
.hero-role { color: var(--pp-ink); }

/* social buttons: ink-stamped chips */
.social-button {
  background-color: #1D1D1B;
  color: var(--pp-card);
  box-shadow: var(--pp-shadow-sm);
}
.social-button:hover { background-color: #333331; }
.social-button:active { background-color: #454542; }


/* Pencil-gray hand-drawn marks: the arrows and the divider. One ink for both,
   per theme. (The hint's hand-drawn ring used to be the third; it is gone.)
   TWO source vars plus a resolved one, rather than just swapping a single var
   under [data-theme]. The reason was a dev dial panel that set both inks as
   inline styles on the root: an inline style beats any selector, so with one
   var the active theme's value would mask the other and only one could be
   previewed. That panel has been removed, so the structure now outlives the
   tool that motivated it. It is kept because it works and collapsing it would
   touch both themes' ink for no gain, not because anything still needs it. */
.divider svg path { stroke: var(--mark-ink); }

/* The divider renders at 3px, the pen width the hand-drawn marks share. The
   number is not 3: the artwork is stretched non-uniformly out of a 5085x203 box
   into a fixed 24px height, so its stroke shrinks by 24/203 and has to be
   multiplied back up to land on 3px. The 24px height is pinned at every
   breakpoint, so this holds everywhere. (The hint's ring carried the mirror of
   this calc — 3 * 196 / 215 — until it was removed.) CSS beats the presentation
   attribute on the path, which stays as a no-CSS fallback. */
.divider svg path { stroke-width: calc(3 * 203 / 24); }

/* ============================================================================
   THE STATIONERY SYSTEM — notes as physical paper objects
   Shared note recipe: fiber grain, layered lift, hand tilt, folded corner.
   ============================================================================ */

/* ── shared physical treatment ───────────────────────────────────────────── */
.about-me,
.collab .quote {
  /* NB: no position override here, and none is needed. The canvas stops at the
     hero, so both of these are plain in-flow boxes now (.about-me is static,
     not the position:absolute it carried via .draggable), and nothing inside
     them is absolutely positioned any more: the fold pseudo that once needed a
     containing block is gone. */
  border-radius: 3px;      /* cut-paper corners, not app-rounded */
  rotate: var(--tilt, 0deg); /* the fixed hand tilt: a resting value, never animated */
}
/* No .dragging treatment for these two. They are not draggable any more, so the
   class can never land on them: script.js only adds it inside the hero. Their
   elevation is the resting --note-lift from PAPER SURFACE, and that is all. */

/* Selection ring is the shared outline from styles.css — it's painted outside the
   box, so the torn mask (.hero-note-2) doesn't distort it, and each note keeps its
   own resting paper edge on hover untouched. */

/* ── HINT TEXT — "This site is interactive" ──────────────────────────────── */
/* Deliberately not a note: it declares no --note-face, so PAPER SURFACE skips
   it and the words sit straight on the paper. Ink is the secondary text tone,
   the same --gray-400 .hero-description resolves to, so the hint reads as an
   aside to the hero instead of competing with it. Shared token, not a repeated
   hex, so the two cannot drift apart. */
.hero-note {
  color: var(--gray-400);
  font-weight: 400;
  /* Declared, not inherited. Neither this nor .about-me-tagline had a
     font-size anywhere, and there is no body/html font-size either, so both sat
     on the browser's 16px default. When the rest of the type went to 90% they
     would have been the only text that did not move, and stuck out for it. */
  font-family: var(--hint-face);
  font-size: var(--hint-size);
  line-height: var(--hint-leading);
}
/* The span is the box the write-on reveal clips open left-to-right, so it has
   to be a block for the clip to run the full length of the line. nowrap keeps
   the hint on ONE line: .hero-note is width:fit-content, so without it a narrow
   band could break the line and the clip would open only the first row. */
.hero-note .hint-line { display: block; white-space: nowrap; }

/* ── PRIMARY STICKY (white) — the About / checklist note ─────────────────── */
.about-me {
  --tilt: 1.1deg;
  --note-face: var(--note-white);
  --note-edge: var(--note-white-edge);
  /* PAPER SURFACE below paints it; this rule keeps only the hand tilt */
  /* The hover-tilt transition was removed as dead (paper.css no longer tilts
     this note on hover), but it also eased the box-shadow change on theme
     flip. .about-me is the largest object on screen during that toggle, so
     restoring just that one property, at the same duration/easing the old
     combined rule used. Covers .about-me when it has NOT (yet) picked up
     .reveal below; once it has, that rule's own transition wins the cascade
     (later in file, same specificity) and needs the matching box-shadow term
     too — see the comment on .reveal. */
  transition: box-shadow 0.18s ease;
}
@media (prefers-reduced-motion: reduce) {
  .about-me { transition: none; }
}
.about-me-title {
  font-family: var(--pp-display);
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--note-white-ink);
}
.about-me-text, .about-me-tagline { color: var(--note-white-ink); }
/* Same inheritance gap as .hero-note above — see the note there. */
.about-me-tagline { font-size: 14px; }
.text-content { color: var(--note-white-ink); }
.input-field { color: var(--note-white-ink); font-family: var(--pp-body); }
.custom-checkbox input[type="checkbox"]:checked + .checkbox-custom + .input-field {
  color: var(--note-white-ink);
}

/* ── hand-stamped checkbox ticks (marker language, no asset swap needed) ──── */
/* keep the SVG assets but tint the checked stamp toward marker-ink + let the
   unchecked box read as a pencil square on the sticky */
.custom-checkbox .checkbox-custom {
  filter: sepia(0.35) saturate(1.3) hue-rotate(-18deg) brightness(0.62);
}
/* Both checkbox SVGs are filled #1E293B, a dark slate BLUE. That matters: a
   bare invert(1) lands on #E1D6C4, a warm cream, so the square reads yellow
   rather than grey. grayscale FIRST collapses the blue to its luminance
   (#282828), and only then does invert produce a neutral #D7D7D7.
   Same asset in both themes, no second file to keep in step.

   The checked state needs its own rule: light sets `filter: none` there at
   (0,4,1), which outranks the (0,3,0) selector above, so without this a ticked
   box would keep the raw slate fill and vanish into the note. */
[data-theme="dark"] .custom-checkbox .checkbox-custom,
[data-theme="dark"] .custom-checkbox input[type="checkbox"]:checked + .checkbox-custom {
  filter: grayscale(1) invert(1);
}
.custom-checkbox input[type="checkbox"]:checked + .checkbox-custom {
  filter: none;
}
/* struck-through checked items read as a marker cross-out, accent not gray */
.custom-checkbox input[type="checkbox"]:checked ~ .input-field,
.custom-checkbox input[type="checkbox"]:checked ~ .text-content {
  text-decoration-color: var(--pp-accent);
  text-decoration-thickness: 2px;
}

/* ── "New" chip — a lavender filing sticker on the canary sheet ──────────── */
#addCheckbox {
  font-family: var(--pp-body);
  background-color: var(--note-lav);
  color: var(--note-lav-ink);
  box-shadow: 2px 2px 0 rgba(33, 24, 56, 0.35);
}
#addCheckbox:hover { background-color: #E2DAF3; }
#addCheckbox:active { background-color: #C6B9E6; box-shadow: 1px 1px 0 rgba(33, 24, 56, 0.35); }

/* ── PAPER SURFACE ───────────────────────────────────────────────────────────
   Each note declares which paper it is (--note-face / --note-edge, from the
   --note-* ramp) and this one rule paints it: fiber grain, cut-paper edge, and a
   bottom-only lift. No inner sheet layer and no clip-path — the fold is gone, and
   with it the only reason those existed. */
.about-me,
.collab .quote,
.nf-note {
  background-color: var(--note-face);
  background-image: var(--note-grain);
  background-blend-mode: multiply, normal;
  border: 1px solid var(--note-edge);
  box-shadow: var(--note-lift);
}

/* nothing clips the notes any more, so the shared outset outline + corner handles
   ride the element and stay fully visible — identical to every other object. */

/* ── EDGE CARD ───────────────────────────────────────────────────────────────
   The site's SECOND card material, and the counterpart to PAPER SURFACE above.
   PAPER SURFACE is the sticky note: coloured stock, fiber grain, bottom-only
   lift. This one is the opposite read — a flat plate weighted by a lit edge,
   for surfaces that should look printed rather than stuck on.

   Same contract as PAPER SURFACE: opt in with the class, override a --card-*
   property to tune, and add no new rules.

   How the edge is painted: a border cannot take a gradient and keep
   border-radius, so the fill is clipped to padding-box and the gradient to
   border-box. The gradient then shows ONLY in the strip the transparent border
   reserves, which means the border widths shape it and the radius is followed
   exactly.

   --card-edge-stop IS PROPORTION-DEPENDENT AND THE ONE THING A NEW CONSUMER
   MUST CHECK. The gradient is a circle but the strip it shows through is a
   rectangle, so the ring is not equidistant from the centre: on a 672x503 card
   the mid-edges land at ~60% (top/bottom) and ~80% (left/right) of the radius
   and the corners at 100%. Everything before that first figure is painted
   underneath the fill and never seen, so a stop left at 0% throws away most of
   the colour range and the edge reads as flat grey. 65% suits that card. A
   squarer or much wider card needs its own value; there is no proportion-
   agnostic setting, which is the honest limit of this technique.

   Widths are authored fractional but browsers floor border-width to the device
   pixel grid: 1.2/1.6 paint as 1/1.5 at dPR 2 and 1/1 at dPR 1. The effective
   ladder is 1 / 1.5 / 2. */
.edge-card,
.card {
  --card-radius: 20px;
  --card-face: var(--pp-card);
  --card-edge: var(--gray-700);
  --card-edge-inner: color-mix(in srgb, var(--card-edge) 50%, transparent);
  --card-edge-stop: 65%;
  --card-edge-width: 1.2px;
  --card-edge-width-bottom: 1.6px;

  border-radius: var(--card-radius);
  border: var(--card-edge-width) solid transparent;
  border-bottom-width: var(--card-edge-width-bottom);
  background:
    linear-gradient(var(--card-face), var(--card-face)) padding-box,
    radial-gradient(circle at 50% 50%,
      var(--card-edge-inner) var(--card-edge-stop),
      var(--card-edge) 100%) border-box;
  /* See --card-shadow at the top of this file for why it is a token. */
  box-shadow: var(--card-shadow);
}

/* ----------------------------------------------------------------------------
   Avatar stickers: one die-cut white edge for the whole layer
   The filter sits on .avatar-layer, not on each piece: a filter renders its
   subtree's combined alpha as one shape, so a hat overlapping the avatar gets
   ONE merged outline instead of two crossing ones — the whole point. Order
   matters inside the list: the four hard offset shadows draw the white edge
   first (stacked, so each pass also outlines the previous one and the
   diagonals fill in), then the two soft shade shadows lift the outlined
   shape off the paper — the white edge casts the shadow, as a real sticker
   would. These shade values moved here from the old per-image rule; putting
   them per-piece again would double them up.
---------------------------------------------------------------------------- */
.avatar-layer {
  /* The white die-cut edge is ONE SVG filter (#sticker-edge, defined inline in
     index.astro): blur + threshold = a disc dilation, uniform in every
     direction. It replaced stacked offset drop-shadows — 8 of them meant 8
     full-surface passes over this hero-sized layer per frame and dragging
     crawled. The ~6px edge width lives in the SVG's stdDeviation/intercept
     pair, not in a CSS token; see the comment on the filter. The edge colour
     is baked white there too (white in both themes by design — the
     --pp-sticker-edge token documents that intent). */
  --sticker-outline: url(#sticker-edge);
  --sticker-shade:
    drop-shadow(0 1px 1px rgb(var(--pp-shade-rgb) / 0.22))
    drop-shadow(0 8px 14px rgb(var(--pp-shade-rgb) / 0.16));
  filter: var(--sticker-outline) var(--sticker-shade);
}
/* The selection ring and corner handles are descendants of the layer, so the
   wrapper's filter would white-edge them too (a filter renders its whole
   subtree's alpha, chrome included — nothing inside can opt out). So the
   pieces' own chrome is hidden on fine pointers and #selectionGhost, a
   sibling OUTSIDE the layer that script.js glues to the active piece, draws
   the ring and handles instead. Under a coarse pointer there is no hover to
   drive the ghost, so the pieces keep their always-on native handles there
   and accept the small white edge around them. */
@media (hover: hover) and (pointer: fine) {
  .avatar-layer .draggable:hover,
  .avatar-layer .draggable:focus-visible,
  .avatar-layer .dressup.kb-grabbed { outline: none; }
  .avatar-layer .editablesquare1,
  .avatar-layer .editablesquare2 { display: none; }
}
.selection-ghost {
  position: absolute;
  pointer-events: none;
  z-index: 500; /* above the layer (a filter = its own stacking context at z:auto) */
  outline: 2px solid var(--blue-400);
  outline-offset: 2px;
  --sq: -10px;
}
.selection-ghost.kb-grabbed { outline-style: dashed; outline-offset: 3px; }
.selection-ghost .editablesquare1,
.selection-ghost .editablesquare2 { opacity: 1; }
/* No per-piece .dragging drop-shadow inside the layer: the child renders
   BEFORE the layer's filter, so its soft shadow alpha would get the white
   outline too — a fuzzy halo, glaring in dark mode. Keep the transparent
   resting pair so the filter transition still has a like-for-like target. */
.avatar-layer .draggable.dragging {
  filter:
    drop-shadow(0 0 0 rgba(28, 25, 23, 0))
    drop-shadow(0 0 0 rgba(28, 25, 23, 0));
}

/* ----------------------------------------------------------------------------
   Selection chrome: accent dots with white cores (kept, retuned)
---------------------------------------------------------------------------- */
/* corner squares: accent dots with white cores — one style for every
   draggable's .editablesquare1/2 (the accent-border recolour and the
   pseudo-handle rules are gone; the shared outline in styles.css is the box) */
.editablesquare1,
.editablesquare2 {
  background-color: var(--pp-accent);
  border-color: #FFFFFF;
  box-shadow: var(--pp-shadow-sm);
}

/* ----------------------------------------------------------------------------
   Index — Work frame, cards, chips
---------------------------------------------------------------------------- */

.projects-title,
.about-title,
.collab-title {
  font-family: var(--pp-display);
  font-weight: 700;
  letter-spacing: -0.02em;
}

/* No wrapping surface: the case-study cards read as cards on their own, so the
   work frame is just an invisible layout container (no sheet, no ring). */
.work-frame { background: none; }
.work-frame::before { display: none; } /* drop the gradient border ring */

/* Work ledger: ruled rows on the paper sheet, like a project register. No
   per-row surface — the hairline rule does the grouping, so the list stays
   slim however many items it holds. */
.work-row { border-block-end-color: var(--pp-hairline); }

/* Touch has no hover, so a tap on the blank space beside the copy gave no
   acknowledgement and the row read as if only the text were live. The whole
   row IS the link, so flash it on press. Neutral shade, not the highlighter
   wash: this is a transient pressed state, not a card treatment. */
@media (pointer: coarse) {
  .work-row:active { background: rgb(var(--pp-shade-rgb) / 0.06); }
}
.row-title {
  font-family: var(--pp-display);
  font-weight: 700;
  letter-spacing: -0.015em;
}
.row-desc { color: var(--pp-ink-soft); }
.row-arrow { color: var(--pp-ink-soft); }

/* white monochrome brand logos → neutral ink */
.brand-logo { filter: invert(0.85) sepia(0.3) saturate(0.5); }

/* ----------------------------------------------------------------------------
   Index — gate form + reset button: paper-white doodle stickers, ink offsets
---------------------------------------------------------------------------- */
.gate-msg { color: var(--pp-ink-soft); }
.gate-form input,
.gate-form button {
  background: var(--pp-card);
  border-color: var(--pp-ink);
  color: var(--pp-ink);
  box-shadow: 5px 5px 0 var(--pp-ink), 0 10px 24px rgb(var(--pp-shade-rgb) / 0.12);
}
/* match the unified button's font so the input and Unlock button read balanced */
.gate-form input { font-family: var(--pp-body); font-size: 14px; }
.gate-form input::placeholder { color: var(--pp-pencil); }
.gate-form input:focus {
  border-color: var(--pp-accent);
  box-shadow: 7px 7px 0 var(--pp-accent), 0 10px 24px rgb(var(--pp-shade-rgb) / 0.12);
}
.gate-form button:hover {
  border-color: var(--pp-accent);
  box-shadow: 7px 7px 0 var(--pp-accent), 0 10px 24px rgb(var(--pp-shade-rgb) / 0.12);
}
.gate-form button:active { box-shadow: 2px 2px 0 var(--pp-ink); }
.gate-error { color: #B3300F; }
.gate-tagline { color: var(--pp-ink-soft); }
.gate-tagline a { color: var(--pp-accent-deep); } /* text: needs 4.5:1 */

.reset-button {
  box-sizing: border-box;
  background: var(--pp-card);
  border-color: var(--pp-ink);
  box-shadow: 5px 5px 0 var(--pp-ink), 0 10px 24px rgb(var(--pp-shade-rgb) / 0.16);
}
.reset-button:hover {
  border-color: var(--pp-accent);
  box-shadow: 7px 7px 0 var(--pp-accent), 0 14px 30px rgb(var(--pp-shade-rgb) / 0.18);
}
.reset-button:active { box-shadow: 2px 2px 0 var(--pp-ink), 0 5px 14px rgb(var(--pp-shade-rgb) / 0.16); }
/* ----------------------------------------------------------------------------
   Index — collaboration: testimonials become filed index cards (blush + mint)
---------------------------------------------------------------------------- */

.collab-statement { color: var(--pp-ink); }

/* the two quote cards get real paper hues and a hand tilt. */
.collab .quote {
  padding: 26px 24px 22px;
  box-sizing: border-box;
}
.collab .quote:nth-of-type(odd) {
  --tilt: -1.3deg;
  --note-face: var(--note-sky);
  --note-edge: var(--note-sky-edge);
  color: var(--note-sky-ink);
}
.collab .quote:nth-of-type(even) {
  --tilt: 1.4deg;
  --note-face: var(--note-mint);
  --note-edge: var(--note-mint-edge);
  color: var(--note-mint-ink);
}
/* Nothing interactive here any more: no selection ring, no corner handles, no
   hover change. The blush/mint paper edge is the resting state and the only one. */
.collab .quote .quote-name,
.collab .quote .quote-role { color: inherit; font-weight: 700; }
.collab .quote .quote-role { font-family: var(--pp-mono); font-weight: 400; font-size: 11px; opacity: 0.85; }
.collab .quote .quote-text { color: inherit; }

/* Emphasis on this page is a drawn underline, never a highlighter swipe: the
   About lead on paper, and the testimonial marks on their sky and mint notes.
   The yellow marker gradient these last used is gone, with no consumers left.

   ONE rule, one pen. The tile arrives through --underline-mark, declared only
   on :root (plus its dark twin), so every mark on the page is drawn by the
   same hand in the same pencil gray as the divider and the arrows.

   The quotes tried their own note ink first, because the gray measures 1.94:1
   on the sky stock and 1.98:1 on mint against 10.94 and 11.32 for the ink,
   and it is visibly fainter there. Ratified anyway by the project owner in
   Sept 2026: one pen across the page beat per-surface legibility. It is a
   decorative underline, not text, and the words it sits under carry their own
   contrast. To undo, redeclare --underline-mark on .collab .quote:nth-of-type()
   beside the stock each already sets — one line per note, no new rule.

   box-decoration-break:clone is what makes it work across a wrapped sentence:
   the default (slice) continues ONE background through every line fragment, so
   only the first line would be drawn on. clone gives each fragment its own
   painting, and the tile repeats along each one.

   padding-bottom is what puts the stroke clear of the descenders; the
   background box of an inline is the em box, not the line box, so `bottom`
   alone would cut through the p and g. No horizontal padding or negative
   margin here (the swipe above needs them to reach past the text); an
   underline should stop where the words do. */
.quote-text mark,
.about-lead mark {
  background: var(--underline-mark) left bottom repeat-x;
  background-size: 180px 9px;
  color: inherit;
  padding: 0 0 4px;
  margin: 0;
  border-radius: 0;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}

.contribution h3 {
  font-family: var(--pp-display);
  font-weight: 700;
  letter-spacing: -0.01em;
}
.contribution-media img {
  border-radius: 6px;
  outline: 1px solid var(--pp-hairline);
  box-shadow: var(--pp-shadow-md);
}


/* ============================================================================
   Case-study pages — minimal reading surface: solid paper, flat cards,
   display-face headings. (Decorative stationery stays on the canvas.)
   ============================================================================ */

/* solid fill only: no cursor glow, no grain on the reading pages */
body.cs::after { display: none; }

.wrap h1 {
  font-family: var(--pp-display);
  font-weight: 800;
  letter-spacing: -0.035em;
  line-height: 1.15; /* more air for the wrapping case-study titles */
}
.subtitle { color: var(--pp-body-ink); }

/* section headings: plain display-face bold in ink */
.wrap h2 {
  font-family: var(--pp-display);
  font-style: normal;
  font-weight: 700;
  font-size: 21px;
  letter-spacing: -0.015em;
  text-transform: none;
  color: var(--pp-ink);
}

.meta dt {
  font-family: var(--pp-mono);
  letter-spacing: 0.12em;
  color: var(--pp-pencil);
}
.meta dd { color: var(--pp-ink); }

/* At a glance / Impact card. Was a canary post-it, physically the same object
   as the homepage's About sticky; it is now the .edge-card plate (enrolled in
   the material above), to match the case-study header card.

   Everything the canary version needed is gone with it, and each removal was
   load-bearing for the yellow, not for the card:
     - the hand tilt (and the alternating lean on a second card): a plate does
       not sit at an angle
     - --note-face/--note-edge and the 3px cut-paper corner
     - the hard-coded inks (--note-canary-ink, #423715, #574A1D). Those existed
       ONLY so the note stayed yellow-with-dark-ink in dark mode. On a plate
       that flips with the theme they would be dark brown on a dark card, so
       the text now inherits normal theme ink instead.
     - the pinned --pp-accent-deep on inline links, which was there because
       dark mode's salmon is unreadable on canary. On the plate it reads fine,
       so links behave like every other inline link on the page.
     - the deepened [data-theme="dark"] lift, which belonged to paper on a dark
       desk.
   A smaller corner than the header's 20px, since these sit inside the reading
   column rather than opening the page. */
/* .board-view rides the SAME declaration, not a copy of the number: the board
   window and the At a glance card sit one after the other in the reading
   column, so a 16 here and a 16 in case-study.css would be two values that
   look identical until one of them is tuned. The board is not an .edge-card
   (no plate, no lit edge, just a border and a window), so it takes only the
   corner and reads the token below.
   isolation: isolate is load-bearing, not decoration. The board's iframe is
   scaled with a transform, and a transformed child can escape a rounded
   overflow:hidden clip on some engines, leaving square corners on a rounded
   box. Forcing a stacking context on the clipping element makes the rounded
   clip apply to it. */
.card,
.board-view { --card-radius: 16px; }
.board-view {
  border-radius: var(--card-radius);
  box-shadow: var(--card-shadow);
  isolation: isolate;
}
/* the lede — body face; serif is reserved for titles/callouts, not paragraphs */
.card > p {
  font-family: var(--pp-body);
  font-style: normal;
  font-weight: 500;
  font-size: 13px;
  line-height: 1.6;
}

/* Impact / outcomes — body face; the display-bold strong carries the emphasis.
   Marker, indent and spacing are shared with ul.did in case-study.css (PROSE
   BULLETS); this rule is type only, so it must not reintroduce either. */
.outcomes li {
  font-family: var(--pp-body);
  font-style: normal;
  font-weight: 400;
  font-size: 13px;
  line-height: 1.6;
  color: var(--pp-body-ink);
}
.outcomes strong {
  font-family: var(--pp-display);
  font-style: normal;
  font-weight: 800;
  font-size: 1.12em;
  letter-spacing: -0.01em;
  color: var(--pp-ink);
}
strong { color: var(--pp-ink); }
section p { color: var(--pp-body-ink); }

/* figures: prints mounted on the wall */
figure.media > img,
figure.media > video {
  background: #FFFFFF;
  border: none; /* the drop shadow carries the edge; the ring only ever showed in dark */
  box-shadow: var(--pp-shadow-md);
}
figure.media figcaption {
  font-family: var(--pp-mono);
  font-size: 11px;
  color: var(--pp-pencil);
}

.media-expand { background: rgb(var(--pp-shade-rgb) / 0.66); }
.media-expand:hover { background: rgb(var(--pp-shade-rgb) / 0.92); }
.lightbox { background: rgba(26, 22, 18, 0.94); }
.lightbox-caption { color: #E6E6E2; }

/* ============================================================================
   THE LINK — one component, two colours, one underline rule
   ============================================================================
   Every text link on the site is this. It replaces five near-duplicates that
   each carried their own dashed underline and their own coloured hover swipe
   (yellow --pp-highlight behind .mint-link / a.inline / .back / .ref-links a,
   #C9EE6B green behind .credit-link, --mint on the case-study pages). The
   swipes are gone: a block of colour landing behind a word on hover is a
   highlighter gesture, and it was doing the job a link's own colour and
   underline should do.

   TWO COLOURS, and only two:
     black  --pp-ink, the default. Named for how it reads, not for a hex: it is
            the ink token, so it flips light in dark mode with everything else.
     blue   --pp-accent — the SAME token the buttons' chrome uses, which is the
            point of calling it the interactive blue: a link and a button now
            read as one colour.

   ACCESSIBILITY TRADEOFF, made deliberately by the project owner (Sept 2026).
   The two-accent note at the top of this file says text takes --pp-accent-deep
   and --pp-accent is chrome-only, because chrome need only clear 3:1 while text
   needs 4.5:1. Blue links break that rule on purpose, to match the buttons:
     light  #0B84FF on paper = 3.37:1 — FAILS AA for body text
     dark   #4DA3FF on #161615 = 6.90:1 — passes
   Only the LIGHT theme fails, and only for these links; every other run of text
   on the site still honours the rule. To put it back, change --link-ink in the
   blue variant below to --pp-accent-deep — one line, nothing else depends on it.

   WEIGHT is 500 (Aeonik Medium) on every link, so a link reads as a link from
   its weight as well as its colour and rule. That matters most for the black
   variant, which has no hue to distinguish it from the prose around it.

   UNDERLINE is full or absent, never dashed. A link inside a sentence keeps it
   at all times, because colour alone is not an affordance (WCAG 1.4.1). The two
   that sit alone on their own line and carry an icon (.back, .ref-links a) rest
   bare and underline on hover — their position and icon already say "link".

   Adding a link means picking a variant, not writing a rule: .link is the
   component, .link--blue and .link--bare are the modifiers. The legacy class
   names are listed alongside them so existing markup did not have to churn. */
.link,
a.inline,
.mint-link,
.credit-link,
.back,
.ref-links a,
.gate-tagline a,
.tliaa-doc a {
  --link-ink: var(--pp-ink);
  --link-ink-hover: var(--pp-accent);
  color: var(--link-ink);
  font-weight: 500;
  /* Text-to-icon distance, one value for every link that carries an icon. Inert
     on the ones that do not: gap only does anything on a flex or grid box, and
     a plain inline link is neither. It lives here rather than beside each
     link's own display:inline-flex so the spacing cannot drift per link — that
     is exactly how .back ended up at 8 while .ref-links a sat at 6. */
  gap: 6px;
  text-decoration-line: underline;
  text-decoration-style: solid;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: color 0.12s ease, text-decoration-color 0.12s ease;
}
.link:hover,
a.inline:hover,
.mint-link:hover,
.credit-link:hover,
.back:hover,
.ref-links a:hover,
.gate-tagline a:hover,
.tliaa-doc a:hover {
  color: var(--link-ink-hover);
}

/* — the blue variant. Rest on the buttons' own blue; hover steps to -deep,
   which is darker in light and lighter in dark, so the reaction reads as "more"
   in both themes rather than needing a per-theme rule. — */
.link--blue,
a.inline,
.gate-tagline a,
.tliaa-doc a {
  --link-ink: var(--pp-accent);
  --link-ink-hover: var(--pp-accent-deep);
}

/* — the bare variant: no underline, EVER, not even on hover —
   The underline is a resting-state decision, not a hover reward. A rule that
   appears under the cursor shifts the text's weight on the page the moment you
   approach it and reads as the link changing its mind about what it is; worse,
   it makes the affordance discoverable only by hovering, which a touch or
   keyboard user never does. So the two states agree: underlined links are
   underlined always, bare links are bare always.

   Bare links are NOT inert on hover — they still take --link-ink-hover from the
   base rule and go to the interactive blue. Colour carries the reaction here.

   .ref-links a used to sit here and no longer does: a reference link is the
   payload of its row, not chrome, and it reads as a link only if it looks like
   one. It takes the default full underline above. .back keeps the bare
   treatment — it is navigation furniture in the topbar, next to the title,
   where a permanent rule would compete with the heading. */
.link--bare,
.back { text-decoration-line: none; }

/* Icons match the text's COLOUR (currentColor, so a variant swap carries them
   along) and the copy button's SIZE: a flat 16px, the same as
   .case-study-copy-button's, which is the size the icon set was drawn for.

   These were briefly `1lh` — one line box of their own text, 21.45px here — so
   they tracked the type. That is given up deliberately: 16px reads right and
   one icon size across the site beats each one tracking its own context.

   16px is also all this rule has to say. The markup carries `size="16"`, and
   the renderer compensates stroke against that attribute (tabler-icons.js), so
   the stroke lands on the site's one physical width with no help from CSS. The
   `vector-effect: non-scaling-stroke` block that used to sit here existed only
   because a size="24" icon was being drawn at 21.45px behind the renderer's
   back; with the attribute and the box agreeing again, it is unnecessary. */
.link .tabler-icon,
.back .tabler-icon,
.ref-links a .tabler-icon,
.ref-arrow {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  color: currentColor;
}

/* ----------------------------------------------------------------------------
   Under 1024px: replace white-on-dark frame art; neutralize note physics that
   don't belong in the stacked layout (fold/tilt would look odd, and
   the torn-mask on hero-note-2 is moot since it's display:none there anyway)
---------------------------------------------------------------------------- */

@media screen and (max-width: 1024px) {
  /* This theme loads AFTER styles.css, so its hero-scoped `.hero .draggable {
     position: absolute }` was overriding the base ≤1024 neutralizer and
     collapsing the stacked mobile flow (hero/cards/sticky all piling up).
     Re-assert the base mobile contract, scoped the same way as Step 1 above;
     .avatar/.beard re-assert absolute in styles1024.css. */
  .hero .draggable {
    position: static;
    width: auto;
    height: auto;
    cursor: default;
  }
  /* …but the dress-up pieces ARE absolutely pinned on mobile (styles1024.css
     sets each one's top/left/right for the touch dress-up zone); this file
     loads last, so re-assert their positioning here too.
     Matching specificity with `.hero .draggable` above (both 0-2-0) so the tie
     resolves by source order again, which is what pins the avatar and beard.
     Scoping only the .draggable rule made it 0-2-0 against .dressup's 0-1-0 and
     flipped both pieces to static. */
  .hero .dressup { position: absolute; }

  /* No wrapping surface on mobile — the case-study cards already read as cards,
     so an outer sheet just nests a card inside a card. Expose them directly. */
  .work-frame {
    background: none;
    box-shadow: none;
    border-radius: 0;
  }

  /* the primary sticky stays sky but drops the tilt/fold so it reads
     as a clean panel in the vertical flow (no negative space, no clip) */
  .about-me {
    --tilt: 0deg;
    transform: none;
    rotate: none;
    translate: none;
  }

  /* collab cards flatten too */
  .collab .quote {
    --tilt: 0deg;
    transform: none;
    rotate: none;
    translate: none;
  }
}

/* ============================================================================
   MOTION SYSTEM (Phase 3)
   Entrance choreography, fire-once scroll reveals, cross-document view
   transitions. Everything is gated behind prefers-reduced-motion.
   ============================================================================ */

/* Case-study links fade the page out before navigation. The destination paints
   the paper background before first paint, then its content cascades in. */

/* Case-study leave fade — class set by case-study.js just before it navigates. */
body.cs-leaving { opacity: 0; transition: opacity 0.28s ease; }

@media (prefers-reduced-motion: no-preference) {
  /* Case-study entrance cascade, top to bottom: topbar → title → subtitle →
     meta → the first band of content. Below-fold sections reveal on scroll
     (case-study.js). `backwards` fill holds each element hidden until its turn
     but releases to its base value afterward — so a below-fold section the
     reveal observer has already hidden (opacity:0) stays hidden instead of being
     forced visible by the finished cascade animation. */
  .cs .topbar         { animation: cs-rise 0.72s var(--cs-pop) 0.00s backwards; }
  .cs .hero h1        { animation: cs-rise 0.72s var(--cs-pop) 0.16s backwards; }
  .cs .hero .subtitle { animation: cs-rise 0.72s var(--cs-pop) 0.32s backwards; }
  .cs .hero .meta     { animation: cs-rise 0.72s var(--cs-pop) 0.48s backwards; }
  .cs .wrap > section,
  .cs .wrap > .card,
  .cs .wrap > figure.media { animation: cs-rise 0.72s var(--cs-pop) 0.64s backwards; }
  /* springy overshoot so each element pops in as it settles */
  .cs { --cs-pop: cubic-bezier(0.34, 1.56, 0.64, 1); }
  @keyframes cs-rise { from { opacity: 0; transform: translateY(30px) scale(0.94); } }

  /* ── entrance: the canvas pops itself together — title, avatar, links, note
     and toys spring in fast, in a scattered order (NOT top-to-bottom) so it
     reads as the board assembling itself. Runs on every load, so it also plays
     when you navigate back from a case study. `scale` (not transform) so it
     releases cleanly to the --lift/--tilt drag composition; `backwards` fill
     hides each piece until its turn, and a `from`-only keyframe means a below-
     fold piece the reveal observer has hidden (opacity:0) stays hidden — its
     implicit `to` is that base value — and reveals on scroll instead. */
  @keyframes snap-on { from { opacity: 0; scale: 0.9; } }
  /* The contact links left the hero for the floating actions bar, so they are
     chrome now and sit out the entrance stagger — same as the reset button they
     share that bar with. The --enter: 6 slot they vacated is still free. */
  /* The dress-up pieces and .pin used to be listed
     here because .avatar-layer sat outside .hero. It is a hero child now, so
     `.hero .draggable` already covers them. */
  .hero .draggable,
  .lamp {
    animation: snap-on 0.34s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
    animation-delay: calc(var(--enter, 0) * 70ms);
  }
  /* scattered order — the on-screen position jumps around at each step */
  .avatar              { --enter: 0; }
  .beard               { --enter: 0; } /* lands with the avatar, one face */
  .hero-hello          { --enter: 2; }
  .hero-description    { --enter: 3; }
  .lamp                { --enter: 4; }
  .wizard-hat          { --enter: 5; }
  .beanie              { --enter: 7; } /* upper right, the slot the glasses used to hold */
  .hero-note           { --enter: 8; }
  .baseball-cap        { --enter: 9; }
  .pin                 { --enter: 11; }
  /* Inside the sticker layer a fading child is poison: the #sticker-edge
     threshold reads any partial alpha as a full silhouette and paints it
     solid white until the image alpha catches up — an entrance flash that
     only shows on dark paper. These pieces enter at full alpha instead:
     `visibility` keeps them hidden through their delay (backwards fill),
     then the scale pop lands them — more sticker-slap than fade anyway. */
  @keyframes snap-on-sticker { from { visibility: hidden; scale: 0.85; } }
  .avatar-layer .draggable { animation-name: snap-on-sticker; }

  /* ── The hint, written on ──────────────────────────────────────────────────
     One beat: the line is clipped open left-to-right, as if written. This was
     two beats — line one, then line two, then the hand-drawn ring DRAWN around
     them by unrolling its dash — until the hint went to a single line and the
     ring was removed. What is left is the write-on itself.

     Gated on html.hint-anim, set pre-paint by the inline script in index.astro
     ONLY when JS is on and motion is allowed — the start state hides the words,
     so it must never apply to someone who can't run the animation. The play
     class (.hint-go) is added by script.js once every hero image and the hint
     face have loaded, so the choreography opens on a finished hero.

     The note sits OUT of the snap-on stagger it would otherwise share: fading
     and scaling in while also being written reads as two competing entrances. */
  html.hint-anim .hero-note { animation: none; }

  html.hint-anim .hint-line { clip-path: inset(0 100% 0 0); }

  /* forwards, so the finished state holds after the one-shot play */
  html.hint-anim.hint-go .hint-line {
    animation: hint-write 0.5s cubic-bezier(0.4, 0, 0.5, 1) forwards;
  }
  @keyframes hint-write { to { clip-path: inset(0 0 0 0); } }

  /* ── The hint's ellipsis, typing itself forever: . → .. → ... → repeat ──────
     Only dots two and three animate. The first is always on, so the cycle
     never blanks the ellipsis entirely and the sentence never looks broken.

     opacity, NOT visibility / display / a content swap: the dots are real text
     that stays in the DOM at every step, so the line still reads and copies as
     "many... hats?", and opacity does not affect layout, so "hats?" holds its
     position instead of sliding left and right three times a second.

     The 1%-wide ramps are the switch: linear over 1% of 1.5s is 15ms, a hard
     cut rather than a fade, which is what makes it read as typed rather than
     as three dots breathing.

     No reduced-motion override is needed. This sits inside the no-preference
     block, so under `reduce` no animation is declared at all and all three dots
     rest at their base opacity — a plain, static "many... hats?". */
  .hint-dots > span:nth-child(2) { animation: hint-dot-2 1.5s linear infinite; }
  .hint-dots > span:nth-child(3) { animation: hint-dot-3 1.5s linear infinite; }
  @keyframes hint-dot-2 {
    0%,  33% { opacity: 0; }
    34%, 100% { opacity: 1; }
  }
  @keyframes hint-dot-3 {
    0%,  66% { opacity: 0; }
    67%, 100% { opacity: 1; }
  }

  /* ── fire-once scroll reveals (script adds .in-view; default = visible so
     content never hides without JS — the .reveal class is added BY script) */
  .reveal {
    opacity: 0;
    translate: 0 22px;
    /* box-shadow 0.18s ease rides alongside: script.js adds this class to any
       below-fold revealable (including .about-me), and at equal specificity
       this rule loads after .about-me's own `transition` shorthand, so it
       would otherwise silently clobber that rule's box-shadow easing the
       moment .reveal lands on the element. */
    transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1), translate 0.7s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.18s ease;
  }
  .reveal.in-view {
    opacity: 1;
    translate: 0 0;
  }
}

/* live x/y badge while dragging — design-tool chrome, in the accent voice */
.drag-badge {
  position: fixed;
  z-index: 99999;
  padding: 3px 7px;
  border-radius: 4px;
  background: var(--pp-accent-deep); /* 10.5px label — small text, needs 4.5:1 */
  color: var(--pp-card);
  font-family: var(--pp-mono);
  font-size: 9px;
  letter-spacing: 0.04em;
  pointer-events: none;
  white-space: nowrap;
  box-shadow: 0 2px 6px rgb(var(--pp-shade-rgb) / 0.25);
}

/* ── Phase 4 case-study additions retuned to the stationery palette ──────── */
/* The next-project wrapper uses the same ruled .work-row as the homepage. */
.next-project-label { font-family: var(--pp-mono); color: var(--pp-pencil); }

.contact-cta-lead { font-family: var(--pp-display); color: var(--pp-ink); }
.contact-cta-line { color: var(--pp-ink-soft); }
.contact-cta-btn {
  background: var(--pp-accent);
  color: var(--pp-card);
  box-shadow: var(--pp-shadow-sm);
}
.contact-cta-btn:hover { box-shadow: var(--pp-shadow-md); }

.stat-figure { font-family: var(--pp-display); font-weight: 800; letter-spacing: -0.02em; color: var(--pp-ink); }
.stat-label { color: var(--pp-pencil); }

/* ============================================================================
   UNIFIED BUTTON COMPONENT (from the reset-button's sticker language)
   Paper-white fill, ink border, hard "comic" offset shadow, hover lifts toward
   the cursor with an accent shadow, press pushes it in. One .btn class +
   the existing button selectors so every button on the site shares it.
   ============================================================================ */
.btn,
.social-button,
#addCheckbox,
.gate-form button,
.contact-cta-btn,
.nf a {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  width: fit-content;
  /* Height is PINNED, not derived from padding. Derived height made these
     disagree by a pixel for an invisible reason: a button whose content is a
     16px icon laid out 1px taller than one whose content is the 15.4px label
     line, so #addCheckbox and .btn sat at 41 while the gate pair sat at 40.
     One number ends that. box-sizing because the 2.5px border counts toward it.
     The two icon-only toolbar buttons keep their 48x48: .toolbar-container
     overrides width/height/padding/border further down, and they have neither
     a label nor a border for a shared text size or padding to apply to. */
  box-sizing: border-box;
  height: 40px;
  padding: 0 14px;
  border-radius: 14px;
  font-family: var(--pp-body);
  font-size: 14px;
  font-weight: 600;
  line-height: 1.1;
  text-decoration: none;
  cursor: pointer;
  color: var(--pp-ink);
  background: var(--pp-card);
  border: 2.5px solid var(--pp-ink);
  box-shadow: 4px 4px 0 var(--pp-ink);
  transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1),
              box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}
.btn:hover,
.social-button:hover,
#addCheckbox:hover,
.gate-form button:hover,
.contact-cta-btn:hover,
.nf a:hover {
  border-color: var(--pp-accent);
  box-shadow: 6px 6px 0 var(--pp-accent);
  transform: translate(-1px, -1px);
  background: var(--pp-card);
  color: var(--pp-ink);
}
.btn:active,
.social-button:active,
#addCheckbox:active,
.gate-form button:active,
.contact-cta-btn:active,
.nf a:active {
  box-shadow: 1px 1px 0 var(--pp-ink);
  transform: translate(3px, 3px);
}
.btn:focus-visible,
.social-button:focus-visible,
#addCheckbox:focus-visible,
.gate-form button:focus-visible,
.contact-cta-btn:focus-visible,
.nf a:focus-visible {
  outline: 2px solid var(--pp-accent);
  outline-offset: 3px;
}

.toolbar-container {
  gap: 0;
  padding: 6px;
  flex-wrap: nowrap;
  background: var(--pp-card);
  border: 3.5px solid var(--pp-ink);
  border-radius: 24px 28px 22px 27px / 26px 22px 28px 24px;
  box-shadow: 5px 5px 0 var(--pp-ink), 0 10px 24px rgb(var(--pp-shade-rgb) / 0.16);
}
.toolbar-container .social-button-group { gap: 0; flex-wrap: nowrap; }
.toolbar-container .social-button,
.toolbar-container .reset-button {
  position: relative;
  width: 48px;
  height: 48px;
  padding: 0;
  justify-content: center;
  box-sizing: border-box;
  border: 0;
  border-radius: 14px;
  background: transparent;
  color: var(--pp-ink);
  box-shadow: none;
  transition: transform 0.15s cubic-bezier(0.2, 0, 0, 1),
              background-color 0.15s ease;
}
.toolbar-container .social-button::before,
.toolbar-container .reset-button::before {
  content: attr(aria-label);
  position: absolute;
  left: 50%;
  bottom: calc(100% + 10px);
  translate: -50% 0;
  padding: 7px 10px;
  border-radius: 7px;
  background: var(--pp-ink);
  color: var(--pp-card);
  font-family: var(--pp-body);
  font-size: 11px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.12s ease;
  z-index: 2;
}
.toolbar-container .social-button:focus-visible::before,
.toolbar-container .reset-button:focus-visible::before {
  opacity: 1;
  visibility: visible;
}
@media (hover: hover) {
  .toolbar-container .social-button:hover::before,
  .toolbar-container .reset-button:hover::before {
    opacity: 1;
    visibility: visible;
  }
}
@media (prefers-reduced-motion: reduce) {
  .toolbar-container .social-button::before,
  .toolbar-container .reset-button::before { transition: none; }
}
.toolbar-divider {
  flex: none;
  width: 1px;
  height: 28px;
  background: var(--pp-hairline);
}
@media screen and (max-width: 1024px) {
  .toolbar-divider--reset { display: none; }
}
.toolbar-container .social-button:hover,
.toolbar-container .reset-button:hover {
  transform: none;
  border-color: var(--pp-ink);
  /* An alpha tint of the ink, not a baked grey. #E6E6E2 and #D6D6D2 were the
     light-mode results of exactly this (ink at 11% and 18% over white), so the
     light bar is unchanged to within a channel value, while dark mode stops
     painting an opaque light plate on a dark bar. */
  background: rgb(var(--pp-ink-rgb) / 0.11);
  box-shadow: none;
}
.toolbar-container .social-button:active,
.toolbar-container .reset-button:active {
  transform: scale(0.96);
  border-color: var(--pp-ink);
  background: rgb(var(--pp-ink-rgb) / 0.18);
  box-shadow: none;
}
.toolbar-container .social-button:focus-visible,
.toolbar-container .reset-button:focus-visible {
  outline: 2px solid var(--pp-accent);
  outline-offset: 3px;
}
.toolbar-container .social-button .tabler-icon,
.toolbar-container .reset-button .tabler-icon { width: 24px; height: 24px; }
/* Icons in labelled buttons are 16px. Listed as the button FAMILY, not as
   individual consumers: as a hand-maintained list it had already missed .nf a,
   which fell through to the 24px default and sat in a 40px button next to a
   2.5px border. The toolbar's icon-only buttons keep 24px through
   .toolbar-container above, which outranks these on specificity. */
.btn .tabler-icon,
#addCheckbox .tabler-icon,
.gate-form button .tabler-icon,
.contact-cta-btn .tabler-icon,
.nf a .tabler-icon,
.tliaa-copy .tabler-icon { width: 16px; height: 16px; }
.theme-toggle-track .tabler-icon { width: 16px; height: 16px; }

/* Accent variant — the one strong call-to-action per context (get in touch,
   unlock, back-to-canvas): blue fill, ink comic shadow. Same language, filled.
   Fills use --pp-accent-deep, NOT --pp-accent: the label is 16px/600, which is
   not "large text", so it needs 4.5:1. Deep gives 6.17:1; the bright chrome
   accent would give 3.65:1 and fail. */
.btn--accent,
.contact-cta-btn {
  background: var(--pp-accent-deep);
  color: var(--pp-card);
  border-color: var(--pp-ink);
  box-shadow: 4px 4px 0 var(--pp-ink);
}
.btn--accent:hover,
.contact-cta-btn:hover {
  background: var(--pp-accent-press);
  color: var(--pp-card);
  border-color: var(--pp-ink);
  box-shadow: 6px 6px 0 var(--pp-ink);
}

/* ── The Work gate: same furniture as the rest of the UI ────────────────────
   The Unlock button used to be the accent fill. It is not the one strong
   call-to-action on this page (the contact bar is), and a blue plate in the
   middle of the ledger band read as a different system, so it was removed from
   the .btn--accent group above and now takes the shared white button style.

   Height is pinned rather than derived from padding, because the input and the
   button reach it from different directions: the input's line-height plus its
   old 12px padding overshot, and the button's came from the shared 10px. One
   number for both is the only way they agree. box-sizing is explicit since the
   border counts toward it and the two used different widths.

   The input's border drops 3.5px -> 2.5px to match the button. Not cosmetic
   sugar: at a fixed 40px an extra 1px of border top and bottom pushes the two
   text baselines out of line with each other.

   Alignment: #gate IS a .work-frame (`class="work-frame client-frame"`), which
   is what makes this safe to scope here. The frame centres its children on both
   axes for the ledger lists; the gate opts out to top-left without touching
   .projects-frame or #protected. */
#gate {
  justify-content: flex-start;
  align-items: flex-start;
}
/* The input is not in the shared button rule, so it pins its own height to
   match the button beside it. */
.gate-form input {
  box-sizing: border-box;
  height: 40px;
}
.gate-form input {
  padding: 0 14px;
  border-width: 2.5px;
  line-height: normal;
  /* The input is not in the shared button rule, so it was keeping styles.css's
     0.2s ease while the button had been given a 0.15s spring. Same values on a
     different curve still reads as two different controls, so the curve has to
     match too, not just the end state. */
  transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1),
              box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
  /* Resting shadow drops 5px -> 4px, and loses the soft ambient layer the
     button never had. Both are in service of the hover: the button travels
     4 -> 6, so an input starting at 5 would lift half as far on the same
     gesture. */
  box-shadow: 4px 4px 0 var(--pp-ink);
}
/* No font-weight here on purpose: the shared button rule's 600 is the site's
   button weight, and Unlock is a button like any other.

   Height and padding are the shared rule's now, not this block's: every
   labelled button on the site is 40px with 14px sides, so the gate no longer
   needs to say so itself. */
/* Hover, identical to the shared button hover above. :not(:focus) is what keeps
   the hierarchy right: focus is the stronger state (7px), and because it is
   declared EARLIER in this file it would otherwise lose the source-order tie to
   this rule and a focused input would drop back to the hover treatment while
   the pointer sat on it. */
.gate-form input:hover:not(:focus) {
  border-color: var(--pp-accent);
  box-shadow: 6px 6px 0 var(--pp-accent);
  transform: translate(-1px, -1px);
}
/* Focus keeps its stronger offset, minus the ambient layer for the same reason
   as the resting state. */
.gate-form input:focus {
  box-shadow: 7px 7px 0 var(--pp-accent);
}

/* keep button labels on one line (E-mail was wrapping on mobile) */
.btn, .social-button, #addCheckbox, .gate-form button, .contact-cta-btn, .nf a { white-space: nowrap; }
@media screen and (max-width: 1024px) {
  /* #addCheckbox used to drop to 13px here, the only labelled button on the
     site that changed size by viewport. The family is one text size now. */
  .social-button { padding: 8px 14px; font-size: 13px; }
  .social-button-group { gap: 10px; flex-wrap: wrap; }
}

/* ============================================================================
   THEME TOGGLE — a wall light-switch next to the avatar. Flipping it turns
   the desk lamp off (After-dark theme below) and the avatar suits up.
   Sticker language: paper track, ink border, comic offset, sprung knob.
   ============================================================================ */
/* Desk lamp = the light switch. The lamp art swaps with the theme; a compact
   toggle sits at its base as the affordance. The whole unit is one button
   (#themeToggle) — script.js flips data-theme + the label text; the lamp image
   and the knob react to [data-theme] in CSS. Fixed on the canvas (not draggable). */
.lamp {
  position: absolute;
  top: 40px;
  right: 24px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  z-index: 50;
}
.lamp-img {
  width: 122px;
  height: 184px;                 /* matches the 0.663 art aspect ratio */
  background: url(assets/lamp-light.png?v=1) center / contain no-repeat;
  filter: drop-shadow(0 8px 10px rgb(var(--pp-shade-rgb) / 0.18));
  transition: filter 0.2s ease, transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1);
}
[data-theme="dark"] .lamp-img { background-image: url(assets/lamp-dark.png?v=1); }
.lamp:hover .lamp-img { transform: translateY(-2px) rotate(-1.5deg); filter: drop-shadow(0 12px 16px rgb(var(--pp-shade-rgb) / 0.26)); }
.lamp:active .lamp-img { transform: translateY(1px) scale(0.99); }

/* compact switch tucked at the lamp base */
.theme-toggle-track {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 54px;
  height: 26px;
  padding: 0 7px;
  box-sizing: border-box;
  border-radius: 999px;
  background: var(--pp-card);
  border: 2px solid var(--pp-ink);
  box-shadow: 2px 2px 0 var(--pp-ink);
  transition: background-color 0.25s ease, border-color 0.25s ease, box-shadow 0.15s ease, transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.lamp:hover .theme-toggle-track {
  border-color: var(--pp-accent);
  box-shadow: 3px 3px 0 var(--pp-accent);
  transform: translate(-1px, -1px);
}
.lamp:hover .theme-toggle-knob,
.tliaa-switch:hover .theme-toggle-knob { background: var(--pp-accent); }
.lamp:active .theme-toggle-track {
  box-shadow: 1px 1px 0 var(--pp-ink);
  transform: translate(1px, 1px);
}
.lamp:focus-visible { outline: 2px solid var(--pp-accent); outline-offset: 4px; border-radius: 14px; }
.theme-toggle-track svg {
  width: 16px;
  height: 16px;
  color: var(--pp-pencil);
  transition: color 0.25s ease, opacity 0.25s ease;
}
.tt-sun { opacity: 0; }
.theme-toggle-knob {
  position: absolute;
  top: 50%;
  left: 3px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--pp-ink);
  box-sizing: border-box;
  translate: 0 -50%;
  transition: background-color 0.15s ease, translate 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Scoped to .lamp: the TL;IAA switch reuses .theme-toggle-knob, and an unscoped
   rule here slid ITS knob to the on position whenever dark mode was active. */
[data-theme="dark"] .lamp .theme-toggle-knob { translate: 28px -50%; }
[data-theme="dark"] .tt-sun { opacity: 1; }
[data-theme="dark"] .tt-moon { opacity: 0; }

/* the lamp sits in the top-right corner at every desktop width (base rule above) */
@media screen and (max-width: 1024px) {
  /* stacked flow: hide the big lamp art, pin the switch to the page's top-right
     corner (opposite the top-left avatar) */
  .lamp {
    position: absolute;
    top: 22px;
    right: 20px;
    flex-direction: row;
    width: fit-content;
    padding: 0;
    gap: 14px;
    z-index: 50;
  }
  .lamp-img { display: none; }
}

/* ============================================================================
   THEME: After dark — lights off, same desk. Warm near-black paper, cream ink,
   the stationery keeps its real paper hues (physical notes don't change color
   in the dark — they just sit under a dimmer lamp). The avatar suits up.
   Scoped to [data-theme="dark"] set on <html> by the hero light switch.
   ============================================================================ */
[data-theme="dark"] {
  color-scheme: dark;

  /* saturated navy ramp flips back: light ink on a deep blue desk (matches the UI navy) */
  --gray-50:  #F0F0EF;
  --gray-100: #E3E3E1;
  --gray-200: #C8C8C4;
  --gray-300: #ADADA8;
  --gray-400: #97978F;
  --gray-600: #5F5F5A;
  --gray-700: #414140;
  --gray-800: #2A2A29;
  --gray-900: #171716;

  --mint: #7FBEFF; /* case-study eyebrows/links — text, so the light tone */

  --pp-paper: #161615;
  --pp-card: #212120;
  --pp-ink: #F0F0EF;
  /* pure black: on a near-black ground an ink-tinted shadow reads as haze */
  --pp-shade-rgb: 0 0 0;
  --pp-ink-rgb: 240 240 239;
  --pp-ink-soft: #ADADA8;
  --pp-body-ink: #DCDCD9;
  --pp-pencil: #94948E;
  --mark-ink: var(--mark-ink-dark);
  --underline-mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 9'%3E%3Cpath d='M 0 5.4 C 20 5.9 38 6.3 60 6 C 82 5.7 98 5 120 5.2 C 140 5.4 152 5 166 5 C 172 5 176 5.2 180 5.4' fill='none' stroke='%235F5F5A' stroke-width='2.6' stroke-linecap='round'/%3E%3C/svg%3E");
  --pp-hairline: rgba(235, 235, 232, 0.14);

  /* The WHITE note inverts with the theme. Only these three tokens change:
     .about-me declares --note-face/--note-edge from them and every piece of
     text inside it (title, rows, tagline, the editable input) already reads
     --note-white-ink, so PAPER SURFACE repaints the whole note with no new
     rules and no per-element dark overrides.
     Face is --pp-card's value, exactly as in light: the note is the card
     surface lifted off --pp-paper, and the New button on top of it is that
     same fill in both themes, told apart by its border and shadow. Edge goes
     one step LIGHTER than the face here, mirroring light's one step darker.
     The coloured notes (sky, mint) deliberately do not invert: they are
     printed stock, not paper. The 404's note rides these too, which is why it
     stops being a white card on a dark page. */
  --note-white:       #212120;
  --note-white-edge:  #414140;
  --note-white-ink:   #F0F0EF;
  /* Same chrome/text split as light, mirrored: on a dark ground the legible
     tone is the LIGHTER one, so -deep and -press get lighter, not darker.
     chrome 6.90:1 · deep 9.24:1 · card-ink on deep fill 8.22:1 */
  --pp-accent: #4DA3FF;
  --pp-accent-rgb: 77 163 255;
  --pp-accent-deep: #7FBEFF;
  --pp-accent-press: #A9D4FF;
  /* --pp-highlight deliberately NOT overridden: the marker stays yellow in dark
     too, and the rule further down forces dark ink onto the swipe. */

  /* deeper shadow voice — the lamp is off */
  --pp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
  --pp-shadow-md: 0 1px 2px rgba(0, 0, 0, 0.35), 0 8px 24px -6px rgba(0, 0, 0, 0.5);
  --note-lift:
    0 1px 1px -1px rgba(0, 0, 0, 0.40),
    0 10px 16px -8px rgba(0, 0, 0, 0.62);
}
html[data-theme="dark"] { scrollbar-color: #414140 #212120; }

[data-theme="dark"] body { color: var(--pp-body-ink); }
body { transition: background-color 0.35s ease, color 0.35s ease; }

/* grain must lighten in dark (multiply is invisible on near-black) */
[data-theme="dark"] body::after {
  mix-blend-mode: screen;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.04'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
[data-theme="dark"] ::selection { background: rgb(var(--pp-accent-rgb) / 0.4); color: var(--pp-ink); }

/* The New button takes the ordinary dark button treatment now. It used to pin
   its tokens back to light values, because a light-bordered button on a note
   that stayed white read as off. The note inverts with the theme, so that
   premise is gone and the button is just a button again. */

/* .card is no longer a post-it, so it needs no dark-mode lift of its own: the
   .edge-card material's shadow rides --pp-shade-rgb and handles both themes. */

/* brand logos are white originals — drop the ink invert */
[data-theme="dark"] .brand-logo { filter: none; opacity: 0.9; }

/* --pp-shade-rgb is pure black in dark, so a shade-based wash is invisible
   here; the press state flips to a light tint. The rule colour needs no dark
   override — it rides --pp-hairline, which already flips. */
@media (pointer: coarse) {
  [data-theme="dark"] .work-row:active { background: rgba(235, 235, 232, 0.08); }
}
[data-theme="dark"] .gate-error { color: #FF7B57; }

/* figures/prints keep white mounts (photo paper), just deeper borders */
[data-theme="dark"] .contribution-media img { outline-color: var(--pp-hairline); }

/* the outfit swap — hoodie by day, suit after dark */
.avatar-night { display: none; }
[data-theme="dark"] .avatar .avatar-night { display: block; }
[data-theme="dark"] .avatar .avatar-day { display: none; }

/* The hats change with the light too, on exactly the avatar's pattern above:
   one img rendered per slot, the other display:none. By day beanie / baseball
   cap / cowboy hat; after dark flat cap / fedora / wizard hat.
   display:none, not opacity or visibility, so the hidden variant leaves the
   accessibility tree as well as the picture — script.js names each piece from
   whichever img is actually on screen, and a merely-transparent one would keep
   answering first. Sizing is in styles.css: the slots pin height and let width
   follow, so the two files' different aspect ratios can't squash each other. */
/* Both states are declared HERE, together, rather than letting styles.css set
   `display:block` on the img: `.beanie img` is (0,1,1) and would beat
   `.hat-night`'s (0,1,0), rendering both variants at once and sizing the slot
   to whichever is wider. Display belongs with the swap that owns it. */
.hat-day { display: block; }
.hat-night { display: none; }
[data-theme="dark"] .hat-day { display: none; }
[data-theme="dark"] .hat-night { display: block; }

/* ---------------------------------------------------------------------------
   TL;IAA control — Paper Studio skin. Structure and the placement variants
   live in styles.css; this is material only, so it flips with the theme.

   The mark reads as a rubber stamp rather than a sticky note: this is the
   machine-readable door out of a hand-made canvas, and looking slightly like
   office stationery is the joke.
--------------------------------------------------------------------------- */
.tliaa-link {
  color: var(--pp-ink);
  border-bottom: 1px solid transparent;
}
/* --pp-accent-deep, not --pp-accent: this IS text, and accent is chrome-only. */
.tliaa-mark {
  font-family: var(--pp-mono);
  font-weight: 700;
  color: var(--pp-ink-soft);
}
.tliaa-expand {
  font-family: var(--pp-body);
  font-weight: 400;
  color: var(--pp-ink-soft);
}

/* No skin of its own: it carries .btn and wears the shared sticker component.
   Only the agent-mode gate lives here, and it needs the extra specificity to
   beat .btn's own display:inline-flex. */
html:not([data-view="tliaa"]) .tliaa-copy { display: none; }

.tliaa-status {
  font-family: var(--pp-body);
  font-weight: 400;
  color: var(--pp-ink-soft);
}

.tliaa-gate {
  background: var(--pp-card);
  color: var(--pp-body-ink);
  border-radius: 10px;
  box-shadow: var(--pp-shadow-md);
  border: 1px solid var(--pp-hairline);
  font-family: var(--pp-body);
}
.tliaa-gate-msg { color: var(--pp-ink-soft); }
.tliaa-gate-form input {
  font-family: var(--pp-body);
  color: var(--pp-ink);
  background: var(--pp-paper);
  border: 1px solid var(--pp-hairline);
  border-radius: 6px;
}
.tliaa-gate-form input:focus-visible { outline: 2px solid var(--pp-accent); outline-offset: 1px; }
/* Filled with --pp-accent-deep, pressed with --pp-accent-press: a labelled fill
   on --pp-accent is 3.65:1 and fails AA. Matches .gate-form button. */
.tliaa-gate-form button {
  font-family: var(--pp-body);
  color: #FFFFFF;
  background: var(--pp-accent-deep);
  border: none;
  border-radius: 6px;
}
.tliaa-gate-form button:hover { background: var(--pp-accent-press); }
.tliaa-gate-error { font-family: var(--pp-body); color: var(--pp-accent-deep); }

/* No motion on the pill or the popover for anyone who asked for none. */
@media (prefers-reduced-motion: reduce) {
  .tliaa-copy { transition: none; }
  .tliaa-copy:hover, .tliaa-copy:active { transform: none; }
}

/* TL;IAA link in the case-study topbar. Deliberately quiet: the case-study
   pages stay flat and impact-first, so this is a mono label, not a control. */
.tliaa-cs {
  font-family: var(--pp-mono);
  font-weight: 700;
  font-size: 11px;
  letter-spacing: 0.04em;
  color: var(--pp-ink-soft);
  text-decoration: none;
  margin-left: 16px;
}

/* ============================================================================
   TL;IAA — the reading view

   The switch swaps the canvas for the same words set as one article. Not a
   different site: the paper, the ink, the type scale and the section eyebrows
   are the ones the case-study pages already use, so it reads as this site with
   the furniture taken out.

   Everything here lives in paper.css on purpose — it is the only stylesheet
   BOTH the homepage and the case-study pages load, and the switch is on both.
   ============================================================================ */

/* An explicit `display` beats the [hidden] attribute's UA `display:none`, so
   both of these need to say it themselves: .tliaa-link is display:flex and
   .tliaa-switch is display:inline-flex below. Without this the fallback link and
   the switch are BOTH visible — the label appeared twice — and the switch also
   flashed in before tliaa.js could reveal it deliberately. */
.tliaa-link[hidden],
.tliaa-switch[hidden] { display: none !important; }

/* ── the switch ─────────────────────────────────────────────────────────────
   Literally the light switch's component: .theme-toggle-track and
   .theme-toggle-knob, reused as-is, with a person and a robot where the sun and
   moon go. Two switches on the page that both flip a mode, so they are the same
   piece of hardware. The button itself is bare (like .lamp) — the track is the
   control, the caption sits under it. */
.tliaa-switch {
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  gap: 10px;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  font-family: var(--pp-body);
  /* A <button> with no authored size takes the UA default, which is 13.3333px
     in Chrome — the one fractional font-size left on the page. Its own children
     declare 13px/9px so nothing rendered at it, but anything added here would
     inherit a fraction. Pinned to the mark's size. */
  font-size: 13px;
  color: var(--pp-ink);
}
/* The track's own hover/active/on states are scoped to .lamp upstream, so the
   switch needs its own copies. Same values, so the two feel identical. */
.tliaa-switch:hover .theme-toggle-track {
  border-color: var(--pp-accent);
  box-shadow: 3px 3px 0 var(--pp-accent);
  transform: translate(-1px, -1px);
}
.tliaa-switch:active .theme-toggle-track {
  box-shadow: 1px 1px 0 var(--pp-ink);
  transform: translate(1px, 1px);
}
.tliaa-switch:focus-visible { outline: 2px solid var(--pp-accent); outline-offset: 4px; border-radius: 14px; }

/* Off offers the robot. On offers the person. Mirrors how
   [data-theme="dark"] flips the sun and moon. */
.tt-person { opacity: 0; }
.tliaa-switch[aria-checked="true"] .theme-toggle-knob { translate: 28px -50%; }
.tliaa-switch[aria-checked="true"] .tt-person { opacity: 1; }
.tliaa-switch[aria-checked="true"] .tt-robot { opacity: 0; }

/* TL;IAA over its expansion, the expansion smaller and quieter. */
.tliaa-label {
  display: flex;
  flex-direction: row;
  align-items: baseline;
  gap: 6px;
  text-align: left;
}
.tliaa-label .tliaa-mark {
  font-family: var(--pp-mono);
  font-weight: 700;
  font-size: 13px;
  letter-spacing: 0.04em;
  color: var(--pp-ink-soft);
  line-height: 1.1;
}
.tliaa-label .tliaa-expand {
  font-family: var(--pp-body);
  font-weight: 400;
  font-size: 12px;
  line-height: 1.2;
  color: var(--pp-ink-soft);
}

/* In the case-study topbar the row is horizontal and tight, so the control sits
   inline and the expansion drops to the aria-label. */
.tliaa-switch--cs {
  flex-direction: row;
  align-items: center;
  gap: 8px;
  margin-left: 16px;
}
.tliaa-switch--cs .tliaa-expand { display: none; }
.tliaa-switch--cs .tliaa-mark { font-size: 11px; }

@media (prefers-reduced-motion: reduce) {
  .tliaa-switch:hover .theme-toggle-track,
  .tliaa-switch:active .theme-toggle-track { transform: none; }
}

/* ── the view swap ──────────────────────────────────────────────────────────
   Homepage: keep .hero (it holds the switch, the copy button and the unlock
   popover — the controls have to survive the swap or you cannot get back) and
   hide every other canvas element. Case study: keep .topbar for the same reason.
   Nothing is destroyed, so switching back is instant and needs no re-render. */
html[data-view="tliaa"] .container > *:not(.hero):not(.tliaa-doc),
html[data-view="tliaa"] .hero > *:not(.tliaa),
html[data-view="tliaa"] .wrap > *:not(.topbar):not(.tliaa-doc),
html[data-view="tliaa"] .topbar > *:not(.tliaa-switch):not(.back) {
  display: none !important;
}

/* The control does NOT move when the switch flips. Deliberately: re-laying it
   out (static, auto margins, its own measure) made it jump across the page on
   the click that turned it on, which reads as a bug even when it settles
   somewhere sensible. So .container and .hero keep their widths — those are what
   resolve the control's `left` — and .hero only loses the height it was
   reserving for a canvas that is no longer there. The article flows after it. */
@media (min-width: 1025px) {
  html[data-view="tliaa"] .hero {
    height: 120px;
    min-height: 0;
  }
  html[data-view="tliaa"] .tliaa {
    right: 48px;
    width: auto;
  }
  html[data-view="tliaa"] .tliaa-copy { margin-left: auto; }
}
html[data-view="tliaa"] .tliaa-status {
  max-width: 320px;
  text-align: center;
}
/* The dot-grid/canvas flourishes have no business behind an article. */
html[data-view="tliaa"] body::before { display: none; }
/* The fixed actions bar floats over a sparse canvas fine, but over dense body
   copy it lands mid-paragraph. Nothing is lost by dropping it here: the contact
   links are a section of the document itself, and there is no canvas to reset. */
html[data-view="tliaa"] .toolbar-container { display: none; }

/* ── the document ───────────────────────────────────────────────────────────
   The case-study measure and rhythm: 720px, 15px/1.65 body, big display h1,
   uppercase accent eyebrows for the section level. */

/* Leaving the reading view only removes data-view — tliaa.js keeps the rendered
   article in the DOM so coming back is instant and needs no re-render. So the
   view, not the switch, has to hide it: without this the whole document stays
   in the flow behind the canvas, which is absolutely positioned and cannot push
   it out of the way. */
html:not([data-view="tliaa"]) .tliaa-doc { display: none; }

.tliaa-doc {
  display: block;
  max-width: 720px;
  margin: 0 auto;
  padding: 8px 24px 120px;
  font-family: var(--pp-body);
  font-weight: 400;
  font-size: 13px;
  line-height: 1.65;
  color: var(--pp-body-ink);
  -webkit-font-smoothing: antialiased;
}

.tliaa-doc > * { margin: 0 0 18px; }
.tliaa-doc > *:last-child { margin-bottom: 0; }

.tliaa-doc h1 {
  font-family: var(--pp-display);
  font-size: 39px;
  line-height: 1.05;
  letter-spacing: -0.02em;
  font-weight: 700;
  color: var(--pp-ink);
  margin: 32px 0 20px;
}
/* h2 = a site section or a whole case study. Ruled above, so the document has
   visible joints when you scroll it. */
.tliaa-doc h2 {
  font-family: var(--pp-display);
  font-size: 25px;
  line-height: 1.15;
  letter-spacing: -0.01em;
  font-weight: 700;
  color: var(--pp-ink);
  margin: 56px 0 16px;
  padding-top: 28px;
  border-top: 1px solid var(--pp-hairline);
}
.tliaa-doc hr + h2 { padding-top: 0; border-top: 0; }
/* h3 = the section eyebrow, exactly as case-study.css sets its h2. */
.tliaa-doc h3 {
  font-family: var(--pp-body);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-weight: 600;
  color: var(--pp-accent-deep);
  margin: 36px 0 12px;
}
.tliaa-doc h4 {
  font-size: 13px;
  font-weight: 600;
  color: var(--pp-ink);
  margin: 24px 0 8px;
}

.tliaa-doc h2.tliaa-case-label {
  font-family: var(--pp-body);
  font-size: 11px;
  line-height: 1;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--pp-accent-deep);
  margin-bottom: 12px;
}
.tliaa-doc .tliaa-case-label + h3 {
  font-family: var(--pp-display);
  font-size: 25px;
  line-height: 1.15;
  text-transform: none;
  letter-spacing: -0.01em;
  font-weight: 700;
  color: var(--pp-ink);
  margin: 0 0 16px;
}

.tliaa-doc strong { color: var(--pp-ink); font-weight: 600; }
.tliaa-doc em { font-style: italic; color: var(--pp-ink-soft); }
/* THE LINK owns these — .tliaa-doc a is listed in its blue variant. This rule
   used to restate the same colour pair with a 2px underline offset, and being
   later in the file at equal specificity it silently won that one property. */
.tliaa-doc code {
  font-family: var(--pp-mono);
  font-size: 0.9em;
  padding: 1px 5px;
  border-radius: 5px;
  background: rgb(var(--pp-shade-rgb) / 0.06);
  border: 1px solid var(--pp-hairline);
  overflow-wrap: anywhere;
}

.tliaa-doc ul { padding-left: 0; list-style: none; }
.tliaa-doc li {
  position: relative;
  padding-left: 20px;
  margin-bottom: 10px;
}
.tliaa-doc li::before {
  content: "";
  position: absolute;
  left: 2px;
  top: 0.62em;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--pp-accent);
}

/* Testimonials and pull quotes are text, not furniture: this is the plain-text
   edition of the site, so a quote reads as a quote from its own words and its
   attribution line. No card, no rule, no tint — the canvas does that job on the
   homepage, and repeating it here just boxes up a document. */
.tliaa-doc blockquote {
  margin: 24px 0;
  padding: 0;
  background: none;
  border: none;
  border-radius: 0;
  box-shadow: none;
}
.tliaa-doc blockquote p { margin: 0 0 10px; }
.tliaa-doc blockquote p:last-child { margin: 0; }

.tliaa-doc figure { margin: 24px 0; }
.tliaa-doc img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 10px;
  border: 1px solid var(--pp-hairline);
  background: var(--pp-card);
}

.tliaa-table { overflow-x: auto; margin: 24px 0; }
.tliaa-doc table { width: 100%; border-collapse: collapse; font-size: 12px; }
.tliaa-doc th, .tliaa-doc td {
  text-align: left;
  padding: 10px 12px;
  border-bottom: 1px solid var(--pp-hairline);
  vertical-align: top;
}
.tliaa-doc th { color: var(--pp-ink); font-weight: 600; border-bottom-color: var(--pp-ink-soft); }

.tliaa-doc hr {
  border: none;
  border-top: 1px solid var(--pp-hairline);
  margin: 40px 0;
}

@media (max-width: 600px) {
  .tliaa-doc { padding: 8px 18px 96px; }
  .tliaa-doc h1 { font-size: 30px; }
  .tliaa-doc h2 { font-size: 21px; margin-top: 44px; }
  html[data-view="tliaa"] .tliaa { padding: 20px 18px 0; }
}

@media (min-width: 1025px) {
  .tliaa-doc {
    max-width: none;
    margin-left: 24px;
    margin-right: 24px;
  }
}
