/* ==========================================================
   WIREFRAME STAGE — structure only, no design decisions yet.

   Hierarchy is flat: the page is one column of five full-bleed
   <section>s, and each positions its own content purely through
   its padding. There is no shared content column — horizontal
   padding is what sets each section's measure.
   ========================================================== */

* { box-sizing: border-box; }

/* Outfit, self-hosted. Served from this repo rather than a CDN, so there
   is no third party in the critical path — it survives a blocked or slow
   fonts.gstatic.com, a corporate proxy, and working offline. 13.5KB, one
   weight, latin only. Licence sits beside it in fonts/OFL-Outfit.txt,
   which the OFL requires to travel with the file.

   font-display: block, NOT swap. The fallback grotesques are up to 17%
   wider than Outfit — Arial Black is 6.03em against Outfit's 5.13em for
   "Matt Banks" — and .hero-name is sized to fill 92% of the line at
   nowrap. Swapping would overflow the viewport horizontally for as long
   as the fallback was showing. Block shows nothing for a moment instead,
   which for a 13.5KB same-origin file is imperceptible, and it cannot
   push the layout sideways. */
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 900;
  font-display: block;
  src: url('fonts/outfit-900.woff2') format('woff2');
}

/* Lato SemiBold, self-hosted, for the hero subheading. Segoe UI cannot be
   shipped — it is Microsoft's and licensed for use on Windows, not for
   redistribution — so matching it meant finding the nearest OFL face
   rather than packaging the same one.

   Chosen on measurement. Against Segoe UI Semibold it is within 1.9% on
   cap height, x-height, side bearing and string width, which is closer
   than Source Sans, Open Sans or Noto. Selawik measured closer still
   (1.4%) and is Microsoft's own OFL Segoe substitute, but it ships no
   Latin kerning at all — "Va" comes out with a delta of exactly zero —
   and an unkerned face is a step down from the one it replaces.

   Subset to latin and stripped of unused layout features, keeping kern
   and the ligature sets: 668KB of TTF down to 28KB. Licence beside it in
   fonts/OFL-Lato.txt, as the OFL requires.

   font-display: block matches the name above. The pair is same-origin and
   under 42KB together, so they arrive as one; blocking means the hero
   appears once, correct, rather than one line popping in ahead of the
   other or reflowing under it. */
@font-face {
  font-family: 'Lato';
  font-style: normal;
  font-weight: 600;
  font-display: block;
  src: url('fonts/lato-600.woff2') format('woff2');
}

/* ==========================================================
   TYPE SYSTEM — five styles, and only five.

     1. .hero-name        Outfit 900, self-hosted     --font-display
     2. .hero-subheading  Lato SemiBold, self-hosted  --font-subheading
     3. h1                system sans                 --font-text
     4. h2                system sans                 --font-text
     5. p                 system sans                 --font-text

   Only the two hero lines carry a face of their own, and both ship with
   the site. Everything else is one system stack: no download, no wait,
   and nothing to go wrong on a platform we have not tested. If a sixth
   style seems necessary, it is worth checking first whether it is really
   one of these five at a different size.

   Both hero names describe the slot rather than the content. The
   subheading reads "Designer, Engineer, Builder" today and is not
   assumed to keep doing so.

   The two hero fallbacks are chosen to fail gracefully rather than
   conveniently — a black grotesque behind the name so a miss changes the
   face and not the weight, and Segoe behind the subheading because Segoe
   is what Lato was measured against. */
:root {
  --font-display:    'Outfit', system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
  --font-subheading: 'Lato', system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
  --font-text:       system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}

/* Placeholder palettes — neutral greys, not a design decision.

   Themes are driven by data-theme on <html>, set by theme.js. Light is
   attached to the bare :root as well, so it is what shows if the script
   never runs — and so a no-JS visitor gets the theme the site actually
   is, rather than the one held in reserve.

   Dark is kept but no longer reachable without an explicit choice: the
   site does not follow prefers-color-scheme. Whether a dark theme is
   offered at all is still undecided, which is why none of this is
   deleted. */
:root,
:root[data-theme="light"] {
  --bg:        #f7f7f8;
  --fg:        #16181c;
  --fg-muted:  #6b7178;
  --line:      #dcdee2;
  --surface:   #ececed;
}

