/* components.css - a catalogue. Each selector is defined exactly ONCE, top to bottom.
   Rules of the house:
     - max specificity 0,2,0. never a three-class descendant chain.
     - no page-scoped overrides. a page never restyles a component; it only sets --accent.
     - variants set custom properties, they do not override properties.
     - no !important (the one exception lives in tokens.css).
     - media queries live inside their component's block, never in a dump at the end. */

/* ============ panel ============ */

.panel {
  margin-top: 20px;
  padding: var(--pad);
  background: var(--panel);
  border: var(--ui-border);
  border-radius: var(--radius2);
}

.panel__label {
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  text-transform: uppercase;
  color: var(--muted2);
}

/* ============ chips ============ */

.chips {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  justify-content: center;
}

.chip {
  position: relative;
  z-index: 1;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: .5px;
  padding: 11px 15px;
  border-radius: var(--pill);
  background: var(--bg2);
  border: var(--ui-border);
  color: var(--text);
  transition: border-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.chip:hover { border-color: var(--accent); }
/* The fill an unscripted page falls back to. With the marker running it is handed over to
   the block below, but if the marker never builds the selected chip is still filled and
   still readable, rather than dark ink on a dark page. */
.chip[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

/* One selection, one block, and it travels. Ten chips each swapping their own background
   is ten unrelated events; a single block that glides and resizes between them is one
   object being moved, which is the thing that actually happened. Positioned and sized from
   JS against the chip it is under, so it survives the row wrapping at 390px. */
.chips__marker {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 0;
  border-radius: var(--pill);
  background: var(--accent);
  pointer-events: none;
}
.chips[data-marker="on"] .chip[aria-pressed="true"] { background: transparent; }

/* ============ buttons ============ */

.btn {
  display: inline-block;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: .5px;
  padding: 13px 16px;
  border-radius: var(--radius);
  background: var(--bg2);
  border: var(--ui-border);
  color: var(--text);
  transition: border-color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}
/* A secondary control reacts by changing its edge and nothing else. Only the primary lifts,
   because if everything lifts then nothing is being recommended. */
.btn:hover { border-color: var(--accent); }

/* Three tiers, and only three: the fill is the one thing you are being asked to do, the
   edge is the runner-up, the plain .btn is everything else. Nothing on the site gets a
   fourth treatment.

   The fill is also the one control cut like a block: --notch takes a single 6px step off
   two opposite corners, which is enough to read as a placed cube and not enough to read as
   a decorative chamfer. It is the only place the geometry is spelled out. */
.btn--primary {
  clip-path: var(--notch);
  border-radius: 0;
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.btn--primary:hover { transform: translateY(-3px); }

.btn--edge {
  border-color: var(--accent);
}

/* Pressing is contact, so it happens at the fastest band on the site and it goes down and
   in, like a block being seated, rather than just shrinking. Transform only, so nothing
   around it reflows. */
.btn:active,
.chip:active,
.rail__btn:active,
.heat__cell:active:not([disabled]) {
  transform: translateY(2px) scale(.98);
  transition-duration: var(--dur-tap);
}

.btn-row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  justify-content: center;
}

/* GO BACK sits at the top of a page, where a back control is looked for, so it lines up
   with the content edge instead of centring like a call to action. */
.btn-row--left {
  justify-content: flex-start;
  margin-bottom: 8px;
}

/* ============ stat tiles ============ */
/* Two columns on a phone; above 640px auto-fit, so a 3-tile row and a 4-tile row both stay
   even without either page declaring a column count.

   These used to be three bordered panels, which gave the totals the same visual weight as
   the work itself. They are supporting evidence, not the point, so they sit straight on the
   page under a hairline now. The page needs a quiet register for the loud parts to read as
   loud, and the numbers are the natural place to find one. */

.stats {
  margin-top: var(--gap);
  display: grid;
  gap: var(--gap);
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 640px) {
  .stats { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
}

.stat {
  padding: 14px 0 0;
  border-top: 2px solid var(--stroke2);
}

.stat__value {
  display: block;
  font-family: var(--display);
  font-size: clamp(32px, 4vw, 42px);
  line-height: 1;
  letter-spacing: .5px;
  color: var(--text);
}

.stat__label {
  display: block;
  margin-top: 10px;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  text-transform: uppercase;
  color: var(--muted2);
}

/* ============ grid + card ============ */

.grid {
  --cols: 2;
  margin-top: var(--gap);
  display: grid;
  gap: var(--gap);
  grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
}
@media (min-width: 720px)  { .grid { --cols: 3; } }
@media (min-width: 1100px) { .grid { --cols: 4; } }

.card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  text-align: left;
  width: 100%;
  background: var(--panel2);
  border: var(--ui-border);
  border-radius: var(--radius2);
  transition: border-color var(--dur) var(--ease);
}
.card:hover { border-color: var(--accent); }

.card__viewer {
  display: block;
  position: relative;
  margin: 14px 14px 0;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius);
  overflow: hidden;
  background: #0e0e0e;
}

.card__thumb {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--dur-slow) var(--ease);
}
/* .card__viewer clips, so the image grows inside its own frame and the card does not
   move. A card is a link to a video: the thumbnail is the thing worth reacting. */
