/* Generated by tools/build.mjs from ui/css/*.css. Edit the sources. */

/* ---- ui/css/00-tokens.css ---- */
@font-face { font-family: "Schibsted Grotesk"; font-style: normal; font-weight: 400 800; font-display: swap; src: url("/fonts/schibsted-grotesk-latin.woff2") format("woff2"); }
/* Metric-matched stand-ins while the font loads, so the swap moves nothing.
   Computed with fontTools against macOS Arial: frequency-weighted English
   advance widths (letters plus word spaces) of Schibsted at 400 / Arial
   Regular = 1.050, at 720 / Arial Bold = 1.022; Schibsted's hhea metrics
   (ascent 2000, descent 528, no line gap, 2048 upm) divided by that scale.
   Liberation Sans is metric-identical to Arial; Helvetica is close. */
@font-face { font-family: "Schibsted Fallback"; font-style: normal; font-weight: 400 549; src: local("Arial"), local("ArialMT"), local("Liberation Sans"), local("Helvetica"); size-adjust: 105%; ascent-override: 93%; descent-override: 24.6%; line-gap-override: 0%; }
@font-face { font-family: "Schibsted Fallback"; font-style: normal; font-weight: 550 800; src: local("Arial Bold"), local("Arial-BoldMT"), local("Liberation Sans Bold"), local("Helvetica Bold"); size-adjust: 102.2%; ascent-override: 95.6%; descent-override: 25.2%; line-gap-override: 0%; }
/* ============================================================================
   ui.css · the design system (revision 5, October 2026 identity pass)
   Source files live in ui/css/ and are concatenated by tools/build.mjs in
   name order, so this file is first. docs/design-system.md explains the
   decisions; this file is their implementation and its token API.

   Colour: the legacy names (--bg, --ink, --muted, --accent, --line) are set
   by styles.css per theme; everything here derives from them.

   TOKEN API (use these; never a raw px, size or curve in a section file)
   -------------------------------------------------------------------------
   Type. One face, Schibsted Grotesk (variable 400–800, fonts/), no italic,
   no synthesis, no tabular figures anywhere (its tabular set widens
   . , :). Seven steps, one job each; nothing else exists.
     role      size        weight        line        tracking     class
     display   --fs-display --fw-display --lh-display --tr-display .t-display  hero, case title
     h2        --fs-h2     --fw-h2       --lh-h2     --tr-h2      .t-h2       section, page h1, contact title
     h3        --fs-h3     --fw-h3       --lh-h3     --tr-h3      .t-h3       project/role/degree titles, email
     lead      --fs-lead   --fw-text     --lh-lead   0            .t-lead
     body      --fs-body   --fw-text     --lh-body   0            .t-body
     small/UI  --fs-sm     --fw-ui (controls) / --fw-text (text)  --lh-sm, --lh-ui (1, buttons)  .t-sm
     label     --fs-label  --fw-label    --lh-label  --tr-label   .t-label / .label  dates, meta
   --fw-strong is <b> inside running text. --fw-display-quiet is the hero's
   lighter second sentence (same size as the display line). Dark mode sets
   the three heading weights 20 lighter: light ink on a dark ground spreads.
   Deprecated aliases, kept so older rules land on the scale: --fs-lg
   (→ lead), --fs-label-sm (→ label), --lh-head, --lh-tight, --track-*.

   Space. --s-1…--s-10 = 4 8 12 16 24 32 48 64 96 128. --gutter (16→48),
   --container 1200, --prose 54ch (Schibsted's 0 is wide: about 70
   characters a line).
   Section rhythm: every .sec (and .ledger) opens with its hairline, then
   --sec-top (48) to the heading, the heading block, --head-gap (32→48) to the
   content, and --sec-gap (64→96) before the next hairline. A section never
   sets its own padding or rule.
   Rows: --row-pad (24) above and below each ruled row.

   Surfaces. --r-card (20) is the one card radius; --card-pad is 24 on
   phones and 32 from 900px (a card may set its own, e.g. Contact 48).
   --r-media is the radius of media inset in a card: card radius minus its
   padding, never below --r-xs. --r-xs / --r-sm / --r-pill for controls.
   Content surfaces (.card) are one hairline ring and no blurred shadow.
   Blur is for floating chrome only, through one recipe, .glass (--glass-bg,
   --glass-blur, --glass-sat, --glass-edge, --glass-shadow): the nav pill,
   the menu sheet, the résumé dialog, the play room, the theater's pill and
   close, the toast. Modal surfaces sit over --scrim. --shadow-1 is now a
   ring alias.

   Motion: "words are never late; objects arrive". Text is readable on the
   first paint and never fades or rises in; only the portrait, plates and
   the objects inside them arrive. Curves: --ease-out (feedback),
   --ease-enter (arrivals), --ease-in (exits only), --ease-move (a thing
   that travels across the screen: starts from rest, settles long).
   Durations: --d-press 120 (press in), --d-fast 160 (hover, colour),
   --d-base 240 (press out, frames, disclosure content), --d-in 320 /
   --d-out 220 (things that arrive and leave: exits are faster), --d-menu
   320, --d-page 420 (disclosure height, the sun light settling), --d-atmos
   1200 (the paper's light fading in once).
   Press scale: --press .97 (buttons, pills), --press-text .98 (text links),
   --press-row .99 (whole rows), --plate-press .985 (every plate that
   links, one rule).
   Entrances (data-appear="object" or .rise; "head" and "caption" are
   accepted and stay still): the plate rises --enter-rise (16, phones 12)
   over --enter-dur (560, phones 480); the .plate-obj inside settles
   --enter-obj-rise (10, phones 6) over --enter-obj-dur (720, phones 600),
   --enter-lag (60) after it; a fan's side phones ([data-fan="left|right"])
   open from --enter-fan (1.5cqw) tucked in. Things arriving together are
   --enter-stagger (60) apart, five steps at most. The portrait arrives the
   same way on load (01-hero.css).
   Plates that link: the hairline --plate-edge → --plate-edge-hover
   (--d-fast) and the .plate-obj comes up --plate-lift (4px) over
   --d-plate; nothing spreads, zooms or glows; press .985.
   Page to page: the old page leaves in --d-vt-out (120, ease-in), the new
   one arrives over --d-vt-in (240) from --d-vt-gap (60), the shared plate
   travels over --d-vt (500) on --ease-move (vt.js names it).
   Smooth wheel (Lenis) on fine pointers on every page; an in-page jump
   takes 0.36–0.6s by distance (ui/js/00-core.js). Touch scrolls natively.
   Reduced motion shortens every duration and removes all travel.

   Components defined here (the API the sections build with):
     .sec, .sec-head (h2 + .lead), .ledger (.ledger-head / .ledger-body:
       4fr / 8fr from 900px, one column below)
     .rows > .row            ruled rows on the paper; .row-link when the
                             whole row is a link (tint, press, mark at end)
     .btn .btn-primary .btn-secondary .btn-sm (the build leads each link
                             button with its icon)
     .btn-close              the one icon-only close (44px, bare, tint)
     .link-arrow (.link-quiet) text links; the build leads each with the
                             icon for where it goes (tools/glyphs.mjs)
     .ico (+ .ico-<name>)    Phosphor bold icons; .ico-ctl for 20px
                             icon-only controls
     .plate (.plate-link, .plate-obj) a flat tinted surface holding one
                             object; as a link it answers on the plate
     [data-appear], .rise    entrances (ui/js/00-core.js)
     .card, .card-media, .frame (media on the paper), .shot-light (a light
                             UI capture: dimmed and ringed in dark mode)
     .label, .t-*, .prose, .glass, .toast (ui.toast / ui.announce), .rise,
     .dis-ico, .sr-only
   ========================================================================== */

:root {
  /* surfaces, derived from the theme's paper and ink */
  --paper: var(--bg);
  --raised: color-mix(in oklab, var(--bg), white 72%);
  --sunk: color-mix(in oklab, var(--bg), var(--ink) 6%);
  --hairline: color-mix(in oklab, var(--ink), transparent 88%);
  --hairline-strong: color-mix(in oklab, var(--ink), transparent 80%);
  --edge: color-mix(in oklab, var(--ink), transparent 86%);           /* card outline */
  --tint: color-mix(in oklab, var(--ink), transparent 96%);           /* hover fill */
  --tint-2: color-mix(in oklab, var(--ink), transparent 92%);         /* pressed fill */
  --chip: color-mix(in oklab, var(--ink), transparent 94%);           /* an action link's resting pill */
  --chip-2: color-mix(in oklab, var(--ink), transparent 89%);         /* its hover */
  --chip-3: color-mix(in oklab, var(--ink), transparent 84%);         /* its press */
  /* Plates: a flat tinted surface that holds ONE object or one compact
     group (an app icon tile, a screenshot, the Skills block). Not a card:
     no shadow, at most one hairline, never a full-width band. */
  --plate: color-mix(in oklab, var(--bg), var(--ink) 4%);
  --plate-2: color-mix(in oklab, var(--bg), var(--ink) 7%);
  --accent-ink: var(--bg);                                            /* text on accent */
  --accent-pop: #d1441a;                                              /* indicators only; text uses --accent */
  --accent-soft: color-mix(in oklab, var(--accent), transparent 86%);

  /* type: one face */
  --font-sans: "Schibsted Grotesk", "Schibsted Fallback", Arial, sans-serif;
  --font-display: var(--font-sans);
  --font-mono: var(--font-sans); /* deprecated: there is no mono */

  --fs-display: clamp(2.375rem, 1.35rem + 4.1vw, 4.875rem);  /* 38 → 78 */
  --fs-h2: clamp(1.75rem, 1.4rem + 1.6vw, 2.75rem);          /* 28 → 44 */
  --fs-h3: clamp(1.25rem, 1.12rem + .6vw, 1.625rem);         /* 20 → 26 */
  --fs-lead: clamp(1.125rem, 1.06rem + .3vw, 1.25rem);       /* 18 → 20 */
  --fs-body: 1.0625rem;                                      /* 17 */
  --fs-sm: .9375rem;                                         /* 15 */
  --fs-label: .8125rem;                                      /* 13 */

  --fw-display: 760;
  --fw-display-quiet: 420;
  --fw-h2: 720;
  --fw-h3: 650;
  --fw-strong: 600;
  --fw-ui: 500;
  --fw-label: 500;
  --fw-text: 400;

  --lh-display: .98;
  --lh-h2: 1.06;
  --lh-h3: 1.18;
  --lh-lead: 1.5;
  --lh-body: 1.6;
  --lh-sm: 1.5;
  --lh-label: 1.35;
  --lh-ui: 1;

  --tr-display: -.042em;
  --tr-h2: -.03em;
  --tr-h3: -.02em;
  --tr-label: .005em;

  /* deprecated aliases: older rules land on the scale */
  --fs-lg: var(--fs-lead);
  --fs-label-sm: var(--fs-label);
  --lh-head: var(--lh-h2);
  --lh-tight: var(--lh-display);
  --track-display: var(--tr-display);
  --track-head: var(--tr-h2);
  --track-label: var(--tr-label);

  /* space (4px base) */
  --s-1: 4px; --s-2: 8px; --s-3: 12px; --s-4: 16px; --s-5: 24px; --s-6: 32px;
  --s-7: 48px; --s-8: 64px; --s-9: 96px; --s-10: 128px;
  --gutter: clamp(16px, 4vw, 48px);
  --container: 1200px;
  --prose: 54ch;
  --sec-top: var(--s-7);
  --sec-gap: clamp(4rem, 7vw, 6rem);
  --head-gap: clamp(2rem, 1.5rem + 1.5vw, 3rem);
  --row-pad: var(--s-5);

  /* shape */
  --r-xs: 6px; --r-sm: 10px; --r-md: 14px; --r-lg: 20px; --r-xl: 28px; --r-pill: 999px;
  --r-card: var(--r-lg);
  --card-pad: var(--s-5);

  /* depth: rings for content, blur for floating chrome only */
  --shadow-ink: 40 30 10;
  --ring: 0 0 0 1px var(--edge);
  --shadow-float: 0 0 0 1px rgb(var(--shadow-ink) / .08), 0 2px 4px rgb(var(--shadow-ink) / .05), 0 8px 24px rgb(var(--shadow-ink) / .08);
  --shadow-dialog: 0 0 0 1px rgb(var(--shadow-ink) / .08), 0 4px 8px rgb(var(--shadow-ink) / .06), 0 20px 48px rgb(var(--shadow-ink) / .12);
  --shadow-1: var(--ring);              /* deprecated: content surfaces have no blur */
  --shadow-2: var(--shadow-float);
  --shadow-3: var(--shadow-dialog);

  /* Glass: the one frost for every floating surface (nav pill, menu sheet,
     résumé dialog, play room, theater pill and close, toast). A paper tint
     dense enough to read over a busy screenshot, a blur, a little
     saturation, a 1px inner edge of light and the float shadow. Without
     backdrop-filter (or with reduced transparency) it is the near-solid
     paper. A modal surface sits over --scrim, black at 30%. */
  --glass-blur: 16px;
  --glass-sat: 160%;
  --glass-bg: color-mix(in oklab, var(--bg), transparent 12%);
  --glass-solid: color-mix(in oklab, var(--bg), transparent 4%);
  --glass-edge: inset 0 1px 0 rgb(255 255 255 / .5), inset 0 0 0 1px rgb(255 255 255 / .2);
  --glass-shadow: var(--shadow-float);
  --scrim: color-mix(in oklab, black, transparent 70%);

  /* motion */
  --d-press: 120ms; --d-fast: 160ms; --d-base: 240ms; --d-out: 220ms; --d-in: 320ms;
  --d-menu: 320ms; --d-page: 420ms; --d-atmos: 1200ms;
  --ease-out: cubic-bezier(.23, 1, .32, 1);
  --ease-in: cubic-bezier(.4, 0, 1, 1);
  --ease-in-out: cubic-bezier(.77, 0, .175, 1);
  --ease-drawer: cubic-bezier(.32, .72, 0, 1);
  --ease-emph: cubic-bezier(.05, .7, .1, 1);
  --ease-enter: cubic-bezier(.22, 1, .36, 1);   /* the house curve for arrivals */
  --ease-move: cubic-bezier(.2, 0, 0, 1);       /* travel: a soft start, a long settle */
  --spring: var(--ease-out);
  --press: .97; --press-text: .98; --press-row: .99;

  /* entrances (ui/js/00-core.js adds the classes; this file moves them):
     once per element, the first time it is scrolled to. Objects only:
     words are on the page from the first paint. */
  --enter-rise: 16px;          /* a plate (or the portrait) */
  --enter-dur: 560ms;
  --enter-obj-rise: 10px;      /* the object settling into its plate */
  --enter-obj-dur: 720ms;
  --enter-fan: 1.5cqw;         /* a fan's side phones start this far tucked in */
  --enter-stagger: 60ms;       /* between things that arrive together, at most 5 steps */
  --enter-lag: 60ms;           /* the object after its plate */
  --enter-arm: 96px;           /* how far below the fold an element is readied */
  /* kept so older rules parse: heads and captions no longer move */
  --enter-rise-head: 0px;
  --enter-dur-head: 0ms;

  /* plates that link: the hairline brightens, the object inside comes up
     4px, press sinks the whole plate a little. No zoom, no spread, no
     shadow. */
  --plate-edge: color-mix(in oklab, var(--ink), transparent 91%);
  --plate-edge-hover: color-mix(in oklab, var(--ink), transparent 76%);
  --plate-lift: -4px;
  --plate-press: .985;
  --d-plate: 380ms;

  /* page to page: the old page leaves fast, the new one arrives a beat
     later, the shared plate travels */
  --d-vt-out: 120ms;
  --d-vt-gap: 60ms;
  --d-vt-in: 240ms;
  --d-vt: 500ms;

  /* icons (tools/glyphs.mjs): Phosphor bold, filled shapes on a 256 grid,
     in one box everywhere, leading their label */
  --ico-size: 1.125em;
  --ico-gap: .4em;
  --ico-rest: .86;             /* in a text link the icon rests a step back, full on hover */
  --ico-ctl: 20px;             /* icon-only controls */
  --ico-stroke: 2.5;           /* deprecated: the icons are filled; kept so old rules parse */

  /* focus */
  --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);

  /* bridge: legacy names used by styles.css */
  --disp: var(--font-sans);
  --sans: var(--font-sans);
  --label: var(--font-sans);
}

@supports (animation-timing-function: linear(0, 1)) {
  :root { --spring: linear(0, .25 6%, .6 13%, .86 21%, .97 28%, 1.02 36%, 1.01 48%, 1); }
}

@media (min-width: 900px) {
  :root { --card-pad: var(--s-6); }
}

:root[data-theme="dark"] {
  --accent-pop: #ff7b4a;
  --raised: color-mix(in oklab, var(--bg), white 8%);
  --sunk: color-mix(in oklab, var(--bg), black 28%);
  --plate: color-mix(in oklab, var(--bg), white 5%);
  --plate-2: color-mix(in oklab, var(--bg), white 9%);
  --edge: color-mix(in oklab, var(--ink), transparent 82%);
  --shadow-ink: 0 0 0;
  --glass-bg: color-mix(in oklab, var(--bg), transparent 14%);
  --glass-edge: inset 0 1px 0 rgb(255 255 255 / .10), inset 0 0 0 1px rgb(255 255 255 / .07);
  /* light ink on a dark ground reads heavier: the heads step back 20 */
  --fw-display: 740;
  --fw-h2: 700;
  --fw-h3: 630;
}

@media (max-width: 640px) {
  :root { --glass-blur: 12px; }
}

/* Phones and touch: native scrolling, the same choreography on shorter
   travel and time. */
@media (pointer: coarse), (max-width: 899.98px) {
  :root { --enter-rise: 12px; --enter-dur: 480ms; --enter-obj-rise: 6px; --enter-obj-dur: 600ms; }
}

@media (prefers-reduced-motion: reduce) {
  :root { --d-base: 120ms; --d-in: 150ms; --d-out: 120ms; --d-menu: 150ms; --d-page: 150ms; --d-atmos: 0ms; --spring: var(--ease-out);
    --enter-rise: 0px; --enter-obj-rise: 0px; --enter-fan: 0px; --plate-lift: 0px; --plate-press: 1; }
}

/* ---------------------------------------------------------------------------
   Base
   ------------------------------------------------------------------------ */

html { scroll-padding-top: 5.5rem; -webkit-text-size-adjust: 100%; font-synthesis: none; }

body {
  font-family: var(--font-sans);
  font-weight: var(--fw-text);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  font-kerning: normal;
  font-synthesis: none;
  font-variant-numeric: normal;
  -webkit-tap-highlight-color: transparent;
}

button, input, select, textarea { font: inherit; color: inherit; }
button { background: none; border: 0; cursor: pointer; touch-action: manipulation; }
b, strong { font-weight: var(--fw-strong); }
/* There is no italic face; emphasis stays in the one voice. */
em { font-family: inherit; font-style: normal; font-weight: inherit; letter-spacing: inherit; }

/* Headings balance their lines, running text avoids orphans and ragged last
   lines. Both degrade to normal wrapping. */
h1, h2, h3 { text-wrap: balance; }
p, li, dd, figcaption { text-wrap: pretty; }

:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--r-xs); }

/* A phrase that must not break, such as "multi-currency" (a breaking
   hyphen reads as hyphenation). */
.nowrap { white-space: nowrap; }

.sr-only {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ---------------------------------------------------------------------------
   Type roles. A rule may use the classes or the same tokens directly.
   ------------------------------------------------------------------------ */

.t-display { font-size: var(--fs-display); font-weight: var(--fw-display); line-height: var(--lh-display); letter-spacing: var(--tr-display); }
.t-h2 { font-size: var(--fs-h2); font-weight: var(--fw-h2); line-height: var(--lh-h2); letter-spacing: var(--tr-h2); }
.t-h3, .h3 { font-size: var(--fs-h3); font-weight: var(--fw-h3); line-height: var(--lh-h3); letter-spacing: var(--tr-h3); }
.t-lead { font-size: var(--fs-lead); font-weight: var(--fw-text); line-height: var(--lh-lead); letter-spacing: 0; }
.t-body { font-size: var(--fs-body); font-weight: var(--fw-text); line-height: var(--lh-body); letter-spacing: 0; }
.t-sm { font-size: var(--fs-sm); line-height: var(--lh-sm); letter-spacing: 0; }
:is(.t-display, .t-h2, .t-h3, .h3) { color: var(--ink); margin: 0; }

/* Labels: metadata, dates, captions. Proportional figures, like the rest. */
.label, .t-label {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  font-weight: var(--fw-label);
  letter-spacing: var(--tr-label);
  line-height: var(--lh-label);
  color: var(--muted);
  text-transform: none;
}

/* ---------------------------------------------------------------------------
   Layout and rhythm
   ------------------------------------------------------------------------ */

.container { width: min(var(--container), 100% - 2 * var(--gutter)); margin-inline: auto; }
.prose { max-width: var(--prose); }

/* A section: the hairline introduces its heading. 48px after the rule, the
   heading, then the content, then --sec-gap before the next rule. */
.sec, .ledger { padding-block: var(--sec-top) var(--sec-gap); border-top: 1px solid var(--hairline); }
/* The first block of a page (the work index) has no rule above it. */
main > :is(.sec, .ledger):first-child { border-top: 0; padding-top: 0; }

.sec-head { display: grid; gap: var(--s-3); margin-bottom: var(--head-gap); max-width: 48rem; }
.sec-head :is(h1, h2) { font-size: var(--fs-h2); font-weight: var(--fw-h2); line-height: var(--lh-h2); letter-spacing: var(--tr-h2); color: var(--ink); margin: 0; }
.sec-head .lead { font-size: var(--fs-lead); line-height: var(--lh-lead); color: var(--muted); max-width: 60ch; margin: 0; }
.sec-head .sec-aside { justify-self: start; }

/* Ledger: the heading in the left 4fr, the content in the right 8fr from
   900px; one column below. The heading scrolls with the page, never sticks. */
.ledger { display: grid; row-gap: var(--head-gap); }
.ledger-head { min-width: 0; }
.ledger-head :is(h1, h2) { font-size: var(--fs-h2); font-weight: var(--fw-h2); line-height: var(--lh-h2); letter-spacing: var(--tr-h2); color: var(--ink); margin: 0; text-wrap: balance; }
.ledger-head .lead { font-size: var(--fs-body); line-height: var(--lh-body); color: var(--muted); max-width: 30ch; margin: var(--s-3) 0 0; text-wrap: pretty; }
.ledger-body { min-width: 0; }
@media (min-width: 900px) {
  .ledger { grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); column-gap: var(--s-8); }
  .ledger-head { align-self: start; }
}

