:root{
  --bg:#0b0f11; --bg-alt:#0f1619; --card:#131b1e; --ink:#e8f0f1; --ink-soft:#aebec2; --muted:#8d9ba0; --line:#223034; --accent:#facc15; --accent-dark:#eab308; --maxw:1080px;

  /* ---- Radius scale ------------------------------------------------------
     Only the 14px surface radius was ever a token; 10px was hardcoded in six
     places, and .stack span (6px) sat next to .prose code (5px) — two small
     inline chips differing by a pixel, which reads as an accident rather than
     a decision. Four steps now, and the pill is named so it stops appearing
     as a magic 999px. */
  --radius-sm:6px;    /* inline chips: stack tags, code */
  --radius-md:10px;   /* controls: buttons, inputs, notices */
  --radius-lg:14px;   /* surfaces: cards, media, quotes */
  --radius-pill:999px;

  /* ---- Space scale -------------------------------------------------------
     A 4px grid. Spacing was previously freehand — six different grid gaps
     (18/20/22/28/34/46), five different heading-to-body gaps (8/12/14/16/20),
     and paddings landing on 13, 15, 22, 27, 38, 52. None of those differences
     were perceptible on their own; together they were why nothing on the page
     shared a vertical rule with anything else.

     Values below 4px are deliberately off the scale: inline code padding is a
     hairline detail that rounding would destroy. */
  --sp-4:4px;   --sp-8:8px;   --sp-12:12px; --sp-16:16px;
  --sp-20:20px; --sp-24:24px; --sp-28:28px; --sp-32:32px;
  --sp-40:40px; --sp-48:48px; --sp-56:56px; --sp-72:72px;

  /* Electric violet, the one added accent. Two tints doing two different jobs,
     measured against --bg: --accent-2 is ~4.9:1 (borders, fills, glows, NOT body
     text), --accent-2-text is ~7.3:1 (text). Yellow is ~12.6:1, so violet always
     reads a touch softer — deliberate, it keeps yellow atop the hierarchy.
     Rule of thumb: yellow = identity and primary action at rest, violet =
     interaction and play. */
  --accent-2:#a855f7; --accent-2-text:#c084fc; --accent-2-glow:rgba(168,85,247,.32);

  /* ---- Surfaces ----------------------------------------------------------
     One card. There were three, all nearly the same and none quite matching:
     padding 28 / 22 / 24, lift -4 / -3 / none, shadow blur 46 / 40 / none,
     duration .30 / .28 / none. Individually invisible; together they are why
     the set read as hand-assembled rather than systematic.

     --card-glow is the hover shadow's colour, so a section can retint the
     whole set without restating the geometry. */
  /* The recess on an alternating section band — see .section-alt. */
  --band-inset:inset 0 14px 26px -20px #000,inset 0 -14px 26px -20px #000;
  --card-pad:var(--sp-24);
  --card-lift:-4px;
  --card-glow:var(--accent-2-glow);
  --card-shadow:0 20px 46px -20px var(--card-glow);
  --font-display:"Space Grotesk","Segoe UI Variable Display",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
  --font-mono:"JetBrains Mono",ui-monospace,"Cascadia Mono","Segoe UI Mono",Consolas,"Liberation Mono",monospace;

  /* ---- Type scale --------------------------------------------------------
     Every font-size on the site is one of these. There were 31 distinct values
     before, 17 of them crowded between .9rem and 1.25rem, and seven separate
     sizes (.71 .74 .76 .78 .8 .82 .85) for what is visually one component: the
     mono uppercase micro-label. A 0.02rem step is not a decision anyone can
     see, so those are now a single --fs-micro.

     Two h1 steps are deliberate rather than drift: --fs-h1 is the display
     headline on the home and games heroes, --fs-h1-sm the page-title size for
     service pages and case studies, whose headings are sentences rather than
     slogans and run to 26ch. */
  --fs-micro:.78rem;   /* mono uppercase: tags, meta, kickers, form labels */
  --fs-xs:.88rem;
  --fs-sm:.95rem;
  --fs-body:1rem;
  --fs-md:1.05rem;
  --fs-lg:1.12rem;
  --fs-lede:1.22rem;
  --fs-h3:1.25rem;
  --fs-h2-sub:1.35rem; /* sub-section headings, below a full section h2 */
  --fs-h2:1.9rem;
  --fs-h1-sm:clamp(2rem,4.2vw,3rem);
  --fs-h1:clamp(2.1rem,5vw,3.4rem);
  --ease-pop:cubic-bezier(.34,1.36,.64,1); --ease-out:cubic-bezier(.2,.9,.3,1);

  /* ---- Motion ------------------------------------------------------------
     Thirteen durations were in use, from 120ms to 600ms, with nine of them
     bunched between 200 and 400. Nobody can tell 220ms from 240ms, but a set
     of transitions all slightly out of step with each other reads as unsettled.
     Three steps:
       fast — presses and state flips, where lag feels broken
       base — the default for hover and colour
       slow — travel across distance: reveals, the shine sweep, media lifts */
  --dur-fast:120ms; --dur:240ms; --dur-slow:400ms;
}

/* Self-hosted latin subsets (variable weight axis), so there is no third-party
   request at runtime. local() first in case the visitor already has them. */
@font-face{font-family:"Space Grotesk";src:local("Space Grotesk"),url("/fonts/space-grotesk-var.woff2") format("woff2");font-weight:500 700;font-display:swap;}
@font-face{font-family:"JetBrains Mono";src:local("JetBrains Mono"),url("/fonts/jetbrains-mono-var.woff2") format("woff2");font-weight:400 600;font-display:swap;}

/* Registered so it interpolates in transitions instead of snapping. */
@property --bracket{syntax:'<length>';inherits:false;initial-value:0px;}

/* Cross-document view transitions: kills the white flash on home -> case study.
   Chrome/Edge 126+, Safari 18.2+. Firefox ignores it and navigates normally. */
@view-transition{navigation:auto;}

/* ---- Breakpoints -----------------------------------------------------------
   Three, and only three. There used to be five — 600, 640, 720, 820, 900 — which
   produced mid-range widths where the layout disagreed with itself: between 721
   and 820 the service cards had already collapsed to one column while About was
   still two columns holding a fixed 280px portrait, and between 641 and 720 the
   nav had collapsed to a hamburger while About had not.

     1024  three-column flows drop to two (testimonial columns)
      820  two-column layouts drop to one (cards, work slabs, About)
      640  everything stacks; the nav collapses behind the toggle

   Native CSS has no variables in media queries, so these stay literal — but
   nothing should introduce a fourth. */
*{box-sizing:border-box;}
html{scroll-behavior:smooth;}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg);line-height:1.6;-webkit-font-smoothing:antialiased;}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 var(--sp-24);}
/* No underline on hover, anywhere. The nav bar is the one exception and it does
   not use text-decoration at all — it has its own sliding underline, drawn as a
   pseudo-element on .nav-links a, further down.

   Previously the global rule added a violet underline on hover and individual
   components then opted out one at a time, which is why the hero's profile links
   and the footer's — the same LinkedIn/GitHub/Email pair — behaved differently
   from each other: the hero suppressed the underline and the footer never did.
   Colour is the hover signal now, and it is applied consistently. */
a{color:var(--accent);text-decoration:none;}
a:hover{text-decoration:none;color:var(--accent-2-text);}
/* Buttons and clickable surfaces own their hover colour. Without these the rule
   above would repaint a solid gold button's dark label violet, and turn the
   brand and the whole-card links a colour they never had. .btn-ghost sits later
   in the file and deliberately does go violet. */
