*{box-sizing:border-box}
html{scroll-behavior:smooth;overflow-x:hidden}
body{margin:0}

/* ── Design tokens ───────────────────────────────────────────────────────────
   Repainted 2026-08-31, migrating the "Practice" direction onto the live site.

   TOKEN NAMES ARE DELIBERATELY UNCHANGED. Only the values move. Practice calls
   these --ground/--ink/--rule; renaming them here would mean touching all 1320
   lines of this file and every var() in five pages to achieve exactly nothing
   visible. The mapping, for anyone reading Practice alongside this:
       --bg = ground   --text = ink   --line = rule   --line-soft = rule-soft

   THE ONE STRUCTURAL CHANGE: --surface now sits ABOVE --bg rather than below.
   The old scheme sank panels a step darker than the page, which meant every
   panel needed a border to define its edge. Lifting them instead lets a panel
   read as raised with no border at all, which is the borderless preference this
   project has landed on twice. --shot inverts with it, for the same reason.

   Radius collapses from a 4-step scale to effectively one value. The documented
   exception is --r-pill, kept for the two things that are genuinely circular
   (the theme toggle, the roster tiles) rather than for pill-shaped buttons,
   of which there are none.

   Every colour below is measured with scripts/contrast.mjs, ratio in the
   comment. Nothing here was chosen by eye.
   ──────────────────────────────────────────────────────────────────────────── */
:root, [data-theme="light"]{
  /* color-scheme is what tells the BROWSER which way this page leans, as
     opposed to --bg, which only tells our own CSS. Without it the dark theme
     still gets a light scrollbar, light form controls and a white flash behind
     the canvas during navigation, because the UA has no way to know. It is
     declared per theme rather than as `light dark` so an explicit data-theme
     choice wins over the OS preference — same precedence the rest of the
     theming already follows. */
  color-scheme:light;
  --bg:#ECEEEA;                   /* the ground: a soft pale board, not white */
  --text:#15181A;                 /* 15.27:1 on --bg · 17.33:1 on --surface */
  --muted:#5B6067;                /*  5.43:1 on --bg ·  6.16:1 on --surface */
  --line:rgba(21,24,26,0.14);
  --line-soft:rgba(21,24,26,0.07);
  --surface:#FBFCFA;              /* LIFTS above --bg. See the note above. */
  --shot:rgba(255,255,255,0.72);  /* image wells lift too, rather than sinking */
  --shot-stripe:rgba(255,255,255,0.55);
  --shot-hover:#FFFFFF;
  --accent:#1D3F8F;               /*  8.33:1 on --bg ·  9.45:1 on --surface */

  --sp-1:4px;   --sp-2:8px;   --sp-3:12px;  --sp-4:16px;  --sp-5:20px;
  --sp-6:24px;  --sp-8:32px;  --sp-10:40px; --sp-11:44px; --sp-12:48px;

  /* One radius. The three old steps all resolve to it; --r-pill survives only
     for the genuinely circular controls. */
  --r-sm:10px; --r-md:10px; --r-lg:10px; --r-pill:999px;
}
[data-theme="dark"]{
  color-scheme:dark;
  --bg:#131619;                   /* same cool cast as the light ground, inverted */
  --text:#E8EAE6;                 /* 15.00:1 on --bg · 13.69:1 on --surface */
  --muted:#9AA0A6;                /*  6.88:1 on --bg ·  6.28:1 on --surface */
  --line:rgba(232,234,230,0.16);
  --line-soft:rgba(232,234,230,0.08);
  --surface:#1B1F23;              /* lifts above --bg, same logic as light */
  --shot:rgba(255,255,255,0.05);
  --shot-stripe:rgba(255,255,255,0.075);
  --shot-hover:rgba(255,255,255,0.09);
  --accent:#93B2F5;               /*  8.58:1 on --bg ·  7.84:1 on --surface.
                                     The light accent #1D3F8F is 1.4:1 here, so
                                     the blue has to be re-picked per theme for
                                     the same reason the old teal did. */
}
/* NOTE: <meta name="theme-color"> is set in the markup and kept in sync by
   site.js when the theme toggles. It cannot live here. */

body{
  /* dvh, not vh: on iOS Safari 100vh is the height WITHOUT the address bar, so
     a 100vh min-height leaves the page a bar's worth too tall on first paint
     and settles as the bar retracts. dvh tracks the live viewport instead. */
  min-height:100dvh;
  background:var(--bg);
  color:var(--text);
  font-family:'Geist',ui-sans-serif,system-ui,sans-serif;
  -webkit-font-smoothing:antialiased;
}
/* The gutter is max(our padding, the notch) rather than the sum: on a phone in
   landscape the safe-area inset can exceed 48px, and adding them would double
   the gutter on exactly the devices with the least width to spare. On every
   device without a notch env() resolves to 0 and the clamp wins unchanged. */
.wrap{
  max-width:1280px;margin:0 auto;
  padding-left:max(clamp(24px,5vw,48px), env(safe-area-inset-left));
  padding-right:max(clamp(24px,5vw,48px), env(safe-area-inset-right));
}
/* Kills the ~300ms tap delay browsers hold in case a second tap makes a
   double-tap zoom. Scoped to things that are actually tapped, so pinch-zoom
   stays available everywhere else — that gesture is an accessibility feature,
   and `touch-action:manipulation` on a scroll container would remove it. */
a, button, summary, [role="button"], input, select, textarea{touch-action:manipulation}

@media (prefers-reduced-motion: reduce){
  html{scroll-behavior:auto}
  *{animation-duration:0.001ms!important;transition-duration:0.001ms!important}
}

/* The cursor follower lived here until 2026-08-31. Removed with the rest of
   the motion layer: a custom cursor is banned outright by the taste skill
   (outdated, accessibility-hostile, perf-hostile) and the .view-inline "View"
   label it needed on touch devices went with it. */

/* ── Header / nav ── */
/* 16/16, not 32/24: the old padding put the header at 100px against the 80px
   cap, and the bar itself is only the 44px link row. 16 + 44 + 16 = 76. */
header.site-header{display:flex;align-items:baseline;justify-content:space-between;gap:24px;padding-top:16px;padding-bottom:16px;flex-wrap:wrap}
header.site-header a.logo{min-height:44px;display:inline-flex;align-items:center;text-decoration:none;color:var(--text);font-family:'Bricolage Grotesque',sans-serif;font-weight:700;font-size:17px;letter-spacing:-0.01em}
header.site-header nav{display:flex;gap:clamp(16px,3vw,40px);align-items:baseline}
/* min-WIDTH as well as min-height: "Work" laid out at 38px wide, so the target
   was 38x44 and short of the 44x44 floor on the narrow axis. Short labels are
   centred in the wider box, so only the two shortest links move at all. */
header.site-header nav a{text-decoration:none;color:var(--text);font-size:15px;font-weight:500;opacity:0.85;min-height:44px;min-width:44px;display:inline-flex;align-items:center;justify-content:center}
header.site-header nav .loc{font-size:12px;color:var(--muted);}
.rule{height:1px;background:var(--line);width:100%}

/* ── Breadcrumb ── */
.breadcrumb{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;margin-top:clamp(28px,5vw,44px)}
/* "Home" is only 31px wide, so min-height alone left a 31x44 target. */
.breadcrumb .bc-link{color:var(--muted);text-decoration:none;min-height:44px;min-width:44px;display:inline-flex;align-items:center;justify-content:center}
.breadcrumb .bc-link:hover{color:var(--text)}
.breadcrumb .bc-current{color:var(--text)}
.breadcrumb .bc-sep{color:var(--line);opacity:0.9}

/* ── Back element ── */
.back-link{display:inline-flex;align-items:center;gap:var(--sp-2);text-decoration:none;color:var(--text);font-size:14px;font-weight:600;margin-top:20px;cursor:pointer;background:none;border:none;padding:var(--sp-3) 0;min-height:44px;font-family:'Geist',ui-sans-serif,system-ui,sans-serif}
.back-link:hover{opacity:0.7}
.back-link .arrow{}

/* ── Case study layout ── */
.cs-hero{padding-top:clamp(16px,3vw,28px);padding-bottom:clamp(40px,7vw,72px)}
.cs-kicker{display:flex;align-items:center;gap:12px;margin:28px 0 24px}
.cs-kicker span.label{font-size:12px;color:var(--muted);}
.cs-kicker .kicker-rule{flex:1;height:1px;background:var(--line);max-width:120px}
.cs-title{margin:0;font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:var(--fit-size, clamp(40px,8vw,104px));line-height:0.96;letter-spacing:-0.03em}
.cs-intro{margin:24px 0 0;font-size:clamp(17px,2vw,21px);line-height:1.55;color:var(--muted);max-width:60ch}

/* ── Layered hero (headline band over the standard background) ── */
.cs-hero-layered{position:relative;padding-top:clamp(12px,2vw,20px);padding-bottom:clamp(12px,2vw,20px)}
.hero-band{position:relative;width:100%;background:var(--shot-hover);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:clamp(28px,5vw,48px) 0}
.hero-band .cs-kicker{margin:0 0 20px}
.hero-headline{margin:0;font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:var(--fit-size, clamp(36px,7vw,88px));line-height:0.98;letter-spacing:-0.03em}
.hero-subhead{margin:var(--sp-5) 0 0;font-size:clamp(16px,1.8vw,20px);line-height:1.5;color:var(--muted);max-width:52ch}

.cs-meta{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;margin-top:clamp(32px,5vw,56px);padding-top:20px;border-top:1px solid var(--line)}
.cs-meta-item{grid-column:span 1;min-width:min(100%,140px)}
.cs-meta-item .k{display:block;font-size:11px;color:var(--muted);margin-bottom:8px}
.cs-meta-item .v{line-height:1.5;font-size:15px;font-weight:500}
@media (max-width:800px){.cs-meta{grid-template-columns:repeat(2,1fr)}}

/* padding-bottom was clamp(8px,2vw,16px) against an 80px top — content hugged
   the bottom of its own section and crowded the next section's rule. */
.cs-section{padding-top:clamp(48px,7vw,80px);padding-bottom:clamp(16px,3vw,32px)}
/* --line, not --text. A full-strength rule above every heading is a stack of
   hard black bars down the page; the control separates sections with a soft
   rule and lets the section padding do the rest. */
/* No rule above the heading. A hairline repeated above all ten section headings
   is decoration, not structure: it never separates two things that needed
   separating, it just draws the same line ten times and gives the page a
   templated rhythm. The section's own top padding (clamp(48px,7vw,80px)) is
   already doing the separating, so the line was carrying nothing. Removed
   2026-09-01 at the user's request, and the taste skill bans exactly this
   ("hairline grid lines as decoration", "border-t on every row").
   padding-top goes with it — it existed to hold the heading off the rule. */
.cs-section-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:clamp(28px,4vw,44px)}
.cs-section-head h2{margin:0;font-family:'Bricolage Grotesque',sans-serif;font-weight:700;font-size:clamp(22px,3vw,32px);letter-spacing:-0.02em}
.cs-section-head .idx{font-size:12px;color:var(--muted);}
.cs-body{margin:0;font-size:clamp(16px,1.6vw,18px);line-height:1.65;color:var(--muted);max-width:64ch}

/* ── In-content links ────────────────────────────────────────────────────────
   Nothing styled these, so every link inside prose fell through to the UA
   default #0000EE / visited #551A8B. On the dark background those measure
   1.99:1 and 1.84:1 — a WCAG failure on the very links that point at the
   shipped product ("dagwood.com.pk", "Dagwood on Google Play").

   :not([class]) rather than a list of parent selectors: every piece of site
   chrome that is a link already carries a class (.logo, .np-link, .bc-link,
   .diary-jump-link, .btn-primary, .f-email), and the header/footer sit outside
   <main>, so this reaches in-content links only and keeps doing so as new
   sections are added. --accent is theme-aware and already contrast-checked
   (5.32:1 light, 7.15:1 dark — and it was darkened from #0F7A74 precisely
   because these links sit on --shot tinted cards, where 4.78:1 fell to 4.38:1).
   The underline carries the affordance so colour is
   not the only signal. ──────────────────────────────────────────────────────── */
main a:not([class]){color:var(--accent);text-decoration:underline;
  text-decoration-thickness:1px;text-underline-offset:3px;
  transition:color .2s ease}
main a:not([class]):hover{color:var(--text)}
main a:not([class]):visited{color:var(--accent)}
@media (prefers-reduced-motion:reduce){main a:not([class]){transition:none}}

/* ── Vertical rhythm ─────────────────────────────────────────────────────────
   .cs-body previously had no margin reset, so it carried the UA default
   `margin-block:1em` (~18px at desktop). That unauthored value sat in every gap
   on the page and collided with every authored one — the numbers in the source
   were never the numbers on screen. Reset above, then drive rhythm from here.

   The governing number: .cs-body is 18px/1.65 = 29.7px of leading. Any gap
   below that makes a paragraph break read as TIGHTER than the space between two
   lines within a paragraph, which is what read as "squished". Every step below
   clears it.

   Paragraph<->media joins were previously 20px, 24px, or a clamp — three values
   for structurally identical boundaries, all scattered as inline styles on the
   markup. They are declared once here instead.
   ──────────────────────────────────────────────────────────────────────────── */
.cs-body + .cs-body{margin-top:var(--sp-8)}                    /* 32px */

.cs-body + .diary-pair,   .cs-body + .tag-grid,
.cs-body + .bento-grid,   .cs-body + .process-timeline,
.cs-body + .brand-grid,   .cs-body + .subsystems,
.cs-body + .diary-carousel,
.diary-pair + .cs-body,   .tag-grid + .cs-body,
.bento-grid + .cs-body,   .process-timeline + .cs-body,
.brand-grid + .cs-body,   .subsystems + .cs-body,
.component-actions + .cs-body,
.diary-pair + .diary-pair,.diary-pair + .tag-grid,
.tag-grid + .diary-pair,  .process-timeline + .diary-pair,
.component-actions + .diary-pair,
/* .flow-chart and .component-mosaic set their own margin-top, so they were not
   caught by the joins above — .flow-chart + .cs-body measured 0px. */
.flow-chart + .cs-body,   .component-mosaic + .cs-body,
.legacy-gallery + .cs-body,
.diary-jump-link + .cs-body,
/* Dagwood section 07 joins onto the same rhythm scale. */
.cs-body + .dag-mark-block, .dag-mark-block + .cs-body,
.cs-body + .dag-type,     .dag-type + .cs-body,
/* Lucrum section 01: the client roster joins below the bento grid. */
.bento-grid + .client-roster, .client-roster + .cs-body,
/* Dagwood section 07 carried these six joins as inline margin-bottom on the
   paragraphs themselves — six copies of one decision, and an inline margin is
   exactly what rule 8 says rhythm must never be. Declared once, here. */
/* .cs-body + .brand-grid and + .dag-type were already declared above; only the
   lockup row was missing one, which is why it alone carried an inline margin. */
.cs-body + .dag-lockups,
/* Bands and twocols join onto the same scale. Without these a paragraph after a
   band measured 0px from the caption above it and read as part of the figure —
   .band sets its own margin-TOP, so nothing was declaring the join below it. */
/* Three pairings were missing and all three measured EXACTLY 0px, so a bento
   grid began at the pixel a caption ended and read as part of the figure above
   it. Rule 8: no gap in the content flow may fall below the body line-height
   (~27px). Found by measuring every adjacent sibling on every page rather than
   by reading the list, which is the only way a missing pair shows up. */
.band + .bento-grid,      .twocol + .bento-grid,
.dag-mark-block + .subsystems,
.band + .cs-body,         .cs-body + .band,
.band + .tag-grid,        .tag-grid + .band,
.band + .flow-chart,      .band + .component-mosaic,
.band + .brand-grid,      .brand-grid + .band,
.band + .process-timeline,.process-timeline + .band,
.band + .subsystems,      .band + .band,
.twocol + .cs-body,       .twocol + .tag-grid,
.tag-grid + .twocol{margin-top:var(--sp-10)}  /* 40px */