/* Ruled rows on the paper: a hairline between rows, none at the ends, so the
   first row's text lines up with the heading beside it. When the whole row is
   a link (.row-link) it answers like every control: ink, a soft tint 12px
   past its text on every side, press .99, the mark at the row's end. */
.rows { display: grid; margin: 0; padding: 0; list-style: none; }
.rows > .row { --row-pt: var(--row-pad); --row-pb: var(--row-pad); min-width: 0; padding-block: var(--row-pt) var(--row-pb); }
.rows > .row:first-child { --row-pt: 0px; }
.rows > .row:last-child { --row-pb: 0px; }
.rows > .row + .row { border-top: 1px solid var(--hairline); }

.row-link {
  position: relative; isolation: isolate;
  display: flex; align-items: baseline; gap: var(--s-4);
  color: var(--ink); text-decoration: none;
  transition: transform var(--d-fast) var(--ease-out);
}
.row-link::before {
  content: ""; position: absolute; z-index: -1;
  inset: calc(var(--row-pt, 0px) - var(--s-3)) calc(-1 * var(--s-3)) calc(var(--row-pb, 0px) - var(--s-3));
  border-radius: var(--r-sm); background: var(--tint);
  opacity: 0; pointer-events: none;
  transition: opacity var(--d-fast) var(--ease-out), background-color var(--d-fast) var(--ease-out);
}
.row-link > .ico:last-child { margin-left: auto; align-self: center; color: var(--muted); transition: translate var(--d-fast) var(--ease-out), color var(--d-fast) var(--ease-out); }
.row-link:active { transform: scale(var(--press-row)); transition-duration: var(--d-press); }
.row-link:active::before { opacity: 1; background: var(--tint-2); }
.row-link:focus-visible { box-shadow: none; }
.row-link:focus-visible::before { opacity: 1; box-shadow: var(--focus-ring); }

/* Below-the-fold sections skip layout until near; never on the hero. */
.cv-auto { content-visibility: auto; contain-intrinsic-size: auto 900px; }

/* ---------------------------------------------------------------------------
   Interaction grammar, shared with the navigation pill
   Hover: text to ink and a soft --tint fill; nothing moves but a mark, by
   1px in its own direction. Press: --press .97 (buttons, pills),
   --press-text .98 (text links), --press-row .99 (rows), with the --tint-2
   fill. Focus: the ring on the control's own shape. Timing: --d-press in,
   --d-fast out, on --ease-out. Hover rules exist only for fine pointers;
   press still answers a tap. Reduced motion keeps every colour change and
   removes travel. Terracotta is never a hover colour.
   ------------------------------------------------------------------------ */

/* Buttons: primary (ink pill) and secondary (hairline pill). A link button
   is led by its icon (Résumé, For recruiters, Email me, All projects), a
   little less padding on that side so the pill looks centred. */
.btn {
  --btn-h: 44px;
  display: inline-flex; align-items: center; justify-content: center; gap: var(--ico-gap);
  min-height: var(--btn-h); padding: 0 20px;
  border-radius: var(--r-pill);
  font-family: var(--font-sans); font-size: var(--fs-sm); font-weight: var(--fw-ui); line-height: var(--lh-ui);
  letter-spacing: 0; white-space: nowrap; text-decoration: none;
  color: var(--ink); background: transparent; border: 1px solid transparent;
  transition: transform var(--d-fast) var(--ease-out),
              background-color var(--d-fast) var(--ease-out), border-color var(--d-fast) var(--ease-out),
              color var(--d-fast) var(--ease-out);
}
.btn:has(> .ico:first-child) { padding-left: 16px; }
.btn-sm:has(> .ico:first-child) { padding-left: 12px; }

.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
/* Secondary: a soft filled pill, never an empty outline (owner, Nov 2026). */
.btn-secondary { border-color: transparent; background: var(--chip); }
.btn-sm { --btn-h: 36px; padding: 0 var(--s-4); }

@media (hover: hover) and (pointer: fine) {
  /* One step off ink, no hue. */
  .btn-primary:hover { background: color-mix(in oklab, var(--ink), var(--bg) 14%); border-color: transparent; }
  .btn-secondary:hover { background: var(--chip-2); border-color: transparent; }
}
/* Press: in at --d-press, the fill one step deeper. */
.btn:active { transform: scale(var(--press)); background-color: var(--tint-2); transition-duration: var(--d-press); }
.btn-primary:active { background-color: color-mix(in oklab, var(--ink), black 8%); }
.btn:focus-visible { border-radius: var(--r-pill); }

/* ---------------------------------------------------------------------------
   Text links. The build leads each with the icon for where it goes (App
   Store, GitHub, case study …, tools/glyphs.mjs); the label is the meaning.
   The icon sits flush with its column inside a 44px target; hover and focus
   share one shape, the pill drawn by ::before with the nav indicator's
   geometry: 36px tall, 10px past the content at each end, fully rounded.
   ------------------------------------------------------------------------ */

/* A grid, not a flex row: the icon is centred on the line while the label
   keeps the link's baseline (a flex row would take its baseline from the
   icon's bottom edge and lift the link off the text beside it), and the
   line stays centred in a taller target. */
.link-arrow, .lt-link, .ft-top {
  display: inline-grid; grid-auto-flow: column; justify-content: start;
  align-content: center; align-items: baseline; column-gap: var(--ico-gap);
  font-weight: var(--fw-ui); color: var(--ink); text-decoration: none;
}
.link-quiet { color: var(--muted); font-weight: var(--fw-text); }
:is(.link-arrow, .lt-link, .ft-top):not(.btn) {
  position: relative; isolation: isolate;
  transition: color var(--d-fast) var(--ease-out), transform var(--d-fast) var(--ease-out);
}
/* (November 2026, owner: actions read as empty words floating on the
   paper.) Every action link now rests on a soft pill of its own, the nav
   pill's shape at a quieter weight: no border, one fill that deepens a
   step on hover and again on press. Inside running text (.link) nothing
   changes. */
:is(.link-arrow, .lt-link, .ft-top):not(.btn) {
  min-height: 40px; padding-inline: 14px;
  border-radius: var(--r-pill);
}
:is(.link-arrow, .lt-link, .ft-top):not(.btn)::before {
  content: ""; position: absolute; z-index: -1; inset: 0;
  border-radius: var(--r-pill); background: var(--chip);
  pointer-events: none;
  transition: background-color var(--d-fast) var(--ease-out), box-shadow var(--d-fast) var(--ease-out);
}
:is(.link-arrow, .lt-link, .ft-top).link-quiet:not(.btn)::before { background: var(--tint); }

/* Underline links inside running text: the underline comes to ink on hover. */
.link {
  color: var(--ink); text-decoration: underline;
  text-decoration-color: color-mix(in oklab, var(--ink), transparent 70%);
  text-decoration-thickness: 1px; text-underline-offset: 3px;
  transition: text-decoration-color var(--d-fast) var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .link:hover { text-decoration-color: var(--ink); }
  :is(.link-arrow, .lt-link, .ft-top):not(.btn):hover { color: var(--ink); }
  :is(.link-arrow, .lt-link, .ft-top):not(.btn):hover::before { background: var(--chip-2); }
  .row-link:hover::before { opacity: 1; }
  .row-link:hover > .ico:last-child { color: var(--ink); }
}
:is(.link-arrow, .lt-link, .ft-top):not(.btn):active { transform: scale(var(--press-text)); transition-duration: var(--d-press); }
:is(.link-arrow, .lt-link, .ft-top):not(.btn):active::before { background: var(--chip-3); }
/* Keyboard focus draws the ring on the pill, so focus and hover share a
   shape, and brings a quiet link to ink as hover does. */
:is(.link-arrow, .lt-link, .ft-top):not(.btn):focus-visible { box-shadow: none; color: var(--ink); }
:is(.link-arrow, .lt-link, .ft-top):not(.btn):focus-visible::before { opacity: 1; box-shadow: var(--focus-ring); }

/* ---------------------------------------------------------------------------
   Icons (tools/glyphs.mjs: Phosphor bold). One 1.125em box everywhere,
   .4em before the label, currentColor. In a line box the flex centre lands
   on Schibsted's cap middle (within .01em), so no nudging is needed. Bold's
   24/256 stroke is 1.58px beside 15px/500 text, the text's own stem. In a
   text link the icon rests at --ico-rest and comes to full ink on hover or
   focus; nothing moves except download (down 1px) and back to top (up
   1px); press puts them back; reduced motion keeps them still.
   ------------------------------------------------------------------------ */

.ico {
  width: var(--ico-size); height: var(--ico-size); flex: none;
  fill: currentColor; stroke: none;
  overflow: visible; vertical-align: -.2em;
  transition: translate var(--d-fast) var(--ease-out), opacity var(--d-fast) var(--ease-out);
}
/* Icon-only controls: a 20px icon in a 44px target. */
.ico-ctl { --ico-size: var(--ico-ctl); }

/* A row's destination word (…-go) with its icon leading it, on the same
   grid as a text link so the word keeps the row's baseline. */
.row-go { display: inline-grid; grid-auto-flow: column; align-items: baseline; column-gap: var(--ico-gap); }
:is(.link-arrow, .lt-link, .ft-top, .row-go) > .ico { align-self: center; }
:is(.link-arrow, .lt-link, .ft-top, .row-link) > .ico, .row-link .row-go > .ico { opacity: var(--ico-rest); }
:is(.link-arrow, .lt-link, .ft-top, .row-link):is(:focus-visible, :active) > .ico, .row-link:is(:focus-visible, :active) .row-go > .ico { opacity: 1; }
@media (hover: hover) and (pointer: fine) {
  :is(.link-arrow, .lt-link, .ft-top, .row-link):hover > .ico, .row-link:hover .row-go > .ico { opacity: 1; }
  :hover > .ico-download-simple { translate: 0 1px; }
  :hover > .ico-arrow-up { translate: 0 -1px; }
}
:active > .ico { translate: none; transition-duration: var(--d-press); }

/* ---------------------------------------------------------------------------
   Chips (being retired; kept on the scale until the last one goes)
   ------------------------------------------------------------------------ */

.chips { display: flex; flex-wrap: wrap; gap: var(--s-2); margin: 0; padding: 0; list-style: none; }
.chip {
  display: inline-flex; align-items: center; gap: var(--s-1);
  height: 28px; padding: 0 var(--s-3); border-radius: var(--r-xs);
  font-family: var(--font-sans); font-size: var(--fs-label); font-weight: var(--fw-label); letter-spacing: var(--tr-label); line-height: 1;
  color: var(--ink-soft, var(--ink)); border: 1px solid var(--hairline-strong); background: var(--raised);
  white-space: nowrap;
}

/* Availability, where a page still states it in a line of its own. */
.avail {
  display: inline-flex; align-items: center; gap: .45em; flex-wrap: wrap;
  min-height: 32px; padding: 0 var(--s-3); border-radius: var(--r-pill);
  background: var(--raised); border: 1px solid var(--hairline-strong);
  font-size: var(--fs-label); font-weight: var(--fw-label); color: var(--muted); line-height: var(--lh-label);
}
.avail b { font-weight: var(--fw-strong); color: var(--ink); }

/* ---------------------------------------------------------------------------
   Cards and media. A card is raised paper inside one hairline ring, with no
   blurred shadow. Media sits in exactly one frame: inside a card it is
   clipped to --r-media on a sunk fill with no ring of its own (the card is
   the frame); on the paper, .frame gives it the card radius and one ring.
   ------------------------------------------------------------------------ */

.card {
  --card-r: var(--r-card);
  --r-media: max(var(--r-xs), calc(var(--card-r) - var(--card-pad)));
  position: relative;
  padding: var(--card-pad);
  border-radius: var(--card-r);
  background: var(--raised);
  border: 1px solid var(--edge);
  box-shadow: none;
  color: var(--ink);
}
.card-sunk { background: var(--sunk); border-color: var(--edge); }

.card-media, .frame { --media-ring: var(--hairline); position: relative; overflow: hidden; background: var(--sunk); }
.card-media { border-radius: var(--r-media, var(--r-xs)); }
.frame { border-radius: var(--r-card); }
/* The ring is drawn over the image, so a white capture keeps its edge. */
.card-media::after, .frame::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 0 0 1px var(--media-ring);
}
.card :is(.card-media, .frame) { --media-ring: transparent; }

/* A light-UI capture on a dark page: dimmed a step, and given a ring strong
   enough to hold its edge, even inside a card. */
:root[data-theme="dark"] .shot-light { filter: brightness(.88); }
:root[data-theme="dark"] :is(.card-media, .frame):has(.shot-light) { --media-ring: var(--hairline-strong); }

/* ---------------------------------------------------------------------------
   Glass (floating chrome only)
   ------------------------------------------------------------------------ */

.glass {
  background: var(--glass-solid);
  box-shadow: var(--glass-shadow), var(--glass-edge);
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  @media (prefers-reduced-transparency: no-preference) {
    .glass {
      background: var(--glass-bg);
      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
      backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
    }
  }
}

/* Icon-only close: one control everywhere (menu sheet, play room, résumé
   dialog; the theater's sits inside its own glass disc). A bare 44px
   circle holding the 20px cross; the tint on hover, a deeper tint and .97
   on press, the ring on the circle. */
.btn-close {
  position: relative; display: grid; place-items: center; flex: none;
  width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%;
  background: transparent; color: var(--ink); cursor: pointer;
  transition: background-color var(--d-fast) var(--ease-out), transform var(--d-fast) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) { .btn-close:hover { background: var(--tint); } }
.btn-close:active { transform: scale(var(--press)); background: var(--tint-2); transition-duration: var(--d-press); }
.btn-close:focus-visible { border-radius: 50%; }

/* ---------------------------------------------------------------------------
   Toast: one small notice in the glass, bottom centre, for what a control
   cannot show itself (a failed copy). Arrives on --d-in, leaves faster on
   --d-out. ui.announce() is its silent twin for screen readers.
   ------------------------------------------------------------------------ */

.toast {
  position: fixed; left: 50%; bottom: max(var(--s-6), env(safe-area-inset-bottom));
  transform: translate(-50%, var(--s-3)); opacity: 0; pointer-events: none;
  padding: var(--s-3) var(--s-5); border-radius: var(--r-pill);
  font-size: var(--fs-sm); font-weight: var(--fw-ui); line-height: var(--lh-sm); color: var(--ink);
  z-index: 600;
  transition: transform var(--d-out) var(--ease-out), opacity var(--d-out) var(--ease-out);
}
.toast.is-on { transform: translate(-50%, 0); opacity: 1; transition-duration: var(--d-in), var(--d-fast); }

/* ---------------------------------------------------------------------------
   Entrances: an enhancement, never a state, and never words. ui/js/00-core.js
   readies a plate (.appear-armed) only while it is still just below the
   fold, then lets it come up (.appear-run) once, the first time it is
   scrolled to: the plate fades and rises 16px over 560ms (12px / 480ms on
   phones); a beat later (--enter-lag) the object inside settles 10px into
   it, and a fan's side phones open from 1.5cqw tucked in. Things arriving
   together are 60ms apart. Everything else (text, heads and captions, what
   is not yet near, skipped by a jump or a fling, printed, captured
   full-page, under reduced motion, on /simple/) is simply in place.
   `translate`, not `transform`, so a press scale or a hover lift on the
   same element composes with it; .plate-obj keeps one transition list for
   both, so neither ever cancels the other.
   ------------------------------------------------------------------------ */

.plate-obj, [data-fan] {
  transition: transform var(--d-plate) var(--ease-enter),
              translate var(--enter-obj-dur) var(--ease-enter) var(--enter-obj-delay, 0ms);
}
@media (prefers-reduced-motion: no-preference) {
  .appear-armed { opacity: 0; translate: 0 var(--enter-rise); }
  .appear-armed :is(.plate-obj, [data-fan="center"]) { translate: 0 var(--enter-obj-rise); }
  .appear-armed [data-fan="left"] { translate: var(--enter-fan) var(--enter-obj-rise); }
  .appear-armed [data-fan="right"] { translate: calc(-1 * var(--enter-fan)) var(--enter-obj-rise); }
  /* (a.plate / .plate-link set their own press transition: outranked here) */
  .appear-run, :is(a.plate, .plate-link).appear-run {
    --enter-obj-delay: calc(var(--enter-delay, 0ms) + var(--enter-lag));
    transition: opacity var(--enter-dur) var(--ease-enter) var(--enter-delay, 0ms),
                translate var(--enter-dur) var(--ease-enter) var(--enter-delay, 0ms),
                transform var(--d-base) var(--ease-out);
  }
}
@media print { .appear-armed, .appear-armed * { opacity: 1 !important; translate: none !important; } }

/* ---------------------------------------------------------------------------
   Plates: a flat tinted surface (--plate, or a product tint through
   --plate-bg) holding ONE object, on the card radius, one hairline drawn
   over its content. A plate that links (a.plate or .plate-link) answers
   with the row grammar's restraint: the hairline brightens (--d-fast) and
   the object inside (.plate-obj) comes up --plate-lift (--d-plate); a fan
   never spreads. Press: ONE rule for every plate, the whole plate sinks to
   --plate-press, in on --d-press and back on --d-base. No zoom, no shadow
   bloom.
   ------------------------------------------------------------------------ */

.plate { position: relative; border-radius: var(--r-card); background: var(--plate-bg, var(--plate)); }
.plate::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 0 0 1px var(--plate-edge);
  transition: box-shadow var(--d-fast) var(--ease-out);
}
:is(a.plate, .plate-link) {
  display: block; color: inherit; text-decoration: none; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: transform var(--d-base) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  :is(a.plate, .plate-link):hover::after { box-shadow: inset 0 0 0 1px var(--plate-edge-hover); }
  :is(a.plate, .plate-link):hover .plate-obj { transform: translateY(var(--plate-lift)); }
}
:is(a.plate, .plate-link):active { transform: scale(var(--plate-press)); transition-duration: var(--d-press); }
:is(a.plate, .plate-link):focus-visible { box-shadow: var(--focus-ring); border-radius: var(--r-card); }

/* Smooth scrolling (Lenis, ui/js/00-core.js): its own recommended rules,
   less the scroll lock (dialogs lock the page themselves). */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-smooth iframe { pointer-events: none; }

@media (prefers-reduced-motion: reduce) {
  .btn, .btn-close, .ico, .row-link, :is(.link-arrow, .lt-link, .ft-top):not(.btn) { transition-duration: 0ms; }
  .btn:active, .btn-close:active, .row-link:active, :is(.link-arrow, .lt-link, .ft-top):not(.btn):active { transform: none; }
  .ico, :hover > .ico, :active > .ico { translate: none; }
}

/* ---------------------------------------------------------------------------
   Disclosures. Native height animation moves the content after them with
   them. Opening, the height runs on --d-page and the content fades up
   behind it; closing is quicker (--d-out) and the content fades out first
   (--d-press, ease-in), so text is never guillotined by a shrinking box.
   The one plus mark turns into a cross when open.
   ------------------------------------------------------------------------ */

@supports (interpolate-size: allow-keywords) {
  :is(.ed-details,.sp-record,.cs-more) { interpolate-size: allow-keywords; }
  :is(.ed-details,.sp-record,.cs-more)::details-content {
    block-size: 0; overflow: clip; opacity: 0;
    transition: block-size var(--d-out) var(--ease-out), opacity var(--d-press) var(--ease-in), content-visibility var(--d-out) allow-discrete;
  }
  :is(.ed-details,.sp-record,.cs-more)[open]::details-content {
    block-size: auto; opacity: 1;
    transition: block-size var(--d-page) var(--ease-enter), opacity var(--d-base) var(--ease-out) 60ms, content-visibility var(--d-page) allow-discrete;
  }
}
:is(.ed-details,.sp-record,.cs-more)[open] > :not(summary) { animation: ui-disclose var(--d-base) var(--ease-out) 60ms both; }
@keyframes ui-disclose { from { opacity: 0; transform: translateY(6px); } }
@media (prefers-reduced-motion: reduce) {
  :is(.ed-details,.sp-record,.cs-more)::details-content { transition: none; }
  :is(.ed-details,.sp-record,.cs-more)[open] > :not(summary) { animation: none; }
}

.dis-ico { width: 1.125em; height: 1.125em; flex: none; fill: currentColor; stroke: none; transition: transform var(--d-base) var(--ease-out); }
details[open] > summary .dis-ico { transform: rotate(45deg); }

/* ---------------------------------------------------------------------------
   Page to page: a cross-document view transition (names set by /vt.js, a
   blocking <head> script). ONE element travels: the project's plate on the
   way in becomes its case study's hero, which always carries vt-media (and
   on the way back the hero lands in its plate). It moves on --ease-move
   over --d-vt, its picture cropped, never stretched, while the frame moves
   and resizes, the two pictures crossing on the first 60% of the trip.
   The navigation pill is the same on both pages, so it holds still
   (vt-nav), its two layouts centred, never scaled. The rest of the page
   is never double-exposed: the old picture leaves in --d-vt-out on
   ease-in, the new one arrives over --d-vt-in from --d-vt-gap, over the
   paper (::view-transition carries --bg, so the gap between them is the
   same paper, not a flash). Without support, under reduced motion and to
   or from /simple/, it is a plain load.
   ------------------------------------------------------------------------ */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
  ::view-transition { background: var(--bg); }
  ::view-transition-old(root) { animation: vt-leave var(--d-vt-out) var(--ease-in) both; }
  ::view-transition-new(root) { animation: vt-arrive var(--d-vt-in) var(--ease-out) var(--d-vt-gap) both; }
  .cs-hero-media[data-vt] { view-transition-name: vt-media; }
  ::view-transition-group(vt-media) { animation-duration: var(--d-vt); animation-timing-function: var(--ease-move); }
  ::view-transition-image-pair(vt-media) { overflow: clip; border-radius: var(--r-card); }
  ::view-transition-old(vt-media), ::view-transition-new(vt-media) {
    width: 100%; height: 100%; object-fit: cover;
    animation-duration: calc(var(--d-vt) * .6); animation-timing-function: var(--ease-out);
  }
  ::view-transition-group(vt-nav) { animation-duration: var(--d-vt); animation-timing-function: var(--ease-move); }
  ::view-transition-old(vt-nav), ::view-transition-new(vt-nav) {
    width: 100%; height: 100%; object-fit: none; object-position: 50% 50%;
    animation-duration: var(--d-base); animation-timing-function: var(--ease-out);
  }
}
@keyframes vt-leave { to { opacity: 0; } }
@keyframes vt-arrive { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  .dis-ico { transition: none; }
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}