.btn:hover,.nav-links a.nav-cta:hover,.skip-link:hover{color:#1c1600;}
.brand:hover,.work-more:hover{color:var(--ink);}
::selection{background:var(--accent-2);color:#0b0f11;}
/* Violet focus rings, so focus never reads as just another content accent. */
:focus-visible{outline:2px solid var(--accent-2-text);outline-offset:3px;border-radius:var(--radius-sm);}
header.nav{border-bottom:1px solid var(--line);position:sticky;top:0;background:rgba(11,15,17,.82);backdrop-filter:blur(10px);z-index:10;}
.nav-inner{display:flex;align-items:center;justify-content:space-between;height:64px;}
/* Optically aligned, which is not the same as either centred or bottom-aligned.
   Measured from the mark itself: its ink spans rows 1-94 of a 96px canvas, but the
   top 24 rows are just the thin antenna, so the solid body spans 25-94 and its
   centre sits 3.35px below the box centre at the rendered 28px.
   Centring the boxes therefore leaves the text sitting ~3px high against the
   robot's body; baseline-aligning it (the text baseline on the mark's bottom edge)
   overshoots ~5px the other way. So: centre the boxes, then nudge the text down to
   put its cap-height centre on the body's centre. `top` rather than a transform,
   so the text stays on the pixel grid and does not soften. */
.brand{display:flex;align-items:center;gap:var(--sp-8);font-family:var(--font-display);font-weight:700;letter-spacing:-.01em;font-size:var(--fs-md);color:var(--ink);text-decoration:none;}
.brand span{position:relative;top:3px;}
/* The mark is white-on-transparent, so it only reads on the dark nav. */
.brand-mark{display:block;width:28px;height:28px;}
/* align-items matters here: without it the plain links stretch to the height of
   the CTA pill (40px) and paint their text at the top of that box, leaving them
   sitting ~13px above the brand name. Centre rather than baseline, to match the
   optically-centred brand above. */
.nav-links{display:flex;align-items:center;gap:var(--sp-24);font-size:var(--fs-sm);}
.nav-links a{color:var(--ink-soft);position:relative;transition:color var(--dur) ease;}
/* Sliding underline rather than a colour swap. */
.nav-links a::after{content:"";position:absolute;left:0;right:0;bottom:-6px;height:2px;background:var(--accent-2);transform:scaleX(0);transform-origin:left center;transition:transform var(--dur) var(--ease-out);}
.nav-links a:hover{color:var(--accent-2-text);}
.nav-links a:hover::after{transform:scaleX(1);}
/* The one action in the header, so it stops being the sixth identical link.
   Outlined rather than solid gold: a filled button in a sticky bar competes with
   the hero's primary call to action directly below it on the page it matters
   most. It fills on hover. */
.nav-links a.nav-cta{
  color:var(--accent);border:1px solid color-mix(in oklab,var(--accent) 42%,var(--line));
  /* Same box as .btn-sm; only the radius differs, and that is the point of it
     reading as a pill rather than as a fifth rectangular control in the bar.
     Weight matches the rest of the button set. */
  border-radius:var(--radius-pill);padding:var(--sp-8) var(--sp-16);font-weight:700;
  transition:background-color var(--dur) ease,color var(--dur) ease,border-color var(--dur) ease;
}
.nav-links a.nav-cta:hover{background:var(--accent);border-color:var(--accent);color:#1c1600;}
/* The sliding underline would sit outside the pill. */
.nav-links a.nav-cta::after{display:none;}
/* ---- Mobile nav ---------------------------------------------------------- */
/* The toggle only exists once js/nav.js unhides it, and the collapsed panel below
   is gated on html.js. Without scripting the links simply wrap, as they used to —
   nothing ends up hidden behind a button that cannot open. */
/* Box and hover come from the shared .btn-quiet ruleset in the buttons section;
   only the default-hidden state is specific to the toggle. */
.nav-toggle{display:none;}

/* Three bars from one element: the middle is the box, the outer two are its
   pseudo-elements. They fold into an X when open. */
.nav-toggle-bars,
.nav-toggle-bars::before,
.nav-toggle-bars::after{
  /* Off the radius scale on purpose: these are 2px tall, so anything from the
     scale would round them into lozenges. */
  display:block;width:17px;height:2px;border-radius:2px;
  background:currentColor;transition:transform var(--dur) var(--ease-out),opacity var(--dur) ease;
}
.nav-toggle-bars{position:relative;}
.nav-toggle-bars::before,
.nav-toggle-bars::after{content:"";position:absolute;left:0;}
.nav-toggle-bars::before{top:-6px;}
.nav-toggle-bars::after{top:6px;}
.nav-open .nav-toggle-bars{background:transparent;}
.nav-open .nav-toggle-bars::before{transform:translateY(6px) rotate(45deg);}
.nav-open .nav-toggle-bars::after{transform:translateY(-6px) rotate(-45deg);}

@media(max-width:640px){
  .nav-inner{height:auto;min-height:60px;gap:var(--sp-8);}
  .nav-links{flex-wrap:wrap;align-items:center;gap:var(--sp-12) var(--sp-16);}

  /* Scripted: collapse into a panel behind the toggle. */
  html.js .nav-toggle{display:inline-flex;}
  html.js .nav-links{
    display:none;
    position:absolute;left:0;right:0;top:100%;
    flex-direction:column;gap:0;
    background:var(--bg-alt);border-top:1px solid var(--line);border-bottom:1px solid var(--line);
    padding:var(--sp-4) var(--sp-24) var(--sp-12);
  }
  html.js .nav-open .nav-links{display:flex;}
  html.js .nav-links a{padding:var(--sp-12) 0;border-bottom:1px solid var(--line);}
  html.js .nav-links a:last-child{border-bottom:none;}
  /* The sliding underline is a desktop affordance; in a stacked list it reads as
     a stray rule under each row. */
  html.js .nav-links a::after{display:none;}

  header.nav{position:sticky;}
  .nav-inner{position:relative;}
}

/* ---- Hero ---------------------------------------------------------------- */
/* min-height gives the animated background room to read as a space rather than
   a strip. No radial glow blobs: the Game of Life field carries the interest and
   the blobs muddied it. */
/* Padding trimmed from 104/76. The hero used to end at 794px with the first work
   slab at 1570px — two screens of scrolling before a visitor saw any work. */
.hero{padding:var(--sp-72) 0 var(--sp-56);position:relative;overflow:hidden;min-height:min(70vh,620px);display:flex;align-items:center;}
.hero > .wrap{position:relative;z-index:1;width:100%;}

/* Conway's Game of Life field — see js/hero-life.js. Monochrome violet, coarse
   cells, behind a dark scrim so it reads as texture rather than as noise
   competing with the headline.
   Tuning: tile count -> CELL in hero-life.js; density -> NOISE; brightness ->
   the canvas opacity here; how muted -> the ::after scrim opacity. */
.hero-fx{position:absolute;inset:0;z-index:0;overflow:hidden;pointer-events:none;contain:layout paint style;filter:blur(.4px);-webkit-mask-image:linear-gradient(to top,#000 2%,transparent 92%);mask-image:linear-gradient(to top,#000 2%,transparent 92%);}
.hero-fx canvas{display:block;width:100%;height:100%;opacity:.5;}
/* Dark scrim over the simulation. Inside .hero-fx (z-index 0), so it darkens the
   cells without touching hero content at z-index 1. Uses --bg, not black, to
   stay in step with the page background. */
.hero-fx::after{content:"";position:absolute;inset:0;pointer-events:none;background:var(--bg);opacity:.45;}

.kicker{display:inline-block;font-family:var(--font-mono);font-size:var(--fs-micro);font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);background:rgba(250,204,21,.09);border:1px solid var(--line);padding:var(--sp-4) var(--sp-12);border-radius:var(--radius-pill);margin-bottom:var(--sp-24);}
h1{font-family:var(--font-display);font-size:var(--fs-h1);line-height:1.12;letter-spacing:-.03em;text-wrap:balance;margin:0 0 var(--sp-20);max-width:17ch;}
/* The one gradient moment on the page. */
h1 .grad{background:linear-gradient(100deg,var(--accent) 8%,var(--accent-2-text) 92%);-webkit-background-clip:text;background-clip:text;color:transparent;}
.lede{font-size:var(--fs-lede);color:var(--ink-soft);max-width:60ch;margin:0 0 var(--sp-32);text-wrap:pretty;}
.cta-row{display:flex;gap:var(--sp-12);flex-wrap:wrap;align-items:center;}

/* Secondary profile links, directly under the hero CTAs. Previously these lived
   only in the footer, ~7000px down. */
.hero-links{margin-top:var(--sp-16);display:flex;flex-wrap:wrap;gap:var(--sp-20);font-size:var(--fs-sm);}
.hero-links a{display:inline-flex;align-items:center;gap:var(--sp-8);padding-block:var(--sp-4);color:var(--ink-soft);}
.hero-links a:hover{color:var(--accent-2-text);}

/* Skip link: off-screen until focused, then a normal-looking button at top-left. */
.skip-link{
  position:absolute;left:12px;top:-64px;z-index:50;
  background:var(--accent);color:#1c1600;font-weight:700;
  padding:var(--sp-12) var(--sp-16);border-radius:0 0 var(--radius-md) var(--radius-md);
  transition:top var(--dur-fast) var(--ease-out);
}
.skip-link:focus{top:0;}

/* ---- Buttons ------------------------------------------------------------- */
/* There were seven button treatments here: .btn, .btn-ghost, .case-actions .btn,
   .nav-cta, .quotes-more, .nav-toggle and .skip-link — between them three label
   sizes, two typefaces, two weights and two radii. .quotes-more and .nav-toggle
   were the same conceptual control (a small mono-uppercase outline button) at
   different paddings AND different font sizes, which is drift rather than
   design.

   One primitive now, with two axes:
     size     — .btn (default), .btn-sm, .btn-lg
     variant  — .btn (solid), .btn-ghost (outlined), .btn-quiet (mono label)
   .nav-toggle shares the quiet variant's ruleset rather than being renamed,
   because js/nav.js selects on that class name. (.quotes-more is gone: the
   testimonial list no longer collapses.)

   Weight is 700 across the set. The ghost button used to be 600, so a primary
   and secondary sitting side by side in .cta-row had mismatched label weights —
   the background and border already carry that distinction. */
.btn{
  display:inline-block;position:relative;overflow:hidden;
  background:var(--accent);color:#1c1600;font-weight:700;
  padding:var(--sp-12) var(--sp-24);border-radius:var(--radius-md);font-size:var(--fs-body);
  box-shadow:0 4px 0 var(--accent-dark);
  transition:transform var(--dur-fast) var(--ease-out),box-shadow var(--dur-fast) var(--ease-out),background-color var(--dur) ease;
}
.btn:hover{background:var(--accent-dark);}
/* Sizes. Only the box and the label change; every variant below inherits them. */
.btn-sm{padding:var(--sp-8) var(--sp-16);font-size:var(--fs-sm);}
.btn-lg{padding:var(--sp-16) var(--sp-28);font-size:var(--fs-md);}
.btn::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(105deg,transparent 22%,rgba(255,255,255,.38),transparent 78%);transform:translateX(-125%);transition:transform var(--dur-slow) var(--ease-out);}
.btn:hover::after{transform:translateX(125%);}
/* Not transparent. A see-through button sat directly over the animated Game of
   Life field, and moving violet cells behind the label wrecked its legibility.
   An opaque-ish panel plus a blur separates it from whatever is moving behind. */
/* No offset shadow on the secondary button — box-shadow:none rather than simply
   dropping the declaration, because this element is `.btn .btn-ghost` and would
   otherwise inherit .btn's gold shadow. The hover glow stays. */
.btn-ghost{background:color-mix(in oklab,var(--card) 92%,transparent);backdrop-filter:blur(8px);color:var(--ink);border:1px solid color-mix(in oklab,var(--accent-2) 30%,var(--line));box-shadow:none;}
.btn-ghost:hover{background:var(--card);border-color:var(--accent-2);color:var(--accent-2-text);box-shadow:0 0 28px -8px var(--accent-2-glow);}

/* The quiet variant: a mono uppercase label in an outlined box, for controls
   that are genuinely secondary to the page (open the menu, show the rest of a
   list). .nav-toggle is joined to the selector rather than given the class, so
   the JS that queries it keeps working. */
/* `display` deliberately stays out of this rule: the nav toggle's default is
   display:none (it is revealed only at the mobile breakpoint, by js/nav.js),
   and this ruleset sits later in the file, so setting display here would
   override that and leave the hamburger visible on desktop. */
.btn-quiet,
.nav-toggle{
  align-items:center;gap:var(--sp-8);
  background:transparent;cursor:pointer;
  border:1px solid var(--line);border-radius:var(--radius-md);
  padding:var(--sp-8) var(--sp-16);color:var(--ink-soft);
  font-family:var(--font-mono);font-size:var(--fs-micro);font-weight:700;
  letter-spacing:.08em;text-transform:uppercase;
  box-shadow:none;
  transition:border-color var(--dur) ease,color var(--dur) ease;
}
.btn-quiet{display:inline-flex;}
.btn-quiet:hover,
.nav-toggle:hover{border-color:var(--accent-2);color:var(--accent-2-text);}

/* :active last, so it wins over every :hover rule above. */
.btn:active{transform:translateY(3px);box-shadow:0 1px 0 var(--accent-dark);}
/* Still nudges on press for tactile feedback, just without a shadow to collapse. */
.btn-ghost:active{transform:translateY(2px);box-shadow:none;}

.avail{font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--muted);display:inline-flex;align-items:center;gap:var(--sp-8);margin-top:var(--sp-20);}
.dot{width:9px;height:9px;border-radius:50%;background:var(--accent);display:inline-block;box-shadow:0 0 0 4px rgba(250,204,21,.18);}
section{padding:var(--sp-72) 0;}
/* Keep anchored sections clear of the sticky header when jumped to — header
   height plus one space step, not the header height exactly. At exactly 64px
   the heading's cap-line landed flush against the header's bottom border with
   nothing between them. */
section[id]{scroll-margin-top:calc(64px + var(--sp-16));}
/* The stacked mobile header measures 86px down to a 320px viewport (the links
   still fit on one row). */
@media(max-width:640px){section[id]{scroll-margin-top:calc(96px + var(--sp-16));}}
/* Every section boundary gets exactly one hairline, drawn once on the lower of
   the two sections. .section-alt used to carry a top AND bottom border of its
   own, which meant a boundary's rule existed only where an alt section happened
   to sit on one side of it — Services running into What people say, both on the
   default background, had no line and no colour change and read as one
   continuous block of page. Drawing it on the boundary rather than on the
   section also means two adjacent alt sections can no longer stack two 1px
   borders into a 2px one. */
section + section{border-top:1px solid var(--line);}
/* The band. Sections alternate so that no two neighbours share a background:
   the hairline says where the seam is, the background change says the subject
   has changed.

   --bg and --bg-alt are four to eight points apart per channel, which is all the
   room there is between them and --card above. At that distance the fill alone
   was not a change anyone registered — Selected work and Services looked like
   one continuous run of page. The inset shadow is what makes it read: the band
   sits recessed rather than merely tinted, and depth is legible at contrast
   ratios where a flat colour step is not.

   The one section that opts out of the two-tone alternation is .contact, which
   closes every page directly above an --bg-alt footer and so needs a third
   treatment rather than a second — see the rule down in the contact section. */
.section-alt,
.testimonials{background-color:var(--bg-alt);box-shadow:var(--band-inset);}
/* Headings need their own leading. Only h1 had one, so every other heading
   inherited the body's 1.6 — a 1.9rem h2 was setting ~49px between lines, which
   left any wrapped heading looking airy and detached from the .section-sub
   directly beneath it. text-wrap:balance guarantees two-line headings, so this
   was visible on most of them. */
/* One heading-to-body gap, everywhere. It used to be 8px by default and then
   12, 14, 16 and 20 in .case-offerings, .contact, .about-body and
   .service-proof — five values for one relationship, which is why sections
   that were otherwise identical did not line up with each other. Sections
   with a .section-sub get their larger gap from that element instead. */
h2{font-family:var(--font-display);font-size:var(--fs-h2);line-height:1.2;letter-spacing:-.02em;text-wrap:balance;margin:0 0 var(--sp-12);}
/* h3 had no global rule at all, so it fell through to the body stack while h1 and
   h2 ran Space Grotesk. That put the real headings (.card h3, .work-item h3) in a
   different typeface from the fake ones next to them (.work-more-title,
   .proof-card .proof-title), which set the display font explicitly. Same size,
   different face, directly adjacent in the same list. */
h3{font-family:var(--font-display);font-size:var(--fs-h3);line-height:1.3;letter-spacing:-.01em;}
.section-sub{color:var(--muted);margin:0 0 var(--sp-40);font-size:var(--fs-md);}

/* ---- Cards --------------------------------------------------------------- */
/* Gradient border + HUD reticle brackets + a straight lift.
   No 3D tilt here, deliberately: rotating a text-dense card forces the browser
   to rasterise the layer and transform it, which softens small type and made the
   copy hard to read on hover. The tilt moved to the game tiles, where the content
   is imagery and the effect costs nothing in legibility. */
.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-20);}
/* Four services rather than three, so a 2x2 reads better than a squeezed row of
   four. Falls to a single column at the same breakpoint as .cards. */
