/* ============================================================
   SHELL — the page frame every page shares.
   Load AFTER redesign.css.

   The app bar, the page measure, the column rules and the language
   control. It exists because index.html and studio.html were about to
   grow their own copies of all four, and a frame that differs between
   two pages of one site is worse than no frame at all.
   ============================================================ */

:root {
  /* Declared HERE, in the stylesheet that uses it. It used to be injected
     into each page by apply-shell.cjs, which looked for the first <style>
     and on case pages found the <noscript> one - so the token landed inside
     <noscript> and was dead the moment JS ran. An undefined var makes
     `mask: var(--lang-caret-svg)` resolve to `mask: none`, and the ::after
     then paints as a solid 11px block of currentColor: the caret rendered
     as a black box.
     A stylesheet should ship the tokens it depends on. */
  --lang-caret-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
}

/* ---------- the app bar ----------
   redesign.css builds .nav as a centred glass pill: fixed, left 50%,
   translateX, width max-content, 999px radius, float shadow. Every one of
   those has to be undone rather than nudged, which is why this reads as a
   list of resets before it states anything of its own. */
.nav {
  position: fixed; top: 0; left: 0; right: 0; transform: none;
  width: auto; max-width: none;
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-xl);
  border: 0; border-bottom: 1px solid var(--rule); border-radius: 0;
  box-shadow: none;
  height: 64px;
  /* The BAR spans the viewport; its CONTENTS do not.
     888, not 960. 960 is the hero BLOCK's outer box; the content inside it
     is 888, because .container takes 16px of padding off each side and the
     block is capped at 920. Centring the bar on 960 therefore put the
     brand 36px left of the headline and the theme toggle 36px right of the
     paragraph — the bar was aligned to a box nothing is actually drawn on.
     888 is the number every section, the footer and the studio page use,
     so it is the one the bar has to use too.
     Below 888 the floor wins and it degrades to a normal padded bar with
     no breakpoint needed.

     16px, not --gutter, as that floor. Every content container on the site
     pads by a flat 16 (.section > .container, .cs-next, .footer,
     .shell-measure), while --gutter is a clamp that reaches 20.9 at 834 and
     up. Between 768 and 888 the bar was therefore inset ~5px further than
     the content under it - a small misalignment, but a misalignment between
     the app bar and everything else, which is the one place it shows. */
  padding-inline: max(16px, calc((100% - 888px) / 2));
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  backdrop-filter: blur(20px) saturate(180%);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
}
[data-theme="dark"] .nav { background: color-mix(in srgb, var(--bg) 78%, transparent); }
.nav-brand { flex: none; }
.nav-links { gap: var(--space-lg); }
.nav .nav-link { padding-inline: 0; }
.nav-name { font-size: var(--text-small); }

/* The photograph lives here now, at mark scale. .nav-avatar is already a
   30px circle with an ink fill behind the initials, so the image simply
   takes the fill's place and the geometry is untouched. */
.nav-avatar.has-photo { background: none; overflow: hidden; border: 1px solid var(--line); }
.nav-avatar.has-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }

@media (max-width: 860px) {
  .nav-links { gap: var(--space-md); }
  .nav-link.is-secondary { display: none; }
}
@media (max-width: 620px) {
  .nav { height: 56px; }
  .nav-name { display: none; }
}

/* ---------- the page measure ----------
   920 of box is 888 of content once .section > .container's own
   padding-inline comes off, which is the hero's inner measure. Every
   section takes it: one section obeying the measure while the others run
   to the frame is worse than none of them doing it, because the eye reads
   the odd one out as a mistake. */
#work > .container,
#studio > .container,
#design-systems > .container,
#how-i-work > .container,
#experience > .container,
#reviews > .container,
.cs-next > .container,
.footer > .container,
.shell-measure > .container { max-width: 920px; }
/* The wordmark - CRYSTAL set across the bottom of the page - is the
   largest type on the site spent on a name already in the app bar and in
   the address right above it. Off on every page, not just index. */
.footer-wordmark { display: none; }
/* .section > .container overrides .container's own gutter down to 16px;
   a page that is not built from .section keeps the full --gutter, so the
   same 920 box gave 848 of content instead of 888. Stated here so a
   standalone page lands on the identical measure. */
.cs-next > .container,
.footer > .container,
.shell-measure > .container { padding-inline: 16px; }