/* ---- ui/css/00a-paper.css ---- */
/* ============================================================================
   00a-paper.css · the paper itself (October 2026)
   Two quiet layers under everything, fixed to the viewport, below the
   content, with no pointer events. A fine grain gives the paper tooth; the
   raised cards stay smooth, because they sit above it. One soft light rests
   where the sun is in Subang Jaya right now: 08-contact.js, which already
   keeps the local clock, sets --sun-x, --sun-y and the light's colour on
   each minute tick, so a morning visit is lit from the right and an evening
   one, warmer, from the left; at night it is a faint cool moonlight.
   Nothing loops and nothing follows the pointer. The light fades in once
   when the script has placed it (--d-atmos), and after that it only moves
   when the minute changes its place, settling over --d-page. Reduced
   motion, and a page without script, get a still light.
   ========================================================================== */
:root {
  --sun-x: 54%;
  --sun-y: 2%;
  --sun: #ffffff;   /* neutral: a soft white highlight, no warm cast */
  --sun-a: .35;
  --grain-a: .16;   /* the tile's mean alpha is .14, so this is about a 2% veil */
  --grain: url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27256%27%20height%3D%27256%27%3E%3Cfilter%20id%3D%27g%27%20x%3D%270%27%20y%3D%270%27%20width%3D%27100%25%27%20height%3D%27100%25%27%20color-interpolation-filters%3D%27sRGB%27%3E%3CfeTurbulence%20type%3D%27fractalNoise%27%20baseFrequency%3D%27.95%27%20numOctaves%3D%272%27%20stitchTiles%3D%27stitch%27%20seed%3D%2711%27%2F%3E%3CfeColorMatrix%20values%3D%270%200%200%200%200%20%200%200%200%200%200%20%200%200%200%200%200%20%20.34%20.34%20.34%200%200%27%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%27gamma%27%20exponent%3D%273%27%20amplitude%3D%271%27%20offset%3D%270%27%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Crect%20width%3D%27100%25%27%20height%3D%27100%25%27%20filter%3D%27url%28%23g%29%27%2F%3E%3C%2Fsvg%3E");
}
:root[data-theme="dark"] {
  --sun: #c9ccd6;
  --sun-a: .05;
  --grain-a: .14;
  --grain: url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27256%27%20height%3D%27256%27%3E%3Cfilter%20id%3D%27g%27%20x%3D%270%27%20y%3D%270%27%20width%3D%27100%25%27%20height%3D%27100%25%27%20color-interpolation-filters%3D%27sRGB%27%3E%3CfeTurbulence%20type%3D%27fractalNoise%27%20baseFrequency%3D%27.95%27%20numOctaves%3D%272%27%20stitchTiles%3D%27stitch%27%20seed%3D%2711%27%2F%3E%3CfeColorMatrix%20values%3D%270%200%200%200%201%20%200%200%200%200%201%20%200%200%200%200%201%20%20.34%20.34%20.34%200%200%27%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%27gamma%27%20exponent%3D%273%27%20amplitude%3D%271%27%20offset%3D%270%27%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Crect%20width%3D%27100%25%27%20height%3D%27100%25%27%20filter%3D%27url%28%23g%29%27%2F%3E%3C%2Fsvg%3E");
}
:root[data-sun="night"] { --sun: #b6c3da; --sun-a: .06; }
:root[data-theme="dark"][data-sun="night"] { --sun: #93a3bf; --sun-a: .045; }

/* The body is the stacking root, so its own paper paints first and the two
   layers sit between it and every object on it. */
body { isolation: isolate; position: relative; min-height: 100vh; }
body::before, body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}
/* The light: one wide, soft ellipse. Its place and colour are registered
   properties, so a new minute's position is a short settle, not a jump.
   It waits, invisible, until the script has placed it (data-sun), then
   fades in once. */
@property --sun-px { syntax: "<percentage>"; inherits: false; initial-value: 54%; }
@property --sun-py { syntax: "<percentage>"; inherits: false; initial-value: 2%; }
@property --sun-c { syntax: "<color>"; inherits: false; initial-value: #ffffff; }
body::before {
  --sun-px: var(--sun-x);
  --sun-py: var(--sun-y);
  --sun-c: var(--sun);
  background: radial-gradient(72vmax 54vmax at var(--sun-px) var(--sun-py), color-mix(in oklab, var(--sun-c) calc(var(--sun-a) * 100%), transparent), transparent 72%);
}
/* The grain: one 256px tile of alpha-only speckle (luminance noise raised
   to the third power, so most of the tile is clear and the tooth is sparse
   and fine), never animated. Calibrated by measuring the tile. */
/* The grain lies on the whole page, not the window, so no edge of it ever
   shows: not under a mobile toolbar that slides away, not in a full-page
   capture. The light stays with the window. */
body::after {
  position: absolute;
  background-image: var(--grain);
  background-size: 256px 256px;
  opacity: var(--grain-a);
}
@media (prefers-reduced-motion: no-preference) {
  /* If the script never places it, the light appears anyway after 4s. */
  .js:not([data-sun]) body::before { opacity: 0; animation: sun-unplaced 0s 4s forwards; }
  .js body::before { transition: opacity var(--d-atmos) var(--ease-out); }
  .js[data-sun-live] body::before {
    transition: opacity var(--d-atmos) var(--ease-out), --sun-px var(--d-page) var(--ease-out),
                --sun-py var(--d-page) var(--ease-out), --sun-c var(--d-page) var(--ease-out);
  }
}
@keyframes sun-unplaced { to { opacity: 1; } }
@media print {
  body::before, body::after { display: none; }
}

/* ---- ui/css/00b-products.css ---- */
/* ============================================================================
   00b-products.css · the one product table (October 2026)
   Every product's plate tint and made-icon tile, light and dark, in one
   place. Sections never mix their own: they use
     background: var(--plate-<slug>, var(--plate));
   or set data-plate="<slug>" on a .plate (the helper below hands the tint
   to the plate's --plate-bg).

   Plates. One lightness for all (light L 89.5%, dark L 27.5%), a trace of
   the product's own hue (chroma .006–.02), so side by side they read as a
   family of tinted greys, never a rainbow, and never beige or terracotta.
   Light plates sit one step below the paper (#F5F5F7); dark plates one
   visible step ABOVE the page (#111113), so a dark screen still has a
   ground. Hues come from the product:
     bupples        mint of its icon            sage
     adelante       black and white identity    neutral
     wayclub        its violet UI accent        lilac
     photoshoot     the shutter red             rose (cooled off red, never brick)
     codeatlas      its slate UI                neutral slate (not WayClub's lilac)
     tajweed        #6A5CFF, toned down         lavender
     expertsmarine  the sea                     sea blue
     csc            the club's black mark       neutral
     fallenasteri   the game's dusk             mauve
     nutrigo        its green leaf              sage-teal (Archive only, never beside Bupples)
     selbi          the heart's lilac           orchid
     planket        its sky-blue gingham        sky
     careeros       its stamp blue              ink blue
     hackfam        its near-black, one yellow  warm neutral
   --plate-<slug>-edge is the hairline a plate draws over its content; one
   value for all keeps the edges identical.

   Made icons (images/icons/apps/<slug>.svg + -56/80/84/120/192.webp, all
   rendered from one script, never drawn by hand): a flat tile at ONE
   lightness (L 40%) and chroma (.045; the calculator is graphite), one
   Phosphor bold glyph at 56% in #F5F5F7. No terracotta, no second green.
   The files carry the same values as hex (chess #294E57, sword #563F53,
   to-do #354A5F, moon #444560, calculator #44484E, boat #314B5E); these
   tokens are for a section that draws a made tile inline.
   One icon per product, everywhere: CodeAtlas = codeatlas-*.webp (its
   own contour mark), Experts Marine = expertsmarine-*.webp (the boat).
   ========================================================================== */

:root {
  --plate-bupples:       oklch(89.5% .018 160);
  --plate-adelante:      oklch(89.5% .002 286);
  --plate-wayclub:       oklch(89.5% .019 295);
  --plate-photoshoot:    oklch(89.5% .013 5);
  --plate-codeatlas:     oklch(89.5% .007 250);
  --plate-tajweed:       oklch(89.5% .019 282);
  --plate-expertsmarine: oklch(89.5% .017 240);
  --plate-csc:           oklch(89.5% .002 286);
  --plate-fallenasteri:  oklch(89.5% .015 340);
  --plate-nutrigo:       oklch(89.5% .015 180);
  --plate-selbi:         oklch(89.5% .017 310);
  --plate-planket:       oklch(89.5% .016 240);
  --plate-careeros:      oklch(89.5% .014 268);
  --plate-hackfam:       oklch(89.5% .006 85);

  --plate-product-edge: color-mix(in oklab, var(--ink), transparent 92%);

  /* made-icon tiles (same values as the files) */
  --made-chess-training-page:     oklch(40% .045 215);
  --made-pokemon-java:            oklch(40% .045 330);
  --made-to-do-list-board:        oklch(40% .045 250);
  --made-moon-n-knight:           oklch(40% .045 282);
  --made-python-based-calculator: oklch(40% .012 260);
  --made-expertsmarine:           oklch(40% .045 240);
  --made-glyph: #F5F5F7;
}

:root[data-theme="dark"] {
  --plate-bupples:       oklch(27.5% .016 160);
  --plate-adelante:      oklch(27.5% .003 286);
  --plate-wayclub:       oklch(27.5% .018 295);
  --plate-photoshoot:    oklch(27.5% .009 350);
  --plate-codeatlas:     oklch(27.5% .007 250);
  --plate-tajweed:       oklch(27.5% .018 282);
  --plate-expertsmarine: oklch(27.5% .017 240);
  --plate-csc:           oklch(27.5% .003 286);
  --plate-fallenasteri:  oklch(27.5% .014 340);
  --plate-nutrigo:       oklch(27.5% .014 180);
  --plate-selbi:         oklch(27.5% .016 310);
  --plate-planket:       oklch(27.5% .015 240);
  --plate-careeros:      oklch(27.5% .014 268);
  --plate-hackfam:       oklch(27.5% .006 85);

  --plate-product-edge: color-mix(in oklab, var(--ink), transparent 91%);
}

/* A .plate takes its product's tint from data-plate="<slug>". */
[data-plate="bupples"]       { --plate-bg: var(--plate-bupples); }
[data-plate="adelante"]      { --plate-bg: var(--plate-adelante); }
[data-plate="wayclub"]       { --plate-bg: var(--plate-wayclub); }
[data-plate="photoshoot"]    { --plate-bg: var(--plate-photoshoot); }
[data-plate="codeatlas"]     { --plate-bg: var(--plate-codeatlas); }
[data-plate="tajweed"]       { --plate-bg: var(--plate-tajweed); }
[data-plate="expertsmarine"] { --plate-bg: var(--plate-expertsmarine); }
[data-plate="csc"]           { --plate-bg: var(--plate-csc); }
[data-plate="fallenasteri"]  { --plate-bg: var(--plate-fallenasteri); }
[data-plate="nutrigo"]       { --plate-bg: var(--plate-nutrigo); }
[data-plate="selbi"]         { --plate-bg: var(--plate-selbi); }
[data-plate="planket"]       { --plate-bg: var(--plate-planket); }
[data-plate="careeros"]      { --plate-bg: var(--plate-careeros); }
[data-plate="hackfam"]       { --plate-bg: var(--plate-hackfam); }

/* ---- ui/css/01-chrome.css ---- */
/* ============================================================================
   01-chrome.css · site navigation (prefix nv-)
   A floating frosted pill that is lean at the top of the page (the hero
   already shows the name and the Résumé, so the pill holds only the section
   links and the theme control), widens once the hero's actions have scrolled
   under it (the name slides in on the left, the Résumé on the right),
   compacts, hides on the way down and returns on the way up; a sliding
   active-section indicator; and, under 900px, a Menu button whose sheet
   morphs out of the pill. One glass surface at a time: the pill is removed
   from rendering while the sheet is open. Both are the shared .glass
   (ui/css/00-tokens.css); compaction is height and padding, never a scale,
   so the text stays on the type scale and the hairlines stay 1px.
   ========================================================================== */

/* The header itself has no box of its own: every part is fixed. */
.nv { position: relative; }

/* ---------------------------------------------------------------------------
   Pill
   ------------------------------------------------------------------------ */

.nv-pill {
  --nv-y: 0px;
  /* Size: a 56px pill of 44px controls; compact (desktop, scrolled) is a
     48px pill of 40px controls. Only the heights and the vertical padding
     change, so the width (and everything centred on it) holds still. */
  --nv-h: 56px;
  --nv-pad: 6px;
  --nv-ctl: 44px;
  /* lean/full morph timing: the slide (width and the inverse translates) and
     the items (name and Résumé fading with 8px of travel). Entering: slide
     first, items over the last 60%. Leaving (.is-lean): items out over the
     first 40%, then the slide. The script sets the real values per morph
     from the distance and the scroll speed; these are the resting ones. */
  --nv-t: calc(var(--d-page) + var(--d-fast));
  --nv-slide-d: var(--nv-t);
  --nv-slide-delay: 0ms;
  --nv-item-d: calc(var(--nv-t) * .6);
  --nv-item-delay: calc(var(--nv-t) * .4);
  --nv-size-d: var(--d-base);
  position: fixed; top: max(10px, env(safe-area-inset-top)); inset-inline: 12px; z-index: 300;
  margin-inline: auto; width: var(--nv-w, fit-content);
  height: var(--nv-h); padding: var(--nv-pad) 6px; border-radius: var(--r-pill);
  display: flex; align-items: center; gap: var(--s-1);
  overflow: hidden; /* entering and leaving items are revealed by the pill's own edge */
  /* Its own layer, and its layout kept to itself: compaction and the
     lean/full morph never relayout or repaint the page under it. */
  contain: layout style;
  will-change: transform;
  backface-visibility: hidden;
  color: var(--ink);
  transform: translateY(var(--nv-y));
  transition: transform var(--nv-visibility-d, var(--d-in)) var(--ease-out), opacity var(--d-menu) var(--ease-out),
              height var(--nv-size-d) var(--ease-out), padding var(--nv-size-d) var(--ease-out),
              width var(--nv-slide-d) var(--ease-drawer) var(--nv-slide-delay);
}
.nv-pill > * { flex: none; }
/* The skip link sits at the very top when focused; the pill steps down out
   of its way instead of floating over it. */
/* Far enough that the shadow leaves with it. */
.nv-pill.is-hidden { --nv-y: calc(-100% - 64px); }
.nv-pill.is-open { display: none; }

/* Lean and full. At the top of the page the name and the Résumé are in the
   hero, so the pill does not repeat them; once the hero's actions have
   scrolled under the pill the script drops .is-lean and they enter. The
   change is a FLIP run by the script: the pill is pinned to its old width
   through --nv-w and every child to its old place through an inverse
   translate, then both transition to the new layout on one duration and one
   curve, so nothing drifts. Entering items fade in from 8px outside their
   edge; a leaving item is lifted out of flow at its old spot (.is-leaving)
   and fades out towards its edge, 20% faster. .is-prep holds the pinned
   picture for one frame with every transition off. */
.nv-pill.is-lean {
  --nv-slide-d: var(--d-page);
  --nv-slide-delay: var(--d-fast);
  --nv-item-d: var(--d-out);
  --nv-item-delay: 0ms;
}
.nv-pill.is-lean > .nv-brand:not(.is-leaving), .nv-pill.is-lean > .nv-resume:not(.is-leaving) { display: none; }
.nv-pill > .is-leaving { position: absolute; top: 50%; margin: 0; pointer-events: none; }
.nv-pill.is-morphing > * { transition: transform var(--nv-slide-d) var(--ease-drawer) var(--nv-slide-delay); }
.nv-pill.is-morphing > .nv-brand, .nv-pill.is-morphing > .nv-resume {
  transition: transform var(--nv-item-d) var(--ease-out) var(--nv-item-delay),
              opacity var(--nv-item-d) var(--ease-out) var(--nv-item-delay);
}
.nv-pill.is-morphing.is-lean > .is-leaving {
  transition: transform var(--nv-item-d) var(--ease-in) var(--nv-item-delay),
              opacity var(--nv-item-d) var(--ease-in) var(--nv-item-delay);
}
/* Only the children are held: the pill's width goes from fit-content to a
   length, which cannot transition. */
.nv-pill.is-prep > * { transition: none !important; }
/* Without script the chrome is a plain row of anchors at the top of the page:
   nothing fixed, nothing hidden, no Menu button with nothing to open. */
html.no-js .nv-pill { position: static; width: auto; height: auto; min-height: 56px; margin: 10px 12px 0; flex-wrap: wrap; justify-content: center; border-radius: var(--r-xl); transform: none; overflow: visible; }
html.no-js .nv-pill.is-lean > :is(.nv-brand, .nv-resume) { display: inline-flex; }
html.no-js .nv-pill > .nv-resume { display: inline-flex; }
html.no-js .nv-links { display: flex; flex-wrap: wrap; height: auto; }
html.no-js .nv-ind, html.no-js .nv-menu, html.no-js .nv-sep { display: none; }
html.no-js .nv-actions { flex-wrap: wrap; justify-content: center; }
/* A running game dims the chrome, as the old header did. */
html.gaming .nv-pill { opacity: .25; pointer-events: none; }

/* The wordmark, shared by the pill and the sheet's top row: the name alone,
   at body size with the h3 weight and tracking. */
.nv-brand {
  display: inline-flex; align-items: center; height: var(--nv-ctl, 44px); padding: 0 var(--s-3);
  border-radius: var(--r-pill);
  font-family: var(--font-sans); font-size: var(--fs-body); font-weight: var(--fw-h3); letter-spacing: var(--tr-h3); line-height: var(--lh-ui);
  color: var(--ink); text-decoration: none; white-space: nowrap;
  transition: transform var(--d-press) var(--ease-out), background-color var(--d-fast) var(--ease-out), height var(--nv-size-d, var(--d-base)) var(--ease-out);
}
.nv-links { position: relative; display: flex; align-items: center; height: var(--nv-ctl); transition: height var(--nv-size-d) var(--ease-out); }
.nv-link {
  position: relative; z-index: 1;
  display: inline-flex; align-items: center; height: var(--nv-ctl); padding: 0 var(--s-3);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm); font-weight: var(--fw-ui); letter-spacing: 0; line-height: var(--lh-ui);
  color: var(--muted); text-decoration: none; white-space: nowrap;
  transition: color var(--d-fast) var(--ease-out), transform var(--d-press) var(--ease-out), height var(--nv-size-d) var(--ease-out);
}
.nv-link:is([aria-current="true"], [aria-current="page"]) { color: var(--ink); }

/* One span slides behind the active link. Fixed 100px wide and scaled on X so
   only transform animates; the horizontal radius is pre-divided by the scale
   so the ends stay perfect semicircles at rest. */
.nv-ind {
  --nv-ind-x: 0px;
  --nv-ind-sx: .6;
  --nv-ind-r: calc((var(--nv-ctl) - 8px) / 2);
  position: absolute; top: 4px; left: 0; width: 100px; height: calc(var(--nv-ctl) - 8px);
  border-radius: calc(var(--nv-ind-r) / var(--nv-ind-sx)) / var(--nv-ind-r);
  background: var(--tint-2);
  transform: translateX(var(--nv-ind-x)) scaleX(var(--nv-ind-sx));
  transform-origin: 0 50%;
  opacity: 0; pointer-events: none;
  transition: transform var(--d-in) var(--spring), opacity var(--d-fast) var(--ease-out), height var(--nv-size-d) var(--ease-out);
  will-change: transform;
}
.nv-ind.is-on { opacity: 1; }
/* While the pill changes shape the tint holds still and is re-placed once
   the shape has settled (01-chrome.js), instead of chasing it. */
.nv-pill.is-morphing .nv-ind { transition: opacity var(--d-fast) var(--ease-out); }

.nv-sep { flex: none; width: 1px; height: 20px; margin: 0 var(--s-1); background: var(--hairline-strong); }

.nv-actions { display: flex; align-items: center; gap: var(--s-1); }

/* The Résumé keeps the 36px small-button look; its tap area is the full 44px row. */
.nv-pill .btn-sm { margin-inline: var(--s-1); }
.nv-pill .btn-sm, .nv-sheet-top .btn-sm { position: relative; }
.nv-pill .btn-sm::before, .nv-sheet-top .btn-sm::before { content: ""; position: absolute; inset: -4px 0; border-radius: inherit; }

.nv-theme {
  position: relative; display: grid; place-items: center;
  width: 44px; height: var(--nv-ctl); border-radius: var(--r-pill); color: var(--ink);
  transition: background-color var(--d-fast) var(--ease-out), transform var(--d-press) var(--ease-out), height var(--nv-size-d) var(--ease-out);
}
/* Sun and moon from tools/glyphs.mjs at the icon-only size (.ico-ctl). The
   icon shows the mode you would switch to; the swap is opacity only. */
.nv-ico { grid-area: 1 / 1; transition: opacity var(--d-base) var(--ease-out); }
.nv-ico-moon { opacity: 0; }
:root[data-theme="light"] .nv-ico-sun { opacity: 0; }
:root[data-theme="light"] .nv-ico-moon { opacity: 1; }

.nv-egg {
  display: inline-flex; align-items: center; gap: var(--s-2); min-height: var(--nv-ctl, 44px); padding: 0 var(--s-3);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm); font-weight: var(--fw-ui); letter-spacing: 0; line-height: var(--lh-ui);
  color: var(--muted); white-space: nowrap;
  transition: color var(--d-fast) var(--ease-out), background-color var(--d-fast) var(--ease-out), transform var(--d-press) var(--ease-out);
}
.nv-egg[hidden] { display: none; }
.nv-egg span { color: var(--ink); font-size: 1.15em; }
/* The egg is a theme in its own right, so the light and dark controls step aside. */

/* Menu's list icon leads the word in the site's one icon box (.ico). The
   sheet's close is the shared icon-only .btn-close. */
.nv-menu {
  display: none; align-items: center; gap: var(--ico-gap); min-height: 44px; padding: 0 var(--s-3);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm); font-weight: var(--fw-ui); letter-spacing: 0; line-height: var(--lh-ui);
  color: var(--ink); white-space: nowrap;
  transition: background-color var(--d-fast) var(--ease-out), transform var(--d-press) var(--ease-out);
}