.cards-4{grid-template-columns:repeat(2,1fr);}
@media(max-width:820px){
  .cards,
  .cards-4{grid-template-columns:1fr;}
}
/* Gradient border via background-clip, not a masked pseudo-element.
   Two background layers: the card fill clipped to the padding box, and the
   gradient clipped to the border box underneath it. The 1px border sits over the
   gradient and hides it until hover, where border-color fades to transparent and
   lets it through. border-color interpolates, so the reveal is still a smooth
   crossfade — and because this is ordinary background/border painting rather than
   mask compositing, the rounded corners anti-alias properly. The earlier
   mask-composite ring was visibly jagged on the corner arc at 1px. */
.card{
  background:
    linear-gradient(var(--card),var(--card)) padding-box,
    linear-gradient(130deg,var(--accent),var(--accent-2)) border-box;
  border:1px solid var(--line);border-radius:var(--radius-lg);padding:var(--card-pad);position:relative;
  transition:border-color var(--dur) ease,transform var(--dur) var(--ease-out),box-shadow var(--dur) ease;
}
.card:hover{border-color:transparent;transform:translateY(var(--card-lift));box-shadow:var(--card-shadow);}
/* Brackets drawn as four 1-D gradients whose length animates via --bracket. */
.card::after{content:"";position:absolute;inset:10px;pointer-events:none;--bracket:0px;opacity:0;background:linear-gradient(var(--accent-2),var(--accent-2)) 0 0/var(--bracket) 2px no-repeat,linear-gradient(var(--accent-2),var(--accent-2)) 0 0/2px var(--bracket) no-repeat,linear-gradient(var(--accent-2),var(--accent-2)) 100% 100%/var(--bracket) 2px no-repeat,linear-gradient(var(--accent-2),var(--accent-2)) 100% 100%/2px var(--bracket) no-repeat;transition:--bracket var(--dur) var(--ease-out),opacity var(--dur) ease;}
.card:hover::after{--bracket:24px;opacity:1;}
.card h3{margin:0 0 var(--sp-8);}
.card p{margin:0;color:var(--ink-soft);font-size:var(--fs-body);}
.card .tag{font-family:var(--font-mono);font-size:var(--fs-micro);font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--accent);}
/* Service cards end on a link to their own page. Pushed to the bottom so the
   links line up across a row whose copy runs to different lengths. */