.card:hover .card__thumb,
.card:focus-visible .card__thumb {
  transform: scale(1.04);
}

.card__badge {
  position: absolute;
  left: 10px;
  top: 10px;
  padding: 6px 9px;
  border-radius: var(--pill);
  background: var(--accent);
  color: var(--on-accent);
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: .5px;
}

.card__meta {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 13px 14px 14px;
}

.card__name {
  margin: 0;
  font-family: var(--body-mixed);
  font-size: var(--fs-body);
  font-weight: 400;
  line-height: 1.25;
}

.card__stats {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 8px;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: .9px;
  text-transform: uppercase;
  color: var(--muted2);
}

/* The grid is twelve of forty-four now, and this line is the only thing that says so.
   Sits with the grid rather than in the call to action below it. */
.grid__note {
  margin: 14px 0 0;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  color: var(--muted2);
}
.grid__note:empty { display: none; }

/* Pinned to the bottom of the card rather than sitting straight under the stats: in a row
   where one card carries a quote and three do not, letting it sit inline pushed that card's
   title and meta out of line with its neighbours and the whole row read as broken. */
.card__quote {
  margin: auto 0 0;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  font-family: var(--body);
  font-size: var(--fs-body-sm);
  line-height: 1.4;
  color: var(--muted);
}

/* ============ heatmap ============ */

.heat {
  display: grid;
  grid-template-columns: auto repeat(12, minmax(0, 46px));
  justify-content: center;
  gap: 6px;
  align-items: center;
  margin-top: var(--gap);
}

.heat__year,
.heat__month {
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: .8px;
  color: var(--muted2);
  text-align: center;
}
.heat__year { text-align: right; padding-right: 8px; color: var(--muted); }