/* ---------- case studies, studio projects and design systems ----------
   These pages ran their own width: .cs-main is 1120px of content, and
   above 1200 it becomes a 1324 box with 204px of left padding, because
   the sticky section rail lives in that margin. Measured at 1425, content
   sat at 255 while the app bar sat at 269 - the bar and the page on two
   different columns again, and a different column from the homepage on
   top of that.

   They take the page measure: 920 of box, 888 of content, the same pair
   the nav, every homepage section and the footer use. A case page is NOT
   a narrower page than the homepage - it is the same frame, split.

       888  =  684 reading column  +  24 gap  +  180 section rail

   Both halves matter. The column is 684 everywhere - prose, headings,
   diagrams, covers - so no figure is wider than the paragraph above it,
   which was the reported bug. And it starts on the frame's own left edge,
   so a paragraph, the app bar above it and the related cards below it all
   begin at the same x. Narrowing the BOX to the column instead (tried, at
   732) fixed the figures and broke that: the case content sat 110px inboard
   of every other block on the site.

   The 204 the column stops short of is the rail's track, not leftover.
   Below the width where the rail shows it reads as a right margin, which
   is what a left-aligned document has.

   The trade is real and worth stating: a 1200-unit diagram canvas renders
   at 684, a scale of 0.57, which puts its 12.5-unit labels at 7.1px. The
   diagrams are drawn for a 1200px display and are too small to read at any
   honest reading measure. They need re-authoring on a smaller viewBox with
   larger type - an open task this measure creates, and a change to the
   SVGs rather than to this file. */
.cs-main { max-width: 920px; padding-inline: 16px; }
@media (min-width: 1200px) {
  /* padding-left drops back to the measure's own 16: the 204 it used to
     carry was the rail's track, and the rail no longer sits inside the
     content box. */
  .cs-main { max-width: 920px; padding-left: 16px; }
  /* Hangs in the page margin rather than eating the column, and on the
     RIGHT rather than the left. 204 = its 180px width plus a 24px gap.

     Right, because the left margin being occupied and the right one empty
     is most of what read as "space on the right": the geometry was already
     symmetric, but only one side had anything in it. The side the eye
     finishes a line on is now the side carrying the wayfinding. It is also
     the conventional place for an "on this page" list.

     The rail sits INSIDE the frame now, in the track the 684 column leaves
     it. Hanging it in the page margin was what forced the 1328px floor
     below; inside the frame it only needs the frame.
     right: 16px, not 0. An absolutely positioned box resolves against its
     containing block's PADDING box, so right:0 parked the rail on .cs-main's
     border edge - 16px past the 888 content edge the cards and the footer
     line up on. 16 is .cs-main's own padding, so this puts the rail's right
     edge exactly on the frame and leaves the intended 24px gap. */
  .cs-toc { left: auto; right: 16px; }
}
/* The rail lives in the frame's own 204 track, so it needs the FRAME to be
   full, not the page margin to be wide: 100vw >= 920 + gutters. That is met
   from about 952. redesign.css reveals the rail at 1200 and the positioning
   it needs lives in that same query, so 1200 is the real floor and this
   guard just has to not fight it.
   Kept as a guard rather than deleted: below 1200 the rail is display:none
   from redesign.css anyway, and stating the floor here keeps the two files
   agreeing out loud.
   History: while the rail hung OUTSIDE the box at right:-204 it needed
   (100vw - 920) / 2 >= 204, a 1328px floor, and between 1200 and 1328 it
   ran off the screen. Moving it inside the frame removed that whole class
   of bug. */
@media (max-width: 1199px) {
  .cs-toc { display: none; }
}

/* ---------- case-study reading measure ----------
   HISTORY, kept because the numbers below explain why the column is the
   width it is. The box now gives 700 and everything takes it, so the tail
   gap this block was balancing is gone; what survives is the rule that
   every child shares ONE width and ONE left edge.

   The box gave 888 of content. Prose capped at 65ch and the lists at 68ch
   used 599-627 of it, so every text block left 260-290px empty to its RIGHT
   while the diagram underneath ran the full width. The page leaned hard:
   53px of margin on one side, 330 on the other.

   The lean was the bug, not the leftover. Filling the box at 16px would be
   96 characters a line, and growing the type until it fits reads oversized
   against the rest of the site. So the column keeps a normal measure and a
   normal 16px, and the leftover is split evenly instead of all pooling on
   one edge: 40rem of text, centred, with the same margin either side.

   40rem = 640px = 69 characters at 16px.

   Everything text-bearing shares ONE width. That matters more than the
   number: with auto margins, two children of different max-widths centre
   to different left edges, so a 553px heading over a 640px paragraph would
   sit 43px inboard of it. Same width, same left edge, one column.

   Media WAS the exception and kept the full 888. It is not any more: a
   figure wider than the paragraph above it was the reported problem, and
   the 188px it gained is not worth an edge the reader cannot resolve.

   Scoped to .cs-section. The hero is .cs-hero and runs its own two-column
   grid, which has its own measure. */