.card{display:flex;flex-direction:column;}
.card .card-link{margin:auto 0 0;padding-top:var(--sp-12);font-size:var(--fs-sm);font-weight:600;}
.card .card-link a{color:var(--accent);}
/* The whole card is the target, not just the link at the bottom — a card that
   lifts and glows on hover reads as clickable, and making the visitor find the
   small link to act on it is a needless bit of aim.
   Done with a stretched pseudo-element rather than by wrapping the card in an
   <a>: there stays exactly one link with real text for a screen reader to
   announce, the body copy stays selectable, and no heading ends up nested inside
   an anchor. .card::after is already taken by the hover brackets, hence hanging
   it off the link. */
.card:has(.card-link){cursor:pointer;}
/* No position on the anchor itself — inset:0 resolves against the nearest
   positioned ancestor, so positioning the link would shrink the overlay back to
   the link's own box and leave the card unclickable. .card is position:relative,
   which is what this is meant to fill. */
.card .card-link a::after{content:"";position:absolute;inset:0;border-radius:var(--radius-lg);}
/* Honest labelling on the /games service menu: three of the four are sellable at
   senior level today and one is deliberately growing. Violet marks the growing
   one rather than hiding it — saying so is the credibility, not a weakness. */
.card-ready .tag{color:var(--accent);}
.card-growing .tag{color:var(--accent-2-text);}

/* ---- Generated cover art ------------------------------------------------- */
/* Emitted by App\Service\CoverArtGenerator as classed <polygon>/<line> with no
   colour of its own, so the palette lives here and nowhere else. Containers set
   --art-accent: gold for work (professional), violet for games (play). */
.art{display:block;width:100%;height:100%;}
.art-top{fill:var(--art-accent);}
.art-right{fill:color-mix(in oklab,var(--art-accent) 58%,var(--bg));}
.art-left{fill:color-mix(in oklab,var(--art-accent) 34%,var(--bg));}
.art-grid line{stroke:color-mix(in oklab,var(--art-accent) 22%,transparent);stroke-width:1;}

/* ---- Work slabs ---------------------------------------------------------- */
/* Two-column media/copy slabs, alternating sides, instead of the old CV-style
   flex row. --art-accent stays gold here: work is the professional track. */
/* One slab component, used by both Selected work and Games. The accent is a
   variable so the games section can run violet without duplicating the layout.
   Generated art is muted well down from the full accent: at full strength it read
   as a bright graphic next to real screenshots. */