.heat__cell {
  --level: 0;
  aspect-ratio: 1;
  min-height: 30px;
  border-radius: 8px;
  border: var(--ui-border);
  background: color-mix(in srgb, var(--accent) calc(var(--level) * 30%), var(--panel));
  font-family: var(--ui);
  font-size: var(--fs-ui);
  color: var(--muted2);
  transition: border-color var(--dur) var(--ease);
}
.heat__cell[data-level="0"] { color: #5a5a5a; }
.heat__cell[data-level="1"] { --level: 1; color: var(--text); }
.heat__cell[data-level="2"] { --level: 2; color: var(--text); }
.heat__cell[data-level="3"] { --level: 3; color: var(--on-accent); }
.heat__cell:hover:not([disabled]) { border-color: var(--accent); }
.heat__cell[disabled] { opacity: .35; cursor: default; }
/* Set by the timeline while the feed is unfiltered: the board points at whatever month is
   being read. Deliberately weaker than the [aria-current] selection further down, because
   this is the board following you, not you having chosen something. */
.heat__cell[data-reading] { border-color: var(--muted2); }

@media (max-width: 720px) {
  .heat { grid-template-columns: auto repeat(6, minmax(0, 1fr)); }
  .heat__cell { min-height: 38px; }
}

.heat__legend {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 14px;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  text-transform: uppercase;
  color: var(--muted2);
}

.heat__swatch {
  --level: 0;
  width: 20px;
  height: 20px;
  border-radius: 6px;
  border: 2px solid var(--stroke);
  background: color-mix(in srgb, var(--accent) calc(var(--level) * 30%), var(--panel));
}

/* ============ modal ============ */

.modal {
  width: min(1000px, 92vw);
  padding: 0;
  border: var(--ui-border);
  border-radius: var(--radius2);
  background: var(--panel);
  color: var(--text);
  overflow: hidden;
}
.modal::backdrop { background: rgba(0, 0, 0, .78); }

/* The lightbox is the one thing on the site that appears on top of everything else, so it
   earns an entrance. The panel and the backdrop get separate ones: a backdrop that scales
   is a wall that changes size, which is not a thing walls do. It fades, and only fades.

   The exit is the half <dialog> does not give you, since close() removes the element on the
   spot. js/modal.js stamps data-closing, waits for animationend and closes then, so the
   button, the backdrop and Escape all leave the same way. These rules come after the [open]
   pair on purpose: same specificity, so source order decides which one is running.
   The reduced-motion block in tokens.css cuts all four to nothing. */
@keyframes modal-in {
  from { opacity: 0; transform: translateY(8px) scale(.97); }
  to   { opacity: 1; transform: none; }
}
@keyframes modal-out {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(8px) scale(.98); }
}
@keyframes veil-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes veil-out { from { opacity: 1; } to { opacity: 0; } }

.modal[open] { animation: modal-in var(--dur-ui) var(--ease-out); }
.modal[open]::backdrop { animation: veil-in var(--dur-ui) linear; }
.modal[data-closing] { animation: modal-out var(--dur-fast) var(--ease-out) forwards; }
.modal[data-closing]::backdrop { animation: veil-out var(--dur-fast) linear forwards; }