/* ---------------------------------------------------------------------------
   Sheet (under 900px): morphs out of the pill, frosted, rows of 56px
   ------------------------------------------------------------------------ */

.nv-backdrop {
  position: fixed; inset: 0; z-index: 390;
  background: var(--scrim);
  touch-action: none;
  animation: nv-fade var(--d-base) var(--ease-out) both;
}
/* Its own keyframes: re-running nv-fade in reverse would only flip the
   finished animation to its first frame, so the dim would vanish at once. */
.nv-backdrop.is-out { animation: nv-fade-out var(--d-out) var(--ease-in) both; }
.nv-backdrop[hidden] { display: none; }

.nv-sheet {
  position: fixed; top: max(10px, env(safe-area-inset-top)); inset-inline: 12px; z-index: 400;
  margin-inline: auto; width: min(100% - 24px, 560px);
  max-height: calc(100vh - 20px); max-height: calc(100dvh - 20px);
  overflow-x: clip; overflow-y: auto; overscroll-behavior: contain;
  padding: 6px 6px var(--s-2); border-radius: var(--r-xl);
  color: var(--ink);
  display: none;
}
.nv-sheet:not([hidden]) { display: block; }

.nv-sheet-top { display: flex; align-items: center; gap: var(--s-1); height: 44px; }
.nv-sheet-top .nv-brand { margin-right: auto; padding-left: 20px; } /* text lines up with the rows below */

.nv-list { list-style: none; margin: var(--s-2) 0 0; padding: 0 var(--s-2); }
.nv-list li + li { border-top: 1px solid var(--hairline); }
.nv-list a {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-3);
  min-height: 56px; padding: 0 var(--s-3); border-radius: var(--r-sm);
  font-family: var(--font-sans); font-size: var(--fs-h3); font-weight: var(--fw-ui); letter-spacing: var(--tr-h3); line-height: var(--lh-h3);
  color: var(--ink); text-decoration: none;
  transition: background-color var(--d-fast) var(--ease-out), transform var(--d-press) var(--ease-out);
}
.nv-list a:is([aria-current="true"], [aria-current="page"]) { background: var(--tint-2); }

/* Appearance and the three contact links close the sheet as one quiet
   block on the same glass, under the same hairline as the rows: one text
   style, muted, with only the chosen appearance raised to paper. */
.nv-sheet-foot {
  display: grid; gap: var(--s-1);
  margin: 0 var(--s-2); padding: var(--s-2) var(--s-3) 0;
  border-top: 1px solid var(--hairline);
}
.nv-appearance { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); min-height: 44px; }
.nv-appearance-label { font-size: var(--fs-sm); font-weight: var(--fw-ui); letter-spacing: 0; color: var(--muted); }
/* A segmented control: a tinted track, the chosen segment raised to paper.
   theme.js appends a third segment, System. */
.nv-sheet .mnav-theme-choices { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--r-pill); background: var(--tint-2); }
.nv-sheet .mnav-theme-choices button {
  position: relative; display: inline-flex; align-items: center; justify-content: center;
  min-height: 32px; padding: 0 var(--s-3); border-radius: var(--r-pill);
  border: 0; background: transparent;
  font-size: var(--fs-sm); font-weight: var(--fw-ui); letter-spacing: 0; line-height: var(--lh-ui); color: var(--muted);
  transition: background-color var(--d-fast) var(--ease-out), color var(--d-fast) var(--ease-out), box-shadow var(--d-fast) var(--ease-out), transform var(--d-press) var(--ease-out);
}
/* The 32px segment answers to a 44px row. */
.nv-sheet .mnav-theme-choices button::before { content: ""; position: absolute; inset: -6px 0; border-radius: inherit; }
.nv-sheet .mnav-theme-choices button[aria-pressed="true"] { background: var(--raised); color: var(--ink); box-shadow: var(--ring); }

/* The narrowest phones stack the label over a full-width control. */
@media (max-width: 379.98px) {
  .nv-appearance { flex-direction: column; align-items: stretch; gap: var(--s-1); padding-top: var(--s-2); }
  .nv-appearance-label { line-height: 1.2; }
  .nv-sheet .mnav-theme-choices { display: flex; }
  .nv-sheet .mnav-theme-choices button { flex: 1 1 0; padding: 0 var(--s-2); }
}

.nv-foot { display: flex; align-items: center; gap: var(--s-2); margin-inline: calc(-1 * var(--s-3)); padding: var(--s-1) var(--s-3) 0; border-top: 1px solid var(--hairline); }
.nv-foot a {
  display: inline-flex; align-items: center; gap: var(--ico-gap);
  min-height: 44px; padding: 0 var(--s-2); border-radius: var(--r-sm);
  font-size: var(--fs-sm); font-weight: var(--fw-ui); letter-spacing: 0; line-height: var(--lh-ui);
  color: var(--muted); text-decoration: none; white-space: nowrap;
  transition: color var(--d-fast) var(--ease-out), background-color var(--d-fast) var(--ease-out), transform var(--d-press) var(--ease-out);
}
.nv-foot a:first-child { margin-left: calc(-1 * var(--s-2)); } /* text on the rows' edge */
/* Inside the sheet's own pill the link is the tint; the text-link pill
   (::before) would draw a second shape. */
.nv-foot a.link-arrow::before { content: none; }
.nv-foot a.link-arrow:focus-visible { box-shadow: var(--focus-ring); }

/* Items arrive 30ms apart: opacity plus 8px of travel, nothing else. */
.nv-sheet.is-in .nv-list li, .nv-sheet.is-in .nv-sheet-foot {
  animation: nv-item var(--d-base) var(--ease-out) both;
  animation-delay: calc(var(--i, 0) * 30ms + 40ms);
}
.nv-list li:nth-child(1) { --i: 0; } .nv-list li:nth-child(2) { --i: 1; } .nv-list li:nth-child(3) { --i: 2; }
.nv-list li:nth-child(4) { --i: 3; } .nv-list li:nth-child(5) { --i: 4; } .nv-list li:nth-child(6) { --i: 5; }
.nv-sheet-foot { --i: 6; }

/* The sheet clips open from the pill's own box and back into it:
   --nv-clip-x is the side inset to the pill's edges, set by the script.
   It leaves faster than it arrives (--d-in / --d-out). */
.nv-sheet.is-clip-in { animation: nv-sheet-in var(--d-in) var(--ease-emph) both; }
.nv-sheet.is-clip-out { animation: nv-sheet-out var(--d-out) var(--ease-in) both; }
.nv-pill.is-returning { animation: nv-fade var(--d-fast) var(--ease-out) both; }
@keyframes nv-sheet-in {
  from { clip-path: inset(0 var(--nv-clip-x, 0px) calc(100% - 56px) var(--nv-clip-x, 0px) round 28px); }
  to { clip-path: inset(0 0 0 0 round 28px); }
}
@keyframes nv-sheet-out {
  from { clip-path: inset(0 0 0 0 round 28px); opacity: 1; }
  65% { opacity: 1; }
  to { clip-path: inset(0 var(--nv-clip-x, 0px) calc(100% - 56px) var(--nv-clip-x, 0px) round 28px); opacity: 0; }
}
@keyframes nv-item { from { opacity: 0; transform: translateY(8px); } }
@keyframes nv-fade { from { opacity: 0; } }
@keyframes nv-fade-out { to { opacity: 0; } }

/* The theme switch is a plain cross-fade: the new page fades in over the
   old one on --d-in, the old one holds still beneath it (so nothing dips
   through a half-transparent middle), and no element transitions its own
   colours inside the snapshot. Scoped to the switch (.nv-vt), so page-to-
   page fades keep theirs. Reduced motion switches at once (the script does
   not start a transition). */
:root.nv-vt::view-transition-old(root) { animation: none; }
:root.nv-vt::view-transition-new(root) { animation: nv-fade var(--d-in) var(--ease-out) both; mix-blend-mode: normal; }
:root.nv-vt::view-transition-image-pair(root) { isolation: auto; }
:root.nv-vt, :root.nv-vt *, :root.nv-vt *::before, :root.nv-vt *::after { transition: none !important; }

/* The 1px markers: the one the compaction observer watches at 80px, the one
   the script places at the bottom of the hero's actions for the lean/full
   change, and the one at the end of the page that keeps the last section
   current; nothing to see. */
.nv-sentinel, .nv-sentinel-hero { position: absolute; top: 80px; left: 0; width: 1px; height: 1px; pointer-events: none; opacity: 0; }
.nv-sentinel-end { width: 1px; height: 1px; margin-top: -1px; pointer-events: none; opacity: 0; }

/* Scroll lock while the sheet is open */
html.nv-lock { overflow: hidden; }

/* While a navigation scroll runs, below-the-fold sections lay out for real so
   the destination is measured exactly (see the script). */
html.nv-navigating .cv-auto { content-visibility: visible; }

/* A section focused after navigation keeps keyboard flow without a ring
   drawn around the whole block. */
[data-nv-focus]:focus { outline: none; box-shadow: none; }

/* ---------------------------------------------------------------------------
   States: press, hover (pointer only), focus
   ------------------------------------------------------------------------ */

.nv-brand:active, .nv-link:active, .nv-theme:active, .nv-egg:active, .nv-menu:active,
.nv-list a:active, .nv-foot a:active, .nv-sheet .mnav-theme-choices button:active { transform: scale(var(--press)); transition-duration: var(--d-press); }
/* The press fill is one step deeper than the hover tint, on touch too. */
.nv-brand:active, .nv-theme:active, .nv-egg:active, .nv-menu:active, .nv-list a:active, .nv-foot a:active { background: var(--tint-2); }

@media (hover: hover) and (pointer: fine) {
  .nv-link:hover, .nv-egg:hover, .nv-foot a:hover, .nv-sheet .mnav-theme-choices button:hover { color: var(--ink); }
  .nv-brand:hover, .nv-theme:hover, .nv-egg:hover, .nv-menu:hover,
  .nv-list a:hover, .nv-foot a:hover { background: var(--tint); }
  .nv-brand:active, .nv-theme:active, .nv-egg:active, .nv-menu:active, .nv-list a:active, .nv-foot a:active { background: var(--tint-2); }
}

.nv-brand:focus-visible, .nv-link:focus-visible, .nv-theme:focus-visible, .nv-egg:focus-visible,
.nv-menu:focus-visible, .nv-sheet .mnav-theme-choices button:focus-visible { border-radius: var(--r-pill); }
.nv-link:focus-visible, .nv-foot a:focus-visible { color: var(--ink); }
.nv-list a:focus-visible, .nv-foot a:focus-visible { border-radius: var(--r-sm); }

/* ---------------------------------------------------------------------------
   Breakpoints
   ------------------------------------------------------------------------ */

@media (min-width: 900px) {
  .nv-pill.is-compact { --nv-h: 48px; --nv-pad: 4px; --nv-ctl: 40px; }
  /* Hidden beats compact: both classes are on while scrolling down the page. */
  .nv-pill.is-compact.is-hidden { --nv-y: calc(-100% - 64px); }
  /* A viewport that grew past the breakpoint never shows the sheet. */
  .nv-sheet:not([hidden]), .nv-backdrop:not([hidden]) { display: none; }
}

@media (max-width: 899.98px) {
  .nv-pill { transition-timing-function: var(--ease-out), var(--ease-out), var(--ease-out), var(--ease-out), var(--ease-drawer); }
  .nv-pill.is-hidden { --nv-y: calc(-100% - max(10px, env(safe-area-inset-top)) - 20px); }
  .nv-links, .nv-sep, .nv-pill > .nv-resume { display: none; }
  .nv-theme { display: grid; }
  .nv-menu { display: inline-flex; }
  .nv-brand { padding: 0 10px; }
  .nv-pill .nv-brand { margin-right: auto; }
  .nv-pill .btn-sm { margin-inline: 0; }
  .nv-pill .nv-egg { font-size: 0; gap: 0; width: 44px; padding: 0; justify-content: center; }
  .nv-pill .nv-egg span { font-size: 1.25rem; }
}

/* Below 400px the pill holds the name, Résumé and Menu with nothing to spare. */
@media (max-width: 399.98px) {
  .nv-menu .ico { display: none; }
  .nv-menu { padding: 0 var(--s-3); }
}

@media (prefers-reduced-motion: reduce) {
  .nv-pill, .nv-pill.is-morphing > *, .nv-links, .nv-ind, .nv-ico, .nv-brand, .nv-link, .nv-theme, .nv-egg, .nv-menu,
  .nv-list a, .nv-foot a, .nv-sheet .mnav-theme-choices button { transition-duration: 0ms; }
  .nv-brand:active, .nv-link:active, .nv-theme:active, .nv-egg:active, .nv-menu:active,
  .nv-list a:active, .nv-foot a:active, .nv-sheet .mnav-theme-choices button:active { transform: none; }
  .nv-sheet.is-in .nv-list li, .nv-sheet.is-in .nv-sheet-foot { animation: nv-fade 150ms var(--ease-out) both; animation-delay: 0ms; }
  .nv-sheet.is-clip-in { animation: nv-fade 150ms var(--ease-out) both; }
  .nv-sheet.is-clip-out { animation: nv-fade-out var(--d-out) var(--ease-in) both; }
  .nv-backdrop { animation-duration: var(--d-in); }
}

/* A departing résumé must leave flex layout. This outranks the button's
   position:relative target-area rule, which otherwise pushes the theme
   toggle beyond the clipped edge until the animation finishes. */
.nv-pill.is-morphing > .is-leaving { position: absolute; }

/* ---- ui/css/01-hero.css ---- */
/* ============================================================================
   01-hero.css · the first screen (revision 5, October 2026 identity pass)
   The name, one two-sentence statement, two actions, the portrait, and a
   rail of plain facts. Tokens and shared components (.btn, the type scale)
   come from 00-tokens.css; this file only places them. Only the portrait
   moves, once, on load.
   ========================================================================== */

/* ---------------------------------------------------------------------------
   Layout. Phones: the name beside a small framed portrait, then the
   statement, the actions and the rail, so the first screen holds who this
   is and the start of Selected work. From 640px the portrait moves beside
   the copy and runs from the name's top to the actions' foot, so it fills
   its column, the hero stays short enough for Selected work to start on the
   first screen, and the crop stays a portrait. The copy takes 7 of 12
   columns up to 899px and 8 of 12 from the desktop threshold. The rail
   spans both.
   ------------------------------------------------------------------------ */

.hero {
  position: relative;
  padding-block: clamp(6.5rem, 15vh, 10.5rem) var(--s-7);
}
@media (max-width: 639.98px) {
  .hero { padding-top: clamp(6rem, 12vh, 8.5rem); }
}
/* Short laptop windows: the hero keeps a clear 48px under the floating
   pill (its shadow reaches about 90px), and Selected work still starts on
   the first screen. */
@media (min-width: 640px) and (max-height: 800px) {
  .hero { padding-top: 7rem; }
}

.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: "h1 art" "copy copy" "facts facts";
  column-gap: var(--s-4);
  row-gap: var(--s-5);
  align-items: start;
}
@media (min-width: 640px) {
  .hero-grid {
    grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
    grid-template-areas: "h1 art" "copy art" "facts facts";
    column-gap: var(--s-6);
    row-gap: var(--s-6);
  }
}
@media (min-width: 900px) {
  .hero-grid { grid-template-columns: minmax(0, 8fr) minmax(0, 4fr); column-gap: var(--s-8); }
}

/* ---------------------------------------------------------------------------
   The name at h3 size, so identity outranks the leads below it; the role in
   the text weight and muted ink, a space apart (facts are spaced, never
   dotted). The role wraps as one unit, and under 480px takes its own line.
   ------------------------------------------------------------------------ */
.hero-h1 {
  grid-area: h1;
  margin: 0;
  font-size: var(--fs-h3);
  font-weight: var(--fw-h3);
  line-height: var(--lh-h3);
  letter-spacing: var(--tr-h3);
  color: var(--ink);
  text-wrap: balance;
}
.hero-role { display: inline-block; font-weight: var(--fw-text); letter-spacing: -.01em; color: var(--muted); }
@media (max-width: 479.98px) {
  .hero-role { display: block; }
}
@media (max-width: 639.98px) {
  .hero-h1 { align-self: center; }
}

/* ---------------------------------------------------------------------------
   The statement: two sentences at display size. The first carries the
   weight; the second is the same size in the text weight and muted ink, so it
   reads as the quieter half of one thought. Lighter strokes need less
   negative tracking to hold the same rhythm.
   ------------------------------------------------------------------------ */
.hero-copy { grid-area: copy; display: grid; gap: var(--s-6); min-width: 0; justify-items: start; }

.hero-statement { margin: 0; display: grid; justify-self: stretch; }
.hero-line {
  display: block;
  max-width: 7em; /* two lines a sentence at every width, 560–639px included */
  font-size: var(--fs-display);
  font-weight: var(--fw-display);
  line-height: var(--lh-display);
  letter-spacing: var(--tr-display);
  color: var(--ink);
  text-wrap: balance;
}
.hero-line-quiet {
  font-weight: var(--fw-display-quiet);
  letter-spacing: -.03em;
  color: var(--muted);
}

/* Actions: Résumé first and filled, For recruiters secondary. No marks. */
.hero-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-3); }

/* Phones: the two share one row, half each, every target 44px. */
@media (max-width: 639.98px) {
  .hero-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); justify-self: stretch; }
  .hero-actions .btn { width: 100%; padding-inline: var(--s-3); }
}

/* ---------------------------------------------------------------------------
   The portrait: one image, no mat, no border. Phones show it small and
   framed beside the name (80–96px, a 4:5 portrait at a small radius), so
   it never takes the first screen. From 640px it stretches over the name
   and copy rows beside them, its top on the name's cap height; from 900px
   it is never wider than 353px, half the source, so it stays sharp at 2x.
   ------------------------------------------------------------------------ */
.hero-photo {
  grid-area: art;
  position: relative;
  margin: 0;
  overflow: hidden;
  width: clamp(80px, 24vw, 96px);
  border-radius: var(--r-md);
  background: var(--sunk);
  aspect-ratio: 4 / 5;
}
.hero-photo picture { position: absolute; inset: 0; display: block; }
.hero-photo img { width: 100%; height: 100%; object-fit: cover; object-position: 55% 40%; }

@media (min-width: 640px) {
  .hero-photo {
    width: auto; aspect-ratio: auto; align-self: stretch;
    margin-top: calc(var(--fs-h3) * .22);
    border-radius: var(--r-card);
  }
  .hero-photo img { object-position: 50% 42%; }
}
@media (min-width: 900px) {
  .hero-photo { width: min(100%, 353px); justify-self: end; }
}

/* ---------------------------------------------------------------------------
   The rail: three plain facts in the label style, place and internship at
   the left edge, the live time at the right, under the portrait's edge.
   The internship, the fact a recruiter came for, is in ink.
   Phones: place and time share the first line, the internship takes the
   second. The facts' own names are read out, not shown.
   ------------------------------------------------------------------------ */
.facts {
  grid-area: facts;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: "place time" "intern intern";
  gap: var(--s-1) var(--s-5);
  margin: var(--s-2) 0 0;
  padding: 0;
  list-style: none;
  font-size: var(--fs-label);
  font-weight: var(--fw-label);
  line-height: var(--lh-label);
  letter-spacing: var(--tr-label);
  color: var(--muted);
}
.facts li { margin: 0; padding: 0; }
.facts-place { grid-area: place; }
.facts-intern { grid-area: intern; color: var(--ink); }
.facts-time { grid-area: time; text-align: right; }
@media (min-width: 640px) {
  .facts { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "place intern time"; column-gap: var(--s-6); }
}
@media (min-width: 900px) {
  .facts { margin-top: var(--s-1); }
}

/* ---------------------------------------------------------------------------
   Entrance: words are never late. The name, the statement, the actions and
   the rail are on the first paint; only the portrait arrives, the way a
   plate does (fades and rises --enter-rise over --enter-dur on the arrival
   curve: 16px / 560ms, 12px / 480ms on phones), starting on the first
   frame. CSS, so it needs no script; guarded by .js, so without script and
   under reduced motion it is simply there. It always ends visible.
   ------------------------------------------------------------------------ */
.js:not(.reduced) .hero-photo[data-enter] {
  animation: hero-in var(--enter-dur) var(--ease-enter) both;
}
@keyframes hero-in { from { opacity: 0; translate: 0 var(--enter-rise); } }
@media (prefers-reduced-motion: reduce) {
  .hero [data-enter] { animation: none !important; opacity: 1; translate: none; }
}

/* ---- ui/css/02-work.css ---- */
/* ============================================================================
   02-work.css · Selected work (revision 6, October 2026: product rows)
   Each project is one row: copy beside a plate tinted from the product
   (00b-products.css) that holds its real object. No cards, no rules: the
   plates are the rhythm, the sides alternate. From 1000px copy takes 5 of
   12 columns, the plate 7, so the media always has the larger share and
   every plate on one side starts on one vertical line (Experience below
   shares the split); below 1000px each row is one column.
   Bupples leads with a taller plate and the lead-size line.

   Copy anatomy, the same in every row: icon and name (the status beside it,
   right-aligned, wherever the column is wide enough), the one line, the
   stack (muted), the links (all in ink, case study first). Phones read
   name, plate, line, status, stack, links.

   One object rule, site-wide: an object stands on the plate's bottom edge.
   Phones (the ab4e28e fans) are cut by it; a flat capture is one window at
   one fixed inset from the plate's top and sides, running down to the
   edge. Dark theme: plates one step above the page, phones get a 1px light
   bezel, light captures dim (.shot-light).

   Feedback: the plate answers on its hairline and the object comes up a
   few px (tokens: .plate/.plate-obj); pointing at the copy lights the same
   hairline, so a row answers as one thing; press sinks the plate (.985),
   one rule for all five. Entrance: the plate rises (data-appear), then its
   object settles and a fan opens 1.5cqw, once. Text never animates.
   ========================================================================== */

/* ---------------------------------------------------------------------------
   App icons: the product's own mark beside its name (Work 40px, Latest
   40px, Experience 28px). One mask, iOS's continuous corner approximated
   at 22.5%, one 1px hairline drawn over the edge so a pale icon still holds
   its shape on the paper (and a near-black one on the dark page). No
   shadow. Bupples ships a dark variant; the page shows the one that
   matches its theme. Decorative: the name is beside it.
   ------------------------------------------------------------------------ */
.app-ico {
  display: block; flex: none;
  width: 40px; height: 40px;
  border-radius: 22.5%;
  outline: 1px solid var(--hairline); outline-offset: -1px;
  background: var(--plate);
}
.app-ico-dark { display: none; }
:root[data-theme="dark"] .app-ico-light { display: none; }
:root[data-theme="dark"] .app-ico-dark { display: block; }