.work-item{
  --slab-accent:var(--accent);
  --slab-ink:var(--accent);
  --art-accent:color-mix(in oklab,var(--accent) 58%,var(--bg));
  display:grid;grid-template-columns:1.05fr 1fr;gap:var(--sp-40);align-items:center;
  padding:var(--sp-40) 0;border-bottom:1px solid var(--line);
}
/* The dividers separate one slab from the next, so the last slab in the list has
   nothing to divide itself from. :last-child alone was not enough on the home
   page, where .work-more follows the slabs — the last slab kept its rule and the
   panel drew its own above it, which showed up as two horizontal lines four
   pixels apart. */
.work-item:last-child,
.work-item:has(+ .work-more){border-bottom:none;}
.work-item--flip .work-media{order:2;}
.work-media{display:block;aspect-ratio:16/9;border-radius:var(--radius-lg);border:1px solid var(--line);overflow:hidden;background:var(--card);position:relative;transition:transform var(--dur-slow) var(--ease-pop),box-shadow var(--dur-slow) ease,border-color var(--dur) ease;}
/* border-radius on the image itself, not just the clipping parent. Chromium's
   anti-aliasing on an ancestor's rounded clip is poor and left visibly
   stair-stepped corners on every photo; rounding the image in its own paint
   fixes it. Same reason it is repeated on the other clipped images below. */
.work-media img{display:block;width:100%;height:100%;object-fit:cover;border-radius:inherit;filter:grayscale(.85) contrast(1.06);transition:filter var(--dur-slow) ease;}
.work-item:hover .work-media{transform:translateY(var(--card-lift));border-color:color-mix(in oklab,var(--slab-accent) 45%,var(--line));box-shadow:0 18px 40px -20px color-mix(in oklab,var(--slab-accent) 32%,transparent);}
.work-item:hover .work-media img{filter:none;}
.work-item h3{margin:0 0 var(--sp-8);}
.work-item p{margin:0;color:var(--ink-soft);}
.work-item .meta{font-family:var(--font-mono);font-size:var(--fs-micro);color:var(--muted);font-weight:600;letter-spacing:.02em;text-transform:uppercase;margin-bottom:var(--sp-8);}
.work-item .work-links a{color:var(--slab-ink);}
.stack{margin-top:var(--sp-12);display:flex;flex-wrap:wrap;gap:var(--sp-8);}
/* --i comes from the Twig loop index, so the entrance stagger costs no JS. */
.stack span{font-family:var(--font-mono);font-size:var(--fs-micro);color:var(--ink-soft);border:1px solid var(--line);border-radius:var(--radius-sm);padding:var(--sp-4) var(--sp-8);background:var(--bg);animation:rp-pop var(--dur-slow) var(--ease-pop) both;animation-delay:calc(var(--i,0) * 45ms);}
@keyframes rp-pop{from{opacity:0;scale:.88;}}
/* padding-block lifts these from 23px to 31px tall. At 23px they failed WCAG 2.2
   SC 2.5.8, which wants a 24x24 minimum target. */
.work-links{margin-top:var(--sp-12);font-size:var(--fs-xs);display:flex;gap:var(--sp-16);flex-wrap:wrap;}
.work-links a{padding-block:var(--sp-4);}
/* Single column below the slab breakpoint. order:1 undoes the flip so the media
   always leads, otherwise every other card would read copy-then-image. */
@media(max-width:820px){
  .work-item{grid-template-columns:1fr;gap:var(--sp-20);}
  .work-item--flip .work-media{order:1;}
}
/* ---- About --------------------------------------------------------------- */
/* Editorial two-column: a portrait-format framed photo against the bio plus a
   facts list. The photo was a 210px gold-ringed circle, which read as an avatar
   chip rather than as a portrait, and the section was one dense paragraph with
   nothing to anchor it. */
.about p{font-size:var(--fs-lg);color:var(--ink-soft);max-width:62ch;}
/* Copy first, portrait right — the heading then sits above the text it introduces
   rather than above the photograph. */
/* No margin-top:the heading is inside the grid now, so there is nothing above it
   to space away from. */
.about-inner{display:grid;grid-template-columns:1fr 280px;gap:var(--sp-48);align-items:center;}
.about-body h2{margin:0;}
.about-body p{margin:0;}
/* Stylized in CSS rather than baked into the file, so the photo stays swappable:
   desaturate and warm it toward the gold accent. Full colour on hover. */
.about-photo{position:relative;width:100%;aspect-ratio:4/5;border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--line);}
.about-photo img{display:block;width:100%;height:100%;object-fit:cover;object-position:center 20%;border-radius:inherit;filter:grayscale(1) contrast(1.08) brightness(.95);transition:filter var(--dur) ease;}
.about-photo::after{content:"";position:absolute;inset:0;background:var(--accent);mix-blend-mode:overlay;opacity:.10;pointer-events:none;transition:opacity var(--dur) ease;}
.about-photo:hover img{filter:none;}
.about-photo:hover::after{opacity:0;}
@media(prefers-reduced-motion:reduce){.about-photo img,.about-photo::after{transition:none;}}

@media(max-width:820px){
  .about-inner{grid-template-columns:1fr;gap:var(--sp-24);}
  /* Photo leads on narrow screens so the section does not open on a wall of text. */
  .about-photo{order:-1;max-width:220px;}
}
/* ---- Shared accent edge -------------------------------------------------- */
/* One treatment for every coloured left edge on the site. Previously the
   testimonial cards used border-left:3px and the portrait used a pseudo-element
   bar, which read as two different devices — and a thick coloured border tapers
   into a wedge where it meets the thin border at a rounded corner. A bar with its
   own matching corner radius avoids that and stays identical everywhere. */
.quote,
.about-photo,
.prose blockquote{position:relative;}
.quote::before,
.about-photo::before,
.prose blockquote::before{
  content:"";position:absolute;left:0;top:0;bottom:0;width:3px;z-index:1;
  background:linear-gradient(180deg,var(--accent),var(--accent-2));
  border-radius:var(--radius-lg) 0 0 var(--radius-lg);
}

