/* Shared across every page: design tokens, fonts, hero, work, modal and footer.
   Page-specific rules live in that page's own <style> block. */
/* ---------- Fonts ---------- */
@font-face{font-family:'Kyoto';src:url('assets/fonts/PPKyoto-LightItalic.otf') format('opentype');font-weight:300;font-style:italic;font-display:swap}
@font-face{font-family:'Kyoto';src:url('assets/fonts/PPKyoto-Medium.otf') format('opentype');font-weight:500;font-style:normal;font-display:swap}
/* The medium italic was in the folder all along with no face pointing at it, so
   every title asking for 500 + italic — the name on the index, Hello world!, the
   case study titles — was being resolved to the medium upright and slanted by the
   browser. This is the real cut. */
@font-face{font-family:'Kyoto';src:url('assets/fonts/PPKyoto-MediumItalic.otf') format('opentype');font-weight:500;font-style:italic;font-display:swap}
@font-face{font-family:'Diatype';src:url('assets/fonts/ABCDiatype-Regular.otf') format('opentype');font-weight:400;font-display:swap}
@font-face{font-family:'Diatype';src:url('assets/fonts/ABCDiatype-Bold.otf') format('opentype');font-weight:700;font-display:swap}
@font-face{font-family:'ApercuMono';src:url('assets/fonts/ApercuMono-Regular.otf') format('opentype');font-weight:400;font-display:swap}
@font-face{font-family:'ApercuMonoPro';src:url('assets/fonts/ApercuMonoPro-Medium.ttf') format('truetype');font-weight:500;font-display:swap}
@font-face{font-family:'Mackinac';src:url('assets/fonts/P22Mackinac-Book.otf') format('opentype');font-weight:400;font-display:swap}
@font-face{font-family:'Mackinac';src:url('assets/fonts/P22Mackinac-Medium.otf') format('opentype');font-weight:500;font-display:swap}
@font-face{font-family:'Mackinac';src:url('assets/fonts/P22Mackinac-Bold.otf') format('opentype');font-weight:700;font-display:swap}
@font-face{font-family:'Monument';src:url('assets/fonts/MonumentGrotesk-Bold.otf') format('opentype');font-weight:700;font-display:swap}

/* ---------- Tokens ---------- */
:root{
  --parchment:#FDFBEF; --antique-gold:#A7A58F; --persimmon:#F2511A; --outline-stroke:#E3E1CC;
  --dove-ivory:#F5F2E5; --new-leaf-green:#E4F56F; --dusty-granite:#423E3D; --cream:#FFFCE3;
  --hero-green:#C8D665; --tag-green:#649F25; --muted:#878676; --sage:#B5C7B9;
  --nougat:#F9F3E2; --disabled-gray:#AEAEAC;
  --butter:#FFEDBC;   /* About: job descriptions and the "at" in each role */
  --herb-green:#749F25;   /* About footer ground. Not --tag-green (#649F25) */
  /* About footer, sampled from Figma 358:1299. It is dark-on-light: a pale
     ground with slate type, not the light-on-dark it was built as. */
  --pear:#E2F085; --slate:#78808F; --moss:#8D9934;
  --ink-blue:#1F597B;   /* Library footer ground, Figma 358:1253; also the Microsoft chip */
  /* Mini-project company chips, Figma 454:1141. WhatsApp reuses --tag-green and
     Microsoft --ink-blue, both already at the exact hex the design specifies. */
  --co-complete:#8460C6; --co-flo-recruit:#D96797; --co-ballertv:#6889E1; --co-nike:#EB804E;
  /* Payments Home only. Named for the case study, not the site: another case study
     should define its own pair rather than inherit these.

     Hue 62, a few degrees off the palette's 44-55 rather than a different temperature:
     the whole site is warm, and a sage/mint green (hue 132) read as cool and clashed
     however well its chroma behaved. The fix for the original problem was never the
     hue -- it was the saturation. The old thumbnail measured chroma 0.353 against
     0.055-0.110 for every other surface; these sit just above --cream. */
  --pay-wash:#F8F9DD;   /* hero field */
  --pay-field:#F0F1C6;  /* card thumbnail field */
  /* Payment Requests only, under the same rule as the pair above.

     Figma 750:1788 specifies #D7D9DC, which is near-achromatic: OKLCh chroma 0.005,
     against 0.025-0.037 for every other surface here and 0.016 for the cream page
     itself. Beside a cream that carries colour, a true grey reads as dinginess
     rather than as a choice, and that is what made the hero look dull. The fault was
     never the hue, which was already blue at 258.

     Hue 238 is --ink-blue's hue exactly, carried up to the frame's own lightness, so
     this is an existing brand hue at a new lightness rather than a new colour.
     Lightness and page contrast (1.36) are unchanged from the Figma value, so the
     hero holds the same weight against the devices sitting on it. */
  --pr-wash:#C8DCEB;   /* hero field */
  --pr-edge:#BCD7E9;   /* hero hairline */
  /* Copilot, under the same rule again. A first pass sat at OKLCh hue 315, which is
     magenta rather than violet, and at chroma .045 it was the most saturated field on
     the site. This is hue 291 -- between --pr-wash's blue at 240 and purple proper --
     carried at chroma .032, inside the .033 that --cream measures, and at lightness
     .952 so it sits nearer --pay-wash's paleness than --pr-wash's weight. */
  --cp-wash:#EFECFF;   /* hero field */
  --cp-edge:#E9E6FF;   /* hero hairline */
}

/* ---------- Reset ---------- */
*{margin:0;padding:0;box-sizing:border-box}
/* A bare <a> only gets the hand cursor when it has an href, so the placeholder
   links (href="#", and the mail icon before script fills it in) were showing an
   arrow. State it outright for anything clickable rather than per-component.
   The footer sets its own plant cursor, so it is left alone. */
a,button,summary,label[for],[role="button"]{cursor:pointer}
button:disabled{cursor:default}
html,body{background:var(--parchment)}
body{overflow-x:hidden;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
img{display:block}
p{margin:0}

/* ---------- Scaled document below the hero (work + footer, authored at 1440px) ---------- */
#stage{position:absolute;left:0;width:1440px;transform-origin:top left;z-index:1}
section{position:relative;width:1440px}

/* Footer colour bleeds full-width behind the (capped, centred) footer on wide screens. */
#bg-footer{position:absolute;left:0;width:100%;z-index:0;background:var(--persimmon)}

/* ================= HERO ================= */
/* Full-viewport cover. Its three clusters anchor to the left / centre / right
   edges at a single uniform scale (set in JS) so nothing is clipped, the side
   margins stay tight, and every element keeps its Figma proportions. */
/* The height is also set from script as 861 * min(vw/1440, vh/861, 1). Stating the same
   formula in CSS means the hero already occupies the viewport on first paint; without
   it the section is zero-height until the layout script runs, the work grid renders at
   the top of a parchment page, and a cold load flashes bright before the dark hero
   appears. Script still writes the exact pixel value afterwards. */
#hero{position:absolute;top:0;left:0;width:100%;height:min(59.79vw,100vh,861px);
  overflow:hidden;background:var(--parchment);z-index:2;
  border-bottom-left-radius:80px;border-bottom-right-radius:80px}
/* Hi-res forest (blur + grain baked in); 91% over the parchment frame matches Figma's tone. */
/* The placeholder colour lives on the IMG, not on #hero. hero-bg.webp is 1.29MB, so on
   a cold load the hero paints before it arrives -- and #hero's background is parchment,
   which meant a full-viewport near-white flash where a dark image was about to land.
   Putting the image's own mean tone (#1B3922) on the element means the pre-load paint
   composites to the same colour as the loaded image: .91 x #1B3922 over parchment is
   what you already see. So the flash goes without the settled design shifting at all. */
#hero-bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:.91;
  background:#1B3922}
.hgroup{position:absolute;top:0;width:1440px;height:861px;transform-origin:top left;pointer-events:none}
/* The centre cluster is the menu, and the menu sticks. Fixed rather than absolute so
   it stays with the reader down the page; the left and right clusters belong to the
   hero and scroll away with it. #hero carries no transform, so a fixed child here is
   positioned against the window and is not clipped by the hero's rounded overflow. */
#hg-center{position:fixed;z-index:50}
/* Re-enable pointer events only on interactive content (the full-size hgroup boxes
   overlap, so they'd otherwise block hover/clicks on the layers beneath). */
.hgroup a,.hgroup button,.clawed-hero{pointer-events:auto}

/* Figma 250:119. Every child shares a 572px measure, and the name is 80px -- at that
   size it sits inside the measure instead of overhanging it, which is what the block
   was missing. Gaps: 8px name -> location, 24px location -> bio, 24px inside the
   location row. */
.name-block{position:absolute;left:44px;top:32px;width:572px}
.name{font-family:'Kyoto';font-weight:500;font-style:italic;font-size:80px;line-height:1.3;color:var(--parchment)}
.loc{display:flex;align-items:center;gap:24px;margin-top:8px}
.loc .dot{width:12px;height:12px;border-radius:50%;background:var(--persimmon);flex:none}
.loc p{width:572px;font-family:'ApercuMono';font-size:20px;line-height:1.3;color:var(--parchment);white-space:pre-wrap}
.loc .wa{position:relative;font-family:'Monument';font-weight:700;color:var(--hero-green);
  text-decoration:none}
.bio{margin-top:24px;width:572px;font-family:'Diatype';font-weight:400;font-size:20px;line-height:1.5;color:var(--parchment)}



/* Nav pill — matches Figma node 227:687: no fill, background blur, and a directional
   gradient stroke (bright top-left ~0.6, second catch bottom-right ~0.47, faint between). */
/* Trimmed from the Figma's 411x81 / 25-49-24-48: the padding comes down to 42 all
   round and the height 81 -> 74. The width is left to the content and the pill is
   centred on 718.5 from its own middle, so a longer label (Home -> Index, or
   Library on that page) lengthens the pill evenly instead of pushing the row off
   centre inside a fixed 398. */
/* The pill is exactly as wide as its contents. It was pinned to one width for a
   while, so that no icon moved between pages, but a fixed box and a centred row left
   21px of dead air after the shorter labels -- "Index" and "About" floated away from
   the separator while "Library" sat snug against it. The padding is 42 on every page
   again, which is what the design asks for and what actually looks even.

   The pill therefore changes width between pages: 399.48 / 424.03 / 404.23. That is
   handled rather than hidden -- it carries a view-transition-name, so the browser
   grows and shrinks it between the two sizes instead of cutting. See PAGE MOTION. */
.nav{position:absolute;left:718.5px;top:746px;transform:translateX(-50%);
  height:74px;border-radius:28px;
  display:flex;align-items:center;padding:21px 42px 21px 42px;
  background:transparent;
  -webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}
/* Stroke fades to nothing at the bottom-left and top-right corners: a 45deg gradient
   (BL->TR axis) transparent at both ends, bright through the middle (top-left brightest). */
.nav::before{content:"";position:absolute;inset:0;border-radius:inherit;padding:1px;pointer-events:none;
  background:linear-gradient(45deg,
    rgba(255,255,255,0) 3%,
    rgba(255,255,255,0.60) 18%,
    rgba(255,255,255,0.30) 50%,
    rgba(255,255,255,0.47) 82%,
    rgba(255,255,255,0) 97%);
  -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);
  -webkit-mask-composite:xor;mask-composite:exclude}
