/* Shared across every page: design tokens, fonts, hero, work, modal and footer.
   Page-specific rules live in that page's own <style> block. */
/* ---------- Fonts ----------
   The real faces, as the original design used them: PP Kyoto, ABC Diatype, Apercu
   Mono, P22 Mackinac and Monument Grotesk. The open substitutes that were here
   before are kept as the second name in every stack, so pulling assets/fonts out
   of the repo degrades the page rather than breaking it.

   LICENSING, and it matters before this goes public: all five are commercial and
   none of them is licensed for redistribution. Serving them from a public origin
   is the part that is not allowed -- working locally is not. Before publishing,
   buy the webfont licences (PP Kyoto from Pangram Pangram, Diatype and Monument
   Grotesk from Dinamo, Apercu from Colophon, Mackinac from P22) or delete this
   folder, at which point every stack below falls through to its open second
   choice and the layout is unchanged. */
@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 original folder with no face pointing at it, so the
   name on the index, "Hello world!" and every case-study title resolved to the
   medium upright and got 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: 'ApercuMono';
   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
}

@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&family=Inter:wght@400;500;700&family=JetBrains+Mono:wght@400;500&family=Fraunces:opsz,wght@9..144,400..700&family=Space+Grotesk:wght@500;700&display=swap');

/* ---------- Tokens ---------- */
:root {
   --parchment: #FDFBEF;
   --antique-gold: #A7A58F;
   /* Was --persimmon #F2511A, and was renamed with the colour because a variable
      called persimmon holding a blue is a trap for whoever edits this next.

      #5690AE is measured, not picked: it sits on the hue the Bannister's water and the
      Monet's blues actually occupy, sampled from the files, and at L* 56.9, which is
      the orange's own lightness. Matching lightness rather than matching some RGB
      sum is what keeps every element that used the orange reading at the same weight
      as before, so nothing else in the palette needed retuning.

      One orange survives on purpose: the asterisk on the laptop screen in the hero
      artwork. It is painted into the picture, and now that it is the only warm mark
      on the page it reads as a deliberate spot rather than as a leftover. */
   --accent: #5690AE;
   /* The footer is 405px of flat accent, and a colour that is right for a link at
      14px is too light across a whole band. Same hue and chroma, L* 47 instead of
      57, so it still reads as one colour with the accent rather than as a second
      blue. Only the footer and its bleed use it. */
   --accent-deep: #3D7791;
   --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(--accent-deep)
}

/* ================= 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
}

/* Hi-res forest (blur + grain baked in); 91% over the parchment frame matches Figma's tone. */
/* The scrim.

   The hero used to carry a photograph dark enough to hold parchment type on its own,
   so there was nothing here. Every painting is high-key Impressionism, and without
   this the name and the six lines of bio dissolve into the brushwork. Measured across
   sixteen candidates: only the darkest of them was readable unaided.

   Weighted to the LEFT rather than laid flat, because that is where the type is. The
   right two thirds stay almost clear so the painting still reads as a painting and
   the cats sit on something rather than on a grey field.

   Tuning: the first number is the strength. .58 suits the current Bannister; a paler
   ground wants about .72. Change that one value, not the whole ramp. */
#hero::after {
   content: "";
   position: absolute;
   inset: 0;
   z-index: 1;
   pointer-events: none;
   background: linear-gradient(100deg,
      rgba(18, 26, 22, .58) 0%,
      rgba(18, 26, 22, .55) 34%,
      rgba(18, 26, 22, .20) 72%,
      rgba(18, 26, 22, 0) 100%)
}
/* Above the scrim. Without this the clusters sit under it and the whole hero dims. */
.hgroup { z-index: 2 }

/* 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,
.mascot {
   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', 'Newsreader', Georgia, serif;
   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(--accent);
   flex: none
}

.loc p {
   width: 572px;
   font-family: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-size: 20px;
   line-height: 1.3;
   color: var(--parchment);
   white-space: pre-wrap
}

.loc .wa {
   position: relative;
   font-family: 'Monument', 'Space Grotesk', system-ui, sans-serif;
   font-weight: 700;
   color: var(--hero-green);
   text-decoration: none
}

.bio {
   margin-top: 24px;
   width: 572px;
   font-family: 'Diatype', 'Inter', system-ui, sans-serif;
   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', 'Newsreader', Georgia, serif;
   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(--accent)
}

.nav-icons svg {
   color: #AEAEAC
}

.nav a {
   display: flex;
   color: inherit;
   text-decoration: none;
   position: relative
}

/* Hover preview above each icon (Figma 460:142): accent 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(--accent);
   color: var(--cream);
   border-radius: 8px;
   padding: 8px 12px;
   font-family: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   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,
#pg-navwrap.is-away,
#cs-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,
#pg-navwrap.is-away,
#cs-navwrap.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 take the accent */
.nav a svg,
.nav-home .label {
   transition: color .15s ease
}

.nav a:hover svg {
   color: var(--accent)
}

.nav-home a:hover~.label {
   color: var(--accent)
}