.modal__viewer {
  aspect-ratio: 16 / 9;
  background: #0e0e0e;
}
.modal__viewer iframe {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

.modal__meta {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: var(--pad);
}

.modal__name {
  margin: 0;
  font-family: var(--body-mixed);
  font-size: 22px;
  font-weight: 400;
  line-height: 1.25;
}

.modal__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.modal__close {
  position: absolute;
  right: 12px;
  top: 12px;
  z-index: 2;
  padding: 10px 13px;
  border-radius: var(--radius);
  border: var(--ui-border);
  background: rgba(0, 0, 0, .55);
  font-family: var(--ui);
  font-size: var(--fs-ui);
  transition: border-color var(--dur) var(--ease);
}
.modal__close:hover { border-color: var(--accent); }

/* ============ empty / status ============ */

.empty {
  grid-column: 1 / -1;
  min-height: 200px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 28px;
  text-align: center;
  background: var(--panel);
  border: var(--ui-border);
  border-radius: var(--radius2);
  font-family: var(--ui);
  font-size: var(--fs-ui);
  line-height: 1.9;
  letter-spacing: var(--track-ui);
  text-transform: uppercase;
  color: var(--muted);
}

.eyebrow {
  margin: 32px 0 0;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  text-transform: uppercase;
  color: var(--accent);
}

.heat__swatch[data-level="1"] { --level: 1; }
.heat__swatch[data-level="2"] { --level: 2; }
.heat__swatch[data-level="3"] { --level: 3; }

/* The 6-column mobile fallback wraps a 13-cell year row out of alignment (the year
   label lands mid-board). Below 720px the board keeps its twelve months and scrolls.

   width and justify-content are both load-bearing, and their absence was a real bug: .heat
   centres itself, and a centred grid whose columns are wider than its scroll container puts
   the overflow on BOTH sides. The left half lands at a negative offset, scrollLeft cannot go
   below zero, and the year labels were therefore parked 81px outside the board with no way
   to reach them. max-content makes the grid honestly wider than its container and start
   pins it to the left, so scrollLeft 0 shows the years and the board scrolls to December. */
@media (max-width: 720px) {
  .heat-scroll { overflow-x: auto; }
  .heat-scroll .heat {
    width: max-content;
    justify-content: start;
    grid-template-columns: auto repeat(12, minmax(30px, 1fr));
  }
}

/* ============ work tab (index.html) ============ */

/* ytjobs' portfolio card puts the channel logo beside the view line and labels the role.
   The row must never become two lines - ragged meta desyncs the whole grid - so it does
   not wrap; when the column is too narrow the role label drops (below). */
.work-meta {
  container-type: inline-size;
  flex-wrap: nowrap;
  align-items: center;
  gap: 8px;
  letter-spacing: .4px;
}

.work-meta__avatar {
  flex: none;
  width: 20px;
  height: 20px;
  border-radius: var(--pill);
  border: 2px solid var(--stroke2);
  object-fit: cover;
  background: var(--bg2);
}

.work-meta__text {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ============ timeline feed (timeline.html) ============ */
/* One feed grouped by month, newest first. A group is a one-line head - month, counts,
   that month's clients - over a .grid of its cards. The heatmap above is the index.

   A group is a left rule, not a box: 14 of 19 months hold 1-2 cards, and a full-width
   bordered panel framed that emptiness. */
.month {
  margin-top: 20px;
  padding-left: var(--pad);
  border-left: 2px solid var(--stroke);
}
.month .grid { margin-top: 12px; }

/* ONE line at every width: the month, its counts, then the month's clients pushed to
   the end. 19 groups pay for every extra line a stacked head would cost. Max in the
   data is 3 clients and 8 videos, so the row never has to wrap in practice. */
.month__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 14px;
  min-height: 28px;
}

.month__when {
  flex: none;
  font-family: var(--display);
  font-size: 22px;
  line-height: 1;
  letter-spacing: 1px;
  color: var(--accent);
}

/* beside the counts, not pushed to the far edge: at 1440 an auto margin stranded three
   26px avatars a thousand pixels from the text they belong to. */
.month__avatars {
  display: flex;
  gap: 6px;
}

.month__avatar {
  width: 26px;
  height: 26px;
  border-radius: var(--pill);
  border: var(--ui-border);
  background: var(--panel2);
  object-fit: cover;
}

/* Two columns once there is room for them. 726px is all the heatmap can ever use: 12
   cells capped at 46px, plus gaps and the year label. align-items:start is what lets the
   left column go sticky inside a grid. 1080px rather than 1100px because components.css
   already owns a min-width:1100px block and the duplicate-block gate keys on that text. */
@media (min-width: 1080px) {
  .tlsplit {
    display: grid;
    grid-template-columns: minmax(0, 726px) minmax(0, 1fr);
    gap: var(--gap);
    align-items: start;
  }
  /* 84px is the topbar. The index stays readable while a nineteen-month feed goes past. */
  .tlsplit__index {
    position: sticky;
    top: 84px;
  }
  /* .grid's own breakpoints read the viewport, not this column, so at 1440 it would put
     four cards into a 570px column at 130px each. */
  .tlsplit__feed .grid {
    --cols: 2;
  }
}

/* Appears only once a month is picked. The chips are the same component the WORK page
   filters with, so walking a year costs no new interaction to learn. */
.feedbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 14px;
  margin-top: var(--gap);
}
.feedbar[hidden] { display: none; }

.feedbar__year {
  font-family: var(--display);
  font-size: 22px;
  line-height: 1;
  letter-spacing: 1px;
  color: var(--accent);
}

/* Compact avatars are enough while you are scanning every month. Once you have picked
   one, the question becomes who it was for, so the row says so in words. */