.prose{max-width:70ch;color:var(--ink-soft);}
.prose h2{color:var(--ink);margin-top:2em;}
.prose h3{color:var(--ink);}
.prose blockquote{margin:1.7em 0;padding:var(--sp-16) var(--sp-20) var(--sp-16) var(--sp-24);background:var(--card);border:1px solid var(--line);border-radius:var(--radius-lg);}
.prose blockquote > :first-child{margin-top:0;}
.prose blockquote > :last-child{margin-bottom:0;}
.prose pre{background:#0c1315;border:1px solid var(--line);border-radius:var(--radius-md);padding:var(--sp-16);overflow:auto;}
/* The one font-size on the site that is not a scale token, and deliberately so:
   inline code has to track whatever type it is embedded in, so it is relative to
   its parent rather than absolute. */
.prose code{background:#0c1315;border:1px solid var(--line);border-radius:var(--radius-sm);padding:1px var(--sp-4);font-size:.9em;}
.prose pre code{border:0;padding:0;}
/* ---- Games handoff, at the end of the work list --------------------------- */
/* This started as a full-bleed yellow banner plus a slab gallery sitting third
   on the page, then became a card at the foot of it, then a bare row. It stays
   at the end of Selected work — which is honest, the games are work — so the
   visitor most likely to want it meets it exactly where they are already
   reading. But as a row it was three lines of unstyled text under three large
   media slabs, and it read as a footnote to the last case study rather than as
   the way into a second body of work, so it is a surface again: violet-tinted
   fill, violet border, and the same lift and glow every other card on the site
   answers a hover with.
   Violet throughout, because it is a different practice rather than a fourth
   case study. */
.work-more{
  display:flex;align-items:center;justify-content:space-between;gap:var(--sp-16) var(--sp-28);flex-wrap:wrap;
  margin-top:var(--sp-40);padding:var(--card-pad);
  /* Tinted toward violet rather than filled with it: at any real saturation this
     panel outweighed the case studies above it, which is the wrong order. */
  background:linear-gradient(120deg,color-mix(in oklab,var(--accent-2) 10%,var(--card)),var(--card) 65%);
  border:1px solid color-mix(in oklab,var(--accent-2) 32%,var(--line));
  border-radius:var(--radius-lg);
  color:var(--ink);
  transition:border-color var(--dur) ease,transform var(--dur) var(--ease-out),box-shadow var(--dur) ease;
}
.work-more:hover{
  border-color:color-mix(in oklab,var(--accent-2) 62%,var(--line));
  transform:translateY(var(--card-lift));
  box-shadow:var(--card-shadow);
}
/* Same mono micro-label as .card .tag, in violet — it names the panel as a
   different practice before the title has to. */
.work-more-tag{
  display:block;margin-bottom:var(--sp-8);
  font-family:var(--font-mono);font-size:var(--fs-micro);font-weight:600;
  letter-spacing:.04em;text-transform:uppercase;color:var(--accent-2-text);
}
.work-more-title{
  display:block;font-family:var(--font-display);font-size:var(--fs-h3);font-weight:700;
  transition:color var(--dur) ease;
}
.work-more:hover .work-more-title{color:var(--accent-2-text);}
.work-more-sub{display:block;margin-top:var(--sp-8);color:var(--ink-soft);max-width:58ch;}
.work-more-go{
  flex:none;color:var(--accent-2-text);font-weight:600;font-size:var(--fs-sm);
  display:inline-flex;align-items:center;gap:var(--sp-8);
}
/* The arrow travels on hover, so the direction is felt as well as read. */
.work-more-go span{transition:transform var(--dur) var(--ease-out);}
.work-more:hover .work-more-go span{transform:translateX(4px);}
@media(max-width:640px){
  .work-more{gap:var(--sp-12);}
}

/* ---- Games landing page -------------------------------------------------- */
/* Violet-led, because this page belongs entirely to the games practice. Shorter
   than the home hero — there is no animated field behind it and no reason to
   hold the fold. */
.games-hero{padding:var(--sp-72) 0 var(--sp-56);}
.games-hero h1{max-width:20ch;}
.games-hero .lede + .lede{margin-top:-14px;}
/* The kicker is violet here rather than gold: on this page the play half of the
   palette leads. Same component, different accent. */
.kicker-alt{color:var(--accent-2-text);background:color-mix(in oklab,var(--accent-2) 10%,transparent);border-color:color-mix(in oklab,var(--accent-2) 26%,var(--line));}
.games-note{
  margin:var(--sp-20) 0 0;padding-left:var(--sp-12);max-width:56ch;
  border-left:2px solid color-mix(in oklab,var(--accent-2) 50%,transparent);
  color:var(--muted);font-size:var(--fs-sm);
}
/* Stated, not buried: an unevidenced engine claim would undo the page. */
.games-caveat{margin:var(--sp-32) 0 0;color:var(--muted);font-size:var(--fs-sm);max-width:64ch;}
/* Slabs run violet here, matching the case-study pages they link to. */
#game-work .work-item{
  --slab-accent:var(--accent-2);
  --slab-ink:var(--accent-2-text);
  --art-accent:color-mix(in oklab,var(--accent-2) 58%,var(--bg));
}

/* ---- Case studies (work and games) -------------------------------------- */
/* One set of rules for both kinds of case study. --case-accent drives the border
   and --art-accent the generated fallback art, so work runs gold and games violet
   off identical markup. */
.work-study{--case-accent:var(--accent);--art-accent:color-mix(in oklab,var(--accent) 58%,var(--bg));}
.game-study{--case-accent:var(--accent-2);--art-accent:color-mix(in oklab,var(--accent-2) 58%,var(--bg));}

/* ---- Service pages ------------------------------------------------------- */
/* Text-led: these pages exist to answer a commercial search and to be sent to a
   prospect, so they carry no hero artwork — the evidence at the foot is the
   visual payload. */
.service-study{--case-accent:var(--accent);}
.service-study h1{max-width:26ch;font-size:var(--fs-h1-sm);}
.service-study .lede{margin-bottom:var(--sp-24);max-width:70ch;font-size:var(--fs-lede);}
/* Wider and a step larger than the case-study prose. .prose is 70ch, tuned for
   long-form reading, which on a 1080px container left a ~630px column of text
   against 400px of nothing — it read as narrow rather than as comfortable.
   These are sales pages people skim, not articles they settle into, so the
   column earns more of the width and the type is sized to match. */
.service-study .prose{max-width:82ch;font-size:var(--fs-md);}
.service-study .prose li{margin-bottom:var(--sp-8);}
.service-proof{margin-top:var(--sp-56);padding-top:var(--sp-8);border-top:1px solid var(--line);}
.service-proof h2{margin:var(--sp-24) 0 var(--sp-12);}
.proof-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:var(--sp-20);}
/* Whole card is the link — a card with a link in it gives keyboard users a tab
   stop that lands on text while the surrounding surface is what looks clickable. */
.proof-card{
  display:flex;flex-direction:column;gap:var(--sp-8);
  background:var(--card);border:1px solid var(--line);border-radius:var(--radius-lg);
  padding:var(--card-pad);color:var(--ink);
  transition:border-color var(--dur) ease,transform var(--dur) var(--ease-out),box-shadow var(--dur) ease;
}
.proof-card:hover{
  color:var(--ink);border-color:color-mix(in oklab,var(--accent) 42%,var(--line));
  transform:translateY(var(--card-lift));box-shadow:var(--card-shadow);
}
.proof-card .meta{font-family:var(--font-mono);font-size:var(--fs-micro);color:var(--muted);text-transform:uppercase;letter-spacing:.04em;}
.proof-card .proof-title{font-family:var(--font-display);font-size:var(--fs-lg);font-weight:700;}
.proof-card .proof-summary{color:var(--ink-soft);font-size:var(--fs-sm);}
.proof-card .proof-more{margin-top:var(--sp-4);color:var(--accent);font-size:var(--fs-xs);font-weight:600;}

/* "Work like this" at the foot of a case study — the internal link up to the
   service pages. Deliberately plain: it is a next step, not a second CTA
   competing with the buttons at the top of the page. */
.case-offerings{margin-top:var(--sp-48);padding-top:var(--sp-24);border-top:1px solid var(--line);}
/* The one place --fs-h2-sub earns its keep: this block really is subordinate to
   the case study above it, so it reads a step down rather than as a rival
   section. Every other h2 on a page is a true sibling section and stays at
   --fs-h2 — the service pages used to run their own body headings at 1.5rem
   while the appendix beneath them sat at 1.9rem, which inverted the hierarchy. */
.case-offerings h2{font-size:var(--fs-h2-sub);}
.case-offerings ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:var(--sp-8) var(--sp-24);}
.case-offerings a{font-weight:600;font-size:var(--fs-body);}

/* Back control. The arrow slides left on hover so the direction is felt as well as
   read; it is aria-hidden in the markup, so "Back" is what gets announced. */