/* 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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   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', 'Newsreader', Georgia, serif;
   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', 'Newsreader', Georgia, serif;
   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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-weight: 500;
   font-size: 14px;
   line-height: 1.4;
   color: var(--antique-gold);
   margin-top: 2px
}

.cap-dots {
   font-family: 'Kyoto', 'Newsreader', Georgia, serif;
   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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   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(--accent);
   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(--accent)
}

#hg-right:has(.pc-print:hover) .pc-btn,
.pc-btn:hover {
   background: var(--accent);
   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 */
.mascot {
   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 */
/* Shifted right and up, clear of the artwork. They were pinned to the mascot's
   old 400px square slot, and the drawing that replaced it is landscape and
   reaches x 530, so the motifs were landing on the cat instead of around her. */
.spk-layer {
   position: absolute;
   left: 452px;
   top: 398px;
   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
   }
}

.mascot-label {
   position: absolute;
   left: 154.1px;
   top: 620px;
   font-family: 'Mackinac', 'Fraunces', Georgia, serif;
   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. */
.mascot-cap,
.mascot-label {
   pointer-events: none
}

.mascot-cap {
   position: absolute;
   left: 37.9px;
   top: 551.6px;
   width: 191px;
   font-family: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-size: 10px;
   line-height: 1.3;
   color: var(--sage);
   transform: rotate(-4.91deg)
}

/* The speech bubble is gone: the prompt is typed onto the laptop screen in the
   artwork instead, from index.html. @keyframes blink stays, because the caret
   on that screen still uses it. */
@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 .shot {
   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 img {
   width: 100%;
   height: 100%;
   object-fit: cover
}


.card-dots {
   position: absolute;
   left: 24px;
   top: 80.5px;
   width: 527px;
   height: 15px;
   overflow: hidden;
   font-family: 'Kyoto', 'Newsreader', Georgia, serif;
   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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-weight: 500;
   font-size: 16px;
   line-height: 1.3;
   color: var(--accent)
}

.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', 'Fraunces', Georgia, serif;
   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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   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', 'Inter', system-ui, sans-serif;
   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', 'Fraunces', Georgia, serif;
   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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-weight: 500;
   font-size: 14px;
   line-height: 1;
   white-space: nowrap
}

.mini-desc {
   margin-top: 8px;
   font-family: 'Diatype', 'Inter', system-ui, sans-serif;
   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-personal {
   color: var(--accent)
}

.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(--accent-deep);
   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/ui/plant-cursor.svg") 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/ui/plant-cursor-dark.svg") 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', 'Fraunces', Georgia, serif;
   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', 'Fraunces', Georgia, serif;
   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', 'JetBrains Mono', ui-monospace, monospace;
   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', 'Fraunces', Georgia, serif;
   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 accent-coloured 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', 'JetBrains Mono', ui-monospace, monospace;
   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', 'Fraunces', Georgia, serif;
   font-weight: 500;
   font-size: 28px;
   line-height: 1.3;
   color: var(--dusty-granite);
   width: 100%;
   margin: 0
}

.pm-dots {
   font-family: 'Kyoto', 'Newsreader', Georgia, serif;
   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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   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', 'Inter', system-ui, sans-serif;
   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(--accent);
   border-bottom-color: var(--accent)
}

/* 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(--accent)
}

.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', 'Newsreader', Georgia, serif;
   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', 'Inter', system-ui, sans-serif;
   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(--accent)
}

/* 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', 'Inter', system-ui, sans-serif;
   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(--accent)
}

.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', 'Newsreader', Georgia, serif;
   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', 'Inter', system-ui, sans-serif;
   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', 'JetBrains Mono', ui-monospace, monospace;
   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(--accent)
}

.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', 'Newsreader', Georgia, serif;
   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', 'JetBrains Mono', ui-monospace, monospace;
   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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   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', 'Newsreader', Georgia, serif;
   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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   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', 'Newsreader', Georgia, serif;
   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', 'Fraunces', Georgia, serif;
   font-weight: 500;
   font-size: 28px;
   line-height: 1.3;
   color: var(--dusty-granite);
   width: 100%
}

.pm-msubtitle {
   margin: 0;
   font-family: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   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', 'Inter', system-ui, sans-serif;
   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 takes the accent 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', 'Inter', system-ui, sans-serif;
   font-weight: 400;
   font-size: 16px;
   line-height: 1.6;
   color: var(--dusty-granite)
}

.pm-ph em {
   font-style: normal;
   color: var(--accent)
}

.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: 'ApercuMono', 'JetBrains Mono', ui-monospace, monospace;
   font-weight: 500;
   font-size: 14px;
   line-height: 1.3;
   color: var(--antique-gold)
}

.pm-matchwhy {
   margin: 0;
   width: 269px;
   font-family: 'Diatype', 'Inter', system-ui, sans-serif;
   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', 'Inter', system-ui, sans-serif;
   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(--accent)
}

.pm-save.is-enabled span,
.pm-save.is-enabled svg {
   color: var(--parchment)
}

.pm-cta.is-enabled {
   background: var(--accent);
   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: --accent on light, new-leaf green on the
   accent-coloured footer, where the accent itself 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(--accent);
   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
   }
}