/* gap is --sp-8, not 16px. 16px was right between two FILLED cards, where the
   fill drew the boundary; between two bare text columns it is too tight and the
   three read as one block. The fill is what used to separate these, so removing
   it means the gap has to do that work instead. */
.tag-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-6) var(--sp-8)}
/* No fill, no radius, no padding. These were --surface panels; they carried no
   hierarchy the type does not already carry, and a card that communicates no
   hierarchy is the one thing taste 4.4 says a card must never be. Lucrum, which
   was rebuilt from the control, never had them, so the site was doing the same
   job two ways.

   .chip-title bold against .chip-body muted is what replaces the container. The
   hover shadow goes with the fill: these are not interactive, and an inset ring
   appearing on hover implied they were. */
.tag-chip{grid-column:span 1;background:none;border:0;padding:0;
  font-size:14px;font-weight:500;line-height:1.5}
@media (max-width:800px){.tag-grid{grid-template-columns:repeat(2,1fr)}}
/* 3-up variant for the research insight cards. These hold 3-5 sentences each,
   not a short chip, so they stack fully on phones. This was an inline
   grid-template-columns on the element, which beat every media query no matter
   the viewport and left three ~110px columns of two-word lines on a phone.
   These three rules must stay AFTER the .tag-grid rule above so the cascade
   resolves in this order at each width. */
.tag-grid-3{grid-template-columns:repeat(3,1fr)}
@media (max-width:800px){.tag-grid-3{grid-template-columns:repeat(2,1fr)}}
@media (max-width:640px){.tag-grid-3{grid-template-columns:1fr}}

/* ── User flow chart: home screen branching into every module and action.
     Rendered as a real flow chart (Figma export, recolored to a single
     neutral tone per theme, no branch color-coding) rather than a nested
     list, which read too much like a nav dropdown. Wrapped in a horizontal
     scroller with a min-width floor so labels stay legible on narrow
     screens instead of shrinking to illegibility. ── */
.flow-chart{margin-top:var(--sp-10);overflow-x:auto;padding-bottom:8px;-webkit-overflow-scrolling:touch}
/* height:auto is required now that the <img> carries width/height attributes:
   those are presentational hints and would otherwise fight width:100% and
   distort the diagram. Same rule as the mosaic tiles. */
.flow-chart-img{display:block;width:100%;min-width:760px;height:auto}
/* The .flow-chart-light/.flow-chart-dark display-swap pair is gone: it kept both
   SVGs in the DOM and display:none does not stop a fetch. One <img> now, src
   swapped by applyThemeAssets() in site.js. */

/* ── Bento grid: problem statement, addressed issues, and metrics composed as one asymmetric layout ── */
/* grid-auto-rows is `auto`, NOT minmax(152px,auto). That 152px floor existed to
   give a PADDED card a minimum body; with the padding gone it is just a floor
   that short cells cannot fill, which is the half-empty-cell failure this grid
   has produced before (see the bento memory: slack of 145px+). Content sets the
   height now. Same gap reasoning as .tag-grid above. */
.bento-grid{display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:auto;gap:var(--sp-6) var(--sp-8)}
/* justify-content:center existed to centre content inside a fixed-height card.
   With no card there is nothing to centre against, and centring makes cells in
   the same row start at different heights. flex-start aligns their first lines. */
.bento-cell{background:none;border:0;padding:0;display:flex;flex-direction:column;justify-content:flex-start}
.bento-cell.problem{grid-column:1/3;justify-content:flex-start;background:none}
/* The fixed row placement below describes ONE composition: a double-height
   problem block with the stat beside it and a row of issues underneath. Pages
   that instead want a simple side-by-side row say so with an inline
   `grid-column:span N`, and for those the hardcoded rows are wrong — the cell
   keeps spanning two 152px tracks and renders as a short paragraph floating in a
   half-empty box. Only apply the fixed rows when the cell has not been placed by
   hand. (Page-scoped rules like .oms below carry higher specificity and still
   win.) */
.bento-cell.problem:not([style*="grid-column"]){grid-row:1/3}
.bento-cell.problem .cell-label{display:block;font-size:11px;color:var(--muted);margin-bottom:var(--sp-4)}
.bento-cell.problem p{margin:0;font-size:clamp(16px,1.7vw,19px);line-height:1.6;color:var(--muted)}
.bento-cell.stat{grid-column:3/5;align-items:flex-start}
.bento-cell.stat .stat-num{font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:clamp(36px,5vw,58px);line-height:1;letter-spacing:-0.02em}
.bento-cell.stat .stat-label{margin-top:var(--sp-3);line-height:1.5;font-size:14px;color:var(--muted);max-width:32ch}
.bento-cell.issue{grid-column:span 1}
.bento-cell.issue:not([style*="grid-column"]){grid-row:3/4}
.bento-cell.issue .cell-label{display:block;font-size:11px;color:var(--muted);margin-bottom:var(--sp-3)}
/* Strike drawn in currentColor, not --line: at --line it measured 1.33:1 and read
   as a rendering artefact rather than a deliberate strike — and the strike is the
   only thing distinguishing the old state from the new one. */
.bento-cell.issue .issue-from{display:block;font-size:13px;color:var(--muted);text-decoration:line-through;text-decoration-color:currentColor;text-decoration-thickness:1px}
.bento-cell.issue .issue-to{display:block;margin-top:4px;font-size:15px;font-weight:700}
/* Scoped to .bento-cell, not .bento-cell.issue: .issue-desc is also used inside
   .problem cells (dagwood x4, order-management-system x3, zeny x2), where it was
   matching nothing and so inherited full --text at body size. That inverted the
   hierarchy — the supporting detail rendered brighter and larger than the lead
   line above it, which is muted 16-19px. */
.bento-cell .issue-desc{display:block;margin-top:var(--sp-3);font-size:14px;line-height:1.55;color:var(--muted)}
@media (max-width:800px){
  .bento-grid{grid-template-columns:repeat(2,1fr)}
  .bento-cell.problem{grid-column:1/3}
  .bento-cell.stat{grid-column:span 1}
  .bento-cell.issue{grid-column:span 1}
  /* The :not() qualifier is repeated here on purpose. The base row placement is
     .bento-cell.problem:not([style*="grid-column"]) — specificity 0,3,0 — and a
     media query adds none, so a plain .bento-cell.problem{grid-row:auto} (0,2,0)
     silently loses and the cell keeps spanning two rows into its neighbours.
     Matching the qualifier is what actually lets the stacked layout take over. */
  .bento-cell.problem:not([style*="grid-column"]),
  .bento-cell.stat:not([style*="grid-column"]),
  .bento-cell.issue:not([style*="grid-column"]){grid-row:auto}
}

/* ── Legacy "before" reference: hidden by default, revealed as an overlay
     via the "View old applications" text link so it never sits inline in
     the case-study reading flow. Reuses the same overlay/close visual
     language as the image lightbox for consistency. ── */
.legacy-gallery{position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:clamp(16px,4vw,48px);background:rgba(0,0,0,0.82);opacity:0;visibility:hidden;transition:opacity .3s ease}
.legacy-gallery.open{opacity:1;visibility:visible}
.legacy-gallery-grid{display:flex;flex-wrap:wrap;justify-content:center;gap:28px;max-height:86vh;max-width:100%;overflow:auto}
.legacy-gallery-grid figure{margin:0}
.legacy-gallery-grid img{display:block;max-height:65vh;max-width:80vw;width:auto;height:auto;border-radius:var(--r-lg);box-shadow:0 20px 60px rgba(0,0,0,0.4)}
.legacy-gallery-grid figcaption{margin-top:var(--sp-3);line-height:1.5;font-size:13px;color:#EDEDEA;text-align:center}
@media (prefers-reduced-motion:reduce){.legacy-gallery{transition:none}}
@media (max-width:520px){
  .bento-grid{grid-template-columns:1fr}
  .bento-cell.problem,.bento-cell.stat,.bento-cell.issue{grid-column:1/2}
}

/* ── Process timeline: numbered stages zigzagging across a week ruler,
     each with its own cluster of sub-activity tags — a richer "what
     happened, when" breakdown than a plain numbered strip. Arrow curve
     coordinates are hand-tuned to the .pt-node vertical rhythm below;
     if node spacing changes, re-check the arrows still land on the circles. ── */
.process-timeline{position:relative;margin-top:8px}
.pt-ruler{display:flex;border-top:1px dashed var(--line);padding-top:var(--sp-3);margin-bottom:28px}
.pt-ruler span{flex:1;font-size:11px;color:var(--muted);padding:0 12px}
.pt-ruler span:first-child{padding-left:0}
.pt-ruler span:last-child{padding-right:0}
.pt-ruler span:not(:last-child){border-right:1px dashed var(--line)}
.pt-arrows{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:0}
.pt-arrows path{fill:none;stroke:var(--accent);stroke-width:2;stroke-dasharray:6 5}
.pt-row{position:relative;z-index:1;display:flex;gap:clamp(16px,2.5vw,32px)}
.pt-node{flex:1;min-width:0}
.pt-node--down{margin-top:clamp(128px,17vw,196px)}
.pt-card{position:relative;z-index:1;border:1px solid var(--line);border-radius:var(--r-md);background:var(--surface);padding:clamp(20px,2.2vw,24px)}
.pt-card-top{display:flex;align-items:center;gap:12px;margin-bottom:16px}
.pt-num{display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:var(--r-pill);background:var(--text);color:var(--bg);font-size:15px;font-weight:700}
.pt-week{display:none;font-size:11px;color:var(--muted)}
.pt-title{margin:0 0 var(--sp-4);font-family:'Bricolage Grotesque',sans-serif;font-weight:700;font-size:clamp(16px,1.7vw,20px);letter-spacing:-0.01em}
.pt-tags{display:flex;flex-wrap:wrap;gap:8px}
.pt-tags span{border:1px solid var(--line);color:var(--text);border-radius:var(--r-pill);padding:var(--sp-1) var(--sp-3);font-size:12px;font-weight:600;white-space:nowrap}
@media (max-width:800px){
  .pt-arrows{display:none}
  .pt-ruler{display:none}
  .pt-week{display:inline-block}
  .pt-row{flex-direction:column;gap:32px}
  .pt-node{width:100%}
  .pt-node--down{margin-top:0}
  .pt-tags{width:100%}
  .pt-tags span{white-space:normal}
}
@media (max-width:520px){
  .pt-ruler{flex-wrap:wrap;row-gap:var(--sp-2)}
  .pt-ruler span{flex:1 1 50%;border-right:none!important}
}

/* ── Brand swatches: name/hex/rgb overlaid on the color itself, plus a
     tint/shade scale strip per color (via color-mix, no precomputed hex
     needed) so each swatch reads as a small palette rather than one flat
     chip. Text color is chosen per swatch for AA contrast against that
     specific color, not tied to the page's light/dark theme. ── */
.brand-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:clamp(20px,3vw,28px)}
.brand-swatch{display:flex;flex-direction:column;gap:var(--sp-2)}
.brand-swatch .chip{position:relative;aspect-ratio:1.3/1;border-radius:var(--r-md);border:1px solid var(--line);background:var(--c);display:flex;flex-direction:column;justify-content:flex-end;padding:clamp(14px,2vw,20px)}
.brand-swatch .sw-name{font-family:'Bricolage Grotesque',sans-serif;font-weight:700;font-size:clamp(15px,1.6vw,19px);letter-spacing:-0.01em}
.brand-swatch .sw-code{font-size:12px;margin-top:var(--sp-3);opacity:0.82}
.brand-swatch .sw-rgb{font-size:11px;opacity:0.82;margin-top:var(--sp-1)}
.brand-scale{display:grid;grid-template-columns:repeat(5,1fr);gap:var(--sp-1)}
.brand-scale span{display:block;aspect-ratio:1/1;border-radius:var(--r-sm);border:1px solid var(--line)}
/* ── Brand showcase: logo lockup + typeface specimen + brand pattern strip,
     modeled on the brand's own reference sheet. No card background/border —
     sits directly on the page. Two monochrome logo variants (dark/white)
     swap based on [data-theme], same as the rest of the site's theming,
     since a single-color logo asset can't invert itself via CSS alone.
     Surrounding text uses theme-aware colors too. ── */
.brand-showcase{margin-top:var(--sp-10)}
.bs-top{display:flex;align-items:flex-start;justify-content:space-between;gap:clamp(20px,4vw,40px);flex-wrap:wrap;padding-bottom:clamp(24px,4vw,40px)}
/* Single element, src swapped by applyThemeAssets() — same reason as the flow
   chart: the display:none pair fetched both files. */
.bs-logo{height:clamp(44px,6vw,68px);width:auto;display:block}
.bs-type{display:flex;align-items:flex-start;gap:20px}
.bs-aa{font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:clamp(44px,6vw,68px);line-height:1;color:var(--text)}
.bs-type-info{display:flex;flex-direction:column;gap:var(--sp-2)}
.bs-type-name{font-family:'Geist',ui-sans-serif,system-ui,sans-serif;font-size:14px;font-weight:600;color:var(--text)}
.bs-type-sample{font-family:'Geist',ui-sans-serif,system-ui,sans-serif;font-size:clamp(13px,1.3vw,16px);line-height:1.5;color:var(--muted)}
.bs-pattern{display:block;width:100%;height:auto;opacity:0.3}
@media (max-width:560px){
  .bs-top{flex-direction:column;align-items:flex-start}
}
@media (max-width:800px){.brand-grid{grid-template-columns:repeat(2,1fr)}}
@media (max-width:560px){
  .brand-grid{grid-template-columns:1fr}
  .brand-swatch .chip{aspect-ratio:2/1}
}

.visual-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(24px,3vw,32px)}
.visual-block{grid-column:span 2;position:relative;aspect-ratio:4/3;border-radius:4px;overflow:hidden;border:1px solid var(--line);background:var(--shot);background-image:repeating-linear-gradient(135deg, var(--shot-stripe) 0, var(--shot-stripe) 1px, transparent 1px, transparent 11px)}
.visual-block.wide{grid-column:span 4;aspect-ratio:16/7}
.visual-block .v-tag{position:absolute;top:16px;left:16px;font-size:12px;font-weight:700}
.visual-block .v-cap{position:absolute;bottom:16px;left:16px;font-size:12px;color:var(--muted)}
@media (max-width:640px){.visual-block{grid-column:span 4}}

/* ── Mockups, alternating large and small placeholders in one strip ── */
.mockup-strip{display:flex;align-items:flex-end;gap:clamp(16px,2.5vw,24px);overflow-x:auto;padding-bottom:8px;-webkit-overflow-scrolling:touch}
.mockup-frame{position:relative;flex-shrink:0;border-radius:4px;overflow:hidden;border:1px solid var(--line);background:var(--shot);background-image:repeating-linear-gradient(135deg, var(--shot-stripe) 0, var(--shot-stripe) 1px, transparent 1px, transparent 11px)}
.mockup-frame.large{width:clamp(220px,26vw,320px);aspect-ratio:9/16}
.mockup-frame.small{width:clamp(160px,19vw,230px);aspect-ratio:4/3}

/* ── Real product mockups, shown as physical hardware (tablet, till stand)
   rather than a browser window, no people, just the device and the screen ── */
.pos-mockup-stack{display:flex;flex-direction:column;gap:clamp(28px,4vw,44px)}
.pos-mockup-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:clamp(28px,4vw,44px)}
@media (max-width:800px){.pos-mockup-grid{grid-template-columns:1fr}}