/* ---------------------------------------------------------------------------
   Rows
   ------------------------------------------------------------------------ */

.wk { --wk-gap: var(--s-8); }
@media (min-width: 1000px) { .wk { --wk-gap: var(--s-7); } }
.wk-rows { display: grid; gap: var(--wk-gap); margin-top: var(--wk-gap); }

/* The lead keeps the class the work index reads it by; it is not a card. */
.wk-lead.card { padding: 0; border: 0; border-radius: 0; background: none; }

/* Phones: one column. The copy's own box steps aside (display: contents)
   so the plate can sit between the name and the line. */
.wk-card {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-areas: "name" "media" "line" "status" "stack" "links";
}
.wk-copy { display: contents; }
.wk-name { grid-area: name; }
.wk-status { grid-area: status; }
.wk-line { grid-area: line; }
.wk-stack { grid-area: stack; }
.wk-links { grid-area: links; }
.wk-media { grid-area: media; margin-top: var(--s-4); }

/* From 600px the name line is wide enough to carry the status. */
@media (min-width: 600px) {
  .wk-card {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas: "name status" "media media" "line line" "stack stack" "links links";
    column-gap: var(--s-4);
  }
  .wk-status { align-self: center; justify-self: end; margin: 0; text-align: right; }
}

/* From 1000px (where three links still share the copy column's line):
   copy beside the plate, 5 | 7, the sides alternating; the copy hangs from
   the plate's top edge. */
@media (min-width: 1000px) {
  .wk-card, .wk-card.wk-flip {
    grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    grid-template-areas: "copy media";
    column-gap: var(--s-7);
    align-items: center;
  }
  .wk-card.wk-flip { grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); grid-template-areas: "media copy"; }
  .wk-copy {
    grid-area: copy; display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "name" "line" "status" "stack" "links";
  }
  .wk-media { margin-top: 0; }
  .wk-status { justify-self: start; text-align: left; }
}
/* From 1200px the copy column holds the status on the name line. */
@media (min-width: 1200px) {
  .wk-copy {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas: "name status" "line line" "stack stack" "links links";
    column-gap: var(--s-4);
  }
  .wk-status { justify-self: end; text-align: right; }
}

/* ---------------------------------------------------------------------------
   Copy
   ------------------------------------------------------------------------ */

.wk-name { display: flex; align-items: center; gap: var(--s-3); min-width: 0; }
.wk-title {
  margin: 0;
  font-size: var(--fs-h3);
  font-weight: var(--fw-h3);
  line-height: var(--lh-h3);
  letter-spacing: var(--tr-h3);
  color: var(--ink);
}
.wk-line {
  margin: var(--s-4) 0 0;
  max-width: 40ch;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--ink);
}
.wk-lead .wk-line { font-size: var(--fs-lead); line-height: var(--lh-lead); max-width: 30ch; }
.wk-nb { white-space: nowrap; }

/* Status: what a reader can check, with its version or date, in ink.
   Stack: the tools, muted, one line. */
.wk-status, .wk-stack { margin: 0; font-size: var(--fs-sm); line-height: var(--lh-sm); text-wrap: balance; }
.wk-status { color: var(--ink); font-weight: var(--fw-ui); }
.wk-stack { color: var(--muted); }
/* One verified number per flagship (from its release record), in ink. */
.wk-num { color: var(--ink); font-weight: var(--fw-ui); }
/* Under the line the status leads the facts and the stack hugs it; on the
   name line the stack leads them. */
.wk-status { margin-top: var(--s-3); }
@media (min-width: 600px) and (max-width: 999.98px), (min-width: 1200px) {
  .wk-status { margin-top: 0; white-space: nowrap; }
  .wk-stack { margin-top: var(--s-3); }
}

/* Links keep a 44px target without adding to the visible rhythm: the box
   is 44px tall and hands its slack back above and below. Case study first,
   then the stores, the live site and the code, every one in ink. */
.wk-links {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
  margin: var(--s-4) 0 0;
  font-size: var(--fs-sm);
}
.wk-links a { min-height: 40px; white-space: nowrap; }
/* The case study leads: the one solid pill in the row, the stores and the
   live site soft beside it. */
.wk-links a[href^="/work/"], .wk-links a[href^="work/"] { color: var(--bg); }
.wk-links a[href^="/work/"]::before, .wk-links a[href^="work/"]::before { background: var(--ink); }
@media (hover: hover) and (pointer: fine) {
  .wk-links a[href^="/work/"]:hover, .wk-links a[href^="work/"]:hover { color: var(--bg); }
  .wk-links a[href^="/work/"]:hover::before, .wk-links a[href^="work/"]:hover::before { background: color-mix(in oklab, var(--ink), var(--bg) 16%); }
}
.wk-links a[href^="/work/"]:active::before, .wk-links a[href^="work/"]:active::before { background: color-mix(in oklab, var(--ink), var(--bg) 26%); }
.wk-links a[href^="/work/"] > .ico, .wk-links a[href^="work/"] > .ico { opacity: 1; }
/* A narrow column keeps three links on one line by closing the gaps (the
   hover pills still clear each other: only one shows at a time). */

/* ---------------------------------------------------------------------------
   The plate. A direct link to the live site or, where unavailable, the
   case study; the images inside are never
   interactive themselves. overflow: clip, not hidden, so it is not a scroll
   container. Phones: 4:3 for a fan, 16:10 for a window. From 600px every
   plate is 16:10 and the lead 3:2.
   ------------------------------------------------------------------------ */

.wk-media {
  position: relative;
  display: block;
  overflow: clip;
  isolation: isolate;
  aspect-ratio: 16 / 10;
  container-type: size;
  border-radius: var(--r-card);
  color: inherit;
  text-decoration: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.wk-media--phones { aspect-ratio: 4 / 3; }
/* The hairline is drawn over the objects, so a phone cut by the edge
   still meets a continuous edge. */
.wk-media::after { z-index: 3; }
@media (min-width: 600px) {
  .wk-media--phones { aspect-ratio: 16 / 10; }
  .wk-lead .wk-media { aspect-ratio: 3 / 2; }
}

/* Pointing at the copy lights its plate's hairline: the row is one thing.
   No movement, no reflow. */
@media (hover: hover) and (pointer: fine) {
  .wk-card:has(.wk-copy:hover) .wk-media::after { box-shadow: inset 0 0 0 1px var(--plate-edge-hover); }
}

/* ---------------------------------------------------------------------------
   Objects. The fan (ab4e28e: the centre phone a step larger and higher,
   the outer two tucked behind it) is drawn in units of the plate's HEIGHT,
   capped by its width, so the trio keeps the same cut at the bottom edge
   on any plate shape: the centre phone shows its top 80% at most, the
   outer two their top 75%. A phone is a real capture with the device's
   corner; a hairline holds its edge (a light one in the dark theme).
   ------------------------------------------------------------------------ */

.wk-phones, .wk-win { transition: transform var(--d-plate) var(--ease-enter); }
.wk-phones { position: absolute; inset: 0; display: block; --u: min(1cqh, .76cqw); --d: 38; }
.wk-phone {
  --pw: 48.5; --dx: 0; --t: 21; --vis: 79; --vmax: 90;
  position: absolute; display: block;
  width: calc(var(--pw) * var(--u)); height: auto; max-width: none;
  left: calc(50% + (var(--dx) - var(--pw) / 2) * var(--u));
  /* --t% from the top, unless that would float it off the bottom edge
     (then it keeps a minimum cut) or show more than --vmax of it */
  top: clamp(calc(100cqh - var(--vmax) * var(--u)), calc(var(--t) * 1cqh), calc(100cqh - var(--vis) * var(--u)));
  border-radius: calc(var(--pw) * .135 * var(--u));
  background: #0c0c0d;
  box-shadow: var(--shadow-float);
  outline: 1px solid color-mix(in oklab, var(--ink), transparent 90%); outline-offset: -1px;
}
.wk-phone:nth-child(1) { --dx: calc(var(--d) * -1); }
.wk-phone:nth-child(3) { --dx: var(--d); }
.wk-phone:nth-child(2) { --pw: 52.5; --t: 9; --vis: 91; --vmax: 100; z-index: 1; box-shadow: var(--shadow-dialog); }
:root[data-theme="dark"] .wk-phone { outline-color: rgb(255 255 255 / .16); }

/* A capture: one window, inset from the plate's sides by one amount,
   standing on the bottom edge. On the 16:10 plate a 16:10 window that
   wide stands 1.25 insets below the top, so the capture shows whole. */
.wk-win {
  --inset: 6.5cqw;
  position: absolute; display: block;
  left: var(--inset); right: var(--inset); top: calc(var(--inset) * 1.25); bottom: 0;
  overflow: clip;
  border-radius: var(--r-sm) var(--r-sm) 0 0;
  background: var(--bg);
  box-shadow: var(--shadow-float);
}
.wk-win::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--ink), transparent 88%);
}
:root[data-theme="dark"] .wk-win::after { box-shadow: inset 0 0 0 1px rgb(255 255 255 / .14); }
.wk-shot {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 0 0;
}
/* CodeAtlas: past its navigation rail, so the impact graph carries the window. */
.wk-win--codeatlas .wk-shot { width: 113%; max-width: none; margin-left: -13%; }

/* Entrance: once the plate has risen (data-appear, 00-tokens.css), its
   object settles the last 10px a beat later and a fan opens from 1.5cqw
   tucked in. Only while it is readied below the fold; never under reduced
   motion or on a touch screen (the plate is never readied there). */
@media (prefers-reduced-motion: no-preference) {
  .wk-media.appear-armed :is(.wk-phones, .wk-win) { translate: 0 10px; }
  .wk-media.appear-armed .wk-phone:nth-child(1) { translate: 1.5cqw 0; }
  .wk-media.appear-armed .wk-phone:nth-child(3) { translate: -1.5cqw 0; }
  .wk-media.appear-run :is(.wk-phones, .wk-win) {
    transition: transform var(--d-plate) var(--ease-enter),
                translate var(--wk-settle, 720ms) var(--ease-enter) calc(var(--enter-delay, 0ms) + var(--enter-lag));
  }
  .wk-media.appear-run .wk-phone { transition: translate var(--wk-settle, 720ms) var(--ease-enter) calc(var(--enter-delay, 0ms) + var(--enter-lag)); }
}
@media (max-width: 599.98px) { .wk { --wk-settle: 600ms; } }

/* ---------------------------------------------------------------------------
   More projects (November 2026): four features in depth above, the rest as
   a two-up grid of smaller cards, the plate on top and the copy under it,
   one shape for all four, so the section reads as a highlight reel and
   not an index. No zig-zag anywhere: every featured plate is on the right.
   ------------------------------------------------------------------------ */
.wk-others { margin-top: var(--s-8); }
.wk-others-t {
  margin: 0 0 var(--s-6);
  font-size: var(--fs-h3); font-weight: var(--fw-h3); line-height: var(--lh-h3); letter-spacing: var(--tr-h3);
  color: var(--ink);
}
.wk-grid { display: grid; gap: var(--s-7) var(--s-6); }
@media (min-width: 700px) { .wk-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.wk-card.wk-mini, .wk-card.wk-mini.wk-flip {
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: "media media" "name status" "line line" "stack stack" "links links";
  column-gap: var(--s-4); row-gap: 0; align-items: start;
}
.wk-mini .wk-copy { display: contents; }
.wk-mini .wk-media, .wk-mini .wk-media--phones { margin: 0 0 var(--s-5); aspect-ratio: 16 / 10; }
.wk-mini .wk-name .app-ico { width: 32px; height: 32px; }
.wk-mini .wk-title { font-size: var(--fs-lead); line-height: var(--lh-lead); }
.wk-mini .wk-status { align-self: center; justify-self: end; margin: 0; text-align: right; white-space: nowrap; color: var(--muted); }
.wk-mini .wk-line { margin-top: var(--s-3); max-width: 46ch; }
.wk-mini .wk-stack { margin-top: var(--s-2); }
@media (max-width: 459.98px) {
  .wk-card.wk-mini { grid-template-columns: minmax(0, 1fr); grid-template-areas: "media" "name" "status" "line" "stack" "links"; }
  .wk-mini .wk-status { justify-self: start; text-align: left; margin-top: var(--s-2); }
}

/* ---------------------------------------------------------------------------
   After the rows: one quiet line to the archive. The 44px target hands its
   slack back, so the section ends on the text, not on the box.
   ------------------------------------------------------------------------ */

.wk-more { margin: var(--s-6) 0 calc(-1 * var(--s-3)); font-size: var(--fs-sm); }
.wk-more .link-arrow { min-height: 44px; }

@media (prefers-reduced-motion: reduce) {
  .wk-phones, .wk-win, .wk-phone { transition: none; }
  .wk-media::after { transition-duration: 0ms; }
}

/* ---------------------------------------------------------------------------
   Prototype tours (HACKFAM): a button on the card opens the four animated
   walkthroughs in a dialog on the page. The row scrolls sideways on a
   phone and sits in one line on a wide screen; each tour stands whole.
   ------------------------------------------------------------------------ */
.wk-tour-btn { border: 0; background: none; font: inherit; cursor: pointer; font-size: var(--fs-sm); }
.tour-lb {
  width: min(1180px, 100vw - 32px); max-height: calc(100dvh - 32px); margin: auto; padding: 0;
  border: 0; border-radius: var(--r-card); background: var(--raised); color: var(--ink);
  box-shadow: var(--shadow-dialog); overscroll-behavior: contain;
}
.tour-lb::backdrop { background: rgb(8 8 10 / .6); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.tour-lb[open] { animation: tour-in var(--d-page) var(--ease-enter); }
@keyframes tour-in { from { opacity: 0; transform: translateY(8px) scale(.99); } }
.tour-in { display: grid; gap: var(--s-5); padding: var(--s-6); max-height: calc(100dvh - 32px); overflow: auto; box-sizing: border-box; }
.tour-head { position: relative; display: grid; gap: var(--s-1); padding-right: 56px; }
.tour-t { margin: 0; font-size: var(--fs-h3); font-weight: var(--fw-h3); line-height: var(--lh-h3); letter-spacing: var(--tr-h3); }
.tour-sub { margin: 0; color: var(--muted); font-size: var(--fs-sm); }
.tour-x { position: absolute; top: -4px; right: -4px; }
.tour-row {
  display: grid; grid-auto-flow: column; grid-auto-columns: minmax(200px, 1fr); gap: var(--s-4);
  margin: 0; padding: 0 0 var(--s-2); list-style: none;
  overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none;
}
.tour-row::-webkit-scrollbar { display: none; }
.tour-item { scroll-snap-align: start; }
.tour-item figure { display: grid; gap: var(--s-3); margin: 0; }
.tour-item img {
  display: block; width: 100%; height: auto; aspect-ratio: 300 / 649;
  border-radius: 22px; background: #0b0c0f; box-shadow: 0 0 0 1px var(--hairline), var(--shadow-float);
}
.tour-item figcaption { font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); }
.tour-item figcaption strong { color: var(--ink); }
@media (max-width: 639.98px) {
  .tour-in { padding: var(--s-5); }
  .tour-row { grid-auto-columns: 68vw; }
  .tour-item img { max-height: calc(100dvh - 260px); width: auto; margin: 0 auto; }
}
@media (prefers-reduced-motion: reduce) { .tour-lb[open] { animation: none; } }
/* 1000–1199px: the copy column is narrow, so the pills close up to keep
   three destinations on one line. */
@media (min-width: 1000px) and (max-width: 1199.98px) {
  .wk-card:not(.wk-mini) .wk-links { gap: 6px; }
  .wk-card:not(.wk-mini) .wk-links a { padding-inline: 11px; }
}
/* Three in progress under the five: one row of three on a wide screen. */
@media (min-width: 1000px) { .wk-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .wk-mini .wk-status { white-space: normal; text-align: right; } }

/* ---- ui/css/02b-research.css ---- */
/* ============================================================================
   02b-research.css · Research (prefix rp-), November 2026
   The homepage block and /research/ list the papers as plates: status, date
   and area in one quiet line, the title, the abstract (clamped on the
   homepage), one sentence on its limits, then Read / PDF / Artifact as the
   site's pills. A paper page is a reading page: one measure, the index
   beside it from 1100px, figures and tables on the plate, references
   numbered, and a BibTeX block with a copy button. The same page prints to
   the PDF (tools/research-pdf.mjs), so the print rules below are the
   paper's typesetting.
   ========================================================================== */

.rp-list { display: grid; gap: var(--s-5); }
@media (min-width: 900px) { .rp-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

.rp-card {
  display: flex; flex-direction: column; gap: var(--s-3);
  min-width: 0; padding: var(--s-6);
  border-radius: var(--r-card); background: var(--plate);
  box-shadow: inset 0 0 0 1px var(--edge);
}
@media (max-width: 639.98px) { .rp-card { padding: var(--s-5); } }
.rp-card-meta { margin: 0; font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); }
.rp-card-t {
  margin: 0; max-width: 24ch;
  font-size: var(--fs-h3); font-weight: var(--fw-h3); line-height: var(--lh-h3); letter-spacing: var(--tr-h3);
  text-wrap: balance;
}
.rp-card-t a { color: var(--ink); text-decoration: none; }
@media (hover: hover) and (pointer: fine) { .rp-card-t a:hover { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; } }
.rp-card-pitch { margin: 0; max-width: 52ch; color: var(--muted); text-wrap: pretty; }
.rp-card-links { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); margin: auto 0 0; padding-top: var(--s-2); font-size: var(--fs-sm); }
.rp-card-links a { min-height: 40px; white-space: nowrap; }
.rp-card-links .rp-read { color: var(--bg); }
.rp-card-links .rp-read::before { background: var(--ink); }
.rp-card-links .rp-read > .ico { opacity: 1; }
@media (hover: hover) and (pointer: fine) {
  .rp-card-links .rp-read:hover { color: var(--bg); }
  .rp-card-links .rp-read:hover::before { background: color-mix(in oklab, var(--ink), var(--bg) 16%); }
}

.rp-h1 { margin: 0; font-size: var(--fs-display); font-weight: var(--fw-display); line-height: var(--lh-display); letter-spacing: var(--tr-display); color: var(--ink); }

/* ---- the paper page ---------------------------------------------------- */
.rp-paper-head { display: grid; gap: var(--s-4); max-width: 60rem; }
.rp-title {
  margin: 0; font-size: clamp(2rem, 1.2rem + 2.6vw, 3.25rem); font-weight: var(--fw-h2);
  line-height: 1.08; letter-spacing: var(--tr-h2); color: var(--ink); text-wrap: balance;
}
.rp-authors { display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-6); margin: 0; }
.rp-author { display: grid; font-weight: var(--fw-strong); color: var(--ink); }
.rp-aff { font-weight: var(--fw-text); font-size: var(--fs-sm); color: var(--muted); }
.rp-actions { display: flex; flex-wrap: wrap; gap: var(--s-2); margin: var(--s-2) 0 0; }

.rp-text { min-width: 0; max-width: 46rem; }
.rp-abstract {
  margin-top: var(--sec-top, var(--s-7)); padding: var(--s-6);
  border-radius: var(--r-card); background: var(--plate); box-shadow: inset 0 0 0 1px var(--edge);
}
.rp-abstract-t { margin: 0 0 var(--s-3); font-size: var(--fs-body); font-weight: var(--fw-strong); color: var(--ink); }
.rp-abstract p { margin: 0; color: var(--ink); text-wrap: pretty; }
.rp-h4 { margin: var(--s-5) 0 var(--s-2); font-size: var(--fs-body); font-weight: var(--fw-strong); color: var(--ink); }
.rp-keys { margin-top: var(--s-4) !important; font-size: var(--fs-sm); color: var(--muted) !important; }
.rp-keys strong { color: var(--ink); }

.rp-sec { padding-top: var(--s-8); scroll-margin-top: 6.5rem; color: var(--ink); }
.rp-sec[tabindex="-1"]:focus { outline: none; }
.rp-sec-t { margin: 0 0 var(--s-5); font-size: var(--fs-h3); font-weight: var(--fw-h3); line-height: var(--lh-h3); letter-spacing: var(--tr-h3); color: var(--ink); text-wrap: balance; }
.rp-sec h3 { margin: var(--s-6) 0 var(--s-3); font-size: var(--fs-lead); font-weight: var(--fw-strong); line-height: var(--lh-lead); color: var(--ink); }
.rp-sec h4 { margin: var(--s-5) 0 var(--s-2); font-size: var(--fs-body); font-weight: var(--fw-strong); color: var(--ink); }
.rp-sec p, .rp-sec li { line-height: 1.7; text-wrap: pretty; }
.rp-sec p { margin: 0 0 var(--s-4); }
.rp-sec ul, .rp-sec ol { margin: 0 0 var(--s-4); padding-left: 1.4em; display: grid; gap: var(--s-2); }
.rp-sec a:not(.cite):not(.btn) { color: var(--ink); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--ink), transparent 70%); text-underline-offset: 3px; }
.rp-sec a.cite { color: var(--muted); text-decoration: none; font-size: .92em; white-space: nowrap; }
@media (hover: hover) and (pointer: fine) { .rp-sec a.cite:hover { color: var(--ink); } }
.rp-sec code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em; padding: .1em .35em; border-radius: 6px; background: var(--sunk); }
.rp-sec pre { margin: 0 0 var(--s-5); padding: var(--s-4); overflow-x: auto; border-radius: var(--r-sm); background: var(--sunk); font-size: var(--fs-sm); line-height: 1.55; }
.rp-sec pre code { padding: 0; background: none; font-size: inherit; }

/* Tables and figures sit on the plate; a wide table scrolls inside itself. */
.rp-sec table { width: 100%; margin: 0 0 var(--s-6); border-collapse: collapse; font-size: var(--fs-sm); line-height: var(--lh-sm); display: block; overflow-x: auto; }
.rp-sec caption { caption-side: top; text-align: left; padding-bottom: var(--s-3); color: var(--muted); font-size: var(--fs-sm); }
.rp-sec th, .rp-sec td { padding: var(--s-2) var(--s-4) var(--s-2) 0; text-align: left; vertical-align: baseline; border-bottom: 1px solid var(--hairline); white-space: nowrap; }
.rp-sec td { font-variant-numeric: tabular-nums; }
.rp-sec thead th { font-weight: var(--fw-strong); border-bottom-color: var(--hairline-strong); }
.rp-sec figure { margin: 0 0 var(--s-6); }
.rp-sec figure img { display: block; width: 100%; height: auto; padding: var(--s-4); border-radius: var(--r-card); background: #fff; box-shadow: inset 0 0 0 1px var(--edge); box-sizing: border-box; }
.rp-sec figcaption { margin-top: var(--s-3); font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); }

