/* ==========================================================================
   Portfolio — visual system measured from lfs.gd
   Every raw value lives here. Pages carry no inline styles.

   Measured from the reference, and deliberately matched:
     type ceiling 32px · body 14/16/18px · letter-spacing 0 · radius 24px
     container min(100vw - 96px, 1104px) · image column 1008px / 1056px wide
     ground #FCFDFD · rule #DFE0E1 · muted #909899 · accent #0090FF
     no full bleed anywhere · no display type above 32px
   ========================================================================== */

/* --- Tokens --------------------------------------------------------------- */
:root {
  /* Ground */
  --ground:        #FCFCFE;   /* page background — all but white, so the grid
                                 reads as cards outlined on paper rather than
                                 as white blocks on grey. The card border is
                                 what separates them now, so it cannot go */
  --canvas:        #F7F7F9;   /* what the ground used to be, and still the
                                 right value for a surface set INTO a white
                                 card: the small chips, and the dotted canvas
                                 grounds in the Mural thumbnails */
  --surface:       #FFFFFF;   /* cards, image frames, the floating pills */
  --fill:          #F1F2F4;   /* subtle fills sitting ON a white surface */

  /* Ink */
  --ink:           #3B3F40;   /* headings, project names */
  --body:          #444A4B;   /* running text */
  --muted:         #909899;   /* metadata, hints */
  --muted-mid:     #858E8E;   /* the descriptor half of a case-study title */
  /* The rung between --body and --muted-mid. It started at #6E7677, which was
     the lightest grey that still cleared AA at 14px (4.65:1), and then went
     two steps lighter on request. At 3.77:1 against white it is BELOW the
     4.5:1 floor for 14px text, and bold does not buy that back — the relaxed
     3:1 threshold starts at 18.66px. So this is a deliberate call, not an
     oversight: the card foot is a label on a card whose whole surface is the
     link, repeating a name the case study states again at the top of its own
     page, so it is the one place here where nothing is lost if the line is
     read as texture. Do not reuse it for type that carries information on its
     own — #6E7677 is the compliant value if this ever needs to be one. */
  --ink-soft:      #7D8586;
  --muted-soft:    rgba(133, 142, 142, 0.7); /* "Role:" / "Contribution:" labels */

  /* Lines and accents */
  --rule:          #DFE0E1;

  /* The card edge. 1px, and alpha rather than a fixed grey: black at 9% is
     roughly #E8E8E8 over a white card, and it darkens by itself where the same
     border runs over a darker one. The terminal card is the reason that
     matters, and so are the pills, which spend half their life over content.

     It was 2px at #F1F2F3, on the reasoning that a nearly-white ground left
     the border as the only thing separating a card from it, so the line went
     lighter to pay for the extra width. The arithmetic held and the conclusion
     did not: precision lives in how sharp the transition is, not in how much
     grey is on screen. A thin defined line reads as a drawn edge, a thick faint
     one reads as a halo, and at 2px the cards had no edges at all.

     5%, which is a whisper, and deliberately so. The reference's cards have
     almost no edge and almost no shadow: they recede to nothing and the
     artwork inside them carries the whole of the card. Two passes were spent
     reading its stylesheet and darkening this line towards 9% and 12% before
     looking at the rendered page, which settles it in a second. The chrome was
     never what made that site look built. Do not add ink here to buy polish;
     it is not for sale at this end of the card.

     So the fix over the original was the width alone. 5% at 1px is a drawn
     edge; the same weight of ink smeared over 2px was the halo.

     Kept apart from --rule so image frames, tables and the survey chart can be
     retuned without the cards moving. */
  --card-rule:       rgba(0, 0, 0, 0.05);
  --card-rule-hover: rgba(0, 0, 0, 0.11);
  --card-rule-drag:  rgba(0, 0, 0, 0.18);

  /* The floating pills follow the cards down to 1px and to the same alpha.
     They gain the most from it: a pill crossing a dark thumbnail keeps its
     edge, where a fixed light grey would have dissolved into it. */
  --pill-rule:       rgba(0, 0, 0, 0.07);
  --pill-rule-hover: rgba(0, 0, 0, 0.14);
  /* Elevation. Three layers rather than one, each with a negative spread, so
     the falloff is a ramp instead of a single blurred blob: a tight contact
     shadow, a mid layer, and a wide soft one. Tinted blue-black rather than
     neutral, because a real shadow takes the colour of the room it is in and
     this room is a cool white. Flat neutral black at 4% was the tell.

     Kept faint. The reference barely shadows its cards at all, and the layering
     is there to make the little there is read as light rather than as grey:
     one tight contact line and one wide soft fall, both tinted.

     --lift-1 is at rest, on anything that floats: every card, every pill.
     --lift-2 is the pointer on a card. --lift-3 is a card in the hand, and
     that one is allowed to be obvious, because a thing being carried should
     look picked up. It answers this site's own drag, not the reference. */
  --lift-1: 0 1px 2px  -1px rgba(0, 58, 102, 0.06),
            0 6px 12px -6px rgba(0, 58, 102, 0.06);
  --lift-2: 0 1px 2px  -1px rgba(0, 58, 102, 0.10),
            0 8px 18px -8px rgba(0, 58, 102, 0.10);
  --lift-3: 0 1px   1px   -1.2px rgba(0, 58, 102, 0.20),
            0 4px   5px   -2.5px rgba(0, 58, 102, 0.18),
            0 20px  30px  -3.7px rgba(0, 58, 102, 0.14);

  --accent:        #0090FF;
  --accent-warm:   #FA0046;
  --cv:            #55B3A5;   /* the CV tile and the project menu — a mark at
                                 rest, the ground it floods with on hover. A
                                 near-cousin of --link-b, kept separate so
                                 retinting it never disturbs the bio links */
  --cv-lift:       #6FBEB2;   /* the same teal lifted, at the corner the flood
                                 comes from */
  --cv-deep:       #4A9C90;   /* and dropped, in the far corner it reaches last */
  /* The dropdown's marks, all seven. A lighter cousin of --cv, kept as its own
     token because it is the icon colour and --cv is the flood colour: retinting
     one should not drag the other with it. */
  --menu-mark:     #6FB8BD;

  /* The deep end of #54BFC6, and the colour any piece of TEXT takes when it is
     interactive: a title on hover, an inline link, the footer address. One
     value, because the alternative is what this file had — a card title going
     #0090FF, a footer address going #54BFC6, an inline link sitting at #0090FF:
     three answers to one question.

     Deep, and not the base, for the reason that keeps coming up here. As text
     on white, #54BFC6 is 2.18:1 and #0090FF is 3.26:1, both under the 4.5:1
     text needs. #12666D is 6.67:1. Same hue, legible. Anything text-sized that
     changes colour uses this; --connect stays the base for rings, borders and
     other things that are not read. */
  --teal-deep:     #12666D;

  /* The middle rung, for anything drawn or set large: a 24px mark, a 32px
     heading. Those clear at 3:1 rather than 4.5:1, so this is as bright as the
     hue goes while still holding its own — 3.70:1, against the base's 2.18:1,
     which clears nothing at all.

     Three rungs and three jobs, and the job is the thing to pick by:
       --connect    #54BFC6  rings, borders, fills — anything not read
       --teal-mid   #2A929A  marks and large headings, >= 3:1
       --teal-deep  #12666D  text, >= 4.5:1
     Reach for the one that matches what the element IS, not the one that looks
     closest to the swatch. That is how this ended up with three answers to one
     question in the first place. */
  --teal-mid:      #2A929A;

  --cv-wash:       #F3FAF9;   /* the teal at ~7% over white — a hover ground
                                 quiet enough to sit behind body text. It was
                                 16% (#E4F3F1), which read as a filled chip
                                 rather than a row lighting up. Compared at 16,
                                 10, 7 and 5: 5 is indistinguishable from the
                                 white panel it sits on, so 7 is as far as this
                                 goes while the band is still a band. */

  --connect:       #54BFC6;   /* the Connect pill's hover detail: the ring, and
                                 the mark that arrives. Cooler and a step
                                 brighter than --cv, deliberately — the CV tile
                                 sits in the same grid and floods solid --cv on
                                 hover, and two neighbours answering the pointer
                                 in the same green would read as one card twice
                                 rather than as two cards */
  --connect-wash:  #EEF9F9;   /* --connect at 10% over white. A tenth rather
                                 than --cv-wash's 7%: that number is for a wash
                                 the width of a menu row, and this one is 100px
                                 of pill, where 7 disappears */

  /* Inline link colours, measured from the reference bio card. Three is the
     whole set — a fourth starts to look like a ransom note. */
  --link-a:        #8339EA;   /* purple */
  --link-b:        #53ABB3;   /* teal   */
  --link-c:        #0090FF;   /* blue   */
  --positive:      #32AB60;   /* green — results columns only */

  /* Survey scale. A diverging ramp: two opposite hues around a neutral grey
     midpoint, one step per arm. Cousins of --accent-warm and --accent, but
     re-stepped so the five points stay apart for colour-blind readers too
     (worst adjacent pair ΔE 17.3 under deuteranopia, 18.3 in normal vision).
     Used by the survey chart and nothing else — these are not brand colours. */
  --scale-1:       #B00034;   /* 1 — least */
  --scale-2:       #FF7096;
  --scale-3:       #DDE1E2;   /* 3 — the neutral midpoint, deliberately grey */
  --scale-4:       #2E9BE0;
  --scale-5:       #005FA8;   /* 5 — most */

  /* Canvas colours. These are Mural's, not this site's — they are the subject
     of the Hana case study rather than part of its palette, which is why they
     sit apart from the four roles above and are used nowhere else. Sampled
     from the specification sheets, so treat them as close rather than exact.
     The Hana yellow is the firmest of them: the same value came off the sticky
     picker in the hero image and off the thumbnail comp independently. */
  /* Confetti. Read off the diagramming thumbnail on this page: the input
     parallelogram's tan and its brown label, the process box's blue edge and
     bluer label, the output card's pink and its rose label, and the grey the
     arrows are drawn in. So the shower is made of the page's own diagram
     rather than of a party palette bought in for the occasion.

     Then every one of them was taken up in lightness and down in saturation
     until the set was pastel throughout. The thumbnail's darkest three — the
     labels — were the reason: at their measured values they punctuated the
     shower rather than belonging to it, and a piece of dark brown paper at 8px
     reads as a mark on the screen. What separates the eight now is hue and
     nothing else, which is why the two blues and the two pinks each had to
     move apart in hue as they came up in lightness.

     Eight, because a handful of confetti has more colours in it than a brand
     does. All eight were then checked against --ground, and the grey came back
     a step: pastelled to #CFD4D7 it stopped being a piece of paper and became
     a smudge on the page. It is the one colour here with no hue to fall back
     on, so lightness is all it has. The butter is close to the same edge and
     holds only because it is warm. */
  --conf-1:        #F0C49A;   /* input border, apricot */
  --conf-2:        #F6E0A8;   /* input fill, butter */
  --conf-3:        #ADD1F0;   /* process border, sky */
  --conf-4:        #8FB3DF;   /* process label, periwinkle */
  --conf-5:        #F5B9C4;   /* output border, blush */
  --conf-6:        #DE9AA8;   /* output label, dusty rose */
  --conf-7:        #C6CDD1;   /* the arrows' grey — the silver in the mix */
  --conf-8:        #D6B48C;   /* input label, warm sand */

  /* The work-in-progress card's flowchart. Its own set rather than the --fc-*
     one inside .flow, because that set was softened for a chart fitted whole
     into a 258px cell, and this card is a viewport zoomed to about four times
     that: at 0.35 and 0.40 scale the shapes fill the crop, and the soft strokes
     read washed out at that size. These are Mural's own diagramming colours,
     which is also what makes the card read as a real canvas.

     In :root, not beside the --fc-* set they were drafted next to. Those live
     inside the .flow rule, so anything declared there is scoped to .flow
     elements — of which this page has none, which is exactly how this card
     first rendered as an empty white square. */
  --wip-ground:     #F7F7F8;
  --wip-grid:       #DCDEDF;
  --wip-line:       #A2A8AA;   /* connectors, arrowheads, the merge symbol */
  --wip-merge:      #E8EAEA;
  /* The first shape, off #54BFC6. Three steps of one hue rather than three
     unrelated values: the fill is that colour at 14% over white, and the
     outline and the label are shades of it.

     Neither of those two is #54BFC6 itself, and that is not a liberty, it is
     the arithmetic: against a 14% tint of itself the base sits at 1.96:1,
     where a drawn outline wants 3 and text wants 4.5. Stepping down the same
     hue to #2A929A gives the outline 3.33:1 and #12666D gives the label
     6.01:1, so all three read as the same colour and the words stay legible at
     the resting scale. */
  --wip-teal-fill:  #E7F6F7;
  --wip-teal-line:  var(--teal-mid);
  --wip-teal-text:  var(--teal-deep);
  --wip-warm-fill:  #FBF6D8;
  --wip-warm-line:  #B54A0B;
  --wip-warm-text:  #99400A;
  --wip-red-fill:   #FDEDF0;
  --wip-red-line:   #F0475C;
  --wip-red-text:   #B00A2B;

  /* How I work. One pair per principle: a ground pale enough to sit behind
     body text, and an ink deep enough to draw a 2px mark on that ground and
     still clear 3:1. These are the picado hues with the saturation taken out
     of the ground and put into the mark, so the card is pastel without being
     a fifth palette — lilac, pink and green are cousins of --picado-3, -1 and
     -6, and the peach is where --picado-2's yellow has to go to be readable
     at all. Used by the principles card and nothing else. */
  --how-q-bg:      #EFEBFC;   /* lilac */
  --how-q-ink:     #5B4BB8;
  --how-mic-bg:    #FDE8F2;   /* pink */
  --how-mic-ink:   #B04A86;
  --how-ship-bg:   #FDEEE0;   /* peach */
  --how-ship-ink:  #B06630;
  --how-sys-bg:    #E7F4E5;   /* mint */
  --how-sys-ink:   #4A8038;

  --fill-classic:  #FFF9B1;   /* classic palette, first swatch */
  --fill-hana:     #FCF281;   /* hana palette, first swatch */
  --canvas-ink:    #1D1D1D;   /* sticky text */

  /* Type */
  --font-sans: "Inter", "Inter Placeholder", -apple-system, BlinkMacSystemFont, sans-serif;
  --font-mono: "DM Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Widths — the three the reference actually uses */
  --w-page:    min(100vw - 96px, 1104px);  /* outer container, 48px gutters */
  --w-content: 1008px;                     /* standard contained image */
  --w-wide:    1056px;                     /* the wider image variant */
  --w-text:    714px;                      /* measure for running text */
  --w-home:    min(100vw - 96px, 1104px);  /* homepage header + grid + footer */
  --cell:      258px;                      /* bento row height, and min column */

  /* Rhythm */
  --gap-s:  8px;
  --gap-m:  24px;
  --gap-l:  48px;
  --gap-xl: 64px;

  --nav-top:      16px;       /* header inset from the top of the viewport */
  --nav-h:        44px;       /* header control height */

  --radius:       24px;
  --radius-small: 12px;
  --radius-large: 44px;       /* the case-study shell */
  --radius-pill:  999px;

  /* Case studies sit on a tint, inside a light shell — the reference's
     structure, and where its stray 44px radius and #f4f6f6 came from.
     Override --case-tint per page to give a project its own colour. */
  --case-tint:    #7FBFC5;

  /* The ground the nav bar brings with it once the page has scrolled. At the
     top of the page there is no bar at all: the row is two controls floating
     on the page's own tint, and this is only what appears behind them on the
     way back up. A page that overrides --case-tint must override this too —
     skills.html does, inline. */
  --case-bar:     #6FB8BD;
  --shell:        #F4F6F6;
  --shell-pad:    24px;       /* the shell's own side padding. The case-nav
                                 reads it to find the content column's edge */
}

@media (max-width: 1199.98px) {
  /* The grid drops to two columns here, so the header and footer narrow with
     it — otherwise they overhang the cards they are meant to line up with. */
  :root { --w-home: min(100vw - 96px, 714px); }
}

@media (max-width: 809.98px) {
  :root {
    --w-page: min(100vw - 32px, 1104px);   /* 16px gutters on mobile */
    --w-home: min(100vw - 32px, 714px);
  }
}

/* --- Reset ---------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--ground);
  color: var(--body);
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 24px;
  letter-spacing: 0;           /* the reference sets 0 everywhere. So do we. */
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

img, svg { display: block; max-width: 100%; height: auto; }

p { margin: 0; }

a { color: inherit; text-decoration: none; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 4px;
}

/* --- Shared layout -------------------------------------------------------- */
.container {
  width: var(--w-page);
  margin-inline: auto;
}

/* The content column inside a case study. Images sit at 1008px, the wide
   variant breaks out to 1056px — which is as wide as this site ever goes.
   There is no full bleed in the reference, and there is none here. */
.column {
  width: min(100%, var(--w-content));
  margin-inline: auto;
}

.measure { max-width: var(--w-text); }

.mono {
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 16px;
  font-weight: 400;
}

/* --- Header --------------------------------------------------------------
   Three separate floating pieces, not one bar: a mark on the left, the
   projects menu centred, and a contact button on the right. The whole row is
   click-through except the pieces themselves.
   ------------------------------------------------------------------------ */
.nav {
  position: fixed;
  top: var(--nav-top);
  left: 0;
  right: 0;
  z-index: 100;

  /* Same width as the bento below, so the mark lines up with the grid's left
     edge rather than floating at the viewport edge. */
  width: var(--w-home);
  margin-inline: auto;

  /* The outer columns are equal, so the menu is centred on the page rather
     than on whatever is left over between the two buttons. */
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: start;
  pointer-events: none;
  transition: transform 0.3s ease, opacity 0.3s ease;
}

.nav > * { pointer-events: auto; }
.nav__left  { justify-self: start; }
.nav__right { justify-self: end; }

/* Both outer columns are 1fr, so whatever lands in here grows leftward and
   the menu stays centred on the page rather than being nudged off it. */
.nav__end {
  display: flex;
  align-items: center;
  gap: var(--gap-s);
}

.nav[data-hidden="true"] {
  transform: translateY(calc(-100% - 24px));
  opacity: 0;
}

/* Set for one frame while site.js settles the header against a scroll position
   the browser restored on reload or Back. Without it the header would be
   painted over the content and then animate away, which is the flash the
   settling exists to prevent. */
.nav[data-settling] { transition: none; }

/* Shared floating-surface treatment. */
.pill {
  background: rgba(255, 255, 255, 0.72);
  -webkit-backdrop-filter: blur(16px) saturate(160%);
  backdrop-filter: blur(16px) saturate(160%);
  border: 1px solid var(--pill-rule);
  border-radius: var(--radius-pill);
  box-shadow: var(--lift-1);
}

/* Round icon buttons, left and right. */
.nav__btn {
  display: grid;
  place-items: center;
  width: var(--nav-h);
  height: var(--nav-h);
  color: var(--ink);
  font-size: 14px;
  font-weight: 500;
  transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}

.nav__btn:hover { background: #FFFFFF; border-color: var(--pill-rule-hover); }

/* The mark, not the button: the envelope is drawn in currentColor, so this is
   all it takes. --teal-mid, not the #54BFC6 base: a 20px stroked mark on white
   is a graphical object at 3:1, and the base clears 2.18. Focus gets it too, so the keyboard sees what the pointer does.
   Scoped to the mail button because the other .nav__btn is the avatar, which is
   a photograph and has no currentColor to recolour. */
.nav__btn[href^="mailto:"]:hover,
.nav__btn[href^="mailto:"]:focus-visible { color: var(--teal-mid); }

/* The avatar has no border to recolour, and no background to reveal: the
   picture covers the button edge to edge. Both hover changes would be doing
   nothing visible on it at best, and flashing white behind a scaling circle at
   worst. */
.nav__btn--avatar:hover { background: var(--surface); }

.nav__btn svg { width: 18px; height: 18px; }

/* The mail mark is the smallest thing in the header and was the darkest. It
   sits at --muted now and only comes up to ink when it is being aimed at, so
   the header reads left to right — avatar, menu, then this — instead of the
   far corner pulling first. */
.nav__end .nav__btn { color: var(--muted); transition: color 0.2s ease,
                      background 0.2s ease, border-color 0.2s ease; }

.nav__end .nav__btn:hover,
.nav__end .nav__btn:focus-visible { color: var(--ink); }

/* The mark is the avatar itself, clipped to the button. It sits at the same
   44px as the pills beside it and only grows on hover.

   Two transforms, deliberately split: the button scales, the image inside it
   turns. Scaling the image instead would just zoom it inside a fixed circle
   rather than making the circle bigger. Because it is a transform, the growth
   costs no layout and nothing beside it shifts.

   Taking the pointer away runs the whole thing backwards, since the transition
   simply returns to its resting values. The >1 control point in the curve
   overshoots and settles back, which is the bounce. */
.nav__btn--avatar {
  padding: 0;
  overflow: hidden;
  background: var(--surface);
  transition: transform 0.85s cubic-bezier(0.34, 1.56, 0.64, 1);

  /* No ring. The .pill border is the edge of a glass pill, which is what the
     two buttons beside this one are; here the picture is already a circle with
     its own edge, so the border only ever read as an outline drawn around it.
     The ambient shadow stays — it lifts the mark off the ground the same way it
     lifts the pills, and it is not an outline. Global box-sizing is border-box,
     so dropping the border leaves the button at its 44px. */
  border: 0;
}

.nav__btn--avatar:hover,
.nav__btn--avatar:focus-visible { transform: scale(1.36); }

.nav__avatar {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
  transform: rotate(0deg);
  transition: transform 0.85s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.nav__btn--avatar:hover .nav__avatar,
.nav__btn--avatar:focus-visible .nav__avatar { transform: rotate(360deg); }

/* Centre menu. <details> gives a dropdown with no JavaScript.
   Height is set here so the borders fall inside it and the pill matches
   the icon buttons exactly. */
.menu { position: relative; height: var(--nav-h); }

/* 600 in --ink-soft, from .card__foot by way of .btn — see the note on .btn.
   The colour here is the word only: both glyphs beside it set their own, a
   step lighter, at .mark-bento and .menu__chevron below. */
.menu__summary {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 100%;
  padding: 0 20px;
  font-size: 14px;
  line-height: 16px;
  font-weight: 600;
  color: var(--ink-soft);
  cursor: pointer;
  list-style: none;
  border-radius: var(--radius-pill);
  transition: background 0.2s ease;
  white-space: nowrap;
}

.menu__summary::-webkit-details-marker { display: none; }
.menu__summary::marker { content: ""; }
.menu__summary:hover { background: #FFFFFF; }

/* 14px, up from 12: at 12 it read as a smudge beside a 16px mark and an 18px
   envelope rather than as the third mark in the row. stroke-width came down
   from 1.4 to 1.2 in the markup to pay for it — 1.2 over a 12-unit viewBox
   drawn at 14px is 1.4px on screen, which is the weight the bento mark is
   already drawn at, so the glyph grew and the line it is drawn with did not. */
.menu__chevron { transition: transform 0.2s ease; color: var(--muted); }
.menu[open] .menu__chevron { transform: rotate(180deg); }

/* --muted, the mail button's grey, rather than the --ink-soft it would inherit
   from the word beside it. That is what puts the three marks in the nav — this
   one, the chevron and the envelope on the right — on one value, and it is the
   same label-and-glyph pairing the card feet use: the word at --ink-soft, the
   mark beside it a step quieter. A glyph as dark as its own label was the one
   thing in the row shouting.

   Open, the mark has nothing left to say — the list under it is the answer —
   so it stays on that grey and lets the rows carry the weight. Same move as
   the mail button: ink only while it is the thing being used. */
.mark-bento { color: var(--muted); transition: color 0.2s ease; }

/* --- The panel ------------------------------------------------------------
   A fixed width rather than a min-width, so the arrows land in the same place
   on every row: sized to content the column would sit wherever each name
   happened to end, and the arrow would appear somewhere new each time the
   pointer moved down the list.

   264 is the longest name plus the two columns beside it plus a little air.
   Wider than that and the panel is mostly empty on the right.

   The shadow is three layers, and the widest one is tinted with the ink
   rather than black. Over the #FCFCFE ground a neutral black spread reads
   grey and slightly dirty, and this panel floats over the cards more often
   than over the ground.
   ------------------------------------------------------------------------ */
.menu__list {
  position: absolute;
  top: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  /* This gap is now between groups, not rows: .menu__section carries the 2px
     the rows used to sit on. */
  gap: 8px;
  /* 300, not 264. The label needs about 190px of column after the tile, the
     gaps and the arrow, and the longest of them wants 150 of it. At 264 they
     were being ellipsised. */
  width: 300px;
  /* 16 above, double the 8 on the other three sides, so the first group has a
     little air over it and the ratio still belongs to the same system. 128 was
     tried and it was a void rather than air: half the open panel was empty. */
  padding: 16px 8px 8px;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 20px;
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.04),
    0 10px 22px -8px rgba(0, 0, 0, 0.10),
    0 30px 60px -24px rgba(59, 63, 64, 0.22);
}

/* The panel sits 8px clear of the trigger, and that band belongs to neither of
   them: a pointer crossing it was leaving .menu entirely, which closed the menu
   on the way to the very thing it had just opened and re-fired the tiles'
   entrance halfway across. This bridges the band, a transparent strip drawn
   by the panel and so inside .menu, which makes the crossing continuous without
   closing the visible gap. A pseudo-element rather than a real one so it stays
   out of the child counting the row delays below rely on. */
.menu__list::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  height: 10px;
}

/* --- Rows -----------------------------------------------------------------
   A grid rather than a flex row, so the arrow holds its own column whether it
   is showing or not. In a flex row it would have had to be absolutely placed
   or it would have pushed every name left the moment the pointer arrived.
   ------------------------------------------------------------------------ */
/* --- Groups ----------------------------------------------------------------
   Seven flat names made the reader do the sorting: what is worth reading first,
   what is a side project, what is not finished. Three headed groups say it
   instead.
   ------------------------------------------------------------------------ */
.menu__section {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* Inter, small, semibold, muted, sentence case. Sentence case in the markup
   rather than by text-transform: uppercase would need letter-spacing to stay
   legible at 11px, and this system is set at 0 throughout.

   600 rather than 700: at 11px the heavier weight closes up the counters and
   the word turns into a block. It reads as bold against the 400 below it. */
.menu__group {
  margin: 0;
  padding: 2px 10px 4px;
  font-size: 11px;
  line-height: 14px;
  font-weight: 600;
  color: var(--muted);
}

.menu__row {
  display: grid;
  grid-template-columns: 30px minmax(0, 1fr) auto;
  align-items: center;
  gap: 10px;
  padding: 7px 10px;
  border-radius: 12px;
  font-size: 14px;
  line-height: 16px;
  color: var(--ink);
  transition: background 0.15s ease;
}

/* The teal at ~4% over white rather than --cv-wash's 7%. A row is a small
   target and the ground only has to say "this one"; at 7% it read as a filled
   chip. Its own value rather than a retuned --cv-wash, which is sized for the
   full-width bands on the case-study pages. */
.menu__row:is(:hover, :focus-visible) { background: #F8FCFB; }

/* Nothing here is long enough to need this today, and a name that outgrows
   its column should shorten rather than shove the tag off the row. */
/* The name column is a stack now. grid-column is stated so it cannot drift if
   anything is added to the row: the tile takes 1, this takes 2, the arrow is
   pinned to 3 in .menu__go. */
/* Fixed height, and that is the whole trick: the box is always as tall as both
   lines, so the row never changes height and the menu never reflows. At rest the
   label has no height, so justify-content centres the name alone — level with
   the icon. On hover the label takes its 15px and the pair re-centres, which
   lifts the name by half of that on its own. Nothing is animated but the
   label's own height and opacity. */
.menu__text {
  grid-column: 2;
  display: flex;
  flex-direction: column;
  justify-content: center;
  height: 36px;
  min-width: 0;
}

/* Both lines clip rather than wrap. A wrapped label would make one row taller
   than its neighbours, which in a menu reads as a mistake rather than as
   emphasis — so the labels are written to fit and this is only a backstop. */
.menu__name,
.menu__note {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.menu__name { line-height: 18px; }

.menu__note {
  height: 0;
  opacity: 0;
  font-size: 12px;
  line-height: 15px;
  color: var(--muted);
  transition: height 0.18s ease, opacity 0.14s ease;
}

.menu__row:is(:hover, :focus-visible) .menu__note {
  height: 15px;
  opacity: 1;
}

/* --- The icon tiles -------------------------------------------------------
   The seven marks were never one weight: the Claude burst is solid ink to
   the edge of its box, #i-hana and #i-pausa are 2.4 strokes, #i-compose is
   five bars. Loose on the row, that difference reads as seven icons from
   seven places. The tile is what makes them a set, and it gives the list a
   left rail to hang on. It is the same #F7F7F9 as any other surface set into
   a white card, so it is a ground rather than a chip.
   ------------------------------------------------------------------------ */
.menu__tile {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 9px;
  background: var(--canvas);
  box-shadow: inset 0 0 0 1px rgba(85, 179, 165, 0);
  transition:
    background 0.18s ease,
    box-shadow 0.18s ease,
    transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* The tilt is the CV card's move at half strength, and it is the whole of the
   fun here: the tile lifts off the row it is sitting in, the way a card picked
   up off a table does. Overshoot in the easing, so it settles rather than
   arrives. */
.menu__row:is(:hover, :focus-visible) .menu__tile {
  background: var(--surface);
  box-shadow:
    inset 0 0 0 1px rgba(85, 179, 165, 0.34),
    0 1px 2px rgba(74, 156, 144, 0.14);
  transform: scale(1.05) rotate(-3deg);
}

/* The icon beside each project in the menu. Drawn in currentColor from the
   sprite, so the glyph simply follows the row: grey at rest, teal under the
   cursor, with no second copy of the art to keep in step. */
.menu__mark {
  flex: none;
  width: 24px;
  height: 24px;
  color: var(--menu-mark);
}

/* --- The arrow ------------------------------------------------------------
   Present at rest and held at zero opacity, rather than absent: it is what
   reserves the third column, so a row taking the pointer changes nothing
   about where its name sits.

   translate rather than transform, because .arrow--right owns the transform
   and needs it for the 90 degrees. The two compose.
   ------------------------------------------------------------------------ */
.menu__go {
  grid-column: 3;
  grid-row: 1;
  justify-self: end;
  width: 16px;
  height: 16px;
  color: var(--cv);
  opacity: 0;
  translate: -7px 0;
  transition: opacity 0.16s ease, translate 0.18s ease;
}

.menu__row:is(:hover, :focus-visible) .menu__go { opacity: 1; translate: 0 0; }

/* --- Opening and closing --------------------------------------------------
   Written as animations rather than transitions, because there is no state to
   transition from until the element exists: <details> has no panel at all
   while it is shut.

   The children arrive after the panel that holds them, one behind the next,
   in the order they are read. The whole thing is under a third of a second;
   any longer and it is in the way of the click that opened it.

   Closing used to be nothing, since <details> takes its panel away the frame
   the attribute drops. js/site.js now holds that attribute for the length of
   menu-close, which is the only reason an exit can be drawn here.
   ------------------------------------------------------------------------ */
@keyframes menu-open {
  from { opacity: 0; transform: translate(-50%, -8px) scale(0.96); }
  to   { opacity: 1; transform: translate(-50%, 0)    scale(1); }
}

@keyframes menu-row {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: none; }
}

/* Shorter than the entrance and it does not travel as far, because a panel
   being dismissed does not need to be watched. It keeps the translateX(-50%)
   the panel is centred with: an animation that omitted it would jump the
   panel half its own width to the right on the first frame of the close. */
@keyframes menu-close {
  from { opacity: 1; transform: translate(-50%, 0)    scale(1); }
  to   { opacity: 0; transform: translate(-50%, -6px) scale(0.97); }
}

/* The mark is the grid, so on open it builds itself the way the grid does:
   the tall tile first, then the two beside it. */
@keyframes menu-tile {
  from { opacity: 0; transform: scale(0.35); }
  to   { opacity: 1; transform: scale(1); }
}

.mark-bento rect {
  /* Without fill-box the origin is the whole SVG's box and the tiles scale
     from the wrong point, sliding as they grow. */
  transform-box: fill-box;
  transform-origin: center;
}

/* --- Hovering the trigger --------------------------------------------------
   The three tiles redraw themselves, in the order the grid builds them —
   the tall one, then the two beside it — and they come back teal.

   --len is each tile's own outline length, because a dash has to know how far
   it is travelling. For a rounded rect that is the four straight runs plus one
   full circle from the corners: 2(w-2r) + 2(h-2r) + 2*pi*r. The tall tile is
   2(6-3.5) + 2(14.5-3.5) + 2*pi*1.75 = 37.996, the small ones 20.996. The
   values below are rounded up rather than down — a dash a shade longer than
   its outline closes invisibly on a shape that meets itself, whereas one a
   shade shorter leaves a permanent nick at the top-left corner.

   pathLength="100" would make all three uniform, but it is only specified for
   basic shapes in SVG 2 and this needs no feature to be present.
   ------------------------------------------------------------------------ */
.mark-bento rect:nth-child(1) { --len: 38.5; --draw-in: 0s; }
.mark-bento rect:nth-child(2) { --len: 21.5; --draw-in: 0.1s; }
.mark-bento rect:nth-child(3) { --len: 21.5; --draw-in: 0.2s; }

@keyframes bento-draw {
  from { stroke-dashoffset: var(--len); }
  to   { stroke-dashoffset: 0; }
}

/* Colour is not motion, so it sits out here and reaches everyone — a visitor
   who has asked for less movement still gets the answer to their pointer,
   just without the redraw. The same teal the menu rows below use for their
   own marks, so hovering the trigger and hovering a row agree.

   Three ways in, one colour: the pointer, the keyboard, and simply being open
   — the last so the trigger reads as the active thing for as long as its panel
   is up, including once the pointer has moved down into the list. */
.menu__summary:hover .mark-bento rect,
.menu__summary:focus-visible .mark-bento rect,
.menu[open] .mark-bento rect {
  stroke: var(--cv);
}

@media (prefers-reduced-motion: no-preference) {
  .menu[open] .menu__list {
    transform-origin: top center;
    animation: menu-open 0.24s cubic-bezier(0.44, 0, 0.22, 1);
  }

  /* Seven delays for seven rows. The rule this replaced had run out at five,
     so the last two rows arrived with no stagger at all.

     backwards, so a row is held at its "from" frame through its own delay
     instead of showing at full strength and then starting. */
  .menu__list > * {
    animation: menu-row 0.3s cubic-bezier(0.44, 0, 0.22, 1) backwards;
  }

  .menu__list > *:nth-child(1)  { animation-delay: 0.04s; }
  .menu__list > *:nth-child(2)  { animation-delay: 0.07s; }
  .menu__list > *:nth-child(3)  { animation-delay: 0.10s; }
  .menu__list > *:nth-child(4)  { animation-delay: 0.13s; }
  .menu__list > *:nth-child(5)  { animation-delay: 0.16s; }
  .menu__list > *:nth-child(6)  { animation-delay: 0.19s; }
  .menu__list > *:nth-child(7)  { animation-delay: 0.22s; }

  /* --- Closing ------------------------------------------------------------
     <details> has no close animation of its own: the panel is simply gone the
     frame the attribute drops, which is the one thing in this menu that read
     as unfinished. So js/site.js holds the attribute for the length of this
     animation and drops it on the way out, and every path that closes the
     menu goes through that one function.

     Faster than the entrance, and it does not stagger. Leaving is one gesture;
     the panel goes as a piece. The rows have their animation taken off them
     outright, because theirs would otherwise restart underneath this one.
     ---------------------------------------------------------------------- */
  .menu[data-closing] .menu__list {
    animation: menu-close 0.13s cubic-bezier(0.4, 0, 1, 1) forwards;
  }

  .menu[data-closing] .menu__list > * { animation: none; }

  /* This and the redraw below both want the animation property on the same
     three rects, and now that the pointer is what opens the menu they would be
     simultaneous. So the pointer gets the redraw, and this covers the openings
     that have no pointer in them: a keyboard Enter, or a tap.

     The test is data-by-hover — which js/site.js sets for exactly as long as
     the pointer owns the menu — and not :not(:hover). Geometry was the wrong
     question twice over: the 8px band above the panel is not :hover even while
     crossing it, and during the closing grace period the menu is open and no
     longer hovered, so this fired its entrance on the way out both times. How
     the menu was opened does not flicker; where the pointer is does. */
  .menu[open]:not([data-by-hover]) .mark-bento rect {
    animation: menu-tile 0.36s cubic-bezier(0.44, 0, 0.22, 1) backwards;
  }

  .menu[open]:not([data-by-hover]) .mark-bento rect:nth-child(2) { animation-delay: 0.07s; }
  .menu[open]:not([data-by-hover]) .mark-bento rect:nth-child(3) { animation-delay: 0.14s; }

  /* The redraw itself. `both` is what makes the stagger legible: during its
     delay a tile holds the from-state and is simply not there yet, so the
     three arrive one behind the next instead of together. */
  .menu__summary:hover .mark-bento rect,
  .menu__summary:focus-visible .mark-bento rect {
    stroke-dasharray: var(--len);
    animation: bento-draw 0.42s ease var(--draw-in) both;
  }
}

/* Case-study header keeps the project title in the centre slot. */
.nav__title {
  display: inline-flex;
  align-items: center;
  height: 44px;
  padding: 0 20px;
  font-size: 14px;
  line-height: 16px;
  color: var(--muted);
  max-width: min(52vw, 520px);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* --- Page rhythm ----------------------------------------------------------
   Content clears the fixed header by exactly one gutter, so the space between
   the header and the first card matches the space between the cards.
   ------------------------------------------------------------------------ */
.page {
  padding-top: calc(var(--nav-top) + var(--nav-h) + var(--gap-m));
  /* Twice the card gutter: enough to read the footer as a separate thing,
     not so much that the page looks like it ran out before it ended. */
  padding-bottom: var(--gap-l);
}

.stack { display: flex; flex-direction: column; }
.stack--m  { gap: var(--gap-m); }
.stack--l  { gap: var(--gap-l); }
.stack--xl { gap: var(--gap-xl); }

/* --- Bento grid ----------------------------------------------------------
   Four columns of 258px square cells with a 24px gap, which is exactly what
   the reference uses. Cards claim space with the span classes below; normal
   row-major auto-placement fills the rest.
   ------------------------------------------------------------------------ */
.bento {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  grid-auto-rows: var(--cell);
  gap: var(--gap-m);
  width: 100%;
  max-width: var(--w-home);
  margin-inline: auto;

  /* Dense packing is what closes a hole when a card is dragged away from it:
     the browser backfills the gap with a later card that fits. */
  grid-auto-flow: dense;
}

.card--w2 { grid-column: span 2; }
.card--h2 { grid-row: span 2; }
.card--h3 { grid-row: span 3; }

.card {
  position: relative;      /* the grip anchors to this, and .is-dragging's
                              z-index needs a positioned box to apply to */
  display: flex;
  flex-direction: column;
  min-width: 0;
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--card-rule);
  border-radius: var(--radius);
  /* A card sits on the page rather than being printed into it, so it carries
     the resting lift even when it is not a link and nothing can be done to it. */
  box-shadow: var(--lift-1);
}

/* Two thumbnails reach the card's own edge — the terminal window and the
   workshop board — so there is no white margin for an outline to sit against
   and the line only ever read as a box drawn around a picture. The border
   stays, at 1px and transparent, so these cards measure the same as every
   other one and nothing in the grid shifts. */
.card--bleed,
a.card--bleed:hover { border-color: transparent; }

/* Only cards that are links react to the pointer. */
a.card {
  transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}

a.card:hover {
  border-color: var(--card-rule-hover);
  transform: translateY(-2px);
  box-shadow: var(--lift-2);
}

/* Media placeholder filling a card. Replace the whole div with:
     <img class="card__img" src="images/one.png" alt="">                     */
.card__ph {
  flex: 1;
  display: grid;
  place-content: center;
  gap: 6px;
  padding: 24px;
  text-align: center;
}

.card__img { width: 100%; height: 100%; object-fit: cover; }

.card__label {
  font-family: var(--font-mono);
  font-size: 14px;
  line-height: 16px;
  color: var(--ink);
}

.card__hint {
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 16px;
  color: var(--muted);
}

/* --- Arrows ----------------------------------------------------------------
   One mark, defined once in the sprite at the top of the page and turned to
   point where each place needs. It replaced the → ↗ ↓ text glyphs, so it is
   sized to sit at their ink size rather than at 1em: the drawn arrow fills
   about half its own box, and a glyph does not.

   Colour is inherited — the path is currentColor — so the rules further down
   that recolour an arrow keep working untouched. */
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

.arrow {
  width: 1.3em;
  height: 1.3em;
  flex: none;
  /* Turning a box that is wider than its ink would shift the mark off centre;
     it does not here, because the box is square. */
  transform: rotate(var(--arrow-turn, 0deg));

  /* Where the mark travels when it hands over — see "The arrow relay" below.
     Zero unless a modifier points it somewhere, and 0px rather than 0 because
     calc() has to negate it and needs a length to do that. */
  --go-x: 0px;
  --go-y: 0px;
}

/* Each modifier says which way the mark points and, on the same line, which
   way it leaves — the two are the same fact and they belong together.

   10 on one axis against 7 on both: 7 and 7 is a 9.9px diagonal, so all three
   travel the same distance rather than the same number. */
.arrow--right { --arrow-turn:  90deg; --go-x: 10px; }
.arrow--down  { --arrow-turn: 180deg; --go-y: 10px; }
.arrow--ne    { --arrow-turn:  45deg; --go-x:  7px; --go-y: -7px; }

/* --- The arrow relay -------------------------------------------------------
   Every arrow on the homepage that means "this goes somewhere" hands itself
   over while the card is hovered: the mark leaves along the line it points
   down, and a second copy arrives on that same line from behind, out of the
   place the first one is going away from.

   So it is not an arrow nudging. It is a mark departing and being replaced,
   which is what following a link does. It started on the Connect pill and it
   reads the same on a card foot, on the CV pill and on the citation, because
   they are one gesture pointed three different ways — which is the whole
   reason the travel is a pair of properties on the direction modifier rather
   than a number written out per place.

   The pair stacks in one 1.3em box, the size .arrow already is, so no label
   moves to make room for the one arriving. Nothing clips them either: the
   travel is 10px at most and every one of them has more room than that
   between it and the edge of whatever it sits in, so the departing mark fades
   out rather than being cut off.

   Colour is deliberately not here. The mark that arrives takes the colour of
   the label beside it, and that is a different value on a white foot, a
   terminal, Pausa's black and a teal CV tile, so each of those sets its own
   next to the rest of its palette.
   ------------------------------------------------------------------------ */
.relay {
  position: relative;
  width: 1.3em;
  height: 1.3em;
  flex: none;
}

.relay__mark {
  position: absolute;
  top: 0;
  left: 0;
  /* translate rather than transform: the modifiers above own the transform for
     the turn, and taking it here would straighten the mark mid-flight. The two
     compose, and translate is applied first, so --go-x / --go-y are screen
     directions and each modifier can aim its own travel. */
  transition: translate 0.34s cubic-bezier(0.44, 0, 0.22, 1),
              opacity 0.28s ease;
}

/* The arriving mark waits one step back down the line, out of sight. */
.relay__mark--in {
  translate: calc(-1 * var(--go-x)) calc(-1 * var(--go-y));
  opacity: 0;
}

a.card:is(:hover, :focus-visible) .relay__mark--out {
  translate: var(--go-x) var(--go-y);
  opacity: 0;
}

a.card:is(:hover, :focus-visible) .relay__mark--in {
  translate: 0 0;
  opacity: 1;
  /* Late enough that the box is nearly empty for an instant, which is what
     makes it read as two marks rather than one sliding through. Past about
     0.1s the gap stops being a handover and becomes a stutter. */
  transition-delay: 0.07s;
}

/* A foot that rides in on hover carries its arrow with it, so on those three
   cards the handover waits for the strip to land: 0.14s is just as it arrives,
   and the second mark keeps its 0.07 behind the first. Without the wait the
   mark relays while the ground under it is still moving, and two ordered
   things read as one busy one.

   a.card.card--foot-reveal, rather than the modifier on its own, only to win
   on specificity: the rule above it has a type selector and would otherwise
   beat this by that one tag. */
a.card.card--foot-reveal:is(:hover, :focus-visible) .relay__mark--out { transition-delay: 0.14s; }
a.card.card--foot-reveal:is(:hover, :focus-visible) .relay__mark--in  { transition-delay: 0.21s; }

/* Nothing travels, and the mark that would have arrived has nothing left to
   say, so it goes. Whatever colour change each context puts on it stays. */
@media (prefers-reduced-motion: reduce) {
  .relay__mark { transition: none; }
  .relay__mark--in { display: none; }
  a.card:is(:hover, :focus-visible) .relay__mark--out {
    translate: 0 0;
    opacity: 1;
  }
}

/* A caption bar pinned to the bottom of a media card. */
.card__foot {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 14px 18px;
  border-top: 1px solid var(--card-rule);
  font-size: 14px;
  line-height: 16px;
  /* 600, up from the body's 400 by way of 500. It is the rung --prose strong
     and the bio's own bold already stand on, so the grid gains no new weight.
     The two dark feet inherit it: light type on a dark ground already looks
     heavier, but splitting them off would leave one foot at a different weight
     from the rest for a reason nobody reading the grid could see. */
  font-weight: 600;
  /* And --ink-soft rather than --ink. These two moved together and have to be
     read together: weight up, colour down, twice. Bold in the darkest ink was
     the two stacking — heavier AND as dark as a heading — and each step lighter
     had to overshoot to survive the step bolder beside it, because bolder type
     reads darker at the same value. What is left is a firm letter in a quiet
     grey. Only the light feet take it; the terminal and Pausa feet set their
     own colour on a dark ground. */
  color: var(--ink-soft);
  background: var(--surface);
}

.card__foot .arrow { color: var(--muted); }

/* The relay box carries the auto margin now. The marks inside it are absolute,
   where a margin has nothing to push against. */
.card__foot .relay { margin-left: auto; }

/* The mark arrives one step firmer than the one that left: --ink-soft, the
   colour of the label beside it, rather than the arrow's own --muted. Same
   move as the reset pill's hover — quiet at rest, firm while it is being
   used. The two dark feet and the CV tile answer this in their own palettes,
   further down. */
.card__foot .relay__mark--in { color: var(--ink-soft); }

/* A foot that is only there on hover. For a card whose picture IS the subject,
   a permanent strip would crop the thing the card exists to show; this one
   rides over the picture instead of displacing it, so the thumbnail never
   reflows as the pointer crosses it. Everything else is the ordinary foot —
   same padding, rule, type and arrow as the diagramming card's. */
.card--foot-reveal .card__foot {
  position: absolute;
  inset: auto 0 0;
  /* The card clips it (overflow: hidden), so at rest it is fully off-card. */
  transform: translateY(100%);
  transition: transform 0.2s ease;
}

.card--foot-reveal:hover .card__foot,
.card--foot-reveal:focus-visible .card__foot { transform: translateY(0); }

@media (prefers-reduced-motion: reduce) {
  .card--foot-reveal .card__foot { transition: none; }
}

/* --- Draggable cards -------------------------------------------------------
   Visitors can rearrange the grid; the order is kept in their own browser and
   affects nobody else. Keyboard equivalent is Ctrl/Cmd + arrow on a focused
   card, so this is not mouse-only.
   ------------------------------------------------------------------------ */
.bento .card { touch-action: manipulation; }

/* The mark that says the layout can be rearranged. js/bento.js puts one on the
   bio, the social card and Compose — every card drags, but the grid does not
   need eight copies of the same sentence, and those three are the ones with an
   empty top corner to spare.

   Two of the three sit inside a link, so bento.js swallows a click that lands
   on a grip: it wears cursor: grab, and the cursor has to be the truth. The
   padding is a hit box, deliberately larger than the six dots, so the hand
   appears as you approach the mark rather than only once you are exactly on
   it. The dots themselves stay out of the way of hit-testing, so the whole
   padded box behaves as one target. */
.grip {
  position: absolute;
  /* Inset is padding, not offset — the box reaches nearer the corner than the
     dots do, which is the point. */
  top: 10px;
  right: 10px;
  z-index: 2;
  padding: 10px;
  color: var(--muted);
  opacity: 0.45;
  transition: opacity 0.2s ease;
  cursor: grab;
}

.grip svg { display: block; width: 12px; height: 18px; pointer-events: none; }

.card:hover > .grip,
.grip:hover,
.bento[data-reordering] .grip { opacity: 1; }

/* A card in flight is pointer-events: none, so the cursor over it comes from
   whatever is underneath — the grid. Say it there instead. */
.bento[data-reordering],
.bento[data-reordering] .grip { cursor: grabbing; }

/* Any pointer move past the threshold starts a drag, so a selection begun
   inside a card could never be finished anyway — it only ever came out as
   text highlighted blue under a card already in flight. The thumbnails hold
   labels, not prose, so there is nothing there to lose. */
.bento .card {
  -webkit-user-select: none;
  user-select: none;
}

/* The bio is the exception, because it is the one card that is text rather
   than a picture with a caption: a name, a role and an email are things people
   copy. So it moves by its grip alone (js/bento.js keeps the drag off the rest
   of the card) and a press anywhere else on it is free to be a selection.
   That is also what the grip's cursor: grab has been promising all along.

   The grip and the party button opt back out: neither has anything to select,
   and both are things the pointer presses. */
.bento .card--bio {
  -webkit-user-select: text;
  user-select: text;
}

.bento .card--bio .grip,
.bento .card--bio .party {
  -webkit-user-select: none;
  user-select: none;
}

/* Stops the browser's own image/link dragging from racing ours. */
.bento .card img,
.bento .card svg,
.bento .card a { -webkit-user-drag: none; }

.card.is-dragging {
  /* Beats the reveal transition, which also animates transform. */
  transition: none !important;
  z-index: 10;
  cursor: grabbing;
  opacity: 0.95;
  border-color: var(--card-rule-drag);
  box-shadow: var(--lift-3);
  /* So elementFromPoint reports the card underneath, not this one. */
  pointer-events: none;
}

.card.is-target { border-color: var(--accent); }

/* Reset appears only once the order has actually been changed, and it appears
   in the header beside the contact button rather than under the grid — the
   grid is what it acts on, so it should not have to be scrolled to.

   It keeps .pill for the blurred surface, which is what holds it legible when
   a card passes under the header. Everything else steps down from the menu and
   the two buttons, because it is not one of them: it is an undo that only
   exists while there is something to undo, and it should not compete with the
   one control that is always there. Three steps down, not one: 32px against
   44, 13px muted text against 14px ink, and no shadow, so it sits on the
   ground the others float above. It stepped down in the edge too, when the
   pills were 2px; they are 1px now and that rung is gone, which leaves three.
   The blur and the 72% white are all it keeps. .nav__end's align-items centres it against the mail
   button, so the two are on one axis despite the height difference. */
.bento-reset {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 32px;
  padding: 0 12px;
  font-size: 13px;
  line-height: 16px;
  /* 600 with the rest of them. The 13px and the --muted are this pill's own
     step down and stay — leaving it at 400 while the other three went to 600
     would have put the inconsistency back one control to the left. */
  font-weight: 600;
  color: var(--muted);
  box-shadow: none;
  cursor: pointer;
  font-family: inherit;
  transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}

.bento-reset:hover {
  color: var(--ink);
  background: #FFFFFF;
  border-color: var(--pill-rule-hover);
}

/* Needed: the display above beats the UA stylesheet's [hidden] rule. */
.bento-reset[hidden] { display: none; }

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* --- The bio card --------------------------------------------------------- */
.card--bio {
  /* The copy is shorter than the card, and the leftover goes above it: the
     block sits on the bottom, so the air is all in one piece over the heading
     rather than split into two thin bands that read as slack at both ends.

     It was centred, which was the right answer when this card was three rows
     tall and the copy nearly filled it. At two rows with shorter copy the two
     halves of the leftover were each too small to read as deliberate. */
  justify-content: flex-end;
  gap: 18px;
  padding: 32px;
}

/* 24px. It went to 28 to close a gap against the reference, and at 28 it was
   simply too big for a heading this long: two lines of it crowd the copy under
   them and the card leads with a title instead of with a person.

   The weight is what was actually missing, and that stays. The name and the
   company are 700 against the sentence's 500, which is what the reference is
   really doing: its darkest ink is #36393B, near enough identical to --ink
   here, so the weight is what makes a name read as near-black, not the colour.
   Size was never the lever. */
.card--bio h1 {
  margin: 0;
  font-size: 24px;
  line-height: 1.3em;
  font-weight: 500;
  color: var(--muted-mid);
}

.card--bio h1 .name,
.card--bio h1 strong { color: var(--ink); font-weight: 700; }

/* --- The party button ------------------------------------------------------
   The one thing on this page that does nothing useful, so it is built to look
   like it belongs anyway: the same round 44px pill as the header buttons, the
   same border, the same hover. Only the contents are silly.
   ------------------------------------------------------------------------ */
/* No pill around it. Everything else in this card is text, and a bordered
   control sitting under the last line read as a form; the emoji on its own
   reads as an aside, which is what it is. The 36px box is still there — it is
   the hit target, and the focus ring needs something to draw around. */
.party {
  align-self: start;
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  margin-top: 2px;
  padding: 0;
  background: none;
  border: 0;
  border-radius: var(--radius-pill);
  font-family: inherit;
  cursor: pointer;
  transition: transform 0.2s ease;
}

.party:hover { transform: translateY(-2px); }
.party:active { transform: translateY(0); }

/* Emoji, so no colour of ours to set — only a size and the tilt it leans into
   when the pointer arrives. */
.party__glyph {
  display: block;
  font-size: 18px;
  line-height: 1;
  transition: transform 0.2s cubic-bezier(0.44, 0, 0.22, 1);
}

.party:hover .party__glyph { transform: rotate(-12deg) scale(1.1); }

@keyframes party-pop {
  0%   { transform: scale(1)    rotate(0deg); }
  35%  { transform: scale(1.35) rotate(-16deg); }
  70%  { transform: scale(0.94) rotate(9deg); }
  100% { transform: scale(1)    rotate(0deg); }
}

.party.is-popped .party__glyph {
  animation: party-pop 0.5s cubic-bezier(0.44, 0, 0.22, 1);
}

/* --- Confetti --------------------------------------------------------------
   Above the header, because a thing falling down the screen that stops at the
   header is a thing that has hit a shelf. Click-through in full: it is weather,
   not furniture, and js/party.js takes the layer away once the last piece has
   landed.

   Three nested elements per piece, and they have to stay three — one transform
   per element, because a second transform on the same element overwrites the
   first rather than adding to it:

     .confetti__p     falls, and carries the burst's lean
     .confetti__sway  swings side to side, on its own period
     .confetti__f     the piece itself, tumbling on a third

   The sway is the whole thing. Paper does not travel down a straight diagonal:
   it stalls, slides, catches, slides back. Two periods per piece that do not
   divide into each other, and no two pieces sharing a pair, is what separates
   a shower from a pattern.
   ------------------------------------------------------------------------ */
.confetti {
  position: fixed;
  inset: 0;
  z-index: 200;
  overflow: hidden;
  pointer-events: none;
}

.confetti__p {
  position: absolute;
  top: var(--y0);
  left: var(--x);
  animation: cf-fall var(--dur) linear var(--delay) backwards;
}

.confetti__sway {
  display: block;
  /* The tumble turns pieces through the depth axis, which is flat without a
     viewer to be flat to. This is that viewer, and it is shallow on purpose:
     a piece 8px across should not read as perspective, only as thickness. */
  perspective: 420px;
  animation: cf-sway var(--sway-dur) ease-in-out var(--delay) infinite;
}

.confetti__f {
  display: block;
  width: var(--w);
  height: var(--h);
  background: var(--c);
  animation: var(--tumble) var(--spin) linear var(--delay) infinite;
}

/* Four kinds, and the mix matters more than any single one: square-ish chips,
   thin slivers, round dots, curled streamers. Nothing here is a symmetrical
   ornament — that is the tell this is trying not to leave.

   Corners come off the piece's own width rather than being a flat 1px, so a
   6px chip and a 13px chip are softened by the same amount rather than the
   small one being left sharp. Slivers take nearly half their width, which
   rounds their ends off into a strip; chips take a fifth, which is a cut
   corner and not a lozenge. Nothing here has a hard 90° left in it, and the
   streamers never did — they are stroked with a round cap. */
.confetti__f--chip   { border-radius: calc(var(--w) * 0.2); }
.confetti__f--sliver { border-radius: calc(var(--w) * 0.42); }
.confetti__f--dot    { border-radius: 50%; }

/* The streamer is the only kind that needs a path: a strip with a curl along
   it, which no amount of border-radius gets to. Drawn as a stroked centreline
   rather than a filled outline, and stretched rather than fitted, so the two
   variants below come out at a different thickness at every width. */
.confetti__f--ribbon { background: none; }
.confetti__f--ribbon svg {
  display: block;
  width: 100%;
  height: 100%;
  color: var(--c);
}

/* Straight down at a constant rate — paper caught by air reaches its terminal
   speed long before it is in shot. The lean is the burst's wind, carried over
   the whole trip. Both fades happen off-screen; they are there so a piece
   removed early does not blink out. */
@keyframes cf-fall {
  0%   { transform: translate3d(0, 0, 0);             opacity: 0; }
  3%   {                                              opacity: 1; }
  95%  {                                              opacity: 1; }
  100% { transform: translate3d(var(--dx), 132vh, 0); opacity: 0; }
}

/* The swing, deliberately lopsided: out fast, back slow. Even sides would be
   a pendulum, and a piece of paper is not hung from anything. */
@keyframes cf-sway {
  0%   { transform: translateX(calc(var(--sway) * -0.6)); }
  38%  { transform: translateX(var(--sway)); }
  100% { transform: translateX(calc(var(--sway) * -0.6)); }
}

/* End over end, or over its own side, off whatever tilt the piece was cut at.
   --dir is 1 or -1, so half the shower turns the other way. The brightness
   step is the light leaving a face as it goes edge-on: without it a flipping
   chip reads as a shape being scaled rather than as paper turning over. */
@keyframes cf-tumble-x {
  0%   { transform: rotate(var(--tilt)) rotateX(0deg);
         filter: brightness(1); }
  50%  { transform: rotate(var(--tilt)) rotateX(calc(var(--dir) * 180deg));
         filter: brightness(0.82); }
  100% { transform: rotate(var(--tilt)) rotateX(calc(var(--dir) * 360deg));
         filter: brightness(1); }
}

@keyframes cf-tumble-y {
  0%   { transform: rotate(var(--tilt)) rotateY(0deg);
         filter: brightness(1); }
  50%  { transform: rotate(var(--tilt)) rotateY(calc(var(--dir) * 180deg));
         filter: brightness(0.85); }
  100% { transform: rotate(var(--tilt)) rotateY(calc(var(--dir) * 360deg));
         filter: brightness(1); }
}

/* Slivers and streamers get a third turn, in their own plane: a strip of paper
   is stiff along its length and swings about it as it flips. */
@keyframes cf-tumble-z {
  0%   { transform: rotate(var(--tilt)) rotateY(0deg) rotateZ(0deg);
         filter: brightness(1); }
  50%  { transform: rotate(var(--tilt)) rotateY(calc(var(--dir) * 180deg))
                                        rotateZ(calc(var(--dir) * 105deg));
         filter: brightness(0.86); }
  100% { transform: rotate(var(--tilt)) rotateY(calc(var(--dir) * 360deg))
                                        rotateZ(calc(var(--dir) * 210deg));
         filter: brightness(1); }
}

/* Reduced motion: the same handful, in the places it would have landed, doing
   nothing but arriving and leaving. A fall that does not fall. */
.confetti--still .confetti__p {
  top: var(--y);
  animation: cf-still var(--dur) ease-out var(--delay) backwards;
}

.confetti--still .confetti__sway {
  perspective: none;
  animation: none;
}

.confetti--still .confetti__f {
  transform: rotate(var(--tilt));
  animation: none;
}

@keyframes cf-still {
  0%   { opacity: 0; }
  25%  { opacity: 1; }
  60%  { opacity: 1; }
  100% { opacity: 0; }
}

.card--bio p {
  font-size: 18px;
  line-height: 1.5em;
  color: var(--body);
}

/* Inline links in the bio carry colour — the reference uses three. */
.card--bio a { font-weight: 500; transition: opacity 0.2s ease; }
.card--bio a:hover { opacity: 0.65; }
.l-a { color: var(--link-a); }
.l-b { color: var(--link-b); }
.l-c { color: var(--link-c); }

.card--bio strong { color: var(--ink); font-weight: 600; }

/* --- The writing card -----------------------------------------------------
   A published piece says more as text than as a thumbnail, so this card
   carries its own citation rather than an image.
   ------------------------------------------------------------------------ */
.card--writing {
  gap: 10px;
  padding: 28px 32px;
}

/* Inter, not DM Mono. No mono anywhere on this site outside the terminal card,
   where it is the subject rather than a label. A small eyebrow gets its step
   down from weight and colour instead — 500 rather than the body's 400, because
   Inter at 12px reads lighter than the mono did at the same size and without it
   the word goes weak. */
.card--writing .card__eyebrow,
.card--file .card__eyebrow {
  font-size: 12px;
  line-height: 16px;
  font-weight: 500;
  color: var(--muted);
}

.card--writing .card__title,
.card--file .card__title {
  font-size: 18px;
  line-height: 1.35em;
  font-weight: 500;
  color: var(--ink);
}

.card--writing .card__sub {
  font-size: 15px;
  line-height: 1.4em;
  color: var(--body);
}

.card--writing .card__cite {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: auto;
  padding-top: 8px;
  font-size: 13px;
  line-height: 18px;
  color: var(--muted);
}

.card--writing .card__cite-text { min-width: 0; }

/* The citation row is this card's foot in all but name — pinned to the bottom,
   the arrow at the far end — so it relays like one. The box takes the margin;
   .relay is already flex: none. */
.card--writing .relay { margin-left: auto; }
.card--writing .card__cite .relay__mark--in { color: var(--ink-soft); }

/* --teal-deep, not --accent: interactive text is one colour on this site, and
   #0090FF is 3.26:1 on white besides. */
a.card--writing:hover .card__title { color: var(--teal-deep); }

/* --- The CV card ----------------------------------------------------------
   A thumbnail, not a paragraph: the mark alone at rest, everything else
   arrives on hover.

   The flood is a single clipped layer, not a stack of things timed to agree.
   It holds the teal gradient AND the white copy of the mark, and one growing
   triangle reveals both — so the diagonal edge that carries the colour in is
   the very same edge that repaints the mark. They cannot drift apart because
   there is only one of them.

   The triangle is pinned to the bottom-right corner and sweeps to the
   top-left. It starts degenerate (all three vertices stacked in the corner)
   and opens until its hypotenuse has cleared the far corner. For vertices
   (a, 100%) (100%, a) (100%, 100%) the hypotenuse is the line x + y = a + 100,
   so the top-left corner is covered once a reaches -100%. -120% clears it with
   a margin while still spending 91% of the travel on screen — overrun much
   past that and the tail of the sweep happens outside the tile, where it only
   eats duration. Interpolation needs both polygons to carry the same vertex
   count.

   The gradient is on the clipped layer rather than a moving one, so its
   bright end stays welded to the bottom-right corner for the whole sweep:
   the colour keeps pouring from the corner it started in.
   -------------------------------------------------------------------------- */
.card--file {
  position: relative;
  gap: 10px;
  padding: 28px;
}

.cv {
  position: absolute;
  inset: 0;
}

.cv__rest,
.cv__wash {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}

.cv__rest { color: var(--cv); }

.cv__wash {
  color: var(--surface);
  background: radial-gradient(140% 140% at 100% 100%,
                              var(--cv-lift) 0%,
                              var(--cv) 42%,
                              var(--cv-deep) 100%);
  clip-path: polygon(100% 100%, 100% 100%, 100% 100%);
  transition: clip-path 0.5s cubic-bezier(0.44, 0, 0.22, 1);
}

/* Both marks lift and tilt together, the way you would pick a sheet of paper
   off a desk. Identical transforms, so they stay registered through the wipe. */
.cv__mark {
  width: 104px;
  height: 104px;
  transform-origin: 50% 60%;
  transition: transform 0.5s cubic-bezier(0.34, 1.3, 0.64, 1);
}

a.card--file:hover .cv__wash,
a.card--file:focus-visible .cv__wash {
  clip-path: polygon(-120% 100%, 100% -120%, 100% 100%);
}

a.card--file:hover .cv__mark,
a.card--file:focus-visible .cv__mark { transform: rotate(-5deg) scale(1.06); }

/* The tile reads as one solid block once the sweep lands, so the hairline
   goes teal with it rather than ringing it in grey. */
a.card--file:hover,
a.card--file:focus-visible { border-color: var(--cv); }

/* Above the flood, so these two carry their own colour change. The eyebrow
   sits in the top-left, the last corner the edge reaches — the delay is timed
   to that crossing, near enough that it never sits grey on teal. */
.card--file .card__eyebrow {
  position: relative;
  z-index: 1;
  transition: color 0.18s ease 0.15s;
}

a.card--file:hover .card__eyebrow,
a.card--file:focus-visible .card__eyebrow { color: rgba(255, 255, 255, 0.85); }

.card--file .btn {
  position: relative;
  color: var(--cv);
  border-color: rgba(85, 179, 165, 0.4);
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.3s ease, transform 0.3s ease,
              color 0.2s ease, border-color 0.2s ease;
}

.card--file .btn .relay { margin-left: 0; }

/* No colour of its own: both marks inherit the pill's, which is already going
   from --cv to white as the flood passes under them. The mark hands over in
   whatever colour the button is wearing at that instant, which is the one
   place on the page where the relay changes colour without being told to. */

/* Rides in just behind the edge that has already passed it. */
a.card--file:hover .btn,
a.card--file:focus-visible .btn {
  opacity: 1;
  transform: none;
  color: var(--surface);
  border-color: rgba(255, 255, 255, 0.5);
  transition-delay: 0.12s;
}

/* The pill has nothing to reveal itself with where there is no pointer, so it
   simply stays out, sitting on the resting white ground. */
@media (hover: none) {
  .card--file .btn { opacity: 1; transform: none; }
}

/* --- The social card ------------------------------------------------------ */
.card--social { gap: 14px; padding: 24px; }

.card--social .avatar {
  display: block;
  width: 44px;
  height: 44px;
  border-radius: var(--radius-pill);
  object-fit: cover;
  background: var(--fill);

  /* The same picture as the one in the header, so it answers the pointer the
     same way. Values copied from .nav__avatar rather than reworked: same
     0.85s, same overshoot, same full turn. Two copies of one gesture only
     read as one gesture if they are actually identical, and a spin that is
     nearly the header's is worse than not spinning at all. */
  transform: rotate(0deg);
  transition: transform 0.85s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Focus gets the same state, as everywhere else on this grid. The header's
   button also scales; that belongs to the pill it sits in, and there is no
   pill here, so this is the turn on its own. */
a.card--social:hover .avatar,
a.card--social:focus-visible .avatar { transform: rotate(360deg); }

.card--social .handle { font-size: 14px; color: var(--muted); }
.card--social .tagline { font-size: 16px; line-height: 1.4em; color: var(--ink); }

/* Type copied from .card__foot, not chosen again: 14/16 at 600 in --ink-soft.
   Every label on this page that is a small line of type on a control now reads
   the same — the five card feet, this pill, the projects trigger, the reset
   pill — and the grid gains no fourth rung for the same job. It was 500 in
   --ink, which is the trap .card__foot's own comment sets out: weight and
   colour have to move together, because bolder type reads darker at the same
   value, so 600 in the darkest ink is the two stacking. Change one of the four
   and change all four. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin-top: auto;
  padding: 10px 18px;
  font-size: 14px;
  line-height: 16px;
  font-weight: 600;
  color: var(--ink-soft);
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  transition: background 0.2s ease;
}

.btn:hover { background: var(--fill); }

/* Inside a link card the button is decorative — the whole card is the link. */
a.card .btn { pointer-events: none; }

/* --- The Connect pill -----------------------------------------------------
   The social card already turns its avatar under the pointer; this is what the
   pill does while that happens. One idea read three ways, all of them going
   the direction the mark points:

     the ground washes in from the left edge,
     the mark leaves by the top right, along the line it points down,
     and a second copy of it arrives from the bottom left, out of the corner
     the wash came from.

   So it is not a button highlighting. It is a mark departing and being
   replaced, which is what a link to somebody else's site does.

   Teal is the detail and only the detail: the ring and the arriving mark take
   it at full strength, the ground at a tenth, and the label neither moves nor
   changes colour. The CV tile in the same grid floods solid teal and turns its
   copy white on hover; if this label went with it, two neighbouring cards
   would be performing the same gesture.

   The block sits here, below .btn, rather than up in the social card section.
   The wash is a background-image, and the background shorthand in .btn:hover
   would wipe it if that rule came last. Inside a card .btn is
   pointer-events: none, so that hover cannot fire in the first place — but the
   ordering is what keeps this safe if the pointer-events line ever goes.
   ------------------------------------------------------------------------ */
.card--social .btn {
  position: relative;
  /* The wash below sits at z-index -1, which puts it behind the label and in
     front of nothing else. isolate is what guarantees the second half of that:
     without a stacking context here, a negative index would drop the wash
     through the pill and behind the card itself. */
  isolation: isolate;
  transition: border-color 0.3s ease;
}

/* The ground, sweeping in from the left on a 45 degree leading edge — the same
   angle the mark is pointing and the same angle it leaves on, so the pill fills
   along the line the whole gesture runs down. A plain vertical edge was the
   first version and it worked, but it was the one part of this that had nothing
   to do with the arrow.

   The lean is the four points of a polygon, not a rotation: 36px of horizontal
   offset over 36px of height, which is the pill's inner height (10 + 16 + 10),
   so the edge is at exactly 45 degrees and stays there. Two points move and two
   stand still, which is what makes the clip interpolate — a polygon can only
   animate into another polygon with the same number of points. The 36px offset
   is carried by both moving points, so the lean is constant the whole way
   across and does not shear as it grows.

   inset: 0 is the padding box, so the wash stops a hair inside the ring rather
   than under it. radius inherited, or the clip would square the corners off. */
.card--social .btn::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: var(--connect-wash);
  clip-path: polygon(0 0, 0 0, -36px 100%, 0 100%);
  /* Same easing as the CV card's diagonal wipe: quick off the mark, long
     settle. The pair below lands at 0.34 + 0.07, so the ground and the
     arriving mark finish together — one gesture, not two of different
     lengths. */
  transition: clip-path 0.42s cubic-bezier(0.44, 0, 0.22, 1);
}

a.card--social:is(:hover, :focus-visible) .btn { border-color: var(--connect); }

a.card--social:is(:hover, :focus-visible) .btn::before {
  clip-path: polygon(0 0, calc(100% + 36px) 0, 100% 100%, 0 100%);
}

/* The geometry and the travel are shared now — see "The arrow relay" up with
   the arrows. What is left here is what belongs to this pill alone: which two
   colours the marks hand over between.

   --muted against the label's --ink-soft, the pairing .card__foot uses for its
   own arrow, so the glyph sits one step quieter than the word beside it. */
.card--social .relay__mark--out { color: var(--muted); }

/* Teal is on the element rather than in a hover rule, because the change of
   colour IS the substitution: the grey mark leaves and a teal one takes its
   place, so nothing has to be tinted on the way.

   Every other relay on the page hands over to the colour of the label beside
   it. This one is the exception, and it is the only one — the ring and the
   ground it sweeps in are already teal, so the mark arriving in it is the
   third reading of one detail rather than a fourth accent. */
.card--social .relay__mark--in { color: var(--connect); }

/* Colour still answers the pointer, and the ground arrives already there. The
   marks are handled by the relay's own reduced-motion rule. */
@media (prefers-reduced-motion: reduce) {
  .card--social .btn::before { transition: none; }
}

/* Inline link inside running text — underline shows the boundary, colour on
   hover. Used in case-study prose. */
.link {
  color: var(--teal-deep);
  font-weight: 500;
  box-shadow: inset 0 -1px 0 rgba(18, 102, 109, 0.35);
  transition: box-shadow 0.2s ease;
}

.link:hover { box-shadow: inset 0 -1px 0 var(--teal-deep); }

/* --- Footer ---------------------------------------------------------------
   Copyright left, contact right, one line. Wraps to two lines rather than
   letting a long address collide with the copyright.
   ------------------------------------------------------------------------ */
.foot {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px 24px;
  flex-wrap: wrap;
  width: var(--w-home);
  margin-inline: auto;
  padding-bottom: 64px;
  font-size: 14px;
  line-height: 20px;
  color: var(--muted);
}

.foot__contact { text-align: right; }

/* No rule under the address at rest — it is the only ink-coloured run in a
   muted line, which is enough to mark it as the link. The rule arrives on
   hover, so the affordance is still there when it is being aimed at. */
.foot__contact a {
  color: var(--ink);
  transition: box-shadow 0.2s ease, color 0.2s ease;
}

/* Teal, not --accent: the two ways of reaching me light up in the same colour
   rather than one blue and one teal at opposite ends of the page. --teal-deep
   rather than --connect, because this is text: the base is 2.18:1 on the
   footer's ground, which is not a colour anyone should have to read. */
.foot__contact a:hover {
  color: var(--teal-deep);
  box-shadow: inset 0 -1px 0 var(--teal-deep);
}

/* --- Case study ----------------------------------------------------------- */
/* Title is the largest type on the site at 32px, and nothing else comes
   close. There are no headings inside the body — the images carry it. */
.case__head { width: min(100%, var(--w-content)); margin-inline: auto; }

.case__title {
  margin: 0;
  max-width: var(--w-text);
  font-size: 32px;
  line-height: 1.2em;
  font-weight: 500;
  color: var(--muted-mid);
}

.case__title .project { color: var(--ink); }

.case__intro {
  max-width: var(--w-text);
  font-size: 16px;
  line-height: 24px;
  color: var(--body);
}

.case__meta {
  display: flex;
  flex-direction: column;
  gap: var(--gap-s);
  max-width: var(--w-text);
  font-size: 16px;
  line-height: 24px;
  color: var(--body);
}

/* Plain "Label: value" — not an uppercase eyebrow, not a grid of stat cards. */
.case__meta .label { color: var(--muted-soft); }

/* Split head. A short case study has no room for a run of sections, so the
   whole of the writing is one block sitting BESIDE the title rather than under
   it: title and metadata left, the summary right. Two columns of the same
   width, so neither reads as a caption to the other.

   The title loses its own --w-text cap here: the column is already narrower
   than the measure, and the cap would leave it ragged inside its own half. */
.case__head--split {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--gap-l);
  align-items: start;
}

.case__head--split .case__title { max-width: none; }

/* Right column: the summary, one stack of paragraphs with the gap doing the
   separating. */
.case__head--split .case__body {
  display: flex;
  flex-direction: column;
  gap: var(--gap-m);
}

/* Body copy in the head runs at the same 18/28 the prose does: it is the only
   writing on the page and should not read as a deck. */
.case__head--split .case__body p {
  margin: 0;
  max-width: var(--w-text);
  font-size: 18px;
  line-height: 28px;
  color: var(--body);
}

/* Left column: the title with the metadata under it. One stack, 32px apart,
   which is a real break without reading as a second section. */
.case__head--split .case__head-main {
  display: flex;
  flex-direction: column;
  gap: 32px;
}

/* The metadata is the quieter half of that column: 16/24, and it keeps the
   smaller gap between its own lines. */
.case__head--split .case__meta { gap: 6px; }

.case__head--split .case__meta p { font-size: 16px; line-height: 24px; }

/* The image sequence. 48px between shots, 64px around a link label. */
.shots {
  display: flex;
  flex-direction: column;
  gap: var(--gap-l);
  align-items: center;
}

.shot { margin: 0; width: min(100%, var(--w-content)); }

.shot--wide { width: min(100%, var(--w-wide)); }

/* Two-up pair — a native pattern in the reference, used often. */
.shot--pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gap-m);
}

/* Placeholder frame. To use a real image, replace the .shot__ph div with:
     <img src="images/01-overview.png" alt="" class="shot__img">           */
.shot__ph {
  aspect-ratio: var(--ratio, 16 / 9);
  display: grid;
  place-content: center;
  gap: 6px;
  text-align: center;
  padding: 24px;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
}

.shot__img {
  width: 100%;
  border-radius: var(--radius);
  border: 1px solid var(--rule);
}

/* The hero. Where it carries no caption the figure itself can be the frame:
   it clips at the radius and the image grows inside it, which is why the
   scale sits on the img and not on the figure. The figure is also what the
   scroll reveal transforms, so keeping the two on separate elements means
   neither overwrites the other.

   A CAPTIONED HERO CANNOT USE THIS. The figure's box then includes the
   caption, so the radius rounds the caption's bottom corners and leaves the
   image's own bottom two square. Those heroes take --still below.

   1.04 over 1.2s on the house curve. Small and slow enough to read as the
   page breathing rather than as a hover state answering back, and the curve's
   soft start is what keeps it from looking like a jump. */
.shot--hero {
  overflow: hidden;
  border-radius: var(--radius);
  /* Do not add transform here as a compositing hint. The scroll-reveal rule
     near the end of this file owns transform on .case > figure and would
     overwrite it the moment the figure comes into view. */
}

.shot--hero .shot__img {
  display: block;
  border: 0;                  /* the frame is the clip now, so the grey rule
                                 would only trace a second edge inside it */
  border-radius: 0;
}

@media (hover: hover) {
  .shot--hero .shot__img {
    transition: transform 1.2s cubic-bezier(0.44, 0, 0.22, 1);
  }

  .shot--hero:hover .shot__img { transform: scale(1.04); }
}

@media (prefers-reduced-motion: reduce) {
  .shot--hero .shot__img { transition: none; }
  .shot--hero:hover .shot__img { transform: none; }
}

/* A hero that holds still and carries a caption. Two changes, and they are one
   decision rather than two: the radius moves off the figure and onto the image,
   which is the only box whose corners we actually mean, and the pointer gets no
   answer. The clip comes off with it, because the clip existed only to contain
   the scale. Keeping the clip and the image radius together would show a thin
   crescent of ground at each corner while the image was scaled, the image's own
   radius growing past the figure's fixed one.

   So a captioned hero does not zoom. That is the coupling, not a preference: an
   unclipped scale would spill over whatever sits beside it. Also used where a
   hero is simply meant to sit still. */
.shot--hero--still {
  overflow: visible;
  border-radius: 0;
}

.shot--hero--still .shot__img { border-radius: var(--radius); }

@media (hover: hover) {
  .shot--hero--still .shot__img { transition: none; }
  .shot--hero--still:hover .shot__img { transform: none; }
}

/* Rule off, radius kept. Every image in the Hana and Authentication cases now
   carries this: those exports sit on their own ground, so the border only ever
   drew a second edge just outside the one the image already had. Left as a
   modifier rather than flipped to the default because design-thinking.html
   still frames all thirteen of its images. */
.shot__img--unframed { border: 0; }

/* Half the radius, 12px instead of 24px. For an export that is a flat sheet
   rather than a device or a screen: the full radius reads as a card and starts
   competing with the shell, and at half it is just a softened edge. Pairs with
   --unframed, since a sheet on white does not want a rule around it either. */
.shot__img--tight { border-radius: var(--radius-small); }

/* A figure that does not need the whole column. The image is cropped tight to
   its own content, so filling 1008px would blow the chart up past every other
   figure on the page; at 75% it renders at the size it was read at. Block-level
   and unstretched, so the left edge still lands on the text column. */
.shot__img--narrow { width: 75%; }

@media (max-width: 809.98px) {
  .shot__img--narrow { width: 100%; }
}

/* Art that carries its own frame — a shot exported with rounded, transparent
   corners. The 24px radius and the rule border would trace a second, empty
   outline around it, so both come off. */
.shot__img--bare {
  border: 0;
  border-radius: 0;
}

.shot__label {
  font-family: var(--font-mono);
  font-size: 14px;
  line-height: 16px;
  color: var(--ink);
}

.shot__hint {
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 16px;
  color: var(--muted);
}

/* Video sits in the same frame as an image. Autoplaying loops must be muted
   and carry playsinline, or iOS opens them fullscreen. */
.shot__video {
  display: block;
  width: 100%;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--surface);
}

/* Which asset this is, and whether it still needs producing. */
.shot__badge {
  justify-self: center;
  padding: 2px 10px;
  margin-bottom: 4px;
  border-radius: var(--radius-pill);
  background: var(--fill);
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 18px;
}

/* Outstanding work, same pink as NEEDS DATA. */
.shot__badge--todo {
  background: rgba(250, 0, 70, 0.08);
  color: var(--accent-warm);
}

/* Four short loops in a row, read as one comparison. */
.shot--quad {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 16px;
}

.shot--quad .shot__ph { padding: 16px; }
.shot--quad .shot__hint { font-size: 11px; }

/* Real photos in a quad are mixed portrait and landscape; squaring them keeps
   the row even instead of ragged. */
.shot--quad .shot__img { aspect-ratio: 1 / 1; object-fit: cover; }

/* The only text between images in the reference: a small link label. */
.shot__link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 16px;
  font-size: 14px;
  line-height: 16px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  transition: background 0.2s ease, color 0.2s ease;
}