:root[data-theme="dark"] {
  --bg:        #16181c;
  --fg:        #e6e8ea;
  --fg-muted:  #8b9198;
  --line:      #32363c;
  --surface:   #21242a;
}

/* Display grey. One fixed value in both themes, deliberately — the name
   holds the same tone whichever way the page is set, rather than flipping
   with the palette the way everything else does.

   Sits outside the two blocks above for that reason: it is not part of
   the theme pair and must not be overridden by either.

   Contrast is 7.6:1 on the dark background and 2.18:1 on the light one.
   The second is below WCAG AA, which wants 3:1 even for large text — a
   known trade for holding one tone, not an oversight. Raising the light
   theme's --bg or darkening this value are the two ways out if that
   matters later. */
:root {
  --fg-display: #a8aaac;
}

body {
  margin: 0;
  font-family: var(--font-text);
  line-height: 1.6;
  /* fluid base size: scales between 320px and 1200px viewports */
  font-size: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  background: var(--bg);
  color: var(--fg);
}

a { color: inherit; }

/* nothing may overflow its container horizontally */
img, video, canvas, svg {
  display: block;
  max-width: 100%;
  height: auto;
}

/* ---- The column ----
   Wraps every section. Uses the same --pad-y / --pad-x names the
   sections do; each section redefines them on itself, so there's no
   leakage — but it means one debug rule can visualise both levels.

   Starts at zero: any vertical padding here pushes the fold's
   min-height: 100dvh past the viewport, so raise it deliberately. */

.column {
  /* Fixed — these stay 32px at every window size. */
  --pad-y: 32px;
  --pad-x: 32px;

  /* The column itself is fluid: it fills the window until it hits this
     cap, then stops growing and centres. box-sizing is border-box, so
     the cap includes the padding above. */
  --col-max: 1200px;

  max-width: var(--col-max);
  margin-inline: auto;

  position: relative;   /* so debug guides can position against it */
  padding: var(--pad-y) var(--pad-x);
}

/* ---- The fold ----
   nav + hero + capabilities occupy exactly one viewport at any
   size or aspect ratio, so the work section never peeks through.
   min-height (not height) so that on very short viewports —
   landscape phones — the content grows instead of overflowing. */

.fold {
  position: relative;   /* containing block for the shader canvas */

  /* The fold is meant to occupy exactly one viewport. It now sits inside
     the column's padding, so that padding has to come out of the height
     or the fold would overhang the bottom of the screen by that much.
     --pad-y is inherited from .column here (.fold doesn't set its own),
     so this tracks automatically if the column padding changes. */
  min-height: calc(100dvh - (var(--pad-y) * 2));
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* hero absorbs all leftover space; nav and capabilities stay
   their natural size */
.fold .section--hero { flex: 1; }

/* Trail layer. Fixed to the viewport rather than placed in the flow:
   the buffer has to be screen space, or content scrolling past would
   carry the canvas along with it and never move relative to it.

   Sits behind the column, so trails render behind content. Transparent
   where there's no trail, so the page shows through untouched — and if
   WebGL2 is missing nothing renders at all and the page is unaffected. */
#trail-canvas {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 0;
  pointer-events: none;
}

.column {
  z-index: 1;   /* above the trail layer */
}

/* ---- Section scaffold ---- */

/* Padding is set through variables rather than the shorthand so any
   section can override one axis without restating the other, and so
   debug mode can draw the content box at exactly the same values.

   Fixed values from a small scale, stepped once at 800px — not fluid.
   The column caps at 1200px, so vw-based padding would keep growing
   inside a container that had stopped, and the proportions would drift
   apart at exactly the widths where they should be settled.

   Two layouts to reason about instead of a continuum of untested ones. */
.section {
  position: relative;
  width: 100%;

  --pad-y: 32px;
  --pad-x: 32px;

  padding: var(--pad-y) var(--pad-x);
}

/* ---- per-section padding — tune here ----
   All 32px vertical for now; override individually as needed. */

/* ---- one step down for narrow screens ---- */

@media (max-width: 800px) {
  .section { --pad-x: 20px; }
}