.device{display:flex;flex-direction:column;align-items:center}
.device-frame{position:relative;width:100%;background:#151515;border-radius:16px;padding:12px 12px 20px;box-shadow:0 30px 60px -28px rgba(0,0,0,0.5)}
.device-frame::before{content:'';position:absolute;top:5px;left:50%;transform:translateX(-50%);width:5px;height:5px;border-radius:50%;background:#3a3a3a}
.device-screen{border-radius:5px;overflow:hidden;background:#fff;line-height:0}
.device-screen img{display:block;width:100%;height:auto}
.device-home{position:absolute;bottom:7px;left:50%;transform:translateX(-50%);width:32px;height:3px;border-radius:2px;background:#3a3a3a}

.device.on-stand .device-frame{border-radius:12px 12px 4px 4px}
.stand-neck{width:16px;height:26px;background:#151515;margin-top:-1px}
.stand-base{width:130px;height:9px;border-radius:5px;background:#151515;margin-top:-1px}

.device-caption{margin-top:16px;font-size:12px;color:var(--muted)}
.mockup-frame .v-tag{position:absolute;top:14px;left:14px;font-size:12px;font-weight:700}

.lifestyle-shot img{width:100%;height:auto;border-radius:16px;display:block;box-shadow:0 30px 60px -28px rgba(0,0,0,0.35)}
.lifestyle-shot figcaption{margin-top:10px}

/* ── Hero lifestyle image, large contained card under the headline/stats ── */
.hero-lifestyle{margin-top:clamp(28px,4vw,44px)}
.hero-lifestyle img{width:100%;height:auto;border-radius:var(--r-lg);display:block;box-shadow:0 30px 60px -28px rgba(0,0,0,0.4)}

/* ============================================================================
   EXPERIMENTAL: Gemini "subsystem showcase" styling (see work/lucrum-pos.html
   section 05 and memory lucrum_pos_gemini_showcase). Flat bordered containers,
   NO drop shadow, teal (#20B2AA brand) uppercase subsystem tags, plain-sans
   captions instead of the site's "//" Space Mono convention. Work in progress,
   intended to be revertible — delete this block and restore the previous
   .device-frame / .pos-mockup-stack markup to revert.
   ============================================================================ */
.subsystems{display:flex;flex-direction:column;gap:clamp(40px,6vw,72px)}
.subsystem-tag{display:block;font-size:12px;font-weight:600;color:var(--accent);margin-bottom:var(--sp-4);line-height:1.5}
/* Borderless, caption below the image, 2026-09-01. This used to be a card: a
   1px border, a --shot fill and the caption in its own bar divided by a rule.
   Lucrum shed it when that page was rebuilt from the control, and Dagwood and
   Zeny kept it, so the same site drew a screenshot two different ways, and
   Dagwood drew it BOTH ways on one page (13 carded, 3 plain).

   The card also outlived its own design system. The migration made surfaces lift
   ABOVE the ground precisely so panels need no outline, and .tag-chip and
   .bento-cell were made borderless then; .ui-shot was missed. A border round
   every screenshot is a card that communicates no hierarchy, which is the one
   thing the taste skill says a card has to earn.

   The radius and the fill move from the figure to the IMAGE, so a transparent
   PNG still gets a ground and the corners still round, with nothing drawn round
   the caption. */
.ui-shot{border:0;background:none;overflow:visible;margin:0}
.ui-shot img{width:100%;height:auto;display:block;border-radius:var(--r-md);background:var(--shot)}
.ui-shot img.placeholder{aspect-ratio:16/10;height:auto;object-fit:cover;opacity:.9}
/* flex-start, NOT space-between. In the old card the caption spanned a bar and
   pushing the icon to the far edge read as a toolbar. Below a full-width image
   the same rule strands it inches from the words it belongs to. It sits after
   the text now, as punctuation on the caption. */
.ui-shot figcaption{display:flex;align-items:center;justify-content:flex-start;
  gap:var(--sp-2);margin-top:var(--sp-3);padding:0;border-top:0;
  font-size:14px;line-height:1.5;color:var(--muted);max-width:60ch}
.sub-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
.sub-grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;align-items:start}
.sub-grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;align-items:start}
.sub-single{max-width:620px}
.sub-feature{margin-bottom:16px}
.sub-split{display:grid;grid-template-columns:1.4fr 1fr;gap:16px;align-items:start}
.sub-stack{display:flex;flex-direction:column;gap:16px}
@media (max-width:800px){.sub-grid-3,.sub-grid-4,.sub-split{grid-template-columns:1fr 1fr}}
@media (max-width:520px){.sub-grid-3,.sub-grid-4,.sub-split{grid-template-columns:1fr}}
/* .sub-grid-2 holds 2880x1920 full-app screenshots, so it collapses far earlier
   than the site's usual 800px breakpoint. Two dense POS screens side by side are
   ~338px each at iPad portrait and ~226px at 521px — unreadable. Collapsing at
   1024px gives every tablet a full-width screen and puts the narrowest possible
   2-up column at ~456px. */
@media (max-width:1024px){.sub-grid-2{grid-template-columns:1fr}}

/* Tall portrait shots — whole phone screens (uncropped 390x844 app captures) and
   the site's own tall modals — cannot be sized by the column the way a landscape
   browser shot can. They run ~2x as tall as they are wide, so a ~584px
   .sub-grid-2 cell would render one 1200px+ tall, and capping the image inside a
   full-width cell just leaves ~240px of dead air on either side of it. These rows
   are sized by the shot instead — fixed-width tracks, centred as a group — and
   the track width is what steps down at each breakpoint. */
.tall-row{display:grid;justify-content:center;gap:16px}
.tall-row-4{grid-template-columns:repeat(4,minmax(0,272px))}
.tall-row-2{grid-template-columns:repeat(2,minmax(0,336px))}
@media (max-width:1120px){.tall-row-4{grid-template-columns:repeat(2,minmax(0,300px))}}
@media (max-width:700px){.tall-row-4,.tall-row-2{grid-template-columns:minmax(0,340px)}}

/* Lightbox affordance for .ui-shot screens.
   The global img[data-lightbox] hover pop (translateY(-4px) scale(1.03), further
   down this file) cannot be used inside .ui-shot: the card is overflow:hidden, so
   the scale clips against the rounded border and the lift tears a gap above the
   caption. Cancel it and move the feedback to the card instead. */
.ui-shot{position:relative}
/* The global img[data-lightbox] hover pop (translateY + scale 1.03) stays
   cancelled. It is no longer a clipping problem now the card is gone, but this
   site runs at a deliberately low motion budget — the scroll reveals, the cursor
   follower and the marquee were all removed — and a screenshot that jumps on
   hover is exactly the kind of motion that decision was about. */
.ui-shot img[data-lightbox],
.ui-shot img[data-lightbox]:hover,
.ui-shot img[data-lightbox]:focus-visible{transform:none}
/* Hover feedback moved off the border, which no longer exists, onto the caption
   and its icon. Quiet, and it does not move anything. */
.ui-shot:hover figcaption{color:var(--text)}
/* A normal outline now rather than the old inset one: the figure was
   overflow:hidden and clipped anything drawn outside the image, and it is not
   any more. Still required — the cancelled transform carries no focus meaning. */
.ui-shot img[data-lightbox]:focus-visible{outline:2px solid var(--text);outline-offset:3px}
/* Expand hint, in the caption bar rather than overlaid on the screenshot: these
   images are themselves full of UI, so a chip floating over one reads as part of
   the captured app (it landed squarely on the app's own "Close POS" button). In
   the caption bar it is unambiguously site chrome. Always visible, not hover-only
   — touch devices never get a hover state. Decorative; the image is the target. */
.shot-zoom{flex:none;width:16px;height:16px;fill:currentColor;opacity:.85;pointer-events:none;transition:opacity .2s ease}
.ui-shot:hover .shot-zoom{opacity:1}
/* ===== END EXPERIMENTAL subsystem showcase styling ===== */

/* ── Component library bento mosaic (section 05, "User flow and components").
     Every component sits in its own individually padded white card — no
     shared clusters, no bare floating pieces — for a uniform, coherent
     rhythm (per the user's "My stacked components.png" reference and
     follow-up request to give every tile the same treatment). Each
     .component-card is a grid item (grid-column/row span set inline per
     card, sized to its image's true size + padding) laid out with
     grid-auto-flow:dense so they pack tightly, the same bento-grid
     language the rest of this design system already uses.

     These Figma exports are all at 2x scale, so each image's real/crisp
     display size is its native px ÷ 2 (set inline per <img> as
     max-width/max-height) — images are never stretched to fill their card,
     only capped and centered, so nothing is upscaled past its true
     resolution (that's what caused the pixelated-status-pills bug two
     rounds ago). White is a deliberate, functional choice, not just
     styling: several exports carry label text colored for a white Figma
     canvas that's nearly invisible on this page's near-black background —
     confirmed by compositing samples onto the real #121212 bg, not by
     eyeballing. Flat, no border/shadow — matches this section's established
     borderless preference (rejected bordered .ui-shot cards twice already;
     see memory lucrum-pos-component-library). ── */
.component-mosaic{display:grid;grid-auto-flow:dense;grid-template-columns:repeat(24,1fr);grid-auto-rows:28px;gap:var(--sp-2);margin-top:var(--sp-10)}
.component-card{display:flex;align-items:center;justify-content:center;background:#fff;border-radius:var(--r-lg);padding:var(--sp-4);place-self:center;box-sizing:border-box;max-width:100%;min-width:0}
.component-card img{display:block;max-width:100%;min-width:0}

/* ── Mosaic cell spans. These were inline `style="grid-column:span N;grid-row:
     span M"` on all 19 cards until 2026-08-29. An inline style outranks every
     media query at every viewport, so the ≤640px stack below could only undo
     them with `grid-column:unset !important` — the !important was not a
     judgement call, it was the only tool left. Moving the spans to classes puts
     them back in the cascade at ordinary (0,1,0) specificity, so the media
     query overrides them on source order alone and the !important is gone.
     Values are unchanged, one class per distinct pair; renders identically at
     every width. Keep this block ABOVE the ≤640px query — that ordering is what
     makes the override work now that nothing is shouting. ── */
.cc-2x4{grid-column:span 2;grid-row:span 4}
.cc-3x3{grid-column:span 3;grid-row:span 3}
.cc-4x3{grid-column:span 4;grid-row:span 3}
.cc-4x4{grid-column:span 4;grid-row:span 4}
.cc-4x6{grid-column:span 4;grid-row:span 6}
.cc-4x7{grid-column:span 4;grid-row:span 7}
.cc-6x2{grid-column:span 6;grid-row:span 2}
.cc-6x3{grid-column:span 6;grid-row:span 3}
.cc-6x4{grid-column:span 6;grid-row:span 4}
.cc-6x8{grid-column:span 6;grid-row:span 8}
.cc-7x3{grid-column:span 7;grid-row:span 3}
.cc-7x5{grid-column:span 7;grid-row:span 5}
.cc-7x6{grid-column:span 7;grid-row:span 6}
.cc-7x7{grid-column:span 7;grid-row:span 7}
.cc-8x3{grid-column:span 8;grid-row:span 3}
.cc-8x7{grid-column:span 8;grid-row:span 7}
/* CRITICAL: place-self:center sizes a grid item to its own CONTENT, then
   centers that within the grid area — it does NOT cap the item at the
   area's width. Every per-image max-width below is a fixed px value (the
   image's true 2x-corrected size), tuned against this project's own
   desktop testing width (~1184px content). On any narrower real viewport —
   Chrome's Android "Request Desktop Site" mode renders the full grid at a
   narrower virtual width than that, and so does any ordinary desktop
   window narrower than ~1280px — the grid tracks shrink but the fixed-px
   images don't, so a .component-card overflows its own grid cell and
   visually overlaps its neighbors. box-sizing:border-box + max-width:100%
   above forces the card (and, by inheriting the same rule, the image
   inside it) to shrink with its actual grid track instead of overflowing.
   Confirmed via a real device screenshot (Chrome/Android desktop-site
   mode), not just the remote browser test harness used elsewhere in this
   section's history — that harness never caught this because it never
   rendered at a narrower-than-testing width. ── */

/* Sizing is self-correcting at every width (inline max-width/max-height
   never exceeds true crisp size) — only the grid itself needs a mobile
   fallback: 24 narrow tracks stop making sense on a phone, so collapse to
   a simple centered stack of cards, each keeping its own consistent padding. */
@media (max-width:640px){
  .component-mosaic{display:flex;flex-direction:column;align-items:center;gap:16px}
  /* No !important needed since the spans became .cc-* classes above: equal
     specificity, and this block is later in the file. */
  .component-card{grid-column:unset;grid-row:unset}
}

/* ── Action buttons ("Payment", "Cancel Order", etc.) — deliberately OUTSIDE
   .component-mosaic's CSS Grid, as a plain block-flow sibling after it.

   Why: these buttons wrap via flex-wrap, and how many rows that produces
   depends on the actual rendering width — which is exactly the kind of
   thing that's unpredictable across real devices (this project has now hit
   that twice: Chrome/Android "Request Desktop Site" renders at a virtual
   width neither this project's own ~1568px test harness nor a hand-picked
   980px iframe test happened to match). A CSS Grid item's row-track size
   is FIXED once set (grid-row:span N reserves exactly N tracks) — if
   flex-wrap content needs more rows than N at some width the layout wasn't
   tuned for, it overflows the cell vertically and collides with whatever
   sits below, which is exactly the "Redeem Points"/"Card" overlap and the
   diary-photos-clipping-the-mosaic bug the user found. There is no fixed
   grid-row span that is correct at every width for content whose row count
   is itself width-dependent — so these buttons don't get one. As normal
   block-flow (height:auto), each group always takes exactly the space its
   wrapped content needs, at any width, with no overflow possible. Trade-off:
   these no longer tile into the bento grid's gaps — correctness over
   novelty, after two real-device bugs from the grid-integrated version. */
.component-actions{display:flex;flex-direction:row;flex-wrap:wrap;align-items:flex-start;gap:var(--sp-2);margin-top:var(--sp-2)}
/* row + flex-wrap (not a stacked column): the group cards sit side by side
   and reflow naturally, matching the mosaic's varied-width multi-card rows
   instead of reading as three full-width bars orphaned below it. gap and
   margin-top match .component-mosaic's own 8px so this reads as a
   continuation of the same rhythm rather than a detached block. Still pure
   flow properties — no spans, height stays auto — so the fixed-grid-row
   overflow bug documented above cannot recur from this.

   align-items:flex-start also overrides the flex default of "stretch" —
   without it, every .component-card-group stretches to fill the FULL width
   of this container, leaving a large dead white area to the right of a
   3-4-button card. Every other tile in this mosaic sizes to its own
   content; these groups should too. */
/* Each group of related buttons (confirm / neutral / destructive) shares
   ONE card — not each button individually boxed with gaps between, which
   read as a jagged, mismatched-width "pyramid" rather than a deliberate
   grouping. Same white/radius/padding language as .component-card, just
   holding multiple buttons instead of one image. */
.component-card-group{display:flex;flex-wrap:wrap;align-items:center;gap:12px;background:#fff;border-radius:var(--r-lg);padding:var(--sp-4);box-sizing:border-box;max-width:100%;min-width:0}
.component-card-group img{display:block;max-width:100%;min-width:0}

/* ── Diary/process-artifact pairs: real 2-column grid (equal-width columns,
     not flex sized off each image's own aspect ratio), compact height since
     these are supporting context beside the section text, not a feature.
     No card frame — image scales down from its natural size via max-height/
     max-width, so no distortion and no letterboxing background. ── */
.diary-pair{display:grid;grid-template-columns:1fr 1fr;gap:clamp(20px,3vw,32px);align-items:start}
.diary-pair figure{margin:0}
.diary-pair img{width:auto;height:auto;max-width:100%;max-height:170px;display:block;border-radius:var(--r-lg)}
.diary-pair figcaption{margin-top:var(--sp-3);line-height:1.5;font-size:13px;color:var(--muted)}
@media (max-width:640px){.diary-pair{grid-template-columns:1fr}}

/* ── Diary notes / research carousel: every notebook photo in one place,
     mixed portrait/landscape shown at natural aspect ratio (no cropping
     frame), height-matched, horizontally scrolling. ── */
.diary-carousel{display:flex;align-items:flex-end;gap:clamp(20px,3vw,32px);overflow-x:auto;padding-bottom:8px;-webkit-overflow-scrolling:touch;scroll-snap-type:x proximity}
.diary-carousel figure{flex-shrink:0;margin:0;scroll-snap-align:start}
.diary-carousel img{height:280px;width:auto;max-width:64vw;display:block;border-radius:var(--r-lg)}
.diary-carousel figcaption{margin-top:var(--sp-3);line-height:1.5;max-width:260px;font-size:13px;color:var(--muted)}
@media (max-width:640px){.diary-carousel img{height:220px}}

.diary-jump-link{padding:var(--sp-3) 0;min-height:44px;display:inline-flex;align-items:center;margin:var(--sp-4) 0 var(--sp-5);font-size:12px;color:var(--muted);text-decoration:none;border-bottom:1px solid var(--line);transition:color .2s ease,border-color .2s ease}
.diary-jump-link:hover{color:var(--text);border-color:var(--text)}

/* ── Lightbox-enabled images: hover pop (same easing as .work-card),
     click/Enter/Space opens the full-size overlay built by site.js. ── */
img[data-lightbox]{cursor:zoom-in;transition:transform .4s cubic-bezier(.16,1,.3,1)}
img[data-lightbox]:hover,img[data-lightbox]:focus-visible{transform:translateY(-4px) scale(1.03)}

body.lightbox-lock{overflow:hidden}

.lightbox{position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:clamp(16px,4vw,48px);background:rgba(0,0,0,0.82);opacity:0;visibility:hidden;transition:opacity .3s ease}
.lightbox.open{opacity:1;visibility:visible}
.lightbox figure{margin:0;max-width:100%;max-height:100%;display:flex;flex-direction:column;align-items:center}
.lightbox-img{max-width:100%;max-height:82vh;width:auto;height:auto;object-fit:contain;border-radius:var(--r-lg);box-shadow:0 20px 60px rgba(0,0,0,0.4)}
.lightbox-caption{margin-top:var(--sp-4);line-height:1.5;font-size:13px;color:#EDEDEA;text-align:center;max-width:60ch}
.lightbox-close{position:absolute;top:var(--sp-5);right:var(--sp-6);width:44px;height:44px;padding:0;border-radius:var(--r-pill);border:1px solid rgba(255,255,255,0.3);background:rgba(255,255,255,0.08);color:#fff;font-size:26px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .2s ease,transform .2s ease}
.lightbox-close:hover{background:rgba(255,255,255,0.18);transform:scale(1.05)}

/* Wide landscape shots on phones (.lightbox-wide is set by site.js from
   data-lightbox-wide). Fitting a 3:2 desktop screenshot to a 375px viewport
   yields a ~343px image — no bigger than the thumbnail it was opened from. So
   below 640px, size it by height instead and let the overlay pan horizontally.
   flex-start matters: with justify-content:center the overflow to the left of
   centre is unreachable by scrolling. The close button switches to fixed so it
   cannot scroll out of reach, and the caption sticks to the left edge so it
   stays on screen while the image pans. */
@media (max-width:640px){
  .lightbox.lightbox-wide{overflow:auto;align-items:flex-start;justify-content:flex-start;padding:72px 16px 16px}
  .lightbox.lightbox-wide figure{max-width:none;max-height:none}
  .lightbox.lightbox-wide .lightbox-img{max-width:none;max-height:none;width:auto;height:70vh}
  .lightbox.lightbox-wide .lightbox-caption{position:sticky;left:0;max-width:calc(100vw - 32px);text-align:left}
  .lightbox.lightbox-wide .lightbox-close{position:fixed}
}
@media (prefers-reduced-motion:reduce){.lightbox{transition:none}}
.mockup-frame .v-cap{position:absolute;bottom:14px;left:14px;font-size:11px;color:var(--muted)}

/* ── Reflection pull quote ── */
.reflection-quote{position:relative;margin:0;padding-top:8px}
.reflection-quote .quote-mark{display:block;font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:clamp(60px,8vw,100px);line-height:1;color:var(--line);margin-bottom:-0.2em}
.reflection-quote p{margin:0;font-family:'Bricolage Grotesque',sans-serif;font-weight:500;font-size:clamp(26px,4vw,48px);line-height:1.25;letter-spacing:-0.015em;max-width:26ch}
.reflection-quote cite{display:block;margin-top:28px;font-size:12px;font-style:normal;color:var(--muted);}

/* No border-top. It was `1px solid var(--text)` — a 1184px full-strength black
   bar, the same rule already taken off every section heading for stacking up as
   black bars down the page. The section's own padding-top already separates it,
   and it is the last block before the footer, which the colophon rule closes. */
.next-project{padding-top:clamp(48px,7vw,80px);padding-bottom:clamp(48px,8vw,88px)}
/* display:block is load-bearing. .np-label is an inline <span> and the link
   below it used to be display:flex, which forced its own line; switching the
   link to inline-flex (so its underline stops at the text) let the label flow up
   beside the project name. */
.next-project .np-label{display:block;font-size:12px;color:var(--muted);}
/* width:fit-content so the underline stops at the link. As a full-width flex row
   with space-between, the rule ran the whole 1184px measure while the word it
   belonged to occupied the first 90px, so it read as another section separator
   rather than as a link. Fitting the content also pulls the arrow back beside
   the name instead of stranding it at the far edge. */
.next-project a.np-link{display:inline-flex;width:fit-content;max-width:100%;
  align-items:baseline;justify-content:flex-start;gap:var(--sp-5);
  text-decoration:none;color:var(--text);margin-top:16px;padding-bottom:8px;
  border-bottom:1px solid var(--line);transition:border-color .3s ease}
.next-project a.np-link:hover{border-color:var(--text)}
.next-project a.np-link .np-name{font-family:'Bricolage Grotesque',sans-serif;font-weight:700;font-size:clamp(30px,6vw,64px);letter-spacing:-0.03em}
.next-project a.np-link .np-arrow{font-size:20px;white-space:nowrap}

.coming-soon{display:inline-flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:var(--r-pill);padding:var(--sp-3) var(--sp-5);font-size:12px;color:var(--muted);margin-top:32px}

/* ── Homepage hero intro + About ─────────────────────────────────────────────
   These two grids were written as inline style="" on index.html and were the
   only grids on the site with no breakpoint — an inline grid-template-columns
   beats every media query regardless of viewport, exactly the failure already
   documented for .tag-grid-3 above. At 371px they held four 53px tracks, so
   the intro paragraph and the CTA both rendered 138px wide (three words per
   line) with ~190px of dead gutter beside them.

   The old inline guard was min-width:min(100%,320px) on the paragraph. That
   can never work: a percentage min-width resolves against the grid area, so
   it cannot widen its own track. Collapsing the grid is the only fix.
   Same 800px tier as .cs-meta, which is the identical 4-column pattern. ── */
.home-intro{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-8);margin-top:clamp(40px,6vw,72px);align-items:end}
.home-intro .intro-copy{grid-column:span 2;margin:0;font-size:clamp(17px,2.2vw,22px);line-height:1.5;color:var(--muted);max-width:46ch}
.home-intro .intro-cta{grid-column:span 2;display:flex;justify-content:flex-start}

.home-about{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-8)}
.home-about .about-label{grid-column:span 1}
.home-about .about-body{grid-column:span 3}

@media (max-width:800px){
  .home-intro,.home-about{grid-template-columns:1fr;gap:var(--sp-6)}
  .home-intro .intro-copy,.home-intro .intro-cta,
  .home-about .about-label,.home-about .about-body{grid-column:1/-1}
}

/* Primary CTA. Was inline onmouseover/onmouseout on index.html, which meant
   the fill never appeared on keyboard focus — :focus-visible fixes that. */
.btn-primary{display:inline-flex;align-items:center;gap:12px;text-decoration:none;color:var(--text);background:transparent;border:1px solid var(--text);border-radius:var(--r-pill);padding:16px 28px;font-size:15px;font-weight:600;transition:background .35s ease,color .35s ease}
.btn-primary:hover,.btn-primary:focus-visible{background:var(--text);color:var(--bg)}
.btn-primary .btn-arrow{}

/* ── Work grid (homepage) ── */
.work-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(32px,4vw,48px) 32px}
.work-card{grid-column:span 2}

/* ── Card overlay labels ("01", the category, "// slug").
     These were twelve inline styles in three different treatments: card 1 used
     var(--muted) with a two-layer shadow, cards 2-3 used #EDEDEA with a lighter
     one, card 4 used var(--muted) with no shadow at all. Measured against the
     actual screenshot pixels underneath (scripts/overlay-contrast.mjs), four of
     the twelve came in below WCAG AA, worst 1.95:1.

     The root cause was var(--muted): it flips with the page theme, but the
     backdrop is a photograph that does not. A label over an image has to be
     coloured against the image, so all twelve are now light and unconditional.

     Light text alone is not enough on an arbitrary screenshot, so the scrim
     below guarantees the backdrop rather than hoping for it — WCAG gives no
     credit for a text-shadow, and a shadow cannot be measured honestly anyway
     (hiding the text to photograph the backdrop hides its shadow too). The
     gradients are confined to the top and bottom strips where labels actually
     sit; the middle of the thumbnail is untouched.

     The scrim must paint above the <img> but below the labels. ::after is the
     last child so it clears the image on DOM order alone, and the labels are
     lifted over it with z-index — they set no z-index inline, so this wins
     without needing !important. ── */
/* The gradient HOLDS at full strength across the band the labels occupy, then
   fades. A plain 0 -> transparent ramp was tried first and failed the bright
   cards: a label at top:16px with ~13px of height sits around 22px into a 76px
   ramp, where only ~70% of the stated alpha has been laid down. Two of the four
   thumbnails are near-white composites, and 0.37 effective alpha does not take
   a near-white pixel anywhere near the 0.14 luminance that #EDEDEA text needs
   for 4.5:1. Holding .62 to 38px puts the floor under the text instead of
   under the top edge, and the fade still lands well before the image middle. */
.work-card .shot::after{
  content:"";position:absolute;inset:0;pointer-events:none;
  background:
    linear-gradient(to bottom, rgba(0,0,0,.62) 0, rgba(0,0,0,.62) 38px, rgba(0,0,0,0) 88px),
    linear-gradient(to top,    rgba(0,0,0,.62) 0, rgba(0,0,0,.62) 36px, rgba(0,0,0,0) 84px);
}
/* Named card-* rather than shot-*: the exploration clone already defines a
   .shot-tag of its own for the category label, and a bare .shot-tag here would
   silently restyle it through the shared stylesheet. */
.card-label{
  position:absolute;z-index:1;
  color:#EDEDEA;
  text-shadow:0 1px 3px rgba(0,0,0,.75);
}
.card-label.card-num {top:16px;left:16px;font-size:13px;font-weight:700}
.card-label.card-cat {top:16px;right:16px;font-size:11px;}
.card-label.card-slug{bottom:16px;left:16px;font-size:12px}

.work-card .shot{transition:transform .4s cubic-bezier(.16,1,.3,1),background-color .4s ease}
.work-card:hover .shot,.work-card:focus-visible .shot{transform:translateY(-4px);background-color:var(--shot-hover)}
@media (max-width:640px){.work-card{grid-column:span 4}}

/* ── Work card thumbnail: tilted 15deg for depth, oversized and centered so
     the rotated corners never leave a gap, scales with the card at any
     breakpoint since sizing is percentage-based off the .shot container. ── */
.shot img.thumb-img{position:absolute;top:50%;left:50%;width:135%;height:135%;object-fit:cover;transform:translate(-50%,-50%) rotate(15deg);transform-origin:center center;transition:transform .4s cubic-bezier(.16,1,.3,1)}
.work-card:hover .thumb-img,.work-card:focus-visible .thumb-img{transform:translate(-50%,-50%) rotate(15deg) scale(1.04)}

/* ── Footer ── */
footer.site-footer{padding-top:clamp(48px,7vw,80px);padding-bottom:64px;border-top:1px solid var(--text)}
footer.site-footer .f-row{display:flex;justify-content:space-between;align-items:flex-start;gap:40px;flex-wrap:wrap}
/* line-height 1.02 on a clamped display size means the link's box is only as
   tall as the glyphs — 29px at the 28px end of the clamp, under the 44px floor.
   fit-content keeps the box hugging the address exactly as display:block did
   here, so only the height changes, and only at the narrow end. */
footer.site-footer .f-email{display:flex;align-items:center;width:fit-content;min-height:44px;text-decoration:none;color:var(--text);font-family:'Bricolage Grotesque',sans-serif;font-weight:700;font-size:clamp(28px,5vw,56px);letter-spacing:-0.03em;line-height:1.02;margin-top:20px}
footer.site-footer .f-else{display:flex;flex-direction:column;gap:12px;align-items:flex-start}
footer.site-footer .f-else a{text-decoration:none;color:var(--text);font-size:16px;font-weight:500;opacity:0.85;padding:var(--sp-3) 0;display:inline-block}
footer.site-footer .f-bottom{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-top:clamp(48px,8vw,88px);flex-wrap:wrap}
footer.site-footer .f-bottom span{font-size:12px;color:var(--muted)}

/* ── Theme toggle ── */
#theme-toggle{position:fixed;bottom:24px;right:24px;z-index:65;width:56px;height:56px;border-radius:var(--r-pill);border:1px solid var(--line);background:var(--bg);color:var(--text);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 6px 24px rgba(0,0,0,0.10);transition:transform .3s cubic-bezier(.16,1,.3,1)}
#theme-toggle:hover{transform:scale(1.06)}
#theme-toggle .dot{width:22px;height:22px;border-radius:var(--r-pill);border:1.5px solid var(--text);background:linear-gradient(90deg,var(--text) 0,var(--text) 50%,transparent 50%,transparent 100%)}

/* ── Cross document view transitions ── */
@view-transition{
  navigation: auto;
}
::view-transition-group(root){animation-duration:460ms}
::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}
::view-transition-old(root){z-index:1}
::view-transition-new(root){z-index:2}

@keyframes cs-slide-in-right{from{transform:translateX(100%)}to{transform:translateX(0)}}
@keyframes cs-slide-out-right{from{transform:translateX(0)}to{transform:translateX(100%)}}
@keyframes cs-slide-in-bottom{from{transform:translateY(100%)}to{transform:translateY(0)}}
@keyframes cs-slide-out-bottom{from{transform:translateY(0)}to{transform:translateY(100%)}}

:root:active-view-transition-type(forward){
  &::view-transition-new(root){animation:cs-slide-in-right 460ms cubic-bezier(.16,1,.3,1) both}
}
:root:active-view-transition-type(back){
  &::view-transition-old(root){animation:cs-slide-out-right 460ms cubic-bezier(.16,1,.3,1) both;z-index:2}
  &::view-transition-new(root){z-index:1}
}
@media (max-width:640px){
  :root:active-view-transition-type(forward){
    &::view-transition-new(root){animation:cs-slide-in-bottom 460ms cubic-bezier(.16,1,.3,1) both}
  }
  :root:active-view-transition-type(back){
    &::view-transition-old(root){animation:cs-slide-out-bottom 460ms cubic-bezier(.16,1,.3,1) both}
  }
}
@media (prefers-reduced-motion: reduce){
  ::view-transition-group(root),
  ::view-transition-old(root),
  ::view-transition-new(root){animation:none!important}
}


/* ── Focus visibility ────────────────────────────────────────────────────────
   Nothing on the case study previously had a designed focus state; keyboard
   users got whatever the browser drew. --text rather than --accent so the ring
   is theme-safe at full contrast in both themes. */
a:focus-visible,
button:focus-visible,
[tabindex]:focus-visible,
summary:focus-visible{outline:2px solid var(--text);outline-offset:3px;border-radius:var(--r-sm)}
/* .ui-shot images keep their own inset ring (overflow:hidden would clip an
   offset one) — declared with the subsystem block above. */
.ui-shot img[data-lightbox]:focus-visible{outline-offset:-3px;border-radius:0}

/* Skip link — visually hidden until focused. */
/* The first thing a keyboard user lands on, so it holds itself to the same
   44px floor as every other control: padding alone left it at 42. */
.skip-link{position:absolute;left:-9999px;top:0;z-index:200;background:var(--text);color:var(--bg);
  padding:var(--sp-3) var(--sp-4);border-radius:var(--r-sm);font-size:14px;font-weight:600;text-decoration:none;
  min-height:44px;display:inline-flex;align-items:center}
.skip-link:focus{left:var(--sp-4);top:var(--sp-4)}

/* The component cards are deliberately #fff in BOTH themes: several Figma
   exports carry muted label text coloured for a white canvas, so a white card
   is functional, not decorative. But white on the light background is a 1.08:1
   boundary, making 23 cards structurally invisible. Give them an edge in light
   theme only; dark theme must not change. */
[data-theme="light"] .component-card,
[data-theme="light"] .component-card-group{border:1px solid var(--line)}

/* Horizontal scrollers are keyboard-focusable (Firefox and Safari do not
   auto-focus scroll containers the way Chrome does). */
.flow-chart:focus-visible,
.diary-carousel:focus-visible{outline:2px solid var(--text);outline-offset:2px}

/* width/height attributes are on every <img> to reserve space and stop layout
   shift. Those attributes are presentational hints, so any image capped by
   max-width/max-height needs an explicit height:auto or the hint would fight
   the cap and stretch it — this matters most for the mosaic, whose tile sizing
   took a long time to get right. */
.component-card img,
.component-card-group img{height:auto}

/* Diary photos get the same expand hint as the section-06 screens. These are
   170-280px renders of handwriting that is illegible at that size, so the
   lightbox is not a bonus — it is the only way to read them. On touch the sole
   previous cue was cursor:zoom-in, which does not exist on touch.

   Note flex-start, not space-between: these captions are far wider than the
   portrait photos above them, so pushing the icon to the caption's right edge
   left it floating unattached to its image. Sitting it directly after the text
   keeps it associated. (.ui-shot captions DO use space-between — there the
   caption and the image are the same width, so the right edge reads correctly.) */
.diary-pair figcaption,
.diary-carousel figcaption{display:flex;align-items:center;justify-content:flex-start;gap:var(--sp-2)}

/* ═══════════════════════════════════════════════════════════════════════════
   ZENY — scoped to .zeny on <main> (work/zeny.html)

   Palette is the approved style-guide system ONLY: black #030303, coffee bean
   #311C21, terracotta #F74E35, dust grey #DDD3CB. The brand book's teal/mint/
   navy/violet system was never approved and appears nowhere.

   --zeny-accent is per-theme for the same reason the site accent is: true
   terracotta #F74E35 is only 2.95:1 on the light ground, fine for large text
   and UI but short of 4.5:1 for body-size text.

   RE-MEASURED 2026-08-31 for the new grounds, and the old light value no longer
   held: #C93A1A was 4.73:1 against the old #F6F6F4 but drops to 4.38:1 against
   #ECEEEA and FAILS. #B83415 clears at 5.08:1. This is exactly the kind of
   breakage a ground change causes quietly, and the responsive sweep is what
   caught it — ten views reporting 4.38:1 on .chip-num and .subsystem-tag.

   Dark keeps the true brand colour: #F74E35 measures 5.27:1 on #131619, so the
   dark theme gets to be brand-accurate where light cannot. The palette SWATCHES
   always render true #F74E35 in both themes — they are content, not text.
   Dust grey is 1.36:1 on light: never use it as text outside a dark surface.
   ═══════════════════════════════════════════════════════════════════════════ */
.zeny{--zeny-accent:#B83415}              /* 5.08:1 on --bg */
[data-theme="dark"] .zeny{--zeny-accent:#F74E35}  /* 5.27:1 on --bg */

/* LT Saeada — LyonsType, SIL Open Font License 1.1 (see lt_saeada/OFL.txt).
   Self-hosting is permitted by the licence. Four weights only: the family ships
   eleven (~231 KB); the specimen needs four (~85 KB). */
@font-face{font-family:'LT Saeada';src:url('../work/images/Zeny/lt_saeada/LTSaeada-Regular.otf') format('opentype');font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:'LT Saeada';src:url('../work/images/Zeny/lt_saeada/LTSaeada-Medium.otf') format('opentype');font-weight:500;font-style:normal;font-display:swap}
@font-face{font-family:'LT Saeada';src:url('../work/images/Zeny/lt_saeada/LTSaeada-Bold.otf') format('opentype');font-weight:700;font-style:normal;font-display:swap}
@font-face{font-family:'LT Saeada';src:url('../work/images/Zeny/lt_saeada/LTSaeada-Black.otf') format('opentype');font-weight:900;font-style:normal;font-display:swap}
.zeny-face{font-family:'LT Saeada','Geist',ui-sans-serif,system-ui,sans-serif}
.zeny .bs-aa.zeny-face{font-weight:900}

/* Tag-chip internals — shared by the failure modes, personas, principles,
   voice rules and pillars. .tag-chip itself already supplies border, radius,
   padding and line-height. */
.chip-title{display:block;font-weight:700;font-size:15px;letter-spacing:-0.01em;color:var(--text)}
.chip-body{display:block;margin-top:var(--sp-2);font-size:14px;line-height:1.5;color:var(--muted);font-weight:400}
/* Falls back to the site accent off the Zeny page, where --zeny-accent is not defined. */
.chip-num{display:block;margin-bottom:var(--sp-2);font-size:11px;color:var(--zeny-accent, var(--accent))}

/* Grid variants. .tag-grid is 4-up; principles need 5-up so five items do not
   wrap 4 + 1, and the palette needs 4-up. Both collapse on the site's
   established 800/520 breakpoints. */
.tag-grid-4{grid-template-columns:repeat(4,1fr)}
.tag-grid-5{grid-template-columns:repeat(5,1fr)}
.brand-grid-4{grid-template-columns:repeat(4,1fr)}
@media (max-width:800px){
  .tag-grid-4,.tag-grid-5{grid-template-columns:repeat(2,1fr)}
  .brand-grid-4{grid-template-columns:repeat(2,1fr)}
}
@media (max-width:520px){
  .tag-grid-4,.tag-grid-5{grid-template-columns:1fr}
  .brand-grid-4{grid-template-columns:1fr}
}

/* Audience matrix — two columns of three, B2B by scale against B2C by intent. */
.aud-matrix{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4)}
.aud-col{display:flex;flex-direction:column;gap:var(--sp-2)}
.aud-head{display:block;font-size:11px;color:var(--zeny-accent);margin-bottom:var(--sp-1)}
.aud-sub{color:var(--muted);text-transform:none;}
.aud-cell{border:1px solid var(--line);border-radius:var(--r-md);padding:var(--sp-4)}
.aud-name{display:block;font-weight:700;font-size:15px;line-height:1.4}
.aud-meta{display:block;margin-top:var(--sp-1);font-size:12px;line-height:1.5;color:var(--muted)}
@media (max-width:640px){.aud-matrix{grid-template-columns:1fr}}

/* Zeny rhythm joins — the sibling rules in the Vertical rhythm block cover
   .cs-body <-> media/grid, but not these pairings. Declared here rather than as
   inline margins on the markup, so all spacing stays in one system. */
/* --sp-10, not --sp-4. 16px was tuned when .tag-chip was a filled card: two card
   grids that close together read as one continuous mosaic, which was the intent.
   With the fill removed the cards are gone and 16px puts two separate groups of
   bare text a hair apart, so they read as one list. The fill was doing the
   separating; the gap has to do it now. */
.zeny .tag-grid + .tag-grid{margin-top:var(--sp-10)}
.zeny .cs-body + .tag-grid,
.zeny .tag-grid + .cs-body,
.zeny .cs-body + .aud-matrix,
.zeny .aud-matrix + .cs-body,
.zeny .bento-grid + .cs-body,
.zeny .cs-body + .brand-showcase,
.zeny .reflection-quote + .cs-body{margin-top:var(--sp-10)}
.zeny .brand-showcase + .brand-grid,
.zeny .brand-grid + .subsystems{margin-top:var(--sp-10)}
.zeny .subsystem .cs-body{margin-bottom:var(--sp-6)}

/* Section labels on this page must use the Zeny accent, not the site accent.
   .subsystem-tag defaults to var(--accent), which is the site's teal-green —
   on a terracotta brand page that reads as off-brand, and worse, it looks like
   the teal direction that was explicitly rejected. */
.zeny .subsystem-tag{color:var(--zeny-accent)}

/* C2C framework block — the organising idea of the Zeny identity.
   "Chaos" is set in the muted tone and "Craft" in the brand accent so the line
   performs the move it describes. Both sit at body weight/size, so the accent
   colour still has to clear 4.5:1 — which is why --zeny-accent darkens in light
   theme rather than using the true terracotta. */
/* Bottom rule only. It used to be framed top AND bottom, which was fine while
   every section heading also carried a rule above it. Once those came off
   (2026-09-01) this became the only heading-adjacent hairline left on the site,
   sitting directly under "Identity system" and reading as a stray section
   divider rather than as the top of a callout. The bottom rule alone still
   separates the callout from the prose under it, which is the job it was doing.
   NOTE: the taste audit's "top+bottom rule rows" check also flagged this, but as
   a FALSE POSITIVE — that rule is about a hairline repeated on every row of a
   long list, and this is one callout. It was changed for the reason above, not
   to satisfy the check. */
.c2c{display:flex;align-items:flex-start;gap:clamp(20px,4vw,40px);flex-wrap:wrap;
     border-bottom:1px solid var(--line);
     padding:0 0 clamp(24px,4vw,40px)}
.c2c-mark{font-size:clamp(56px,9vw,112px);line-height:0.9;font-weight:900;
          letter-spacing:-0.03em;color:var(--zeny-accent);flex:none}
.c2c-body{flex:1 1 320px;min-width:0}
.c2c-line{margin:0 0 var(--sp-3);font-family:'Bricolage Grotesque',sans-serif;
          font-weight:700;font-size:clamp(22px,3vw,32px);line-height:1.2;
          letter-spacing:-0.02em;color:var(--text)}
.c2c-chaos{color:var(--muted)}
.c2c-craft{color:var(--zeny-accent)}
.zeny .c2c + .cs-body{margin-top:var(--sp-10)}
.zeny .cs-section-head + .c2c{margin-top:0}
@media (max-width:520px){.c2c{gap:var(--sp-4)}}

/* ── Scroll-triggered reveals ────────────────────────────────────────────────
   Progressive enhancement, deliberately: the hidden state is scoped to
   .reveal-ready, which site.js puts on <html> ONLY when JS is running and the
   user has not asked for reduced motion. So with JS off, or with reduced motion
   on, every element is simply visible — the content is never hidden by a script
   that might not run.

   Motion is small on purpose (14px, 500ms). This is an editorial portfolio; the
   reveal should register as the page settling, not as an entrance.

   NOTE for layout verification: an element that has not yet revealed sits 14px
   low, which will skew getBoundingClientRect measurements. Any overlap/overflow
   audit should run `document.documentElement.classList.remove('reveal-ready')`
   first. Revealed elements are transform:none and measure correctly. */
/* RETIRED 2026-08-31 with the Practice migration. The rules that hid content
   until it scrolled into view are gone, and site.js no longer sets
   .reveal-ready or [data-reveal] at all.

   This is a safe removal by construction rather than by testing: the hidden
   state was already scoped to a class only added when JS ran AND reduced motion
   was off, so "no reveals" was already the failsafe path that a reader with JS
   disabled or motion reduced always got. Removing the enhancement lands every
   reader on that same path.

   The layout-verification note that used to live here is also moot: nothing
   sits 14px low any more, so getBoundingClientRect measures the settled
   position on first paint and the audit scripts no longer need to strip a
   class before measuring. */

/* Print: reveals must never withhold content from paper. Only the elements the
   reader happened to scroll past would otherwise print, and a portfolio page is
   a plausible thing to print or "Save as PDF". */
@media print{
  .reveal-ready [data-reveal]{opacity:1!important;transform:none!important;transition:none!important}
}

/* Photography direction cards — fixed frame, image fills it.
   The three source photos are 0.893, 0.684 and 0.668 wide-to-tall, so letting
   each keep its own ratio rendered them ragged and unequal. A single 3:4 frame
   (closest to the set, so the least crop) plus object-fit:cover makes the row
   uniform and means the sources never need cropping by hand — the frame does it.
   object-position centres the crop; the subject is centred in all three. */
.photo-shot img{width:100%;height:auto;aspect-ratio:3/4;object-fit:cover;object-position:center;display:block}

/* ═══════════════════════════════════════════════════════════════════════════
   ORDER MANAGEMENT SYSTEM — scoped to .oms (work/order-management-system.html)

   Same product family as Lucrum POS (this is its back-office/branch-ops
   counterpart), so it intentionally shares the site's global palette/type
   scale rather than getting its own accent — the redesign is about layout
   discipline, not a new brand. Two things earned page-specific rules:

   1. The two .bento-grid sections had every cell forced to an identical
      grid-column:span 2 inline, flattening the grid's asymmetric design into
      a uniform checkerboard. Fixed with layout-only modifier classes so the
      site's existing 800/520 breakpoints keep working (an inline
      grid-column, like an inline grid-template-columns elsewhere in this
      file, would beat any media query regardless of viewport).
   NOT A GAP — resolved 2026-08-20 by looking at the actual screen. This note
   used to say section 05's copy ("collections are green and additive,
   settlements red and subtractive") described something the page did not do. The
   ledger *does* do exactly that: green +amounts for collections, red -amounts
   for settlements. Nobody could see it because the rider-financials screenshot
   was captured before the ledger loaded, so the table was empty in every image.
   The note was only ever true of these CHIPS, which carry no tone colour and do
   not need any — the copy refers to the ledger, not to them. An earlier version
   also claimed --tone-positive/--tone-warn made it literal; those properties
   never existed. Verify against a working capture before trusting a note here.
   ═══════════════════════════════════════════════════════════════════════════ */

/* Section 02 "System architecture": ERPNext + Go are the two dominant halves
   (tall, tinted via the existing .problem background), WHY SPLIT / THE SEAM
   are a shorter supporting row underneath. nth-child matches this section's
   fixed problem-problem-issue-issue order. */
.oms .oms-systems .bento-cell.problem:nth-child(1){grid-column:1/3;grid-row:1/3}
.oms .oms-systems .bento-cell.problem:nth-child(2){grid-column:3/5;grid-row:1/3}
.oms .oms-systems .bento-cell.issue:nth-child(3){grid-column:1/3;grid-row:3/4}
.oms .oms-systems .bento-cell.issue:nth-child(4){grid-column:3/5;grid-row:3/4}
/* :nth-child raises specificity above a plain .bento-cell.problem selector, so
   the breakpoint overrides below repeat the same :nth-child qualifiers —
   otherwise the "desktop" rule above would outrank them at any width. */
@media (max-width:800px){
  .oms .oms-systems .bento-cell.problem:nth-child(1),
  .oms .oms-systems .bento-cell.problem:nth-child(2),
  .oms .oms-systems .bento-cell.issue:nth-child(3),
  .oms .oms-systems .bento-cell.issue:nth-child(4){grid-column:1/3;grid-row:auto}
}
@media (max-width:520px){
  .oms .oms-systems .bento-cell.problem:nth-child(1),
  .oms .oms-systems .bento-cell.problem:nth-child(2),
  .oms .oms-systems .bento-cell.issue:nth-child(3),
  .oms .oms-systems .bento-cell.issue:nth-child(4){grid-column:1/2}
}

/* Section 05 "Prototype to production": THE CONTRACT and WHAT IT BOUGHT are
   cause and payoff, not a hierarchy — equal-width, single row, differentiated
   by the existing .problem/.issue tint rather than by size. */
.oms .oms-contract .bento-cell.problem{grid-column:1/3;grid-row:1/2}
.oms .oms-contract .bento-cell.issue{grid-column:3/5;grid-row:1/2}
@media (max-width:800px){
  .oms .oms-contract .bento-cell.problem,
  .oms .oms-contract .bento-cell.issue{grid-column:1/3;grid-row:auto}
}
@media (max-width:520px){
  .oms .oms-contract .bento-cell.problem,
  .oms .oms-contract .bento-cell.issue{grid-column:1/2}
}

/* The hero de-duplication crop that used to live here is gone. It forced the
   OMS hero into a 3/1 band purely to disguise the fact that orders-queue.jpg
   was both the hero and section 03's first screenshot — treating the symptom
   of an asset shortage with CSS. The hero is now its own image
   (orders-intelligence.jpg), so the page needs no hero override at all. */

/* ── Dagwood: brand foundation, type specimen, palette provenance ────────────
   Section 07 of work/dagwood.html shows the client's own brand book resolving
   into the shipped product's design system. Two consequences for the CSS:

   1. The specimens are set in the REAL faces. Bebas Neue and Lato are loaded on
      dagwood.html for this block alone. Setting a type specimen in the
      portfolio's own typeface (as the Lucrum showcase does) makes the section a
      claim rather than a demonstration — the whole point here is that you can
      see the face.
   2. The brand band is deliberately NOT theme-aware. #B41E05 is the brand's
      constant, the reversed lockup is the variant the book specifies for it,
      and the pattern PNG is a white silhouette so it tints by opacity alone.
      Everything else in the section follows the page's light/dark tokens.
   ──────────────────────────────────────────────────────────────────────────── */
.dag-bebas{font-family:'Bebas Neue','Bricolage Grotesque',sans-serif;font-weight:400}
.dag-lato{font-family:'Lato','Geist',ui-sans-serif,system-ui,sans-serif}

/* Brand mark + essence. Replaced a full-bleed red slab carrying a reversed
   lockup, a tiled pattern and 40px display type — too loud for an opener to a
   section whose job is to be systematic. The mark is the app's real splash
   animation, converted from its Lottie to inline SVG so it costs ~3KB instead of
   lottie-web's ~150KB, and so it can be paused for prefers-reduced-motion and
   tinted with currentColor. Brand red is kept as the mark's own colour rather
   than as a background, so the block sits on the page's theme like everything
   else in the section. */
.dag-mark-block{margin:0;display:flex;flex-direction:column;align-items:center;
  gap:var(--sp-6);text-align:center;padding:clamp(24px,4vw,44px) var(--sp-4)}
.dag-mark{width:clamp(84px,11vw,124px);height:auto;color:#B41E05;display:block}
.dag-mark-cap{display:flex;flex-direction:column;align-items:center;gap:var(--sp-3)}
.dag-essence{margin:0;max-width:24ch;font-size:clamp(21px,3.2vw,32px);line-height:1.15;
  letter-spacing:0.015em;color:var(--text);text-wrap:balance}
.dag-mark-src{font-size:11px;color:var(--muted)}

/* The staggered drop-in that mirrored the client's Lottie is retired with the
   reveal system it was gated on. The mark now simply renders in place, which
   was already what a reader with JS off or reduced motion on always saw. The
   SVG itself is untouched: this was the animation, not the artwork. */

/* Type specimen — reuses Lucrum's .bs-type / .bs-aa / .bs-type-info /
   .bs-type-name / .bs-type-sample verbatim rather than defining a parallel set of
   dag-* card classes. Borderless and inline by design: the bordered cards this
   replaced read as a different component from the identical block on the Lucrum
   page. All this file adds is the 2-up grid (Dagwood shows two faces where Lucrum
   shows one) and a mono role line. */
.dag-type{display:grid;grid-template-columns:repeat(2,1fr);gap:clamp(24px,4vw,48px)}
.dag-type .bs-type-sample{font-size:clamp(14px,1.5vw,18px);overflow-wrap:anywhere}
/* Bebas Neue is condensed all-caps with no lowercase, so at the shared sample
   size it sets far smaller and tighter than Lato does and the specimen stops
   being readable. Two corrections, both of which the brand book already asks for:
   size it up so the cap height roughly matches Lato's x-height-plus-ascender, and
   apply the book's own display tracking (2% of font size — the same rule stated
   in the "Tracking on display type" note further down this section). */
.dag-type .bs-type-sample.dag-bebas{font-size:clamp(19px,2.2vw,26px);
  letter-spacing:0.02em;line-height:1.3}
@media (max-width:860px){.dag-type{grid-template-columns:1fr}}

/* Logo usage tiles. These are deliberately NOT theme-aware: each tile shows the
   background the brand book specifies that lockup for, so the trio has to look
   the same in light and dark or it stops being a usage exhibit. */
.dag-lockups{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-4)}
.dag-lockups figure{margin:0}
.dag-lockup{display:flex;align-items:center;justify-content:center;min-height:118px;
  /* 18/30 were both off the 4pt grid; 20/32 are the neighbouring steps
     (--sp-5 / --sp-8). A clamp cannot take var() for its bounds here without
     losing the 3vw middle term, so the literals stay — but they are now grid
     values. Net effect is 2px more padding at each end of the range. */
  padding:clamp(20px,3vw,32px);border:1px solid var(--line);border-radius:var(--r-md);
  background:var(--bg)}
.dag-lockup img{width:100%;max-width:200px;height:auto;display:block}
/* 14px, matching every other live caption on the site. It was 11px, the only
   caption at that size anywhere, which made the three lockup labels read as a
   different class of text from the captions beside them on the same page. */
.dag-lockups figcaption{margin-top:var(--sp-3);font-size:14px;line-height:1.5;color:var(--muted)}
@media (max-width:640px){.dag-lockups{grid-template-columns:1fr}}

/* ── Client roster ───────────────────────────────────────────────────────────
   Fifteen brands running Lucrum POS, as two rows scrolling in opposite
   directions. It sits under the §01 bento grid because it is the same argument
   the stat tiles are making — this shipped and it is in daily use — in a form a
   reader can verify rather than take on trust.

   Monochrome at rest, colour on hover. The reason is not taste: these fifteen
   marks carry fifteen unrelated palettes (a red tile, a dark-green tile, an
   orange tile, a navy wordmark), and at full colour side by side they read as a
   ransom note and pull the eye off the case study. One ink makes them read as a
   single component; the hover returns the brand to anyone who looks closer.

   NOTE ON TOUCH: phones have no hover, so on a phone this stays monochrome
   throughout. That is deliberate — the alternative, full colour on touch only,
   makes the phone and desktop pages look like two different designs.

   Sizing: each logo carries its own inline --h. A single shared height cannot
   work when the set runs from a 9.5:1 serif wordmark to a 1:1 app tile — at
   equal height the wordmark carries six times the area. The values are set by
   eye for equal optical weight, which is the same judgement a designer makes on
   a logo wall, and is why they are per-instance rather than a class.
   ──────────────────────────────────────────────────────────────────────────── */
.client-roster{border-top:1px solid var(--line);border-bottom:1px solid var(--line);
  padding:var(--sp-6) 0}
.roster-head{display:flex;justify-content:space-between;align-items:center;gap:var(--sp-4);
  margin-bottom:var(--sp-5);font-size:11px;
  color:var(--muted)}
.roster-head .count{color:var(--accent)}
/* On a phone the three items in one row squeeze the label onto two lines and
   crowd the 44px button. Give the label its own line and let the control and
   the count share the next one. */
@media (max-width:640px){
  .roster-head{flex-wrap:wrap;gap:var(--sp-3)}
  .roster-head > span:first-child{flex:1 0 100%}
  .roster-head .count{margin-left:auto}
}

/* ── Client roster ──────────────────────────────────────────────────────────
   Was a two-row opposing marquee on a 48s loop until 2026-08-31. Now static.

   The change is not a rewrite: this component already shipped a
   prefers-reduced-motion fallback that wrapped the track like a grid, dropped
   the duplicated copy and hid the pause button. That fallback WAS the static
   design, already written and already tested, so the migration promotes it to
   the only state rather than inventing a second one.

   Why static at all: a marquee is what you reach for when you are covering for
   thin content, and fifteen real businesses is not thin. Standing still and
   letting a reader count them is the more confident move, and it costs nothing
   from a motion budget that is now close to zero. It also retires the pause
   control that WCAG 2.2.2 required only because the thing moved. */
.roster-marquee{overflow:hidden}
.roster-marquee + .roster-marquee{margin-top:var(--sp-6)}
/* Three of the roster logos link out to the client. Those anchors were as tall
   as their artwork (38px for Dagwood) rather than as tall as a tap target, and
   a target inside a moving marquee is hard enough to hit already. The track is
   align-items:center, so a 44px floor on the anchor only changes the row height
   if the tallest logo is under 44 — it is not. */
.roster-track a{min-height:44px;display:inline-flex;align-items:center}
.roster-track{display:flex;flex-wrap:wrap;width:100%;align-items:center;
  justify-content:center;gap:var(--sp-6) var(--sp-8)}
/* The second row holds seven items to the first row's eight, so one copy of it
   measures ~1050px — narrower than a desktop viewport, which puts the same logo
   on screen twice. The wider gap pushes one copy past the fold. Desktop only:
   on a phone one copy already outruns the viewport, and a 120px gap there would
   cost exactly what this two-row layout was chosen for, which is how many
   brands are on screen at once. */
.roster-track.rev{gap:var(--sp-6) var(--sp-8)}

/* Phone: scale the marks down to 80% and tighten the gap. Two rows on a phone
   were chosen over a static grid precisely to get more brands on screen at
   once, and at full size only two fit per row, which gives that back. */
@media (max-width:640px){
  .roster-track, .roster-track.rev{gap:var(--sp-8)}
  .roster-logo{height:calc(var(--h,40px) * .8)}
  .roster-logo.tile{width:calc(var(--h,48px) * .8)}
  .roster-name{font-size:16px}
}
/* The duplicated second copy existed only so the loop could seam. It is
   aria-hidden, so dropping it removes fifteen silent duplicates from the
   accessibility tree as well as from the page. */
.roster-dupe{display:none}

/* Hovering a row pauses it, so the logo under the cursor is readable rather than
   sliding away from it. Focus does the same, for the Dagwood link inside. */

/* The duplicate copy must not become a flex item of its own — display:contents
   flattens it into the track. It lives here and not inline because the
   reduced-motion rule below needs to be able to hide it, and an inline style
   would outrank a media query. */

/* Greyscale alone is not enough, because these marks do not share a luminance
   range: Layers is pale gold and Cinnabon is near-black. Desaturated straight,
   the gold lands almost exactly on the light background and disappears. Each
   theme therefore also pushes luminance toward its own contrast direction —
   darker on light, lighter on dark — so every mark lands in a visible band. */
.roster-logo{height:var(--h,40px);width:auto;max-width:190px;object-fit:contain;display:block;
  filter:grayscale(1) contrast(1.1) brightness(.72) opacity(.85);
  transition:filter .25s ease}
[data-theme="dark"] .roster-logo{filter:grayscale(1) contrast(1.05) brightness(1.45) opacity(.8)}
/* The four app-icon marks carry their own opaque background, so they are already
   self-contained and must NOT get the per-theme luminance push the transparent
   marks need — pushed lighter on dark they flatten into featureless grey blocks
   and lose the artwork inside them. Greyscale and a little contrast only. */
.roster-logo.tile,
[data-theme="dark"] .roster-logo.tile{width:var(--h,48px);border-radius:var(--r-md);
  filter:grayscale(1) contrast(1.15) opacity(.82)}
.roster-logo:hover,
[data-theme="dark"] .roster-logo:hover,
.roster-logo.tile:hover,
[data-theme="dark"] .roster-logo.tile:hover{filter:none}

/* Theme safety. Artwork that is pure black or pure white vanishes against one of
   the two backgrounds; invert+hue-rotate flips lightness while holding hue, so a
   coloured mark survives the flip instead of turning into its complement. These
   have to restate the greyscale stack because they replace the whole filter. */
[data-theme="dark"]  .roster-logo[data-ink="dark"]{
  filter:invert(1) hue-rotate(180deg) grayscale(1) brightness(1.45) opacity(.8)}
[data-theme="light"] .roster-logo[data-ink="light"]{
  filter:invert(1) hue-rotate(180deg) grayscale(1) brightness(.72) opacity(.85)}
[data-theme="dark"]  .roster-logo[data-ink="dark"]:hover,
[data-theme="light"] .roster-logo[data-ink="light"]:hover{
  filter:invert(1) hue-rotate(180deg)}
[data-theme="light"] .roster-only-dark{display:none}
[data-theme="dark"]  .roster-only-light{display:none}

/* Two brands have no usable logo file, so they are set rather than faked. */
.roster-name{font-family:'Bricolage Grotesque',sans-serif;font-weight:700;font-size:18px;
  letter-spacing:-.01em;white-space:nowrap;color:var(--muted);transition:color .25s ease}
.roster-name:hover{color:var(--text)}

/* The pause control is gone with the motion it existed to pause. WCAG 2.2.2
   required it because the marquee started on its own and ran past five seconds;
   a static grid triggers none of that. Its rules are removed rather than left
   hidden, so nothing here describes a control the page no longer has.

   The prefers-reduced-motion block that used to follow is gone for the same
   reason: it described the static layout, and the static layout is now the only
   one. Keeping it would have been a media query that changes nothing. */

/* ════════════════════════════════════════════════════════════════════════════
   THE PRACTICE LAYER
   Added 2026-08-31, migrating explore/practice/ onto the live site.

   These are the compositional classes the Practice direction introduced. They
   are written against this file's existing tokens (--bg / --text / --line /
   --sp-* / --r-lg) rather than Practice's own names, so they inherit both
   themes automatically instead of needing a second palette.

   The organising idea, for anyone reading this later: WORDS AND EVIDENCE SHARE
   A HORIZONTAL BAND. The old pages ran as a stack — a prose block at a 66ch
   measure, then a full-width image block, repeating — which left the same void
   to the right of every paragraph and made every section the same shape.
   ════════════════════════════════════════════════════════════════════════════ */

/* ── Bands ────────────────────────────────────────────────────────────────
   5fr/7fr rather than an even split: a screenshot needs the width more than a
   paragraph does. .flip mirrors BOTH the order and the tracks, so the media
   keeps the wider column whichever side it sits on — flipping only the order
   silently demotes the image into the narrow column. */
.band{display:grid;grid-template-columns:5fr 7fr;gap:var(--sp-6);
  align-items:start;margin-top:var(--sp-8)}
.band.flip{grid-template-columns:7fr 5fr}
.band.flip > .words{order:2}
.band.flip > .media{order:1}
/* Inside a band the column IS the measure, so a 66ch cap would pull the text
   off the column's right edge and reopen the void one level down. */
.band .words p{max-width:none}
.band .words p + p{margin-top:var(--sp-5)}
/* This stylesheet has NO global img{max-width:100%} — every component
   constrains its own images, because the component mosaic depends on fixed-px
   sizing that a global cap would fight. So a band figure needs its own rule, and
   without it a 3200px screenshot rendered at natural width and blew the page out
   to 6440px. */
.band .media figure{margin:0}
/* Scoped to the band's OWN figures with the child combinator, NOT to every
   image nested anywhere inside it. A bare `.band .media img` also matched the
   wordmark inside .brand-showcase and the pattern strip beside it — components
   that size themselves (.bs-logo sets a height) — and stretched a 68px-tall
   logo across the full 700px column. */
.band .media > figure:not([class]) > img,
.band .media > .pair > figure:not([class]) > img{width:100%;height:auto;display:block;border-radius:var(--r-lg)}
/* Same reasoning: a .ui-shot caption is a bar with its own type, so only the
   band's own captions get the muted 14px treatment the control uses. */
.band .media > figure:not([class]) > figcaption,
.band .media > .pair > figure:not([class]) > figcaption{margin-top:var(--sp-3);font-size:14px;
  line-height:1.5;color:var(--muted);max-width:60ch}
.band .media figure + figure{margin-top:var(--sp-4)}
.band .media .pair{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4)}
.band .media .pair figure + figure{margin-top:0}

/* Two columns of prose, for sections that genuinely have no image. Better than
   one column with a void beside it, and it breaks a run of bands without
   pretending an illustration exists. */
.twocol{margin-top:var(--sp-8);display:grid;grid-template-columns:1fr 1fr;
  gap:var(--sp-6);align-items:start}
.twocol p{max-width:none}
/* A reflection sitting in one half of a twocol is in a ~570px column, where the
   full-measure clamp(26px,4vw,48px) resolves to 48px off the VIEWPORT width and
   swamps the prose beside it. Inside a twocol it takes the control's size, and
   the oversized quote glyph gives way to a rule, which is what marks a quote
   when the type is no longer big enough to do it alone. */
.twocol .reflection-quote{padding-left:var(--sp-6);border-left:2px solid var(--text)}
.twocol .reflection-quote .quote-mark{display:none}
.twocol .reflection-quote p{font-size:clamp(19px,2.2vw,25px);line-height:1.32;max-width:34ch}
.twocol .reflection-quote cite{margin-top:var(--sp-4);font-size:15px}

@media (min-width:901px){
  .band, .twocol{gap:var(--sp-12)}
}
@media (max-width:900px){
  /* .band.flip is listed explicitly: two classes plus a child selector outrank
     .band, so it would otherwise keep its 7fr/5fr tracks on a phone. */
  .band, .band.flip{grid-template-columns:1fr}
  .band.flip > .words, .band.flip > .media{order:0}
  .band .media{margin-top:var(--sp-5)}
  .twocol{grid-template-columns:1fr}
}
/* A .pair holds two application screenshots. Once the band itself is one column
   the pair still splits that column in two, which put a 2880px-wide register
   screen into ~160px at 390 and made it a grey smudge. Same reasoning as
   .sub-grid-2 collapsing at 1024 rather than 800: the shot decides, not the
   column count. */
@media (max-width:640px){
  .band .media .pair{grid-template-columns:1fr}
  .band .media .pair figure + figure{margin-top:var(--sp-4)}
}

/* ── Opening ──────────────────────────────────────────────────────────────
   Three text elements: headline, one sentence, one link. No eyebrow. */
.opening{padding-block:clamp(56px,10vw,132px) clamp(48px,7vw,88px)}
.opening h1{max-width:15ch;margin:0;font-family:'Bricolage Grotesque',sans-serif;
  font-variation-settings:'opsz' 96;font-weight:800;
  font-size:var(--fit-size, clamp(38px,6.6vw,82px));
  line-height:1.02;letter-spacing:-0.032em;text-wrap:balance}
.opening .lede{margin:clamp(24px,3.5vw,40px) 0 0;max-width:52ch;
  font-size:clamp(18px,2vw,21px);line-height:1.5;color:var(--muted)}
.opening .go{margin:clamp(24px,3vw,36px) 0 0;font-size:17px}

/* ── Work rows ────────────────────────────────────────────────────────────
   Rows, not a 2x2 card grid. A card grid forces a four-week POS rebuild and an
   unshipped brand concept into the same box; rows let each take the room its
   evidence deserves, and the composition can change down the page. */
.project{padding-block:clamp(40px,5.5vw,72px)}
/* The FIRST row needs no top padding. That padding exists to separate one row
   from the next (with .project + .project drawing the rule between them); on the
   first row it stacks on top of .sec-head margin-bottom and put ~120px between
   "Selected work" and its own first screenshot, more space under the heading
   than the 80px above it. The section heading owns that gap. */
.project:first-of-type{padding-top:0}
.project + .project{border-top:1px solid var(--line-soft)}
.project .shot{background:var(--surface);border-radius:var(--r-lg);overflow:hidden}
/* height:auto is NOT optional here. Every <img> carries width/height
   attributes to reserve space, and those are presentational hints: with
   width:100% and no height rule the hint wins on the vertical axis and the
   image is stretched to its attribute height. This file already warns about
   exactly that further up; I wrote the rule without it anyway. */
.project .shot img{width:100%;height:auto;display:block}
.project .title{display:flex;align-items:baseline;gap:var(--sp-4);
  flex-wrap:wrap;margin-bottom:var(--sp-3)}
.project .title h3{margin:0;font-family:'Bricolage Grotesque',sans-serif;
  font-weight:700;font-size:clamp(22px,2.6vw,30px);letter-spacing:-0.02em}
/* The project name is the primary way into a case study, so it has to clear the
   44px floor. An anchor around a heading collapses to its line box; padding
   grows the hit area and an equal negative margin takes the space back, so the
   target reaches 44px while nothing visibly moves. */
.project .title a{color:var(--text);text-decoration:none;display:inline-block;
  padding-block:11px;margin-block:-11px;min-width:44px}
.project .title a:hover{text-decoration:underline;text-decoration-thickness:2px;
  text-underline-offset:3px}
/* Status is a plain word, not a coloured pill with a dot. "Live" already says
   live, and a status dot before every row is on the banned list. */
.project .status{font-size:14px;color:var(--muted)}
.project .result{margin-top:var(--sp-4);font-size:clamp(17px,1.7vw,19px);line-height:1.5}
/* The one place the accent appears outside a link. Numbers are the argument on
   this page, so they take the emphasis a stat tile would otherwise get from
   being 56px tall in a box of its own. */
.project .result b{font-weight:600;color:var(--accent)}
.project .facts{margin-top:var(--sp-5);display:flex;flex-wrap:wrap;
  gap:var(--sp-2) var(--sp-6);font-size:14px;color:var(--muted)}
/* One separator, and it is a rule rather than a middle dot. Chained middle dots
   are their own tell. */
.project .facts span + span{border-left:1px solid var(--line);padding-left:var(--sp-6)}
.project .facts a{display:inline-block;padding-block:13px;margin-block:-13px}

.project.wide .strip{margin-top:var(--sp-6);display:grid;gap:var(--sp-8);
  grid-template-columns:1.2fr 1fr;align-items:start}
.project.split .row{display:grid;gap:var(--sp-8);grid-template-columns:7fr 5fr;align-items:center}
.project.split.flip .row{grid-template-columns:5fr 7fr}
.project.split.flip .row > .shot{order:2}
.project.split.flip .row > .body{order:1}
/* A concept does not get the same room as shipped work, and saying so with size
   is more honest than saying it in a label. */
.project.compact .row{display:grid;gap:var(--sp-8);grid-template-columns:4fr 6fr;align-items:center}
.project.compact .shot{max-width:380px}

@media (max-width:900px){
  .project.wide .strip,
  .project.split .row,
  .project.split.flip .row,
  .project.compact .row{grid-template-columns:1fr}
  .project.split.flip .row > .shot,
  .project.split.flip .row > .body{order:0}
  .project.compact .shot{max-width:100%}
  .project .facts{flex-direction:column;gap:var(--sp-1)}
  .project .facts span + span{border-left:0;padding-left:0}
}

/* ── Method ───────────────────────────────────────────────────────────────
   Promoted from the last paragraph on the old homepage to a section of its own.
   For anyone hiring a product designer in 2026 this is the most differentiating
   material on the site, and it was the easiest thing to miss. */
.method .grid{display:grid;gap:var(--sp-8);
  grid-template-columns:repeat(auto-fit,minmax(260px,1fr));margin-top:var(--sp-8)}
.method .item{border-top:2px solid var(--text);padding-top:var(--sp-5)}
.method .item h3{margin:0 0 var(--sp-3);font-family:'Bricolage Grotesque',sans-serif;
  font-weight:600;font-size:clamp(19px,2vw,23px);letter-spacing:-0.012em}
.method .item p{font-size:16px;color:var(--muted);line-height:1.6}

/* ── The client wall ──────────────────────────────────────────────────────
   Fifteen businesses, five columns, three rows. Five and three are the only
   counts that divide fifteen exactly; auto-fill picked seven at desktop and
   left a single orphan trailing a mostly empty row, which reads as a grid that
   ran out of clients. */
.clients .roster-grid{margin-top:clamp(28px,4vw,44px);display:grid;
  grid-template-columns:repeat(5,1fr);gap:1px;background:var(--line-soft);
  border:1px solid var(--line-soft);border-radius:var(--r-lg);overflow:hidden}
.clients .cell{background:var(--surface);min-height:104px;display:flex;
  align-items:center;justify-content:center;padding:var(--sp-5) var(--sp-4)}
/* Normalised to a common optical height rather than a common box, so a wide
   wordmark and a square badge read as the same weight. */
.clients .cell img{height:var(--h,34px);width:auto;max-width:100%;object-fit:contain;
  filter:saturate(.72) contrast(1.02)}

/* THE PLATE STAYS LIGHT IN BOTH THEMES, and the text on it goes dark to match.
   Measured every one of the fifteen marks against both a dark tile and a light
   one: on the dark tile Zara Shahjahan renders 0% of its pixels at 3:1 or
   better and Steak Away renders 1.3%, i.e. invisible, because both are
   dark-inked wordmarks. Some of the others (Love Biryani, What A Paratha) are
   genuinely better on dark, so no single tile is right for all fifteen — which
   is why the old roster carried per-logo data-ink attributes and light/dark
   file variants.

   A light plate is the better compromise rather than the perfect answer: its
   worst case is Phulkari at 2.8% ink, against the dark tile's worst case of 0%.
   It also shows each mark on the ground its owner designed it for, so the wall
   reads as a row of brand plates instead of fifteen logos fighting a backdrop. */
[data-theme="dark"] .clients .cell{background:#F2F3F1}
[data-theme="dark"] .clients .cell span{color:#5B6067}   /* 5.69:1 on the plate */

/* Because that plate is the SAME in both themes, the logos on it must be too.
   They were not. The per-logo `data-ink` inversion and the dark-theme
   brightness lift both still fired, and they were written for a DARK tile that
   no longer exists: a dark-inked wordmark got flipped to light ink and then
   placed on a light plate. Measured, as a percentage of opaque logo pixels that
   contrast with their own cell:

       Zara Shahjahan   0%     Steak Away   4%     Phulkari   34%

   Zero. Invisible, in the client wall that carries the credibility claim, and
   found by an external audit rather than by me. Light theme was fine throughout
   (every mark >=87%), which is why it survived a reader looking at one theme.

   Resetting to the light-theme treatment inside the roster keeps the plate
   logic in one place: one plate, one filter, no per-theme special cases. */
[data-theme="dark"] .clients .cell .roster-logo,
[data-theme="dark"] .clients .cell .roster-logo[data-ink="dark"],
[data-theme="dark"] .clients .cell .roster-logo[data-ink="light"]{
  filter:grayscale(1) contrast(1.1) brightness(.72) opacity(.85)}
[data-theme="dark"] .clients .cell .roster-logo.tile{
  filter:grayscale(1) contrast(1.15) opacity(.82)}
/* The light-ink marks still need the flip, in BOTH themes now, because the
   plate they sit on is light in both. */
[data-theme="dark"] .clients .cell .roster-logo[data-ink="light"]{
  filter:invert(1) hue-rotate(180deg) grayscale(1) brightness(.72) opacity(.85)}
[data-theme="dark"] .clients .cell .roster-logo:hover{filter:none}
/* Two of the fifteen have no usable mark, so they are set as text at the same
   optical weight rather than omitted. A gap would read as an incomplete list. */
.clients .cell span{font-family:'Bricolage Grotesque',sans-serif;font-weight:600;
  font-size:16px;color:var(--muted);text-align:center}
@media (max-width:720px){
  /* 3 also divides 15 exactly, so the grid stays complete at every width. */
  .clients .roster-grid{grid-template-columns:repeat(3,1fr)}
}
@media (max-width:560px){
  .clients .cell{min-height:88px;padding:var(--sp-4) var(--sp-3)}
}

/* ── Shared small pieces ──────────────────────────────────────────────────── */
/* The homepage's content sections had NO declared vertical rhythm. Every one sat
   at padding 0 / margin 0, and the only thing separating them was the UA's
   default margin on the <h2> inside .sec-head — 19.92px, which is below the 27px
   body line-height floor and put "Selected work" almost on top of the client
   grid above it, while the same section put 48px between that heading and its
   first row. Less space BETWEEN sections than inside one.

   Rule 8: rhythm is declared, never inherited. The case studies already do this
   (.cs-section padding-top + .cs-section-head h2{margin:0}); the homepage was
   never given the same treatment. Same clamp so the two page types breathe
   alike. .opening and .contact are excluded: they set their own larger padding
   as the first and last blocks on the page. */
/* section.clients, NOT a bare .clients: the case studies use a
   <div class="clients"> INSIDE a .cs-section that already owns its padding, so
   the bare selector put 80px of unasked-for space in the middle of Lucrum. Only
   the homepage top-level section is meant here. */
section.clients, #work, #method, #about{padding-top:clamp(48px,7vw,80px)}
.sec-head{margin-bottom:clamp(28px,4vw,48px)}
.sec-head h2{margin:0}
.sec-head p{margin-top:var(--sp-3)}
.lede{font-size:clamp(18px,2vw,21px);line-height:1.5}
.muted{color:var(--muted)}
/* A pull fact, used where the page would have reached for a stat tile. Same
   information, a sixth of the furniture. */
.pull{margin:clamp(28px,4vw,44px) 0 0;padding-left:var(--sp-6);
  border-left:2px solid var(--accent);font-family:'Bricolage Grotesque',sans-serif;
  font-weight:600;font-size:clamp(20px,2.4vw,27px);line-height:1.3;
  letter-spacing:-0.018em;max-width:30ch}
/* The line that lets a reader leave and check. Deliberately quieter than the
   pull fact above it: the number is the claim, this is the receipt. */
.verify{font-size:15px;line-height:1.55;color:var(--muted)}
.colophon{border-top:1px solid var(--line);
  padding-block:var(--sp-6) max(var(--sp-6), env(safe-area-inset-bottom));
  font-size:14px;color:var(--muted);
  display:flex;flex-wrap:wrap;align-items:center;gap:var(--sp-2) var(--sp-6);justify-content:space-between}
/* The build credit sits opposite the copyright rather than under it: the row
   already spans the full measure and a second stacked line would read as a
   second colophon. Wraps to its own line under 480px, where they no longer fit
   side by side. */
.colophon .built a{color:inherit;text-decoration:underline;
  text-decoration-thickness:1px;text-underline-offset:3px;
  min-height:44px;display:inline-flex;align-items:center}
.colophon .built a:hover{color:var(--text);text-decoration-thickness:2px}

/* ── Contact ──────────────────────────────────────────────────────────────
   The old footer rules are all scoped to `footer.site-footer`, and the Practice
   contact block is a <section> instead, so none of them reached it and every
   link came back at 21px tall. These are the Practice equivalents. The case
   studies keep the old footer until phase 5 moves them onto this one.

   No "ELSEWHERE" label above the links: the links say what they are, and a
   poetic label for a list of links is on the banned list. */
.contact{padding-block:clamp(56px,8vw,104px) clamp(40px,5vw,64px)}
.contact .f-email{
  display:inline-flex;align-items:center;width:fit-content;min-height:44px;
  margin-top:var(--sp-5);
  font-family:'Bricolage Grotesque',sans-serif;font-variation-settings:'opsz' 72;
  font-weight:700;font-size:clamp(26px,5vw,52px);letter-spacing:-0.03em;
  line-height:1.05;color:var(--text);
  text-decoration-line:underline;text-decoration-thickness:1px;text-underline-offset:6px}
.contact .f-email:hover{text-decoration-thickness:2px}
.contact .f-else{margin-top:clamp(28px,4vw,44px);display:flex;flex-wrap:wrap;
  gap:var(--sp-3) var(--sp-6)}
.contact .f-else a{min-height:44px;display:inline-flex;align-items:center;font-size:16px}

/* A logo in the client wall that links out is a discrete control, not a word
   inside a sentence, so it does not get the inline-prose exemption from the
   44px floor. It laid out at 91x41. */
.clients .cell a{min-height:44px;display:inline-flex;align-items:center;justify-content:center}

/* The UA gives h1 a 0.67em margin, which lands at 21.4px and off the 4pt
   grid. Rhythm on this page is declared, not inherited. */
.cs-head h1{margin:0}
.cs-head .lede{margin-top:var(--sp-6)}

/* Rhythm on these is declared, not inherited. A bare <p> carries the UA's 1em
   margin, which lands at 21px and 15px here and off the 4pt grid — invisible in
   the source, caught only by measuring the rendered page. */
.cs-head .lede{margin:var(--sp-6) 0 0}
.verify{margin:var(--sp-3) 0 0}
.pull + .verify{margin-top:var(--sp-4)}

/* ============================================================================
   LUCRUM CONTROL COMPONENTS
   Ported 2026-09-01 from explore/practice/assets/practice.css when the live
   Lucrum page was rebuilt as a literal copy of the control. .band, .twocol,
   .clients .roster-grid and .twocol .reflection-quote came across in the
   earlier migration; these four did not, and the copy needs all four.

   Token names are translated, not copied. Practice calls these
   --s1..--s12 / --rule / --rule-soft / --r / --display / --ink; here that is
   --sp-1..--sp-12 / --line / --line-soft / --r-md / Bricolage / --text. Same
   values, this sheet's names, per the mapping note at the top of the file.
   ============================================================================ */

/* -- The four-week schedule ------------------------------------------------
   A definition list, not a row of four equal cards. The week number is
   supporting metadata; the work is the heading. It sits INSIDE .words, so it
   declares its own top margin against the paragraph above it. */
.schedule{margin-top:clamp(24px,3.5vw,40px);border-top:1px solid var(--line)}
.schedule .wk{display:grid;grid-template-columns:140px 1fr;
  gap:var(--sp-4) var(--sp-8);padding-block:var(--sp-6);
  border-bottom:1px solid var(--line-soft);align-items:baseline}
.schedule .when{font-size:15px;color:var(--muted)}
.schedule h3{margin:0 0 var(--sp-2);font-family:'Bricolage Grotesque',sans-serif;
  font-weight:600;font-size:18px;letter-spacing:-0.01em}
.schedule .did{margin:0;font-size:16px;line-height:1.6;color:var(--muted)}

/* -- Component library -----------------------------------------------------
   Column flow, not a fixed-row grid. The ten crops run 0.21 to 6.08 in aspect
   ratio, so span classes never sum to a whole number of rows and dense flow
   leaves a four-tile void. Columns let each tile take exactly the height its
   image needs at the column width. The one component too wide for a column
   (the totals row, 6.08) is pulled out above as .comp-lead. */
.comp-lead{margin-top:clamp(24px,3.5vw,40px)}
.comp-grid{margin-top:var(--sp-3);columns:3;column-gap:var(--sp-3)}
/* gap matters only for the totals lead, which carries three sibling images in
   one cell; every other .comp holds a single image and is unaffected. wrap so
   the three cards stack instead of squeezing on a phone. */
.comp{background:var(--surface);border-radius:var(--r-md);
  display:flex;align-items:center;justify-content:center;
  flex-wrap:wrap;gap:var(--sp-3);
  padding:var(--sp-4);break-inside:avoid;margin-bottom:var(--sp-3)}
/* The cap is for the toolbar, 410x1972: at a 380px column it would otherwise
   render a tile nearly two thousand pixels tall. This sheet has NO global
   img{max-width:100%}, so the cap has to be stated on both axes here. */
.comp img{max-width:100%;max-height:440px;width:auto;height:auto}
.comp-lead .comp{margin-bottom:0}
/* The control is a light-only exploration, so var(--surface) was enough there.
   Here --surface goes to #1B1F23 in dark, and these crops are transparent PNGs
   whose label text is coloured for a white canvas — the cart line item, the
   field labels and the option text all dropped to unreadable grey-on-grey.
   Pinning the plate to the light --surface value keeps the tile identical in
   both themes, which is both what the control renders and the same call the
   old .component-card mosaic documents a few hundred lines up. */
[data-theme="dark"] .comp{background:#FBFCFA}

/* -- Interface subsystems --------------------------------------------------
   Whole screens, grouped by the part of the business they belong to. Two up:
   a full application screen at 1.5:1 stops being readable much below half the
   container. Distinct from .subsystems/.subsystem-tag, which the other case
   studies still use. */
.subsys{margin-top:clamp(28px,4vw,44px)}
.subsys + .subsys{margin-top:clamp(32px,4.5vw,52px)}
.subsys h3{margin:0 0 var(--sp-4);font-family:'Bricolage Grotesque',sans-serif;
  font-weight:600;font-size:clamp(17px,2vw,20px);letter-spacing:-0.01em}
.subsys .screens{display:grid;gap:var(--sp-4);grid-template-columns:repeat(2,1fr)}
.subsys figure{margin:0}
.subsys img{width:100%;height:auto;display:block;
  border-radius:var(--r-md);background:var(--surface)}
.subsys figcaption{margin-top:var(--sp-2);font-size:14px;line-height:1.5;
  color:var(--muted);max-width:60ch}

/* -- Palette ---------------------------------------------------------------
   Label under the swatch, on the page ground, rather than set inside the chip.
   Text on a brand colour has to be re-measured for every colour in the set and
   two of these three would need a different ink; putting the label outside
   sidesteps a contrast problem instead of solving it three times. */
.palette{margin-top:clamp(24px,3.5vw,40px);display:grid;gap:var(--sp-5);
  grid-template-columns:repeat(3,1fr)}
.palette .chip{height:96px;border-radius:var(--r-md);background:var(--c)}
.palette .nm{display:block;margin-top:var(--sp-3);font-weight:500;font-size:16px}
.palette .hx{display:block;font-size:14px;color:var(--muted)}

/* -- Adjacency joins for the four above ------------------------------------
   Declared here, never as inline margins on the components. .comp-lead,
   .comp-grid, .subsys, .schedule and .palette each set their own margin-top,
   which covers every pairing this page actually produces; what is NOT covered
   is a .band sitting directly above them, because .band declares only its own
   top margin. */
.band + .comp-lead,  .band + .comp-grid,
.band + .subsys,     .band + .palette{margin-top:var(--sp-10)}

@media (max-width:900px){
  .subsys .screens{grid-template-columns:1fr}
  .schedule .wk{grid-template-columns:1fr;gap:var(--sp-2)}
}
@media (max-width:560px){
  /* One column: a component crop in a third of a 360px screen is 110px wide
     and stops being a component and starts being a smudge. */
  .comp-grid{columns:1}
  .comp img{max-height:340px}
  .palette{grid-template-columns:1fr;gap:var(--sp-4)}
  .palette .chip{height:72px}
}

/* -- Two fidelity gaps against the control, both measured -------------------
   Found by putting the rendered pages side by side, not by reading the CSS.

   1. A bare <p> inside .words or .twocol had NO typography of its own. It fell
      through to the UA: 16px, line-height:normal, and a 16px margin top and
      bottom that is off the 4pt grid. Beside it .cs-body renders at
      clamp(16px,1.6vw,18px)/1.65, so the same page carried two different body
      sizes depending on which container a paragraph happened to sit in. The
      control sets body{font-size:17px;line-height:1.6} and p{margin:0}
      globally; matching that here without repainting the homepage means
      declaring it on the two containers that actually hold bare prose. The
      existing `p + p{margin-top:var(--sp-5)}` joins still supply the rhythm.

      Measured before: 16px/normal, mt:16px mb:16px.
      Measured after:  17px/1.6,    mt:0    mb:0.

   2. Band figure images had a radius but no ground under them. Every image on
      the control sits on a --surface plate, which is invisible under an opaque
      screenshot and is the whole component under a transparent PNG: the Lucrum
      wordmark rendered as bare artwork floating on the page ground instead of
      as a card. Added to the SAME narrow selector the radius already uses —
      `> figure:not([class]) >` — because a bare `.band .media img` also catches
      images inside components that ground themselves, which has broken this
      layout before. */
.band .words p,
.twocol > p,
.twocol > div > p{margin:0;font-size:clamp(16px,1.6vw,17px);line-height:1.6}
/* Resetting the UA margin above removes the only thing that was separating two
   paragraphs inside a .twocol column, and they rendered as one run-on block.
   .band .words already had this join; .twocol never did, because until now it
   was riding on the UA margin it should not have been riding on. Caught by
   opening the crop, not by any of the mechanical gates — all of which stayed
   green through it. */
.twocol > p + p,
.twocol > div > p + p{margin-top:var(--sp-5)}

.band .media > figure:not([class]) > img,
.band .media > .pair > figure:not([class]) > img{background:var(--surface)}

/* -- Work rows: make the row the target, and say so at rest ----------------
   Measured before this change: the only clickable thing in a work row was the
   eight characters of the title, the thumbnail was inert, and nothing on the
   row carried any affordance until the pointer was already on the title
   (rest state: text-decoration none, colour = body text). Readers had no way
   to know where to click.

   A stretched pseudo-element on the REAL <a> makes the whole row the hit
   target while keeping exactly one link in the accessibility tree, and keeps
   cmd-click and middle-click working. A <div onClick> would break all three,
   and is on the web-interface-guidelines anti-pattern list. */
.project{position:relative}
.project .title h3 a::after{content:"";position:absolute;inset:0;z-index:1}
/* The external "Live product" link sits above the overlay or it becomes
   unreachable — it is the one other link inside the row. */
.project .facts a{position:relative;z-index:2}

/* The affordance at REST, which is the half that was missing. Hover-only
   affordances do not help a reader who has not moved the pointer yet. */
.project .cue{display:inline-flex;align-items:center;gap:var(--sp-2);
  margin-top:var(--sp-5);font-size:15px;font-weight:500;color:var(--accent)}
.project .cue .arw{transition:transform .18s ease}
/* Hovering anywhere on the row moves the arrow and underlines the title, so
   the row reads as one object rather than as text that happens to be a link. */
.project:hover .cue .arw{transform:translateX(4px)}
.project:hover .title a{text-decoration:underline;text-decoration-thickness:2px;
  text-underline-offset:3px}
@media (prefers-reduced-motion:reduce){.project .cue .arw{transition:none}
  .project:hover .cue .arw{transform:none}}

/* ============================================================================
   EXTENDED WORK  (work/extended.html)
   A showcase tier, not case studies. One column per project, screens in a
   horizontal snap strip.
   ============================================================================ */

.xw-head{display:grid;grid-template-columns:7fr 5fr;gap:var(--sp-6);align-items:start}
.xw-head .facts{margin-top:0;display:flex;flex-direction:column;gap:var(--sp-2);
  font-size:15px;color:var(--muted)}
.xw-head .facts a{color:var(--accent)}
@media (max-width:900px){.xw-head{grid-template-columns:1fr;gap:var(--sp-5)}
  .xw-head .facts{flex-direction:row;flex-wrap:wrap;gap:var(--sp-2) var(--sp-6)}}

/* -- The strip -------------------------------------------------------------
   Scroll-snap, not a JS carousel. Snap gives touch, trackpad and keyboard for
   free, needs no script, and has nothing to disable under reduced motion
   because it never animates on its own. A carousel would also be the loudest
   thing on a site that deliberately removed its scroll reveals and marquee.

   tabindex + role + aria-label because Firefox and Safari do not make a scroll
   container keyboard focusable on their own, the same reason .flow-chart has
   them. scroll-padding matches the wrap gutter so a snapped item does not sit
   flush against the edge. */
.screens-strip{
  margin-top:clamp(24px,3.5vw,40px);
  display:flex;gap:var(--sp-5);
  overflow-x:auto;overscroll-behavior-x:contain;
  scroll-snap-type:x mandatory;scroll-padding-left:2px;
  padding-bottom:var(--sp-4);
  -webkit-overflow-scrolling:touch}
.screens-strip > figure{flex:0 0 auto;margin:0;scroll-snap-align:start}
.screens-strip:focus-visible{outline:2px solid var(--text);outline-offset:4px;
  border-radius:var(--r-md)}
.screens-strip figcaption{margin-top:var(--sp-3);font-size:14px;line-height:1.5;
  color:var(--muted);max-width:34ch}

/* -- Phone frame -----------------------------------------------------------
   A bezel and a radius, nothing else. The screenshots already carry their own
   status bar, so drawing a notch or a Dynamic Island on top would render two
   of them. Source is 860x1864, iPhone 14 Pro Max at 2x, so the screen keeps
   that exact ratio and the frame is built outward from it with padding. */
.phone{width:clamp(210px,26vw,272px)}
/* The bezel is a BORDER, not padding, so the screen's corner radius is derived
   (34 - 8 = 26px) instead of being a second declared value. That matters: the
   taste skill caps a page at three distinct radii, and declaring 34 and 26
   separately put this page at four and failed the shape-consistency check.
   One number, and the inner curve falls out of the geometry the way it does on
   the real object.

   34px itself is off the one-radius scale on purpose, the same exemption
   --r-pill gets for things that are genuinely circular: a bezel radius is a
   fact about a physical object, and snapping it to 10px would draw a rectangle
   and call it a phone. */
.phone-screen{
  border:8px solid #0c0e10;
  border-radius:34px;
  background:#0c0e10;
  box-shadow:inset 0 0 0 1px rgba(255,255,255,0.10);
  overflow:hidden}
.phone-screen img{
  width:100%;height:auto;display:block;
  background:var(--surface)}
[data-theme="dark"] .phone-screen{box-shadow:inset 0 0 0 1px rgba(255,255,255,0.16)}

/* -- Browser frame ---------------------------------------------------------
   Lexer is a website. Its captures are 3072px wide and up to 17046px tall,
   cropped to a 3:2 window on the top of each page, which is what a visitor
   lands on. A phone frame would have been the wrong object entirely. */
.web{width:clamp(280px,52vw,560px)}
.web-bar{
  display:flex;align-items:center;gap:var(--sp-2);
  background:var(--surface);
  padding:var(--sp-2) var(--sp-3);
  border-radius:var(--r-md) var(--r-md) 0 0;
  box-shadow:inset 0 -1px 0 var(--line)}
.web-bar span{width:8px;height:8px;border-radius:50%;background:var(--line);
  display:block}
.web img{width:100%;height:auto;display:block;
  border-radius:0 0 var(--r-md) var(--r-md);background:var(--surface)}

/* Vertical rhythm, declared here rather than as inline margins on the page. */
.xw .cs-body + .facts{margin-top:var(--sp-5)}
.xw-head + .screens-strip{margin-top:clamp(24px,3.5vw,40px)}

/* The pointer to the second tier. Sits after the work rows, deliberately quiet:
   it is a doorway, not a fifth project, and giving it a row of its own would
   put it in competition with work that carries real numbers. */
.more-work{margin:clamp(32px,4.5vw,52px) 0 0;padding-top:var(--sp-6);
  border-top:1px solid var(--line-soft);
  font-size:clamp(16px,1.6vw,17px);line-height:1.6;color:var(--muted)}
.more-work a{color:var(--accent);text-decoration:none;
  display:inline-flex;align-items:center;gap:var(--sp-2);min-height:44px}
.more-work a:hover{text-decoration:underline;text-decoration-thickness:2px;
  text-underline-offset:3px}
.more-work .arw{transition:transform .18s ease}
.more-work a:hover .arw{transform:translateX(4px)}
@media (prefers-reduced-motion:reduce){.more-work .arw{transition:none}
  .more-work a:hover .arw{transform:none}}

/* Rights fine print. Its own line under the copyright row rather than a third
   item squeezed into it: flex-basis 100% forces the wrap. --muted measures
   5.43:1 on the light ground and 6.88:1 on the dark one (scripts/contrast.mjs),
   both clear of the 4.5:1 floor for body text, so it is quiet without being
   unreadable. */
/* NO max-width here, deliberately. A max-width clamps a flex item's hypothetical
   main size BEFORE the line-breaking step, so `flex-basis:100%` with
   `max-width:70ch` resolved to 603px, fitted beside the other two items and
   never wrapped. The rule looked correct and the row looked wrong. Full width
   is right anyway: it is one line of fine print, not a paragraph to read. */
.colophon .fineprint{flex:1 0 100%;margin-top:var(--sp-3);
  font-size:13px;line-height:1.6;color:var(--muted)}