/* --connect, the same teal the footer address and the Connect pill answer
   with, rather than the blue --accent. One hover colour for the things that
   take you somewhere off the page. */
.shot__link:hover { color: var(--teal-deep); border-color: #CFD2D3; }

.shots__note {
  display: flex;
  justify-content: center;
  padding-block: 16px;
}


/* --- Case-study shell -----------------------------------------------------
   The page takes a colour, the content sits in one large rounded container,
   and the header sits above it on the tint — aligned to the same width, not
   pinned to the viewport edges. No floating pills here; that is the homepage.
   ------------------------------------------------------------------------ */
body.case-page { background: var(--case-tint); }

/* Sticky, and it hides on the way down and returns on the way up — the same
   behaviour and the same data-hidden vocabulary as the homepage's .nav, driven
   by the same function in site.js. These pages run long, and the way back was
   only reachable by scrolling to the very top.

   No background of its own: once it leaves the top it is a pill floating over
   the shell, which is what data-stuck below dresses it for. The row is
   click-through so it never blocks the text it now sits over; only the Home
   pill takes the pointer. */
.case-nav {
  position: sticky;
  top: 0;
  z-index: 50;

  display: flex;
  padding-block: 28px;

  /* The row is as wide as the page, but it must line up with the words, not
     with the page. The title below it starts at the shell's padding plus
     whatever the content column is centred by inside the shell, so that is
     what this reproduces: the Home pill's left edge and the h1's left edge are
     the same edge, and the project name on the right ends where the text does.

     The max() is the case where the shell is narrower than the content column
     and there is nothing to centre — below about 1100px, and on the note pages
     at any width. Then the inset is just the shell's padding. */
  padding-inline: calc(var(--shell-pad) + max(0px,
                  (var(--w-page) - var(--shell-pad) * 2 - var(--w-content)) / 2));

  pointer-events: none;
  transition: transform 0.3s ease, opacity 0.3s ease;
}

.case-nav[data-hidden="true"] {
  transform: translateY(calc(-100% - 12px));
  opacity: 0;
}

/* One frame while site.js settles the row against a restored scroll position.
   Same reason as .nav[data-settling]: without it the row is painted in place
   and then visibly flies off on every reload. */
.case-nav[data-settling] { transition: none; }

/* The bar is a container that is INVISIBLE at the top of the page. It exists
   there only to hold the two controls at the right edges. See [data-stuck]. */
.case-nav__bar {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  padding: 6px 22px 6px 6px;

  /* -6px so the pill's own left edge, not the bar's padding, lands on the
     h1's edge. At the top of the page the bar is not there to be seen and the
     pill must sit exactly where it always did. */
  margin-left: -6px;

  background: transparent;
  border-radius: var(--radius-pill);
  transition: background 0.3s ease, box-shadow 0.3s ease;
}

/* Past the top of the page. site.js sets this at y > 40, and the row itself is
   already hidden on the way down and returned on the way up — so in practice
   the bar appears when the reader heads back up, which is the only time the
   title is worth re-reading. Before this existed the title simply faded out
   here, because white on the page's tint had nothing behind it once the page
   had scrolled. Now it has this. */
.case-nav[data-stuck] .case-nav__bar {
  background: var(--case-bar);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.08);
  pointer-events: auto;
}