.monthclients {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 12px;
}

.monthclient {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 14px 8px 8px;
  background: var(--panel);
  border: var(--ui-border);
  border-radius: var(--pill);
}

.monthclient__avatar {
  width: 40px;
  height: 40px;
  border-radius: var(--pill);
  background: var(--panel2);
  object-fit: cover;
}

.monthclient__name {
  font-family: var(--body-mixed);
  font-size: var(--fs-body-sm);
  line-height: 1.1;
}

.monthclient__meta {
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  color: var(--muted2);
}

/* the month the feed is currently narrowed to */
.heat__cell[aria-current] { border-color: var(--accent); }


/* ============ best-work rail ============ */
/* Sideways on scroll-snap alone. Touch, trackpad, shift-wheel, keyboard and
   prefers-reduced-motion all work because none of them are reimplemented: the arrows
   are an affordance for mouse users, not the mechanism. */

.rail {
  margin-top: 8px;
}

.rail__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.rail__nav {
  display: flex;
  gap: 10px;
}

.rail__btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  border-radius: var(--radius);
  background: var(--bg2);
  border: var(--ui-border);
  font-family: var(--ui);
  font-size: var(--fs-ui-lg);
  transition: border-color var(--dur) var(--ease);
}
.rail__btn:hover { border-color: var(--accent); }

.rail__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: min(80%, 380px);
  gap: var(--gap);
  margin-top: 14px;
  padding-bottom: 4px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
/* The arrows and the snap are the affordance. A raw scrollbar under the slides read as
   a stray UI element rather than as part of the rail. Scrolling itself is untouched:
   touch, trackpad, shift-wheel and the keyboard all still work. */
.rail__track::-webkit-scrollbar {
  display: none;
}

/* The slide you are on keeps its full weight; the ones either side step back. Scale and
   opacity only, no rotation and no perspective: a 16:9 thumbnail is the client's own
   artwork and turning it in 3D distorts the one thing a visitor is judging. The reference
   for this gets away with rotation because its slides are 9:16 verticals. */
.card--slide {
  scroll-snap-align: start;
  transition: opacity var(--dur-slow) var(--ease), transform var(--dur-slow) var(--ease);
}
/* Gated on [data-ready] so nothing is dimmed in the frame before the script marks the
   first slide active. The neighbours used to drop to half opacity, which read as broken
   thumbnails rather than as depth; they now step back just far enough to say the middle one
   is the one being offered. These are client artwork, and dimming client artwork by half is
   not a design decision anyone bought. */
.rail__track[data-ready] .card--slide:not([data-active]) {
  opacity: .82;
  transform: scale(.97);
}

/* Where you are in six. The row is a block track, not a dot row: N blocks in the stroke
   colour with one accent block travelling across them. The buttons stay, because they are
   the keyboard jump control, but the accent no longer lives on the current button - it is
   its own block, driven from real scroll position, so it moves with a drag instead of
   snapping when the drag lets go. */
.rail__pos {
  position: relative;
  width: min(240px, 60%);
  margin: 10px auto 0;
}
.rail__pos[hidden] { display: none; }

.rail__dots {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: 4px;
}

/* 26px so the tap target clears the minimum; the visible block is the pseudo-element. */
.rail__dot {
  height: 26px;
  display: grid;
  place-items: center;
}
.rail__dot::before {
  content: "";
  width: 100%;
  height: 6px;
  background: var(--stroke2);
}

/* Transform only: its width is set once per resize, never per frame, so following the rail
   costs no layout. */
.rail__prog {
  position: absolute;
  left: 0;
  top: 10px;
  height: 6px;
  background: var(--accent);
  pointer-events: none;
}
/* An arrow that cannot move is worse than no arrow: it reads as a broken control rather
   than as the end of the rail. */
.rail__btn[disabled] {
  opacity: .35;
  cursor: default;
}

/* ============ client roster ============ */
/* Every client, biggest first, including the ones whose videos have since come down.
   auto-fit rather than a column count, so ten names and three names both sit evenly. */

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