/* ---- one step down for SHORT screens ----

   Vertical padding is the only thing in the fold with slack in it. On a
   landscape phone the fold has under 450px to work with, and 192px of
   that was padding — 32px at three levels (column, hero section,
   capabilities section), none of which responded to viewport height.

   Type cannot give here: at those widths the name and subheading are
   already sitting on their clamp floors, and pinning the name to its
   32px minimum still left the fold ~15px over. Padding was never the
   symptom, it was the cause.

   Steps rather than a fluid clamp on purpose — a vh-based clamp would
   also thin the padding on ordinary desktop windows, which do not have
   the problem and should not pay for it. Above 700px tall nothing
   changes.

   Both custom properties have to be set: .column owns the outer padding
   and the fold's min-height calc reads it, while .section owns the two
   inner ones. Sized against a three-line capabilities paragraph, which
   is the worst case the placeholder copy asks for. */
@media (max-height: 700px) {
  .column, .section { --pad-y: 24px; }
}

@media (max-height: 560px) {
  .column, .section { --pad-y: 16px; }
}

@media (max-height: 450px) {
  .column, .section { --pad-y: 8px; }
}

/* No shared content column — each section positions its own content
   entirely through its padding. Horizontal padding is therefore what
   sets the measure, and it's tuned per section above. */

/* ---- Per-section rough heights (adjust freely) ---- */

.section--hero {
  display: flex;
  align-items: center;
}

/* ---- Content bits ---- */

/* ==========================================================
   DEV AFFORDANCES — delete this whole block before launch.
   ========================================================== */

/* Absolutely positioned so the controls take no part in layout. */
/* Hidden by default, shown only where index.html's head script found a
   localhost hostname. Hiding is the default rather than the exception so
   that a failure to set the flag errs towards a clean public page.

   The theme itself still resolves and still follows the OS preference off
   localhost — only the buttons go. Same for debug: debug.js still binds,
   so `document.documentElement.dataset.debug = ''` in a console works on
   the live site if it is ever needed. */
.dev-controls { display: none; }

:root[data-dev] .dev-controls {
  position: absolute;
  top: 1rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 3;
  display: flex;
  gap: 0.25rem;
}

.dev-toggle {
  font: inherit;
  font-size: 0.85rem;
  color: var(--fg-muted);
  background: none;
  border: 0;
  padding: 0.2rem 0.5rem;
  cursor: pointer;
  border-radius: 3px;
}

.dev-toggle:hover { color: var(--fg); }

/* ---- debug mode ---- */

/* Section extents. `outline` rather than `border` so turning it on
   doesn't shift the layout being measured. */
:root[data-debug] .section {
  outline: 1px dashed color-mix(in srgb, var(--fg-muted) 55%, transparent);
}

:root[data-debug] .fold {
  outline: 1px solid color-mix(in srgb, var(--fg-muted) 70%, transparent);
}

/* Padding boundaries, drawn as four guide lines that run the full width
   and height of the section rather than as an inset box — a box reads
   as an object, lines read as measurements.
   Green follows the DevTools convention for padding.

   Four background images on one pseudo-element: two horizontal rules
   positioned at the vertical padding edges, two vertical rules at the
   horizontal ones. Backgrounds don't affect layout and can't be hovered,
   so they can't interfere with what's underneath. */
:root[data-debug] .section::after,
:root[data-debug] .column::after,
:root[data-debug] .card::after,
:root[data-debug] .card-media::after,
:root[data-debug] .card-text::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;

  --dbg-pad: rgba(110, 210, 140, 0.7);
  --dash-h: repeating-linear-gradient(to right,
              var(--dbg-pad) 0 4px, transparent 4px 8px);
  --dash-v: repeating-linear-gradient(to bottom,
              var(--dbg-pad) 0 4px, transparent 4px 8px);

  background-image: var(--dash-h), var(--dash-h), var(--dash-v), var(--dash-v);
  background-position:
    0 var(--pad-y),                  /* top padding edge    */
    0 calc(100% - var(--pad-y)),     /* bottom padding edge */
    var(--pad-x) 0,                  /* left padding edge   */
    calc(100% - var(--pad-x)) 0;     /* right padding edge  */
  background-size: 100% 1px, 100% 1px, 1px 100%, 1px 100%;
  background-repeat: no-repeat;
}