.rp-reflist { padding-left: 1.8em; font-size: var(--fs-sm); line-height: var(--lh-sm); }
.rp-reflist li { scroll-margin-top: 6.5rem; overflow-wrap: anywhere; }
.rp-reflist li:target { background: var(--tint); border-radius: 6px; }
.rp-ref-url { color: var(--muted) !important; }
.rp-ai { font-size: var(--fs-sm); color: var(--muted); }
.rp-ai strong { color: var(--ink); }
.rp-bib { display: grid; gap: var(--s-3); justify-items: start; }
.rp-bib pre { width: 100%; box-sizing: border-box; margin: 0; }

.rp-more-t { margin: 0 0 var(--s-5); font-size: var(--fs-h3); font-weight: var(--fw-h3); line-height: var(--lh-h3); letter-spacing: var(--tr-h3); color: var(--ink); }

.rp-artifact { display: grid; gap: var(--s-4); }
.rp-files { display: flex; flex-wrap: wrap; gap: var(--s-2); margin: 0; padding: 0; list-style: none; font-size: var(--fs-sm); }
.rp-readme { margin: var(--s-4) 0 0; padding: var(--s-5); overflow-x: auto; border-radius: var(--r-card); background: var(--plate); font-size: var(--fs-sm); line-height: 1.6; white-space: pre-wrap; }