/* Five across, so ten clients read as two even rows. auto-fit gave eight and left an
   orphan pair on its own line.

   This has to sit AFTER the .clients rule above, not in the min-width:640px block near the
   top of the file: same specificity, so the later declaration wins and the two-column base
   was quietly beating it. 660 rather than 640 because the duplicate-block convention is one
   @media per breakpoint value, and 640 is already spoken for by .stats.

   It used to wait for 900px, which left the de-panelled roster running two-up and 800px
   deep on a tablet. Ten avatars in a single column is the monotony the boxes were hiding. */
@media (min-width: 660px) {
  .clients { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}

/* Ten bordered panels made the roster as loud as the work. It is a wall of faces: the
   names and the avatars carry it, and the boxes were only ever framing empty space. */
.client {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 12px 2px;
}

/* All the feedback the tile needs, on the one element that is already the subject. */
/* A rounded square, not a circle, and big enough to be the thing you look at: at 64px in a
   280px column the roster was ten small dots in a lot of empty page. The squircle is the
   same placed-block shape the marker and the slab make, so the roster now rhymes with the
   rest of the site instead of being the one round thing on it.

   width is capped rather than fixed: five across at 660px leaves a 109px column, and a
   fixed 96px tile plus its ring and padding overflows it. aspect-ratio keeps it square as
   it shrinks, so there is one number here instead of a size per breakpoint. */
.client__avatar {
  width: min(128px, 100%);
  /* height:auto is load-bearing. The img carries a height attribute, which is a
     presentational hint that still applies when CSS declares no height, and it beats
     aspect-ratio: the tile rendered 128x96. */
  height: auto;
  aspect-ratio: 1;
  border-radius: var(--radius2);
  /* --muted2, not --stroke2. The ring is what turns client artwork into a placed tile, and
     at #3a3a3a it was invisible against the page: ten pieces of art bleeding into the
     background is what read as bland. It is the same grey the subscriber line uses, so no
     new value enters the palette. */
  border: 3px solid var(--muted2);
  background: var(--panel2);
  object-fit: cover;
  transition: border-color var(--dur-tap) var(--ease-out);
}
.client:hover .client__avatar,
.client:focus-visible .client__avatar { border-color: var(--accent); }

.client__name {
  font-family: var(--body-mixed);
  font-size: var(--fs-body);
  line-height: 1.1;
  text-align: center;
}

.client__subs {
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  color: var(--muted2);
}

/* How many videos and over what stretch. 12px is the Silkscreen floor, so this line
   wraps rather than shrinks; centred and leading-tight so two lines still read as one
   caption under the subscriber count. */
.client__work {
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  line-height: 1.5;
  text-align: center;
  color: var(--accent);
}

/* The roster read as ten logos with no claim attached. This one derived line is the
   claim, and it sits with the eyebrow rather than inside the grid. */
.clients__summary {
  margin: 6px 0 0;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  color: var(--muted2);
}

/* On a phone ten full-size cards are five rows deep and push the work itself off the
   second screen, so the roster gets compact rather than long.

   This block has to sit AFTER every rule it overrides, not above them: same specificity, so
   the later declaration wins and .client, .client__avatar and .client__name were all quietly
   beating it. Same trap the 660px .clients block above is commented for. */
@media (max-width: 640px) {
  .clients { gap: 10px; }
  .client { gap: 6px; padding: 8px 4px; }
  /* Corner radius tracks the tile: 14 on 72 is the same .2 ratio 22 on 96 is, so the
     squircle reads as the same shape rather than as a different one scaled down. */
  .client__avatar { width: min(72px, 100%); border-radius: var(--radius); }
  .client__name { font-size: var(--fs-body-sm); }
}

/* ============ client quotes ============ */
/* A quote is worth more beside the video it is about. Blocks alternate side so the page
   does not read as another grid; below 760px they stack, video first. */

.cases {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  margin-top: var(--gap);
}

.case {
  display: grid;
  gap: var(--gap);
  grid-template-columns: 1fr;
  padding: var(--pad);
  background: var(--panel);
  border: var(--ui-border);
  border-radius: var(--radius2);
}

@media (min-width: 760px) {
  .case { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); align-items: center; }
  .case:nth-child(even) { grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); }
  .case:nth-child(even) > .case__viewer { order: 2; }
  /* Scoped so it outranks the :nth-child rule above whatever position it lands in. */
  .cases > .case--quote { grid-template-columns: minmax(0, 1fr); }
}