/* Only the NESTED caps get reset. The direct children are handled by the
   `> *` rule below, which at (0,2,0) already outranks the (0,1,0) base
   rules in redesign.css.
   Resetting them here instead was a bug worth naming: a descendant
   selector like `.cs-main .cs-section .prose` is (0,3,0) and BEATS
   `.cs-main .cs-section > *`, so the reset won and the prose ran the full
   888 at 96 characters - the exact thing this block exists to prevent. */
.cs-main .cs-section .decisions-cat .decisions,
.cs-main .cs-section .metrics .label { max-width: none; }

/* One left edge AND one right edge for EVERY child of the page, text and
   figures alike. --cs-measure is 684 = the 888 frame minus the rail's 204
   track, so the column and the rail together are exactly the frame.
   Applied at .cs-main level, not just inside .cs-section: the hero, the
   cover, the fact strip and the NDA note are siblings of the sections, and
   leaving them on the full 888 would put a 888 cover directly above a 684
   paragraph - the same mismatch one level up.
   .cs-toc is excluded; it is absolutely positioned into the rail track and
   a cap on it would fight its own width. The type does not move. */
.cs-main > *:not(.cs-toc) { max-width: var(--cs-measure); }
.cs-main .cs-section { grid-template-columns: minmax(0, 1fr); }
.cs-main .cs-section > * { grid-column: 1; max-width: var(--cs-measure); }

/* The media exemption is GONE, deliberately. These six selectors used to
   carry max-width: none so figures ran the full 888 while the text beside
   them stopped at 700. That 188px overhang is the bug this measure exists
   to remove: it is too small a difference to read as a choice and too large
   to read as aligned, so the eye keeps trying to resolve it. Media takes
   the measure like everything else.
   Left here as a named absence so the next person does not re-add it:
     .csd  .cs-figure  .cs-parts  .live-frame  .ds-design-view  .ds-dev-view */
/* ...except the phone variant, which is the one frame here that is NOT
   trying to fill the column: it holds an authored 390px so the app runs at
   1:1. This rule is more specific than .live-frame--phone's own cap, so
   without this line the cap was simply switched off, and on a 375px phone
   the frame stood 392px wide inside a 324px column and gave curacare and
   forkful 52px of horizontal scroll the page could never use. Below its own
   width the frame gives in; above it, the 390 still stands. */
.cs-main .cs-section > .live-frame--phone { max-width: calc(100% - 2px); }

/* ---------- hero ----------
   The hero ran a two-track grid, statement left and the principles rail
   right, sized for the full 888. On the measure that split is 349 + 285:
   a 49px display title with ~20px of clearance and a standfirst at 38
   characters a line. Both tracks are below the width they need, so the
   hero takes one track and the principles sit under the statement.
   Not a viewport question - .cs-main's children are 684 at every viewport,
   so the old max-width: 900px collapse never fires. */
.cs-hero-grid { grid-template-columns: minmax(0, 1fr); gap: var(--space-xl); }

/* the caption and its figure are the same width now, so this only has to
   stop the cap from being inherited as something narrower */
.cs-main .cs-section > .cs-figure figcaption { max-width: var(--cs-measure); }

/* ---------- case-study diagrams ----------
   .cs-section is a grid of "168px 680px": a sticky label rail and the
   content column. A figure was landing in column 2, so a 1200-unit canvas
   was being drawn at 680px - a scale of 0.567, which renders its 12.5-unit
   labels at 6.8px against this site's own 16px floor for functional text.

   Diagrams span both tracks. The rail is a label, and a label does not
   need to reserve room beside a picture. 888 instead of 680 is a 31%
   larger canvas and takes the smallest text to 9.3px.

   Still under the floor: these were drawn for a ~1100px column and the
   page measure is 888. Bringing them the rest of the way needs either a
   deliberate bleed past the measure or redrawing them on a narrower
   canvas, and both are design calls rather than fixes. */
.cs-section > .csd { grid-column: 1 / -1; }