/* ---- print: the paper's typesetting for the PDF --------------------------- */
@media print {
  @page { size: A4; margin: 18mm 17mm 20mm; }
  .rp-paper-page .nv, .rp-paper-page .ft, .rp-paper-page .cs-toc, .rp-paper-page .rp-actions,
  .rp-paper-page .rp-more, .rp-paper-page .ct, .rp-paper-page .copy-btn { display: none !important; }
  .rp-paper-page { font-size: 10.5pt; }
  .rp-paper-page .cs-main { padding-top: 0 !important; }
  .rp-paper-page .cs-body { display: block !important; margin-top: 0; }
  .rp-paper-page .container { width: auto !important; }
  .rp-paper-page .rp-text { max-width: none; }
  .rp-title { font-size: 20pt; }
  .rp-abstract { box-shadow: none; border: 1px solid #ccc; background: #f6f6f6; }
  .rp-sec { padding-top: 14pt; }
  .rp-sec-t { break-after: avoid; }
  .rp-sec figure, .rp-sec table, .rp-sec pre { break-inside: avoid; }
  .rp-sec table { display: table; }
  .rp-sec th, .rp-sec td { white-space: normal; }
  .rp-sec a { color: #000 !important; }
  .rp-card-meta { color: #333; }
}

.rp-key { display: flex; align-items: baseline; gap: var(--s-3); margin: var(--s-2) 0 0; }
.rp-key > * { min-width: 0; }
.rp-key-v { flex: none; font-size: var(--fs-h2); font-weight: var(--fw-h2); line-height: 1; letter-spacing: var(--tr-h2); color: var(--ink); }
.rp-key-l { font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); text-wrap: pretty; max-width: 28ch; }
.rp-glance-lim { margin: 0; font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); }
.rp-glance-lim strong { color: var(--ink); }
.cs-rp { margin-top: var(--s-5); }
@media (min-width: 900px) { .cs-rp { grid-template-columns: minmax(0, 1fr); } }

/* ---- the reader ---------------------------------------------------------- */
.rp-progress { position: fixed; z-index: 60; top: 0; left: 0; right: 0; height: 3px; pointer-events: none; }
.rp-progress span { display: block; height: 100%; background: var(--ink); opacity: .55; transform: scaleX(0); transform-origin: 0 50%; }
.rp-sec-t { position: relative; }
.rp-anchor {
  display: inline-grid; place-items: center; width: 32px; height: 32px; margin-left: var(--s-2);
  vertical-align: middle; border-radius: 50%; color: var(--muted); opacity: 0;
  transition: opacity var(--d-fast) var(--ease-out), background-color var(--d-fast) var(--ease-out);
}
.rp-anchor .ico { width: 16px; height: 16px; }
.rp-sec-t:hover .rp-anchor, .rp-anchor:focus-visible { opacity: 1; }
@media (hover: hover) and (pointer: fine) { .rp-anchor:hover { background: var(--chip); color: var(--ink); } }
@media (hover: none) { .rp-anchor { opacity: .7; } }
.rp-anchor.is-done { opacity: 1; color: var(--ink); background: var(--chip-2); }
.rp-sec figure img[role="button"] { cursor: zoom-in; }
.rp-sec figure img:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.rp-fig-lb { width: 100vw; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 56px clamp(12px, 5vw, 64px) 24px; border: 0; background: transparent; box-sizing: border-box; }
.rp-fig-lb[open] { display: grid; place-items: center; }
.rp-fig-lb::backdrop { background: rgb(8 8 10 / .88); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.rp-fig-lb figure { display: grid; justify-items: center; gap: var(--s-3); margin: 0; max-width: 100%; }
.rp-fig-lb img { display: block; max-width: min(100%, 1200px); max-height: calc(100dvh - 56px - 24px - 5rem); width: auto; height: auto; padding: var(--s-4); border-radius: 12px; background: #fff; box-sizing: border-box; }
.rp-fig-lb-cap { max-width: 64ch; color: rgb(245 245 247 / .75); font-size: var(--fs-sm); line-height: var(--lh-sm); text-align: center; }
.rp-fig-lb-x { position: absolute; top: 12px; right: 12px; }
@media print { .rp-progress, .rp-anchor, .rp-fig-lb { display: none !important; } }
@media (prefers-reduced-motion: reduce) { .rp-anchor { transition: none; } }

/* Long titles and artifact labels never widen the column on a phone. */
.rp-paper-head > *, .rp-key > * { min-width: 0; }
.rp-title { overflow-wrap: anywhere; hyphens: auto; }
.rp-actions .btn { white-space: normal; text-align: left; }

/* ---- reading long text -------------------------------------------------
   At a glance first (the number, the findings, the figure), then the
   abstract; prose keeps to ~68 characters a line, paragraphs breathe, and
   each section opens with its first paragraph a step larger so a reader
   skimming headings and first lines gets the argument. */
.rp-glance {
  display: grid; gap: var(--s-5); margin-top: var(--sec-top, var(--s-7)); padding: var(--s-6);
  border-radius: var(--r-card); background: var(--raised); box-shadow: inset 0 0 0 1px var(--edge), var(--shadow-float);
}
.rp-glance-key { display: flex; align-items: baseline; gap: var(--s-4); margin: 0; }
.rp-glance-key .rp-key-v { font-size: clamp(2rem, 1.4rem + 2vw, 3rem); font-weight: var(--fw-display); letter-spacing: var(--tr-display); line-height: 1; }
.rp-glance-key .rp-key-l { font-size: var(--fs-body); max-width: 30ch; }
.rp-glance-list { display: grid; gap: var(--s-3); margin: 0; padding: 0; list-style: none; counter-reset: f; }
.rp-glance-list li { position: relative; padding-left: 2.25rem; color: var(--ink); line-height: 1.55; text-wrap: pretty; counter-increment: f; }
.rp-glance-list li::before {
  content: counter(f); position: absolute; left: 0; top: .1em;
  display: grid; place-items: center; width: 1.5rem; height: 1.5rem; border-radius: 50%;
  background: var(--ink); color: var(--bg); font-size: 12px; font-weight: var(--fw-strong);
}
.rp-glance-pitch { margin: 0; font-size: var(--fs-lead); line-height: var(--lh-lead); color: var(--ink); text-wrap: pretty; }
.rp-glance-fig { margin: 0; }
.rp-glance-fig img { display: block; width: 100%; height: auto; padding: var(--s-4); border-radius: calc(var(--r-card) - 8px); background: #fff; box-shadow: inset 0 0 0 1px var(--edge); box-sizing: border-box; }
@media (max-width: 639.98px) { .rp-glance, .rp-abstract { padding: var(--s-5); } }
.rp-sec p, .rp-sec li, .rp-abstract p { max-width: 68ch; }
.rp-sec-t + p { font-size: var(--fs-lead); line-height: var(--lh-lead); }
.rp-sec + .rp-sec { border-top: 1px solid var(--hairline); margin-top: var(--s-6); }
@media print { .rp-glance { box-shadow: none; border: 1px solid #ccc; break-inside: avoid; } .rp-glance-fig { display: none; } }

/* Phones and tablets: a paper's index would be a stray line of shortcuts;
   the reader scrolls, and the progress line shows where they are. The
   glance figure waits for its full-size place in the text on phones. */
@media (max-width: 1099.98px) { .rp-paper-page .cs-toc { display: none; } }
@media (max-width: 639.98px) { .rp-glance-fig { display: none; } }

/* A structured abstract: four labelled parts, read top to bottom on a
   phone and as label | text rows from 640px. */
.rp-abs-parts { display: grid; gap: 0; margin: 0; }
.rp-abs-part { display: grid; gap: var(--s-1); padding-block: var(--s-4); border-top: 1px solid var(--hairline); }
.rp-abs-part:first-child { border-top: 0; padding-top: 0; }
.rp-abs-part dt { font-size: var(--fs-sm); font-weight: var(--fw-strong); color: var(--ink); }
.rp-abs-part dd { margin: 0; color: var(--ink); line-height: 1.65; max-width: 62ch; text-wrap: pretty; }
.rp-abs-part:nth-child(3) dd { font-weight: var(--fw-strong); }
@media (min-width: 640px) { .rp-abs-part { grid-template-columns: 9rem minmax(0, 1fr); column-gap: var(--s-5); align-items: baseline; } }

/* Cards in a row share their rows (subgrid), so the title, the number,
   the sentence and the buttons line up across cards whatever their
   lengths. Order (scanning research: the result first, the evidence one
   step behind): where it comes from, the title, the number, one
   sentence, the actions. */
.rp-list { grid-auto-rows: auto; }
.rp-card {
  display: grid; grid-row: span 5; grid-template-rows: subgrid; row-gap: var(--s-3);
  align-content: start;
}
@supports not (grid-template-rows: subgrid) { .rp-card { display: flex; } }
.rp-card-meta { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s-2) var(--s-4); }
.rp-from { display: inline-flex; align-items: center; gap: var(--s-2); color: var(--ink); font-weight: var(--fw-ui); }
.rp-from .cs-appicon { --appicon: 20px; margin-right: calc(var(--s-1) * -1); }
.rp-from .cs-appicon + span { margin-left: var(--s-2); }
.rp-when { color: var(--muted); }
.rp-key { margin: var(--s-2) 0 0; align-self: end; }
.rp-card-pitch { align-self: start; }
.rp-card-links { margin: 0; padding-top: var(--s-2); align-self: end; }
/* The app icon outside the work pages (the homepage loads no case.css). */
.rp-from .cs-appicon { position: relative; display: inline-block; flex: none; width: 20px; height: 20px; border-radius: 22.5%; overflow: hidden; background: var(--plate); box-shadow: 0 0 0 2px var(--plate); }
.rp-from .cs-appicon img { display: block; width: 100%; height: 100%; object-fit: cover; }
.rp-from .cs-appicon-dark { display: none; }
:root[data-theme="dark"] .rp-from .cs-appicon-dark { display: block; }
:root[data-theme="dark"] .rp-from .cs-appicon-light:has(+ .cs-appicon-dark) { display: none; }
/* The PDF is named, not buttoned: its type and size as quiet text at the
   card's bottom right (owner, 2 Nov 2026). */
.rp-card-links { justify-content: space-between; }
.rp-pdf {
  margin-left: auto; padding: var(--s-2) 0; min-height: 40px; display: inline-flex; align-items: center;
  font-size: var(--fs-sm); font-weight: var(--fw-ui); color: var(--muted); text-decoration: none;
  text-underline-offset: 3px; letter-spacing: .01em;
}
@media (hover: hover) and (pointer: fine) { .rp-pdf:hover { color: var(--ink); text-decoration: underline; } }
.rp-pdf:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: 4px; }
.rp-from--stack .cs-appicon { margin-right: -7px; }
.rp-from--stack .cs-appicon + span { margin-left: var(--s-3); }

/* The stat block (owner, 2 Nov: "how the numbers are presented"): the
   figure on its own line, its label straight under it on one measure, both
   on the card's left edge; a hairline above separates the result from the
   title. The source line gets a step of air above the title. */
.rp-card .rp-key {
  display: grid; justify-items: start; gap: var(--s-1);
  margin: var(--s-2) 0 0; padding-top: var(--s-4); border-top: 1px solid var(--hairline);
  align-self: start;
}
.rp-card .rp-key-v { font-size: clamp(2rem, 1.6rem + 1.2vw, 2.5rem); line-height: 1.05; }
.rp-card .rp-key-l { max-width: 40ch; font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); }
.rp-card .rp-card-meta { margin-bottom: var(--s-2); }
.rp-card .rp-card-pitch { color: var(--ink); }
.rp-card .rp-card-links { align-items: center; padding-top: var(--s-3); }
.rp-more {
  display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; margin-left: -1px !important;
  border-radius: 999px; background: var(--chip-2); box-shadow: 0 0 0 2px var(--plate);
  font-size: 11px; font-weight: var(--fw-strong); color: var(--ink); line-height: 1;
}
.rp-from--stack { gap: 0; }
.rp-from--stack .cs-appicon { margin-right: -5px; }
.rp-from--stack .cs-appicon:nth-child(2) { z-index: 1; }
.rp-from--stack .rp-more { position: relative; z-index: 2; margin-left: 0 !important; }
@media (max-width: 639.98px) {
  .rp-card .rp-card-meta { flex-direction: column; align-items: flex-start; justify-content: flex-start; gap: var(--s-2); }
}
/* The PDF is a mark, not a word: one file icon at the card's bottom right. */
.rp-pdf { justify-content: center; width: 40px; height: 40px; padding: 0; border-radius: 50%; color: var(--muted); }
.rp-pdf .ico { width: 22px; height: 22px; }
@media (hover: hover) and (pointer: fine) { .rp-pdf:hover { color: var(--ink); background: var(--chip); text-decoration: none; } }
/* One spacing for every source line: icons 6px apart, the badge the same
   size as an icon, whether a paper comes from two products or eight. */
.rp-from, .rp-from--stack { gap: 6px; }
.rp-from .cs-appicon, .rp-from--stack .cs-appicon { margin-right: 0; box-shadow: none; }
.rp-from .cs-appicon + span:not(.cs-appicon) { margin-left: 4px; }
.rp-from--stack .rp-more { height: 20px; min-width: 20px; padding: 0 5px; margin: 0 !important; box-shadow: none; background: var(--chip-2); }
.rp-from .cs-appicon { outline: 1px solid var(--hairline-strong); outline-offset: -1px; }
.rp-from, .rp-from--stack { gap: 4px; }
.rp-from .cs-appicon + .cs-appicon { margin-left: 0; }

/* The homepage shows the four lead reports as compact cards (no sentence):
   source, title, number, actions; four rows instead of five. */
.rp-list--home .rp-card { grid-row: span 4; }

/* ---- ui/css/03-latest.css ---- */
/* ============================================================================
   03-latest.css · Latest (revision 6)
   A slim dated strip straight after Work: the standard section rule and
   heading, then one neutral plate holding three short items, each led by
   its product's app icon (the same file as everywhere else) and a small
   muted date, then one sentence and one link. Phones stack the items as
   ruled rows, the icon in its own column; from 720px they stand side by
   side as three columns parted by one vertical hairline each, the links on
   one line at the plate's foot. Nothing animates: it is words.
   ========================================================================== */

.lt-rows {
  display: grid;
  margin: 0;
  padding: var(--s-2) var(--s-5);
  list-style: none;
}
.lt-row {
  display: grid;
  grid-template-columns: 40px minmax(0, 1fr);
  grid-template-areas: "ico date" "ico text" "ico link";
  column-gap: var(--s-4);
  align-items: start;
  padding-block: var(--s-4);
  min-width: 0;
}
.lt-row + .lt-row { border-top: 1px solid var(--hairline); }
.lt-ico { grid-area: ico; }
.lt-date {
  grid-area: date;
  font-size: var(--fs-label);
  font-weight: var(--fw-label);
  line-height: var(--lh-label);
  letter-spacing: var(--tr-label);
  color: var(--muted);
  white-space: nowrap;
}
.lt-text { grid-area: text; margin: var(--s-1) 0 0; max-width: 34ch; font-size: var(--fs-body); line-height: var(--lh-sm); color: var(--ink); }
/* A 44px target that hands its slack back above and below. */
.lt-link { grid-area: link; justify-self: start; min-height: 44px; margin-block: calc(-1 * var(--s-1)) calc(-1 * var(--s-3)); font-size: var(--fs-sm); white-space: nowrap; }

@media (min-width: 720px) {
  .lt-rows { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: var(--s-7, 48px); padding: var(--s-6) 0; }
  .lt-row {
    grid-template-columns: 40px minmax(0, 1fr);
    grid-template-rows: auto auto 1fr;
    grid-template-areas: "ico date" "text text" "link link";
    column-gap: var(--s-3);
    padding: 0 var(--s-6);
  }
  .lt-row + .lt-row { border-top: 0; border-left: 1px solid var(--hairline); }
  /* Six items stand as two strips of three: no divider at the head of a strip. */
  .lt-row:nth-child(3n + 1) { border-left: 0; }
  .lt-date { align-self: center; }
  .lt-text { margin-top: var(--s-3); max-width: 30ch; }
  /* The links share one line at the strip's foot, whatever the sentence. */
  .lt-link { align-self: end; margin-top: var(--s-2); }
}
@media (min-width: 720px) and (max-width: 899.98px) {
  .lt-row { padding-inline: var(--s-5); }
}

/* Phones (November 2026, owner: "cluttered, the button not placed
   properly"): the link is a small pill on the date line, at the right,
   so each item reads icon · date · link, then one clean sentence. */
@media (max-width: 719.98px) {
  .lt-rows { padding: var(--s-1) var(--s-5); }
  .lt-row {
    grid-template-columns: 40px minmax(0, 1fr) auto;
    grid-template-areas: "ico date link" "ico text text";
    align-items: center; row-gap: var(--s-2);
    padding-block: var(--s-5);
  }
  .lt-ico { align-self: start; }
  .lt-text { margin: 0; max-width: none; }
  .lt-link { min-height: 32px; margin: 0; padding-inline: 12px; font-size: var(--fs-label); }
  .lt-link .ico { width: 14px; height: 14px; }
}

/* ---- ui/css/04-skills.css ---- */
/* ---------------------------------------------------------------------------
   04 · The desk (#skills, prefix dk-) and Skills (prefix sk-)
   Skills, Education and Working with AI tools are one section: one hairline,
   the section rhythm, and under it three plates, each with its own h2 above
   it on the paper. From 900px Skills (7) and Education (5) share a row and
   their headings one baseline; Working with AI tools runs the full width
   under them. Below 900px the three stack. The plates are the site's
   .plate (flat --plate, the card radius, one hairline), each holding one
   object: the tool map, the timeline, the agents/me table. Only the marks
   inside them move; their words are there on first paint.
   ------------------------------------------------------------------------ */

.dk-grid { display: grid; grid-template-columns: minmax(0, 1fr); row-gap: var(--s-8); }
@media (min-width: 900px) {
  .dk-grid { grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--s-5); }
  .dk-skills { grid-column: span 7; }
  .dk-ed { grid-column: span 5; }
  .dk-ai { grid-column: 1 / -1; }
}
.dk-col { min-width: 0; display: flex; flex-direction: column; align-items: stretch; }
.dk-h {
  margin: 0 0 var(--s-5); color: var(--ink);
  font-size: var(--fs-h2); font-weight: var(--fw-h2); line-height: var(--lh-h2); letter-spacing: var(--tr-h2);
}
.dk-plate { padding: var(--card-pad); }

/* ---- Skills: tools down the side, the products across the top --------- */
/* A dot is "used in that project", never a level. The plate is a container,
   so the map decides by its own width: names under the icons when there is
   room for them (37rem), icons alone in less, and below 24rem it stops
   being a grid and reads as text rows, the tools and then where they were
   used. A fixed layout keeps the six product columns equal. */
.sk-plate { container: sk / inline-size; }
.sk-map { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: var(--fs-sm); line-height: 1.3; }
.sk-map :is(th, td) { padding: 0; font-weight: inherit; }
.sk-map thead :is(th, td) { padding-bottom: var(--s-4); vertical-align: bottom; border-bottom: 1px solid var(--hairline-strong); }
.sk-corner { width: 12rem; text-align: left; }
.sk-map thead th { position: relative; text-align: center; }
.sk-ico { width: 28px; height: 28px; margin: 0 auto; }
.sk-p {
  position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
  font-size: var(--fs-label); font-weight: var(--fw-label); letter-spacing: var(--tr-label); line-height: var(--lh-label); color: var(--muted);
}
.sk-key {
  display: inline-flex; align-items: center; gap: var(--s-2);
  font-size: var(--fs-label); font-weight: var(--fw-label); letter-spacing: var(--tr-label); line-height: var(--lh-label); color: var(--muted);
}
.sk-map tbody th { padding: 10px var(--s-3) 10px 0; text-align: left; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sk-map tbody tr:first-child > * { padding-top: var(--s-4); }
.sk-map tbody td { text-align: center; vertical-align: middle; }
.sk-dot {
  display: block; width: 6px; height: 6px; margin: 0 auto; border-radius: 50%; background: var(--ink-soft);
  transition: opacity var(--d-fast) var(--ease-out), scale var(--d-fast) var(--ease-out);
}
.sk-key .sk-dot { margin: 0; }
.sk-more { margin: var(--s-5) 0 0; font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); }
.sk-more-l { margin-right: var(--s-2); color: var(--ink); font-weight: var(--fw-ui); }

/* Room for the names: each under its icon, muted, ink when its column is
   the one being pointed at. */
@container sk (min-width: 37rem) {
  .sk-p {
    position: static; width: auto; height: auto; margin: var(--s-2) 0 0; overflow: visible; clip: auto; clip-path: none;
    display: block; transition: color var(--d-fast) var(--ease-out);
  }
}
/* Icons alone: pointing at a column names it, just above its icon. */
@container sk (min-width: 24rem) and (max-width: 36.99rem) {
  @media (hover: hover) and (pointer: fine) {
    .sk-map:has(.c1:hover) thead .c1 .sk-p, .sk-map:has(.c2:hover) thead .c2 .sk-p, .sk-map:has(.c3:hover) thead .c3 .sk-p,
    .sk-map:has(.c4:hover) thead .c4 .sk-p, .sk-map:has(.c5:hover) thead .c5 .sk-p, .sk-map:has(.c6:hover) thead .c6 .sk-p {
      width: auto; height: auto; margin: 0; overflow: visible; clip: auto; clip-path: none;
      left: 50%; bottom: calc(100% + var(--s-1)); translate: -50% 0; color: var(--ink);
    }
  }
}

/* Feedback: point at a product (its icon or any cell under it) and its
   column comes forward on a faint band while the others step back. CSS
   only; without :has() the map simply stays still. */
@media (hover: hover) and (pointer: fine) {
  .sk-map :is(th, td):is(.c1, .c2, .c3, .c4, .c5, .c6) { transition: background-color var(--d-fast) var(--ease-out); }
  .sk-map:has(.c1:hover) .c1, .sk-map:has(.c2:hover) .c2, .sk-map:has(.c3:hover) .c3,
  .sk-map:has(.c4:hover) .c4, .sk-map:has(.c5:hover) .c5, .sk-map:has(.c6:hover) .c6 { background: var(--tint); }
  .sk-map:has(:is(.c1, .c2, .c3, .c4, .c5, .c6):hover) tbody .sk-dot { opacity: .22; }
  .sk-map:has(.c1:hover) .c1 .sk-dot, .sk-map:has(.c2:hover) .c2 .sk-dot, .sk-map:has(.c3:hover) .c3 .sk-dot,
  .sk-map:has(.c4:hover) .c4 .sk-dot, .sk-map:has(.c5:hover) .c5 .sk-dot, .sk-map:has(.c6:hover) .c6 .sk-dot { opacity: 1; scale: 1.34; background: var(--ink); }
  .sk-map:has(.c1:hover) .c1 .sk-p, .sk-map:has(.c2:hover) .c2 .sk-p, .sk-map:has(.c3:hover) .c3 .sk-p,
  .sk-map:has(.c4:hover) .c4 .sk-p, .sk-map:has(.c5:hover) .c5 .sk-p, .sk-map:has(.c6:hover) .c6 .sk-p { color: var(--ink); }
  /* The band's ends follow the plate's corners, a little. */
  .sk-map thead th:is(.c1, .c2, .c3, .c4, .c5, .c6) { border-radius: var(--r-xs) var(--r-xs) 0 0; }
  .sk-map tbody tr:last-child td { border-radius: 0 0 var(--r-xs) var(--r-xs); }
}

/* Arrival: the marks come in column by column, 50ms apart, once, the first
   time the map is scrolled to (ui/js/04-skills.js readies it while it is
   still below the fold). The table and every word in it stay put. */
.sk-map .c2 { --c: 1; } .sk-map .c3 { --c: 2; } .sk-map .c4 { --c: 3; } .sk-map .c5 { --c: 4; } .sk-map .c6 { --c: 5; }
@media screen and (prefers-reduced-motion: no-preference) {
  .sk-map[data-marks="armed"] tbody .sk-dot { opacity: 0; scale: .3; transition: none; }
  .sk-map[data-marks="run"] tbody .sk-dot {
    transition: opacity 380ms var(--ease-enter), scale 380ms var(--ease-enter);
    transition-delay: calc(120ms + var(--c, 0) * 50ms);
  }
}

/* Phones (a narrow plate): the same grid, compact. Icons alone head the
   columns (names stay for screen readers), the tool names may wrap in a
   narrower first column, and the key shrinks to its dot. */
@container sk (max-width: 23.99rem) {
  .sk-corner { width: 7.5rem; }
  .sk-map { font-size: var(--fs-label); }
  .sk-ico { width: 24px; height: 24px; }
  .sk-map thead :is(th, td) { padding-bottom: var(--s-3); }
  .sk-map tbody th { white-space: normal; overflow: visible; text-overflow: clip; padding: 8px var(--s-2) 8px 0; line-height: 1.25; }
  .sk-key { gap: 6px; }
}

/* Dark: the icons' hairline already holds them; the dots step to the
   softer ink so forty of them never read as a spreadsheet. */
:root[data-theme="dark"] .sk-dot { background: color-mix(in oklab, var(--ink-soft), transparent 8%); }

/* Phones (November 2026): a six-column dot grid is unreadable at 390px.
   Each tool becomes one row: its name, then the app icons of the projects
   that used it (the column's own icon, drawn from the header), so the
   phone reads "Flutter, Dart: Bupples, Adelante, WayClub" at a glance.
   Empty cells go; the screen-reader words stay. */
@media (max-width: 639.98px) {
  .sk-map, .sk-map tbody { display: block; width: 100%; }
  .sk-map thead { display: none; }
  .sk-map tbody tr {
    display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
    padding: var(--s-3) 0; border-top: 1px solid var(--hairline);
  }
  .sk-map tbody tr:first-child { border-top: 0; padding-top: 0; }
  .sk-map tbody th { flex: 1 0 100%; margin: 0 0 var(--s-1); padding: 0; text-align: left; font-weight: var(--fw-ui); }
  .sk-map tbody td { display: none; padding: 0; }
  .sk-map tbody td.on {
    display: block; width: 28px; height: 28px; flex: none;
    border-radius: 22.5%; background: var(--plate) center / cover no-repeat;
    outline: 1px solid var(--hairline); outline-offset: -1px;
  }
  .sk-map tbody td.on .sk-dot { display: none; }
  .sk-map tbody td.c1.on { background-image: url("/images/icons/apps/bupples-56.webp"); }
  .sk-map tbody td.c2.on { background-image: url("/images/icons/apps/adelante-56.webp"); }
  .sk-map tbody td.c3.on { background-image: url("/images/icons/apps/wayclub-56.webp"); }
  .sk-map tbody td.c4.on { background-image: url("/images/icons/apps/photoshoot-56.webp"); }
  .sk-map tbody td.c5.on { background-image: url("/images/icons/apps/codeatlas-56.webp"); }
  .sk-map tbody td.c6.on { background-image: url("/images/icons/apps/tajweed-56.webp"); }
  :root[data-theme="dark"] .sk-map tbody td.c1.on { background-image: url("/images/icons/apps/bupples-dark-56.webp"); }
  :root[data-theme="dark"] .sk-map tbody td.c2.on { background-image: url("/images/icons/apps/adelante-dark-56.webp"); }
}

/* ---- ui/css/05-experience.css ---- */
/* ---------------------------------------------------------------------------
   05 · Experience (#experience, prefix xp-)
   A gallery of plates, not Work's rows: each role is a tile whose top is a
   plate (the tokens' .plate, as on Work: the card radius, a flat tint from
   the one product table, one hairline, no shadow) holding a composed real
   object, and under it a caption set like an app listing: the icon beside
   the role, the organisation and the dates under it, then one line and one
   link.
   Layout. From 900px two rows on Work's own column lines: Tajweed 7 +
   Experts Marine 5 (the line of Work's flipped rows), then the club 4 +
   Fallen Asteri 8 (the line of the Bupples lead), the second row shorter.
   The partner plates' ratios are worked out so both plates in a row are one
   height, so the captions start on one line. 640–899px: two equal columns;
   phones: one, the two smaller roles on shorter 16:10 plates.
   Objects follow Work's one rule (02-work.css): they stand on the plate's
   bottom edge. Phones are cut by it (Taj peeks up from the same edge, the
   app's own edge-peek); a capture is a window at one inset from the sides
   and top, running down to the edge. The club's logo is the club's own
   mark (not his design): it sits on the plate like an app icon and is never
   repeated as the caption's icon.
   Motion. Only the plates arrive (data-appear="object"); every word is in
   place on first paint. A plate that links answers on its hairline and its
   object comes up (tokens); pointing at the caption's link lights the same
   hairline, so a tile answers as one thing.
   ------------------------------------------------------------------------ */

/* Tints: one table for the whole site (ui/css/00b-products.css). */
.xp-row[data-org="tajweed"]       { --plate-bg: var(--plate-tajweed, var(--plate)); }
.xp-row[data-org="expertsmarine"] { --plate-bg: var(--plate-expertsmarine, var(--plate)); }
.xp-row[data-org="csc"]           { --plate-bg: var(--plate-csc, var(--plate)); }
.xp-row[data-org="fallenasteri"]  { --plate-bg: var(--plate-fallenasteri, var(--plate)); }

/* ---------------------------------------------------------------------------
   The gallery
   ------------------------------------------------------------------------ */
.xp-list { grid-template-columns: minmax(0, 1fr); row-gap: var(--s-7); }
.rows.xp-list > .xp-row {
  display: grid; grid-template-rows: auto auto; row-gap: var(--s-4); align-content: start;
  padding: 0; border: 0;
}

@media (min-width: 640px) {
  .xp-list { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--s-6); row-gap: var(--s-8); }
}
/* From 900px the columns are drawn so each row splits exactly where Work
   does: 7/12 | gap | 5/12 and 4/12 | gap | 8/12 of the same width. */
@media (min-width: 900px) {
  .xp-list {
    --xp-g: var(--s-7);
    column-gap: 0;
    grid-template-columns:
      calc((100% - var(--xp-g)) / 3) var(--xp-g)
      calc((100% - var(--xp-g)) / 4 - var(--xp-g)) var(--xp-g)
      calc((100% - var(--xp-g)) * 5 / 12);
  }
  .xp-row:nth-child(1) { grid-area: 1 / 1 / 2 / 4; }
  .xp-row:nth-child(2) { grid-area: 1 / 5 / 2 / 6; }
  .xp-row:nth-child(3) { grid-area: 2 / 1 / 3 / 2; }
  .xp-row:nth-child(4) { grid-area: 2 / 3 / 3 / 6; }
}

/* ---------------------------------------------------------------------------
   The plate
   ------------------------------------------------------------------------ */
.xp-media { margin: 0; min-width: 0; }
.xp-plate {
  display: block; overflow: clip;
  container-type: size;
  aspect-ratio: 4 / 3;
}
.xp-row[data-org="expertsmarine"] .xp-plate { aspect-ratio: 5 / 4; }
.xp-row[data-org="fallenasteri"] .xp-plate { aspect-ratio: 16 / 10; }
.xp-row[data-org="csc"] .xp-plate { aspect-ratio: 16 / 10; }
@media (min-width: 640px) {
  .xp-row[data-org="tajweed"] .xp-plate { aspect-ratio: 5 / 4; }
}
@media (min-width: 900px) {
  /* 7 : 5 of one width at one height: 16/10 beside 8/7. Row 2, shorter,
     4 : 8 at one height: 6/5 beside 12/5. */
  .xp-row[data-org="tajweed"] .xp-plate { aspect-ratio: 16 / 10; }
  .xp-row[data-org="expertsmarine"] .xp-plate { aspect-ratio: 8 / 7; }
  .xp-row[data-org="csc"] .xp-plate { aspect-ratio: 6 / 5; }
  .xp-row[data-org="fallenasteri"] .xp-plate { aspect-ratio: 12 / 5; }
}
.xp-obj { position: absolute; inset: 0; display: block; }

@media (hover: hover) and (pointer: fine) {
  .xp-row:has(.xp-link:hover) .xp-plate::after { box-shadow: inset 0 0 0 1px var(--plate-edge-hover); }
}

/* ---------------------------------------------------------------------------
   Objects
   ------------------------------------------------------------------------ */

/* Tajweed: two screens rising from the bottom edge and cut by it, the
   second a step lower, in Work's phone material (the float shadow, a
   hairline; a light one in the dark theme); Taj peeks up from the same
   edge just past its corner. One unit (--u) sizes the group: the plate's
   height, or less where the plate is narrow, so the group (114u wide) never
   reaches the sides. The screens follow the page's theme. */
.xp-plate--app { --u: min(1cqh, .74cqw); }
.xp-phone {
  position: absolute; display: block;
  height: calc(98 * var(--u)); width: auto; aspect-ratio: 480 / 1039;
  border-radius: calc(6.1 * var(--u));
  box-shadow: var(--shadow-float);
  outline: 1px solid color-mix(in oklab, var(--ink), transparent 90%); outline-offset: -1px;
}
.xp-phone--a { left: calc(50% - 58 * var(--u)); bottom: calc(-9 * var(--u)); }
.xp-phone--b { left: calc(50% - 10 * var(--u)); bottom: calc(-17 * var(--u)); }
.xp-taj {
  position: absolute; display: block;
  height: calc(42 * var(--u)); width: auto;
  left: calc(50% + 27 * var(--u)); bottom: calc(-8 * var(--u));
}
.xp-dark { display: none; }
:root[data-theme="dark"] .xp-light { display: none; }
:root[data-theme="dark"] .xp-dark { display: block; }
:root[data-theme="dark"] .xp-phone { outline-color: rgb(255 255 255 / .16); }

/* Captures, as Work's windows: one inset from the plate's sides (6.5cqw)
   and 1.25 insets from its top, standing on the bottom edge and cut by it,
   top corners rounded, the float shadow; light captures dim in the dark
   theme (.shot-light). Fallen Asteri is one window (pixel art, scaled by
   whole pixels on dense screens). Experts Marine stacks two: the live hero
   whole at the same top and left inset, and the first booking step
   standing on the bottom edge in front of its lower right, clear of the
   wordmark. */
.xp-plate:is(.xp-plate--site, .xp-plate--game) { --inset: 6.5cqw; }
.xp-win {
  position: absolute; display: block; max-width: none;
  object-fit: cover; object-position: 50% 0;
  border-radius: var(--r-sm) var(--r-sm) 0 0;
  background: var(--bg);
  box-shadow: var(--shadow-float);
  outline: 1px solid color-mix(in oklab, var(--ink), transparent 88%); outline-offset: -1px;
}
:root[data-theme="dark"] .xp-win { outline-color: rgb(255 255 255 / .14); }
.xp-game {
  left: var(--inset); top: calc(var(--inset) * 1.25);
  width: calc(100% - 2 * var(--inset)); height: calc(100% - 1.25 * var(--inset));
}
.xp-shot--back { left: var(--inset); top: calc(var(--inset) * 1.25); width: 80cqw; height: auto; border-radius: var(--r-sm); }
.xp-shot--front { right: var(--inset); bottom: -4cqw; width: 70cqw; height: auto; }
@media (min-resolution: 2dppx) { .xp-game, .xp-pixel { image-rendering: pixelated; } }

/* The club: its own mark, set on the plate the way an app icon is. */
.xp-plate--mark .xp-obj { display: grid; place-items: center; }
.xp-mark {
  display: block; width: 40cqh; height: 40cqh;
  border-radius: 22.5%;
  outline: 1px solid var(--hairline); outline-offset: -1px;
}
:root[data-theme="dark"] .xp-mark { outline-color: rgb(255 255 255 / .14); }

/* ---------------------------------------------------------------------------
   The caption: the 40px icon (Work's size, mask and hairline) beside the
   role, the organisation under it and the dates under that, at every
   width; then one line and one link. The club's caption has no icon: its
   mark is the plate above.
   ------------------------------------------------------------------------ */
.xp-cap { display: grid; align-content: start; justify-items: start; min-width: 0; }
.xp-meta { display: flex; align-items: flex-start; gap: var(--s-3); }
.xp-ico { width: 40px; height: 40px; }
.xp-who { display: grid; flex: 1; min-width: 0; }
.xp-role {
  margin: 0; color: var(--ink);
  font-size: var(--fs-body); font-weight: var(--fw-strong); line-height: 1.3; letter-spacing: -.005em;
}
.xp-org { margin: 0; color: var(--muted); font-size: var(--fs-sm); line-height: var(--lh-label); }
.xp-when { margin: 2px 0 0; color: var(--muted); white-space: nowrap; }
.xp-line { margin: var(--s-3) 0 0; max-width: 52ch; color: var(--ink); text-wrap: pretty; }
/* The link follows its paragraph: a 44px target that hands its slack back. */
.xp-link { min-height: 44px; margin: var(--s-1) 0 calc(-1 * var(--s-3)); }

/* The club: three of its LinkedIn posts (the owner writes the captions and
   plans the posting; the graphics are the club's design team's), fanned and
   cut by the plate's bottom edge like the phones. No rotation, no hover
   spread. */
.xp-plate--posts { position: relative; overflow: hidden; }
.xp-posts {
  position: absolute; inset: 7% 3% 0; display: flex; justify-content: center; align-items: flex-end;
}
.xp-post {
  flex: none; display: block; width: auto; max-width: none; border-radius: var(--r-sm, 10px) var(--r-sm, 10px) 0 0;
  box-shadow: 0 0 0 1px var(--hairline);
}
.xp-post--lead { position: relative; z-index: 2; height: 92%; }
.xp-post--side { height: 76%; }
.xp-post--side:first-child { margin-right: -8%; }
.xp-post--side:last-child { margin-left: -8%; }
@media (min-width: 900px) {
  .xp-post--lead { height: 90%; }
  .xp-post--side { height: 74%; }
}

/* Fallen Asteri: three levels from its itch.io page, overlapped like the
   club's posts and standing on the plate's bottom edge. Pixel art stays
   crisp. */
.xp-shots-fan { position: absolute; inset: 7% 3% 0; display: flex; justify-content: center; align-items: flex-end; }
.xp-shot { flex: none; display: block; height: auto; max-width: none; image-rendering: pixelated; border-radius: var(--r-sm, 10px) var(--r-sm, 10px) 0 0; box-shadow: 0 0 0 1px var(--hairline); }
.xp-shot--lead { position: relative; z-index: 2; width: 80%; }
/* On the wide desktop plate the levels are sized by height so they fill it
   to the same top margin as the club's posts. */
@media (min-width: 900px) {
  .xp-shots-fan .xp-shot--lead { width: auto; height: 88%; }
  .xp-shots-fan .xp-shot--side { width: auto; height: 64%; }
  .xp-shots-fan .xp-shot--side:first-child { margin-right: -7%; }
  .xp-shots-fan .xp-shot--side:last-child { margin-left: -7%; }
}
.xp-shot--side { width: 52%; }
.xp-shot--side:first-child { margin-right: -22%; }
.xp-shot--side:last-child { margin-left: -22%; }

/* The club: one photo from ECX Week, the committee with their
   certificates and the club's sign, as one window at the plate's inset,
   standing on its bottom edge like the site captures. The certificate
   itself is one link away in the caption. */
.xp-pair {
  position: absolute; inset: calc(var(--inset, 6.5cqw) * 1.25) var(--inset, 6.5cqw) 0;
  display: grid; grid-template-columns: minmax(0, 1fr);
}
.xp-pair .xp-photo {
  display: block; width: 100%; height: 100%; max-width: none; object-fit: cover; object-position: 55% 45%;
  border-radius: var(--r-sm, 10px) var(--r-sm, 10px) 0 0; box-shadow: 0 0 0 1px var(--hairline);
}
.xp-links { display: flex; flex-wrap: wrap; gap: var(--s-2); margin: 0; }

/* ---- ui/css/06-ai.css ---- */
/* ---------------------------------------------------------------------------
   06 · Working with AI tools (#ai, prefix ai-)
   On the homepage, the last plate of the desk (04-skills.css): beside the
   heading the four tools, each led by its own mark (Simple Icons, CC0;
   every mark is its owner's trademark, used only to name the tool),
   monochrome, at the link-icon size. Under it one plate read like a table:
   a product (its icon, its name), what the agents did, what I did, and a
   worded link to that case study's Working with AI tools part. Rows are
   ruled; the column names sit once above them. In a narrow plate each row
   stacks, and the two lines carry their own small labels.
   Underneath, the lead and every row stay plain sentences ("I work with
   …", "The agents …", "What I did …") for screen readers and for any page
   that reuses them without the desk.
   ------------------------------------------------------------------------ */

/* ---- the rows anywhere (and the homepage's narrow plate) -------------- */
.ai-row { display: grid; gap: var(--s-1); justify-items: start; }
.ai-copy { margin: 0; max-width: var(--prose); }
.ai-name { display: block; color: var(--ink); font-weight: var(--fw-ui); }
.ai-ag, .ai-me { display: block; padding-left: calc(5rem + var(--s-2)); text-indent: calc(-5rem - var(--s-2)); }
.ai-ag { color: var(--muted); }
.ai-me { color: var(--ink); }
/* Each line names who did it ("The agents", "What I did") from its data-l,
   a small muted label before the words. */
.ai-ag::before, .ai-me::before {
  content: attr(data-l); display: inline-block; width: 5rem; margin-right: var(--s-2); text-indent: 0;
  font-size: var(--fs-label); font-weight: var(--fw-label); letter-spacing: var(--tr-label); color: var(--muted);
}
/* A 44px target that hands its slack back above and below. */
.ai-link { min-height: 44px; margin-block: calc(-1 * var(--s-3)); font-size: var(--fs-sm); white-space: nowrap; }
@media (min-width: 640px) {
  .ai-row { grid-template-columns: minmax(0, 1fr) auto; column-gap: var(--s-6); align-items: baseline; }
  .ai-link { justify-self: end; margin-inline-end: var(--s-3); }
}
.ai-ico { width: 32px; height: 32px; }

/* ---- the homepage ------------------------------------------------------ */
.dk-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--s-3) var(--s-6); margin-bottom: var(--s-5); }
.dk-head .dk-h { margin: 0; }

/* The tools: a row of marks and names. The sentence underneath ("I work
   with …, … and …") stays whole for screen readers and the recruiter page. */
.dk-ai .lead {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2) var(--s-5); margin: 0;
  font-size: var(--fs-sm); font-weight: var(--fw-ui); line-height: var(--lh-sm); color: var(--ink);
}
.dk-ai :is(.ai-with, .ai-sep) {
  position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}