/* The page's proof moment, and the only place a wipe is allowed. An accent slab covers the
   thumbnail and retracts in six visible steps, so the video is uncovered rather than faded
   in. Six steps and not a smooth slide: the steps are what make it read as blocks.
   Base state is retracted, so a page where the attribute never lands shows the thumbnail
   with no slab over it rather than a permanently covered frame. */
.case__viewer {
  display: block;
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius);
  overflow: hidden;
  background: #0e0e0e;
  transition: border-color var(--dur-fast) var(--ease-out);
}
.case__viewer::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: right;
}
.case[data-wipe="run"] .case__viewer::after {
  animation: case-wipe var(--dur-enter) steps(6, end) backwards;
}
@keyframes case-wipe {
  from { transform: scaleX(1); }
  to   { transform: scaleX(0); }
}

/* The words arrive a beat after the picture and by a different move, so the two halves read
   as one moment with an order to it rather than as the same fade applied twice. Visible by
   default: if the attribute never lands, the quote is simply there. */
.case__said {
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 4px 6px;
}
.case[data-wipe="run"] .case__said {
  animation: said-rise var(--dur-enter) var(--ease-out) 120ms backwards;
}
@keyframes said-rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

.case__who {
  display: flex;
  align-items: center;
  gap: 12px;
}

.case__avatar {
  flex: none;
  width: 48px;
  height: 48px;
  border-radius: var(--pill);
  background: var(--panel2);
  object-fit: cover;
}

.case__name {
  display: block;
  font-family: var(--body-mixed);
  font-size: 23px;
  line-height: 1;
}

.case__subs {
  display: block;
  margin-top: 4px;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  letter-spacing: var(--track-ui);
  color: var(--muted2);
}

.case__quote {
  margin: 0;
  font-family: var(--body);
  font-size: 24px;
  line-height: 1.4;
  color: var(--text);
}

.case__source {
  margin: 0;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  line-height: 1.8;
  letter-spacing: var(--track-ui);
  text-transform: uppercase;
  color: var(--muted2);
}

/* ============ call to action ============ */
/* Two contact moments on the page and no more: the hero, and this. There used to be a third
   between the quotes and the grid carrying the same three buttons as the other two, which
   is how a page starts reading as a template rather than as an offer. */

.cta {
  margin-top: 44px;
  padding: 48px 24px;
  background: var(--panel);
  border: var(--ui-border);
  border-radius: var(--radius2);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  text-align: center;
}

.cta__title {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(30px, 4.6vw, 48px);
  line-height: 1;
  letter-spacing: 1px;
}

/* One word of the CTA carries the accent. The rest of the line stays --text so the colour
   reads as emphasis rather than as a second heading. This is the timeline's version, and it
   stays still: the rotating treatment below is used once on the site, on the work page. */
.cta__pop {
  color: var(--accent);
}

/* ---- the rotating word, work page only ----
   The single memorable motion moment on the site, which is why nothing else around it moves.

   The slot takes its width from the block inside it and the block takes its width from the
   phrase it is holding, so the rest of the line is pushed and pulled as the phrase changes.
   That movement is the effect. It is also genuine layout shift rather than a transform, so
   it was measured instead of argued about, and the number is in CLAUDE.md.

   The slab is a rectangle and not another pill, because a placed block is the one shape this
   site keeps making. Without JavaScript, and under reduced motion, it is a slab with CRAZY
   in it and nothing ever happens to it. */