/* ---------- column rules ----------
   Two hairlines marking where the content column starts and ends, running
   unbroken behind everything. The layer borrows .container's geometry
   rather than recomputing the edges, so it cannot drift out of sync. */
.col-rules {
  position: fixed; inset: 0; z-index: 0; pointer-events: none;
  max-width: var(--max-width); margin-inline: auto;
}
.col-rules span { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--rule); }
.col-rules span:first-child { left: 0; }
.col-rules span:last-child  { right: 0; }
@media (max-width: 1200px) { .col-rules { display: none; } }

/* ============ LANGUAGE MENU ============
   A real menu, not a toggle wearing a chevron.

   The trigger is navigation: plain text in the bar's own voice at --ink,
   the same as every link beside it. It is NOT .lang-toggle — that button
   still exists, hidden, because components/site.js binds the actual
   language swap to it and reads its label to know which way to go. The
   menu items click it. Rewriting site.js to expose a setLanguage() would
   be the better fix; until then this drives the mechanism that is there
   rather than duplicating it.

   The panel is the system's own surface: --bg-raised, a hairline, the
   12px panel radius and --shadow-rest, which is the recipe every other
   raised object on the site uses. Nothing new is introduced. */
.lang { position: relative; }
/* The hidden toggle has to actually be hidden. The `hidden` attribute works
   by setting display:none in the UA stylesheet, which ANY author display
   rule outranks -- and redesign.css sets `.lang-toggle { display: inline-flex }`
   for the chip it used to be. Without this the old control renders beside
   the new one and lands in the tab order: two language buttons in one bar.
   display:none is safe here because .click() fires on an unrendered element,
   which is all the menu needs from it. */
.lang-toggle[hidden] { display: none !important; }
.lang-trigger {
  appearance: none; background: none; border: 0; padding: 0;
  font-family: var(--font-body); font-size: var(--text-small); font-weight: 500;
  color: var(--ink); cursor: pointer;
  display: inline-flex; align-items: center; gap: var(--space-2xs);
  height: 36px;
}
/* A real hover state. It used to set color:var(--ink), which is what the
   trigger already is, so hovering changed nothing at all - and now that
   the menu opens on hover, the control has to look like something the
   pointer can act on before the pointer gets there.
   The tint is the same 6% ink wash the nav links and the dock use, so
   this introduces no new hover language. The open state takes the same
   tint, which keeps the trigger visibly "on" while the panel is out. */
.lang-trigger {
  padding: 0 var(--space-xs);
  border-radius: var(--radius-pill);
  transition: background-color var(--dur-fast) var(--ease-out-expo),
              color var(--dur-fast) var(--ease-out-expo);
}
.lang-trigger:hover,
.lang-trigger:focus-visible,
.lang-trigger[aria-expanded="true"] {
  color: var(--ink);
  background: color-mix(in srgb, var(--ink) 6%, transparent);
}
[data-theme="dark"] .lang-trigger:hover,
[data-theme="dark"] .lang-trigger:focus-visible,
[data-theme="dark"] .lang-trigger[aria-expanded="true"] {
  background: rgba(255,255,255,0.08);
}
@media (prefers-reduced-motion: reduce) { .lang-trigger { transition: none; } }
/* Drawn, never a child element: site.js assigns to .textContent on the
   hidden button, and anything nested inside a label that gets rewritten
   disappears on the first swap. Generated content is out of its reach.
   A mask rather than a background-image, so the caret inherits
   currentColor and needs no second copy per theme. */
.lang-trigger::after {
  content: ""; flex: none; width: 11px; height: 11px;
  background: currentColor;
  -webkit-mask: var(--lang-caret-svg) center / contain no-repeat;
  mask: var(--lang-caret-svg) center / contain no-repeat;
  transition: transform var(--dur-fast) var(--ease-out-expo);
}
.lang-trigger[aria-expanded="true"]::after { transform: rotate(180deg); }