.case-nav__home {
  pointer-events: auto;
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 18px 10px 14px;
  background: var(--surface);
  border-radius: var(--radius-pill);
  font-size: 15px;
  line-height: 20px;
  font-weight: 500;
  color: var(--ink);
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.case-nav__home:hover {
  transform: translateX(-2px);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.10);
}

/* At the top of the page the pill floats on the tint and carries the resting
   shadow the homepage's pills have. On the bar it is sitting on a surface, so
   the shadow comes off and the bar's own does the lifting. */
.case-nav__home { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06); }
.case-nav[data-stuck] .case-nav__home { box-shadow: none; }
.case-nav[data-stuck] .case-nav__home:hover { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.10); }

.case-nav__home svg { width: 18px; height: 18px; }

/* Name solid, descriptor a step back, so the eye lands on the project. Only a
   step: the ground here is light, so taking the descriptor much further down
   would cost more legibility than the distinction is worth. */
.case-nav__title {
  min-width: 0;
  font-size: 15px;
  line-height: 20px;
  color: rgba(255, 255, 255, 0.85);
  text-align: right;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  pointer-events: none;
}

.case-nav__title b { font-weight: 500; color: #FFFFFF; }

/* Under this the descriptor has nowhere to go. Drop it and let the bar shrink
   to the Home pill. */
@media (max-width: 700px) {
  .case-nav__title { display: none; }
  .case-nav__bar { flex: 0 1 auto; }
}

.case-shell {
  position: relative;      /* the ongoing-work toast hangs off its top edge */
  background: var(--shell);
  border-radius: var(--radius-large);
  padding: 64px var(--shell-pad) 56px;
  margin-bottom: 48px;
}

/* --- Ongoing-work toast ----------------------------------------------------
   A notice on a case study that is still moving. It straddles the shell's top
   edge rather than sitting inside it: half on the tint, half over the title,
   which is what makes it read as something laid on top of the page instead of
   a section of it. Dark ground, because every other floating thing on the site
   is white and this one has to be seen once.
   ------------------------------------------------------------------------ */
.case-toast {
  position: absolute;
  top: -19px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 4;
  display: flex;
  align-items: center;
  gap: 4px;
  max-width: calc(100% - 2 * var(--shell-pad));
  padding: 9px 8px 9px 18px;
  border-radius: var(--radius-pill);
  background: var(--ink);
  box-shadow: var(--lift-2);
}

/* The dismiss state lives in the markup, so no script is needed once the
   toast has been injected as a string. */
.case-toast__box {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.case-toast:has(.case-toast__box:checked) { display: none; }

.case-toast__text {
  margin: 0;
  font-size: 14px;
  line-height: 20px;
  color: rgba(255, 255, 255, 0.72);
}

.case-toast__text b { font-weight: 500; color: #FFFFFF; }

.case-toast__close {
  flex: none;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: var(--radius-pill);
  color: rgba(255, 255, 255, 0.6);
  cursor: pointer;
  transition: color 160ms ease, background 160ms ease;
}

.case-toast__close:hover {
  color: #FFFFFF;
  background: rgba(255, 255, 255, 0.12);
}

.case-toast__close svg { width: 16px; height: 16px; }

/* Focus has to be visible on the label, since the input it drives is not. */
.case-toast__box:focus-visible + .case-toast__text + .case-toast__close {
  color: #FFFFFF;
  background: rgba(255, 255, 255, 0.12);
  outline: 2px solid rgba(255, 255, 255, 0.5);
  outline-offset: 1px;
}

/* Inside the shell the content column is 1008px and a wide shot fills the
   full 1056px inner width, so the shell's padding is what frames it. */
.case-shell .case { width: 100%; }

.case-foot {
  width: min(100%, var(--w-content));
  margin: 56px auto 0;
  font-size: 14px;
  line-height: 16px;
  color: var(--muted);
}

/* --- Long-form case study -------------------------------------------------
   The reference has none of this: no headings, no captions, no stats. These
   exist because the content needs them, and they are built to sit inside the
   reference's system rather than beside it — nothing above 32px, no new
   colours, the same 24px radius and white-on-grey frames.
   ------------------------------------------------------------------------ */
.case {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--gap-l);
}

/* Everything sits in the 1008px column; wide shots break out to 1056px. */
.case > *          { width: min(100%, var(--w-content)); }
.case > .shot--wide { width: min(100%, var(--w-wide)); }

/* Text blocks get extra air above, so images sit 48px apart while a new
   section reads as a break. */
.case > .prose { margin-top: 16px; }

/* A tighter rhythm than the default case. Every figure here is a white block
   on the grey ground, so the block already reads as a break and does not need
   48px of ground announcing it as well. Asymmetric on purpose: a figure
   belongs to the paragraph above it, so it sits closer to that than to the
   section that follows. */
.case--tight { gap: 32px; }
.case--tight > .prose { margin-top: 20px; }

/* A stat row is not a section of its own: it belongs to the heading above it
   and to the paragraph that reads the numbers out. At the full 48px it floated
   between the two, so pull it towards its heading and let the prose below
   follow closer than a new section would. */
.case > .stats { margin-top: -24px; }
.case > .stats + .prose { margin-top: -20px; }

.prose {
  display: flex;
  flex-direction: column;
  gap: var(--gap-m);
}

/* Section heading. 22px keeps a real step above 18px body while leaving the
   32px title as the only large type. */
.prose h2 {
  margin: 0;
  font-size: 22px;
  line-height: 1.3em;
  font-weight: 500;
  color: var(--ink);
}

/* 18/28 rather than 16/24. The 714px measure runs ~86 characters at 16px,
   well past the 60-75 that reads comfortably; at 18px it lands near 77, and
   the extra 4px of leading is what a line that long needs to be findable. */
.prose p {
  max-width: var(--w-text);
  font-size: 18px;
  line-height: 28px;
  color: var(--body);
}

/* Lead-in phrase that opens a decision. */
.prose strong { color: var(--ink); font-weight: 600; }

/* A list of parallel items. Custom marker rather than a disc, so it sits on
   the same grid as everything else. */
.prose ul {
  display: flex;
  flex-direction: column;
  gap: 14px;
  max-width: var(--w-text);
  margin: 0;
  padding: 0;
  list-style: none;
}

.prose li {
  position: relative;
  padding-left: 22px;
  font-size: 18px;
  line-height: 28px;
  color: var(--body);
}

.prose li::before {
  content: "";
  position: absolute;
  left: 3px;
  top: 11px;
  width: 6px;
  height: 6px;
  border-radius: 2px;
  background: var(--muted);
}

/* A wide shot breaks out 24px each side; nudge its caption back so every
   caption on the page shares one left edge with the prose. */
.shot--wide .shot__caption { margin-left: 24px; }

/* In a pair the caption is a grid item, so the 24px row gap stacks on top of
   the caption's own 14px. Pull it back by the gap so a paired caption sits the
   same 14px under its images as every other caption. Both breakpoints use
   --gap-m for the row gap, so one value covers them. */
.shot--pair .shot__caption { margin-top: calc(14px - var(--gap-m)); }

/* Caption under an image. Left-aligned to the image, letter-spacing 0. */
.shot__caption {
  max-width: var(--w-text);
  margin-top: 14px;
  font-size: 14px;
  line-height: 20px;
  color: var(--muted);
}

/* Unresolved figure you still owe the page. Deliberately loud. */
.needs-data {
  display: inline-block;
  padding: 1px 8px;
  border-radius: 6px;
  background: rgba(250, 0, 70, 0.08);
  color: var(--accent-warm);
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 18px;
}

/* --- Colour ---------------------------------------------------------------
   Every value here is measured from the reference. The theme names are
   colour-coded identically in the prose and in the diagram, so the two read
   as one legend rather than two unrelated pieces of styling.

     blue   #0090FF  the Hana family, and inline links
     purple #8339EA  the Classic family
     teal   #53ABB3  sizing — the property the whole case study is about
     pink   #FA0046  reserved for NEEDS DATA, nothing else
   ------------------------------------------------------------------------ */
/* The one inline tint left in running prose. It marks the values that move
   with the sizing axis, and the specimen caption establishes what the teal
   means at the point it is first used.

   Theme names are deliberately NOT tinted. Colouring "Classic" and "Hana" on
   every mention made a paragraph read as a legend for itself, and the names
   are capitalised proper nouns that already stand out as terms. The two
   families still carry colour where it does work: the conversion diagram's
   nodes, below. */
.t-size    { color: var(--link-b); font-weight: 500; }

/* Diagramming: the three parallel tracks. Same three hues, different meaning
   per page — the legend is always established within the page that uses it.
   The `.prose strong` variants are needed because that rule sets its own
   colour and would otherwise out-specify these. */
.t-speed,   .prose strong.t-speed   { color: var(--accent); font-weight: 600; }
.t-routing, .prose strong.t-routing { color: var(--link-a); font-weight: 600; }
.t-trust,   .prose strong.t-trust   { color: var(--link-b); font-weight: 600; }

/* The project name in the case-study title. The reference keeps this dark;
   colouring it is the one deliberate departure, and the colour is --connect,
   the same teal the address in the footer and the link pills answer with, so
   the site has one accent rather than a blue for headings and a teal for
   everything that reacts. Revert by setting this back to var(--ink). */
.case__title .project { color: var(--teal-mid); }

/* A quiet marker to give a very long page some rhythm. Delete this rule if
   the headings read better bare. */
.prose h2 {
  display: flex;
  align-items: center;
  gap: 10px;
}

.prose h2::before {
  content: "";
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 2px;
  background: var(--case-tint);   /* the page's own tint, so the marker reads as
                                     part of the ground the page sits on rather
                                     than as a second accent */
}

/* --- Stat row -------------------------------------------------------------
   The cards are capped at 260px rather than sharing the full width in equal
   fractions. Stretched to the shell, three short figures read as a dashboard
   the page does not have: a lot of empty card around six characters, and a
   left edge that no longer lines up with anything. Capped and packed left,
   they sit on the prose's edge and the row ends where the numbers end.

   The outline is --card-rule, the bento cards' rung, rather than --rule: a card
   sitting on the page's own ground wants the alpha edge the homepage cards
   wear, and reusing that rung keeps one ladder rather than inventing a
   second. */
.stats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gap-m);
}