/* Each nesting level gets its own hue so they don't read as the same
   measurement: column blue, section green, card amber. */
:root[data-debug] .column::after {
  --dbg-pad: rgba(150, 165, 255, 0.75);
}

:root[data-debug] .column {
  outline: 1px solid rgba(150, 165, 255, 0.4);
}

:root[data-debug] .card::after {
  --dbg-pad: rgba(240, 185, 100, 0.75);
}

:root[data-debug] .card {
  outline: 1px dashed rgba(240, 185, 100, 0.45);
}

/* cells — the innermost level */
:root[data-debug] .card-media::after,
:root[data-debug] .card-text::after {
  --dbg-pad: rgba(235, 130, 190, 0.75);
}

:root[data-debug] .card-media,
:root[data-debug] .card-text {
  outline: 1px dashed rgba(235, 130, 190, 0.45);
}

/* Section name, top-left of each */
:root[data-debug] .section::before {
  content: attr(data-label);
  position: absolute;
  top: 0.3rem;
  left: 0.5rem;
  font-size: 0.65rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--fg-muted);
  opacity: 0.7;
}

/* Hover targets over the padding bands. Invisible until hovered, when
   they tint and show their readout. */
.debug-hit {
  position: absolute;
  z-index: 2;
  pointer-events: auto;
}

.debug-hit:hover {
  background: rgba(110, 210, 140, 0.14);
}

/* Gaps are always visible, not just on hover — they're the one kind of
   space with no box of their own, so a boundary line can't show them.
   Hatched, in the amber the card level uses. */
:root[data-debug] .debug-hit[data-kind="gap"] {
  background: repeating-linear-gradient(
    45deg,
    rgba(240, 185, 100, 0.22) 0 5px,
    transparent 5px 10px
  );
}

:root[data-debug] .debug-hit[data-kind="gap"]:hover {
  background: rgba(240, 185, 100, 0.3);
}

.debug-hit:hover::after {
  content: attr(data-tip);
  position: absolute;
  top: 2px;
  left: 4px;
  z-index: 4;

  padding: 0.15rem 0.4rem;
  border-radius: 3px;
  background: #101216;
  color: #e6e8ea;
  font: 0.68rem/1.5 ui-monospace, monospace;
  white-space: pre;   /* corner tips carry two values on separate lines */
  pointer-events: none;
}

/* strips on the right edge anchor their tooltip the other way, or it
   would run off the side of the window */
.debug-hit[data-anchor="right"]:hover::after {
  left: auto;
  right: 4px;
}

/* ---- 3. h1 ---- */

/* The hero's <h1> is a container: both its lines set their own face and
   size below, so nothing here reaches them. This is the style for any
   other h1 on the site. */
h1 {
  font-family: var(--font-text);
  /* fluid: ~1.75rem on a 320px phone, ~3rem on desktop */
  font-size: clamp(1.75rem, 1.2rem + 2.5vw, 3rem);
  line-height: 1.15;
  margin: 0 0 1rem;
  text-wrap: balance;
}

/* ---- 4. h2 ---- */

/* Site-wide rather than scoped to cards. It was .card h2, which meant a
   heading anywhere else silently fell back to the browser default — 2em
   bold, far larger than this. */
h2 {
  font-family: var(--font-text);
  font-size: 1.15rem;
  line-height: 1.3;
  margin: 0 0 0.25rem;
  text-wrap: balance;
}

/* ---- 5. p ---- */

/* Size and line-height come from body; stated here so the fifth style has
   somewhere to live and the family cannot drift from the other four.
   Margins are left to the browser deliberately — this is a type system,
   and taking spacing over is a separate decision. */
p {
  font-family: var(--font-text);
}

/* Separate blocks so the two always stack, rather than depending on the
   line breaking where it happens to fit — at some widths the old single
   string wrapped mid-phrase.

   Neither line rides the h1's clamp any more. The name's size is bounded
   by how wide "Matt Banks" is, which has nothing to do with how large a
   heading should be, and the subheading was decoupled with it so that
   editing style 3 cannot reach into the hero. All three sizes are now
   stated where they are used. */
.hero-name,
.hero-subheading { display: block; }