.dk-ai .ai-tool { display: inline-flex; align-items: center; gap: var(--ico-gap); white-space: nowrap; }
.dk-ai .ai-tool::before {
  content: ""; flex: none; width: var(--ico-size); height: var(--ico-size);
  background: var(--ink-soft); opacity: var(--ico-rest);
  -webkit-mask: var(--mark) center / contain no-repeat; mask: var(--mark) center / contain no-repeat;
}
.ai-tool-claude { --mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z'/%3E%3C/svg%3E"); }
.ai-tool-cursor { --mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23'/%3E%3C/svg%3E"); }
.ai-tool-copilot { --mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z'/%3E%3C/svg%3E"); }
.ai-tool-gemini { --mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81'/%3E%3C/svg%3E"); }

/* The plate: a table in a grid. The column names sit once above the rows
   and line up with them through the same tracks. */
.ai-plate { container: ai / inline-size; }
.dk-ai .ai-list { --row-pad: var(--s-5); }
.dk-ai .ai-thead { display: none; }
.dk-ai .ai-row {
  grid-template-columns: 32px minmax(0, 1fr) auto; column-gap: var(--s-4); row-gap: var(--s-2); align-items: center;
}
.dk-ai .ai-row > .ai-ico { grid-column: 1; grid-row: 1; }
.dk-ai :is(.ai-copy, .ai-text) { display: contents; }
.dk-ai .ai-name { grid-column: 2; grid-row: 1; }
.dk-ai .ai-link { grid-column: 3; grid-row: 1; margin-inline-end: 0; }
.dk-ai :is(.ai-ag, .ai-me) { grid-column: 1 / -1; font-size: var(--fs-sm); line-height: var(--lh-sm); max-width: 60ch; }

@container ai (min-width: 46rem) {
  .dk-ai .ai-thead, .dk-ai .ai-row {
    display: grid; grid-template-columns: 32px minmax(0, 8.5rem) minmax(0, 1fr) minmax(0, 1.15fr) 8.5rem;
    column-gap: var(--s-5); align-items: start;
  }
  .dk-ai .ai-thead {
    padding-bottom: var(--s-3);
    font-size: var(--fs-label); font-weight: var(--fw-label); letter-spacing: var(--tr-label); line-height: var(--lh-label); color: var(--muted);
  }
  .dk-ai .ai-thead > :first-child { grid-column: 3; }
  .dk-ai .ai-thead > :last-child { grid-column: 4; }
  .dk-ai .ai-list > .row:first-child { --row-pt: var(--row-pad); border-top: 1px solid var(--hairline); }
  .dk-ai .ai-row > .ai-ico { grid-row: 1; }
  .dk-ai .ai-name { grid-column: 2; line-height: 32px; }
  .dk-ai .ai-ag { grid-column: 3; grid-row: 1; }
  .dk-ai .ai-me { grid-column: 4; grid-row: 1; }
  .dk-ai .ai-link { grid-column: 5; justify-self: end; margin-block: calc((32px - 44px) / 2) 0; }
  .dk-ai :is(.ai-ag, .ai-me) { padding: calc((32px - 1lh) / 2) 0 0; text-indent: 0; max-width: none; }
  /* The column names say who; inside the cells the labels are for
     screen readers only. */
  .dk-ai :is(.ai-ag, .ai-me) { position: relative; }
  .dk-ai :is(.ai-ag, .ai-me)::before {
    position: absolute; width: 1px; height: 1px; min-width: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
  }
}

/* The smallest phones: each line's label sits above its words. */
@container ai (max-width: 17.99rem) {
  .dk-ai :is(.ai-ag, .ai-me) { padding-left: 0; text-indent: 0; }
  .dk-ai :is(.ai-ag, .ai-me)::before { display: block; width: auto; margin: 0; }
}

/* ---- ui/css/07-education.css ---- */
/* ---------------------------------------------------------------------------
   07 · Education (#education, prefix ed-)
   The Education plate of the desk (04-skills.css lays the desk out): a
   timeline scaled by events, not by years, so it is as tall as what
   happened and no stretch of it is empty bar. A rail on the left, one node
   per event: a filled dot for what is done, an outlined ink bar for the
   internship (25 Jan – 23 Apr 2027), a ring for graduating. Done is solid
   ink; what is to come is the hairline. "Today" is a tick across the rail
   in the gap before the next event, labelled in that gap with the month,
   so it can never sit on a word or read as an event; ui/js/07-education.js
   moves it by the visitor's clock and drops it after Aug 2027. Each qualification carries its CGPA as a
   figure at the h3 size. The plate takes its own height, top-aligned.
   ------------------------------------------------------------------------ */

.ed-plate { align-self: start; width: 100%; }
.ed-tl {
  --rail: 7px;                /* the rail's centre, from the plate's content edge */
  --gap: var(--s-6);          /* between events; "Today" is labelled inside it */
  --node: calc(var(--fs-body) * .65); /* a node's centre: the title's first line */
  --done: var(--ink-soft);
  --todo: var(--hairline-strong);
  display: grid; row-gap: var(--gap); margin: 0; padding: 0; list-style: none;
}
.ed-ev {
  position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: var(--s-4); align-items: start;
  padding-left: calc(var(--rail) + var(--s-5));
}
/* The rail: each event draws the stretch from its node to the next one. */
.ed-ev:not(:last-child)::after {
  content: ""; position: absolute; left: calc(var(--rail) - 1px); width: 2px; border-radius: 1px;
  top: var(--node); height: calc(100% + var(--gap));
  background: var(--todo); transform-origin: top center;
}
.ed-ev.is-done:not(:last-child)::after { background: var(--done); }
/* The last done event: solid to "Today", then the hairline on to the next. */
.ed-ev.is-last-done::after {
  background: linear-gradient(to bottom, var(--done) 0 calc(100% - var(--gap) / 2 - var(--node)), var(--todo) calc(100% - var(--gap) / 2 - var(--node)) 100%);
}
/* Nodes */
.ed-node {
  position: absolute; left: var(--rail); top: var(--node); translate: -50% -50%;
  width: 9px; height: 9px; border-radius: 50%; background: var(--done);
  box-shadow: 0 0 0 3px var(--plate);
}
.ed-int .ed-node { width: 8px; height: 16px; border-radius: 3px; background: var(--plate); box-shadow: inset 0 0 0 1.5px var(--ink), 0 0 0 3px var(--plate); }
.ed-ev.is-done:is(.ed-int, .ed-grad) .ed-node { background: var(--done); box-shadow: 0 0 0 3px var(--plate); }
.ed-grad .ed-node { width: 10px; height: 10px; background: var(--plate); box-shadow: inset 0 0 0 1.5px var(--ink), 0 0 0 3px var(--plate); }
/* Today: a short ink tick across the rail and its words, muted, both in
   the gap above the next event: a position on the rail, not an event. The
   month comes from the visitor's clock (data-today, set by the script; no
   script, no month). The words are generated with empty alternative text:
   the timeline's order already says it to a screen reader. */
.ed-ev.is-next::before {
  content: "Today" attr(data-today) / "";
  position: absolute; left: 0; top: calc(var(--gap) / -2); translate: 0 -50%;
  padding-left: calc(var(--rail) + var(--s-5));
  font-size: var(--fs-label); font-weight: var(--fw-ui); letter-spacing: var(--tr-label); line-height: 1; color: var(--muted);
  background: linear-gradient(var(--ink), var(--ink)) no-repeat left calc(var(--rail) - 7px) center / 14px 2px;
}

.ed-copy { min-width: 0; }
.ed-title {
  margin: 0; color: var(--ink);
  font-size: var(--fs-body); font-weight: var(--fw-strong); line-height: 1.3; letter-spacing: 0; text-wrap: balance;
}
.ed-sub { margin: 0; font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); }
/* The CGPA, set as the figure: the number at h3, its name under it. */
.ed-gpa { margin: 0; display: grid; justify-items: end; text-align: right; color: var(--muted); }
.ed-gpa b {
  color: var(--ink); font-size: var(--fs-h3); font-weight: var(--fw-h3); line-height: 1; letter-spacing: var(--tr-h3);
}
.ed-gpa span { margin-top: var(--s-1); font-size: var(--fs-label); font-weight: var(--fw-label); letter-spacing: var(--tr-label); line-height: var(--lh-label); }
/* What is still to come reads a step quieter than what is done. */
.ed-ev:not(.is-done) .ed-title { font-weight: var(--fw-ui); }

/* Arrival (ui/js/04-skills.js readies the list once, below the fold): the
   rail draws down, stretch by stretch, the nodes and "Today" settle with it.
   The words are there from the start. */
.ed-ev:nth-child(2) { --k: 1; } .ed-ev:nth-child(3) { --k: 2; } .ed-ev:nth-child(4) { --k: 3; }
@media screen and (prefers-reduced-motion: no-preference) {
  .ed-tl[data-marks="armed"] .ed-ev::after { scale: 1 0; transition: none; }
  .ed-tl[data-marks="armed"] .ed-node { opacity: 0; scale: .4; transition: none; }
  .ed-tl[data-marks="armed"] .ed-ev.is-next::before { opacity: 0; }
  .ed-tl[data-marks="run"] .ed-ev::after { transition: scale 300ms var(--ease-out); transition-delay: calc(80ms + var(--k, 0) * 140ms); }
  .ed-tl[data-marks="run"] .ed-node { transition: opacity 240ms var(--ease-enter), scale 240ms var(--ease-enter); transition-delay: calc(var(--k, 0) * 140ms); }
  .ed-tl[data-marks="run"] .ed-ev.is-next::before { transition: opacity 240ms var(--ease-out); transition-delay: 520ms; }
}

/* A very narrow plate (the smallest phones): the CGPA moves under the
   institution, the number still the figure, so the titles keep their
   width. */
.ed-plate { container: ed / inline-size; }
@container ed (max-width: 17.99rem) {
  .ed-ev { grid-template-columns: minmax(0, 1fr); }
  .ed-gpa { display: flex; align-items: baseline; gap: var(--s-2); margin-top: var(--s-2); text-align: left; }
  .ed-gpa b { font-size: var(--fs-h3); }
  .ed-gpa span { margin: 0; }
}

/* Each qualification's dates, quiet under its institution. */
.ed-date { margin: 2px 0 0; font-size: var(--fs-label); font-weight: var(--fw-label); line-height: var(--lh-label); letter-spacing: var(--tr-label); color: var(--muted); }

/* Beside Skills (from 900px) the plate takes the row's height, and the four
   events share it in equal rows, so the rail stays continuous and the
   spacing is even rather than leaving an empty foot. */
@media (min-width: 900px) {
  .dk-ed .ed-plate { flex: 1 1 auto; align-self: stretch; display: flex; flex-direction: column; }
  .dk-ed .ed-tl { flex: 1 1 auto; grid-auto-rows: 1fr; }
}

/* ---- ui/css/08-contact.css ---- */
/* ---------------------------------------------------------------------------
   08 · Contact (#contact, prefix ct-) and the footer (prefix ft-)
   Contact is the page's last raised card. The card's own edge is its rule,
   so the section drops the shared hairline (and the 48px after it) rather
   than draw two lines one above the other. Two groups: the ask (the question as the h2 and one plain
   sentence of availability) and the reply (the address at h3, three
   buttons, and LinkedIn and GitHub as one quiet row of text links). From
   1024px the groups stand side by side on one baseline, the question's
   first line level with the address; below that they stack. Nothing is
   illustrated: the feedback is the controls' own (Copy address turns to
   Copied, with a check).
   The footer is one hairline row: ©, the place, back to top.
   ------------------------------------------------------------------------ */

.sec.ct { border-top: 0; padding-top: 0; }

/* (November 2026) The closing card sits on the page's own plate, the same
   surface as every other block, with one soft light in its corner; the
   address is a raised field on it. It changes with the theme like the rest
   of the page. Padding on the scale: 24 on phones, 32 from 640, 56 from
   900. */
.card.ct-card {
  --card-pad: var(--s-5);
  position: relative; isolation: isolate; overflow: clip;
  display: grid;
  gap: var(--s-6);
  min-width: 0;
  background: var(--plate);
  border-color: var(--edge);
}
.card.ct-card::before {
  content: ""; position: absolute; z-index: -1; inset: 0; pointer-events: none;
  background: radial-gradient(70% 120% at 100% 0%, color-mix(in oklab, var(--raised), transparent 30%), transparent 70%);
}
@media (min-width: 640px) { .card.ct-card { --card-pad: var(--s-6); } }
@media (min-width: 900px) { .card.ct-card { --card-pad: 56px; gap: var(--s-7); } }

.ct-ask, .ct-reply { display: grid; align-content: start; min-width: 0; }
.ct-ask { gap: var(--s-4); max-width: 36rem; }

.ct-title {
  margin: 0;
  font-size: var(--fs-h2);
  font-weight: var(--fw-h2);
  line-height: var(--lh-h2);
  letter-spacing: var(--tr-h2);
  color: var(--ink);
  text-wrap: balance;
}
.ct-avail { margin: 0; max-width: 34rem; font-size: var(--fs-lead); line-height: var(--lh-lead); color: var(--muted); text-wrap: pretty; }

/* Where and when: two quiet facts, each led by its mark. The time is live. */
.ct-facts { display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-5); margin: var(--s-2) 0 0; padding: 0; list-style: none; font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); }
.ct-facts li { display: inline-flex; align-items: center; gap: var(--s-2); }
.ct-facts .ico { width: 16px; height: 16px; opacity: .8; }
.ct-facts time { color: var(--ink); font-variant-numeric: tabular-nums; }

/* (2 Nov 2026, redone) The reply reads top to bottom: the address in large
   type on one line, the two things to do with it, then the other ways in
   as three ruled rows (name, what it is, the mark at the end), the site's
   own row pattern. */
.ct-email {
  margin: 0;
  font-size: clamp(1.25rem, .8rem + 1.4vw, var(--fs-h3));
  font-weight: var(--fw-h3);
  line-height: var(--lh-h3);
  letter-spacing: var(--tr-h3);
  color: var(--ink);
  white-space: nowrap;
  user-select: all;
  -webkit-user-select: all;
}
.ct-actions { display: flex; flex-wrap: wrap; gap: var(--s-2); margin-top: var(--s-4); }
.ct-actions .btn { min-width: 0; }

/* The other ways in: one quiet line of small pills, no labels, no rules. */
.ct-links { display: flex; flex-wrap: wrap; gap: var(--s-2); margin-top: var(--s-5); font-size: var(--fs-sm); }
.ct-links .ct-link { min-height: 36px; padding-inline: 12px; }

/* Side by side from 1100px: the ask on the left, the reply on the right,
   both starting at the top so the address sits level with the question. */
@media (min-width: 1100px) {
  .card.ct-card { grid-template-columns: minmax(0, 1fr) auto; column-gap: var(--s-9); align-items: center; }
}
@media (max-width: 1099.98px) { .ct-reply { max-width: 30rem; } }

/* Phones: the address may take two lines rather than overflow; the two
   actions share the width. */
@media (max-width: 639.98px) {
  .card.ct-card { gap: var(--s-6); }
  .ct-email { white-space: normal; overflow-wrap: anywhere; font-size: var(--fs-lead); }
  .ct-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  .ct-actions .btn { width: 100%; min-width: 0; padding-inline: var(--s-3); }
}
@media (max-width: 359.98px) { .card.ct-card { --card-pad: var(--s-4); } .ct-actions { grid-template-columns: minmax(0, 1fr); } }

/* Copy (.copy-btn, any button[data-copy] on any page): two faces in one
   grid cell, so the button never changes width. "Copy address" with its
   copy mark leaves first (up 4px, --d-press, ease-in); "Copied" comes in
   from 4px below as one centred group (--d-base, 60ms later) and its
   Phosphor check draws left to right once the first label has fully left.
   It holds 1.6s (08-contact.js), then returns on --d-out. */
.btn.copy-btn { display: inline-grid; place-items: center; padding-left: 16px; }
/* Without script there is nothing to copy with: the address is text. */
.no-js .copy-btn { display: none; }
.copy-face {
  grid-area: 1 / 1; display: inline-flex; align-items: center; gap: var(--ico-gap);
  transition: opacity var(--d-out) var(--ease-out), translate var(--d-out) var(--ease-out);
}
.copy-done { opacity: 0; translate: 0 4px; }
.copy-btn.is-done .copy-idle { opacity: 0; translate: 0 -4px; transition-duration: var(--d-press); transition-timing-function: var(--ease-in); }
.copy-btn.is-done .copy-done { opacity: 1; translate: none; transition-duration: var(--d-base); transition-delay: 60ms; transition-timing-function: var(--ease-enter); }
.copy-done .ico-check { clip-path: inset(0 100% 0 0); transition: clip-path var(--d-out) var(--ease-out); }
.copy-btn.is-done .copy-done .ico-check { clip-path: inset(0 0 0 0); transition: clip-path var(--d-base) var(--ease-enter) var(--d-press); }

@media (prefers-reduced-motion: reduce) {
  .copy-face { translate: none !important; transition-property: opacity; }
  .copy-done .ico-check { clip-path: none !important; }
}

/* ---------------------------------------------------------------------------
   Footer: one row under a hairline, 48px of air above and below, on three
   equal tracks so the place sits on the page's centre line. The copy line
   in ink, the place and the way back muted. "white to play" is the chess
   hint (type chess), the caption of a chess puzzle: quiet, unmarked, after
   a middle dot so it never reads as part of the address, and it comes to
   ink under the pointer. The inner pages drop it.
   ------------------------------------------------------------------------ */

.ft { padding-bottom: env(safe-area-inset-bottom); }

.ft-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center;
  gap: var(--s-3) var(--s-6);
  padding-block: var(--s-7);
  border-top: 1px solid var(--hairline);
  font-size: var(--fs-sm);
  line-height: var(--lh-sm);
  color: var(--muted);
}
.ft-row p { margin: 0; }
.ft-copy { color: var(--ink); }

.ft .egg-tell {
  color: inherit;
  text-decoration: none;
  cursor: help;
  transition: color var(--d-fast) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .ft .egg-tell:hover { color: var(--ink); text-decoration: none; }
}

/* Back to top: a 44px target whose text is flush with the container edge;
   hover, press and focus come from the shared grammar. */
.ft-top { justify-self: end; min-height: 44px; margin-block: calc(-1 * var(--s-3)); color: var(--muted); }
.ft-place { text-align: center; }

/* Phones: three left-aligned lines, evenly spaced: the 44px target of
   Back to top hands its slack back above and below. */
@media (max-width: 639.98px) {
  .ft-row { grid-template-columns: minmax(0, 1fr); justify-items: start; gap: var(--s-1); }
  .ft-place { text-align: left; }
  .ft-top { justify-self: start; margin-block: var(--s-3) 0; }
}

@media (max-width: 639.98px) {
  .ct-links { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .ct-links .ct-link { justify-content: center; padding-inline: 8px; }
}
@media (max-width: 399.98px) { .ct-actions { grid-template-columns: minmax(0, 1fr); } .ct-actions .btn { padding-inline: 16px; } }

/* ---- ui/css/10-playroom.css ---- */
/* ---------------------------------------------------------------------------
   10 · The play room (hidden: triple-click the hero name, type play, or the
   keyboard-only key at the end of the page). One dialog in the shared
   glass over the shared scrim; the games are one ruled list like the
   résumé roles (title and note, no trailing mark) that answers like every
   row: a soft tint, a press of .99, no lift. The close is the shared
   .btn-close; the archive link leads with its Phosphor icon. Focus opens
   on the heading. Games load on demand.
   ------------------------------------------------------------------------ */

.playroom {
  width: min(560px, calc(100vw - 2 * var(--s-4)));
  max-height: calc(100dvh - 2 * var(--s-5));
  margin: auto;
  padding: var(--s-5);
  color: var(--ink);
  border: 0;
  border-radius: var(--r-card);
  overflow: auto;
  overscroll-behavior: contain;
}
@media (min-width: 640px) { .playroom { padding: var(--s-6); } }
.playroom::backdrop { background: var(--scrim); }

.playroom-head { display: flex; align-items: start; justify-content: space-between; gap: var(--s-5); margin-bottom: var(--s-5); }
.playroom h2 { margin: 0 0 var(--s-2); font-size: var(--fs-h3); font-weight: var(--fw-h3); line-height: var(--lh-h3); letter-spacing: var(--tr-h3); text-wrap: balance; }
.playroom h2:focus { outline: none; box-shadow: none; } /* where reading starts, not a control */
.playroom p { margin: 0; color: var(--muted); font-size: var(--fs-body); line-height: var(--lh-body); }
.playroom-close { margin: calc(-1 * var(--s-2)) calc(-1 * var(--s-2)) 0 0; }

/* The list: hairlines between rows, none at the ends; the tint bleeds 12px
   past the text so the titles stay flush with the heading. */
.playroom-games { list-style: none; margin: 0 calc(-1 * var(--s-3)); padding: 0; }
.playroom-games li { position: relative; }
.playroom-games li + li::before { content: ""; position: absolute; left: var(--s-3); right: var(--s-3); top: 0; height: 1px; background: var(--hairline); }
.playroom-game {
  display: flex; align-items: center; gap: var(--s-4); width: 100%; min-height: 64px;
  padding: var(--s-3); border: 0; border-radius: var(--r-sm);
  background: transparent; color: var(--ink); text-align: left; font: inherit; cursor: pointer;
  transition: background-color var(--d-fast) var(--ease-out), transform var(--d-fast) var(--ease-out);
}
.playroom-game-text { display: grid; gap: 2px; flex: 1; min-width: 0; }
.playroom-game strong { font-size: var(--fs-body); font-weight: var(--fw-ui); line-height: var(--lh-label); }
.playroom-note { font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--muted); text-wrap: pretty; }
.playroom-game:disabled { cursor: default; }
.playroom-game:disabled strong { color: var(--muted); }
.playroom-game:focus-visible { position: relative; z-index: 1; border-radius: var(--r-sm); }

@media (hover: hover) and (pointer: fine) {
  .playroom-game:not(:disabled):hover { background: var(--tint); }
}
.playroom-game:not(:disabled):active { transform: scale(var(--press-row)); background: var(--tint-2); transition-duration: var(--d-press); }

.playroom-status:not(:empty) { padding-top: var(--s-4); }
.playroom-foot { display: flex; justify-content: space-between; align-items: center; gap: var(--s-4); padding-top: var(--s-4); font-size: var(--fs-sm); color: var(--muted); }
.playroom-archive { min-height: 44px; }


@media (max-width: 419.98px) {
  .playroom-foot span { display: none; }
}

/* Arrives on --d-in from 12px; the dim fades with it. */
@media (prefers-reduced-motion: no-preference) {
  .playroom[open] { animation: playroom-in var(--d-in) var(--ease-out); }
  .playroom[open]::backdrop { animation: playroom-veil var(--d-in) var(--ease-out); }
}
@keyframes playroom-in { from { opacity: 0; transform: translateY(12px); } }
@keyframes playroom-veil { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  .playroom-game { transition-duration: 0ms; }
  .playroom-game:active { transform: none; }
}

/* The games read the page's colours through these. */
:root { --game-ink: var(--ink); --game-muted: var(--muted); --game-line: var(--hairline-strong); --game-bg: var(--bg); --game-raised: var(--raised); }