.back-link{display:inline-flex;align-items:center;gap:var(--sp-8);font-family:var(--font-mono);font-size:var(--fs-micro);font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:var(--sp-24);}
.back-link span{transition:transform var(--dur) var(--ease-out);}
.back-link:hover{color:var(--accent-2-text);}
.back-link:hover span{transform:translateX(-4px);}

/* Case-study call to action, sitting between the overview and the artwork. Scaled
   up a step from a standard button: this is the one thing on the page a visitor is
   most likely to want, so it should outweigh the body type around it. */
.case-actions{margin:var(--sp-4) 0 var(--sp-28);display:flex;flex-wrap:wrap;gap:var(--sp-12);}

/* Full colour here: on the detail page the artwork is the main visual, not a
   thumbnail whose colour is teased in on hover. */
.case-hero{margin:var(--sp-28) 0 var(--sp-4);border-radius:var(--radius-lg);overflow:hidden;border:1px solid color-mix(in oklab,var(--case-accent) 30%,var(--line));background:var(--card);}
.case-hero img{display:block;width:100%;height:auto;border-radius:inherit;}
/* Generated fallback art has no intrinsic height, so give it the same 16:9 the
   real artwork uses. */
.case-hero svg.art{display:block;width:100%;aspect-ratio:16/9;border-radius:inherit;}

.case-meta{margin:var(--sp-24) 0 var(--sp-32);display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:var(--sp-16) var(--sp-28);}
.case-meta div{border-top:1px solid var(--line);padding-top:var(--sp-12);}
.case-meta dt{font-family:var(--font-mono);font-size:var(--fs-micro);font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);}
.case-meta dd{margin:var(--sp-4) 0 0;color:var(--ink);font-size:var(--fs-body);}
.post-list{list-style:none;padding:0;margin:0;}
.post-list li{padding:var(--sp-20) 0;border-bottom:1px solid var(--line);}
.post-list a{color:var(--ink);font-size:var(--fs-h3);font-weight:600;}
.post-meta{font-family:var(--font-mono);color:var(--muted);font-size:var(--fs-xs);margin-top:var(--sp-4);}
/* ---- Testimonials -------------------------------------------------------- */
/* No carousel. Every quote is on the page, flowed into CSS multi-column so the
   varying lengths pack tightly instead of leaving the ragged gaps an equal-height
   grid would. Nothing is hidden behind a swipe and it needs no JS.
   Column order is vertical-then-horizontal, which is fine here: the quotes have
   no meaningful sequence. */
/* Faint violet dot lattice over the alt band, so this section is not just
   another flat grey panel. Subtle enough not to fight the quotes.
   The band itself comes from the section-layout rules near the top, which treat
   .testimonials as an alternating band in its own right: it always follows a
   section on the default background — Services on the home page, the study on a
   service page — and the dots alone were far too quiet to mark where one ended
   and the next began. Anything carrying .section-alt next to this has to give it
   up, or the alternation collapses. */
.testimonials{background-image:radial-gradient(color-mix(in oklab,var(--accent-2) 11%,transparent) 1px,transparent 1px);background-size:24px 24px;}
.testimonials .quotes{margin-top:var(--sp-28);columns:3;column-gap:var(--sp-20);}
@media(max-width:1024px){.testimonials .quotes{columns:2;}}
@media(max-width:640px){.testimonials .quotes{columns:1;}}

/* Where only the one or two quotes tagged for a page are shown, multi-column is
   the wrong tool: with two items it either leaves a column empty or splits one
   quote across a break. A flex row of equal cards keeps them side by side and
   collapses to a stack on narrow screens. */
.testimonials .quotes-row{columns:auto;display:flex;flex-wrap:wrap;gap:var(--sp-20);}
.testimonials .quotes-row .quote{flex:1 1 320px;margin:0;}
@media(max-width:640px){.testimonials .quotes-row{flex-direction:column;}}

/* Quote, then attribution. Left-aligned: centred multi-line prose is harder to
   scan, and these run several lines. The coloured edge comes from the shared
   .accent-edge treatment further down, not a border-left. */
.testimonials .quote{
  break-inside:avoid;-webkit-column-break-inside:avoid;
  margin:0 0 var(--sp-20);background:var(--card);border:1px solid var(--line);
  border-radius:var(--radius-lg);
  /* Extra left padding clears the shared accent edge, which is 3px wide. */
  padding:var(--card-pad) var(--card-pad) var(--card-pad) calc(var(--card-pad) + var(--sp-4));
  display:flex;flex-direction:column;gap:var(--sp-16);text-align:left;
  transition:border-color var(--dur) ease,box-shadow var(--dur) ease;
}
/* These were the one card with no hover response at all, which read as the set
   being inconsistent rather than as the quotes being deliberately inert. No
   lift: they sit in a multi-column flow where shifting one card nudges the
   column reflow. Border and glow only. */
.testimonials .quote:hover{
  border-color:color-mix(in oklab,var(--accent-2) 38%,var(--line));
  box-shadow:var(--card-shadow);
}
.testimonials blockquote{margin:0;color:var(--ink-soft);font-size:var(--fs-body);}
.testimonials figcaption{display:flex;align-items:center;gap:var(--sp-12);font-weight:600;color:var(--ink);}
.testimonials .quote-avatar{width:42px;height:42px;border-radius:50%;object-fit:cover;flex:none;}
.testimonials .quote-name{font-family:var(--font-display);font-size:var(--fs-sm);}

/* No collapse. The list used to cut off at five behind a "show all" button; the
   quotes are the strongest third-party evidence on the site and three columns
   hold all ten without the section running away with the page, so hiding half of
   them bought nothing. The rules that did it, js/testimonials.js and the button
   are all gone with it. */
/* ---- Icons --------------------------------------------------------------- */
/* Sized in em so an icon always tracks the type it sits beside, and coloured by
   currentColor so it follows the link's hover state for free. */
.icon{width:1.05em;height:1.05em;flex:none;vertical-align:-.15em;}
.icon-stroke{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
.icon-fill{fill:currentColor;stroke:none;}
/* Any link carrying an icon becomes a flex row so the glyph and label stay on a
   shared baseline and never split across a wrap. */
.contact-links a,.work-links a{display:inline-flex;align-items:center;gap:var(--sp-8);}

/* The closing band. About and Get in touch were both on the default background
   with only the boundary hairline between them, so on every page that ends with
   this pair — home, /games, each service page — two sections read as one long
   run of centred type.

   Not --bg-alt, which is what the alternation would otherwise reach for: the
   footer is already --bg-alt, so an alt contact section would fix the seam above
   it by creating an identical one below it. A violet wash rising off the top
   edge instead. It differs from the flat panel on either side, and it is the one
   place on the page where the accent's stated job — violet for interaction, see
   the token block up top — is literally what the section is for.

   Linear rather than a radial glow centred on the section. A radial is the
   prettier shape, but it is weakest at its own left and right edges — which is
   where the seam still has to read — so it would mark the boundary in the middle
   and leave it invisible at either end. That is the opposite of a band. Straight
   down gives the top edge one even wash across the full bleed.

   Gone by 55%, so the form below sits on the plain background it needs. 10% is a
   far bigger step than the four-to-eight points between --bg and --bg-alt, and
   deliberately so: it carries this boundary alone, without the inset shadow that
   helps the alternating bands read. */
.contact{
  text-align:center;
  background-image:linear-gradient(to bottom,color-mix(in oklab,var(--accent-2) 10%,transparent),transparent 55%);
}
.contact p{color:var(--ink-soft);font-size:var(--fs-lg);margin:0 auto var(--sp-28);max-width:52ch;}
.contact-links{display:flex;gap:var(--sp-20);justify-content:center;flex-wrap:wrap;font-weight:600;}

/* ---- Contact form -------------------------------------------------------- */
/* Left-aligned inside a centred section: labels and error text are read, not
   scanned, and centred form fields are hard to follow down the page. */
.contact-form{max-width:620px;margin:0 auto var(--sp-32);text-align:left;}
.field{margin-bottom:var(--sp-16);}
/* Name and email share a row on desktop — they are short and related — and stack
   below the breakpoint where two columns would squeeze both. */
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-16);}
@media(max-width:640px){.field-row{grid-template-columns:1fr;gap:0;}}