.cta__slot {
  display: inline-block;
  position: relative;
  max-width: 100%;
  vertical-align: baseline;
}

.cta__slab {
  display: inline-block;
  position: relative;
  padding: var(--slab-pad);
  /* The block is the mask, in both directions. Characters leave through its top edge, and
     the outgoing word - which is out of flow and can be wider than the word replacing it -
     is kept from hanging off its right edge while the two overlap.

     clip-path and NOT overflow:hidden, which is not interchangeable here: an inline-block
     whose overflow is anything but visible takes its baseline from its bottom margin edge
     instead of from the text inside it. That is what dragged the whole slab 12px above the
     sentence it sits in, and the vertical-align:bottom that used to be on .cta__word was
     compensating for it. clip-path clips without touching baseline alignment, so the word
     in the block now sits on the same baseline as the words either side of it. */
  clip-path: inset(0);
  background: var(--accent);
  color: var(--on-accent);
  white-space: nowrap;
}

/* Both words live here so the outgoing one can be positioned against the content box
   rather than against the slab's padding edge. */
.cta__stack {
  position: relative;
  display: inline-block;
}

/* No clipping and no vertical-align of its own: both belong to the slab now, and both on
   this element pushed the text off the sentence's baseline. An inline-flex box takes its
   baseline from its first item, so the word sits exactly where a plain word would. */
.cta__word {
  display: inline-flex;
}
/* The outgoing word is taken out of flow so it cannot widen the slab on its way out; the
   incoming one is what the slab measures itself against. */
.cta__word--out {
  position: absolute;
  left: 0;
  top: 0;
}

.cta__char {
  display: inline-block;
  white-space: pre;
}

.cta__note {
  margin: 0;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  line-height: 2;
  letter-spacing: var(--track-ui);
  color: var(--muted2);
}

/* The TYPE row sits straight on the page now, so it aligns with the section label
   above it rather than centring like the hero's button row. */
.chips--left {
  justify-content: flex-start;
  margin-top: 14px;
}

/* ============ legal page ============ */
/* A reading page, not a portfolio page: one narrow column, generous line height, and
   the .ph placeholders from base.css marking every fact only Rayan can supply. */

.legal {
  max-width: 72ch;
  margin-top: var(--gap);
  padding: var(--pad);
  background: var(--panel);
  border: var(--ui-border);
  border-radius: var(--radius2);
}

.legal__title {
  margin: 0 0 6px;
  font-family: var(--display);
  font-size: clamp(28px, 4vw, 40px);
  line-height: 1;
  letter-spacing: 1px;
}

.legal__head {
  margin: 26px 0 8px;
  font-family: var(--ui);
  font-size: var(--fs-ui-lg);
  font-weight: 400;
  line-height: 1.7;
  letter-spacing: var(--track-ui);
  text-transform: uppercase;
  color: var(--accent);
}

.legal__body {
  margin: 0;
  font-family: var(--body);
  font-size: var(--fs-body);
  line-height: 1.45;
  color: var(--muted);
}

.legal__note {
  margin: 8px 0 0;
  font-family: var(--ui);
  font-size: var(--fs-ui);
  line-height: 2;
  letter-spacing: var(--track-ui);
  color: var(--muted2);
}

/* Label over value at every width. A two-column dl would need a breakpoint, and at
   390px the labels here ("TVA intracommunautaire") are too long for a narrow column. */
.legal__list {
  margin: 0;
}

.legal__list dt {
  font-family: var(--ui);
  font-size: var(--fs-ui);
  line-height: 1.9;
  letter-spacing: var(--track-ui);
  text-transform: uppercase;
  color: var(--muted2);
}

.legal__list dd {
  margin: 0 0 8px;
  font-family: var(--body);
  font-size: var(--fs-body-sm);
  color: var(--text);
}

.legal a { color: var(--accent); }
.legal a:hover { text-decoration: underline; text-underline-offset: 3px; }