.hero-name {
  font-family: var(--font-display);
  font-weight: 900;

  /* Width-bound, not taste-bound. "Matt Banks" in Outfit 900 advances
     5.309em, and the -0.02em tracking below claws back 0.18em of that
     across its nine gaps, so one line costs 5.129em of width.

     The hero's content box is the viewport less 128px — .column and
     .section each pad 32px a side — so the largest size that still fits
     on one line is (100vw - 128px) / 5.129. The coefficients below are
     that, held to 92% so the fallback face has room to be a little wider
     than Outfit during the swap without overflowing.

     It fills 92% of the content width at every width from 320px up, and
     caps at 12rem because the column stops growing at 1200px and there
     is no more room to take. Re-measure if the name, the weight or the
     tracking changes — all three move the constant. */
  font-size: clamp(2rem, 17.9vw - 23px, 12rem);

  /* One line was the brief. The sizing above is what makes that safe;
     this only stops a sub-320px viewport breaking it. */
  white-space: nowrap;

  /* Recessive rather than full --fg. At this size a near-white name is a
     lot of bright area and glares; stepping it toward the background lets
     it read as a mark rather than as shouting. */
  color: var(--fg-display);

  /* Black weights look gappy at display size; tracking in closes the
     counters back up. Folded into the width maths above. */
  letter-spacing: -0.02em;

  /* Closes the gap to the subheading by half. There is no margin between
     the two by default — the space is half-leading, and Outfit's content
     box (1.26em) is taller than the 1.15 line-height, so that leading is
     already negative and the glyphs overflow their own boxes. Pulling on
     line-height instead would move the name inside its box rather than
     move the two closer.

     em, so it resolves against the name's own size and tracks the fluid
     clamp: the gap scales with the name, not with the subheading. Measured
     baseline-to-cap-height, this halves it at desktop and lands between
     54% and 67% on smaller screens, where the two sizes converge.

     Re-derived for Lato: its content box is 1.200em against Segoe's
     1.330em, which moved the subheading's baseline and with it the gap. */
  margin-bottom: -0.133em;

  /* Optical alignment. A glyph's ink starts to the right of its origin by
     its left side bearing, and that bearing scales with font size — so at
     192px the M sits 11.3px in while the 48px D below sits only 4.1px in,
     and the name reads as indented by the 7.2px difference.

     Cancelling each line's own bearing lands both ink edges on the
     content edge, which aligns them to each other and to the column.

     GLYPH-SPECIFIC: 0.059em is Outfit 900's 'M'. Change the first letter
     of the name and this needs re-measuring. */
  margin-left: -0.059em;
}

/* Style 2. Stated in full rather than inherited from anywhere: the hero
   is the one place where sizes and bearings are load-bearing, so nothing
   here should move because something else changed. */
.hero-subheading {
  font-family: var(--font-subheading);

  /* Its own size rather than the h1's. It used to inherit, which quietly
     tied this line to style 3 — editing the h1 to suit a case-study page
     would have moved the hero with it. Same value the inherited clamp
     produced, so nothing shifts; the two are just independent now. */
  font-size: clamp(1.75rem, 1.2rem + 2.5vw, 3rem);

  /* h1 carries the browser default of 700, which is heavier than this
     line needs sitting under a 900 name. Stated rather than inherited so
     it does not silently follow if the h1 default ever changes. */
  font-weight: 600;

  /* Opened slightly. The name is tracked in at -0.02em, so letting the
     subheading breathe the other way separates the two as texture and not
     only as size. */
  letter-spacing: 0.02em;

  /* Same optical alignment as the name above — cancels this line's own
     left side bearing so its ink starts on the content edge.

     GLYPH-SPECIFIC: 0.086em is Lato SemiBold's 'D'. Now exact on every
     platform rather than only on Windows — that was the point of
     self-hosting this line. It measured 0.0855em against Segoe's 0.0864em,
     so the value carried over unchanged. Change the first letter of the
     subheading and re-measure. */
  margin-left: -0.086em;
}


.credibility { margin: 0; }

/* Video beside the text rather than above it, alternating sides.
   DOM order is always video-then-text (correct when stacked on narrow
   screens); the flip is presentational, done with `order`. */
/* Each row is its own two-column block with its own padding, tunable
   independently of the work section that contains it. Same variable
   names as .section — redefined here, so nothing leaks either way. */