/* flex, not a 260px grid track: a fixed track would overflow the 1008px shell
   on the four-up rows. The cards grow from zero to the 260px cap instead, so
   three figures cap and leave the rest of the line empty, and four share what
   the shell has. The count comes from the items, so .stats--3 has nothing
   left to say. */
.stat {
  flex: 1 1 0;
  max-width: 260px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 24px;
  background: var(--surface);
  border: 1px solid var(--card-rule);
  border-radius: var(--radius);
  transition: transform 0.2s ease, border-color 0.2s ease;
}

/* A small lift under the pointer. Not a link, so it stays at scale: nothing to
   click, and the card only acknowledges being looked at. */
.stat:hover {
  transform: scale(1.02);
  border-color: var(--card-rule-hover);
}

@media (prefers-reduced-motion: reduce) {
  .stat { transition: border-color 0.2s ease; }
  .stat:hover { transform: none; }
}

.stat__value {
  font-size: 24px;
  line-height: 1.2em;
  font-weight: 500;
  color: var(--ink);
}

.stat--key .stat__value { color: var(--link-b); }

.stat__label {
  font-size: 14px;
  line-height: 20px;
  color: var(--muted);
}

/* --- Password gate --------------------------------------------------------
   The front door of a private case study. It sits where the shell would sit,
   at the shell's own width and radius, so the page reads as the same object
   with the content not yet in it rather than as an error screen. Short: a
   title, a reason, a field. Nothing about the work it is holding back.
   ------------------------------------------------------------------------ */
.gate {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--gap-m);
  padding-top: calc(var(--nav-top) + var(--nav-h) + 72px);
  padding-bottom: 96px;
}

.gate__card {
  display: flex;
  flex-direction: column;
  gap: 14px;
  width: min(100%, 480px);
  padding: 40px;
  border-radius: var(--radius);
  background: var(--shell);
  box-shadow: var(--lift-1);
}

.gate__title {
  margin: 0;
  font-size: 24px;
  line-height: 1.3em;
  font-weight: 500;
  color: var(--ink);
}

.gate__note {
  margin: 0;
  font-size: 16px;
  line-height: 24px;
  color: var(--body);
}

/* The field and its button are one object, so they share a row and a height
   rather than stacking into two decisions. */
.gate__row {
  display: flex;
  gap: 8px;
  margin-top: 6px;
}

.gate__input {
  flex: 1 1 auto;
  min-width: 0;
  height: 44px;
  padding: 0 14px;
  border: 1px solid var(--rule);
  border-radius: var(--radius-small);
  background: #FFFFFF;
  font-family: inherit;
  font-size: 16px;
  color: var(--ink);
}

.gate__input::placeholder { color: var(--muted); }

.gate__input:focus {
  outline: none;
  border-color: var(--cv);
  box-shadow: 0 0 0 3px rgba(85, 179, 165, 0.18);
}

.gate__btn {
  flex: none;
  height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: var(--radius-small);
  background: var(--ink);
  font-family: inherit;
  font-size: 15px;
  font-weight: 500;
  color: #FFFFFF;
  cursor: pointer;
  transition: background 160ms ease, opacity 160ms ease;
}

.gate__btn:hover { background: #2E3233; }
.gate__btn:disabled { opacity: 0.55; cursor: default; }

.gate__msg {
  margin: 0;
  font-size: 14px;
  line-height: 20px;
  color: var(--muted);
}

.gate__msg--bad { color: var(--accent-warm); }

.gate__ask {
  margin: 0;
  font-size: 14px;
  line-height: 20px;
  color: rgba(255, 255, 255, 0.82);
}

.gate__ask a {
  color: #FFFFFF;
  box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.5);
}

.gate__ask a:hover { box-shadow: inset 0 -1px 0 #FFFFFF; }

@media (max-width: 809.98px) {
  .gate { padding-top: calc(var(--nav-top) + var(--nav-h) + 32px); }
  .gate__card { padding: 28px 24px; }
  .gate__row { flex-direction: column; }
  .gate__btn { width: 100%; }
}

/* --- Note ------------------------------------------------------------------
   A third format, for work that is not a case study and should not ask to be
   read as one. It sits ON TOP of .case rather than beside it: the shell, the
   tint, the section markers and the scroll reveal all still apply, and .note
   changes the page width, the alignment and the head.

   What makes it a note is that the whole object is smaller. A case study is
   1104px of page holding a 1056px shell; this is 960px holding 912px. Same
   system, less of it — which is the signal, and it costs one token.

   Narrowing the CONTENT rather than the page was the wrong move and is worth
   recording: .case centres its children, so a 714px prose column inside a
   1008px figure column leaves the text floating between two ragged edges and
   breaks the rule the captions already follow, that everything on a page
   shares one left edge. Hence align-items: stretch below. Prose still caps at
   the 714px measure; it is left-aligned inside the column rather than being
   the column.
   ------------------------------------------------------------------------ */
.note-page { --w-page: min(100vw - 96px, 960px); }

/* Stretch, not centre: this is what gives the prose, the figures and the
   captions one shared left edge. */
.note { align-items: stretch; }

/* Fill the shell. The 1008 / 1056 pair is a case-study distinction and the
   shell is already narrower than either, so a note has no use for it. */
.note > *,
.note > .shot--wide { width: 100%; }

/* The measure still applies — .prose p already caps at 714px, and these are
   the blocks that sit outside .prose and would otherwise run the full 912. */
.note__head,
.note__lead { max-width: var(--w-text); }

.note__head {
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* .case__head centres itself with margin-inline: auto, which is harmless at
     full width but pushes the head in by 99px once the measure caps it. The
     head is the one thing that must sit on the shared left edge. */
  margin-inline: 0;
}

/* 24px, not the 32px a case-study title takes. One step above the 20px
   section headings rather than three, because the whole piece is flatter —
   that flatness is the format. */
.note__title {
  margin: 0;
  font-size: 24px;
  line-height: 1.3em;
  font-weight: 500;
  color: var(--ink);
}

.note__dateline {
  font-family: var(--font-mono);
  font-size: 13px;
  line-height: 20px;
  color: var(--muted);
}

/* The standfirst. One step up from body text and carrying no heading, which
   is what a TLDR becomes when the piece is short enough not to need one. */
.note__lead {
  font-size: 18px;
  line-height: 28px;
  color: var(--ink);
}

/* --- Skill list -----------------------------------------------------------
   One card holding six rule-separated rows, the same shape .survey__card
   uses: a single structured object that sits inside a normal image frame and
   so inherits the 1008px column and the scroll reveal.

   The index is mono and muted because it is a count of six documents, not a
   ranking and not a sequence. Nothing here introduces a colour — six parallel
   items cannot be coded with a three-hue palette, so they are not coded at
   all.
   ------------------------------------------------------------------------ */
.skills {
  padding-inline: 32px;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
}

.skills__row {
  display: grid;
  grid-template-columns: 40px minmax(0, 1fr);
  gap: 0 16px;
  padding-block: 24px;
  border-top: 1px solid var(--rule);
}

/* The card's own border is the top edge of the first row. */
.skills__row:first-child { border-top: 0; }

.skills__n {
  font-family: var(--font-mono);
  font-size: 13px;
  line-height: 24px;      /* sits on the first line of the name beside it */
  color: var(--muted);
}

.skills__name {
  font-size: 16px;
  line-height: 24px;
  font-weight: 600;
  color: var(--ink);
}

.skills__desc {
  margin-top: 4px;
  max-width: var(--w-text);
  font-size: 16px;
  line-height: 24px;
  color: var(--body);
}

/* A single closing action, aligned to the text column rather than centred —
   .shots__note centres because it sits between images, and this does not. */
.shots__note--start { justify-content: flex-start; }

/* --- Survey chart ---------------------------------------------------------
   One column chart per question: five bars for the five points of the scale,
   on the diverging --scale-* ramp so the low end and the high end read as
   opposite rather than merely different.

   No y axis. Five bars, each direct-labelled with its share — the labels are
   the axis, and a rule for the baseline is the only chrome the chart needs.

   The response shares live here, as bar heights, because this stylesheet holds
   every raw value and the pages carry no inline styles. A bar's height is its
   share as a proportion of the tallest bar in either chart (50%), so the two
   charts sit on one scale and can be compared directly.
   ------------------------------------------------------------------------ */
.survey__card {
  padding: 32px;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
}

.survey__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--gap-l);
}

.survey__q {
  min-height: 40px;              /* two lines, so both charts start level */
  font-size: 14px;
  line-height: 20px;
  color: var(--ink);
}

/* The headline the chart is there to support. Text tokens, never a series
   colour — the bars carry the identity. */