.nav-inner{display:flex;align-items:center;gap:36px}
.nav-home{display:flex;align-items:center;gap:12px}
.nav-home .label{font-family:'Kyoto';font-weight:300;font-style:italic;font-size:24px;line-height:1.3;color:var(--parchment)}
.nav-sep{width:1px;height:24px;background:#AEAEAC}
.nav-icons{display:flex;align-items:center;gap:24px}
.nav-home svg{color:var(--persimmon)}
.nav-icons svg{color:#AEAEAC}
.nav a{display:flex;color:inherit;text-decoration:none;position:relative}
/* Hover preview above each icon (Figma 460:142): persimmon pill, 12x8, r8, cream
   Apercu Mono 14/1.3, never wrapping. Built from ::after rather than a real element
   so every link gets one from its data-tip alone, and the pill sits centred over the
   icon: left 50% then -50% of its own width, which is the only way to centre
   something whose width depends on its text. */
.nav a[data-tip]::after{
  content:attr(data-tip);
  position:absolute;left:50%;bottom:calc(100% + 33px);transform:translateX(-50%) translateY(4px);
  background:var(--persimmon);color:var(--cream);border-radius:8px;padding:8px 12px;
  font-family:'ApercuMono';font-weight:400;font-size:14px;line-height:1.3;
  white-space:nowrap;pointer-events:none;opacity:0;
  transition:opacity .16s ease,transform .16s cubic-bezier(.22,.8,.3,1)}
/* The offset clears the glass pill rather than the icon, so it moves whenever the
   nav's padding does. The row is 32 tall inside the padding, so a 32px icon starts at
   21 and a 28px one is centred at 23; 33 and 35 therefore put every preview on one
   line, 12px clear of the pill.

   Keyed to the icon's SIZE, not its position. It used to be nth-child(n+2), which
   only held while the first icon happened to be the 32px one -- moving copy-email to
   the end of every menu broke that assumption on all three pages at once. Where
   :has() is unavailable the 28px previews sit 2px low, which is the whole cost. */
.nav-icons a:has(svg[width="28"])[data-tip]::after{bottom:calc(100% + 35px)}
.nav a[data-tip]:hover::after,
.nav a[data-tip]:focus-visible::after{opacity:1;transform:translateX(-50%) translateY(0)}
@media (prefers-reduced-motion:reduce){
  .nav a[data-tip]::after{transition:none;transform:translateX(-50%)}
}
/* The sticky menu stands down as the footer arrives. The footer carries the same
   three links, so nothing is lost — and the pill is glass, which all but disappears
   against a pale footer anyway. Same behaviour the case studies give their chrome.
   pointer-events rather than visibility on the index, because its entrance animation
   already owns opacity there and a visibility switch would snap mid-fade. */
/* Off the photograph, onto parchment. The pill is transparent with a white gradient
   stroke and a backdrop blur, which is right over an image and reads as nothing at
   all over the work section — the label is parchment on parchment. It takes a real
   surface there. This is the same treatment, and the same reason, as the bottom bar
   on a phone; script sets the class when the pill clears the page's ground. */
.nav{transition:background .25s ease,border-color .25s ease,box-shadow .25s ease}
.nav.on-light{background:rgba(253,251,239,.94);
  border:1px solid var(--outline-stroke);
  box-shadow:0 6px 22px rgba(66,62,61,.16)}
.nav.on-light::before{display:none}
.nav.on-light .nav-home .label{color:var(--dusty-granite)}
@media (prefers-reduced-motion:reduce){.nav{transition:none}}

#hg-center.is-away,#ab-navwrap.is-away,#lb-navwrap.is-away{pointer-events:none}
/* No longer gated on .hero-in. That gate made sense while #hg-center started at
   opacity 0 and only existed once the hero had arrived; now that the pill is visible
   from the first frame, standing down at the footer has to work from the first frame
   too. Same rule as the other two navwraps below. */
#hg-center.is-away{opacity:0}
#ab-navwrap,#lb-navwrap{transition:opacity .3s ease}
#ab-navwrap.is-away,#lb-navwrap.is-away{opacity:0}
@media (prefers-reduced-motion:reduce){#ab-navwrap,#lb-navwrap{transition:none}}

/* Hover: menu buttons turn persimmon-red */
.nav a svg,.nav-home .label{transition:color .15s ease}
.nav a:hover svg{color:var(--persimmon)}
.nav-home a:hover ~ .label{color:var(--persimmon)}

/* Postcard / ticket (right) */
.postcard-top{position:absolute;left:1061px;top:74.25px;width:300px;height:644.5px}
.postcard-bottom{position:absolute;left:1061px;top:739px;width:300px;height:122px}
.pc-label{position:absolute;left:1074px;top:99px;font-family:'ApercuMonoPro';font-weight:500;font-size:14px;line-height:1.3;color:var(--antique-gold)}
.pc-dots{position:absolute;left:1074px;top:114px;width:283px;font-family:'Kyoto';font-weight:300;font-style:italic;font-size:12px;line-height:1.3;color:var(--antique-gold);overflow:hidden;height:16px}
.pc-photo{position:absolute;left:1074px;width:275px;height:206px;border-radius:4px;object-fit:cover}
.pc-photo.one{top:134px}
.pc-photo.two{top:352px}
.pc-caps{position:absolute;left:1074px;top:594px;width:280px}
/* Never wraps. A pairing whose title and artist do not fit is stepped down to size
   by script instead — the name is not negotiable, so the type gives way. */
.cap-title{font-family:'Kyoto';font-weight:500;font-size:16px;line-height:1.4;color:var(--dusty-granite);
  white-space:nowrap}
/* Was .cap-date, when the line held only one. It carries the maker and the date
   now — the artist moved down here off the title line, which is what let the
   title fit without losing the name. */
.cap-info{font-family:'ApercuMonoPro';font-weight:500;font-size:14px;line-height:1.4;color:var(--antique-gold);margin-top:2px}
.cap-dots{font-family:'Kyoto';font-weight:300;font-style:italic;font-size:12px;line-height:1.3;color:var(--antique-gold);height:16px;overflow:hidden;margin:2px 0}
.pc-print{position:absolute;left:1151px;top:760px;font-family:'ApercuMonoPro';font-weight:500;font-size:14px;line-height:1.4;color:var(--antique-gold);white-space:nowrap;cursor:pointer;pointer-events:auto;transition:color .35s ease}
.pc-btn{position:absolute;left:1180px;top:787px;width:62px;height:62px;border-radius:50%;background:var(--parchment);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;transition:background .35s ease,transform .4s cubic-bezier(.34,1.56,.64,1)}
/* rotating dashed "perforation" ring */
.pc-btn::before{content:"";position:absolute;inset:0;border-radius:50%;border:2px dashed var(--antique-gold);animation:pcSpin 16s linear infinite;transition:border-color .35s ease,transform .4s ease;will-change:transform}
.pc-btn svg{color:var(--persimmon);position:relative;z-index:1;transition:color .35s ease,transform .5s cubic-bezier(.34,1.56,.64,1)}
/* hover: fill, spring-scale, spin the plus, brighten the ring */
#hg-right:has(.pc-btn:hover) .pc-print,#hg-right:has(.pc-print:hover) .pc-print{color:var(--persimmon)}
#hg-right:has(.pc-print:hover) .pc-btn,.pc-btn:hover{background:var(--persimmon);transform:scale(1.1)}
#hg-right:has(.pc-print:hover) .pc-btn::before,.pc-btn:hover::before{border-color:var(--parchment);animation-duration:5s}
#hg-right:has(.pc-print:hover) .pc-btn svg,.pc-btn:hover svg{color:var(--parchment);transform:rotate(90deg)}
.pc-btn:active{transform:scale(1.02)}
@keyframes pcSpin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){
  .pc-btn::before{animation:none}
}

/* Rabbit + bubble */
.clawed-hero{position:absolute;left:16px;top:461px;width:400px;height:400px;object-fit:cover;cursor:pointer}
/* Sparkle motifs (real pixels lifted from the image) — cycle one at a time, looping */
.spk-layer{position:absolute;left:16px;top:461px;width:400px;height:400px;object-fit:cover;pointer-events:none;
  opacity:0;animation:2.4s infinite}
#spk1{animation-name:spk1}
#spk2{animation-name:spk2}
#spk3{animation-name:spk3}
/* Cumulative build: 1 appears, then 2, then 3; brief hold; all clear; loop. */
@keyframes spk1{0%,4%{opacity:0}10%,80%{opacity:1}88%,100%{opacity:0}}
@keyframes spk2{0%,29%{opacity:0}35%,80%{opacity:1}88%,100%{opacity:0}}
@keyframes spk3{0%,54%{opacity:0}60%,80%{opacity:1}88%,100%{opacity:0}}
.clawed-label{position:absolute;left:154.1px;top:620px;font-family:'Mackinac';font-weight:400;font-size:10px;line-height:1.4;color:var(--dusty-granite);transform:rotate(-4.97deg)}
/* pointer-events:none, like .bubble-prompt above. Both captions sit ON Calcifer, and
   he is the tap target for the bubble: without this the text swallows the gesture over
   its own box. On desktop that box is inside his, so hovering the caption never opened
   the bubble either. Decoration should not take pointer input. */
.clawed-cap,.clawed-label{pointer-events:none}
.clawed-cap{position:absolute;left:37.9px;top:551.6px;width:191px;font-family:'ApercuMono';font-size:10px;line-height:1.3;color:var(--sage);transform:rotate(-4.91deg)}
/* Static prompt (shown until Calcifer is hovered) */
.bubble-prompt{position:absolute;left:269px;top:461px;width:179px;pointer-events:none;
  font-family:'ApercuMono';font-size:16px;line-height:1.4;color:var(--sage);text-align:center;
  transition:opacity .22s ease}
.bubble-prompt.is-hidden{opacity:0}
/* Speech bubble — fades + gently pops in on hover (from near Calcifer), out on leave */
.bubble{position:absolute;left:207px;top:461px;background:var(--parchment);padding:16px;border-radius:20px;
  display:flex;align-items:flex-end;pointer-events:none;
  opacity:0;visibility:hidden;transform:translateY(6px) scale(.9);transform-origin:8% 100%;
  transition:opacity .22s ease, transform .3s cubic-bezier(.34,1.45,.5,1), visibility 0s linear .22s}
.bubble.is-visible{opacity:1;visibility:visible;transform:none;
  transition:opacity .2s ease, transform .34s cubic-bezier(.34,1.45,.5,1)}
@media (prefers-reduced-motion:reduce){.bubble{transition:opacity .15s linear, visibility 0s linear .15s;transform:none}
  .bubble.is-visible{transition:opacity .15s linear}}
.bubble p{font-family:'Diatype';font-weight:700;font-size:14px;line-height:1.3;color:var(--dusty-granite);white-space:nowrap}
.bubble .caret{display:inline-block;width:1px;height:1em;vertical-align:-0.12em;background:var(--dusty-granite);margin-left:2px;animation:blink 1.1s step-end infinite}
@keyframes blink{50%{opacity:0}}

/* ================= WORK ================= */
#work{background:var(--parchment);padding:84px 72px}
.work-inner{display:flex;flex-direction:column;align-items:center;padding-bottom:32px}
.work-row{display:flex;justify-content:space-between;width:100%;padding:0 42px}
.work-row.top{height:703px;align-items:center}
.work-row.bottom{padding-bottom:60px}

.card{position:relative;width:573px;height:703px}
/* A card that links to its case study lifts slightly and warms its title */

/* ---------- Link underlines ----------
   One mechanism for every underlined link: a 1px ::after pinned to the text box, so the
   rule is anchored to the word rather than to the canvas. That also retires .hairline,
   a hand-placed div under "WhatsApp" whose top had to be recomputed by hand every time
   the name changed size.

   On hover the rule sweeps -- it retracts to the right, then redraws from the left.
   Subtle, and it reads as the line being drawn rather than merely recoloured. */
.wa,.touch{position:relative;text-decoration:none}
.wa::after,.touch::after{
  content:"";position:absolute;left:0;right:0;bottom:-2px;height:1px;
  background:var(--antique-gold);transform-origin:left}
.wa:hover::after,.touch:hover::after{
  animation:link-sweep .42s cubic-bezier(.4,0,.2,1)}
@keyframes link-sweep{
  0%   {transform:scaleX(1);transform-origin:right}
  48%  {transform:scaleX(0);transform-origin:right}
  52%  {transform:scaleX(0);transform-origin:left}
  100% {transform:scaleX(1);transform-origin:left}
}
/* Footer links carry no rule at rest, so theirs grows in rather than sweeping.

   The rule has to measure the WORD, not the box around it. Both columns are flex,
   so their anchors were stretching to the column width — a nav anchor is 95px wide
   whatever it says, and an .foot-links anchor also contains a 24px icon and a 12px
   gap. left:0/right:0 on those drew a rule well past the end of the text. The
   columns now pack their items to the start, and the icon links hang the rule off
   an inner span so it stops where the word does. */
.foot-links,.foot-nav{align-items:flex-start}
/* The anchor is positioned so the toast measures from the Email line itself. It
   lost this when the underline moved onto .lbl, and the toast quietly fell back
   to the whole right-hand cluster as its containing block, which made its offset
   almost unresponsive. */
.foot-links a,.foot-links a .lbl,.foot-nav a{position:relative}
.foot-links a .lbl::after,.foot-nav a::after{
  content:"";position:absolute;left:0;right:0;bottom:-3px;height:1px;
  background:currentColor;transform:scaleX(0);transform-origin:left;
  transition:transform .28s cubic-bezier(.4,0,.2,1)}
.foot-links a:hover .lbl::after,.foot-nav a:hover::after{transform:scaleX(1)}
@media (prefers-reduced-motion:reduce){
  .wa:hover::after,.touch:hover::after{animation:none}
  .foot-links a .lbl::after,.foot-nav a::after{transition:none}
}

a.card{display:block;text-decoration:none;color:inherit;
  transition:transform .35s cubic-bezier(.34,1.3,.5,1)}
/* Tickets lift and tip rather than recolouring. Pivoting from the bottom edge makes it
   read as the card being picked up off the page instead of spun about its middle, and
   the title stays put -- the motion carries the affordance, so a colour change on top
   of it was saying the same thing twice. */
a.card{transform-origin:50% 100%}
a.card:hover{transform:translateY(-8px) rotate(-1.4deg)}
a.card:nth-of-type(even):hover{transform:translateY(-8px) rotate(1.4deg)}
.card .ticket{position:absolute;left:0;top:25.5px;width:573px;height:627.75px}
/* Card thumbnail, Figma 557:187: a 161x350 device on --pay-field, sat at top 28 so it
   runs off the bottom edge of the 304px window. Centred in the slot (529 wide here vs
   the file's 521, so left is 184 rather than 180).

   The screen is live rather than a flat export: Asset 2's strip and viewport, so it can
   zoom out to show the whole device, scroll the length of Payments Home, and settle
   back into the cropped hero shot.

   Selectors are scoped to .card-photo deliberately: the card already sizes every image
   inside it to fill its box, which silently stretched the strip and the FAB to their
   parents' dimensions. */
/* Card 1's field is the image from Figma 619:177, exported rather than reproduced.

   Three procedural attempts at this card all read as foreign, and the reason was the
   category rather than the parameters: the site has no generated textures anywhere --
   its interest comes from photographs, drawings and type. An exported image is the same
   kind of thing as the hero photograph and the polaroids.

   --pay-field stays underneath as a fallback tone, so a failed image request leaves the
   card coloured rather than white. It no longer shows through anywhere: the image is a
   dark photograph now, not a light tint. */
.card-photo .cp-field{position:absolute;inset:0;width:529px;height:304px;
  display:block;object-fit:cover;z-index:0}

/* Backing store is 2x the 529x304 box so the 3px tiles stay crisp when fit() scales the
   stage up. Behind the device, which sits at z-index 1. */
/* Every thumbnail is an exported photograph now, so the generated-pattern rules that
   used to live here are gone with the canvases they styled. */
.card-photo .cp-phone{z-index:1}
.card-photo{position:absolute;left:22px;top:101.5px;width:529px;height:304px;
  background:#fff;border:1px solid var(--outline-stroke);
  border-radius:10px;overflow:hidden}
.card-photo .cp-phone{position:absolute;left:184px;top:28px;width:161px;height:350px;
  transform-origin:50% 0;animation:cp-zoom 11s cubic-bezier(.4,0,.2,1) infinite}
.card-photo .cp-base{position:absolute;inset:0;width:161px;height:350px;display:block}
.card-photo .cp-view{position:absolute;left:3.43px;top:42.89px;width:154.15px;height:303.6px;
  overflow:hidden;clip-path:inset(0 round 0 0 21.33px 21.33px)}
.card-photo .cp-strip{display:block;width:154.15px;height:548.27px;will-change:transform;
  animation:cp-scroll 11s cubic-bezier(.45,0,.15,1) infinite}
.card-photo .cp-fab{position:absolute;left:64.87px;top:304.32px;width:87.67px;height:29.49px;display:block}
/* .743 puts the whole 350px device inside the 304px window; the -6px lift makes the
   top and bottom margins equal at 22px. */
@keyframes cp-zoom{
  0%,10%   {transform:translateY(0) scale(1)}
  20%,80%  {transform:translateY(-6px) scale(.743)}
  90%,100% {transform:translateY(0) scale(1)}
}
/* Stops at -236, not the full -244.7. The last 8.35px of pux-strip.png are the device's
   own baked bottom chrome -- bezel corners and the home indicator -- which Asset 2 can
   scroll into because there the viewport's foot IS the screen's foot, so it lands where
   a real indicator belongs. Here it would slide up into the content and read as a stray
   line appearing under the FAB. 236 stops just short of it. */
@keyframes cp-scroll{
  0%,26%   {transform:translateY(0)}
  40%,58%  {transform:translateY(-236px)}
  72%,100% {transform:translateY(0)}
}
@media (prefers-reduced-motion:reduce){
  .cp-phone,.card-photo .cp-strip{animation:none}
}
.card-photo img{width:100%;height:100%;object-fit:cover}

/* ----- Card 2: Payment Requests -----
   The flow running, not two frames trading places. A cross-fade between a sheet and a
   chat was accurate and inert; card 1 earns its attention by showing the product being
   used, and this has to do the same or it reads as the quiet one.

   Four beats in nine seconds, a little quicker than card 1's eleven: the tray opens,
   the sheet arrives, the amount goes in, the request lands in the chat. Three taps, so
   something is happening at almost every moment. The screens are too small to read here
   and do not need to be -- what carries at this size is the movement and the green
   bubble at the end.

   Geometry is card 1's 161x350 box and the screenshots are already that ratio. The
   device is pinned at the end of cp-zoom so all four screens stay whole; the flow is
   the motion, and a zoom on top of it would be one movement too many.

   Tap targets are the walkthrough's rescaled: those were measured against a 170px
   frame, these run at 161px, so image coordinates scale by 0.3333 rather than 0.352.

   More specific than .card-photo img above, which sizes every image to fill the card. */
/* Fallback tone only. The field is Figma 793:2075, an exported photograph, so this is
   what shows if the image fails rather than a colour anyone sees. Ink-blue because the
   photograph is a blue sky. */
.card-req .card-photo{background:var(--ink-blue)}
/* Origin sits on the sheet's centre (88 + 346)/2, not the device's top like card 1, so
   pushing in frames the sheet rather than the chat header. Every pose below solves
   card_y(p) = 28 + T + 217 + (p - 217) * S for the framing named in the comment.
   The still is the old translateY(-6px) scale(.743) pose restated against the new
   origin: device centred, 22px of field above and below, unchanged to the pixel. */
.card-photo .rq-phone{animation:none;transform-origin:50% 217px;
  transform:translate(0,-61.8px) scale(.743);will-change:transform}
.card-photo .rq-phone > img{position:absolute;inset:0;width:161px;height:350px;
  object-fit:contain;display:block;opacity:0}
/* The chat is the base layer and never fades: everything else stacks over it, so a
   cross-fade at the loop point dissolves against a screen rather than against the card. */
.card-photo .rq-phone .rq1{opacity:1}
/* The still, for reduced motion and for no JS: the request already delivered. */
.card-photo .rq-phone .rq4{opacity:1}

/* SHEET RISE ---------------------------------------------------------------
   The request sheet is a real bottom sheet here rather than a cross-fade, because the
   cross-fade was what made this thumbnail feel slow: four still states with dead air
   between them, next to card 1 whose screen scrolls continuously.

   The screenshots are flat composites, so the sheet was cut out of them and is animated
   as its own layer. Every number below is measured off the 483x1048 source and divided
   by 3, which is the exact scale the 161px-wide phone renders at (483 = 161 x 3).

     screen inside the bezel   x 10..472, y 10..1037   -> 3.3333 / 3.6667, 154.3333 x 342.6667
     sheet top edge            y 263                   -> 84.3333 from the screen top
     screen corner radius      60                      -> 20
     scrim over the chat       at4 = at1 x 0.681       -> black at .319

   object-fit:contain letterboxes 1048/3 = 349.3333 inside a 350 box, so every vertical
   figure carries the resulting 0.3333 offset. */
.rq-screen{position:absolute;left:3.3333px;top:3.6667px;width:154.3333px;height:342.6667px;
  border-radius:20px;overflow:hidden;z-index:1;pointer-events:none}
.rq-scrim{position:absolute;inset:0;background:rgba(0,0,0,.319);opacity:0}
.rq-sheet{position:absolute;left:0;top:84.3333px;width:154.3333px;height:258.3333px;
  transform:translateY(100%);will-change:transform}
/* Height matches the sheet exactly, so translateY(100%) parks it flush below the screen. */
.rq-sheet img{position:absolute;inset:0;width:100%;height:100%;display:block}
/* Same sheet with the amount filled in. Only one band differs, 37% to 47% down, so this
   cross-fade reads as the field populating rather than as a screen change. */
.rq-sheet .rq-amt{opacity:0}
/* case-study.css owns .tap-cursor and index does not load that file. Restated here to
   the same numbers rather than pulling in a stylesheet for one rule.

   Split in two because the camera now pushes to 1.62. The cursor rides inside the phone,
   so at that scale one 32px disc rendered as a 52px blur with a 52px shadow and swamped
   the keypad. The anchor is a zero-size point that carries the travel and the tap squeeze;
   the dot is the visible disc and counter-scales against the camera on the camera's own
   keyframe times, holding roughly 32px on screen throughout. Two animations cannot both
   write transform on one element, which is why this is a parent and a child. */
.rq-cur{position:absolute;left:0;top:0;width:0;height:0;z-index:3;
  opacity:0;will-change:transform}
.rq-dot{position:absolute;left:-16px;top:-16px;width:32px;height:32px;
  border-radius:50%;border:1px solid rgba(255,255,255,.92);
  background:rgba(0,0,0,.10);-webkit-backdrop-filter:blur(1px);backdrop-filter:blur(1px);
  box-shadow:0 14px 34px rgba(0,0,0,.25),0 2px 6px rgba(0,0,0,.18);
  transform:scale(1.4286)}
/* CAMERA -------------------------------------------------------------------
   The zoom has exactly one reason to exist: the optional amount field is the smallest
   thing on the screen and it is the thing this case study is about. So the camera closes
   on that field while it is being filled in, and opens again once the request has to be
   read in the context of the chat.

   That gives it one push and one pull, and the direction reverses exactly once, on the
   Send tap. The previous version pushed in, pulled out, and drifted sideways on its own
   schedule while the cursor moved on another, so nothing caused anything. Every move here
   is caused by the finger:

     0 to 10    still, wide          a chat with the tray open. tap Pix at 11
     10 to 34   push .70 to 1.62     follows the sheet up, then settles onto the field
     34         keypad tap           the amount lands at the deepest point of the push
     34 to 46   hold 1.62            close on the field while Send is reached for
     46 to 100  pull 1.62 to .70     opens out as the sheet drops and the request lands

   Because the framing target moves from the device's middle (y 175) to the field and
   keypad (y 234), the push carries a vertical pan down the device with it. That is the
   pan, and it is motivated. A horizontal pan is not available: the phone is portrait in
   a landscape window, so it only exceeds 529px wide past scale 3.29, and at 3.29 the
   463px sheet crop is a 2x upsample on a retina screen and the vertical slice is down to
   92px, too little to hold the field and the keypad at once. Panning left to right at any
   sharp scale would move the phone around the frame without revealing anything, which is
   the arbitrary movement this rewrite is removing.

   Poses, by the formula on .rq-phone above:
     .70   T -63.6    device 245 tall, whole, centred, 29.5 of field above and below
     1.50  T -118.5   framed on element y 234, the field with the keypad beneath it
     1.62  T -120.54  same framing, the deepest point
     .84   T -57.72   device 294 tall in a 304 window, whole, 5px margins

   1.62 is the ceiling worth using. The source is a 483px export with nothing larger
   behind it, so the sheet crop is 463px wide; past about 1.5 it upsamples on a retina
   screen, and by 2.6 the keypad digits are visibly soft. 1.62 keeps the field, the Send
   button and the tapped key sharp and in frame together.

   ONE KEYFRAME PER MOVE. This is the whole reason the earlier version felt unpolished. It
   carried waypoints at 1.50 and .84 as shaping guides, but a cubic-bezier ending in 1
   decelerates to zero velocity at its own keyframe and the next segment restarts from
   zero, so each guide became a full stop: measured 0.005 scale/s at 30% and 0.001 at 60%,
   dead stops in the middle of a move, plus a third soft stall at 76%. What was described
   as one push and one pull was actually push, stop, push, hold, pull, stop, pull, stall.
   A continuous move needs exactly two keyframes and no interior points. */
.pt .card-photo .rq-phone{animation:rq-cam 6s linear infinite}
@keyframes rq-cam{
  0%,10%{transform:translate(0,-63.6px) scale(.70);animation-timing-function:cubic-bezier(.33,.03,.24,1)}
  34%   {transform:translate(0,-120.54px) scale(1.62)}
  46%   {transform:translate(0,-120.54px) scale(1.62);animation-timing-function:cubic-bezier(.24,1,.34,1)}
  100% {transform:translate(0,-63.6px) scale(.70)}
}
/* Reciprocal of rq-cam, holding the tap disc near 32px on screen while the camera moves
   under it. CSS interpolates each track's own value, and lerp(1/a,1/b) is not 1/lerp(a,b),
   so two keyframes per move would leave the disc up to 19% oversize mid-move. These
   percentages are sampled from the running camera rather than derived from the curve,
   which keeps the error under a pixel wherever the disc is actually visible. Regenerate
   them if rq-cam's poses or curves change. */
@keyframes rq-dot{
  0%,10%{transform:scale(1.4286)}
  14%   {transform:scale(1.2204)}
  18%   {transform:scale(.8625)}
  22%   {transform:scale(.7069)}
  26%   {transform:scale(.6475)}
  30%   {transform:scale(.6235)}
  34%,46%{transform:scale(.6173)}
  50%   {transform:scale(.7397)}
  54%   {transform:scale(.8852)}
  58%   {transform:scale(1.0339)}
  66%   {transform:scale(1.2599)}
  76%   {transform:scale(1.3862)}
  88%   {transform:scale(1.4249)}
  100%  {transform:scale(1.4286)}
}
.pt .rq-sheet {animation:rq-sheet  6s linear infinite}
.pt .rq-scrim {animation:rq-scrim  6s linear infinite}
.pt .rq-amt   {animation:rq-amt    6s linear infinite}
.pt .rq-dot   {animation:rq-dot    6s linear infinite}
.pt .card-photo .rq-phone .rq4{animation:rq4 6s linear infinite}
.pt .card-photo .rq-cur{animation:rq-cur 6s linear infinite}
@keyframes rq-sheet{
  0%,13%  {transform:translateY(100%);animation-timing-function:cubic-bezier(.18,.9,.28,1)}
  27%     {transform:translateY(0)}
  47%     {transform:translateY(0);animation-timing-function:cubic-bezier(.4,0,1,1)}
  58%,100%{transform:translateY(100%)}
}
/* Same curves as rq-sheet, not linear. The dim and the movement are one event, and a
   linear ramp against a decelerating sheet reads as two things happening at once. */
@keyframes rq-scrim{
  0%,13%  {opacity:0;animation-timing-function:cubic-bezier(.18,.9,.28,1)}
  27%     {opacity:1}
  47%     {opacity:1;animation-timing-function:cubic-bezier(.4,0,1,1)}
  58%,100%{opacity:0}
}
/* Instant. A value landing in a field does not dissolve, and any cross-fade at all shows
   the placeholder ghosting through the digits. */
@keyframes rq-amt{0%,33.9%{opacity:0}34%,100%{opacity:1}}
/* Both switches are cuts, not fades. Cross-fading these two chats doubles every line of
   text, because delivering the request scrolls the thread: 73% of the pixels below the
   sheet line differ, and 15% above it. Even 0.2s read as two screens overlaid.

   The delivery cuts at 48%, one frame into the sheet's descent, so the sheet still covers
   the large half of that change and the scrim still dims the small half. The reset cuts
   on the loop boundary itself, where 100% meets 0% and there is no in-between frame to
   render. A loop has to un-send the request somewhere; this is the cheapest place. */
@keyframes rq4{0%,47.9%{opacity:0}48%,100%{opacity:1}}
/* Pix tile, then the keypad, then Send.

   The finger lifts at 15 and comes back at 31. It used to hold on the Pix coordinate
   while the sheet rose, which was survivable at .84 but not at 1.62: the sheet puts the
   keypad exactly where the tray tile was, so the disc sat on the minus key, drifted onto
   the comma, then jumped to the 5. Hiding it is also what a hand does. It repositions
   while invisible between 15 and 28, so nothing slides across the frame. */
@keyframes rq-cur{
  0%   {transform:translate(140px,318px) scale(1);opacity:0;animation-timing-function:cubic-bezier(.4,0,.2,1)}
  4%   {transform:translate(138px,298px) scale(1);opacity:1;animation-timing-function:cubic-bezier(.4,0,.2,1)}
  9%   {transform:translate(136.7px,273.3px) scale(1);opacity:1}
  11%  {transform:translate(136.7px,273.3px) scale(.8);opacity:1}
  13%  {transform:translate(136.7px,273.3px) scale(1);opacity:1}
  15%  {transform:translate(136.7px,273.3px) scale(1);opacity:0}
  28%  {transform:translate(68.3px,292px) scale(1);opacity:0;animation-timing-function:cubic-bezier(.4,0,.2,1)}
  31%  {transform:translate(68.3px,283px) scale(1);opacity:1}
  33%  {transform:translate(68.3px,280.7px) scale(1);opacity:1}
  34%  {transform:translate(68.3px,280.7px) scale(.8);opacity:1}
  36%  {transform:translate(68.3px,280.7px) scale(1);opacity:1}
  39%  {transform:translate(68.3px,280.7px) scale(1);opacity:1;animation-timing-function:cubic-bezier(.4,0,.2,1)}
  44%  {transform:translate(75.7px,228px) scale(1);opacity:1}
  45.5%{transform:translate(75.7px,228px) scale(.8);opacity:1}
  47%  {transform:translate(75.7px,228px) scale(1);opacity:1}
  53%  {transform:translate(75.7px,228px) scale(1);opacity:1}
  58%,100%{transform:translate(75.7px,228px) scale(1);opacity:0}
}

.card-dots{position:absolute;left:24px;top:80.5px;width:527px;height:15px;overflow:hidden;
  font-family:'Kyoto';font-weight:300;font-style:italic;font-size:12px;line-height:1.3;color:var(--antique-gold);white-space:nowrap}
.card-no{position:absolute;left:26px;top:58.25px;font-family:'ApercuMonoPro';font-weight:500;font-size:16px;line-height:1.3;color:var(--persimmon)}
.card-meta{position:absolute;left:26px;top:471.25px;width:525px;display:flex;flex-direction:column;gap:16px}
.card-title-row{display:flex;align-items:center;gap:12px}
.card-title{flex:1;font-family:'Mackinac';font-weight:500;font-size:28px;line-height:1.3;color:var(--dusty-granite)}
.tags{display:flex;gap:8px;align-items:center;flex:none}
/* Padding drops to 11 so the 1px stroke lands inside the pill's existing footprint --
   otherwise the border adds 2px each way and nudges the title row's alignment. */
.tag{background:var(--cream);border:1px solid var(--outline-stroke);padding:11px;border-radius:12px;font-family:'ApercuMonoPro';font-weight:500;font-size:16px;line-height:1;white-space:nowrap}
.tag.green{color:var(--tag-green)}
/* Microsoft, matching .mini-tag.co-microsoft on the Storyline card below it. The
   three WhatsApp cards are green because WhatsApp is; this one was only green by
   inheriting the same modifier. */
.tag.blue{color:var(--ink-blue)}
.tag.mut{color:var(--muted)}
.card-desc{font-family:'Diatype';font-weight:400;font-size:16px;line-height:1.6;color:var(--muted)}

/* Outline-stroke, the same hairline the cards on either side of it are drawn with,
   rather than antique-gold. It separates the case studies from the mini projects, so it
   should read as part of their frame and not as a rule of its own. */
.divider{width:896px;height:1px;background:var(--outline-stroke)}

/* ================= MINI PROJECTS (Figma 454:1141) =================
   Six smaller cards below the four case studies, replacing the three empty
   .read-card placeholders that stood here. The spacing was already right: .reads
   carried the 24px gutter, 42px sides and 120px lead that the Figma frame uses, so
   only the display type changed, from a 3-up flex row to a 3-column grid.

   1212 of content across three 388 columns and two 24 gutters is exact, and it is the
   same 1212 the .work-row cards above resolve to, so both grids share one column.

   The type is the .card-title / .tag / .card-desc vocabulary from those cards, one
   step down: 28px title to 16, 16px tag to 14, 16px description to 14. */
.reads{display:grid;grid-template-columns:repeat(3,1fr);gap:32px 24px;
  width:100%;padding:120px 42px 10px}
.mini{display:flex;flex-direction:column}
/* 312 tall including its own stroke, and the stroke on all four sides: the bottom one
   is the rule between the shot and the panel, which is why the panel has no top border
   of its own. The exported thumbnails had this same stroke baked in at 4x; it was
   cropped off so the line comes from the token and stays 1px at any zoom. */
.mini-shot{height:312px;background:var(--dove-ivory);
  border:1px solid var(--outline-stroke);border-radius:8px 8px 0 0;overflow:hidden}
.mini-shot img{display:block;width:100%;height:100%;object-fit:cover}
/* flex:1 lets the grid's stretched rows equalise cards whose copy runs to different
   lengths, rather than pinning a height the placeholder text happens to produce. */
/* The design measures its text block from the card's outer edge: 24 in from the left,
   19 from the right, 28 up from the bottom. Each of those sides carries a stroke, so
   the padding is one less than the designed inset and the text lands on the number.
   The top is not, the panel having no top border, so 19 there is 19. */
.mini-panel{flex:1;background:var(--dove-ivory);
  border:1px solid var(--outline-stroke);border-top:0;border-radius:0 0 8px 8px;
  padding:19px 18px 27px 23px}
.mini-head{display:flex;gap:2px;align-items:center}
.mini-title{flex:1;font-family:'Mackinac';font-weight:500;font-size:16px;line-height:1.3;color:var(--dusty-granite)}
.mini-tags{display:flex;gap:4px;align-items:center;flex:none}
/* Padding is 7 rather than the designed 8 for the same reason .tag uses 11 rather than
   12: the stroke has to land inside the pill's footprint. 14px type on line-height 1,
   plus 7 twice, plus the stroke twice, is the 30 the design asks for. */
.mini-tag{background:var(--cream);border:1px solid var(--outline-stroke);border-radius:8px;
  padding:7px;font-family:'ApercuMonoPro';font-weight:500;font-size:14px;line-height:1;white-space:nowrap}
.mini-desc{margin-top:8px;font-family:'Diatype';font-weight:400;font-size:14px;line-height:1.6;color:var(--muted)}
/* WhatsApp and Microsoft already had a token at exactly the hex the design uses --
   Figma reports the Microsoft chip as ink-blue itself. The other four are new.

   Prefixed co- rather than the two-letter names these started with. ".wa" is already
   taken: it is the hero bio's "Currently @ WhatsApp" link, which carries an animated
   underline on an ::after, and the WhatsApp pill inherited that underline the moment it
   was given the same class. Two letters is not enough room to be unique in a global
   stylesheet. */
.mini-tag.co-whatsapp{color:var(--tag-green)}
.mini-tag.co-microsoft{color:var(--ink-blue)}
.mini-tag.co-complete{color:var(--co-complete)}
.mini-tag.co-flo{color:var(--co-flo-recruit)}
.mini-tag.co-baller{color:var(--co-ballertv)}
.mini-tag.co-nike{color:var(--co-nike)}
.mini-tag.co-year{color:var(--muted)}

/* Entrance. These cards are not tappable and lead nowhere, so they must not borrow the
   case studies' motion: a.card answers the POINTER, lifting 8px and tipping 1.4deg off
   its bottom edge, and that motion is the affordance. The minis answer the PAGE instead.
   They develop into place once as you reach them, and nothing under the pointer ever
   changes, so there is no moment where the cursor suggests something will happen.

   The card rises and fades as one object rather than in parts, because the shot and the
   panel meet on a shared 1px rule and any offset between them would open a seam. The
   photograph then resolves a beat behind the frame it sits in: briefly the card is an
   empty dove-ivory mount, then the image arrives in it. That is the whole idea, and it
   is a different one from being picked up off the page.

   Once, then still, per the same rule the impact stats follow -- motion that keeps
   replaying reads as decoration rather than as something having happened.

   Staggered by column so a row lands left to right; rows separate on their own, having
   crossed the viewport at different times. Gated on .pt, so reduced motion and a page
   without script both get the cards outright.

   ONE fade per card. This used to run two: the card faded in to reveal an empty mount,
   then the photograph faded in on top of it. Conceptually that was a picture developing
   into its frame, but the second ramp is a large luminance jump, cream ground to a
   full-bleed photograph, and six of those going off on an irregular rhythm read as
   flashing rather than as arriving. The frame and its picture now come in together. */
.pt .mini{opacity:0;transform:translateY(10px);
  transition:opacity .6s ease,transform .6s cubic-bezier(.22,.7,.3,1)}
.pt .mini.is-in{opacity:1;transform:none}
.pt .reads .mini:nth-child(3n+2){transition-delay:.08s}
.pt .reads .mini:nth-child(3n+3){transition-delay:.16s}

/* Hover. The rule that keeps this off the case studies' territory: a.card moves as an
   object you could pick up, tipping off its bottom edge. A mini card never moves at
   all -- only what is inside it does. The frame stays nailed to the page, so there is
   no moment where it offers itself, and the picture inside gains a little depth, the
   way looking closer at something mounted under glass would.

   So: no translate, no rotate, no scale on the card, no shadow, no cursor change, and
   nothing at all in the caption. Just the photograph easing 2.5% closer inside a fixed
   frame. Slow enough to settle rather than snap.

   The panel used to warm from dove-ivory to cream alongside it. It is gone: even though
   the description's own colour never changed, re-toning the ground under it changed how
   the text read, which is a colour change whatever the property says.

   At rest the shot is scale 1, which is the framing the Figma specifies to the pixel;
   only the hover state crops in at all. Behind hover:hover so a touch device, which has
   no way to leave, never gets stuck holding one of these open. */
@media (hover:hover){
  .mini-shot img{transition:transform .45s cubic-bezier(.22,.7,.3,1)}
  .mini:hover .mini-shot img{transform:scale(1.025)}
}
@media (hover:hover) and (prefers-reduced-motion:reduce){
  .mini-shot img{transition:none}
  .mini:hover .mini-shot img{transform:none}
}

/* ================= FOOTER ================= */
#footer{height:405px;background:var(--persimmon);overflow:hidden}
/* Footer scaled down 15%, but each cluster scales toward its Figma-anchored corner
   so the outer padding stays on-spec (L48 / R51 / T51 / B72). */
.foot-inner{position:absolute;inset:0}
.foot-left{position:absolute;left:48px;top:51px;display:flex;gap:42px;align-items:flex-start;transform:scale(0.85);transform-origin:top left}
.flowers{width:120px;display:flex;flex-direction:column;gap:12px}
/* Each flower tile is the same SVG cropped to one cell. A soft breeze gusts down
   the column (top leads) — each turns around its own centre, with lulls between gusts. */
.flowers .fl{width:120px;height:110.504px;object-fit:none;display:block;transform-origin:50% 50%;
  animation:flower-wind 6s ease-in-out infinite;will-change:transform}
.flowers .fl.f1{object-position:0 0;animation-delay:-.5s}
.flowers .fl.f2{object-position:0 -122.504px;animation-delay:-.25s}
.flowers .fl.f3{object-position:0 -245.007px;animation-delay:0s}
@keyframes flower-wind{
  0%,15%{transform:rotate(0deg)}      /* lull */
  30%{transform:rotate(7.5deg)}       /* gust */
  43%{transform:rotate(-3deg)}
  55%{transform:rotate(4.5deg)}
  70%,100%{transform:rotate(0deg)}    /* settle back into the lull */
}
@media (prefers-reduced-motion:reduce){.flowers .fl{animation:none}}

/* Footer plant cursor + click-to-plant stamps */
#footer{cursor:url("assets/plant-cursor.png") 18 29, auto}
/* The About footer is pale, and the light cursor all but vanished on it. Same
   sprout, drawn in the moss the dark stamps use, so what you are holding matches
   what you plant. */
#footer.is-about{cursor:url("assets/plant-cursor-dark.png") 18 29, auto}
.foot-right a{cursor:pointer}
.plant-layer{position:absolute;inset:0;overflow:hidden;pointer-events:none}
.plant-stamp{position:absolute}
.plant-stamp img{position:absolute;left:0;bottom:0;display:block;opacity:0;
  transform:translate(-50%,0) scaleY(.35);transform-origin:bottom center;
  transition:opacity .5s ease, transform .75s cubic-bezier(.34,1.5,.5,1)}
.plant-stamp img.grow{opacity:1;transform:translate(-50%,0) scaleY(1)}
.plant-stamp img.grow.fade{opacity:0}
.plant-stamp .p1{height:29px}
.plant-stamp .p2{height:46px}
.plant-stamp .p3{height:77px}
@media (prefers-reduced-motion:reduce){.plant-stamp img{transition:none}}
.quote{width:414px;height:356px;font-family:'Mackinac';font-weight:700;font-size:42px;line-height:1.2;color:var(--cream)}
.foot-right{position:absolute;right:51px;top:51px;display:flex;gap:84px;align-items:flex-start;transform:scale(0.85);transform-origin:top right}
.foot-links{display:flex;flex-direction:column;gap:12px;padding-top:8px}
.foot-links a{display:flex;align-items:center;gap:12px;text-decoration:none;color:var(--cream);
  transition:color .2s ease;
  font-family:'Mackinac';font-weight:400;font-size:28px;line-height:1.2}
.foot-links svg{color:var(--cream)}
.foot-nav{display:flex;flex-direction:column;gap:12px;width:95px}
/* The same object as the nav's hover preview, in the footer's colours: identical
   radius, padding, face, size, leading, and the same 4px rise on the same curve.
   Only the parchment ground and the dusty granite text differ, and it clears the
   line by 12px, which is what the nav's preview clears its pill by. No shadow. */
.foot-toast{position:absolute;left:0;bottom:calc(100% + 13.6px);
  background:var(--parchment);color:var(--dusty-granite);border-radius:8px;padding:8px 12px;
  font-family:'ApercuMono';font-weight:400;font-size:14px;line-height:1.3;
  white-space:nowrap;pointer-events:none;
  opacity:0;visibility:hidden;transform:translateY(4px);
  transition:opacity .16s ease,transform .16s cubic-bezier(.22,.8,.3,1),visibility .16s}
.foot-toast.is-on{opacity:1;visibility:visible;transform:none}
@media (prefers-reduced-motion:reduce){.foot-toast{transition:none;transform:none}}
.foot-nav a{text-decoration:none;font-family:'Mackinac';font-weight:400;font-size:28px;line-height:1.2;
  color:var(--cream);transition:color .2s ease}
.foot-nav a.index{font-weight:700;color:var(--new-leaf-green)}
/* Green variant (Figma 358:1299), used by the About page. Only the ground, the
   tiles and which entry reads as current differ; every measurement is shared. */
#footer.is-about{background:var(--pear)}
#footer.is-about .flowers .fl{object-fit:contain;object-position:0 0}
/* Every mark in this footer is slate on the pale ground; only the current nav item
   and the weather dot lift to moss. */
#footer.is-about .quote,
#footer.is-about .foot-links a,
#footer.is-about .foot-links svg,
#footer.is-about .foot-nav a,
#footer.is-about .weather p{color:var(--slate)}
#footer.is-about .foot-nav a.current{color:var(--moss)}
#footer.is-about .weather .dot{background:var(--moss)}
/* cream would disappear against this ground, so hover darkens instead of lightening */
#footer.is-about .foot-links a:hover,
#footer.is-about .foot-nav a:hover,
#footer.is-about .foot-nav a.current:hover{color:var(--dusty-granite)}
.foot-nav a.current{font-weight:700;color:var(--new-leaf-green)}
.foot-nav a.current:hover{color:var(--cream)}
/* Ink-blue variant (Figma 358:1253), used by the Library page. It shares more with
   the persimmon original than the About one does: cream type, new-leaf-green on the
   current entry, every measurement the same. Only the ground, the flower tiles and
   the weather dot differ, and the dot is hero-green here rather than new-leaf. */
#footer.is-library{background:var(--ink-blue)}
#footer.is-library .flowers .fl{object-fit:contain;object-position:0 0}
#footer.is-library .weather .dot{background:var(--hero-green)}
.weather{position:absolute;left:186px;top:327px;display:flex;align-items:center;gap:24px;transform:scale(0.85);transform-origin:top left}
.weather .dot{width:12px;height:12px;border-radius:50%;background:var(--new-leaf-green);flex:none}
.weather p{font-family:'ApercuMono';font-size:24px;line-height:1.3;color:var(--cream);white-space:pre-wrap}

/* ================= PRINT-YOUR-OWN MODAL (Figma node 396:6) ================= */
#modal-overlay{position:fixed;inset:0;z-index:1000}
#modal-overlay[hidden]{display:none}
/* Backdrop: 50% black + 5px blur (Figma Rectangle 26) */
#modal-backdrop{position:absolute;inset:0;background:rgba(0,0,0,0.5);
  -webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}
/* Modal box 1052x750, centred, scaled to track the hero. It is now only a
   positioned frame: the parchment card belongs to each STATE, because the matched
   state is not one card but a ticket and a panel side by side (Figma 459:62). */
#print-modal{position:absolute;top:50%;left:50%;
  transform:translate(-50%,-50%) scale(var(--modal-scale,1));transform-origin:center;
  width:1052px;height:750px;box-sizing:border-box}