.contact-form label{
  display:block;margin-bottom:var(--sp-8);
  font-family:var(--font-mono);font-size:var(--fs-micro);font-weight:600;
  letter-spacing:.07em;text-transform:uppercase;color:var(--muted);
}
.contact-form input[type="text"],
.contact-form input[type="email"],
.contact-form textarea{
  width:100%;padding:var(--sp-12) var(--sp-16);
  background:var(--bg);color:var(--ink);
  border:1px solid var(--line);border-radius:var(--radius-md);
  font:inherit;font-size:var(--fs-body);
  transition:border-color var(--dur) ease,box-shadow var(--dur) ease;
}
.contact-form textarea{resize:vertical;min-height:140px;}
.contact-form input::placeholder,
.contact-form textarea::placeholder{color:var(--muted);}
.contact-form input:focus,
.contact-form textarea:focus{
  outline:none;border-color:var(--accent-2);
  box-shadow:0 0 0 3px color-mix(in oklab,var(--accent-2) 22%,transparent);
}
.contact-form button[type="submit"]{border:0;cursor:pointer;font-family:inherit;}

/* Field-level errors. Symfony renders these as a <ul>, so the bullets go. */
.contact-form ul{list-style:none;margin:var(--sp-8) 0 0;padding:0;}
.contact-form ul li{color:#fca5a5;font-size:var(--fs-xs);}
/* The field itself is marked too, so the error is findable without reading. */
.contact-form .field:has(li) input,
.contact-form .field:has(li) textarea{border-color:#f87171;}

/* Form-level notices: the flash after a redirect, and the summary on failure. */
.form-note{
  max-width:620px;margin:0 auto var(--sp-20);padding:var(--sp-12) var(--sp-16);
  border-radius:var(--radius-md);border:1px solid var(--line);
  font-size:var(--fs-body);text-align:left;
}
.form-note--ok{
  border-color:color-mix(in oklab,var(--accent) 45%,var(--line));
  background:rgba(250,204,21,.08);color:var(--ink);
}
.form-note--bad{border-color:#f87171;background:rgba(248,113,113,.09);color:#fecaca;}

.contact-alt{font-size:var(--fs-body);margin-bottom:var(--sp-16);color:var(--muted);}

/* The honeypot. Not display:none and not hidden — both are the first thing a bot
   discards. It is pulled out of the layout and out of the accessibility tree
   (aria-hidden and tabindex=-1 are on the markup), so a person never sees it,
   never tabs into it and never has it announced, while a bot filling every input
   it can parse fills it and fails the Blank constraint. */
.contact-form .hp{
  position:absolute;left:-9999px;top:auto;
  width:1px;height:1px;overflow:hidden;
}
/* ---- Footer -------------------------------------------------------------- */
/* Was a single centred copyright line — a dead end on every page, and the one
   internal-linking surface that appears site-wide going unused. The service
   pages are the ones built to rank, so they are named here on every page. */
footer{border-top:1px solid var(--line);padding:var(--sp-48) 0 var(--sp-32);color:var(--muted);font-size:var(--fs-xs);background:var(--bg-alt);}
.footer-nav{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-32);}
/* A caption, not a heading. These were <h2>, which made the smallest text on
   every page share a semantic level with "Selected work" — and put three of
   them at the foot of every document outline. The <nav aria-label="Footer">
   already names the region, so nothing is lost by demoting them. */
.footer-col-label{
  font-family:var(--font-mono);font-size:var(--fs-micro);font-weight:600;
  letter-spacing:.09em;text-transform:uppercase;color:var(--muted);
  margin:0 0 var(--sp-12);
}
.footer-col ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--sp-8);}
.footer-col a{color:var(--ink-soft);display:inline-flex;align-items:center;gap:var(--sp-8);}
.footer-col a:hover{color:var(--accent-2-text);}
.footer-legal{
  margin-top:var(--sp-40);padding-top:var(--sp-20);border-top:1px solid var(--line);
  text-align:center;
}
@media(max-width:640px){
  .footer-nav{grid-template-columns:1fr;gap:var(--sp-28);}
  footer{padding:var(--sp-40) 0 var(--sp-28);}
}

/* ---- Scroll progress bar ------------------------------------------------- */
/* Pure CSS, no JS. Hidden outright where scroll timelines are unsupported,
   rather than sitting there as a permanently-full bar. */
.progress{position:fixed;top:0;left:0;z-index:30;height:2px;width:100%;transform-origin:0 50%;background:linear-gradient(90deg,var(--accent),var(--accent-2));}
@supports not (animation-timeline:scroll()){.progress{display:none;}}
@supports (animation-timeline:scroll()){
  .progress{animation:rp-grow linear both;animation-timeline:scroll(root);}
  @keyframes rp-grow{from{transform:scaleX(0);}to{transform:scaleX(1);}}
}

/* ---- Scroll reveals ------------------------------------------------------ */
/* Scroll-driven CSS animations: Chrome/Edge 115+, Safari 26+. Firefox does not
   ship animation-timeline by default yet, so this is gated behind @supports and
   the un-animated state is the visible default — nothing is ever stranded at
   opacity:0.

   Note the reveal animates the independent `translate` property, not
   `transform`. `transform` is already spoken for by the card tilt and the chip
   hover, and an animation with fill:both would otherwise pin those to their
   final keyframe value forever. */
@media (prefers-reduced-motion:no-preference){
  @supports (animation-timeline:view()){
    .card,.work-item,.work-more,.testimonials .quote,.about-inner,.post-list li,
    section > .wrap > h2,section > .wrap > .section-sub{
      animation:rp-rise linear both;
      animation-timeline:view();
      animation-range:entry 6% cover 30%;
    }
    @keyframes rp-rise{from{opacity:0;translate:0 26px;}}
  }
}

/* Fallback for browsers without scroll-driven animations (Firefox), driven by
   js/reveal.js. .reveal-js is set by an inline snippet in <head>, so it is only
   ever present when scripting is on AND the native path is unavailable — the two
   never both apply, and no-JS visitors never land on opacity:0. */
html.reveal-js .card,
html.reveal-js .work-item,
html.reveal-js .work-more,
html.reveal-js .testimonials .quote,
html.reveal-js .about-inner,
html.reveal-js .post-list li,
html.reveal-js section > .wrap > h2,
html.reveal-js section > .wrap > .section-sub{
  opacity:0;translate:0 26px;
  transition:opacity var(--dur-slow) ease,translate var(--dur-slow) var(--ease-out);
}
html.reveal-js .is-revealed{opacity:1;translate:0 0;}

/* ---- Reduced motion ------------------------------------------------------ */
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important;scroll-behavior:auto !important;}
  .card,.work-more{transform:none !important;}
}