.lang-menu {
  position: absolute; top: calc(100% + var(--space-xs)); right: 0;
  z-index: 30; min-width: 168px; padding: var(--space-2xs);
  background: var(--bg-raised);
  border: 1px solid color-mix(in srgb, var(--ink) 8%, transparent);
  border-radius: var(--radius-panel);
  box-shadow: var(--shadow-rest);
  display: grid; gap: 2px;
  /* closed state: the panel is in the DOM so the menu can be measured and
     focused, and is taken out of the layout when shut */
  opacity: 0; visibility: hidden; pointer-events: none;
  transform: translateY(-4px);
  /* visibility is a DISCRETE property: transitioning it flips the value
     halfway through the duration, which both delays the paint and makes
     the state unreadable while the transition runs. The idiom is to give
     it a zero duration and delay it to the END of the fade on close, so
     the panel stays visible long enough to fade out, then leaves the
     accessibility tree in one step. On open the delay is removed and it
     appears immediately. */
  transition: opacity var(--dur-fast) var(--ease-out-expo),
              transform var(--dur-fast) var(--ease-out-expo),
              visibility 0s linear var(--dur-fast);
}
[data-theme="dark"] .lang-menu { border-color: rgba(255,255,255,0.10); }
.lang-menu[data-open] {
  opacity: 1; visibility: visible; pointer-events: auto; transform: none;
  transition-delay: 0s;
}
.lang-option {
  appearance: none; background: none; border: 0; cursor: pointer;
  display: flex; align-items: center; gap: var(--space-sm);
  width: 100%; padding: var(--space-xs) var(--space-sm);
  border-radius: calc(var(--radius-panel) - 6px);
  font-family: var(--font-body); font-size: var(--text-small); font-weight: 500;
  color: var(--ink-body); text-align: left;
  transition: background-color var(--dur-fast) var(--ease-out-expo),
              color var(--dur-fast) var(--ease-out-expo);
}
.lang-option:hover { background: color-mix(in srgb, var(--ink) 5%, transparent); color: var(--ink); }
.lang-option[aria-checked="true"] { color: var(--ink); }
/* The tick occupies its slot whether or not it is drawn, so the two
   labels stay on one left edge instead of shifting as the choice moves. */
.lang-check { flex: none; width: 14px; height: 14px; opacity: 0; }
.lang-option[aria-checked="true"] .lang-check { opacity: 1; color: var(--accent); }
.lang-native { margin-left: auto; color: var(--ink-3); font-weight: 400; }
@media (prefers-reduced-motion: reduce) {
  .lang-trigger::after, .lang-menu { transition: none; }
}

/* ---- the language control inside the mobile sheet ----
   Same element, moved there by site.js, so this is a second layout for one
   component rather than a second component.
   In the bar it is a floating panel whose visibility rides an opacity
   transition. In the sheet none of that survives: the panel sits in flow and
   the open state is display, not opacity, so a tap either shows the options
   or it does not. Nothing to mistime, nothing to position against a fixed
   blurred bar, and nothing that can be open and invisible at the same time.
   The row is the auto layout the brief asked for: language name hard left,
   caret hard right, the space between them doing the work. */
.lang-word { display: none; }
@media (max-width: 760px) {
  .nav-sheet .lang {
    display: block; width: 100%;
    margin-top: var(--space-sm); padding-top: var(--space-sm);
    border-top: 1px solid var(--rule);
  }
  .nav-sheet .lang-code { display: none; }
  .nav-sheet .lang-word { display: inline; }
  .nav-sheet .lang-trigger {
    width: 100%; justify-content: space-between;
    height: auto; min-height: 44px; padding: var(--space-sm) 0;
    border-radius: 0; font-size: var(--text-body);
  }
  /* the bar's hover wash would read as a selected row here */
  .nav-sheet .lang-trigger:hover,
  .nav-sheet .lang-trigger:focus-visible,
  .nav-sheet .lang-trigger[aria-expanded="true"] { background: transparent; }
  .nav-sheet .lang-menu {
    position: static; width: 100%; min-width: 0;
    padding: 0; background: none; border: 0; box-shadow: none;
    opacity: 1; visibility: visible; pointer-events: auto;
    transform: none; transition: none;
  }
  .nav-sheet .lang-menu:not([data-open]) { display: none; }
  .nav-sheet .lang-option {
    min-height: 44px; padding: var(--space-xs) 0;
    font-size: var(--text-body);
  }
  .nav-sheet .lang-option:hover { background: transparent; }
  .nav-sheet .lang-native { display: none; }
}

/* ---------- design systems: draggable rail ----------
   The row hides its scrollbar, so with a mouse there was no visible way
   to move it except the pager arrows. The cursor now says it is grabbable
   and stays grabbed through the throw. */
.ds-row { cursor: grab; }
.ds-row.is-dragging { cursor: grabbing; scroll-behavior: auto; }
/* Text selection turns a drag into a highlight halfway through it. */
.ds-row.is-dragging, .ds-row.is-dragging * { user-select: none; }