.card {
  --pad-y: 16px;
  --pad-x: 16px;

  position: relative;   /* so debug guides can position against it */
  padding: var(--pad-y) var(--pad-x);

  display: grid;
  grid-template-columns: 2fr 1fr;   /* video two thirds, text one third */

  /* Space between siblings belongs to neither of them, which is exactly
     what gap expresses. Routing it through symmetric cell padding would
     inset the outer edges too. */
  --gap: 40px;
  gap: var(--gap);

  /* stretch, not center: both cells fill the row height, so the text
     cell is exactly as tall as the media and its own padding is what
     positions the copy inside it. Centering made the cell shrink to its
     content and float in undefined space. */
  align-items: stretch;
}

/* Cells keep their own padding hook, but at zero — the card's gap does
   the separating now. Raise these if a cell needs its own inset. */
.card-media,
.card-text {
  --pad-y: 0px;
  --pad-x: 0px;

  position: relative;   /* so debug guides can position against it */
  padding: var(--pad-y) var(--pad-x);
}

/* Centre the copy against the video.

   Done inside the cell rather than with align-items: center on the card,
   because that would size each cell to its own content and leave the text
   floating in unclaimed space. The cell still stretches to the full row
   height and stays paddable; only the text moves within it. */
.card-text {
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* Even cards put the text on the left. The track widths have to flip
   too — `order` only changes which cell an item lands in, so without
   this the video would be placed into the narrow 1fr column. */
.card:nth-child(even) {
  grid-template-columns: 1fr 2fr;
}

.card:nth-child(even) .card-media { order: 2; }

/* Card headings take the site-wide h2 above — the scoped rule that used
   to live here said the same thing and only reached cards. */

.card-meta {
  margin: 0 0 0.5rem;
  font-size: 0.85rem;
  color: var(--fg-muted);

  /* Balanced like the h2 above it, so the two read as one block rather
     than as a tidy heading sitting on a ragged paragraph. Greedy wrapping
     left this line filling the measure and dropping a two-word orphan
     underneath, which was the widest thing in the card and made the
     heading look arbitrarily short by comparison.

     Only worth doing because this line is short. Balance is for a few
     lines, not for body copy — browsers cap how far it will look, and on
     a long paragraph it costs layout time for nothing. That is why it is
     here and not on p. */
  text-wrap: balance;
}

.card p { margin: 0; }

/* Below this the columns get too cramped, so stack. Resetting `order`
   puts the video back on top, which is the right reading order. */
@media (max-width: 800px) {
  /* the two columns collapse into two rows, video first */
  .card,
  .card:nth-child(even) {
    --pad-y: 24px;
    --pad-x: 0px;
    grid-template-columns: 1fr;
    grid-auto-flow: row;
    --gap: 16px;
  }
  .card:nth-child(even) .card-media { order: 0; }

  /* Stacked, the text cell inherits the whole column width — a 113
     character measure on the meta line, well past readable. Capped, and
     ranged left rather than centred.

     Centring it was tried and read as broken: the video stays full-bleed,
     so a centred block sat 124px in from the video's left edge at 800px
     and every card looked misaligned with its own media. Sharing the left
     edge matters more here than symmetric gutters, because the eye tracks
     that edge down the whole page.

     The cap alone is what makes the blocks identical: same width on every
     card, whatever the heading length, so only the height varies with
     content. Below roughly 570px the cap stops binding and the cell
     simply fills the column, which is what you want when there is no room
     to give. */
  .card-text {
    max-width: var(--text-measure, 28rem);
  }
}

.portrait {
  width: 180px;
  max-width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;   /* square crop regardless of the source ratio */
  display: block;
  margin-bottom: 1.5rem;
  background: var(--surface);
}

/* Project clips. Same 16:9 box the placeholder used, so the card layout
   is unchanged. object-fit: cover crops rather than letterboxes, which
   keeps every card the same shape regardless of source aspect. */
.card-video {
  width: 100%;
  aspect-ratio: 16 / 9;
  height: auto;
  object-fit: cover;
  display: block;
  background: var(--surface);
}

.placeholder-note {
  color: var(--fg-muted);
  font-style: italic;
}