.survey__pct {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-top: 16px;
  font-size: 32px;
  line-height: 32px;
  font-weight: 500;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.survey__pct span {
  font-size: 14px;
  line-height: 20px;
  font-weight: 400;
  color: var(--muted);
}

.survey__plot {
  display: flex;
  align-items: flex-end;
  gap: 12px;
  height: 160px;
  margin-top: 24px;
  border-bottom: 1px solid var(--rule);
}

.survey__col {
  display: flex;
  flex: 1;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 6px;
  height: 100%;
}

.survey__val {
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 16px;
  color: var(--muted);
}

/* Thin marks, rounded at the free end only — the baseline end stays square
   against the axis rule. */
.survey__fill {
  width: 100%;
  max-width: 44px;
  border-radius: 4px 4px 0 0;
}

.survey__axis {
  display: flex;
  gap: 12px;
  margin-top: 8px;
}

.survey__tick {
  flex: 1;
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 16px;
  color: var(--muted);
  text-align: center;
}

.survey__col--1 .survey__fill { background: var(--scale-1); }
.survey__col--2 .survey__fill { background: var(--scale-2); }
.survey__col--3 .survey__fill { background: var(--scale-3); }
.survey__col--4 .survey__fill { background: var(--scale-4); }
.survey__col--5 .survey__fill { background: var(--scale-5); }

/* --- the two questions, and their numbers ------------------------------- */
/* Talking about design thinking with others.
   1: 0% · 2: 7.7% · 3: 30.8% · 4: 38.5% · 5: 23.1% — 4 and 5 together, 62% */
.survey__item--talk .survey__col--1 .survey__fill { height: 0; }
.survey__item--talk .survey__col--2 .survey__fill { height: 15.38%; }
.survey__item--talk .survey__col--3 .survey__fill { height: 61.54%; }
.survey__item--talk .survey__col--4 .survey__fill { height: 76.92%; }
.survey__item--talk .survey__col--5 .survey__fill { height: 46.15%; }

/* Using design thinking in my role at Mural.
   1: 0% · 2: 3.8% · 3: 50% · 4: 26.9% · 5: 19.2% — 4 and 5 together, 46% */
.survey__item--role .survey__col--1 .survey__fill { height: 0; }
.survey__item--role .survey__col--2 .survey__fill { height: 7.69%; }
.survey__item--role .survey__col--3 .survey__fill { height: 100%; }
.survey__item--role .survey__col--4 .survey__fill { height: 53.85%; }
.survey__item--role .survey__col--5 .survey__fill { height: 38.46%; }

.survey__meta {
  margin-top: 32px;
  font-size: 14px;
  line-height: 20px;
  color: var(--muted);
}

@media (max-width: 639.98px) {
  .survey__card { padding: 24px; }
  .survey__grid { grid-template-columns: minmax(0, 1fr); }
  .survey__q { min-height: 0; }
}

/* --- Results table --------------------------------------------------------
   Wide content must scroll inside its own container, never push the page
   sideways. Figures use tabular numerals so columns line up.
   ------------------------------------------------------------------------ */
.table-wrap {
  /* Not width: 100%. This rule sits after `.case > *` at the same specificity,
     so a plain 100% resolved against the 1056px case column and pushed the
     card 24px past the text on each side. Same expression as every other block
     in the column, so the card's edges land on the text's. */
  width: min(100%, var(--w-content));
  overflow-x: auto;
  /* The side air sits on the card, not on the outer cells. The rules are drawn
     on the cells, so padding here insets the whole table, rules and text
     together, and each line still starts on the row label and stops on the
     last figure. */
  padding: 12px 40px;
  background: var(--surface);
  border-radius: var(--radius);
  -webkit-overflow-scrolling: touch;
}

.table {
  width: 100%;
  border-collapse: collapse;
  font-size: 15px;
  line-height: 22px;
}

.table th,
.table td {
  padding: 20px 24px;
  text-align: right;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* No padding on the outer edges. The rules are drawn on the cells, so any
   padding here is rule that runs past the text; zero is what makes each line
   start on the row label and stop on the last figure. The card is already the
   width of the text column, so this also puts the first column on the same
   left edge as every paragraph in the case. */
.table th:first-child,
.table td:first-child { padding-left: 0; }

.table th:last-child,
.table td:last-child { padding-right: 0; }

.table th:first-child,
.table td:first-child {
  text-align: left;
  white-space: normal;
  color: var(--ink);
}

.table thead th {
  font-size: 13px;
  line-height: 16px;
  font-weight: 400;
  color: var(--muted);
  border-bottom: 1px solid var(--rule);
}

.table tbody tr + tr th,
.table tbody tr + tr td { border-top: 1px solid var(--rule); }

.table td { color: var(--body); }

/* The result. Every row here improved, so the colour marks improvement, not
   the sign of the number: a falling abandon rate is a good outcome. Set in the
   case's own tint rather than the site's green, so the column reads as part of
   this page. */
.table .delta { color: #54BFC6; font-weight: 500; }

/* --- Conversion diagram ---------------------------------------------------
   Built in HTML rather than exported, so it stays crisp and editable.
   ------------------------------------------------------------------------ */
/* --- User journey ---------------------------------------------------------
   The conversion model as a branching tree, drawn from the user outward. SVG
   rather than boxes-in-a-row because the shape IS the argument: one person,
   two things they can do, and one of those branching again. A CSS flex row
   cannot draw the elbow that carries that, and faking it with pseudo-elements
   breaks the moment a label wraps.

   Coordinates are authored at 1:1 against the 1080-unit viewBox, so a unit is
   a pixel at full width and the type sizes below are real px.

   One hue only. Every state box is the same, because the diagram's job is the
   path a person takes, not which family a mural belongs to. The ramp is
   --cv #55B3A5 mixed toward white or black, nothing else:

     #EEF7F6   90% to white   box fill
     #6EBEB2   15% to white   box border
     #48988C   15% to black   connectors and arrowheads, 3.4:1 on white
     #2F625B   45% to black   box text, 6.4:1 on the fill
   ------------------------------------------------------------------------ */
.journey {
  --jr-fill: #EEF7F6;
  --jr-edge: #6EBEB2;
  --jr-line: #48988C;
  --jr-ink:  #2F625B;

  padding: 32px;
  background: var(--surface);
  border-radius: var(--radius);
}

.journey__svg {
  display: block;
  width: 100%;
  height: auto;
}

/* Elbows are single paths with a 6-unit arc at each turn rather than two
   segments meeting square, so a corner reads as a bend and not a join. */
.journey__line {
  fill: none;
  stroke: var(--jr-line);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* An open chevron, not a filled triangle — the same arrow the rest of the
   site draws, stroked with round caps (see the case-nav back arrow and the
   menu chevrons in index.html). */
.journey__head {
  fill: none;
  stroke: var(--jr-line);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.journey__glyph  { fill: var(--jr-line); }

.journey__box { fill: var(--jr-fill); stroke: var(--jr-edge); stroke-width: 1.5; }

.journey__t {
  font-family: var(--font-sans);
  font-size: 13px;
  text-anchor: middle;
}

.journey__t--node { fill: var(--jr-ink); font-weight: 500; }
.journey__t--step { fill: var(--body); }
.journey__t--user { fill: var(--muted); font-size: 12px; }

/* --- Sequence strip -------------------------------------------------------
   Slot 04 of the authentication case study: where a step sits, not what it
   looks like. Two rows of one flow, stacked, so the only difference the eye
   has to find is which side of "In Mural" the profiling ask falls on.

   Built rather than exported for the same reason the conversion diagram above
   is: the steps are words, and words inside a PNG cannot be corrected without
   reopening Figma. Two tinted steps carry the whole figure, so everything
   else is deliberately the quietest pill the system has.
   ------------------------------------------------------------------------ */
.seq {
  display: flex;
  flex-direction: column;
  gap: 20px;
  /* Narrower side padding than the other cards on this page. Each row has to
     hold seven steps and six arrows on one line, and the card's own margin is
     the cheapest place to find the width. */
  padding: 28px 24px;
  background: var(--surface);
  border-radius: var(--radius);
}

.seq__row {
  display: grid;
  grid-template-columns: 56px minmax(0, 1fr);
  align-items: center;
  gap: 12px;
}

.seq__when {
  font-size: 13px;
  line-height: 16px;
  color: var(--muted);
}

.seq__steps {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 7px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The arrow lives between two items rather than inside either, so it wraps
   with them and never ends a line. */
.seq__steps li { display: flex; align-items: center; gap: 7px; }

.seq__steps li:not(:last-child)::after {
  content: "\2192";
  font-size: 13px;
  line-height: 16px;
  color: var(--muted-soft);
}

.seq__step {
  padding: 7px 10px;
  background: var(--canvas);
  border: 1px solid var(--rule);
  border-radius: var(--radius-small);
  font-size: 13px;
  line-height: 16px;
  color: var(--body);
  white-space: nowrap;
}

/* The ask. Same blue tint the Hana diagram uses for the thing under
   discussion, so a reader coming from that page reads it the same way. */
.seq__step--ask {
  border-color: #BFDDF7;
  background: #F5FAFF;
  color: var(--accent);
  font-weight: 500;
}

/* The moment the decision turns on: the only filled step in the figure. */
.seq__step--gate {
  border-color: var(--ink);
  background: var(--ink);
  color: var(--surface);
  font-weight: 500;
}

@media (max-width: 639.98px) {
  .seq { padding: 24px; }

  /* Under 640 the strip wraps to several lines, and a 72px label column beside
     a three-line strip leaves the word floating. Stack instead. */
  .seq__row { grid-template-columns: minmax(0, 1fr); gap: 10px; }
}

/* --- Before / after -------------------------------------------------------
   The same switch the Hana case uses, over a stacked pair of screenshots
   rather than a live board. Side by side, each pane rendered at half the
   column and the reader had to hunt for what moved; stacked in one frame the
   difference lands in the same place on the retina, which is the only way a
   before and after of two dense screens is legible at this width.

   No JavaScript: the checkbox sits ahead of everything it drives, so :checked
   and the sibling combinator carry the whole component. The switch itself is
   the shared .toggle, restyled here only because the input is no longer the
   track's immediate previous sibling.
   ------------------------------------------------------------------------ */
.compare {
  /* Contains the visually hidden input, which is absolutely positioned. */
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 20px;
  padding: 32px 40px;
  background: var(--surface);
  border-radius: var(--radius);
}

/* Both panes in one grid cell, so the frame is the height of the pair and
   nothing reflows when the switch is flipped. */
.compare__stage { display: grid; }

.compare__img {
  grid-area: 1 / 1;
  width: 100%;
  border-radius: 10px;
  transition: opacity 0.28s ease;
}

.compare__img[data-compare-after] { opacity: 0; }

.compare__input:checked ~ .compare__stage .compare__img[data-compare-before] { opacity: 0; }
.compare__input:checked ~ .compare__stage .compare__img[data-compare-after]  { opacity: 1; }

.compare__input:checked ~ .toggle .toggle__track {
  background: var(--accent);
  border-color: var(--accent);
}

.compare__input:checked ~ .toggle .toggle__thumb { transform: translateX(18px); }

.compare__input:focus-visible ~ .toggle .toggle__track {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* Only the side showing is emphasised, so the switch reads as a position. */
.compare__input:not(:checked) ~ .toggle [data-toggle-off],
.compare__input:checked ~ .toggle [data-toggle-on] {
  color: var(--ink);
  font-weight: 500;
}

@media (prefers-reduced-motion: reduce) {
  .compare__img { transition: none; }
}

@media (max-width: 639.98px) {
  .compare { padding: 24px; }
}

/* --- Live-figure nudge ----------------------------------------------------
   Three figures on this page are live, and a toggle at rest looks exactly like
   a screenshot of a toggle. Once, as each figure arrives, two slow rings come
   off its switch, so the affordance is visible to someone scrolling past the
   words. .is-in is added once by the reveal in js/site.js and never removed,
   so this cannot loop.
   ------------------------------------------------------------------------ */
@keyframes try-nudge {
  0%, 100% { box-shadow: 0 0 0 0 rgba(0, 144, 255, 0); }
  45%      { box-shadow: 0 0 0 5px rgba(0, 144, 255, 0.16); }
}

.js .shot.is-in .toggle__track {
  animation: try-nudge 1.4s ease 0.5s 2;
}

@media (prefers-reduced-motion: reduce) {
  .js .shot.is-in .toggle__track { animation: none; }
}

/* --- Four-theme specimen --------------------------------------------------
   Slots 07, 09 and 10 of the Hana case study in one component: the 2x2 that
   carries the thesis, the widget anatomy, and the property readout.

   Built rather than exported, for two reasons. The master property table is
   9 rows x 4 columns of specification; as a screenshot at this page's width
   its body text lands around 8px and cannot be read at all. And a rendered
   sticky *demonstrates* the mechanic instead of depicting it — the notes below
   are drawn from the specification's own numbers, at 1:1.

   Every value comes from js/hana-theme.js, which holds the master property
   table and is the single source of truth. The rules here only derive. Padding
   is the one to look at:

     calc(var(--pad-fixed) + var(--pad-ratio) * var(--h))

   A fixed 8px and 8% of widget height are the same expression with one term
   zeroed, so padding recomputes from height in CSS. That is what lets the
   planned size control work without recalculating anything in JavaScript.
   ------------------------------------------------------------------------ */
.specimen {
  display: flex;
  flex-direction: column;
  gap: var(--gap-m);
  padding: 32px;
  background: var(--surface);
  border-radius: var(--radius);
  /* No border, matching every other figure in this case. The white surface
     against the grey ground is the edge, and a rule here competed with the
     two real edges inside: the theme selector and the dot-grid stage. */

  /* Resting state = Hana, so a visitor without JavaScript gets a correct and
     complete specimen rather than an empty frame. JS overwrites these on the
     root element as soon as a theme is chosen. */
  --sq: 168px;
  --sqh: 168px;
  --rw: 288px;
  --rh: 168px;
  --ci: 168px;
  --fs: 28px;
  --lh: 1.03;
  --pad-fixed: 0px;
  --pad-ratio: 0.08;
  --pad-fixed-circle: 0px;
  --pad-ratio-circle: 0.2;

  /* Overwritten by reserveStage(); these are the no-JS fallback, and match
     what it computes for the table as it stands. */
  --cell-a: 210px;
  --cell-b: 360px;
  --sticky-r: 8px;
  --sticky-shadow: 0 2px 3px rgba(0, 0, 0, 0.14);
  --sticky-fill: var(--fill-hana);
  --align-x: left;
  --align-y: flex-start;

  /* The size control. A sticky can only be resized proportionally, so one
     scale factor covers it — and because padding is derived from height in
     the rule below, the slider sets this and nothing else. */
  --scale: 1;
}

/* --- The 2x2 selector. The control *is* the thesis: rows are visual style,
       columns are sizing, so choosing a theme teaches the model that separating
       the two axes is what produced four themes instead of two. ------------ */
.specimen__pick {
  display: grid;
  grid-template-columns: 104px 1fr 1fr;
  gap: 8px;
  align-items: stretch;
}

.specimen__axis {
  display: flex;
  align-items: flex-end;
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 16px;
  color: var(--muted);
}

/* The row labels sit in the gutter, vertically centred against their pair. */
.specimen__axis--row { align-items: center; }

.specimen__axis--size { color: var(--link-b); }

/* Each tile is a label for a visually hidden radio, so grouping and arrow-key
   navigation come from the browser rather than from script. */
.specimen__opt { display: contents; }

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

.specimen__tile {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 12px 14px;
  background: var(--canvas);
  border: 1px solid var(--rule);
  border-radius: var(--radius-small);
  font-size: 15px;
  line-height: 20px;
  font-weight: 500;
  color: var(--ink);
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;
}

.specimen__tile span {
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 15px;
  font-weight: 400;
  color: var(--muted);
}

.specimen__tile:hover { border-color: var(--muted); }

/* Tinted by family on selection, matching the prose and the conversion
   diagram, so all three read as one legend. */
.specimen__radio:checked + .specimen__tile {
  border-color: var(--ink);
  background: var(--surface);
}

.specimen__radio:focus-visible + .specimen__tile {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.specimen__opt[data-family="hana"] .specimen__radio:checked + .specimen__tile {
  border-color: #BFDDF7;
  background: #F5FAFF;
  color: var(--accent);
}

.specimen__opt[data-family="classic"] .specimen__radio:checked + .specimen__tile {
  border-color: #DFCEF9;
  background: #FAF6FF;
  color: var(--link-a);
}

/* --- Stage and readout, side by side above 810px. ---------------------- */
.specimen__body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 300px;
  gap: var(--gap-m);
  align-items: start;
}

/* The dot grid is the canvas the notes actually sit on, at Mural's spacing. */
.specimen__stage {
  /* A grid rather than a wrapping flex row: with four notes of three different
     widths, wrapping would re-flow at some point mid-drag and the layout would
     jump while the reader is comparing sizes.

     The cells are a fixed reservation, set once by reserveStage() in
     hana-theme.js to the largest note any theme produces at full scale. Sizing
     them to their contents instead would make the whole canvas grow and shrink
     with the slider, which reads as the canvas resizing rather than the
     stickies on it. The extra 22px on the row is the caption and its gap. */
  display: grid;
  grid-template-columns: var(--cell-a) var(--cell-b);
  grid-auto-rows: calc(var(--cell-a) + 22px);
  justify-content: start;
  align-items: start;
  gap: 24px 28px;
  padding: 28px;
  background: var(--canvas);
  background-image: radial-gradient(circle, var(--rule) 1.5px, transparent 1.5px);
  background-size: 24px 24px;
  background-position: -1px -1px;
  border-radius: var(--radius-small);

  /* The notes are shown at 1:1 and must never be scaled down — that would
     forfeit the actual-size claim, which is the point of rendering them from
     the specification at all. So the stage scrolls inside itself instead, the
     same bargain .table-wrap makes for a wide table. */
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

/* The note itself. Nothing here is a chosen value — width, height, padding,
   type size and leading are all read off the specification. */
.sticky {
  --h: calc(var(--sqh) * var(--scale));
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  justify-content: var(--align-y);
  flex: none;
  width: calc(var(--sq) * var(--scale));
  height: var(--h);

  /* The two padding rules, as one expression. In Classic and Hana Lite the
     fixed term is 8px and the ratio term is zero, so the box and the type
     scale but the padding does not — which is how the product behaved, and why
     the padding-to-height ratio drifts as a note grows. In Hana and Classic
     Lite the fixed term is zero and the ratio is 8% of height, so padding
     scales with the note and the ratio holds. Nothing here is scaled by hand;
     the drift falls out of the arithmetic. */
  padding: calc(var(--pad-fixed) + var(--pad-ratio) * var(--h));

  overflow: hidden;
  background: var(--sticky-fill);
  border-radius: var(--sticky-r);
  box-shadow: var(--sticky-shadow);
  text-align: var(--align-x);
  font-size: calc(var(--fs) * var(--scale));
  line-height: calc(var(--fs) * var(--scale) * var(--lh));
  color: var(--canvas-ink);
  transition: width 0.28s ease, height 0.28s ease, padding 0.28s ease,
              font-size 0.28s ease, border-radius 0.28s ease,
              box-shadow 0.28s ease, background 0.28s ease;
}

/* Switching theme animates the size change, which is the point — you watch the
   note grow or shrink. Dragging the slider must not, or the note lags behind
   the thumb. So the transition is suppressed for as long as the drag lasts and
   restored the moment it stops. */
[data-dragging] .sticky { transition: none; }

.sticky--rect {
  --h: calc(var(--rh) * var(--scale));
  width: calc(var(--rw) * var(--scale));
}

/* The same string in all four themes, so any difference you see is the theme
   and never the content. Type size is set inline by the autofit, so the value
   in the stylesheet is only the no-JS fallback. */
.sticky p { margin: 0; }

.note {
  display: flex;
  flex-direction: column;
  gap: 7px;
  min-width: 0;
}

.note__cap {
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 15px;
  color: var(--muted);
}

/* Circle sticky. Its padding was already a proportion of height in Classic
   (1/7) as well as in Hana (20%), which is why it is the control case: it
   never drifts, and it shows that the square's fixed 8px was the anomaly. */
.sticky--circle {
  --h: calc(var(--ci) * var(--scale));
  width: calc(var(--ci) * var(--scale));
  padding: calc(var(--pad-fixed-circle) + var(--pad-ratio-circle) * var(--h));
  border-radius: 50%;
  text-align: center;
  justify-content: center;
}

/* A note carrying far more text than its default. Nothing about the box
   changes — only the type shrinks, which is what autofit does. The size here is
   a fallback for the no-JS case, where nothing measures anything. */
.sticky--dense p { font-size: 12px; line-height: 1.15; }

.specimen__scale {
  /* Sits directly under the dot grid the notes are rendered on, so it needs
     air between it and the grid's bottom edge rather than the stage's own
     padding, which reads as part of the canvas. */
  margin-top: 16px;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 15px;
  color: var(--muted);
}

/* --- Size control ---------------------------------------------------------
   Hidden without JavaScript, where it would be a control that does nothing.
   The specimen itself still renders correctly in that case: the resting state
   in the rules above is Hana, which is also the checked radio and the markup's
   own readout values, so a no-JS visitor gets one complete, honest theme
   rather than a broken switcher. ------------------------------------------ */
.specimen__size { display: none; }

.js .specimen__size {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 14px;
}

.specimen__size label {
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 15px;
  color: var(--muted);
  white-space: nowrap;
}

.specimen__range {
  flex: 1;
  min-width: 0;
  height: 20px;
  margin: 0;
  accent-color: var(--accent);
  cursor: ew-resize;
}

.specimen__range:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

.specimen__reset {
  flex: none;
  padding: 3px 10px;
  background: var(--canvas);
  border: 1px solid var(--rule);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 16px;
  color: var(--muted);
  cursor: pointer;
}

.specimen__reset:hover { border-color: var(--muted); color: var(--ink); }

/* The ratio is the figure the slider exists to move: it drifts under a fixed
   padding and holds under a proportional one. Pulled out of the list and given
   its own emphasis, because it is the finding rather than a property. */
.props__ratio {
  display: grid;
  grid-template-columns: 1fr auto auto;
  gap: 4px 10px;
  align-items: baseline;
  padding-top: 10px;
  border-top: 1px solid var(--rule);
  margin: 0;
  font-size: 14px;
  line-height: 20px;
  color: var(--muted);
}

.props__ratio b {
  font-variant-numeric: tabular-nums;
  font-weight: 500;
  color: var(--link-b);
}

/* One word saying which way the figure moves as a note is resized — what turns
   a number into a finding. */
.props__ratio i {
  font-style: normal;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--muted);
}

/* Says which way the figure moves as the note is resized — the one word that
   turns a number into a finding. */
.props__verdict {
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 15px;
  color: var(--muted);
}

/* --- The property readout. Values that move with the sizing axis are teal,
       the page's established colour for sizing; values that move with the
       visual axis stay neutral. Switching rows changes one set, switching
       columns changes the other — which is the whole argument. ------------ */
.props {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin: 0;
}

.props__set {
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-width: 0;
}

.props__group {
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 15px;
  color: var(--muted);
}

.props__list {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 6px 12px;
  margin: 0;
  font-size: 14px;
  line-height: 20px;
}

.props__list dt { color: var(--muted); }

.props__list dd {
  margin: 0;
  text-align: right;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

.props__list dd.is-size { color: var(--link-b); font-weight: 500; }

/* The resolved value beside a rule, e.g. 8% of height reading as 13px. It is
   the number the size control will animate. */
.props__px {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--muted);
  font-weight: 400;
}

@media (prefers-reduced-motion: reduce) {
  .sticky { transition: none; }
}

/* --- Mural ----------------------------------------------------------------
   Images 02 and 12. Both render one coordinate list from hana-mural.js under a
   different theme, so the notes are positioned absolutely and every dimension
   comes from a custom property the script sets.

   Nothing here knows about "broken" or "correct". Applying Hana's sizing to
   coordinates authored under Classic produces the collisions on its own, which
   is the point: the break is the update a user would have received, not a
   drawing of one.
   ------------------------------------------------------------------------ */
.mural {
  display: flex;
  flex-direction: column;
  gap: var(--gap-m);
  padding: 32px;
  background: var(--surface);
  border-radius: var(--radius);
  /* No border. The white surface against the grey ground is enough of an edge,
     and a rule here competed with the two real edges inside the card: the
     dot-grid panel and the mural frame the notes sit in. */

  /* Resting state = Classic, so the no-JS case shows the board as built. */
  --n-w: 138px;
  --n-h: 138px;
  --n-pad-fixed: 8px;
  --n-pad-ratio: 0;
  --n-radius: 0px;
  --n-shadow: none;
  --n-fill: var(--fill-classic);
  --n-align-x: center;
  --n-align-y: center;

  /* View zoom. A transform on the stage, never a change to the geometry — the
     board is drawn at its true coordinates and then looked at from further
     back, the same thing zooming a canvas does. Every figure the readout
     reports still describes the real board. */
  --mural-zoom: 0.8;
}

/* --- Toggle ---------------------------------------------------------------
   One switch rather than two buttons. The two figures are each a comparison of
   one board against itself, so the control should read as a state being flipped,
   not as a choice between two things. Built on a checkbox with role="switch",
   so the keyboard and screen-reader behaviour come from the browser.
   ------------------------------------------------------------------------ */
.toggle {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}

.toggle__label {
  font-size: 15px;
  line-height: 20px;
  color: var(--muted);
  transition: color 0.2s ease;
}

/* Only the side that is showing is emphasised, so the switch reads as a
   position rather than as a pair of labels. */
.mural[data-on="false"] [data-toggle-off],
.mural[data-on="true"]  [data-toggle-on] {
  color: var(--ink);
  font-weight: 500;
}

.toggle__track {
  position: relative;
  flex: none;
  width: 44px;
  height: 26px;
  padding: 3px;
  background: var(--fill);
  border: 1px solid var(--rule);
  border-radius: 999px;
  cursor: pointer;
  transition: background 0.2s ease, border-color 0.2s ease;
}

.toggle__thumb {
  display: block;
  width: 18px;
  height: 18px;
  background: var(--surface);
  border-radius: 50%;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
  transition: transform 0.2s ease;
}

.toggle__input:checked + .toggle__track {
  background: var(--accent);
  border-color: var(--accent);
}

.toggle__input:checked + .toggle__track .toggle__thumb {
  transform: translateX(18px);
}

.toggle__input:focus-visible + .toggle__track {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .toggle__thumb,
  .toggle__label { transition: none; }
}

/* The canvas. Sized once by the script to the largest extent any of the
   figure's modes reaches, so switching moves the notes and never the board.
   Scrolls inside itself rather than scaling: the whole claim of image 12 is
   that positions are unchanged, and a board that resized to fit would forfeit
   the ability to make it. */
.mural__scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  background: var(--canvas);
  background-image: radial-gradient(circle, var(--rule) 1.5px, transparent 1.5px);
  background-size: 24px 24px;
  border-radius: var(--radius-small);
  padding: 24px;
}

/* The view box is the board's scaled footprint. A transform does not affect
   layout, so without this the container would still reserve the full size and
   leave a band of empty canvas below the board. */
.mural__view {
  margin: 0 auto;
  overflow: hidden;
}

.mural__stage {
  position: relative;
  transform: scale(var(--mural-zoom));
  transform-origin: 0 0;
}

/* The frame the notes were arranged inside. It does not grow with them — an
   area carries its own stored size — which is why notes end up past its
   padding under Hana. */
.mural__frame {
  position: absolute;
  background: rgba(255, 255, 255, 0.55);
  border: 1px solid var(--rule);
}

.mural__title {
  position: absolute;
  margin: 0;
  font-weight: 500;
  color: var(--canvas-ink);
  white-space: nowrap;
}

.mural__note {
  position: absolute;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  justify-content: var(--n-align-y);
  width: var(--n-w);
  height: var(--n-h);
  padding: calc(var(--n-pad-fixed) + var(--n-pad-ratio) * var(--n-h));
  overflow: hidden;
  background: var(--n-fill);
  border-radius: var(--n-radius);
  box-shadow: var(--n-shadow);
  text-align: var(--n-align-x);
  color: var(--canvas-ink);
  transition: width 0.32s ease, height 0.32s ease, padding 0.32s ease,
              border-radius 0.32s ease, box-shadow 0.32s ease,
              background 0.32s ease;
}

.mural__note p { margin: 0; }

/* A note that has grown over its neighbour has to be drawn above it, or the
   overlap reads as a gap rather than a collision. */
.mural[data-theme="hana"] .mural__note:hover { z-index: 2; }

/* --- Conversion, both directions ------------------------------------------
   Two independent units in one card. Each is a complete board with its own
   switch, so the two conversion paths can be read side by side.
   ------------------------------------------------------------------------ */
.convert {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--gap-m);
  align-items: start;
}

.convert__unit {
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-width: 0;
}

.convert__head {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 16px;
  color: var(--muted);
}

/* The two units carry different sizings, so their boards are different widths.
   Left-align both rather than centring, or the notes would sit at different
   offsets and invite a comparison the figure is not making. */
.convert .mural__view { margin: 0; }

.convert .toggle { gap: 10px; }

.convert .toggle__label { font-size: 14px; }

.mural__readout {
  margin: 0;
  font-size: 14px;
  line-height: 20px;
  color: var(--muted);
}

.mural__readout b {
  font-variant-numeric: tabular-nums;
  font-weight: 500;
  color: var(--link-b);
}

/* The count of things that broke. The only place on the page besides NEEDS DATA
   that earns the warm colour. */
.mural__readout b.is-bad { color: var(--accent-warm); }

@media (prefers-reduced-motion: reduce) {
  .mural__note { transition: none; }
}

/* --- Authentication thumbnail ----------------------------------------------
   The signup screen the project shipped, rebuilt as elements and set on a
   Mural canvas rather than screenshotted.

   Two states, and the second is the case in one gesture. At rest the screen is
   drawn in grey and sits small on the board: no colour on the mark, no label
   on the button, and the only colour anywhere on the card is the caret
   blinking in an empty field. On hover the view zooms in, the mark saturates,
   a work address types itself, and the button goes live the moment the last
   character lands. The card is a dead form filling itself in, which is what
   the project was.

   THE ZOOM IS THE HANA CARD'S GESTURE, and it shares the 0.5s overshoot curve
   with it. What the two hold in common is the RATIO, not the absolute --z:
   this screen sits at 1.24 resting where Hana's board sits at 1.15, because
   the two subjects need different amounts of the cell, and matching the raw
   numbers would only make one of them the wrong size.

   Hana travels 1.15 to 1.44, a factor of 1.252. This card now travels 1.24 to
   1.50, a factor of 1.210: the zoom was reduced by 15% because at 1.55 the
   screen came at you rather than being approached, and the card is meant to
   read as a form quietly filling itself in. The two are no longer identical
   gestures. If Hana should follow, its hover value is 1.39 (1.15 x 1.210), and
   both should move together from then on.

   Everything else scales off --z the way the Hana board does, as lengths
   derived from it rather than a transform, which keeps the address crisp at
   the end of the zoom instead of rasterised at the size it started. The
   grounds were never shared: this card is white, Hana keeps its dotted canvas,
   and that is a difference of subject, not of motion.

   Sizes are in canvas units, proportioned off the released screen: field and
   button the same width and height, the button's gap a fifth of its own
   height, the mark a two thirds of the field's width above it.

     page  #FFFFFF               field  #FFFFFF   border #C6C6C6
     live  #E02935               dead   #C6C6C6   (border and dead button are
                                                   one grey, and it is the
                                                   wordmark's own light grey:
                                                   images/mural-logo-byn.png is
                                                   drawn in #C6C6C6 over
                                                   #878787, so the whole card
                                                   at rest is two values off
                                                   one file. Sampled from the
                                                   reference, not chosen: do
                                                   not nudge either of them.)
   ------------------------------------------------------------------------ */
.auth {
  --auth-red:   #E02935;
  --auth-dead:  #C6C6C6;
  --auth-line:  #C6C6C6;

  /* The view. See the note above before changing either of these. --dot went
     with the grid it sized. */
  --z: 1.24;
  --thumb-t: 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);

  /* The screen, in canvas units: one column of mark, field, button. */
  --col:  132;   /* field and button width */
  --h:     27;   /* and the height of each */
  --lead:  14;   /* mark to field */
  --gap:    8;   /* field to button */
  --mark:  88;   /* the wordmark's width; 3.577 is the file's own aspect */

  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  line-height: 1;
  color: #2F2F2F;

  /* White, and no dot grid. This was the canvas ground with Mural's own 24-unit
     dots, the same as the Hana card beside it, and it is a sign-up page rather
     than a board: the dots were saying "canvas" about the one screen in the
     product that is not one. White also leaves the field's own #C6C6C6 border
     as the only edge in the card, which is what the case study is about.

     Nothing on .auth transitions now — the grid's background-size was the only
     thing here that did. Everything inside still scales off --z.

     NOTE: .thumb, the Hana card, carries a byte-identical ground block and
     keeps it. Do not search and replace across both. */
  background-color: var(--surface);
}

/* The mark. Both prints are forced into the same box with object-fit rather
   than left to their own aspects: the two files are cropped a hair differently
   (744x208 against 804x224), and at this size a half pixel of drift between
   them would show as the mark twitching as the colour arrives. */
.auth__logo {
  position: relative;
  width: calc(var(--mark) * var(--z) * 1px);
  height: calc(var(--mark) / 3.577 * var(--z) * 1px);
  margin-bottom: calc(var(--lead) * var(--z) * 1px);
  transition: width var(--thumb-t), height var(--thumb-t),
              margin-bottom var(--thumb-t);
}

.auth__mark {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* The grey print stays where it is and the colour one comes up over it. The
   colour file covers every shape the grey one draws, so there is nothing to
   fade out — and a cross dissolve would put a muddy half-grey halfway through
   what should read as the mark saturating.

   In colour at rest was tried and it was too much: the card is a dead form
   filling itself in, and a mark already at full strength gives that away. */
.auth__mark--color {
  opacity: 0;
  transition: opacity 0.45s ease;
}

.auth__field {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  width: calc(var(--col) * var(--z) * 1px);
  height: calc(var(--h) * var(--z) * 1px);
  padding-left: calc(7 * var(--z) * 1px);
  background: var(--surface);
  border-radius: calc(var(--h) * 0.14 * var(--z) * 1px);
  font-size: calc(10 * var(--z) * 1px);

  /* 1.5px, flat at both zoom levels, and measured off the reference rather
     than chosen: in the example the border is 5px at 0.854 of this card's
     scale, which is 1.3px here, and 1.5 is the nearest value that still lands
     on whole device pixels on a 2x screen. It was 2px, on the argument that a
     fractional border renders soft and a hairline on white cannot afford it.
     That argument was about a #919191 edge. At #C6C6C6 the edge is quiet
     enough that 2px read as a drawn outline instead of a form field, which is
     the opposite of what the card is saying, so the half pixel is worth it.
     On a 1x screen it does render soft. Accepted.

     Flat, not scaled by --z: the Hana card's selection handles hold their
     weight through the zoom too, and an edge that thickens as the view comes
     in reads as a stroke rather than as a screen getting closer. */
  border: 1.5px solid var(--auth-line);

  transition: width var(--thumb-t), height var(--thumb-t),
              padding-left var(--thumb-t), border-radius var(--thumb-t),
              font-size var(--thumb-t);
}

/* The invisible copy of the string sets the box width, so the reveal and the
   caret can both animate to 100% of the real text with no measured value in
   the stylesheet. */
.auth__type {
  position: relative;
  display: inline-block;
  white-space: nowrap;
}

.auth__type::before {
  content: attr(data-text);
  visibility: hidden;
}

/* The visible copy, clipped from the right. steps() divides the width evenly
   rather than per character, so with a proportional face the edge lands a
   fraction off the letter boundaries — at this size that is just visible on
   one or two frames, and it still buys a typewriter with no script behind it. */
.auth__typed {
  position: absolute;
  /* The clip is horizontal, but overflow: hidden clips both ways, and .auth
     sets line-height: 1 — so the line box is exactly 1em while Inter's glyphs
     need about 1.21em, and the excess was being cut off top and bottom. The
     '@' shows it first, since it is the one glyph here that drops below the
     baseline. It was always wrong and always tiny; raising --z grew it from
     0.9px to 1.7px, which is where it became visible.

     0.2em of vertical padding gives the glyphs room inside the clip, and the
     matching negative top puts the baseline back where it was, so nothing
     moves. In em, so it tracks the zoom on its own. Horizontal padding stays
     0: the width animation is what does the typing, and this must not touch
     it. */
  top: -0.2em;
  left: 0;
  width: 0;
  padding: 0.2em 0;
  overflow: hidden;
}

/* Red, and the only colour on the card at rest. A grey screen with one live
   red caret in it says the form is waiting rather than broken, which is the
   difference between this and a screenshot of a disabled state. 2px flat, for
   the same reason the border is. */
.auth__caret {
  position: absolute;
  top: -2px;
  bottom: -2px;
  left: 0;
  width: 2px;
  background: var(--auth-red);
  animation: auth-blink 1.06s step-end infinite;
}

.auth__btn {
  position: relative;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  width: calc(var(--col) * var(--z) * 1px);
  height: calc(var(--h) * var(--z) * 1px);
  margin-top: calc(var(--gap) * var(--z) * 1px);
  border-radius: calc(var(--h) * 0.14 * var(--z) * 1px);
  background: var(--auth-dead);
  color: var(--surface);
  font-size: calc(11 * var(--z) * 1px);
  font-weight: 700;
  transition: width var(--thumb-t), height var(--thumb-t),
              margin-top var(--thumb-t), border-radius var(--thumb-t),
              font-size var(--thumb-t);
}

/* The live colour is a layer over the dead one rather than a background on the
   button itself. The button's own transition list is five zoom properties long,
   and the colour has to arrive late while those do not — one delay on one
   element beats keeping a positional delay list in step with them. */
.auth__btn::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--auth-red);
  opacity: 0;
  transition: opacity 0.25s ease;
}

/* Positioned, so it paints over the layer above without needing a z-index. */
.auth__word {
  position: relative;
  opacity: 0;
  transition: opacity 0.25s ease;
}

@keyframes auth-blink { 50% { opacity: 0; } }
@keyframes auth-type  { to  { width: 100%; } }
@keyframes auth-caret { to  { left:  100%; } }

/* Hover and focus get the same state, so the card behaves for the keyboard as
   it does for the pointer.

   The script: the view starts moving and the mark starts colouring at 0. The
   typing starts at :15 and runs 0.65s over 19 characters, about 34ms each.

   The button comes alive at :55, which is about 60% of the way through the
   address rather than after it. It used to wait for the last character, on the
   reasoning that a real form validates in that order, and the card was the
   better argument: a form that goes live while you are still typing reads as
   one that is paying attention, and the whole thumbnail is 0.8s long, so a
   button that waits for the end may as well not light at all. The label arrives
   with the colour, since a dead button has nothing to say.

   The caret holds solid for the length of the typing and picks its blink back
   up at :80, when the address lands. */
/* 1.50, down from 1.55: 0.26 of travel off 1.24 instead of 0.31, which is the
   15% cut. Hana still travels its own 0.29 off 1.15; see the note at the top of
   the block before putting the two back in step. */
a.card:hover  .auth,
a.card:focus-visible .auth { --z: 1.50; }

a.card:hover  .auth__mark--color,
a.card:focus-visible .auth__mark--color { opacity: 1; }

a.card:hover  .auth__typed,
a.card:focus-visible .auth__typed { animation: auth-type 0.65s steps(19) 0.15s forwards; }

a.card:hover  .auth__caret,
a.card:focus-visible .auth__caret {
  animation: auth-caret 0.65s steps(19) 0.15s forwards,
             auth-blink 1.06s step-end 0.8s infinite;
}

a.card:hover  .auth__btn::before,
a.card:focus-visible .auth__btn::before,
a.card:hover  .auth__word,
a.card:focus-visible .auth__word { opacity: 1; transition-delay: 0.55s; }

/* Reduced motion keeps the difference between the two states and drops the
   performance of getting there: the view is simply already close, the mark
   already in colour and the address already in the field. */
@media (prefers-reduced-motion: reduce) {
  .auth,
  .auth__logo,
  .auth__field,
  .auth__btn,
  .auth__btn::before,
  .auth__mark--color,
  .auth__word { transition: none; }

  .auth__caret { animation: none; }

  a.card:hover  .auth__typed,
  a.card:focus-visible .auth__typed { animation: none; width: 100%; }

  a.card:hover  .auth__caret,
  a.card:focus-visible .auth__caret { animation: none; left: 100%; }

  a.card:hover  .auth__btn::before,
  a.card:focus-visible .auth__btn::before,
  a.card:hover  .auth__word,
  a.card:focus-visible .auth__word { transition-delay: 0s; }
}

/* --- Pausa thumbnail -------------------------------------------------------
   Pausa is an iOS app, so the card is a phone: a device on Pausa's own light
   ground, cropped by the bottom edge of the cell the way the Hana card crops
   its canvas. The chrome is what says "phone" at 258px, so the notch, the
   status bar and the bezel radius are drawn even though nothing else in this
   grid needs them.

   At rest it is a journal entry. On hover the weekly recap rises over the
   bottom of the screen, which is the case study's whole argument: a journal
   is only worth keeping if it answers you. Nothing else moves.

   Colours are Pausa's own, off the token sheet in the case study
   (images/pausa/identity.png and the colour page of images/pausa/toolkit.png):

     black    #18181B      grey 30  #494950
     grey     #797986      grey 90  #E5E5E7      light  #F2F2F3

   Sizes are the app's ratios, not its values. The phone screen is 160px wide
   against a real 390pt, so everything is roughly 0.41x: the 34pt journal title
   becomes 14px, 16pt body becomes 7.5px. Body type that small is texture
   rather than reading matter, which is correct for a thumbnail, and the two
   lines that have to be read (the title and the recap theme) are the two that
   are set large enough to read.
   ------------------------------------------------------------------------ */
/* Declared on the card rather than on .pz, because the foot is a sibling of
   .pz and would not inherit them from it. */
.card--pausa {
  --pz-ink:    #18181B;
  --pz-body:   #494950;
  --pz-line:   #E5E5E7;
  --pz-ground: #F2F2F3;
}

.pz {
  position: relative;
  flex: 1;
  overflow: hidden;
  background: var(--pz-ground);
}

/* The bezel. Deeper at the bottom than the top is what a phone looks like, but
   the bottom is off the card, so the padding here is even and the difference
   is invisible. Height runs past the card edge on purpose: a device that ends
   inside the frame reads as an icon of a phone rather than as one. */
.pz__phone {
  position: absolute;
  left: 50%;
  top: 20px;
  bottom: -34px;
  width: 172px;
  transform: translateX(-50%);
  padding: 6px;
  background: var(--pz-ink);
  border-radius: 26px;
}

.pz__screen {
  position: relative;
  display: flex;
  flex-direction: column;
  height: 100%;
  padding: 0 10px;
  overflow: hidden;
  background: var(--surface);
  border-radius: 20px;
}

/* Dynamic island, at the proportion the export shows: a third of the screen
   width, sitting 4px down. */
.pz__notch {
  position: absolute;
  top: 4px;
  left: 50%;
  width: 52px;
  height: 8px;
  transform: translateX(-50%);
  background: var(--pz-ink);
  border-radius: 999px;
}

.pz__status {
  display: flex;
  align-items: center;
  gap: 3px;
  height: 18px;
  padding-top: 2px;
  font-size: 7px;
  line-height: 8px;
  font-weight: 600;
  color: var(--pz-ink);
}

/* The time sits left, the glyphs right, and the notch takes the middle. */
.pz__time { margin-right: auto; font-style: normal; }

.pz__sig { display: flex; align-items: flex-end; gap: 1px; }

.pz__sig b {
  width: 1.5px;
  background: var(--pz-ink);
  border-radius: 1px;
}

.pz__sig b:nth-child(1) { height: 3px; }
.pz__sig b:nth-child(2) { height: 4.5px; }
.pz__sig b:nth-child(3) { height: 6px; }

.pz__batt {
  width: 9px;
  height: 5px;
  background: var(--pz-ink);
  border-radius: 1.5px;
}

.pz__bar {
  display: flex;
  align-items: center;
  gap: 6px;
  padding-top: 6px;
}

.pz__mark { width: 13px; height: 13px; border-radius: 50%; }

.pz__title {
  margin-right: auto;
  font-size: 14px;
  line-height: 17px;
  font-weight: 600;
  letter-spacing: -0.02em;
  color: var(--pz-ink);
}

/* The search and settings controls. Circles rather than glyphs: at 7px an
   outlined magnifier is four grey pixels and a guess. */
.pz__ctl {
  width: 7px;
  height: 7px;
  background: var(--pz-line);
  border-radius: 50%;
}

.pz__date {
  margin-top: 9px;
  padding-top: 7px;
  border-top: 1px solid var(--pz-line);
  font-size: 7.5px;
  line-height: 10px;
  font-weight: 600;
  color: var(--pz-ink);
}

/* No max height and no ellipsis: the entry is cropped by the phone, which is
   what an entry does in the product and what tells the reader there is more
   of it. */
.pz__entry {
  margin-top: 4px;
  font-size: 7.5px;
  line-height: 11px;
  color: var(--pz-body);
}

/* The foot, in the card's own ground rather than white, on the same reasoning
   as the terminal card further down: a white strip under a grey card reads as
   two objects stacked, and matching it to the ground makes the card one object
   with a label at the bottom. It is still a .card__foot, so it keeps the arrow
   and the layout every other captioned card uses.

   It is also the link's accessible name, since .pz is aria-hidden in full.
   Do not remove it without putting an aria-label on the <a>.

   The border is the ground stepped one value darker, which is Pausa's own
   grey 90. Delete it for a seamless card. */
.card--pausa .card__foot {
  /* Pausa's black, light type on it, the same relationship the terminal card's
     foot has with its window. Dark against a light card needs no rule to part
     it from the body, so the border is set to the fill and disappears. */
  background: var(--pz-ink);
  border-top-color: var(--pz-ink);
  color: #E8E8EA;

  /* Slide and fade, no overshoot: the travel is a little longer than the
     0.2s the other revealed feet use, and the fade a little shorter than the
     travel, so the line is legible while it is still moving. */
  opacity: 0;
  transition: transform 0.28s ease, opacity 0.2s ease;
}

.card--pausa:hover .card__foot,
.card--pausa:focus-visible .card__foot { opacity: 1; }

.card--pausa .card__foot .arrow { color: #797986; }

/* And the same step again, in Pausa's pair: the grey it uses for a control at
   rest, handing over to the near-white its own label is set in. */
.card--pausa .card__foot .relay__mark--in { color: #E8E8EA; }

/* Later in the file than the .card--foot-reveal rule of the same specificity,
   so this is the one that lands: keep the fade, drop the travel. */
@media (prefers-reduced-motion: reduce) {
  .card--pausa .card__foot { transition: opacity 0.2s ease; }
}

/* --- Hana thumbnail --------------------------------------------------------
   The card is a canvas, not a picture of one. A Hana note carrying the project
   title sits among other notes on the dot grid, cropped by the card edges so
   the board clearly continues past it. Hovering selects the title note and
   zooms the canvas in.

   Everything scales from one number, --z. Positions, sizes, corner radii and
   the grid spacing are all expressed as canvas units multiplied by it, so the
   notes drift outward as the view moves closer and the whole thing reads as a
   canvas zoom rather than as one object growing.

   --z is not transitioned itself, which would need @property. The properties
   derived from it are, and those are ordinary animatable lengths, so this works
   without it.

   The one thing deliberately left out of the zoom is the selection chrome. Its
   outline stays 2px and its handles stay 12px at any --z, because that is how a
   real canvas behaves: zoom in and the note gets bigger while the interface
   around it does not. Scaling the whole layer with a transform would have
   enlarged them too, which is why sizes are animated instead.

   The selection box is a sharp rectangle even though the note is rounded,
   because it bounds the object rather than tracing it, the same box a circle
   sticky would get.
   ------------------------------------------------------------------------ */
.thumb {
  /* View zoom, and the only thing hover changes. Resting at 1.15 rather than 1
     because at 1 the note had no presence in a 258px cell; past about 1.2 the
     top-left neighbour drops out of frame and the board stops reading as a
     board. Hover keeps the same 1.25x step from there. */
  --z: 1.15;

  /* Where the board sits relative to the card, in canvas units. At rest it is
     nudged off centre so the composition does not read as a diagram of a board.
     On hover it pans to 0,0, which puts the title note exactly in the middle of
     the card: the same thing a canvas does when you zoom to a selection.

     Panning the BOARD rather than moving the note is what keeps the grid
     intact. Move the note alone and it would fall out of line with the column
     and row its neighbours sit on. */
  --ox: 4;
  --oy: -17;
  --dot: 24;       /* canvas grid, at Mural's spacing */
  --fit: 0.134;    /* type as a fraction of the note, see .thumb__note */

  /* The zoom overshoots and settles rather than easing flat into place. Same
     curve as the homepage mark, so the page has one idea of what a bounce is,
     but shorter, since this fires on hover rather than on a deliberate poke. */
  --thumb-t: 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);

  position: relative;
  flex: 1;
  overflow: hidden;
  background-color: var(--canvas);
  background-image: radial-gradient(circle, var(--rule) 1.5px, transparent 1.5px);
  background-size: calc(var(--dot) * var(--z) * 1px) calc(var(--dot) * var(--z) * 1px);
  background-position: center;
  transition: background-size var(--thumb-t);
}

/* Every note on the board, the title one and its neighbours alike. Each is
   placed by its canvas coordinate from the centre, so the zoom pushes them
   apart on its own rather than needing per-note hover rules. */
.thumb__note,
.thumb__peer {
  position: absolute;
  left: calc(50% + (var(--dx) + var(--ox)) * var(--z) * 1px);
  top: calc(50% + (var(--dy) + var(--oy)) * var(--z) * 1px);
  width: calc(var(--w) * var(--z) * 1px);
  height: calc(var(--h) * var(--z) * 1px);
  transform: translate(-50%, -50%);

  /* 8 on a 168px note, from the specification. */
  border-radius: calc(var(--w) * var(--z) / 21 * 1px);
  box-shadow: 0 2px 3px rgba(0, 0, 0, 0.14);
  transition: width var(--thumb-t), height var(--thumb-t),
              left var(--thumb-t), top var(--thumb-t),
              border-radius var(--thumb-t), font-size var(--thumb-t);
}

/* The neighbours. Same size and same colour as the title note, sitting on the
   same grid, because that is what a board actually looks like: notes of one
   default size lined up on a pitch, not a scatter of different colours and
   sizes. 147 is the note plus a 27 gutter, both measured off the comp. */
.thumb__peer {
  --w: 120;
  --h: 120;
  background: var(--fill-hana);
}

.thumb__peer--a { --dx: -147; --dy:    0; }   /* left, beside the title note */
.thumb__peer--b { --dx: -147; --dy: -147; }   /* the row above, cropped at the top */
.thumb__peer--c { --dx: -147; --dy:  147; }   /* the row below, cropped at the bottom */
.thumb__peer--d { --dx:    0; --dy:  147; }   /* directly under the title note */

/* The title note. Sits above its neighbours because it is the selected one. */
.thumb__note {
  /* The origin of the board. Where it lands on the card is set by --ox/--oy
     above, which is what moves it to dead centre on hover. */
  --dx: 0;
  --dy: 0;
  --w: 120;
  --h: 120;

  z-index: 2;
  display: flex;
  flex-direction: column;
  justify-content: center;
  box-sizing: border-box;
  padding: calc(var(--w) * var(--z) * 0.08px);   /* 8% of the note, per spec */
  background: var(--fill-hana);
  color: var(--canvas-ink);

  /* Not the spec's 28-on-168, because this note carries a title rather than a
     couple of words, and text autofits: more of it means a smaller size. --fit
     was found by binary search in the browser against height AND width, at the
     resting size, the zoomed size and the bounce's overshoot, then eased back
     from the maximum to leave the note some air.

     Width is what actually governs it, and specifically the longest single
     word, since a word cannot wrap. That is why the wording avoids long ones.
     Change the text and this has to be measured again: unlike the radius and
     padding above, it is fitted to this string rather than drawn from the
     specification. */
  font-size: calc(var(--w) * var(--z) * var(--fit) * 1px);

  /* Looser than the specification's 1.03, which is deliberate: this is a card,
     not a demonstration of the spec, and the comp sets its leading here. The
     air is what stops a four-line title reading as a wall. */
  line-height: 1.35em;
  font-weight: 400;
}

.thumb__text {
  display: block;
  text-align: left;

  /* Clipped here rather than on the note, because the note also carries the
     selection layer, whose handles sit outside its box. Clipping there would
     cut the corners off. */
  overflow: hidden;
}

/* The project name takes its own line, as in the comp. */
.thumb__text b {
  display: block;
  font-weight: 600;
}

/* A reaction on the note, the way a real sticky collects them. Sized and placed
   as a share of the note so it travels with the zoom. */
.thumb__heart {
  position: absolute;
  top: 11%;
  left: 70%;
  width: 18.5%;
  height: auto;
  fill: #EEBBCA;
}

/* The selection layer, from the product: a blue outline and four square corner
   handles. The edge circles are deliberately left out, since at this size they
   read as noise rather than as affordances. */
.thumb__sel {
  position: absolute;
  inset: -1px;
  border: 2px solid var(--accent);
  opacity: 0;
  transition: opacity 0.2s ease;
}

.thumb__sel i {
  position: absolute;
  box-sizing: border-box;
  width: 12px;
  height: 12px;
  background: var(--surface);
  border: 2px solid var(--accent);
  border-radius: 3px;
}

.thumb__sel i:nth-child(1) { top: 0;    left: 0;  transform: translate(-50%, -50%); }
.thumb__sel i:nth-child(2) { top: 0;    right: 0; transform: translate(50%, -50%); }
.thumb__sel i:nth-child(3) { bottom: 0; left: 0;  transform: translate(-50%, 50%); }
.thumb__sel i:nth-child(4) { bottom: 0; right: 0; transform: translate(50%, 50%); }

/* Zoom in. Focus gets the same state, so the card behaves for the keyboard as
   it does for the pointer. */
a.card:hover .thumb,
a.card:focus-visible .thumb { --z: 1.44; --ox: 0; --oy: 0; }

a.card:hover .thumb__sel,
a.card:focus-visible .thumb__sel { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .thumb,
  .thumb__note,
  .thumb__peer,
  .thumb__sel { transition: none; }
}

/* --- Design thinking thumbnail ---------------------------------------------
   The workshop board, with the session's cursors drifting over it on hover.

   board-thumb.png is the supplied thumbnail with its six cursors painted out —
   all of them sat on plain white canvas, so removing them cost nothing but a
   few pixels of the dotted diamond guides. The cursors are then rebuilt as
   elements at the same coordinates and in the same colours, because a cursor
   flattened into a PNG cannot move.

   Positions are percentages of the card, taken from the plate: an arrow's
   top-left in the 1080x516 export divided by those dimensions. Change the
   picture and these change with it.

   Sized to the plate rather than to comfortable reading — at this scale the
   names are texture, not labels, and a cursor larger than the board it sits on
   would give the whole thing away. The board is aria-hidden for that reason;
   the link is named by the foot, which appears on hover.
   ------------------------------------------------------------------------ */
/* The plate carries the mural's own orange frame line in its outermost 4px on
   all four sides. At rest that is cropped out: --board-zoom holds the board at
   1.05, and 4px of 516 is 0.8% a side, so anything past 1.016 clears it. What
   the card shows at rest is the board, edge to edge, inside the card's own
   1px rule — the same rule and lift the bio card carries, which is why this
   one is no longer a --bleed.

   Hovering pulls back to 1 and the frame line arrives, a hairline just inside
   that rule: the card widens out to the whole mural rather than pushing into
   it. The board moves, so the picture is not sitting still while the cursors
   drift over it.

   The zoom is on the board, not on the background, so the cursors ride with it
   and stay on the marks they were positioned against. */
.board {
  --board-zoom: 1.05;
  position: relative;
  flex: 1;
  overflow: hidden;
  background: var(--surface) url("../images/design-thinking/board-thumb.png") center / cover no-repeat;
  transform: scale(var(--board-zoom));
  transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Inside the motion query with the cursors: the crop is a framing decision and
   stays either way, the pull-back is motion and goes with them. */
@media (prefers-reduced-motion: no-preference) {
  a.card:hover .board,
  a.card:focus-visible .board { --board-zoom: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .board { transition: none; }
}

.cur {
  position: absolute;
  display: flex;
  align-items: flex-start;
  color: var(--cur);          /* the arrow is currentColor; the pill is --cur */
  pointer-events: none;       /* the card is draggable — never swallow the drag */
}

/* The arrow is Mural's, traced off a full-size capture rather than drawn by
   eye: a dart with a convex leading edge, a concave trailing one, and a tail
   that comes to a point. Every proportion below is that capture's, divided by
   its pill height — arrow 0.73, label inset 0.37, radius 0.24 — so the set
   still reads as one object at any size. */
.cur svg {
  width: 8px;
  height: 8px;
  flex: none;
}

/* The label tucks under the arrow's tail, the way Mural draws it. */
.cur__name {
  margin: 5px 0 0 -3px;
  padding: 0 4px;
  border-radius: 3px;
  background: var(--cur);
  color: #FFFFFF;
  font-size: 7px;
  line-height: 11px;
  font-weight: 700;
  letter-spacing: 0.1px;
  white-space: nowrap;
}

.cur--catherine { --cur: #EC89BC; left: 21.94%; top: 19.19%; }
.cur--samantha  { --cur: #E6CF58; left: 80.93%; top: 14.73%; }
.cur--juana     { --cur: #9285E4; left: 64.35%; top: 35.08%; }
.cur--erik      { --cur: #CB76C2; left: 11.39%; top: 40.12%; }
.cur--chris     { --cur: #487CE6; left: 39.07%; top: 79.46%; }
.cur--rodrigo   { --cur: #7FBB61; left: 81.94%; top: 80.43%; }

/* Yellow takes ink, not white — white on it is unreadable. */
.cur--samantha .cur__name { color: var(--canvas-ink); }

@media (prefers-reduced-motion: no-preference) {
  a.card:hover .cur,
  a.card:focus-visible .cur {
    animation-timing-function: cubic-bezier(0.5, 0, 0.2, 1);
    animation-iteration-count: infinite;
  }

  a.card:hover .cur--catherine,
  a.card:focus-visible .cur--catherine { animation: drift-catherine 4.6s infinite; }

  a.card:hover .cur--samantha,
  a.card:focus-visible .cur--samantha { animation: drift-samantha 5.4s infinite; }

  a.card:hover .cur--juana,
  a.card:focus-visible .cur--juana { animation: drift-juana 5s infinite; }

  a.card:hover .cur--erik,
  a.card:focus-visible .cur--erik { animation: drift-erik 6s infinite; }

  a.card:hover .cur--chris,
  a.card:focus-visible .cur--chris { animation: drift-chris 4.2s infinite; }

  a.card:hover .cur--rodrigo,
  a.card:focus-visible .cur--rodrigo { animation: drift-rodrigo 5.8s infinite; }
}

/* Move, brief hold, move again. Each cursor leaves the moment the card is
   hovered — no opening pause — and crosses 40 to 65px in about a second, so
   the board reads as busy rather than becalmed. Six durations between 4.2 and
   6s, none a multiple of another, so the room never falls into step.

   Every path is bounded by what it must not cover: the title and the stage
   row above, the five coloured pills across the middle, the video tile on the
   right. Catherine is wedged between the title and "Discover" with four pixels
   to spare, so she only slides sideways; Samantha runs up into the open canvas
   rather than down, because down is "Test" and then the video tile. */
@keyframes drift-catherine {
  0%        { transform: translate(0, 0); }
  20%,  28% { transform: translate(55px, -2px); }
  50%,  58% { transform: translate(20px, -5px); }
  80%,  88% { transform: translate(-38px, -3px); }
  100%      { transform: translate(0, 0); }
}

@keyframes drift-samantha {
  0%        { transform: translate(0, 0); }
  20%,  28% { transform: translate(-56px, 12px); }
  50%,  58% { transform: translate(-20px, 4px); }
  80%,  88% { transform: translate(14px, -16px); }
  100%      { transform: translate(0, 0); }
}

@keyframes drift-juana {
  0%        { transform: translate(0, 0); }
  20%,  28% { transform: translate(-55px, 20px); }
  50%,  58% { transform: translate(30px, -14px); }
  80%,  88% { transform: translate(-20px, 45px); }
  100%      { transform: translate(0, 0); }
}

@keyframes drift-erik {
  0%        { transform: translate(0, 0); }
  20%,  28% { transform: translate(60px, -24px); }
  50%,  58% { transform: translate(25px, 35px); }
  80%,  88% { transform: translate(-15px, 10px); }
  100%      { transform: translate(0, 0); }
}

@keyframes drift-chris {
  0%        { transform: translate(0, 0); }
  20%,  28% { transform: translate(-60px, -18px); }
  50%,  58% { transform: translate(35px, 25px); }
  80%,  88% { transform: translate(-20px, 8px); }
  100%      { transform: translate(0, 0); }
}

@keyframes drift-rodrigo {
  0%        { transform: translate(0, 0); }
  20%,  28% { transform: translate(-65px, -20px); }
  50%,  58% { transform: translate(-25px, 25px); }
  80%,  88% { transform: translate(20px, 5px); }
  100%      { transform: translate(0, 0); }
}

/* --- Diagramming thumbnail ------------------------------------------------
   Built rather than screenshotted, like the Hana, design-thinking and skills
   cards. A flowchart of the connector logic the case study is about, on the
   dot grid the canvas uses, in the shape vocabulary a flowchart actually has:
   terminator pill, parallelogram for input and output, rectangles for steps,
   document for the artefact, circled cross for the merge.

   At rest the chart is finished, so the card never looks half-built sitting
   in the grid. On hover it clears and redraws itself ONCE, then holds — one
   element at a time, in the order somebody would actually draw it: a shape
   lands, its label appears, the connector strokes from that shape's edge to
   where the next one will be, and only then does the next shape land in the
   arrow's tip. Five elements down a single spine, which is few enough that
   each one can be large.

   The mechanism is two animations per element, in this order:
     flow-clear   0.11s forwards   — hides it, and HOLDS it hidden
     flow-*-in    delayed forwards — no backwards fill, so during the delay
                                     the held clear value is what shows
   Later animation wins once it starts, which is what makes the sequence read
   as one thing being drawn at a time rather than everything arriving at once.

   Geometry lives in the SVG's user space. The card is two cells wide by one
   tall, so the viewBox is 640x304 — the card's own 2.11 proportion — and the
   chart is a horizontal row inside it. That ratio is what sets the chart's
   size in the cell: hold it and nothing letterboxes, and the units the chart
   does not use become margin. Widen the viewBox and the chart shrinks; no
   coordinate has to move. The dot grid is a CSS background, so it runs edge
   to edge underneath at a fixed size regardless.

   The chart used to run top to bottom in a one-by-two card, on a 288x556
   field. Turning it did not rotate anything: every shape was relaid out, the
   connectors point right, and the labels are level. A rotated chart has
   sideways type in it, which is the tell.
   ------------------------------------------------------------------------ */
.flow {
  /* Diagram colours. These are the flowchart's own, not this site's palette —
     same reasoning as the canvas colours above: they are the subject, not the
     system. Sampled from the reference chart. */
  /* Two greys, not one. The pill carries white text so it has to hold a
     contrast ratio; the connectors and the merge symbol do not, and read
     better a step lighter than the shapes they join. */
  /* The whole chart went lighter: at full strength it was the loudest thing
     in the grid, and it is a thumbnail, not a chart anybody reads off. Each
     family keeps its three steps — fill, stroke, label — and the order within
     a family is unchanged, so the shapes still read as filled outlines with
     type on them. Only the whole ladder moved up.

     The label is always darker than the stroke around it. That gap is what
     keeps the words legible once the outlines have gone quiet, and it is why
     the warm family needed a --text of its own: it used to letter itself in
     its stroke colour, which at this weight would have been unreadable. */
  --fc-ink:        #7A8184;   /* terminator pill — 4.2:1 under white text, the
                                 floor for this. Any lighter needs dark text */
  --fc-line:       #A2A8AA;   /* connectors, arrowheads, merge symbol */
  --fc-ground:     #F6F7F7;
  --fc-grid:       #D9DDDE;
  --fc-blue-fill:  #E9F4FC;
  --fc-blue-line:  #79BBE7;
  --fc-blue-text:  #3D7BA8;
  --fc-warm-fill:  #FDF8E4;
  --fc-warm-line:  #DFA97E;
  --fc-warm-text:  #A9713F;
  --fc-red-fill:   #FDEBEE;
  --fc-red-line:   #F59BA3;
  --fc-red-text:   #BE5967;


  /* The chart is an in-flow child, not an absolutely positioned one. That is
     load-bearing: an absolutely positioned child contributes no height, which
     left this panel depending entirely on `flex: 1` resolving against a height
     the card gets from `grid-row: span 2`. WebKit does not reliably treat that
     as definite, so the panel collapsed to nothing and the foot rode to the
     top of the card. In flow, the SVG's own aspect ratio gives the panel a
     height no matter what flex does. */
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  background-color: var(--fc-ground);
  background-image: radial-gradient(var(--fc-grid) 1.1px, transparent 1.2px);
  background-size: 14px 14px;
  background-position: 7px 7px;
}

/* Width fills the cell; height comes from the viewBox ratio, which is the
   cell's own ratio, so this lands at the panel's exact height by construction
   rather than by being stretched to it. */
.flow__svg {
  display: block;
  width: 100%;
  height: auto;
  max-height: 100%;
}

/* Shapes. Stroke width is in user units, so it scales with the chart. */
/* .flow__pill, .flow__merge, .flow__x and .flow__t--inv are unused by the
   current thumbnail, which is three shapes and a sentence. They are the
   terminator and the merge symbol: part of the notation, and what a real
   chart on this card will want back. Kept rather than deleted for that. */
.flow__pill  { fill: var(--fc-ink); }
.flow__step  { fill: var(--fc-blue-fill); stroke: var(--fc-blue-line); stroke-width: 2.4; }
.flow__io    { fill: var(--fc-warm-fill); stroke: var(--fc-warm-line); stroke-width: 2.4; }
.flow__doc   { fill: var(--fc-red-fill);  stroke: var(--fc-red-line);  stroke-width: 2.4; }
.flow__merge { fill: var(--surface);      stroke: var(--fc-line);      stroke-width: 2.4; }

/* Corner radii live in the path data; this only softens what is left — the
   skewed points on the parallelograms, where a miter join would spike. */
.flow__step,
.flow__io,
.flow__doc,
.flow__line { stroke-linejoin: round; }
.flow__x     { stroke: var(--fc-line); stroke-width: 2.4; stroke-linecap: round; }

.flow__t {
  font-family: 'Inter', system-ui, sans-serif;
  font-size: 16px;
  font-weight: 700;
  text-anchor: middle;
  dominant-baseline: central;
}

.flow__t--inv  { fill: var(--surface); }
.flow__t--step { fill: var(--fc-blue-text); }
.flow__t--io   { fill: var(--fc-warm-text); }
.flow__t--doc  { fill: var(--fc-red-text); }

/* Connectors. pathLength="100" on every path normalises the dash maths, so
   one pair of keyframes draws paths of any length; duration carries the
   difference in feel. */
.flow__line {
  fill: none;
  stroke: var(--fc-line);
  stroke-width: 2.4;
  stroke-dasharray: 100 101;
  stroke-dashoffset: 0;
}

/* Same ink as the fill, so the stroke only rounds the three points. */
.flow__head {
  fill: var(--fc-line);
  stroke: var(--fc-line);
  stroke-width: 1.6;
  stroke-linejoin: round;
}

/* --- The build ------------------------------------------------------------
   Four roles, each with its own default duration. Only --d changes per
   element, which is what keeps the timeline below readable as a script.
   ------------------------------------------------------------------------ */
.flow__shape { --dur: 0.13s; }
.flow__label { --dur: 0.10s; }
.flow__line  { --dur: 0.17s; }
.flow__head  { --dur: 0.07s; }

@keyframes flow-clear    { to   { opacity: 0; } }
@keyframes flow-shape-in { from { opacity: 0; transform: translateY(5px); }
                           to   { opacity: 1; transform: translateY(0); } }
@keyframes flow-fade-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes flow-line-in  { from { opacity: 1; stroke-dashoffset: 100; }
                           to   { opacity: 1; stroke-dashoffset: 0; } }

a.card:hover .flow__shape,
a.card:focus-visible .flow__shape {
  animation: flow-clear 0.11s linear forwards,
             flow-shape-in var(--dur) var(--d) cubic-bezier(0.2, 0.8, 0.3, 1) forwards;
}

a.card:hover .flow__label,
a.card:focus-visible .flow__label,
a.card:hover .flow__head,
a.card:focus-visible .flow__head {
  animation: flow-clear 0.11s linear forwards,
             flow-fade-in var(--dur) var(--d) linear forwards;
}

a.card:hover .flow__line,
a.card:focus-visible .flow__line {
  animation: flow-clear 0.11s linear forwards,
             flow-line-in var(--dur) var(--d) linear forwards;
}

/* The script. One beat per node, 0.50s apart, and the four phases inside a
   beat always fall at the same offsets: shape at :00, its label at :14 (once
   the shape has landed, so the text never rides the settle), the connector at
   :27, its arrowhead at :40. Read the column downward and you have the
   animation; to retime the whole thing, scale every value by one factor and
   the rhythm holds.

   Total run is 1.38s across three shapes, three labels and two connectors.
   It was 2.3s when the chart had five shapes; the column below is the same
   column, stopped after the third beat, so nothing was retimed to shorten it.

   It was 4.2s, which was longer than anyone holds a pointer still on a
   thumbnail: the chart is the case study's argument and most visitors were
   only ever seeing the first two nodes of it. Every value here — the four
   role durations above, the 0.11s clear, and this column — is the original
   scaled by 0.55, which is what keeps the phases inside a beat landing where
   they did relative to each other. Notably the arrowhead still arrives before
   its connector has finished stroking, which is what makes the line read as
   arriving somewhere rather than merely stopping. Scale all of it again by
   one factor to retime; do not touch a single value on its own. */
.flow__shape--a { --d: 0.14s; }   /* This case study    */
.flow__label--a { --d: 0.28s; }
.flow__line--1  { --d: 0.41s; }
.flow__head--1  { --d: 0.54s; }

.flow__shape--b { --d: 0.64s; }   /* is still           */
.flow__label--b { --d: 0.78s; }
.flow__line--2  { --d: 0.91s; }
.flow__head--2  { --d: 1.04s; }

.flow__shape--c { --d: 1.14s; }   /* a work in progress */
.flow__label--c { --d: 1.28s; }

/* Reduced motion gets the finished chart and nothing else. There is no
   quieter version of "it draws itself" that is still that idea. */
@media (prefers-reduced-motion: reduce) {
  a.card:hover .flow__shape,
  a.card:focus-visible .flow__shape,
  a.card:hover .flow__label,
  a.card:focus-visible .flow__label,
  a.card:hover .flow__line,
  a.card:focus-visible .flow__line,
  a.card:hover .flow__head,
  a.card:focus-visible .flow__head { animation: none; }
}

/* --- Skills thumbnail -----------------------------------------------------
   NOT CURRENTLY SHIPPED. The homepage uses the terminal variant below; this is
   kept as the revert path and is referenced from a comment in index.html.

   The side-project card is built rather than screenshotted, like the Hana and
   design-thinking cards: a directory listing of the six skill files, which
   says "six documents" faster than a picture of a terminal would.

   Real directory names, so the card and the page agree. The longest is 28
   characters, which is what sets the 10px mono size — one line each, no
   wrapping and no truncation, at the 258px cell.
   ------------------------------------------------------------------------ */
.skillstack {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  padding: 20px 16px;
  font-family: var(--font-mono);
  font-size: 10px;
  line-height: 18px;
}

.skillstack__path {
  margin-bottom: 8px;
  color: var(--ink);
}

.skillstack__f {
  color: var(--muted);
  transition: color 0.3s ease;
}

/* Hovering the card walks the set from muted to ink, top to bottom — the
   staggered delay is the whole effect, so it is a transition on colour and
   costs no layout. Dropped under reduced motion by the block at the end of
   this file, which also clears transitions. */
a.card:hover .skillstack__f,
a.card:focus-visible .skillstack__f { color: var(--ink); }

.skillstack__f:nth-child(2) { transition-delay: 0.00s; }
.skillstack__f:nth-child(3) { transition-delay: 0.04s; }
.skillstack__f:nth-child(4) { transition-delay: 0.08s; }
.skillstack__f:nth-child(5) { transition-delay: 0.12s; }
.skillstack__f:nth-child(6) { transition-delay: 0.16s; }
.skillstack__f:nth-child(7) { transition-delay: 0.20s; }

/* --- Terminal thumbnail ---------------------------------------------------
   The side-project card as a terminal window. Replaces .skillstack on the
   homepage; .skillstack is left in place directly above because this is a
   swap, not a deletion — reverting is removing `card--term` and putting the
   old markup back from the comment in index.html.

   THE DARK CARD. The README's rule is that the ground is grey and the cards
   are white, because that contrast is what makes a bento grid read as cards.
   This is the one exception, and it earns it by being the only one: a single
   dark card among fourteen reads as deliberate emphasis, and this is the side
   project, which is the card that should feel unlike the others. Two dark
   cards would just look like the system broke.

   THE COLOUR. A terminal needs six hues and the palette allows four, so these
   sit apart from the four roles exactly as the Hana canvas colours do — they
   are the SUBJECT rather than the palette, and they appear nowhere else. What
   keeps them from being a foreign theme is that they are this site's own
   accents lightened for a dark ground rather than a scheme lifted off someone
   else's terminal. Each one names the role it came from below.
   ------------------------------------------------------------------------ */
.card--term {
  /* Ground: the ink family taken down rather than a blue-black, so the card
     sits in the same neutral as everything else on the page. */
  --term-bg:    #1E2223;
  --term-bar:   #2A2F30;
  --term-text:  #D8DCDC;   /* the light cousin of --muted */
  --term-dim:   #7C8687;
  --term-rule:  #313738;   /* the ground lifted, to part the foot from the body */
  --term-shift: 22px;      /* half the slack under the body, and half the foot:
                              what the body gives back on hover. See .term__body. */

  --term-user:  #B18CF5;   /* --link-a  #8339EA lightened */
  --term-path:  #7FD3DA;   /* --link-b  #53ABB3 lightened */
  --term-ok:    #5FD68C;   /* --positive #32AB60 lightened */
  --term-cmd:   #5CBBFF;   /* --accent  #0090FF lightened */
}

.term {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
  background: var(--term-bg);
  font-family: var(--font-mono);
  /* 10px is not a preference here, it is the budget. The card is 258px: the
     bar takes 30 and ten lines at 14px plus padding takes 184, which leaves 44
     under it. The foot is 44 and is out of the flow, so that slack is what the
     body has to give back on hover — see --term-shift on .term__body. At
     11px/16px the body needs 206 and there is nothing left to move. Change
     this and re-measure both. */
  font-size: 10px;
  line-height: 14px;
}

/* Title bar. The traffic lights are the cheapest possible signal that this is
   a terminal, and they do more work than any amount of styling below them. */
.term__bar {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 7px 10px;
  background: var(--term-bar);
}

.term__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex: none;
}

/* Not derived from anything — these three are quoted, not designed, and a
   recoloured set would stop reading as a window chrome at all. */
.term__dot--r { background: #FF5F57; }
.term__dot--y { background: #FEBC2E; }
.term__dot--g { background: #28C840; }

/* Mono and dim, like the rest of the window. A sans label was tried here and
   read as a caption bolted onto a terminal rather than as the terminal's own
   title. What the card is gets said in the foot instead. */
.term__name {
  margin-left: 4px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--term-dim);
}

/* Centred, not top-aligned. The foot rides in on hover rather than sitting in
   the flow, so at rest the body has the whole card under the bar: 228px for
   184px of content, and top-aligning it left 44px of empty ground at the
   bottom. Centring splits that slack in two, and hovering spends it.

   --term-shift is that half, and it is also exactly half the foot's height, so
   the two numbers are the same number: sliding the body up by it moves the
   content out of the foot's way and lands its last line on the foot's top
   edge. Re-measure both if the font size or the line count changes. */
.term__body {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 10px 10px 12px;
  color: var(--term-text);
  transform: translateY(0);
  transition: transform 0.2s ease;
}

.card--term:hover .term__body,
.card--term:focus-visible .term__body {
  transform: translateY(calc(-1 * var(--term-shift)));
}

@media (prefers-reduced-motion: reduce) {
  .term__body { transition: none; }
}

.term__line { white-space: nowrap; }

.term__user { color: var(--term-user); font-weight: 500; }
.term__path { color: var(--term-path); font-weight: 500; }
.term__ok   { color: var(--term-ok); }

/* The bullet that opens a typed command, as in the reference prompt. */
.term__caret { color: var(--term-ok); }
.term__cmd   { color: var(--term-cmd); }

/* The six. Dim at rest so the prompt reads first; the hover walks them up. */
.term__f {
  color: var(--term-dim);
  white-space: nowrap;
  transition: color 0.3s ease;
}

a.card:hover .term__f,
a.card:focus-visible .term__f { color: var(--term-text); }

.term__f:nth-child(3) { transition-delay: 0.00s; }
.term__f:nth-child(4) { transition-delay: 0.04s; }
.term__f:nth-child(5) { transition-delay: 0.08s; }
.term__f:nth-child(6) { transition-delay: 0.12s; }
.term__f:nth-child(7) { transition-delay: 0.16s; }
.term__f:nth-child(8) { transition-delay: 0.20s; }

/* Block cursor. Solid at rest on purpose — one blinking thing in a grid of
   fourteen cards is a distraction, so the blink is a hover reward. */
.term__cursor {
  display: inline-block;
  width: 5px;
  height: 10px;
  margin-left: 1px;
  vertical-align: -1px;
  background: var(--term-text);
}

@media (prefers-reduced-motion: no-preference) {
  a.card:hover .term__cursor,
  a.card:focus-visible .term__cursor { animation: term-blink 1.06s steps(1) infinite; }
}

@keyframes term-blink {
  0%, 50%   { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}

/* The foot, in the window's own colours rather than white. A white strip under
   a dark terminal read as two objects stacked; matching it to the body makes
   the card one object with a label at the bottom, and the card stops fighting
   itself. It is still a .card__foot, so it keeps the arrow and the layout every
   other captioned card uses.

   It also stays the link's accessible name: .term is aria-hidden in full, so
   this is the only text a screen reader gets from the card. Do not remove it
   without putting an aria-label on the <a>.

   --term-rule is the one value here not derived from the four roles — it is the
   ground lifted just far enough to separate the foot from the body. Delete the
   border outright if you want the card fully seamless. */
.card--term .card__foot {
  background: var(--term-bg);
  border-top-color: var(--term-rule);
  color: var(--term-text);
}

.card--term .card__foot .arrow { color: var(--term-dim); }

/* --term-dim to --term-text, which is the same one-step firming the light feet
   do with --muted to --ink-soft, in the window's own two greys. The file
   already makes this move on .term__f for the keyboard. */
.card--term .card__foot .relay__mark--in { color: var(--term-text); }

/* --- Responsive ----------------------------------------------------------- */

/* Tablet: the reference drops to two columns and a 714px grid. */
@media (max-width: 1199.98px) {
  .bento { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* The readout drops under the stage as soon as the two cannot both have the
   room they need — well above the tablet breakpoint, because the notes are
   pinned to actual size and so cannot absorb the squeeze themselves. Measured
   rather than guessed: the widest state is Hana at 1.25x, which is 654px of
   notes, and a 300px readout beside it only leaves that much room from 1200px
   up — which is the breakpoint the rest of the site already turns on. Below
   it the readout stacks and the stage gets the full width. */
@media (max-width: 1199.98px) {
  .specimen__body { grid-template-columns: minmax(0, 1fr); }

  /* Stacked, the readout has the full width to itself, so its two groups sit
     side by side rather than one under the other — worth about 115px of height,
     which is the whole of the shorter group. */
  .props {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 14px var(--gap-m);
    align-items: start;
  }

  .props__ratio { grid-column: 1 / -1; }
}

@media (max-width: 809.98px) {
  /* :root sets the 16px mobile gutter, but .note-page sets --w-page on body,
     which wins inside body no matter what :root says. Restate it. */
  .note-page { --w-page: min(100vw - 32px, 960px); }

  .page { padding-bottom: var(--gap-m); }

  .stat { flex-basis: calc(50% - var(--gap-m) / 2); max-width: none; }

  .diagram { padding: 20px; }

  /* The specimen stacks: readout under the stage, and the 2x2 drops its axis
     labels entirely — all of them, since leaving the column headers in place
     would offset every tile by one cell. The sub-line on each tile already
     names its quadrant, so the grid still reads correctly without them. */
  .specimen { padding: 20px; }

  .specimen__pick { grid-template-columns: repeat(2, minmax(0, 1fr)); }

  .specimen__axis { display: none; }

  .specimen__stage {
    gap: 20px;
    padding: 20px;
  }

  .mural { padding: 20px; }

  .mural__scroll { padding: 16px; }

  /* Too narrow to sit the two conversion paths beside each other. */
  .convert { grid-template-columns: minmax(0, 1fr); gap: 32px; }

  .diagram__row {
    grid-template-columns: 1fr;
    gap: 10px;
    padding-bottom: 8px;
  }

  .diagram__op { justify-content: flex-start; }

  .case__title { font-size: 26px; }

  /* Two columns of half a phone are not columns. The head stacks, and the
     title keeps its measure back now that it owns the full width. */
  .case__head--split { grid-template-columns: 1fr; gap: var(--gap-m); }
  .case__head--split .case__title { max-width: var(--w-text); }

  /* On :root, not on .case-shell — the case-nav is a sibling of the shell and
     reads this to place itself. */
  :root { --shell-pad: 16px; }

  .case-shell {
    padding: 32px var(--shell-pad) 28px;
    border-radius: var(--radius);
    margin-bottom: 24px;
  }

  .case-nav { padding-block: 18px; }

  .case-nav__title { display: none; }

  .skills { padding-inline: 20px; }

  .skills__row { grid-template-columns: 28px minmax(0, 1fr); gap: 0 12px; }

  .shot--pair { grid-template-columns: 1fr; gap: var(--gap-m); }

  .shot--quad { grid-template-columns: repeat(2, minmax(0, 1fr)); }

  .shots { gap: var(--gap-m); }
}

/* Phone: one column, and cards size to their content rather than to a
   fixed 258px row, so text cards stop clipping. */
@media (max-width: 519.98px) {
  /* Below this the two footer halves cannot share a line, so stack them left
     instead of leaving the contact half stranded on the right. */
  .foot { justify-content: flex-start; }
  .foot__contact { text-align: left; }
}

/* Too narrow to pair the readout groups: the longer labels and their values
   would both wrap. Back to one column. */
@media (max-width: 599.98px) {
  .props { grid-template-columns: minmax(0, 1fr); }
}

@media (max-width: 599.98px) {
  .bento { grid-template-columns: 1fr; grid-auto-rows: minmax(var(--cell), auto); }

  /* The cap that used to be here is gone with the tall chart it was for. A
     288x556 chart across a full-width cell was two thirds of a phone tall, so
     the panel was pinned to 420px. The chart is 640x304 now: at any phone
     width its own ratio gives a panel shorter than the cell's 258px minimum,
     so the row governs and the chart centres in it. A cap would only put a
     wide chart in a tall box. */

  .stat { flex-basis: 100%; max-width: none; }

  .shot--quad { grid-template-columns: 1fr; }

  .card--w2 { grid-column: span 1; }
  .card--h2,
  .card--h3 { grid-row: span 1; }

  .card--bio {
    grid-row: span 1;
    overflow-y: visible;
    /* Taller than its cell here, so there is nothing to centre — centring
       would hang the first lines off the top of the card. */
    justify-content: flex-start;
  }

  .nav__title { display: none; }   /* leave the back and contact buttons */
}

/* --- Motion ---------------------------------------------------------------
   The load entrance is taken from the reference, which writes its initial
   keyframes straight into the HTML:

     header  opacity 0.001  translateY(-24px)
     main    opacity 1      translateY(96px)     <- slides, deliberately no fade
     both with perspective(1200px)

   The scroll reveal below is NOT in the reference — it has no scroll-triggered
   motion at all. It is here because these pages run much longer than the
   reference's, so a single load animation would be over before most of the
   content is ever seen. To drop it, delete the two `.js :where(...)` rules.
   ------------------------------------------------------------------------ */

@media (prefers-reduced-motion: no-preference) {

  /* The pills, not .nav itself — .nav owns the hide-on-scroll transform and
     an animation on it would win the cascade and pin it in place. */
  .nav > * {
    animation: enter-header 0.5s cubic-bezier(0.44, 0, 0.22, 1) backwards;
  }

  /* The reset is display: none until the grid has been rearranged, so this
     runs when it returns rather than at load — it arrives the way the rest of
     the header did, instead of simply being there. */
  .bento-reset {
    animation: enter-header 0.35s cubic-bezier(0.44, 0, 0.22, 1) backwards;
  }

  .page,
  .case-shell {
    animation: enter-main 0.8s cubic-bezier(0.44, 0, 0.22, 1) backwards;
  }

  /* The row's children, not the row — .case-nav now owns the hide-on-scroll
     transform, and an animation on the same property would fight it. This is
     the same split, for the same reason, as .nav > * above. */
  .case-nav > * {
    animation: enter-header 0.5s cubic-bezier(0.44, 0, 0.22, 1) backwards;
  }

  /* Scroll reveal. :where() keeps specificity at 0 so .is-in can win with a
     plain class rather than !important. */
  .js :where(.case > figure,
             .case > .prose,
             .case > .stats,
             .case > .table-wrap,
             .case > .case__head,
             .case > .shots__note,
             .shots > figure,
             .shots > .shots__note,
             .bento > .card) {
    opacity: 0;
    transform: translateY(24px);
    transition: opacity 0.7s cubic-bezier(0.44, 0, 0.22, 1),
                transform 0.7s cubic-bezier(0.44, 0, 0.22, 1);
  }

  .js :where(.is-in) {
    opacity: 1;
    transform: none;
  }

  /* Held for one frame at startup so content already on screen settles with
     the page rather than animating a second time on top of it. */
  .no-anim * {
    transition: none !important;
    animation: none !important;
  }
}

@keyframes enter-header {
  from { opacity: 0.001; transform: perspective(1200px) translateY(-24px); }
  to   { opacity: 1;     transform: perspective(1200px) translateY(0); }
}

@keyframes enter-main {
  from { transform: perspective(1200px) translateY(96px); }
  to   { transform: perspective(1200px) translateY(0); }
}

/* --- Tetris intro (homepage, first visit only) ----------------------------
   The grid assembles itself instead of simply being there. Each card drops
   into its own slot as a blank light-grey block, and the block dissolves to
   reveal what the card actually holds. Blocks fall, they stack, the well
   fills from the floor up — which is why the bottom row lands first and the
   top row lands last, the reverse of every other entrance on this site.

   Only cards that are on screen at load take part; everything below the fold
   keeps the ordinary scroll reveal. Otherwise the sequence would spend its
   whole length animating rows nobody is looking at, and the visible ones —
   being the topmost — would be the very last to arrive.

   The grey is the point. A card is a block first and a card second: nothing
   reads until it has landed, so the eye follows the shapes filling the grid
   rather than trying to read eight thumbnails at once mid-flight.

   The five numbers, in the order worth touching:
     --drop-step   pace of the whole thing. Up for a slower stack.
     --drop-rise   how far above its slot a card starts. Up for more weight.
     --drop-dur    one card's fall.
     --drop-skew   the lag across a row, so a row is not one flat slab.
     --drop-block  the grey.

   The whole feature is this block, js/intro.js, and the one <script> tag that
   loads it. Delete those three and the site is exactly as it was.
   ------------------------------------------------------------------------ */

/* .page's own entrance travels 96px upward — the opposite direction, at the
   same moment. Two entrances at once is one too many, so on a load the grid
   drops in, the grid's is the only one. The class is set before the first
   paint, so nothing has been drawn at the offset this cancels.

   It is cancelled for the whole load, not just for the intro's length:
   js/intro.js leaves the class on. Removing it when the last card landed gave
   .page its animation back, and an animation handed to an element mid-life
   starts at its first keyframe — the page slid up 96px right after the grid
   had finished settling. */
.is-tetris .page { animation: none; }

.bento .card.is-drop {
  --drop-rise:  44px;
  --drop-dur:   0.44s;
  --drop-step:  0.09s;
  --drop-skew:  0.03s;
  --drop-load:  0.32s;
  --drop-block: #F4F5F7;

  /* --w is the row, counted from the bottom of the on-screen set; --c is the
     position across that row. Both are set by js/intro.js. */
  --drop-delay: calc(var(--drop-step) * var(--w, 0) +
                     var(--drop-skew) * var(--c, 0));

  animation: bento-drop var(--drop-dur) var(--drop-delay) both;
}

/* The block itself. z-index 4 so it covers everything the card holds, and inset: 0 so
   it stops at the card's own border — the outline stays drawn through the
   fall and the block arrives as a card-shaped thing, not a grey rectangle. */
.bento .card.is-drop::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 4;
  background: var(--drop-block);
  pointer-events: none;
  animation: bento-drop-load var(--drop-load)
             calc(var(--drop-delay) + var(--drop-dur) * 0.55) both;
}

/* 55% is the landing. Before it, gravity — a curve that arrives fast. After
   it, 3px of rebound: not a bounce, just enough that the block reads as
   having weight rather than as having stopped dead. */
@keyframes bento-drop {
  from { opacity: 0; transform: translateY(calc(var(--drop-rise) * -1));
         animation-timing-function: cubic-bezier(0.42, 0, 0.9, 0.62); }
  /* Solid well before it lands. Fading across the whole fall left the block
     almost invisible against a ground this close to white for the first third
     of it, which reads as a card appearing late rather than as one falling. */
  30%  { opacity: 1;
         animation-timing-function: cubic-bezier(0.42, 0, 0.9, 0.62); }
  55%  { transform: translateY(0);
         animation-timing-function: cubic-bezier(0.33, 0, 0.35, 1); }
  74%  { transform: translateY(-3px);
         animation-timing-function: cubic-bezier(0.44, 0, 0.22, 1); }
  to   { transform: translateY(0); }
}

@keyframes bento-drop-load {
  from { opacity: 1; }
  to   { opacity: 0; }
}

/* --- Motion preference ---------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
  .nav__btn--avatar:hover,
  .nav__btn--avatar:focus-visible,
  .nav__btn--avatar:hover .nav__avatar,
  .nav__btn--avatar:focus-visible .nav__avatar,
  a.card--social:hover .avatar,
  a.card--social:focus-visible .avatar { transform: none; }
  /* No sliding for these visitors, but the header still gets out of the way
     rather than sitting on the text: it goes, it does not travel. */
  .nav[data-hidden="true"] { transform: none; opacity: 0; visibility: hidden; }
  .case-nav[data-hidden="true"] { transform: none; opacity: 0; visibility: hidden; }

  /* The blanket rule above would flatten the confetti's fade to nothing
     and the button would answer a press with silence. Nothing here travels —
     it is an opacity change, which is the version of this these visitors are
     meant to get — so it is given its duration back. */
  .confetti--still .confetti__p { animation-duration: var(--dur) !important; }
}

/* --- Mural Compose thumbnail ----------------------------------------------
   The mark is the subject, so the card is the mark and nothing else: white
   surface, no ground pattern, no crop of a screenshot. At rest it is the logo
   exactly as supplied — same geometry, same five brand colours, no residual
   transform on any bar. That is the point of the card. It is quiet until asked.

   On hover the mark listens. Every column scales about its own centre, so the
   coloured cap and the black body pull apart on a dip and swell together on a
   peak: the logo carved into a waveform and then restored, which is the
   behaviour the product itself has. The scale numbers come from the voice
   exploration, not from taste — see js/site.js for the driver and why it is
   JS rather than @keyframes.

   Geometry is images/compose-logo-solo.svg, unchanged. The viewBox is taller
   than that file's (0 -9 93 93 against 0 0 93 75) purely for headroom: at the
   1.30 peak the tallest column reaches y -6.8 and y 82.4, and the supplied box
   would clip both ends. The mark still sits optically centred at rest, because
   its own centre (y 37.8) and the padded box's (y 37.5) agree to a third of a
   unit.
   ------------------------------------------------------------------------ */
.cmp {
  flex: 1;
  display: grid;
  place-content: center;
  min-height: 0;
  padding: 24px;
}

/* One size token, so the mark can be retuned against the 258px cell without
   touching the geometry. 132 of 258 is 51%. It was 164 (64%), picked against
   140, 152 and 176 as the last step where the mark still read as a logo placed
   on a card rather than one filling it. That test was the mark against the
   card. The real test is the mark against the row it sits in, and it loses it:
   this is the only cell on the page carrying saturated colour on black, so at
   64% it pulls the eye off the case studies either side of it and the grid
   stops reading as a grid of equals. Half the cell is quiet enough to sit in
   the row and still leaves the 1.30 peak clear of the edge by a wide margin. */
.cmp__svg {
  display: block;
  width: 132px;
  max-width: 100%;
  height: auto;
}

/* Each bar scales about its own centre, which is what separates cap from body.
   transform-box is what makes 50% mean the rect rather than the whole SVG. */
.cmp__bar {
  transform-box: fill-box;
  transform-origin: 50% 50%;
}

/* Mural's colours, not this site's, for the same reason the Hana card uses
   Mural's: they are the subject rather than the palette. Sampled straight out
   of the supplied file. */
.cmp__ink   { fill: #000000; }
.cmp__red   { fill: #FF4B4B; }
.cmp__blue  { fill: #5887FF; }
.cmp__amber { fill: #FFAA00; }
.cmp__green { fill: #00843F; }
.cmp__pink  { fill: #FC83FF; }

/* The same mark as the homepage card, standing in for the hero until there is
   footage. It sits in a .shot frame, so it takes the frame's ground and radius
   and needs only a height and a bigger mark. */
.compose-hero {
  display: grid;
  place-items: center;
  min-height: 420px;
  padding: 48px;
  background: var(--surface);
  border-radius: var(--radius);
}

.cmp__svg--hero { width: 320px; }

/* The "How I work" band that used to close the bio card is gone, and its CSS
   with it. Four pastel marks in a line, each painting an inset panel with the
   principle written out; the --how-* tokens above are all that is left of it.
   If it comes back it belongs in its own cell, not inside the bio.
   ------------------------------------------------------------------------ */

/* --- The editable specification -------------------------------------------
   The third simulator. It reuses .specimen wholesale — the dot-grid stage, the
   .sticky rules and the .props readout are all the same, because the claim
   being made is that the notes render from the values and from nothing else.
   What is added here is only the controls: the sizing half of the readout
   becomes inputs, and the neutral/teal split holds, so a control that reads
   teal is a control that moves with sizing.

   The panel stays at the specimen's 300px. It is not a preference: the stage
   reserves 210 + 360 cells plus its gap and padding, and at 1056px wide there
   is exactly enough left for 300. A wider panel makes the canvas scroll, so
   every control below is sized to fit that column rather than the reverse.
   ------------------------------------------------------------------------ */

.editor__top {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px 16px;
}

/* Says whether the values in hand are still one of the four themes. It reads
   as a finding rather than as a label, and turns teal the moment they are not:
   the release depended on there being four, and changing one number here is
   what a fifth would have cost. */
.editor__state {
  font-size: 14px;
  line-height: 20px;
  color: var(--muted);
}

.editor[data-custom="true"] .editor__state { color: var(--link-b); }

.editor__presets {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

/* The same chip as .specimen__reset, which is the other button in this
   component, plus a pressed state — these four are a choice, not an action. */
.editor__preset {
  padding: 5px 11px;
  background: none;
  border: 1px solid var(--rule);
  border-radius: 999px;
  font: inherit;
  font-size: 13px;
  line-height: 18px;
  color: var(--muted);
  cursor: pointer;
  transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}

.editor__preset:hover { border-color: var(--muted); color: var(--ink); }

.editor__preset[aria-pressed="true"] {
  background: var(--fill);
  border-color: transparent;
  color: var(--ink);
  font-weight: 500;
}

/* One row's controls. Right-aligned into the readout's value column so an
   input sits where the number it replaced used to be. */
.editor__field {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 6px;
}

/* Two segmented controls under one label, for the row that carries two values.
   Stacked rather than side by side because 146px each is more than the column
   has, and because horizontal and vertical are not a pair to be read across. */
.editor__field--stack {
  flex-direction: column;
  align-items: flex-end;
  gap: 5px;
}

.editor__num {
  flex: none;
  width: 52px;
  padding: 4px 6px;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 6px;
  font: inherit;
  font-size: 14px;
  line-height: 20px;
  font-variant-numeric: tabular-nums;
  font-weight: 500;
  text-align: right;
  color: var(--link-b);
  transition: border-color 0.15s ease;
}

.editor__num:hover { border-color: var(--muted); }

.editor__num:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: transparent;
}

/* The half of a row the editor derives rather than asks for: the second term of
   "138 × 138", the px a ratio resolves to, or a bare unit. A fixed slot, so
   every input in the column lines up however wide the text beside it runs. */
.editor__echo {
  flex: 0 0 46px;
  font-size: 13px;
  line-height: 20px;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

/* Segmented choice. Too few options for a select and too many for a checkbox,
   and every one of them is a value in the specification, so they are all
   visible at once rather than behind a control. */
.editor__seg {
  flex: none;
  display: inline-flex;
  border: 1px solid var(--rule);
  border-radius: 7px;
  overflow: hidden;
}

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

.editor__seg label {
  padding: 3px 7px;
  font-size: 13px;
  line-height: 20px;
  color: var(--muted);
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}

/* Every label after the first, whatever the option count. */
.editor__seg input + label ~ label { border-left: 1px solid var(--rule); }

.editor__seg label:hover { color: var(--ink); }

.editor__seg input:checked + label {
  background: var(--fill);
  color: var(--ink);
  font-weight: 500;
}

/* Sizing values are teal throughout this case, controls included. */
.editor__seg--size input:checked + label { color: var(--link-b); }

.editor__seg input:focus-visible + label {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* A row is 30px tall now rather than 20px, so the readout's 6px stops holding
   each label to its own value. */
.editor .props__list {
  gap: 10px 12px;
  align-items: center;
}

.editor .props__list dt { line-height: 20px; }

/* Below this the panel cannot hold the controls beside the canvas, so it goes
   under it — the stage keeps its own scroll and nothing has to shrink. */
@media (max-width: 940px) {
  .editor .specimen__body { grid-template-columns: minmax(0, 1fr); }
}

/* --- Presets of your own --------------------------------------------------
   The four shipped chips and the ones you save read as one row, because they
   do the same job. What separates them is a hairline and a remove: a shipped
   theme is a record and cannot be deleted, yours is a working note and can.
   ------------------------------------------------------------------------ */

/* Two rows on their own gap, rather than two children of .specimen, which
   would put the component's 24px between a label and the row it labels. */
.editor__head {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.editor__save {
  display: flex;
  align-items: center;
  gap: 6px;
}

.editor__name {
  width: 168px;
  padding: 5px 11px;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 999px;
  font: inherit;
  font-size: 13px;
  line-height: 18px;
  color: var(--ink);
  transition: border-color 0.15s ease;
}

.editor__name::placeholder { color: var(--muted); }
.editor__name:hover { border-color: var(--muted); }

.editor__name:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: transparent;
}

/* The one chip that acts rather than selects, so it takes the ink of a live
   control instead of the muted rest of the row. */
.editor__preset--add {
  color: var(--ink);
}

/* Says what just happened, sharing the state line so that saying it never
   moves the controls. Stays muted while the state line beside it goes teal:
   the finding is the state, not the confirmation. */
.editor__hint { color: var(--muted); }

/* Divides the shipped four from yours. Not a heading, because the row does not
   need two: what is on the right of it has a remove and what is on the left
   does not, which is the whole distinction. */
.editor__div {
  width: 1px;
  align-self: stretch;
  margin: 3px 3px;
  background: var(--rule);
}

/* A preset of yours is one pill holding two buttons: the values, and the
   remove. The pill carries the border and the radius so the two halves cannot
   drift apart. */
.editor__chip {
  display: inline-flex;
  align-items: stretch;
  border: 1px solid var(--rule);
  border-radius: 999px;
  overflow: hidden;
  transition: border-color 0.15s ease, background 0.15s ease;
}

.editor__chip:hover { border-color: var(--muted); }

.editor__chip.is-on {
  background: var(--fill);
  border-color: transparent;
}

.editor__preset--mine {
  border: 0;
  border-radius: 0;
  padding-right: 8px;
}

/* The pill is already filled; a second fill inside it reads as a seam. */
.editor__preset--mine[aria-pressed="true"] { background: none; }

.editor__drop {
  padding: 0 10px 0 7px;
  background: none;
  border: 0;
  border-left: 1px solid var(--rule);
  font: inherit;
  font-size: 15px;
  line-height: 1;
  color: var(--muted);
  cursor: pointer;
  transition: color 0.15s ease, background 0.15s ease;
}

.editor__drop:hover {
  color: var(--ink);
  background: var(--fill);
}

.editor__drop:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* On the pressed pill the fill and the hover fill are the same value, so the
   remove needs its own ground to react on. */
.editor__chip.is-on .editor__drop:hover { background: var(--surface); }

/* --- Work in progress -----------------------------------------------------
   A window onto a canvas far wider than it. The card is the camera: at rest it
   frames the first shape only, and hover zooms in and travels to the far end of
   the chart, so the sentence "Diagramming case study / is still a / work in
   progress" is only finished by travelling.

   Two elements, two motions. .wip__stage scales, .wip__cam translates, and
   because they are separate properties on separate elements they can run as
   separate animations that OVERLAP: the pan starts while the zoom is still
   settling, so there is no dead beat between the two. As one transform on one
   element they were necessarily sequential, and the handoff showed.

   The framing is now structural rather than a set of computed offsets. The
   stage is positioned so that shape one's centre sits exactly on the window's
   centre, and it scales about that same point, so that shape stays centred at
   every zoom with nothing to recompute. The pan is then one number: the
   distance between two shape centres, times the zoom.

     stage           2560 x 1180, the 1760 x 780 chart inset by 400 and 200.
                     The padding is not decoration: at the resting scale the
                     canvas has to reach the window's edges, and the window is
                     as wide as 567px in the one-column layout. Checked to
                     582px, which is where the resting frame would first show
                     the card's background.
     fixed centre    826, 594.5 — shape one's centre in stage coordinates
     rest scale      0.45 — the shape fills 65% of a 256px window. It was
                     0.3526, the 51% of the source image, and at 51% the card
                     was two thirds empty canvas beside a Hana card that fills
                     its frame edge to edge. This is the one value that decides
                     how full the card looks at rest
     zoom scale      0.5913, so the shape fills 85%. That is 1.31x rather than
                     Hana's 1.44x, and the reason is arithmetic: 1.44x from the
                     new resting scale puts the shape at 93% of the window, nine
                     pixels off each edge, which crops the connectors that give
                     it context. 85% is as far in as this frame goes
     pan             -(1355 - 426) x 0.5913 = -549.3px, shape one's centre to
                     shape three's

   Because the stage carries the grid as well as the chart, both scale together
   and the dots go from 18px to 24px, which is what a canvas zoom looks like.
   Same behaviour as the Hana card, whose background-size also tracks its zoom.
   ------------------------------------------------------------------------ */
/* No card--bleed on this one. The canvas does reach the card's edge, which is
   what that class is for, but the Hana card is the same shape of thing — a
   full-bleed canvas inside a card — and it keeps its outline, and looks better
   for it. An edge is what makes a card read as a card; the terminal and the
   workshop board drop theirs because they are pictures of a window and a board,
   with their own frames already in the artwork. A canvas has no frame of its
   own, so it borrows the card's. */
.card--wip { position: relative; }

/* And it answers the pointer the way every other card does. This has to be
   said here because the rule that does it for the rest of the grid is
   a.card:hover, and this card is a <section>: same border, same 2px, same
   --lift-2, so the whole grid still lifts as one family. */
.card--wip {
  transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}

.card--wip:hover {
  border-color: var(--card-rule-hover);
  transform: translateY(-2px);
  box-shadow: var(--lift-2);
}

.wip {
  /* The connector weight, in canvas units. See .wip__line below. */
  --wip-w: 7;

  position: absolute;
  inset: 0;
  overflow: hidden;
  background: var(--wip-ground);
}

/* The pan. Translate only, in screen pixels, so it is unaffected by the zoom
   underneath it. */
.wip__cam {
  position: absolute;
  inset: 0;
  transform: translateX(0);
  transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}

/* The zoom. Scale only, about shape one's centre, which is also the point the
   stage is positioned on: the two cancel out, so that shape sits dead centre at
   any scale. 50% is the window's centre, so this re-centres itself rather than
   assuming the 258px cell. */
.wip__stage {
  position: absolute;
  left: calc(50% - 826px);
  top:  calc(50% - 594.5px);
  width: 2560px;
  height: 1180px;
  transform-origin: 826px 594.5px;
  transform: scale(0.45);
  transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
  background-color: var(--wip-ground);
  background-image: radial-gradient(var(--wip-grid) 3px, transparent 3.2px);
  background-size: 40px 40px;
}

/* Inset by the stage's padding, which is what puts the chart's own coordinates
   400 and 200 into stage coordinates without touching a single path. */
.wip__svg {
  position: absolute;
  left: 400px;
  top: 200px;
  width: 1760px;
  height: 780px;
  display: block;
}

/* Zoom: 1.31x in 0.42s, overshooting and settling. Hana's curve, and as much of
   Hana's 1.44x as this frame has room for — that card is the other place on this
   page where a thumbnail is a canvas you get closer to, so the two move alike.

   Pan: 2.25s, starting at 0.18s. That delay is the whole point of the split. It
   is less than half the zoom's duration, so the camera is still springing when
   it begins to move and the two beats read as one gesture. Raise it towards
   0.42s to separate them again; drop it to 0 and the zoom and the pan start
   together, which loses the sense of arriving before travelling.

   The pan's curve is close to linear in the middle on purpose. An ease-in-out
   is fastest exactly halfway, which is exactly where the second shape is
   centred, and it whipped "is still a" past faster than it could be read.

   `both` on each keeps the last frame, so it settles on "work in progress" for
   as long as the pointer stays. */
.card--wip:hover .wip__stage {
  transition: none;
  animation: wip-zoom 0.42s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}

.card--wip:hover .wip__cam {
  transition: none;
  animation: wip-glide 2.25s 0.18s cubic-bezier(0.28, 0, 0.72, 1) both;
}

@keyframes wip-zoom {
  from { transform: scale(0.45); }
  to   { transform: scale(0.5913); }
}

@keyframes wip-glide {
  from { transform: translateX(0); }
  to   { transform: translateX(-549.3px); }
}

/* --- The chart ------------------------------------------------------------
   Stroke widths are in user units, so they scale with the camera: 5 units is
   1.8px at rest and 2px zoomed, which is the weight the source images have.
   ------------------------------------------------------------------------ */
.wip__step { fill: var(--wip-teal-fill); stroke: var(--wip-teal-line); stroke-width: 5; }
.wip__io   { fill: var(--wip-warm-fill); stroke: var(--wip-warm-line); stroke-width: 5; }
.wip__doc  { fill: var(--wip-red-fill);  stroke: var(--wip-red-line);  stroke-width: 5; }

/* Corner radii live in the path data; this only softens what is left, which is
   the skewed points on the parallelogram where a miter join would spike. */
.wip__step,
.wip__io,
.wip__doc,
.wip__line { stroke-linejoin: round; }

/* Thicker and lighter, both on purpose and both for the same reason: at 5 units
   in #4A4E50 the connectors were the darkest ink in the card, so the routing
   out-weighed the three shapes it exists to join. Widening them and draining
   them turns a hard rule into a broad soft path — more present as structure,
   less present as contrast. One value, because the notation has to hold one
   weight: the lines, the arrowheads that terminate them and the merge symbol
   that interrupts them are all one drawing. */
.wip__line  { stroke: var(--wip-line); stroke-width: var(--wip-w); }
.wip__head  { fill: var(--wip-line); }
.wip__merge { fill: var(--wip-merge); stroke: var(--wip-line); stroke-width: var(--wip-w); }

/* The X is the one exception, a shade under the rest: it is two strokes crossing
   inside a 22-unit circle, and at the full weight the counters close up. */
.wip__x     { stroke: var(--wip-line); stroke-width: 5.5; stroke-linecap: round; }

.wip__t {
  font-family: 'Inter', system-ui, sans-serif;
  font-size: 40px;
  font-weight: 700;
  text-anchor: middle;
  dominant-baseline: central;
}

.wip__t--step { fill: var(--wip-teal-text); }
.wip__t--io   { fill: var(--wip-warm-text); }
.wip__t--doc  { fill: var(--wip-red-text); }

/* No hover to spend, so the card takes the trip by itself: the sentence is the
   content of this card, and a touch visitor cannot be left holding a third of
   it. Its own keyframes rather than the hover pair, because two animations of
   different durations set to `infinite alternate` drift out of step within a
   few cycles. One shared 7s cycle, each doing its own out-and-back inside it,
   keeps the zoom and the pan locked together for as long as the page is open.
   The percentages hold the same overlap: the pan starts at 6%, before the zoom
   has finished at 8%. Turned off entirely under reduced motion, below. */
@media (hover: none) {
  .wip__stage { animation: wip-zoom-loop 7s ease-in-out infinite; }
  .wip__cam   { animation: wip-glide-loop 7s infinite; }
}

@keyframes wip-zoom-loop {
  0%,  100%     { transform: scale(0.45); }
  8%,  90%      { transform: scale(0.5913); }
}

@keyframes wip-glide-loop {
  0%,  6%       { transform: translateX(0); animation-timing-function: cubic-bezier(0.28, 0, 0.72, 1); }
  46%, 56%      { transform: translateX(-549.3px); animation-timing-function: cubic-bezier(0.28, 0, 0.72, 1); }
  96%, 100%     { transform: translateX(0); }
}

/* One move, no travel: the sentence still finishes, it just arrives instead of
   panning. The resting frame is a third of a sentence, so doing nothing here
   would leave the card meaningless rather than merely still. */
@media (prefers-reduced-motion: reduce) {
  .wip__cam,
  .wip__stage { transition: transform 0.3s linear; }

  .card--wip:hover .wip__stage {
    animation: none;
    transition: transform 0.3s linear;
    transform: scale(0.5913);
  }
  .card--wip:hover .wip__cam {
    animation: none;
    transition: transform 0.3s linear;
    transform: translateX(-549.3px);
  }

  @media (hover: none) {
    .wip__stage,
    .wip__cam { animation: none; }
  }
}