/* States crossfade rather than swap. They stay in the DOM and are hidden with
   opacity + visibility, because display:none cannot be transitioned — and the cut
   from the loader to the result was the most abrupt moment in the flow. visibility
   (not just opacity) keeps a hidden state out of the tab order and unclickable. */
.pm-state{position:absolute;inset:0;box-sizing:border-box;
  opacity:0;visibility:hidden;transition:opacity .34s ease,visibility .34s}
#print-modal.is-compose .pm-compose,
#print-modal.is-loading .pm-loading,
#print-modal.is-matched .pm-matched{opacity:1;visibility:visible}
/* The result arrives rather than appears: the ticket and the panel settle up as
   they fade, and the ticket leads by a beat so the eye lands on it first. */
.pm-matched .pm-ticket,.pm-matched .pm-panel{transform:translateY(10px);
  transition:transform .48s cubic-bezier(.22,.8,.3,1)}
#print-modal.is-matched .pm-ticket,
#print-modal.is-matched .pm-panel{transform:none}
#print-modal.is-matched .pm-panel{transition-delay:.06s}
@media (prefers-reduced-motion:reduce){
  .pm-state{transition:none}
  .pm-matched .pm-ticket,.pm-matched .pm-panel{transform:none;transition:none}
}
/* Both single-card states carry the card itself. */
.pm-compose,.pm-loading{background:var(--parchment);border-radius:12px;padding:32px}
.pm-compose{display:flex;flex-direction:column;gap:24px;align-items:flex-end}
.pm-header{display:flex;flex-direction:column;gap:12px;align-items:flex-start;width:100%}
.pm-titleblock{display:flex;flex-direction:column;gap:4px;align-items:flex-start;width:100%}
.pm-title{font-family:'Mackinac';font-weight:500;font-size:28px;line-height:1.3;color:var(--dusty-granite);width:100%;margin:0}
.pm-dots{font-family:'Kyoto';font-weight:300;font-style:italic;font-size:12px;line-height:1.3;color:var(--antique-gold);width:100%;height:16px;overflow:hidden;white-space:nowrap}
.pm-subtitle{font-family:'ApercuMonoPro';font-weight:500;font-size:18px;line-height:1.4;color:var(--antique-gold);width:100%;margin:0}
.pm-body{display:flex;flex-direction:column;gap:32px;align-items:flex-start;flex:1 0 0;min-height:0;width:984px}
.pm-tabs{display:flex;align-items:center;width:100%}
.pm-tab{flex:1 0 0;min-width:0;display:flex;align-items:center;padding:15px 16px;
  font-family:'Diatype';font-weight:400;font-size:18px;line-height:1.6;color:var(--dusty-granite);white-space:nowrap;
  background:none;cursor:pointer;text-align:left;
  border:0;border-bottom:4px solid var(--outline-stroke);border-top-left-radius:20px;border-top-right-radius:20px;
  transition:color .2s ease,border-color .2s ease}
