/* Portfolio Studio tokens. Loaded only by pages that have migrated. */
:root {
  --color-bg: #f3f0ea;
  --color-surface: #fff;
  /* Case pages' warm surfaces (Design.md 4.1): data cards, the chapter index and the next-case card in the paper's
     hue, lifted toward white, instead of a cool #fff on the sand gradient. */
  --color-surface-warm: #fdfbf7;
  --color-border-warm: #e6dfd1;
  /* Case pages below their opening: the flowing field fades into this solid warm paper, the field's own median
     colour, so the fade lands without a seam (Design.md 4.1). */
  --color-bg-warm: #f0e9dc;
  --color-text: #131210;
  --color-body: var(--color-text);
  --color-subtitle: #4a4741;
  --color-muted: #68655f;
  --color-accent: #1e4d3b;
  --color-accent-hover: #2a6350;
  --color-accent-current: #357d5a;
  /* The pale green well behind a data card's icon, and the faint wash on a lead card (--color-accent-current at 12% and 7%). */
  --color-accent-tint: rgb(53 125 90 / .12);
  --color-accent-wash: rgb(53 125 90 / .07);
  /* Spot colours of the pulsing border on the current case card (scripts/pulsing-border.js). */
  --pulse-border-colors: #1f9a63, #f0a531, #ec6a45;
  --color-on-accent: #fff;
  --color-border: #d8d3c9;
  --color-control-border: #716e68;
  --color-success: #176f31;
  --color-error: #b3261e;
  --font-display: Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
  --font-display-zh: Inter, "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
  --font-body: Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
  --font-label: "JetBrains Mono", ui-monospace, monospace;
  --radius-sm: 2px;
  --radius-md: 4px;
  --radius-lg: 8px;
  --radius-xl: 16px;
  --radius-2xl: 24px;
  /* Elevation for things that float above the content (Design.md 4.4). */
  --shadow-float: 0 10px 24px -14px rgb(19 18 16 / .35);
  /* A project card lifted as the one on screen: a faint contact shadow under a wide, soft one, so a card this
     large visibly rises. The pinned bar still reads as on top: the cards scroll under it. */
  --shadow-lift: 0 1px 3px rgb(19 18 16 / .06), 0 22px 44px -14px rgb(19 18 16 / .32);
  /* The card under the pointer on desktop: deeper than --shadow-lift. */
  --shadow-hover: 0 2px 6px rgb(19 18 16 / .08), 0 30px 56px -16px rgb(19 18 16 / .36);
  /* Case pages: a warm brown ink, since a black shadow turns grey on the sand gradient. Data cards rest on
     --shadow-warm; the voice strip in the Goodwill opening sits higher on --shadow-warm-raised. */
  --shadow-warm: 0 1px 2px rgb(92 68 30 / .07), 0 16px 32px -20px rgb(92 68 30 / .38);
  --shadow-warm-raised: 0 2px 4px rgb(92 68 30 / .08), 0 24px 40px -24px rgb(92 68 30 / .45);
  /* The homepage's warm cards (since 2026-11-05) lift, hover and float on the same shapes as --shadow-lift,
     --shadow-hover and --shadow-float, in the warm ink. */
  --shadow-warm-lift: 0 1px 3px rgb(92 68 30 / .06), 0 22px 44px -14px rgb(92 68 30 / .32);
  --shadow-warm-hover: 0 2px 6px rgb(92 68 30 / .08), 0 30px 56px -16px rgb(92 68 30 / .36);
  --shadow-warm-float: 0 10px 24px -14px rgb(92 68 30 / .35);
  --content-wide: 1120px;
  --content-reading: 720px;
  --page-gutter: 48px;
  --leading-body: 1.75;
  --bg: var(--color-bg);
  --text: var(--color-text);
  --muted: var(--color-muted);
  --border: var(--color-border);
  --edge-gutter: var(--page-gutter);
}
/* English body copy sets slightly tighter than Chinese (Design.md 4.2). */
:root:lang(en) { --leading-body: 1.65; }
/* Chinese pages set “” in Noto Sans SC, whose double quotes are full-width (fonts.css). */
:root:lang(zh) {
  --font-display: "Noto Sans SC Quotes", Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
  --font-display-zh: "Noto Sans SC Quotes", Inter, "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
  --font-body: "Noto Sans SC Quotes", Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
}

* { box-sizing: border-box; }
/* The site header scrolls away, so anchors only need a little air; the home page sets its own offset
   below the sticky project index (scripts/portfolio-home.js). */
html { scroll-padding-top: 24px; }
body { margin: 0; background: var(--color-bg); color: var(--color-text); font-family: var(--font-body); font-size: 1rem; line-height: var(--leading-body); -webkit-font-smoothing: antialiased; }
button, input, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
a { color: inherit; text-underline-offset: 5px; }
button { cursor: pointer; }
a:focus-visible, button:focus-visible, summary:focus-visible, video:focus-visible { outline: 2px solid var(--color-text); outline-offset: 3px; }
img, video { display: block; max-width: 100%; }
h1, h2, p, figure { margin: 0; }
.studio-wrap { width: min(calc(100% - 2 * var(--page-gutter)), var(--content-wide)); margin-inline: auto; }
.nowrap { white-space: nowrap; }
.latin-display { font-family: var(--font-display); font-weight: 800; letter-spacing: -.02em; }
.mono { font-family: var(--font-label); font-size: .875rem; letter-spacing: .06em; }
.skip-link { position: fixed; top: 12px; left: 12px; z-index: 2000; transform: translateY(-200%); background: var(--color-text); color: var(--color-surface); padding: 12px 20px; border-radius: var(--radius-md); }
.skip-link:focus { transform: none; }
.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

.home-hero { position: relative; padding-block: 48px 56px; }
/* Flowing gradient behind the whole home page and the case studies (scripts/flow-field.js; Yusen 2026-11-02, after jiayu-zhang.com,
   first only behind the opening, then site-wide on the home page because it was too subtle): one WebGL canvas
   fixed to the window below everything (negative z-index in the root stacking context, so it sits just above
   the body's paper background), so the field keeps flowing while the page scrolls. The cards, the work index
   and dialogs keep their own surfaces; the header and footer drop their paper background here so the field
   runs behind them, and their muted grey is darkened to --color-subtitle because it now sits on the tints.
   Without WebGL (or without 32-bit floats in fragment shaders) the canvas is transparent and the paper shows.
   Print drops it, and so do high-contrast settings: a Windows high-contrast theme (forced colours) would keep
   the light field around the visitor's own colours, and a request for more contrast (macOS Increase contrast)
   should not get tinted text grounds; both get the plain paper. 100lvh: the canvas keeps the large-viewport height, so a phone's collapsing URL bar does
   not resize it. */