.pm-tab:first-child{border-top-right-radius:12px}
.pm-tab.active{font-weight:700;color:var(--persimmon);border-bottom-color:var(--persimmon)}
/* Panels crossfade between tabs */
.pm-panels{position:relative;flex:1 0 0;min-height:0;width:984px}
.pm-drop,.pm-draw{opacity:0;transform:translateY(8px);pointer-events:none;
  transition:opacity .22s ease,transform .22s ease}
.pm-drop.pm-on,.pm-draw.pm-on{opacity:1;transform:none;pointer-events:auto}
.pm-drop{position:absolute;inset:0;box-sizing:border-box;cursor:pointer;
  border:2px dashed var(--outline-stroke);border-radius:12px;
  display:flex;flex-direction:column;align-items:flex-start;justify-content:center;padding:87px 405px}
.pm-drop.dragover{border-color:var(--persimmon)}
.pm-drop-inner{display:flex;flex-direction:column;gap:8px;align-items:center;width:174px}
.pm-drop-inner svg{color:var(--antique-gold)}
.pm-drop-label{font-family:'Kyoto';font-weight:500;font-size:16px;line-height:1.4;color:var(--dusty-granite);text-align:center;width:174px;margin:0}
/* Uploaded-file row — uploading (Figma 466:213) → complete (467:352) */
.pm-drop.has-file{cursor:default}
/* The prompt and the file row cross-fade rather than swapping on display, so the
   panel does not blink between two states. Both are centred in the same box, which
   is what lets them overlap for the length of the fade. */
.pm-drop-inner{transition:opacity .26s ease,transform .26s cubic-bezier(.4,0,.2,1)}
.pm-drop.has-file .pm-drop-inner{opacity:0;transform:scale(.96);pointer-events:none}
.pm-filerow{position:absolute;inset:0;display:flex;gap:12px;align-items:center;justify-content:center;
  opacity:0;transform:translateY(7px);
  transition:opacity .34s ease,transform .34s cubic-bezier(.22,.8,.3,1)}
.pm-filerow.is-in{opacity:1;transform:none}
.pm-fileicon{flex:none;width:48px;height:48px;color:var(--antique-gold)}
/* Fixed to the track's width. The row is centred, so anything that changes width
   inside it re-centres the whole cluster — and the percentage was growing from one
   digit to three while it counted, which slid the icon, the name and the bar
   sideways the entire way up. Pinning the column and using tabular figures holds
   everything still. */
.pm-filemeta{display:flex;flex-direction:column;gap:8px;align-items:flex-start;width:292px}
.pm-filename{font-family:'Diatype';font-weight:400;font-size:16px;line-height:1.6;color:var(--dusty-granite);
  white-space:nowrap;margin:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;
  font-variant-numeric:tabular-nums}
.pm-pct{transition:color .34s ease;font-variant-numeric:tabular-nums}
.pm-filerow.done .pm-pct{color:#749F25}
.pm-track{position:relative;width:292px;height:12px;border-radius:30px;background:var(--outline-stroke);overflow:hidden}
/* Width is driven frame by frame from script, so there is no transition here to
   fight it. */
.pm-bar{position:absolute;left:0;top:0;height:100%;width:0;border-radius:30px;
  background:var(--persimmon)}
/* It holds its place from the moment the row appears. It used to be display:none
   until the bar filled, and revealing a 24px button with a 12px gap into a centred
   row shoved everything left by 18px at exactly the moment the eye was on it — the
   jump at 100%. Visibility costs the same nothing and keeps the layout still. */
.pm-remove{flex:none;width:24px;height:24px;padding:0;border:0;background:none;cursor:pointer;color:var(--antique-gold);display:flex;align-items:center;justify-content:center;
  visibility:hidden;opacity:0;transform:scale(.86);
  transition:opacity .3s ease,transform .3s cubic-bezier(.22,1.2,.4,1),visibility .3s}
.pm-remove.is-in{visibility:visible;opacity:1;transform:none}
.pm-fileicon svg,.pm-remove svg{display:block}
/* Draw-something interactive canvas (Figma node 409:779) */
.pm-draw{position:absolute;inset:0;box-sizing:border-box;overflow:hidden;
  border:2px dashed var(--outline-stroke);border-radius:12px;display:flex;flex-direction:column}
.pm-toolbar{background:var(--dove-ivory);box-sizing:border-box;width:100%;
  display:flex;align-items:center;justify-content:space-between;padding:12px 24px}
.pm-tools-left{display:flex;align-items:center;gap:16px}
.pm-brushes{display:flex;align-items:center;gap:12px}
.pm-brush{background:none;border:0;padding:0;margin:0;cursor:pointer;display:flex;align-items:center;justify-content:center}
.pm-brush i{display:block;border-radius:50%;background:var(--antique-gold)}
.pm-brush[data-size="8"] i{width:8px;height:8px}
.pm-brush[data-size="12"] i{width:12px;height:12px}
.pm-brush[data-size="16"] i{width:16px;height:16px}
.pm-brush.active i{background:var(--dusty-granite)}
.pm-div{width:1px;height:20px;background:var(--disabled-gray);flex:none}
.pm-colors{display:flex;align-items:center;gap:12px}
.pm-color{width:16px;height:16px;border-radius:50%;border:0;padding:0;margin:0;cursor:pointer}
.pm-color.active{box-shadow:0 0 0 2px var(--dove-ivory),0 0 0 3.5px var(--dusty-granite)}
.pm-tool{background:var(--outline-stroke);border:0;border-radius:8px;padding:8px 16px;
  display:flex;align-items:center;gap:8px;cursor:pointer}
.pm-tool span{font-family:'Diatype';font-weight:400;font-size:12px;line-height:1.6;color:var(--dusty-granite);white-space:nowrap}
.pm-tool svg{color:var(--dusty-granite)}
.pm-tool.active{box-shadow:inset 0 0 0 2px var(--persimmon)}
.pm-canvas-wrap{flex:1 0 0;min-height:0;position:relative}
.pm-canvas{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair;touch-action:none}
.pm-hint{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
  display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}
.pm-hint svg{color:var(--antique-gold)}
.pm-hint p{font-family:'Kyoto';font-weight:500;font-size:16px;line-height:1.4;color:var(--dusty-granite);text-align:center;white-space:nowrap;margin:0}
#print-modal [hidden]{display:none}
.pm-cta{display:flex;gap:10px;align-items:center;padding:12px 16px;border:0;border-radius:8px;
  background:var(--nougat);overflow:hidden;cursor:default}
.pm-cta span{font-family:'Diatype';font-weight:400;font-size:20px;line-height:1.5;color:var(--disabled-gray);white-space:nowrap}
.pm-cta svg{color:var(--disabled-gray)}
/* Small credit to the vision-model author, tucked in the compose panel's bottom-left,
   aligned to its 32px padding so it sits under the title, opposite the CTA. */
.pm-credit{position:absolute;left:32px;bottom:32px;margin:0;
  font-family:'ApercuMono';font-weight:400;font-size:11px;line-height:1.3;
  color:var(--antique-gold);pointer-events:none}

/* ---------- Matchmaking state (Figma 457:1146) ----------
   A dashed well filling the card, with the four palette dots over the label. The
   dots are the same four the drawing tool offers, in the same order, so the wait
   reads as the tool thinking rather than as a generic spinner. */
.pm-loading{display:flex;align-items:center;justify-content:center}
.pm-well{width:984px;height:100%;box-sizing:border-box;
  border:2px dashed var(--outline-stroke);border-radius:12px;
  display:flex;flex-direction:column;gap:10px;align-items:center;justify-content:center}
/* 100x100 with 24px dots at 14/62 on both axes — Figma 457:1298 */
.pm-dots4{position:relative;width:100px;height:100px;flex:none}
.pm-dots4 i{position:absolute;width:24px;height:24px;border-radius:50%}
.pm-dots4 i:nth-child(1){left:14px;top:14px;background:var(--persimmon)}
.pm-dots4 i:nth-child(2){left:62px;top:14px;background:#1F597B}
.pm-dots4 i:nth-child(3){left:14px;top:62px;background:#6082F4}
.pm-dots4 i:nth-child(4){left:62px;top:62px;background:var(--hero-green)}
/* Taken off the reference recording (dots.mov) frame by frame, not invented.
   One cycle is 2.0s and has four beats:
     0-10%    rest at the corners
     10-33%   each dot travels one position CLOCKWISE around the square
     33-43%   hold
     43-62%   all four converge on the centre and swell; green swells furthest
     62-79%   hold as a single green circle
     79-100%  burst back out to the home corners
   Green is last in the DOM so it paints over the other three, which is what makes
   the convergence read as a merge rather than a pile-up. Travel is 48px (the centre
   spacing) and the merged circle is 60px across — 24px x 2.5 — both measured off
   the recording, which is a 2x capture. */
@media (prefers-reduced-motion:no-preference){
  /* linear here, with the easing stated per keyframe below. The shorthand's timing
     function applies to EVERY segment, so a single ease made all six accelerate and
     decelerate in turn — six little starts and stops instead of one travelling
     motion, which is what made it feel choppy. */
  .pm-dots4 i{animation:pm-orbit 2s linear infinite;will-change:transform}
  .pm-dots4 i:nth-child(1){animation-name:pm-orbit-tl}
  .pm-dots4 i:nth-child(2){animation-name:pm-orbit-tr}
  .pm-dots4 i:nth-child(3){animation-name:pm-orbit-bl}
  .pm-dots4 i:nth-child(4){animation-name:pm-orbit-br}
}
/* The 50% and 92% stops are what make it read correctly: the recording holds four
   DISTINCT enlarged dots drawn slightly inward (±19 from centre, not the resting
   ±24) both before the merge and after the burst. Without them the green swells
   too early and the whole thing collapses into a blob. */
/* top-left: right along the top, then in to the centre */
@keyframes pm-orbit-tl{
  0%,10%   {transform:translate(0,0) scale(1);animation-timing-function:cubic-bezier(.45,0,.35,1)}
  33%,43%  {transform:translate(48px,0) scale(1);animation-timing-function:cubic-bezier(.4,0,1,1)}
  50%      {transform:translate(43px,5px) scale(1.35);animation-timing-function:cubic-bezier(0,0,.2,1)}
  62%,79%  {transform:translate(24px,24px) scale(1.35);animation-timing-function:cubic-bezier(0,0,.3,1)}
  92%      {transform:translate(5px,5px) scale(1.35);animation-timing-function:cubic-bezier(.25,0,.2,1)}
  100%     {transform:translate(0,0) scale(1)}
}
/* top-right: down the right side */
@keyframes pm-orbit-tr{
  0%,10%   {transform:translate(0,0) scale(1);animation-timing-function:cubic-bezier(.45,0,.35,1)}
  33%,43%  {transform:translate(0,48px) scale(1);animation-timing-function:cubic-bezier(.4,0,1,1)}
  50%      {transform:translate(-5px,43px) scale(1.35);animation-timing-function:cubic-bezier(0,0,.2,1)}
  62%,79%  {transform:translate(-24px,24px) scale(1.35);animation-timing-function:cubic-bezier(0,0,.3,1)}
  92%      {transform:translate(-5px,5px) scale(1.35);animation-timing-function:cubic-bezier(.25,0,.2,1)}
  100%     {transform:translate(0,0) scale(1)}
}
/* bottom-left: up the left side */
@keyframes pm-orbit-bl{
  0%,10%   {transform:translate(0,0) scale(1);animation-timing-function:cubic-bezier(.45,0,.35,1)}
  33%,43%  {transform:translate(0,-48px) scale(1);animation-timing-function:cubic-bezier(.4,0,1,1)}
  50%      {transform:translate(5px,-43px) scale(1.35);animation-timing-function:cubic-bezier(0,0,.2,1)}
  62%,79%  {transform:translate(24px,-24px) scale(1.35);animation-timing-function:cubic-bezier(0,0,.3,1)}
  92%      {transform:translate(5px,-5px) scale(1.35);animation-timing-function:cubic-bezier(.25,0,.2,1)}
  100%     {transform:translate(0,0) scale(1)}
}
/* bottom-right: left along the bottom. Scales to 2.5 rather than 1.35 because this
   is the one that swallows the others at the centre. */
@keyframes pm-orbit-br{
  0%,10%   {transform:translate(0,0) scale(1);animation-timing-function:cubic-bezier(.45,0,.35,1)}
  33%,43%  {transform:translate(-48px,0) scale(1);animation-timing-function:cubic-bezier(.4,0,1,1)}
  50%      {transform:translate(-43px,-5px) scale(1.35);animation-timing-function:cubic-bezier(0,0,.2,1)}
  62%,79%  {transform:translate(-24px,-24px) scale(2.5);animation-timing-function:cubic-bezier(0,0,.3,1)}
  92%      {transform:translate(-5px,-5px) scale(1.35);animation-timing-function:cubic-bezier(.25,0,.2,1)}
  100%     {transform:translate(0,0) scale(1)}
}
.pm-well-label{font-family:'Kyoto';font-weight:500;font-size:16px;line-height:1.4;
  color:var(--dusty-granite);text-align:center;white-space:nowrap;margin:0}
/* The thoughts trail. Reserved height so the card does not grow line by line and
   shove the label around; only the newest line is at full strength, the ones above
   it recede, which is what makes it read as a train of thought rather than a log. */
/* A window with a list sliding under it. The list is never trimmed — trimming meant
   deleting a row, and that is a layout change, so everything above snapped up in a
   single frame while the incoming line was still fading in. That jolt was the pop.
   The list translates instead, which is one property on one element and never
   touches layout, and the mask dissolves whatever leaves the top rather than
   cutting it. */
.pm-trail{margin:14px 0 0;padding:0;width:100%;height:132px;overflow:hidden;
  -webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 30%,#000 100%);
          mask-image:linear-gradient(to bottom,transparent 0,#000 30%,#000 100%)}
.pm-trail-list{list-style:none;margin:0;padding:0;width:100%;
  display:flex;flex-direction:column;align-items:center;gap:7px;
  transition:transform .62s cubic-bezier(.22,.8,.3,1);will-change:transform}
.pm-trail li{font-family:'ApercuMono';font-weight:400;font-size:15px;line-height:1.4;
  color:var(--dusty-granite);text-align:center;margin:0;
  opacity:0;transform:translateY(9px);
  transition:opacity .62s ease,transform .62s cubic-bezier(.22,.8,.3,1)}
.pm-trail li.in{opacity:.38;transform:none}
.pm-trail li.in:last-child{opacity:1}
@media (prefers-reduced-motion:reduce){
  .pm-trail li,.pm-trail-list{transition:none}
  .pm-drop-inner,.pm-filerow,.pm-remove,.pm-pct{transition:none}
}

/* ---------- Matched state (Figma 459:62) ----------
   Not a card: a 300px ticket and a 720px panel with a 32px gutter, bottom-aligned,
   which is exactly the 1052 the other two states occupy. */
.pm-matched{display:flex;gap:32px;align-items:flex-end}

/* ----- the ticket ----- */
.pm-ticket{position:relative;width:300px;height:750px;flex:none}
.pm-tk-bg{position:absolute;inset:0;width:300px;height:750px;display:block}
.pm-ticket>*:not(.pm-tk-bg){position:absolute}
.pm-tk-kicker{left:13px;top:32.58px;width:269px;margin:0;
  font-family:'ApercuMonoPro';font-weight:500;font-size:14px;line-height:1.3;color:var(--antique-gold)}
.pm-tk-dots-top{left:13px;top:50.04px;width:283px}
/* Two behaviours, chosen per image by script (see fitSlot in index.html).
   Close to the slot's proportions -> fill it, since a few percent off the edges is
   invisible and thin mount bands just look like a mistake. Far off -> mount it,
   because a hanging scroll centre-cropped to a landscape box shows a random middle
   band of itself, which looks like a bug and does the painting a disservice.
   Default is the safe one: contain, until the image has loaded and been measured. */
.pm-tk-slot{left:13px;width:275px;height:239.721px;border-radius:4px;overflow:hidden;
  background:var(--dove-ivory);display:flex;align-items:center;justify-content:center}
.pm-tk-slot img{width:100%;height:100%;object-fit:contain;display:block}
.pm-tk-slot img.is-fill,.pm-matchthumb img.is-fill{object-fit:cover}
.pm-tk-slot img:not([src]),.pm-matchthumb img:not([src]){display:none}
.pm-tk-yours{top:73px}
.pm-tk-art{top:327px}
/* Anchored to the BOTTOM, not the top. Figma places this at y=613.78 with a
   one-line artwork title, but real titles wrap — "The Temptation of Saint Anthony,
   Herri met de Bles" runs to two — and a top-anchored block pushed the date out
   through the ticket's bottom edge. Growing upward instead uses the gap above,
   which is empty, and the resting position is identical: 750 - 613.78 - 121.02. */
/* Fixed height with space-between, so the two pairs and the rule between them sit
   at the same rhythm whatever the titles say. Growing the block to fit a long title
   was what made the spacing drift: the gaps stayed put while the block moved, and
   a two-line artwork title read as lopsided against a one-line user title. Every
   row is one line now (see .pm-tk-t).

   bottom:23 rather than the 15.2 that Figma's own y implies. Measured off the
   rasterised ticket: the perforation holes end at y=593 and the flat bottom edge
   ends at y=740, so the old offset left 20.8px above the block and 5.2px below —
   a four-to-one imbalance, and the reason the top looked padded. 23 puts 13px on
   each side. */
.pm-tk-meta{left:13px;bottom:23px;width:280px;height:121.02px;
  display:flex;flex-direction:column;justify-content:space-between}
.pm-tk-pair{display:flex;flex-direction:column;gap:2px;width:100%}
/* One line, then an ellipsis. Met titles run to a full sentence, and letting them
   wrap is what threw the spacing out. The full string stays on the title attribute
   so nothing is actually lost. */
.pm-tk-t{margin:0;font-family:'Kyoto';font-weight:500;font-size:16px;line-height:1.4;color:var(--dusty-granite);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pm-tk-d{margin:0;font-family:'ApercuMonoPro';font-weight:500;font-size:14px;line-height:1.4;color:var(--antique-gold)}
/* The dotted rules are generated, not typed: a literal string of bullets cannot be
   made to end flush at an arbitrary width, and these sit at three different widths. */
.pm-tk-dots,.pm-dots{margin:0;overflow:hidden;white-space:nowrap;
  font-family:'Kyoto';font-weight:300;font-style:italic;font-size:12px;line-height:1.3;
  color:var(--antique-gold)}
.pm-tk-dots::before,.pm-dots::before{content:"•••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••"}

/* ----- the panel ----- */
.pm-panel{position:relative;flex:1 0 0;min-width:0;height:750px;box-sizing:border-box;
  background:var(--parchment);border-radius:12px;padding:32px;
  display:flex;flex-direction:column;gap:32px;align-items:center}
.pm-panel-in{display:flex;flex-direction:column;gap:24px;align-items:flex-start;
  flex:1 0 0;min-height:0;width:100%}
.pm-panel-top{display:flex;flex-direction:column;gap:16px;align-items:flex-start;width:100%}
.pm-mhead{display:flex;flex-direction:column;gap:12px;align-items:flex-start;width:100%}
.pm-mtitleblock{display:flex;flex-direction:column;gap:4px;align-items:flex-start;width:100%}
.pm-mtitle{margin:0;font-family:'Mackinac';font-weight:500;font-size:28px;line-height:1.3;color:var(--dusty-granite);width:100%}
.pm-msubtitle{margin:0;font-family:'ApercuMonoPro';font-weight:500;font-size:18px;line-height:1.4;color:var(--antique-gold);width:100%}
/* the rule sits at 604 inside the 32px-inset content box, so 636 from the panel edge */
.pm-panel-rule{position:absolute;left:0;top:636px;width:100%;height:1px;background:var(--outline-stroke)}

.pm-fields{display:flex;gap:18px;align-items:flex-end;width:100%}
.pm-field{flex:1 0 0;min-width:0;display:flex;flex-direction:column;align-items:flex-start}
.pm-field-box{display:flex;gap:10px;align-items:center;width:100%;height:58px;box-sizing:border-box;
  border:1px solid var(--antique-gold);border-radius:8px;padding:0 16px;overflow:hidden}
.pm-input{flex:1 0 0;min-width:0;border:0;background:none;padding:0;margin:0;
  font-family:'Diatype';font-weight:400;font-size:16px;line-height:1.6;color:var(--dusty-granite)}
.pm-input:focus{outline:none}
.pm-field-box:focus-within{border-color:var(--dusty-granite)}
/* The required asterisk is persimmon while the label is ink, and ::placeholder cannot
   be styled per-character — so the label is a real element sitting over the field,
   and JS removes it once the field has a value. It also gives the date input a
   resting label instead of the browser's mm/dd/yyyy. */
.pm-field-box{position:relative}
.pm-ph{position:absolute;left:16px;top:50%;transform:translateY(-50%);pointer-events:none;
  font-family:'Diatype';font-weight:400;font-size:16px;line-height:1.6;color:var(--dusty-granite)}
.pm-ph em{font-style:normal;color:var(--persimmon)}
.pm-field-box.has-value .pm-ph{display:none}
.pm-cal{flex:none;color:var(--antique-gold)}
/* Hide the native mm/dd/yyyy until there is a value, so .pm-ph reads cleanly; the
   picker indicator is made transparent and stretched over our own calendar glyph. */
.pm-field-box:not(.has-value) .pm-in-date{color:transparent}
.pm-in-date::-webkit-calendar-picker-indicator{position:absolute;right:16px;
  width:24px;height:24px;margin:0;padding:0;opacity:0;cursor:pointer}

.pm-matchbox{box-sizing:border-box;width:100%;
  border:2px dashed var(--outline-stroke);border-radius:12px;padding:40px;
  display:flex;flex-direction:column;align-items:flex-start;justify-content:center}
.pm-matchrow{display:flex;gap:24px;align-items:flex-start;justify-content:center}
.pm-matchthumb{flex:none;width:200px;height:150px;border-radius:4px;overflow:hidden;background:var(--dove-ivory)}
.pm-matchthumb img{width:100%;height:100%;object-fit:contain;display:block}
.pm-matchtext{display:flex;flex-direction:column;gap:8px;align-items:flex-start;justify-content:center}
.pm-matchname{margin:0;width:269px;font-family:'ApercuMonoPro';font-weight:500;font-size:14px;line-height:1.3;color:var(--antique-gold)}
.pm-matchwhy{margin:0;width:269px;font-family:'Diatype';font-weight:400;font-size:16px;line-height:1.4;color:var(--dusty-granite)}

.pm-actions{display:flex;gap:32px;align-items:flex-start}
.pm-btn{display:flex;gap:10px;align-items:center;box-sizing:border-box;height:48px;
  padding:12px 16px;border-radius:8px;border:0;background:var(--nougat);cursor:pointer}
.pm-btn span{font-family:'Diatype';font-weight:400;font-size:16px;line-height:1.5;white-space:nowrap}
.pm-btn svg{flex:none;display:block}
/* inset shadow, not a border: Figma draws the stroke inside, so a real border
   would add 2px to the 145px the design specifies */
.pm-again{box-shadow:inset 0 0 0 1px var(--outline-stroke)}
.pm-again span,.pm-again svg{color:var(--dusty-granite)}
/* Quieter than the two below it, and inside the dashed box, because it belongs to the
   match rather than to the ticket: it re-rolls the painting against the same picture. */
.pm-rematch{height:36px;padding:8px 12px;margin-top:20px;background:transparent;
  box-shadow:inset 0 0 0 1px var(--outline-stroke)}
.pm-rematch span{font-size:14px}
.pm-rematch span,.pm-rematch svg{color:var(--dusty-granite)}
.pm-save:disabled{cursor:default}
.pm-save:disabled span,.pm-save:disabled svg{color:var(--disabled-gray)}
.pm-save.is-enabled{background:var(--persimmon)}
.pm-save.is-enabled span,.pm-save.is-enabled svg{color:var(--parchment)}
.pm-cta.is-enabled{background:var(--persimmon);cursor:pointer}
.pm-cta.is-enabled span,.pm-cta.is-enabled svg{color:var(--parchment)}

/* ================= PAGE MOTION =================
   Enabled only when site-motion.js adds .pt to <html>, so reduced-motion users
   and no-JS visitors get the page with no animation at all. */
/* Cross-document transitions have one hard constraint: two documents cannot see each
   other. Every hand-rolled version of this either left a dead middle (fade out, wait
   for the fetch, fade in) or made the departure opacity depend on network speed. So
   hand it to the browser: it snapshots the outgoing page, fetches underneath it, and
   cross-fades the two itself. Nothing to time, and no shared value to agree on.
   Both documents must opt in -- they share this stylesheet, so they do. */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
}
::view-transition-old(root),
::view-transition-new(root){animation-duration:.32s;animation-timing-function:ease}

/* The glass bar and the back arrow are the two things that sit in the same place on
   every page. Left unnamed they were part of the root cross-fade like everything
   else, so crossing from About to Library faded the bar out and faded an identical
   bar back in -- which is exactly what read as a jump rather than a move.

   Naming them hands each to the browser as one persistent object: it matches the
   outgoing box to the incoming one and morphs between them. Where the two agree the
   bar simply holds still through the whole navigation, which is the point.

   The name has to be unique per document, and it is: one .nav per page, and the
   three back arrows never appear together. */
.nav{view-transition-name:glass-menu}
.ab-back,.lb-back,.cs-back{view-transition-name:back-arrow}
::view-transition-group(glass-menu),
::view-transition-group(back-arrow){animation-duration:.42s;
  animation-timing-function:cubic-bezier(.4,0,.2,1)}
/* The pill is a different width on each page, so its group box animates between the
   two. Left alone the browser stretches each snapshot to fill that animating box,
   which squashes the type horizontally for the whole 420ms. Holding the snapshots at
   their own size and centring them means the PILL grows while its contents simply
   cross-fade, which is what "expanding" should look like. */
::view-transition-old(glass-menu),
::view-transition-new(glass-menu){height:100%;width:auto;
  object-fit:none;object-position:center}

/* Fallback only where cross-document view transitions are unavailable. A short dim
   that always COMPLETES before navigating, so both sides meet at .55 -- overlapping
   the fade with the fetch instead made the exit opacity vary with network speed
   (measured .72/.82/.91/.97 leaving against a constant .2 arriving). */
@supports not (view-transition-name: none) {
  .pt body{animation:pt-in .22s ease both}
  .pt.pt-leaving body{animation:pt-out .13s ease both}
  @keyframes pt-in{from{opacity:.55}to{opacity:1}}
  @keyframes pt-out{from{opacity:1}to{opacity:.55}}
}

/* Case-study title: each CHARACTER fades up out of a blur, staggered. Per-word reads
   as a wave only on a long headline; on a two-word title it is two discrete pops.
   .w just keeps a word from breaking across lines. */
.pt .cs-title .w{display:inline-block;white-space:nowrap}
.pt .cs-title .c{display:inline-block;opacity:0;filter:blur(8px);
  transform:translateY(12px);will-change:transform,filter;
  animation:char-in .62s cubic-bezier(.2,.7,.3,1) both;animation-delay:var(--d,0s)}
@keyframes char-in{to{opacity:1;filter:blur(0);transform:none}}
/* The kicker leads, the intro and metadata follow the title in. */
.pt .cs-kicker{animation:rise-in .6s cubic-bezier(.2,.7,.3,1) both}
.pt .cs-headgroup{animation:rise-in .7s cubic-bezier(.2,.7,.3,1) both .42s}
@keyframes rise-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}

/* ================= LINK SYSTEM =================
   The accent means interaction. Standalone links (nav, cards, footer, icons)
   inherit their surroundings and take the accent on hover; links set inside
   prose carry the accent at rest, because they have to be findable mid-sentence.
   The accent follows the surface: persimmon on light, new-leaf green on the
   persimmon footer, where persimmon would be invisible. */
.foot-links a:hover,.foot-nav a:hover{color:var(--new-leaf-green)}
.foot-nav a.index:hover{color:var(--cream)}

/* ---------- Back to top ----------
   Same component language as the case study's .pux-toggle, matched value for value:
   36px circle, 1px --outline-stroke hairline, --parchment fill, and a 2px
   --antique-gold chevron with round caps. The concentric ring is that button's pulse
   ring held static at inset -6px, since a permanent pulse on a persistent control
   would nag rather than invite.

   It hides again over the footer: a parchment disc floating on the persimmon block
   reads as a stray element, and by then you can see the top link anyway. */
.to-top{position:fixed;right:40px;bottom:40px;z-index:40;width:36px;height:36px;padding:0;
  border:1px solid var(--outline-stroke);border-radius:50%;background:var(--parchment);
  cursor:pointer;display:flex;align-items:center;justify-content:center;
  opacity:0;visibility:hidden;transform:translateY(10px);
  transition:opacity .32s ease,transform .32s cubic-bezier(.34,1.4,.5,1),
             visibility .32s,border-color .25s ease}
.to-top::after{content:"";position:absolute;inset:-6px;border-radius:50%;
  border:1px solid var(--outline-stroke);opacity:.55;
  transition:transform .3s cubic-bezier(.34,1.4,.5,1),opacity .3s ease}
.to-top.is-on{opacity:1;visibility:visible;transform:none}
.to-top:hover{border-color:var(--antique-gold)}
.to-top:hover::after{transform:scale(1.12);opacity:.9}
.to-top:focus-visible{outline:2px solid var(--persimmon);outline-offset:4px}
.to-top svg{display:block;width:24px;height:24px}
@media (prefers-reduced-motion:reduce){
  .to-top,.to-top::after{transition:opacity .2s ease,visibility .2s}
}

/* Home hero entrance. The clusters carry a JS-set scale() transform, so this animates
   opacity only -- touching transform here would fight fit(). Staggered left -> centre
   -> right so the page assembles rather than appearing all at once.

   #hg-center is left out of it. It holds nothing but the pill, which makes it this
   page's navwrap rather than a hero cluster; it keeps the .hgroup class only because
   fit() positions and scales all three the same way. About and Library already keep
   their pills outside the fading content, in #ab-navwrap and #lb-navwrap, and this is
   the same arrangement written the same way.

   Leaving it in was what made the glass menu stick. .nav carries
   view-transition-name:glass-menu, so the browser morphs the pill from the old page to
   the new one as a single persistent object -- but on this page the destination sat at
   opacity 0 behind the hero image decode. The shared pill animated toward something
   invisible, arrived part transparent, and held there until hero-in fired. Chrome that
   persists across a navigation cannot also be content that fades in on arrival. */
.js #hg-left,.js #hg-right{opacity:0;transition:opacity .55s ease}
.js.hero-in #hg-left{opacity:1}
.js.hero-in #hg-right{opacity:1;transition-delay:.18s}
/* Its own transition rather than the clusters', matching the other two navwraps, so
   standing down at the footer still fades instead of cutting. */
#hg-center{transition:opacity .3s ease}
@media (prefers-reduced-motion:reduce){
  .js #hg-left,.js #hg-right{opacity:1;transition:none}
  #hg-center{transition:none}
}

/* Library entrance. Every other page hands the cross-fade off to something that keeps
   moving -- home and About fade their clusters in, a case study reveals its title and
   then its figures -- so the navigation resolves over about three quarters of a second
   rather than stopping dead at 320ms. Library had nothing, and arriving at a page that
   is simply, instantly there read as a cut rather than as an arrival. It was the only
   page in the site without an entrance.

   Three steps because Library has three blocks, which is also what the home hero has:
   the same .55s ease and the same 90ms interval, opacity only. Reusing those numbers
   rather than picking new ones is the point -- the two pages should arrive at the same
   speed.

   Gated on .pt, so reduced-motion and no-JS visitors get the page with no animation,
   and written as an animation rather than a class-triggered transition so it starts on
   parse, needs nothing to release it, and can never strand content at opacity 0.

   It deliberately does NOT wait for anything. An earlier version held the page until
   the fonts and the ground had both landed, which removed the re-typesetting but put a
   noticeable pause in front of every visit -- a page that waits reads as stuck, which
   is worse than one that settles.

   What makes starting immediately safe is the preloading in library.html, not luck.
   The four faces carrying text that is on screen at arrival now land between 268ms and
   430ms, well inside this 730ms fade, so the swap happens while the text is still
   part transparent and the motion covers it. Before those preloads they arrived at 737ms to
   913ms, after the fade had finished, and swapping on a settled page is exactly what
   read as broken. If a face is ever added to the top of this page, preload it too. */
.pt .lb-title,.pt .lb-intro,.pt .lb-shelf{animation:lb-in .55s ease both}
.pt .lb-intro{animation-delay:.09s}
.pt .lb-shelf{animation-delay:.18s}
@keyframes lb-in{from{opacity:0}to{opacity:1}}



/* ----- Card 4: Copilot in Viva Engage -----
   Slate, so the one Microsoft case study is not wearing a WhatsApp colour. It is the
   coolest thing in the palette and the only card that is neither green nor blue nor
   cream, which is what stops the row reading as one hue getting lighter. Still a
   palette value, so it is different without being off the mark. */
/* The hero animation restated at thumbnail size. The cast is the same 1464x834 frame the
   case study uses, so everything expressed in cast coordinates -- the four states, and the
   cover that meters the answer out -- is identical to it; only the camera and the pointer
   change, because those live in view space and the view here is 529x304 rather than
   799x307. At .345 the window is 505x288 inside that box, so it stays whole with ground
   showing all round, the same rule the hero follows.

   The keyframes are duplicated rather than shared: the home page does not load
   case-study.css, and hoisting figure internals into the global sheet to save forty lines
   would be the worse trade. If the hero timing changes, change it in both places.

   Images are a lighter cut of the same four frames -- 1200px against the 2928px the case
   study loads. The cast renders 505 CSS px here, so 1200 still covers a 2x screen, and the
   home page carries 318KB instead of 1.2MB. */
.card-copilot .card-photo{background:var(--slate)}
/* The photograph stays as the ground -- the window sits on it the way the two phone
   thumbnails sit on theirs. Above .cp-field at z-index 0, and transparent, so the field
   shows everywhere the window does not reach. */
.ch-cam{position:absolute;inset:0;overflow:hidden;z-index:1}
/* Two framings, and the move between them is the story. Wide is .29 -- 425x242 with 52px
   of ground either side, a screen sitting on a field the way the other two thumbnails do.
   The corner framing is .40, placed so the window's own bottom right corner lands about
   two thirds across the card and near its foot -- view 352.7,266 -- which leaves 176px of
   field to its right and a strip beneath it in shot. Dead centre left the right half of
   the card empty and the composition sat off balance. The screen runs off the top and the left instead of covering
   everything: what is being looked at is a corner, so the corner is what is composed, and
   the ground it sits on is still there. Anchoring it to fill the frame, which is what an
   earlier pass did at .52, is the thing to avoid.

   So it pushes in to watch the entry point being pressed, holds while the nudge expands
   and gets tapped, and pulls back out as the panel starts answering -- the answer is a
   column of text, and reading it is not what a thumbnail is for. */
.ch-cast{position:absolute;left:0;top:0;width:1440px;height:810px;transform-origin:0 0;
  transform:translate(52.1px,32.5px) scale(.295);will-change:transform;
  border-radius:6.3px;box-shadow:0 16px 40px rgba(28,24,22,.13),0 2px 6px rgba(28,24,22,.07);
  animation:ch-cam 16s cubic-bezier(.4,0,.2,1) infinite}
@keyframes ch-cam{
  0%,16%    {transform:translate(52.1px,32.5px) scale(.295)}
  22%,40%   {transform:translate(-233.4px,-63.7px) scale(.407)}
  48%,100%  {transform:translate(52.1px,32.5px) scale(.295)}
}
.ch-shot{position:absolute;left:0;top:0;width:1440px;height:810px;display:block;opacity:0;
  animation-duration:16s;animation-timing-function:linear;animation-iteration-count:infinite}
.ch-shot.s1{opacity:1;animation-name:ch-s1}
.ch-shot.s2{animation-name:ch-s2}
.ch-shot.s3{animation-name:ch-s3}
.ch-shot.s4{animation-name:ch-s4}

@keyframes ch-s1{0%,23.5%{opacity:1} 25.5%,91%{opacity:0} 94%,100%{opacity:1}}
@keyframes ch-s2{0%,23.5%{opacity:0} 25.5%,35.5%{opacity:1} 38%,100%{opacity:0}}
@keyframes ch-s3{0%,35.5%{opacity:0} 38%,65%{opacity:1} 67%,100%{opacity:0}}
@keyframes ch-s4{0%,65%{opacity:0} 67%,91%{opacity:1} 94%,100%{opacity:0}}
/* Cast coordinates, so these are the case study's numbers unchanged. */
.ch-veil{position:absolute;left:1081px;top:98px;width:359px;height:480px;overflow:hidden;
  z-index:2;opacity:0;pointer-events:none;animation:ch-on 16s linear infinite}
.ch-veil i{position:absolute;display:block}
.ch-veil .v-run{left:0;top:0;width:359px;height:500px;will-change:transform;
  animation:ch-stream 16s cubic-bezier(.4,0,.2,1) infinite}
.ch-veil .v-fill{left:0;top:0;width:359px;height:500px;
  background:linear-gradient(to bottom,rgba(250,250,250,0),#FAFAFA 14px)}
.ch-veil .v-p1{left:0;top:0;width:359px;height:70px;background:#FAFAFA;
  animation:ch-p1 16s linear infinite}
.ch-veil .v-p2{left:0;top:70px;width:359px;height:65px;background:#FAFAFA;
  animation:ch-p2 16s linear infinite}
@keyframes ch-p1{0%,41%{opacity:1} 43.2%,100%{opacity:0}}
@keyframes ch-p2{0%,45%{opacity:1} 47.2%,100%{opacity:0}}
@keyframes ch-on{0%,35.5%{opacity:0} 38%,90%{opacity:1} 91%,100%{opacity:0}}
@keyframes ch-stream{
  0%,39%     {transform:translateY(0px);animation-timing-function:steps(1,end)}
  41%,43%    {transform:translateY(70px);animation-timing-function:steps(1,end)}
  45%,47%    {transform:translateY(135px)}
  49.00%    {transform:translateY(166px)}
  49.46%    {transform:translateY(166px)}
  49.93%    {transform:translateY(190px)}
  50.39%    {transform:translateY(190px)}
  50.86%    {transform:translateY(211px)}
  51.32%    {transform:translateY(211px)}
  51.79%    {transform:translateY(234px)}
  52.25%    {transform:translateY(234px)}
  52.71%    {transform:translateY(258px)}
  53.18%    {transform:translateY(258px)}
  53.64%    {transform:translateY(278px)}
  54.11%    {transform:translateY(278px)}
  54.57%    {transform:translateY(298px)}
  55.04%    {transform:translateY(298px)}
  55.50%    {transform:translateY(318px)}
  55.96%    {transform:translateY(318px)}
  56.43%    {transform:translateY(338px)}
  56.89%    {transform:translateY(338px)}
  57.36%    {transform:translateY(357px)}
  57.82%    {transform:translateY(357px)}
  58.29%    {transform:translateY(377px)}
  58.75%    {transform:translateY(377px)}
  59.21%    {transform:translateY(398px)}
  59.68%    {transform:translateY(398px)}
  60.14%    {transform:translateY(418px)}
  60.61%    {transform:translateY(418px)}
  61.07%    {transform:translateY(441px)}
  61.54%    {transform:translateY(441px)}
  62.00%    {transform:translateY(471px)}
  64%,100%   {transform:translateY(480px)}
}
/* 18px against the case study's 32: the disc should read the same size relative to the
   window, and the window is smaller here. */
.ch-cur{position:absolute;left:0;top:0;width:18px;height:18px;margin:-9px 0 0 -9px;
  border-radius:50%;border:1px solid rgba(255,255,255,.92);
  background:rgba(0,0,0,.10);box-shadow:0 10px 24px rgba(0,0,0,.25),0 2px 6px rgba(0,0,0,.18);
  opacity:0;z-index:3;will-change:transform;animation:ch-cur 16s linear infinite}
/* T1 and T2 are read through the corner camera, T3 through the wide one, because the
   pull-back happens between them. The pointer waits through the move, as a hand does. */
@keyframes ch-cur{
  0%,15%     {transform:translate(362px,266px) scale(1);opacity:0;animation-timing-function:cubic-bezier(.4,0,.2,1)}
  16.5%      {opacity:1}
  22%        {transform:translate(324.2px,239.5px) scale(1);opacity:1}
  23.2%      {transform:translate(324.2px,239.5px) scale(.82);opacity:1}
  24.4%,27%  {transform:translate(324.2px,239.5px) scale(1);opacity:1;animation-timing-function:cubic-bezier(.4,0,.2,1)}
  34%        {transform:translate(211.5px,239.9px) scale(1);opacity:1}
  35.2%      {transform:translate(211.5px,239.9px) scale(.82);opacity:1}
  36.4%,57%  {transform:translate(211.5px,239.9px) scale(1);opacity:1;animation-timing-function:cubic-bezier(.4,0,.2,1)}
  63%        {transform:translate(415.2px,95.6px) scale(1);opacity:1}
  64.2%      {transform:translate(415.2px,95.6px) scale(.82);opacity:1}
  65.4%,66%  {transform:translate(415.2px,95.6px) scale(1);opacity:1}
  68%,100%   {transform:translate(415.2px,95.6px) scale(1);opacity:0}
}
@media (prefers-reduced-motion:reduce){
  .ch-cast,.ch-shot,.ch-veil,.ch-veil .v-run{animation:none}
  .ch-cur{display:none}
}

/* ----- Card 3: Split Payments, still to come -----
   A flat field, not the Figma frame's mesh. The corrugated glass read as a lit, three
   dimensional object beside two thumbnails that are quiet product shots on flat tint,
   so it pulled the eye away from the work.

   And no stripes. Banding at low alpha is not texture, it is just faint stripes: the
   eye reads a repeating edge as structure however soft it is. Texture has to be
   irregular, so it comes from noise at two scales -- a coarse mottle for unevenness in
   the field and a fine grain over the top for the surface. Neither repeats at a size
   the eye can find.

   The toast is --persimmon, which is palette. #F4BDCA is the only colour here that
   is not, down from the five the mesh needed.

   Nothing is blurred and nothing overhangs, so the clean edge is free: every layer is
   inset:0 inside .card-photo's existing radius and overflow. */
/* Fallback tone only, as above. The field is Figma 777:1811, a warm amber photograph. */
.card-split .card-photo{background:#C9A46B}
.sp-photo{cursor:none;--mx:264.5px;--my:152px;--rot:0deg}
/* Explicit width and height, not inset:0. A canvas is a replaced element, so inset
   alone leaves it at its intrinsic 1058x608 attribute size rather than stretching it to
   the box -- the span this replaced did stretch, which is why the bug arrived with the
   swap. Sparks were being painted at double scale and mostly outside the card. */
.sp-trail{position:absolute;left:0;top:0;width:529px;height:304px;
  display:block;pointer-events:none;z-index:1}

/* The noise layers that lived here are gone. Card 3 now carries the same halftone
   canvas as the other three, so its texture comes from the shared pattern rather than
   from a second system only this card used. Sheen and toast are unchanged. */

/* The toast leaves persimmon behind it instead of dragging a light with it. The glow
   was a white radial pinned to the pointer, which on a photograph read as a smudge
   sitting on top rather than anything happening in the card. Sparks are emitted at the
   toast's own position as it moves and decay where they were dropped, so the mark
   belongs to the path rather than to the cursor. */

/* The toast is the cursor. At rest it sits dead centre; on hover it tracks the pointer
   tightly and the real cursor is hidden under it, so the label becomes the thing you
   are moving. On the way out it takes half a second to glide home. */
.sp-toast{position:absolute;left:0;top:0;width:144px;height:40px;z-index:2;
  display:flex;align-items:center;justify-content:center;
  border-radius:10px;background:var(--persimmon);color:var(--parchment);
  font-family:'ApercuMonoPro';font-weight:500;font-size:13px;letter-spacing:.06em;
  text-transform:uppercase;white-space:nowrap;pointer-events:none;
  box-shadow:0 6px 18px rgba(66,62,61,.20);
  transform:translate(calc(var(--mx) - 72px),calc(var(--my) - 20px)) rotate(var(--rot,0deg));
  /* Overshoot on the way home. A clean glide to a stop is what a UI element does; an
     object with mass arrives slightly past where it is going and settles back. The 1.35
     control point is the whole of it. */
  transition:transform .62s cubic-bezier(.22,1.35,.36,1)}
/* Tight follow while the pointer is inside. The lean is driven by script rather than by
   this transition, so it eases on its own clock and does not fight the tracking. */
.sp-photo.is-live .sp-toast{transition:transform .07s linear}

@media (prefers-reduced-motion:reduce){
  .sp-trail{display:none}
  .sp-toast{transition:none}
  .sp-photo{cursor:default}
}