.page-glow { position: fixed; top: 0; left: 0; z-index: -1; width: 100%; height: 100vh; height: 100lvh; pointer-events: none; }
body:has(.page-glow) :is(site-header, site-footer) { background: transparent; --color-muted: var(--color-subtitle); }
/* Case studies carry the same field (Yusen, 2026-11-02). Their muted labels, meta and captions sit straight on
   the tints (#68655F drops to 3.7:1 on sand), so the page darkens the muted token to --color-subtitle, and the
   error red one step (#b3261e is 4.1:1 on sand; #9f1f18 keeps 4.9:1). Text on white panels only gets darker. */
body:has(.page-glow):has(.case-hero) { --color-muted: var(--color-subtitle); --color-error: #9f1f18; background: var(--color-bg-warm); }
/* Case pages are for reading: the field stays behind the opening and fades into the solid warm paper over about
   one viewport (Yusen, 2026-11-05). The canvas sits at the top of the page, two viewports tall with its lower
   half masked away, so it scrolls off with the opening and flow-field.js, which draws only while its canvas is
   on screen, stops for the rest of the visit. The home page keeps the field fixed behind everything. */
body:has(.case-hero) .page-glow { position: absolute; height: 200vh; height: 200lvh;
  -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent); mask-image: linear-gradient(to bottom, #000 50%, transparent); }
.flow-field { display: block; width: 100%; height: 100%; opacity: 0; }
.flow-field.is-live { opacity: 1; }
@media print, (forced-colors: active), (prefers-contrast: more) { .page-glow { display: none; } }
.hero-greeting { margin-bottom: 16px; font-size: 1.125rem; }
/* Waving figure after the greeting: a small drawing of Yusen that stands on the greeting's baseline and
   reaches up into the hero's top padding. The negative margin keeps the line box at the text's height, so
   the hero is exactly as tall as without it. Decorative (aria-hidden): the greeting already says hello.
   Drawn as a pastel cartoon with coloured line art (Yusen's choice, Design.md 5): every outline is a
   darker shade of the fill it surrounds. The tee wears the site's accent green, so it matches the
   Contact button; the other colours belong to the drawing, not to the site's tokens. In the markup the
   figure, head, forearm and hand each sit in a wrapper that puts their pivot (feet, neck, elbow, wrist)
   at the origin, so the motion below needs no transform-origin and turns the same way in every
   browser. */
.hero-wave { --wave-skin: #f4bd96; --wave-skin-line: #a8563a; --wave-hair: #2a2337; --wave-hair-line: #15101f; --wave-tee: var(--color-accent); --wave-tee-line: #0d2a1f; display: inline-block; width: auto; height: 64px; aspect-ratio: 284 / 208; margin: -48px 0 0 8px; vertical-align: -0.15em; overflow: hidden; fill: none; stroke: var(--wave-skin-line); stroke-width: 3.6; stroke-linecap: round; stroke-linejoin: round; }
/* vertical-align: the figure's flat bottom edge sits next to the greeting's
   comma, whose tail hangs about 0.17em below the Latin baseline (Inter) and
   0.16em below it (Noto Sans SC); CJK glyphs themselves reach 0.1em down. On
   the baseline the figure looked lifted beside them, so it drops 0.15em. */
/* The Chinese greeting ends in a full-width comma, which brings its own space. */
.hero-wave:lang(zh) { margin-left: 0; }
.hero-wave :is(.wave-skin, .wave-limb) { fill: var(--wave-skin); }
.hero-wave :is(.wave-limb, .wave-shade, .wave-blush, .wave-pupil, .wave-glint, .wave-teeth, .wave-tongue) { stroke: none; }
/* The bare forearm and the hand are drawn twice, thick outline-coloured copies under skin copies, so
   the arm, palm and fingers share one outline even while the wrist turns. The hand's copy sits in a
   scaled group, hence its own width. */
.hero-wave .wave-limb-ol { fill: var(--wave-skin-line); stroke-width: 7.2; }
.hero-wave .wave-hand .wave-limb-ol { stroke-width: 9; }
.hero-wave .point-hand .wave-limb-ol { stroke-width: 8.5; }
.hero-wave .wave-finger { stroke-width: 3.2; }
.hero-wave .wave-shade { fill: #e6a27e; }
.hero-wave .wave-hair { fill: var(--wave-hair); stroke: var(--wave-hair-line); }
.hero-wave .wave-shine { stroke: #5d4f7c; stroke-width: 4; }
.hero-wave .wave-brow { stroke: var(--wave-hair-line); stroke-width: 6.5; }
.hero-wave .wave-tee { fill: var(--wave-tee); stroke: var(--wave-tee-line); }
.hero-wave .wave-collar { fill: #163d2f; stroke: var(--wave-tee-line); stroke-width: 3; }
/* The sleeve is a filled band over the shoulder end of the upper arm, in the tee's colour so it reads
   as one shape with the torso (no seam, by Yusen's choice); only its outer edges and hem get a line. */
.hero-wave .wave-sleeve { fill: var(--wave-tee); stroke: none; }
.hero-wave .wave-sleeve-edge { stroke: var(--wave-tee-line); }
.hero-wave .wave-blush { fill: #f0877a; opacity: .55; }
.hero-wave .wave-eye { fill: #fff; stroke-width: 2; }
.hero-wave .wave-pupil { fill: #1d1626; }
.hero-wave :is(.wave-glint, .wave-teeth) { fill: #fff; }
.hero-wave :is(.wave-lid, .wave-eyes-glad) { stroke: #4a2a22; }
.hero-wave .wave-lid { stroke-width: 3.4; }
.hero-wave .wave-eyes-glad { stroke-width: 4; }
.hero-wave .wave-lip { stroke-width: 2.4; opacity: .6; }
.hero-wave .wave-mouth-in { fill: #8f2f45; }
.hero-wave .wave-tongue { fill: #f58fa0; }
.hero-wave .wave-spark { fill: #ffd84d; stroke: #d99a1f; stroke-width: 1.6; }
/* The laughing face only shows while the hand waves or points. */
.hero-wave :is(.wave-eyes-glad, .wave-mouth-glad) { opacity: 0; }
/* The figure's move below the statement (scripts/portfolio-home.js): as soon as the greeting
   figure has waved it bows out and a second copy comes up behind the top edge of the work-index card
   below the statement with its arm already over the edge, pointing down at the card, swings its forearm,
   with a word about the work below, and stays there. Its body is clipped at the card's edge inside the
   SVG (a clip path), so the SVG itself lets the arm through (overflow) and sits above the card
   (z-index); the arm in front of the card never takes the pointer. The copy and the bubble stay hidden
   until the script needs them; it plays at every width, only reduced motion retains the greeting. The hero is their
   positioning box; the script sets left, bottom and height from where the card's edge and the statement
   really are. */
.hero-wave--nudge { position: absolute; z-index: 21; margin: 0; aspect-ratio: 216 / 208; overflow: visible; }
/* The arm is drawn twice in the same place, hanging over the card's edge with the finger down: the copy
   inside the body's clip shows what is above the edge, the copy in front of the card (clipped to below
   the edge) shows what hangs over it, and never takes the pointer. */
.hero-wave--nudge .point-over { pointer-events: none; }
.hero-wave--nudge.is-bowing { pointer-events: none; }
.hero-wave--nudge[hidden], .hero-wave-bubble[hidden] { display: none; }
.hero-wave-bubble { position: absolute; z-index: 1; margin: 0; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-float); font-size: .875rem; line-height: 1.5; white-space: nowrap; transform-origin: 0 50%; }
.hero-wave-bubble::before { content: ""; position: absolute; top: 50%; left: -6px; width: 10px; height: 10px; margin-top: -5px; border-left: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); background: var(--color-surface); transform: rotate(45deg); }
.hero-layout { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 48px; align-items: start; }
/* "Hero Display" is each home page's inline copy of its title glyphs (fonts/build-subset.py),
   so the title paints in its final face instead of swapping from system Hei seconds later. */
.hero-title { max-width: 13em; font-family: "Hero Display", var(--font-display-zh); font-size: clamp(2.25rem, 4.65vw, 4.5rem); font-weight: 900; line-height: 1.16; letter-spacing: .01em; text-wrap: balance; }
/* The English statement is longer; a smaller size keeps it to three lines beside the timeline. */
.hero-title:lang(en) { font-size: clamp(3rem, 3.75vw, 3.5rem); letter-spacing: -.02em; }
/* Career timeline beside the hero title. With motion allowed the rows enter one by one from the right and the
   connector segments then draw themselves top to bottom (the page entrance, in the no-preference block
   below); with reduced motion everything is simply there on first paint. */
.hero-career { --career-row: 36px; --career-year: 68px; --career-gap: 12px; --career-end: 10px; --career-row-gap: 14px; position: relative; display: grid; row-gap: var(--career-row-gap); margin: 0; padding: 0 0 var(--career-end); list-style: none; font-size: .875rem; line-height: 1.5; }
/* The connector is drawn per row as two half-segments (from the logo's top edge up to the middle of the gap
   above, and from its bottom edge down to the middle of the gap below), so a row that grows taller (a wrapping
   line on a narrow screen, enlarged text) keeps the line joined and the line never crosses a logo. The logos
   therefore need no opaque background and the flowing gradient shows through them. The halves overlap by
   .5px; the border colour is opaque, so the join is invisible. */
.hero-career li { position: relative; display: grid; grid-template-columns: var(--career-year) var(--career-row) minmax(0, 1fr); column-gap: var(--career-gap); align-items: center; }
.hero-career li::before, .hero-career li::after { content: ""; position: absolute; left: calc(var(--career-year) + var(--career-gap) + var(--career-row) / 2 - .5px); width: 1px; height: calc(50% - var(--career-row) / 2 + var(--career-row-gap) / 2 + .5px); background: var(--color-border); }
.hero-career li::before { bottom: calc(50% + var(--career-row) / 2); }
.hero-career li::after { top: calc(50% + var(--career-row) / 2); }
.hero-career li:first-child::before, .hero-career li:last-child::after { content: none; }
.hero-career .mono { color: var(--color-subtitle); } /* darker than --color-muted: it sits on the flowing gradient */
.hero-career-logo { position: relative; z-index: 1; width: var(--career-row); height: var(--career-row); padding: 1px; object-fit: contain; }
.hero-career-logo--square { padding: 2px; }
.hero-career p { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; }
.hero-career strong { font-family: var(--font-display-zh); font-size: 1rem; font-weight: 700; white-space: nowrap; }
.hero-career-role { color: var(--color-subtitle); white-space: nowrap; }
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; border-top: 1px solid var(--color-border); padding-top: 24px; margin-bottom: 32px; }
.section-heading h2 { font-family: var(--font-display-zh); font-size: 1rem; font-weight: 700; }
.section-heading .mono { color: var(--color-muted); }
/* Project index in the work heading: numbered anchors to each project, named like the project categories.
   The site header scrolls away, so this row is the page's only sticky bar: it sticks to the top of the
   viewport while the work section is on screen, and scripts/portfolio-home.js keeps the anchor offset below
   it. The row is always a white card as tall as the site header (--bar-height, 73px), centred on the row
   (--card-pad above and below it; English sets a shorter line, so the pad follows --leading-body). It is
   as wide as the project cards, with their 24px corners, and its text sits --bar-inset in, in line with
   theirs, so it reads as the top of the column of cards. It looks the same before and after
   it sticks: once stuck it only floats, rising 2px as its shadow comes in (--index-morph). The card is a
   pseudo-element, so the row's box never changes size and nothing below moves; the band between the card
   and the top of the viewport is see-through. The card starts at the row's top edge, over the section's
   1px rule (kept transparent: the card is the divider), so the greeting figure that stands on that edge
   (scripts/portfolio-home.js) touches the card. The top padding is the pad less that rule, so the card
   stays centred on the row. The card sits 32px above the first project, like the cards between
   themselves. The row keeps its place under the hero, so the figure keeps its room above it. */
.section-heading--index { --bar-height: 73px; --row-height: calc(1rem * var(--leading-body)); --card-pad: calc((var(--bar-height) - var(--row-height)) / 2); --card-top: -1px; --bar-inset: 40px; --float-gap: 8px; --index-morph: 600ms ease-in-out; position: sticky; top: calc(var(--float-gap) - var(--card-top) - 1px); z-index: 20; border-top-color: transparent; padding-top: calc(var(--card-pad) - 1px); padding-inline: var(--bar-inset); padding-bottom: var(--card-pad); margin-bottom: 32px; }
.section-heading--index.is-stuck { transform: translateY(-2px); }
/* Floating navigation and its open menu stay above the mobile poses (30) and dialogue (31). */
.section-heading--index.is-stuck, .section-heading--index:has(.work-index.is-open) { z-index: 40; }
/* The card is opaque, so the content scrolling underneath never shows through the row. Its corners stay
   rounded, so the phone list can square them without waiting. */
.section-heading--index::before { content: ""; position: absolute; inset: var(--card-top) 0 0; z-index: -1; border: 1px solid var(--color-border-warm); border-radius: var(--radius-2xl); background: var(--color-surface-warm); box-shadow: none; pointer-events: none; }
.section-heading--index.is-stuck::before { box-shadow: var(--shadow-warm-float); }
.section-heading--index h2 { flex: none; }
/* When the row is too narrow the index scrolls sideways instead of wrapping, so the first project keeps its
   place on the first screen. The padding keeps 44px targets and focus rings inside the scroller; the negative
   margin keeps the heading row at its previous height. */
.work-index { min-width: 0; margin: -17px -8px; padding-block: 6px; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
.work-index::-webkit-scrollbar { display: none; }
/* The list, not the scroller, carries the side padding, so it stays inside the scrolled width at both ends.
   The links carry the spacing between them (10px each side), room for the current project's block. */
.work-index ol { display: flex; gap: 0; width: max-content; margin: 0; padding: 0 8px; list-style: none; white-space: nowrap; }
/* Items read "1. Label | 2. Label": a thin bar sits between the links (outside them, so it is never part of a
   target or the current block) and is hidden from screen readers; the collapsed list drops it (is-compact). */
.work-index li { display: flex; align-items: center; }
.work-index li + li::before { content: "|"; content: "|" / ""; margin-inline: 2px; color: var(--color-border); font-size: .875rem; line-height: 1; }
/* Numbers and labels share the body face, like the header's navigation, so "1." reads as ordinary text. */
.work-index a { position: relative; isolation: isolate; display: inline-flex; align-items: baseline; gap: 6px; min-height: 44px; padding: 11px 10px; font-size: .875rem; line-height: 1.5; text-decoration: none; }
.work-index .mono { font-family: inherit; font-size: inherit; letter-spacing: normal; }
.work-index a::before { content: ""; position: absolute; inset: 5px 0 7px; z-index: -1; border-radius: var(--radius-md); background: var(--color-accent-current); opacity: 0; }
/* The project on screen (aria-current, set while scrolling) sits on a solid green block shaped like the
   header's Contact button, one step lighter (--color-accent-current) so it reads as where you are rather
   than as the page's main action; the others step back to muted. Every link has the same padding and the
   weight never changes, so the row does not shift when the marker moves. The block is 32px tall inside
   the 44px target, 1px above its middle so it is centred on the floating card and on the text. */
.work-index.has-current a:not([aria-current]) { color: var(--color-muted); }
.work-index a[aria-current] { color: var(--color-on-accent); }
.work-index a[aria-current]::before { opacity: 1; }
.work-index a[aria-current] .mono { color: inherit; }
/* The labels carry no underline: hovering a project brings up a faint block of the marker green and inks
   its text. */
.work-index.has-current a:not([aria-current]):hover { color: var(--color-text); }
.work-index a:not([aria-current]):hover::before { opacity: .12; }
/* When the whole row doesn't fit beside the heading (every phone, and the longer English labels on tablets),
   the script sets is-compact: the index collapses to a button naming the current project, which opens the
   full list as a card below the bar. Without the script the row scrolls sideways instead. */
.work-index-toggle { display: none; }
/* Centre the button against the heading: their baselines sit at different depths (16px text on a 28px line
   vs 14px on 21px), so baseline alignment would make the row taller. The row is the 28px button. */
.section-heading--index:has(> .work-index.is-compact) { --row-height: 28px; align-items: center; }
/* A flex box, so the button is not set on a text line whose strut would make the row taller. */
.work-index.is-compact { display: flex; align-items: center; margin: 0; padding: 0; overflow: visible; }
.work-index.is-compact .work-index-toggle { display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; min-height: 44px; margin-block: -8px; padding: 8px 0; border: 0; background: none; color: inherit; font-size: .875rem; line-height: 1.5; }
/* A name too long for the row ends in an ellipsis instead of wrapping, so the bar keeps its height. */
.work-index-toggle .work-index-current { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-index-toggle::after { content: ""; align-self: center; width: 7px; height: 7px; margin-left: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: translateY(-2px) rotate(45deg); }
.work-index.is-open .work-index-toggle::after { transform: translateY(2px) rotate(-135deg); }
.work-index.is-compact ol { display: none; }
/* The list opens as wide as the bar's card. Its links are padded by the bar's text inset less the list's 1px
   border and 6px padding, so their text lines up with the bar's, and the current project's block sits 6px
   inside the card. */
.work-index.is-compact.is-open ol { display: grid; position: absolute; top: 100%; left: 0; right: 0; width: auto; gap: 0; padding: 8px 6px; background: var(--color-surface-warm); border: 1px solid var(--color-border-warm); border-radius: var(--radius-2xl); box-shadow: var(--shadow-warm-float); white-space: normal; }
.work-index.is-compact.is-open ol a { display: flex; padding-inline: calc(var(--bar-inset) - 7px); }
/* The list is a column: no bars between items, and each link spans the full row. */
.work-index.is-compact li { display: block; }
.work-index.is-compact li::before { display: none; }
.work-index.is-compact.is-open ol a::before { inset: 6px 0; }
/* Opened, the list continues the bar's card: square corners at the seam, and the card's bottom border is
   the only line between them, so no page text shows through a gap. */
.section-heading--index:has(.work-index.is-compact.is-open)::before { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.section-heading--index .work-index.is-compact.is-open ol { top: 100%; border-top: 0; border-top-left-radius: 0; border-top-right-radius: 0; }

/* Each project is one warm card (--color-surface-warm with its hairline and --shadow-warm, as on the case pages;
   Yusen picked warm solid over white and glass, 2026-11-05) with 24px corners holding its text and cover. The
   covers sit straight on the card; the four with a white ground of their own opt in to .media-blend below. Text starts 40px in (16px card padding plus 24px);
   the cover keeps its panel's own padding. The project on screen (is-current, set by the index script)
   rises by --card-lift (8px; 4px on phones, where the cards sit 16px apart) on --shadow-lift and takes a
   2px ring in --color-accent-current, the green of the index's current item, so the bar and the card
   point at each other; it settles back when the next one takes over. Shadow and ring sit on a
   pseudo-element so only its opacity animates; the motion is in the reduced-motion block at the end. */
.project { --card-lift: 8px; position: relative; display: grid; grid-template-columns: minmax(0, .82fr) minmax(0, 1.3fr); grid-template-areas: "heading media" "description media"; column-gap: 48px; row-gap: 20px; align-content: start; margin-bottom: 32px; padding: 16px; background: var(--color-surface-warm); border-radius: var(--radius-2xl); box-shadow: inset 0 0 0 1px var(--color-border-warm), var(--shadow-warm); }
.project::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: var(--shadow-warm-lift), inset 0 0 0 2px var(--color-accent-current); opacity: 0; pointer-events: none; }
.project.is-current::after { opacity: 1; }
/* The pulsing border (scripts/pulsing-border.js): one canvas that moves into the current card, reaching
   --pulse-bleed past it on every side for the glow (less than the gap between cards and the page gutter,
   so it never meets the next card or widens the page). It paints over the ring above (the script puts it
   first in the card, under the illustration), and fades in once
   its first frame is drawn; until then, without WebGL, or in the high-contrast settings that also hide the
   background field, the ring alone marks the card. */
.project { --pulse-bleed: 28px; }
.pulse-border { position: absolute; z-index: 1; top: calc(-1 * var(--pulse-bleed)); left: calc(-1 * var(--pulse-bleed)); width: calc(100% + 2 * var(--pulse-bleed)); height: calc(100% + 2 * var(--pulse-bleed)); pointer-events: none; opacity: 0; }
.pulse-border.is-live { opacity: 1; }
@media print, (forced-colors: active), (prefers-contrast: more) { .pulse-border { display: none; } }
@supports (corner-shape: squircle) {
  /* Apple-style continuous corners where the browser can draw them; elsewhere plain round corners. */
  .project, .project::before, .project::after, .section-heading--index::before, .work-index.is-compact.is-open ol { corner-shape: squircle; }
}
.project-heading { grid-area: heading; align-self: end; min-width: 0; padding: 24px 0 0 24px; }
.project-category { margin-bottom: 16px; color: var(--color-muted); font-size: .875rem; }
/* Short English labels use the label face; Chinese labels stay in body Hei. */
.project-category:lang(en) { font-family: var(--font-label); letter-spacing: .06em; }
.project-title { font-family: var(--font-display-zh); font-size: clamp(1.5rem, 2.25vw, 2rem); font-weight: 700; line-height: 1.35; text-wrap: balance; }
/* The title's link is the card's only link: its ::after covers the whole card (.project is positioned), so
   a click anywhere on the card opens the case study, while the Tab key and screen readers meet one link
   named by the title. z-index keeps it above the preview videos, which form their own layer while they
   fade. The keyboard focus ring goes round the card. */
.project-title a { text-decoration: none; }
.project-title a::after { content: ""; position: absolute; inset: 0; z-index: 1; }
.project-title a:focus-visible { outline: none; }
.project:has(.project-title a:focus-visible) { outline: 2px solid var(--color-text); outline-offset: 4px; }
/* Desktop hover: the card under the pointer rises 4px above the lift of the project on screen, on a deeper
   shadow drawn by ::before (only its opacity animates). The green ring stays with the current project, so
   a hovered card that is not the current one rises without it. The movement is in the reduced-motion
   block; with reduced motion only the shadow changes. */
.project::before { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: var(--shadow-warm-hover); opacity: 0; pointer-events: none; }
@media (hover: hover) and (pointer: fine) {
  .project:hover::before { opacity: 1; }
}
.project-title .title-detail { display: block; margin-top: 8px; }
.project .project-title .title-detail { color: var(--color-subtitle); font-size: .8em; font-weight: 600; }
.project-description { grid-area: description; min-width: 0; align-self: start; padding: 0 0 24px 24px; }
.project-description p { font-size: 1rem; line-height: var(--leading-body); text-wrap: pretty; }
.project-media { grid-area: media; min-width: 0; align-self: start; }
/* These covers sit at the card's vertical center; the figures may overlap them (Yusen, 2026-10-29). */
:is(#goodwill, #yipai, #hive-five) .project-media { align-self: center; }
.media-panel { display: flex; align-items: center; justify-content: center; aspect-ratio: 16 / 10; padding: 24px; border-radius: var(--radius-lg); }
/* A cover with a white ground of its own opts in to .media-blend (the four posters in index.html and zh.html): multiplied
   onto the warm card, its white takes the card's colour instead of showing as a box, and a 2% lift turns a near-white
   ground into white first. The card isolates the blend. Never on the preview videos: browsers blend video unreliably.
   While a preview plays over its poster, the poster hides once the 300ms fade-in is done. */
.project:has(.media-blend) { isolation: isolate; }
.media-blend { filter: brightness(1.02); mix-blend-mode: multiply; }
.media-panel:has(.media-preview.is-ready) > .media-blend { visibility: hidden; transition: visibility 0s 300ms; }
.media-panel img { width: 100%; height: 100%; object-fit: contain; }
.media-panel--screen { padding: 16px; }
.media-panel--screen img { border-radius: var(--radius-sm); }
/* Muted previews stack on top of the still cover and fade in once playing (scripts/portfolio-home.js). */
.media-panel[data-preview-src] { display: grid; grid-template: minmax(0, 1fr) / minmax(0, 1fr); }
.media-panel[data-preview-src] > img, .media-preview { grid-area: 1 / 1; }
.media-preview { width: 100%; height: 100%; object-fit: contain; opacity: 0; }
.media-preview.is-ready { opacity: 1; }
.media-panel--screen .media-preview { border-radius: var(--radius-sm); }
/* Clickable Category's cover, poster and preview alike, is a phone that the frame crops at its bottom edge. Floating in
   its panel, the crop read as a hard line with a clipped shadow (2026-11-05), so the cover sits on the bottom of its box
   and fades out over its lowest fifth. The next-case card on the case pages does the same (styles/case-study.css). */
.media-panel[data-preview-src*="clickable-category"] > img, .media-panel[data-preview-src*="clickable-category"] > .media-preview { object-position: 50% 100%; -webkit-mask-image: linear-gradient(to bottom, #000 78%, transparent); mask-image: linear-gradient(to bottom, #000 78%, transparent); }
/* Captions under case-study figures. The home page's project cards carry none: the title, role and
   outcome beside the cover say what it is, and the cover's alt text describes it. */
.media-caption { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 16px; margin-top: 8px; color: var(--color-muted); font-size: .875rem; line-height: 1.5; }

/* Transparent PNG poses rise from the inner edge of the card's 2px highlight, independent of padding.
   The card itself stays unclipped for its shadow,
   focus ring and stretched link. Waist-up poses end through the torso. */
.project-figure { position: absolute; bottom: 2px; z-index: 1; aspect-ratio: var(--figure-ratio); overflow: hidden; pointer-events: none; }
.project-figure[hidden] { display: none; }
.project-figure img { position: absolute; left: var(--figure-left); top: var(--figure-top); display: block; width: var(--figure-width); max-width: none; height: auto; transform: translateY(103%); }
.project-figure.is-visible img { transform: translateY(0); }
/* Frame (64,175)-(1132,1180) in the original 1254px square. */
.goodwill-figure { --figure-ratio: 1068 / 1005; --figure-left: -5.992509%; --figure-top: -17.412935%; --figure-width: 117.415730%; }
/* Frame (18,134)-(1224,1180) in the 1254px square; preserve the jacket and crop the lower torso flat. */
.yipai-figure { --figure-ratio: 1206 / 1046; --figure-left: -1.492537%; --figure-top: -12.810707%; --figure-width: 103.980100%; }
/* Frame (9,43)-(1681,884) in the original 1683×935 group illustration. */
.hive-five-figure { --figure-ratio: 1672 / 841; --figure-left: -.538278%; --figure-top: -5.112961%; --figure-width: 100.657895%; }
/* Frame (174,11)-(1237,1180) in the original 1254px square; crop through the lower torso. */
.instagram-figure { --figure-ratio: 1063 / 1169; --figure-left: -16.368768%; --figure-top: -.940975%; --figure-width: 117.968015%; }
/* Frame (174,0)-(1017,1218) in the 1254px square. The transparent row at y=290
   separates the bulb from the pose, letting both use the original, unedited PNG. */
.flashcard-figure { --figure-ratio: 843 / 1218; --figure-left: -20.640569%; --figure-top: 0%; --figure-width: 148.754448%; }
.flashcard-pose { clip-path: inset(23.125997% 0 0); }
.flashcard-bulb { clip-path: inset(0 0 76.874003%); opacity: 0; }
.flashcard-figure.is-visible .flashcard-bulb { opacity: 1; }
.project-speech { position: absolute; z-index: 2; margin: 0; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-float); font-size: .875rem; line-height: 1.5; text-wrap: balance; pointer-events: none; visibility: hidden; opacity: 0; transform: translateY(6px) scale(.96); transform-origin: left center; }
.project-speech::after { content: ""; position: absolute; left: -6px; top: calc(50% - 5px); width: 10px; height: 10px; background: var(--color-surface); border-bottom: 1px solid var(--color-border); border-left: 1px solid var(--color-border); transform: rotate(45deg); }
.flashcard-figure.is-visible + .project-speech { visibility: visible; opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  .project-figure, .project-speech { display: none; }
}

@media (max-width: 1023px) {
  :root { --page-gutter: 32px; }
  .hero-layout { grid-template-columns: minmax(0, 1fr); gap: 24px; }
  .hero-title { max-width: 13em; font-size: clamp(2.25rem, 5.4vw, 4rem); }
  .hero-title:lang(en) { font-size: clamp(2.25rem, 5vw, 3rem); }
  .project { column-gap: 32px; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); }
}

@media (max-width: 767px) {
  /* Poses overlap preceding content, below the floating index (40), without changing card gaps. */
  .has-mobile-figures .project-figure { bottom: calc(100% - 2px); z-index: 30; }
  .project-speech { display: none; }
  .has-mobile-figures .project-speech { display: block; z-index: 31; word-break: keep-all; }
  /* Keep attention on the current case and its illustration. No overlay intercepts scrolling or links;
     keyboard focus restores any background section being used. When the footer enters view or there is
     no current case, all content returns to full opacity. */
  body:not(.is-footer-visible):has(#work > .project.is-current) :is(.home-hero, site-header, site-footer, #work > .project:not(.is-current)):not(:focus-within) { opacity: .28; }
  :root { --page-gutter: 24px; }
  .home-hero { padding-block: 32px; }
  .hero-greeting { font-size: 1rem; margin-bottom: 12px; }
  .hero-wave { height: 48px; margin-top: -34px; }
  /* Leave room above the index for its small pointing figure and dialogue, clear of the last career row.
     The rem portion also accommodates wrapped dialogue when visitors enlarge their text. */
  .has-mobile-figures .home-hero { padding-bottom: calc(3rem + 24px); }
  .hero-wave--nudge { margin: 0; }
  .hero-wave-bubble { padding: 6px 10px; font-size: .75rem; line-height: 1.4; white-space: normal; pointer-events: none; }
  .hero-layout { gap: 16px; }
  .hero-title, .hero-title:lang(en) { font-size: clamp(2.25rem, 7.6vw, 2.75rem); line-height: 1.2; }
  /* Below about 414px, 36px no longer fits "designer who drives" on one line, so the English statement
     scales with the screen (about 34px at 390, 31px at 360) and stays at four lines instead of five. */
  .hero-title:lang(en) { font-size: min(clamp(2.25rem, 7.6vw, 2.75rem), 8.7vw); }
  .hero-career { --career-year: 60px; --career-gap: 10px; --career-end: 0px; --career-row-gap: 10px; margin-top: 8px; }
  .hero-career .mono { letter-spacing: .02em; }
  .section-heading { padding-top: 20px; margin-bottom: 24px; }
  /* The row is the 28px index button here, and the section's 20px top padding is less than the card's pad:
     the pad becomes the top padding and the card starts right under the rule. The card sits 16px above
     the first project, like the cards between themselves (enough for a current card's 4px rise), and the
     row moves up 12px so the first project keeps its place. */
  .section-heading--index { --row-height: 28px; --card-top: 0px; --bar-inset: 20px; padding-top: var(--card-pad); margin-top: -12px; margin-bottom: 16px; }
  /* Keep the chevron 8px wide, including its border, even when the current label truncates. */
  .work-index-toggle::after { box-sizing: border-box; flex-shrink: 0; width: 8px; height: 8px; }
  /* Align labels with the toggle's text: 8px chevron + two 6px gaps, less the list's 7px inset.
     Links keep their full-row tap targets. */
  .work-index.is-compact.is-open ol a { justify-content: flex-end; padding-inline-end: calc(var(--bar-inset) + 13px); text-align: right; }
  .project { --card-lift: 4px; --pulse-bleed: 16px; grid-template-columns: minmax(0, 1fr); grid-template-areas: "heading" "media" "description"; gap: 16px; margin-bottom: 16px; padding: 20px; }
  .project-heading, .project-description { padding: 0; }
  .project-category { margin-bottom: 12px; }
  .project-title { font-size: 1.5rem; }
  .project-title .title-detail { margin-top: 4px; }
  .media-panel, .media-panel--screen { padding: 0; }
  .project-description p { font-size: 1rem; }
}
@media (max-width: 360px) {
  :root { --page-gutter: 20px; }
  .hero-title { font-size: 2.25rem; }
  .hero-career { --career-gap: 8px; }
}
@media (prefers-reduced-motion: no-preference) {
  a, button { transition: border-color 160ms ease, background-color 160ms ease; }
  .flow-field.is-live { transition: opacity 1200ms ease; } /* fade in only; a lost context hides at once */
  /* Page entrance (Yusen's idea, 2026-10-21): the header is simply there; the greeting and the statement
     come in from the left, the career rows from the right, then the index card and the work section rise
     together. Unhurried (Yusen found a 1.1s version too quick): each piece takes 1s on an expo-out
     curve that starts fast and settles slowly, the intro pieces are 160-260ms apart, the career rows arrive one by one, the work section
     rises 110ms after the last row, and it is all in by about 2s. 24px of travel. The end state is not kept, so nothing stays
     transformed afterwards (the sticky index and the figures' layers are unaffected). */
  .hero-greeting, .hero-title, .hero-career > li > *, #work { animation: enter-right 1000ms cubic-bezier(.16, 1, .3, 1) 100ms backwards; }
  .hero-title { animation-delay: 260ms; }
  /* The rows' contents (year, logo, text) slide in, not the rows themselves, so the connector segments drawn on
     each row (li::before/::after) stay put. */
  .hero-career > li > * { animation-name: enter-left; animation-delay: 520ms; }
  .hero-career > li:nth-child(2) > * { animation-delay: 610ms; }
  .hero-career > li:nth-child(3) > * { animation-delay: 700ms; }
  .hero-career > li:nth-child(4) > * { animation-delay: 790ms; }
  /* The connector still draws top to bottom from 760ms: one half-segment after another (six for four rows,
     117ms each, about 700ms in all), each revealed with clip-path so it never crosses a logo while drawing. */
  .hero-career > li:nth-child(2) { --i: 1; }
  .hero-career > li:nth-child(3) { --i: 2; }
  .hero-career > li:nth-child(4) { --i: 3; }
  .hero-career > li:nth-child(5) { --i: 4; }
  .hero-career > li:nth-child(6) { --i: 5; }
  .hero-career li::before { --seg: calc(var(--i, 0) * 2 - 1); }
  .hero-career li::after { --seg: calc(var(--i, 0) * 2); }
  .hero-career li::before, .hero-career li::after { animation: career-line 117ms linear both; animation-delay: calc(760ms + var(--seg) * 117ms); }
  #work { animation-name: enter-up; animation-delay: 900ms; }
  /* On phones everything stacks in one column, so everything rises (Yusen, 2026-10-21); same timing. */
  @media (max-width: 767px) {
    .hero-greeting, .hero-title, .hero-career > li > * { animation-name: enter-up; }
  }
  /* Waving figure: it rises from behind the greeting's baseline (640ms) once the greeting has landed,
     then waves once (1500ms) and stays still, about 2.7s after the page appears. The wave starts while the rise is still settling,
     so the landing runs into the wind-up. Every part of the wave runs on the same 1500ms timeline with
     the same delay, so the offsets between forearm, hand and head live in the keyframes, and a replay
     only has to restart the animations together (scripts/portfolio-home.js). */
  .hero-wave .wave-figure { animation: wave-rise 640ms 700ms both; }
  /* The 700ms wait is for the page entrance only. When the greeting figure comes back later (phones, scrolled
     back above the index; the script marks it is-back), it rises as soon as its bow is taken off. */
  .hero-wave.is-back .wave-figure { animation-delay: 250ms; }
  .hero-wave :is(.wave-arm, .wave-hand, .wave-head, .wave-eyes, .wave-mouth, .wave-eyes-glad, .wave-mouth-glad, .wave-spark) { animation: 1500ms ease-in-out 1170ms both; }
  .hero-wave .wave-arm { animation-name: wave-arm; }
  .hero-wave .wave-hand { animation-name: wave-hand; }
  .hero-wave .wave-head { animation-name: wave-head; }
  .hero-wave :is(.wave-eyes, .wave-mouth) { animation-name: wave-face-rest; }
  .hero-wave :is(.wave-eyes-glad, .wave-mouth-glad) { animation-name: wave-face-glad; }
  .hero-wave .wave-spark--a { animation-name: wave-spark-a; }
  .hero-wave .wave-spark--b { animation-name: wave-spark-b; }
  .hero-wave .wave-spark--c { animation-name: wave-spark-c; }
  /* The copy below the statement points down instead of waving. The forearm in front of the card drops
     into place while the body rises, so the figure arrives already pointing. Then one shared 1600ms
     timeline, which starts once it stands (the script pops the bubble at that moment): the forearm
     swings at the elbow like a pendulum, out, in and out again, the fingertip drawing an arc over the
     card, the eyes on the hand for the first swing and a grin for the rest. Each spark pops once, when
     the finger first reaches its end of the arc. A replay (on hover, and as the script's reminder) runs
     the same timeline again; the arm dropping into place and the sparks' pop are not replayed. */
  /* The copy's own rise keeps its 250ms start: the greeting figure's later start belongs to the page
     entrance, and the forearm in front of the card (point-drop) must come down with the body. */
  .hero-wave--nudge .wave-figure { animation-delay: 250ms; }
  .hero-wave--nudge .point-drop { animation: point-drop 640ms ease-in-out 250ms both; }
  .hero-wave--nudge .point-pop { animation: point-pop 160ms ease-out 1030ms both; }
  .hero-wave--nudge .point-pop--b { animation-delay: 1430ms; }
  .hero-wave--nudge :is(.wave-head, .wave-eyes, .wave-mouth, .wave-eyes-glad, .wave-mouth-glad, .wave-spark, .point-gaze, .point-elbow) { animation: 1600ms ease-in-out 950ms both; }
  .hero-wave--nudge .wave-head { animation-name: point-head; }
  .hero-wave--nudge .point-gaze { animation-name: point-gaze; }
  .hero-wave--nudge :is(.wave-eyes, .wave-mouth) { animation-name: point-face-rest; }
  .hero-wave--nudge :is(.wave-eyes-glad, .wave-mouth-glad) { animation-name: point-face-glad; }
  .hero-wave--nudge .point-elbow { animation-name: point-elbow; }
  .hero-wave--nudge .point-spark--a { animation-name: point-spark-a; }
  .hero-wave--nudge .point-spark--b { animation-name: point-spark-b; }
  /* The figure's move: bowing out is a small hop, then a lean while dropping behind the line (it replaces
     the rise on that element, so taking the class off makes the figure rise again); the bubble grows
     out of its tail. */
  .hero-wave.is-bowing .wave-figure { animation: wave-bow 450ms ease-in both; }
  .hero-wave--nudge.is-bowing .point-over { opacity: 0; transition: opacity 180ms ease-in; }
  .project-figure img { transition: transform 280ms ease-in; }
  .project-figure.is-visible img { transition: transform 400ms cubic-bezier(.2, .75, .25, 1); }
  .flashcard-figure .flashcard-bulb { transition: transform 280ms ease-in, opacity 120ms ease-out; }
  .flashcard-figure.is-visible .flashcard-bulb { transition: transform 400ms cubic-bezier(.2, .75, .25, 1), opacity 200ms ease-out 450ms; }
  .project-speech { transition: opacity 120ms ease-out, transform 120ms ease-out, visibility 0s 120ms; }
  .flashcard-figure.is-visible + .project-speech { transition: opacity 220ms ease-out 760ms, transform 260ms cubic-bezier(.3, 1.4, .5, 1) 760ms, visibility 0s 760ms; }
  .hero-wave-bubble { animation: wave-bubble 260ms cubic-bezier(.3, 1.4, .5, 1) both; }
  .media-preview { transition: opacity 300ms ease; }
  .project { transition: transform 600ms ease-in-out; }
  .project::after { transition: opacity 600ms ease-in-out; }
  .pulse-border.is-live { transition: opacity 700ms ease-out; }
  .project.is-current { transform: translateY(calc(-1 * var(--card-lift))); }
  .work-index a { transition: color 160ms ease; }
  .work-index a::before { transition: opacity 160ms ease; }
  .section-heading--index { transition: transform var(--index-morph); }
  .section-heading--index::before { transition: box-shadow var(--index-morph); }
  .project::before { transition: opacity 200ms ease-out; }
  @media (hover: hover) and (pointer: fine) {
    .project:hover { transform: translateY(calc(-1 * (var(--card-lift) + 4px))); transition-duration: 200ms; transition-timing-function: ease-out; }
  }
  @media (max-width: 767px) {
    body:has(.home-hero) :is(.home-hero, site-header, site-footer) { transition: opacity 320ms ease-in-out; }
    .project { transition: transform 600ms ease-in-out, opacity 320ms ease-in-out; }
    /* Preserve the card lift without trapping the PNG in a transform stacking context. The floating
       index stays above it; relative top does not change document flow or the 16px card spacing. */
    .has-mobile-figures .project { top: 0; transform: none; transition: top 600ms ease-in-out, opacity 320ms ease-in-out; }
    .has-mobile-figures .project.is-current { top: calc(-1 * var(--card-lift)); }
    @media (hover: hover) and (pointer: fine) {
      .has-mobile-figures .project:hover { top: calc(-1 * (var(--card-lift) + 4px)); transition-duration: 200ms, 320ms; transition-timing-function: ease-out, ease-in-out; }
    }
  }
}
@keyframes career-line { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0); } }
/* Page entrance: fade plus 24px of travel; named for the direction of travel. */
@keyframes enter-right { from { opacity: 0; transform: translateX(-24px); } to { opacity: 1; transform: none; } }
@keyframes enter-left { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }
@keyframes enter-up { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: none; } }
/* Rise: a fast climb that overshoots by 7 units and stretches 3%, dips 2 units and squashes, then settles. */
@keyframes wave-rise {
  0% { transform: translateY(205px); animation-timing-function: cubic-bezier(.2, .7, .3, 1); }
  55% { transform: translateY(-7px) scaleY(1.03); animation-timing-function: ease-in-out; }
  78% { transform: translateY(2px) scaleY(.985); animation-timing-function: ease-in-out; }
  100% { transform: translateY(0) scaleY(1); }
}
/* Forearm, turning at the elbow: a 110ms wind-up outwards, then six swings of about 200ms each (2.5 round trips a second) that die down. It swings further out than in, so the hand stays clear of the face. */
@keyframes wave-arm {
  0%, 100% { transform: rotate(0deg); }
  7.3% { transform: rotate(4deg); }
  20.7% { transform: rotate(-9deg); }
  34% { transform: rotate(13deg); }
  47.3% { transform: rotate(-8deg); }
  60.7% { transform: rotate(11deg); }
  74% { transform: rotate(-5deg); }
  86% { transform: rotate(4deg); }
}
/* Hand, turning at the wrist: it trails the forearm, so its keyframes fall halfway between the
   forearm's. It is bent furthest back mid-swing and straight at each end. */
@keyframes wave-hand {
  0%, 100% { transform: rotate(0deg); }
  14% { transform: rotate(12deg); }
  27.3% { transform: rotate(-14deg); }
  40.7% { transform: rotate(14deg); }
  54% { transform: rotate(-12deg); }
  67.3% { transform: rotate(10deg); }
  80% { transform: rotate(-7deg); }
  93% { transform: rotate(3deg); }
}
/* Head, turning at the neck: it leans towards the hand and sways a little with each swing. */
@keyframes wave-head {
  0%, 100% { transform: rotate(0deg); }
  14% { transform: rotate(5deg); }
  34% { transform: rotate(3.5deg); }
  47.3% { transform: rotate(5.5deg); }
  60.7% { transform: rotate(4deg); }
  74% { transform: rotate(5deg); }
  88% { transform: rotate(1.5deg); }
}
/* The resting face and the laughing face swap without a fade. */
@keyframes wave-face-rest {
  0%, 11.9%, 86.1%, 100% { opacity: 1; }
  12%, 86% { opacity: 0; }
}
@keyframes wave-face-glad {
  0%, 11.9%, 86.1%, 100% { opacity: 0; }
  12%, 86% { opacity: 1; }
}
/* Sparkles: hidden while the figure rises, then each pops in turn (180ms apart), twinkles once and
   settles at full size, which is also how they look when nothing moves. */
@keyframes wave-spark-a {
  0%, 14% { transform: scale(0) rotate(-40deg); }
  30% { transform: scale(1.25) rotate(8deg); }
  48% { transform: scale(.5) rotate(0deg); }
  66% { transform: scale(1.15) rotate(10deg); }
  100% { transform: scale(1) rotate(0deg); }
}
@keyframes wave-spark-b {
  0%, 26% { transform: scale(0) rotate(40deg); }
  42% { transform: scale(1.2) rotate(-8deg); }
  60% { transform: scale(.45) rotate(0deg); }
  78% { transform: scale(1.1) rotate(-10deg); }
  100% { transform: scale(1) rotate(0deg); }
}
@keyframes wave-spark-c {
  0%, 38% { transform: scale(0) rotate(-40deg); }
  54% { transform: scale(1.2) rotate(8deg); }
  72% { transform: scale(.45) rotate(0deg); }
  90% { transform: scale(1.1) rotate(6deg); }
  100% { transform: scale(1) rotate(0deg); }
}
/* Bow out: a small hop (anticipation), then a lean forward while dropping behind the line. */
@keyframes wave-bow {
  0% { transform: none; }
  28% { transform: translateY(-8px) rotate(-4deg); }
  100% { transform: translateY(214px) rotate(12deg) scaleY(.88); }
}
@keyframes wave-bubble {
  from { opacity: 0; transform: scale(.6); }
  to { opacity: 1; transform: none; }
}
/* The forearm in front of the card comes down from the card's edge while the body comes up behind it,
   is in place before the body has settled, and then rides the body's little bounce (the same moments
   as wave-rise), so the two halves of the arm stay joined at the edge. */
@keyframes point-drop {
  0% { transform: translateY(-78px); animation-timing-function: cubic-bezier(.2, .7, .3, 1); }
  40% { transform: translateY(-5px); }
  55% { transform: translateY(-7px); }
  78% { transform: translateY(2px); }
  100% { transform: translateY(0); }
}
/* Forearm, turning at the elbow, which stays on the card's edge: a pendulum of 15 degrees to each side.
   Out (away from the body), in, out again a little less far, then back to rest a touch past the middle,
   every swing eased at both ends. The hand is rigid with the forearm, so the fingertip draws an arc;
   nothing changes length. */
@keyframes point-elbow {
  0%, 100% { transform: rotate(0deg); }
  15% { transform: rotate(-15deg); }
  40% { transform: rotate(15deg); }
  65% { transform: rotate(-12deg); }
  88.8% { transform: rotate(4deg); }
}
/* Head: it leans towards the hand as the hand swings out, and comes back as it swings in. */
@keyframes point-head {
  0%, 94.8%, 100% { transform: none; }
  15% { transform: translateY(2px) rotate(3deg); }
  40% { transform: translateY(1px) rotate(.5deg); }
  65% { transform: translateY(1.5px) rotate(2deg); }
}
/* Pupils: on the hand for the first swing, then back to the visitor. */
@keyframes point-gaze {
  0%, 24%, 100% { transform: none; }
  7%, 19% { transform: translate(2.5px, 3px); }
}
/* The grin comes on as the hand swings back in, and stays until the arm comes to rest. */
@keyframes point-face-rest {
  0%, 24.9%, 92.9%, 100% { opacity: 1; }
  25%, 92.8% { opacity: 0; }
}
@keyframes point-face-glad {
  0%, 24.9%, 92.9%, 100% { opacity: 0; }
  25%, 92.8% { opacity: 1; }
}
/* Sparks, one beyond each end of the arc. Each pops once, as the finger first comes its way; on this
   timeline it only twinkles when the finger reaches its end of the first two swings, so a replay never
   makes it vanish. At full size is also how they look when nothing moves. */
@keyframes point-pop {
  from { transform: scale(0) rotate(-40deg); }
  to { transform: scale(1) rotate(0deg); }
}
@keyframes point-spark-a {
  0%, 11%, 25%, 100% { transform: scale(1) rotate(0deg); }
  15% { transform: scale(1.3) rotate(10deg); }
  19% { transform: scale(.8) rotate(0deg); }
}
@keyframes point-spark-b {
  0%, 36%, 50%, 100% { transform: scale(1) rotate(0deg); }
  40% { transform: scale(1.3) rotate(-10deg); }
  44% { transform: scale(.8) rotate(0deg); }
}
