/**
 * Portfolio archive (`project` CPT archive at /projects/) — fully replaces
 * Neve's generic default archive rendering.
 *
 * Pairs with archive-project.php. Enqueued by the Neve child theme's
 * functions.php only when is_post_type_archive('project') is true.
 *
 * Design tokens duplicated (not @import-ed) from editorial-split-wide.css /
 * editorial-split.css, per this repo's existing per-template convention, so
 * this file works standalone.  Font dependency ('Noto Sans', 'Inter')
 * is enqueued site-wide by functions.php, not by this file.
 *
 * TWO CARDS SINCE v1.13.9, AND THIS IS THE FIRST THING TO KNOW ABOUT THIS
 * FILE. The archive serves two layouts, and until now they differed only
 * in CSS. They now render different markup from one loop, so every rule
 * below belongs to one of three groups: shared, `--stacked`, or `--grid`.
 * A rule with a bare `.portfolio-archive__*` selector is shared BY
 * INTENTION, not by accident -- check before you add one.
 *
 * THE GRID CARD (the default, /projects/): a 16/9 crop box with a 20px
 * radius, then the project name BELOW it in Noto Sans SemiBold, then that
 * project's discipline terms, both inset 16px from the image's left edge
 * on the `--ground-band` grey. No scrim, no text over any photograph.
 * SPEC-archive-card-design CAP-1; the measured values and the optical-gap
 * derivation live at rules 5/5b.
 *
 * THE STACKED CARD (`?layout=stacked`, the preserved dark prototype) keeps
 * what Story 4 shipped on 2026-09-16 and is frozen: when a project has a
 * Featured Image its name is the last child INSIDE
 * `.portfolio-archive__media`, positioned absolute at left:7px/bottom:3px
 * over a bottom-to-top gradient scrim (`.portfolio-archive__scrim`, also
 * inside `.media`) painted on the image itself — identical values at every
 * breakpoint, no per-breakpoint override.
 *
 * `.portfolio-archive__title--no-media` is now STACKED-ONLY, and that is
 * not the same as the no-Featured-Image case going away. Story 2's frozen
 * `MISSING_THUMBNAIL` contract (the title always renders, image or not)
 * holds on both layouts. That class exists to UNDO an overlay, and the
 * grid's title was never one, so the grid has nothing to undo.
 *
 * WHICH LAYOUT THE NEXT TWO PARAGRAPHS DESCRIBE. As of v1.12.0 this sheet
 * renders TWO layouts and the GRID IS THE DEFAULT -- see "THE DEFAULT
 * LAYOUT" below, which is what /projects/ now serves. Everything from here
 * to that banner describes the STACKED layout, preserved at the named
 * prototype URL `?layout=stacked`. Read the breakpoint notes immediately
 * below as scoped to it, not to the archive as a whole.
 *
 * STACKED, SMALL BREAKPOINT (<=768px): a single-column image+title stack,
 * approximating Neve's own pre-migration default archive look (captured
 * live via Claude in Chrome against dev.joetennis.com before writing this
 * rule, per this story's "Ask First" boundary — Neve itself isn't vendored
 * in this repo), with one deliberate departure from that capture: the
 * default was a 2-per-row wrapping grid, but Joe asked (2026-09-12, after
 * installing and viewing live) to keep it one column throughout instead —
 * so `.portfolio-archive__item` is `width:100%`, not `50%`. Story 4
 * (2026-09-16) further replaced this breakpoint's original ~16:9
 * `object-fit:cover` crop and below-image title with the same uncropped/
 * native-ratio image and corner-overlay title used at medium/wide, for
 * visual consistency across breakpoints — see the <=768px media query
 * below. None of Neve's own archive class names (nv-blog-grid /
 * posts-wrapper / layout-grid / article-content-col / blog-entry-title,
 * etc.) are reused, since the point of this template is to fully remove
 * that rendering path, not just visually resemble it.
 *
 * STACKED, MEDIUM/WIDE (>=769px, identical this pass — see portfolio-
 * archive-layout.md): one project per row, in a full-band-width `.media`
 * box at a fixed `aspect-ratio: 900 / 188` filled with `object-fit:
 * cover`. See the base `.portfolio-archive__media` / `__image` rules below
 * for the measured reasoning and the four outliers that centre-crop.
 *
 * CORRECTED 2026-09-21 (v1.12.0). This paragraph used to describe the hero
 * as "never cropped — sized up to 90vw wide / 80vh tall via max-width/
 * max-height with width/height both `auto`". That was superseded at Joe's
 * live review on 2026-09-16, when the shrink-wrap was replaced by the
 * fixed-aspect cover box and the 80vh backstop was removed outright (see
 * SPEC.md Constraints); the rules below changed then and this header did
 * not. It had been describing a layout the sheet no longer rendered for
 * five days.
 *
 * DISCIPLINE FILTER CHIPS ARE RETIRED (CAP-3, 2026-09-21, v1.11.16).
 * The chip row, the small-breakpoint funnel button, the mobile chip
 * overlay, the `.is-hidden` client-side filter and archive-project.js are
 * all deleted, from BOTH layouts -- SPEC.md's "must not fork the
 * template". `.portfolio-archive__toolbar` now holds its <h1> alone, and
 * the shared `.joetennis-breadcrumb-utility-row`'s own
 * `justify-content: space-between` left-anchors a lone child, so nothing
 * here needs an `order` override any more.
 *
 * WHAT DELIBERATELY SURVIVES, AND WHERE IT MOVED TO: the `--state-chip-*`
 * and Family B `--current-selected-*` declarations are still bound, but as
 * of v1.11.18 they live in `.joetennis-ground-white`
 * (site-interactive-states.css) along with the rest of this layout's white
 * ground, not in the grid section below. They are VALUES, not rules, and
 * that sheet's contract is that a ground supplies them whether or not a
 * component is on screen -- so they are latent again, exactly as they were
 * before this archive first rendered a chip. Do not "clean up" what looks
 * unused, there or here. The shared sheet also still carries chip RULES
 * naming selectors this file no longer emits; that sheet belongs to
 * spec-interactive-color-states and is not this file's to edit.
 */

.portfolio-archive {
	--color-bg-site: #0a0e1a;
	--color-bg-recessed: #05070d;
	--color-bg-surface: #111827;
	--color-accent-primary: #00e5ff;
	--color-accent-secondary: #a855f7;
	--color-text-heading: #f8fafc;
	--color-text-body: #e2e8f0;

	background-color: var(--color-bg-site);
	color: var(--color-text-body);
	font-family: var(--font-body);
}

.portfolio-archive__empty {
	max-width: 1200px;
	margin: 0 auto;
	padding: 64px 24px 120px;
	font-family: var(--font-body);
	font-size: 18px;
	color: var(--color-text-body);
	opacity: 0.7;
}

/* Band alignment (CAP-3, spec-sitewide-alignment-band): left-anchors the
   hero image row to the same shared --container band as the header and
   the breadcrumb/utility row, instead of centering on the raw viewport.
   text-align:left (was :center, Story 4) left-anchors
   .portfolio-archive__media, which shrink-wraps to its image
   (inline-block) rather than spanning full width -- see .media's own rule
   below for why. */
.portfolio-archive__link {
	display: block;
	text-decoration: none;
	color: inherit;
	text-align: left;
	max-width: var(--container);
	margin: 0 auto;
	padding: 0 var(--band-gutter, 15px);
}

/* The utility row. Band alignment (max-width/centering/horizontal
   padding, left/right flex-anchoring) is owned by the shared
   `.joetennis-breadcrumb-utility-row` class (see
   site-breadcrumb-utility-row.css) applied alongside this one in
   archive-project.php — only vertical spacing is this rule's own (CAP-2,
   spec-sitewide-alignment-band, superseding this rule's old hardcoded
   1200px/24px band). Since the chips were retired (v1.11.16) this row has
   a single child at every breakpoint. The flex declarations below stay
   anyway -- not as row-behaviour this file still needs, but as the
   defensive mirror of the shared class documented in the rule itself, so
   the row degrades to a flex row if that sheet fails to load. The <=768px
   override, which existed only to re-pack three children, keeps nothing
   but its padding. */
.portfolio-archive__toolbar {
	/* display:flex duplicated here defensively (review, 2026-09-16) — the
	   canonical declaration lives on the shared `.joetennis-breadcrumb-
	   utility-row` class (site-breadcrumb-utility-row.css), but this rule
	   still needs its own display:flex so this row degrades gracefully
	   (still lays out as a flex row) rather than stacking to block if that
	   shared stylesheet ever fails to enqueue/load. Harmless duplication
	   when both are present — same value, no conflict. */
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 16px 24px;
	/* 20px (Joe live review 2026-09-16, was 48px): the shared vertical
	   rhythm unit this pass introduced -- see .portfolio-archive__list's
	   own comment for where the number came from. Measured from the
	   header's hairline to this row's content. */
	padding-top: 20px;
}

/* Styled as a place-indicator, not a page heading (Joe live review
   2026-09-16, superseding the 32px/700/heading-font treatment): every
   template's breadcrumb/utility row should read the same way, so this
   matches .flush-split__breadcrumb (flush-split.css) exactly -- 14px,
   body font, regular weight, plain white. Deliberately still an <h1> in
   archive-project.php: the visual demotion is presentational only, and
   the page still needs one real top-level heading for SEO and screen
   readers. It is also already non-interactive (no <a> wrapper) and
   already omits a "Home /" prefix, per Joe's spec for this slot, so
   neither needed a markup change here. */
.portfolio-archive__heading {
	font-family: var(--font-body);

	/* IT TAKES THE BREADCRUMB'S CURRENT TREATMENT, v1.13.5 (Joe). This row
	   is the same slot, on the same shared band class, as the breadcrumb on
	   a project page -- so "Portfolio" here and "Portfolio /
	   <title>" there read as the same piece of chrome, and until now they
	   did not. Joe spotted it by eye: "the spacing of the characters seems
	   to shift, like something's inheriting something wrong."

	   HE WAS RIGHT, AND IT WAS INHERITANCE. This is an <h1>, so it took
	   Neve's HEADING letter-spacing theme_mod -- computed -1px, measured
	   live -- while the breadcrumb takes Neve's BODY one, +0.3px. Neither
	   value is written anywhere in this repo. At 1.3px per character that
	   is ~12px accumulated across "Portfolio": it measured 54.2px wide
	   against roughly 66px at breadcrumb tracking, i.e. visibly cramped.

	   `inherit`, NOT A LITERAL `0.3px`, and this is the important half.
	   0.3px is a Customizer value that can move; hand-copying it here
	   would silently desynchronise this row from the breadcrumb the next
	   time Joe touches body typography -- the same hazard CLAUDE.md
	   records for `--primarybtnpadding` in site-footer.css. Inheriting
	   walks up to <body>, which is exactly where the breadcrumb gets its
	   own value, so the two track one dial by construction. Same argument
	   for line-height.

	   14px/600 mirrors `.flush-split__breadcrumb-item--current`. Weight is
	   the one thing Joe named outright: a lone crumb takes the current
	   treatment's 600, because when it stands alone it IS where you are.

	   STILL AN <h1> in archive-project.php. The visual demotion is
	   presentational only and the page still needs one real top-level
	   heading for SEO and screen readers -- which is precisely why the
	   Neve heading typography reaches it, and why the override belongs
	   here rather than in the markup. */
	font-weight: 600;
	font-size: 14px;
	letter-spacing: inherit;
	line-height: inherit;

	/* WHITE SERVES THE DARK STACKED LAYOUT ONLY. `?layout=stacked` renders
	   on the dark ground and needs this; the grid -- the default since
	   v1.12.0 -- overrides it to #000000 further down, matching the
	   breadcrumb rather than the page's ground ink. */
	color: #ffffff;
	text-align: left;
	margin: 0;
}

/* ---------------------------------------------------------------------
   Wide/medium (>=769px, identical this pass): one project per row.
   Mobile-first base rules below target this breakpoint; the <=768px
   media query at the bottom of this file overrides them for small.
   ------------------------------------------------------------------ */

/* Top padding/item gap (20px, Joe live review 2026-09-16): matches the
   header's own vertical clearance around the CTA button (measured live,
   ~21px top/bottom -- a byproduct of centering a fixed-height button in a
   fixed-height row, not a value typed elsewhere), so the same rhythm now
   repeats at hairline->toolbar-content, toolbar->first-hero, and
   hero->hero. Was 96px/96px pre-review. Bottom padding (120px) untouched
   -- not part of this pass. Small-breakpoint override below (32px) is
   also untouched -- mobile vertical rhythm wasn't part of this request. */
.portfolio-archive__list {
	padding: 20px 0 120px;
}

.portfolio-archive__item {
	margin: 0 0 20px;
}
.portfolio-archive__item:last-child {
	margin-bottom: 0;
}

/* .media is a fixed-aspect, full-band-width crop box (Joe live review
   2026-09-16, superseding the intrinsic-size shrink-wrap this rule used
   pre-review). Two things changed together, and each depends on the
   other:

   1. Full band width. The hero now always spans the band's left edge to
      its right edge. Pre-review it shrink-wrapped to the image's own
      intrinsic size, so any image narrower than the band fell short of
      the right edge -- the misalignment Joe reported, visible at medium
      widths especially.

   2. Fixed 900/188 aspect + object-fit:cover (see .image below). Width
      alone is not enough: with height:auto, a squarish source image
      stretched to a 1600px-wide band renders ~1600px tall. Measured live
      against the real library -- 11 of 15 published projects are 900x188
      (4.79:1), but four are near-square or portrait (Book Contributions
      900x1002, Early Mobile Banking 400x400, Progressive Search 900x695,
      CD ROMs & Kiosks 480x360), which rendered 1200-1780px tall before
      this cap. 900/188 is the dominant ratio, so those 11 render
      pixel-exact and uncropped; the four outliers center-crop. Joe is
      separately reworking or removing those four (live review), so this
      ratio is sized to the well-formed majority deliberately, not as a
      compromise across all 15.

   The old `max-height: 80vh` cap that used to contain those outliers is
   gone -- aspect-ratio supersedes it, and unlike 80vh it produces a
   uniform row height across every project rather than a per-image one.
   The scrim (inset:0) and corner title (bottom:3px) are anchored to
   .media, so they now track this box's edges exactly; verified live that
   the title stays inside the cropped box. */
.portfolio-archive__media {
	position: relative;
	display: block;
	width: 100%;
	aspect-ratio: 900 / 188;
	overflow: hidden;
	text-align: left;
}

.portfolio-archive__image {
	display: block;
	width: 100%;
	height: 100%;
	/* Fills .media's fixed-aspect box, cropping overflow rather than
	   letterboxing or distorting. center keeps the crop symmetrical --
	   only the four outlier images above are cropped at all. */
	object-fit: cover;
	object-position: center;
	border-radius: 0;
}

/* "Cropped Feature Heroes" overlay: gradient scrim + corner title sit on
   top of the hero image's own box, at every breakpoint OF THE STACKED
   LAYOUT (no medium/wide-only override needed — see spec-projects-landing-
   page Story 4). Only rendered when has_post_thumbnail() is true; see
   .title--no-media below for the fallback. "Every breakpoint" used to mean
   every breakpoint of the archive, full stop; see the scoping note. */
/* STACKED-ONLY SINCE v1.13.9 (SPEC-archive-card-design CAP-1), along with
   the two title rules below. The grid's card has no scrim: its name sits
   under the image on the grey, so nothing needs a gradient to be legible
   against. archive-project.php renders the scrim in the stacked branch
   only, so this selector could have been left bare and would still paint
   nothing on the grid -- it is scoped anyway, because a rule whose
   applicability depends on which PHP branch ran is a rule you cannot read.

   ALL THREE OF THESE MOVE TOGETHER OR NONE OF THEM DO, and that is the
   trap. `.portfolio-archive__title--no-media` beats the overlay title
   rule purely on SOURCE ORDER today -- both sit at (0,1,0). Scoping the
   overlay rule alone lifts it to (0,2,0), at which point the fallback
   stops winning and a stacked project with no Featured Image gets
   `position: absolute` with nothing to anchor to. */
.portfolio-archive--stacked .portfolio-archive__scrim {
	position: absolute;
	inset: 0;
	pointer-events: none;
	/* rgba(5,7,13,...) is this file's own --color-bg-recessed (#05070d) at
	   60% alpha, confirmed matching -- kept literal rather than the
	   variable, since --color-bg-recessed itself is opaque and a gradient
	   stop can't apply its own alpha on top of a custom property's stored
	   color. */
	background: linear-gradient(0deg, rgba(5, 7, 13, .6) 0%, rgba(5, 7, 13, 0) 100%);
}

.portfolio-archive--stacked .portfolio-archive__title {
	position: absolute;
	left: 7px;
	bottom: 3px;
	z-index: 2;
	margin: 0;
	padding: 0;
	max-width: none;
	font-family: var(--font-heading);
	font-weight: 600;
	font-size: clamp(19px, 2.6vw, 32px);
	color: var(--color-text-heading); /* already #f8fafc — avoid a second literal */
	text-align: left;
}

/* Fallback for has_post_thumbnail() === false: no .media box exists to
   anchor an overlay to, so this renders the original pre-Story-4
   below-image title styling instead. Not breakpoint-specific -- this
   fallback was never medium/wide-only even before Story 4. position:static
   here makes the overlay rule's left/bottom/z-index inert (a static element
   ignores offsets) -- not re-declared/reset since they have no effect, but
   noted here so a future editor doesn't assume every value below is
   load-bearing for this selector specifically.

   STACKED-ONLY SINCE v1.13.9, and NOT because the grid stopped supporting
   projects without a Featured Image -- it still does. This class exists to
   UNDO an overlay, and the grid's title was never an overlay: it is below
   the image by construction, so a card with no image is simply a card with
   no image above its text. archive-project.php therefore stops emitting
   the modifier on the grid. The scoping is also what keeps this rule
   winning against the overlay rule above; see that rule's note. */
.portfolio-archive--stacked .portfolio-archive__title--no-media {
	position: static;
	max-width: 1200px;
	margin: 24px auto 0;
	padding: 0 24px;
	font-family: var(--font-heading);
	font-weight: 600;
	font-size: 20px;
	line-height: 1.3;
	color: var(--color-text-body);
	text-align: left;
}

/* ---------------------------------------------------------------------
   Small/mobile (<=768px): this theme's one existing breakpoint value,
   already used by every other template. Single-column list; hero image
   and title overlay match medium/wide exactly as of Story 4 (2026-09-16)
   — only image *sizing* is overridden here (native ratio vs. medium/
   wide's own 90vw/80vh viewport-relative sizing), not the overlay itself.
   ------------------------------------------------------------------ */

@media (max-width: 768px) {
	/* Only the padding is an override now. `flex-wrap`, `gap` and
	   `align-items` went with the chips (v1.11.16): the first two governed
	   a three-child row and are inert on a lone <h1>, and the third
	   restated the base rule's own value. */
	.portfolio-archive__toolbar {
		padding-top: 24px;
	}

	/* THE 24px MOBILE STEP IS RETIRED, v1.13.5 (Joe: "14px everywhere").
	   It sized this as a page heading; it now reads as a location
	   indicator, and the breadcrumb it mirrors does not change size at
	   any breakpoint either. Nothing replaces it -- the base 14px is the
	   value at every width. */

	/* 20px top/gap matches medium/wide (Joe live review 2026-09-16, was
	   32px) -- the same rhythm unit at every breakpoint. Horizontal 12px
	   and bottom 64px are unchanged; neither was part of that review. */
	.portfolio-archive__list {
		padding: 20px 12px 64px;
	}

	.portfolio-archive__item {
		width: 100%;
		margin: 0 0 20px;
	}

	/* No .portfolio-archive__media or .portfolio-archive__image override
	   here, deliberately (Joe live review 2026-09-16): both inherit the
	   base rules' full-width fixed-aspect crop box, so hero images run
	   edge-to-edge at small exactly as they do at medium/wide. This
	   replaces a pre-review override (width:auto / max-height:80vh) that
	   reverted small back to intrinsic sizing -- which would have
	   silently undone the full-width fix at exactly the breakpoint where
	   Joe first reported the images not reaching the right edge. */

	/* Keeps the small-breakpoint margin at its current documented value
	   (SPEC.md Non-goals: small-breakpoint left/right margin is a deferred
	   tune-live parameter, currently 0px) -- override back to 0 so the new
	   band's `--band-gutter` horizontal padding doesn't apply here. text-align:left
	   and max-width:var(--container) from the base rule still apply
	   unchanged (CAP-3, spec-sitewide-alignment-band). */
	.portfolio-archive__link {
		padding: 0;
	}

	/* No .portfolio-archive__title override here anymore -- the
	   image-present case inherits the base overlay rule unmodified, same
	   values as medium/wide. .title--no-media was never breakpoint-specific
	   either, so it needs no override here. */
}

/* =====================================================================
   THE DEFAULT LAYOUT: "Grid Feature Heroes" — /projects/
   =====================================================================

   Ported from Figma (file oiFIkg88DHxhQKIW68bG2V, node 47:2, "Projects
   Landing/Archive — Grid Feature Heroes"). THIS IS WHAT /projects/
   SERVES as of v1.12.0 (2026-09-21); everything above this line is the
   stacked layout it replaced, preserved at the named prototype URL
   `?layout=stacked` and untouched by every rule below. This whole
   section is gated behind `.portfolio-archive--grid`, which
   archive-project.php emits unless `?layout=stacked` was asked for.

   THE CLASS NAMES PREDATE THE FLIP and deliberately kept their spelling
   through it -- `--grid` named the variant back when the stacked layout
   was the default. Renaming them would touch this sheet,
   site-interactive-states.css and the template for no behavioural gain,
   and they still describe the layout accurately.

   ONE MARKUP SHAPE, TWO LAYOUTS -- TRUE UNTIL v1.13.9, AND THE PART THAT
   MATTERED IS STILL TRUE. There has never been a second template or a
   second loop, and there still is not: the query, the `entry_date`
   ordering, the archive-exclusion filter, the `<article>` and the `<a>`
   are one of each, so the two layouts cannot show different projects in a
   different order. checks/04 asserts that by comparing the rendered title
   sequences. That is also why retiring the chips (v1.11.16) retired them
   from BOTH layouts rather than only from the grid.

   WHAT IS NO LONGER TRUE is that the CARD is one shape. CAP-1 takes the
   project name out of the image -- `.media` is a crop box with
   `overflow: hidden`, so the `<h2>` has to move in the DOM, not just in
   CSS -- and the scrim it used to sit on would have gone with it, off the
   stacked prototype too, which SPEC-archive-card-design forbids in as
   many words. So archive-project.php branches inside the `<a>`, and three
   rules above are scoped to `.portfolio-archive--stacked`. The fork is
   named at the branch; read that comment before widening either side.

   THE BAND MATH NO LONGER LANDS ON THE FIGMA COLUMN, AND THAT IS THE MOST
   VISIBLE CONSEQUENCE OF v1.14.0 ANYWHERE ON THE SITE. It used to:
   `--container` was 1600px with a flat 15px inset = 1570px, minus two 20px
   gutters = 1530 / 3 = 510px columns, which was EXACTLY the Figma frame's
   column width -- the design was drawn on that band rather than an
   arbitrary one.

   The band is now 1400px with a ramping `--band-gutter` that reaches 56px
   at that width: 1400 - 112 = 1288, minus two 20px gutters = 1248 / 3 =
   416px columns. The drawn 510px is no longer reachable at any viewport,
   because the cap and the gutter both moved against it.

   WHAT THAT DOES AND DOES NOT BREAK. It does not break the card: rule 5b
   sizes its type in `cqw` against the card's own width (v1.13.14), so every
   proportion inside the card holds at 416px the way it held at 510px --
   which is precisely the property that change was made for. What it does is
   push the card further into the range where rule 5b's 20px type FLOOR
   binds, so the name reads fatter relative to its card than the aimed
   4.17%: at 416px it is 4.81%, about where a 1280px viewport already sat
   before this change. Read rule 5b's own table for that curve.

   So this is a deliberate re-proportioning, not a regression, and it is
   visible -- look at the archive and decide. If 416 reads too narrow the
   lever is a 2-column medium tier (the KNOWN GAP below), not a wider band.

   KNOWN GAP, for the live review rather than a guessed value: the Figma
   is a 1600px desktop frame only. Below 769px this collapses to one
   column (bottom of this section). Between those -- roughly 769-1100px --
   three columns divide into something cramped (~240px at 769px). No
   medium-breakpoint treatment was designed, and this theme has exactly
   one breakpoint by convention, so no second value is invented here.
   Look at it live and decide; a 2-column medium tier is the obvious
   candidate if it reads badly. */

/* 1. The white ground.

      NOT a retarget of --color-text-heading, and that restraint is the
      whole rule. That token has TWO consumers on this page sitting on
      OPPOSITE grounds: .portfolio-archive__title, which renders white
      over the dark gradient scrim on top of each hero, and the page ink
      around it. Rebinding the token to a dark value would repaint the
      overlay title black onto a dark scrim -- unreadable, and exactly
      the failure flush-split-white.css's own rule-4 note documents
      ("each have one consumer out here on the white ground and another
      inside the dark card, so retargeting either token would repaint the
      card too"). So the page ink moves by SELECTOR below, and the two
      overlay tokens are deliberately left alone.

      The header is untouched on purpose: the Figma keeps the dark header
      band over the white body, and #header-grid.hfg_header paints its
      own --bgcolor independently of body anyway. */
.portfolio-archive--grid {
	/* transparent, NOT #ffffff: the base .portfolio-archive rule paints
	   --color-bg-site (dark) and has the same specificity as this one, so
	   it must be actively released rather than left to source order.

	   WHAT IT REVEALS HAS CHANGED TWICE. It was white from body until
	   v1.13.0, then white from Neve's light palette, and since v1.13.8 it
	   is the #e4e4e4 page area 1c paints on `.neve-main` -- this element's
	   own ancestor. Releasing it is what lets that ground through. The
	   original reason to release rather than repaint still holds and is
	   the reason this line survives every one of those moves: repainting
	   here would put a second, CONTAINER-WIDTH box on top of a full-bleed
	   ground and re-create the floating-card look this variant exists to
	   avoid. The colour that box would be has never been the point. */
	background-color: transparent;
	color: #16191d;
}

/* 1b. HISTORICAL, AND ITS CONCLUSION IS SUPERSEDED BY 1c -- read them
       together or not at all. This block used to be titled "the ground
       reaches the viewport edges, and it has to be BODY that paints it".
       It is kept for its MEASUREMENT, which is still true and still
       load-bearing, and stripped of its RECOMMENDATION, which is not.

       The measurement: painting `.portfolio-archive` alone stopped the
       white at Neve's 1600px container -- at a 1926px viewport that left a
       163px navy gutter down each side, and the page read as a white card
       floating on the dark site. That is why a container-width element is
       never the right target for a page ground, and flush-split-white.css's
       rule 1 records the same finding.

       The recommendation it drew from that -- "body is the only element
       painting the page ground" -- was true of the PRE-v1.12.0 archive and
       is false here: `.portfolio-archive--grid` is itself full-bleed on
       this layout, and body is disqualified for a different reason
       entirely (canvas propagation). 1c has the measurements and the target
       actually in force.

       Still true and still worth having: the class comes from archive-
       project.php's `body_class` filter, registered above get_header()
       because that is what emits the body tag; and painting the page does
       NOT repaint the header (it sets its own --bgcolor) or the dark band
       (site-footer.css paints #403f3f -- #666 until v1.11.21, #21232b
       until v1.13.3, and this comment said #21232b until v1.13.8). */
/* THE #ffffff PAINT WAS DELETED IN v1.13.0 (CAP-5). `body.portfolio-archive-
   layout-grid { background-color: #ffffff }` existed to cover the dark
   ground Neve painted from the `darkMode` palette. The active palette is
   light now, so Neve paints `var(--nv-site-bg)` white on every surface and
   that rule would only restate it -- CAP-5's bar is that no child
   stylesheet carries a background-color whose sole purpose is covering a
   Neve dark ground. That deletion stands; this layout still gets its WHITE
   by declaring nothing.

   A GROUND IS PAINTED HERE AGAIN AT v1.13.8, and it is not white -- see 1c
   below. This note used to end "if a ground ever has to be painted here
   again, paint body, not the wrapper", on the strength of a measurement
   taken on the PRE-v1.12.0 shape: painting `.portfolio-archive` stopped the
   white at Neve's 1600px container, leaving a 163px navy gutter down each
   side at a 1926px viewport. BOTH HALVES OF THAT ADVICE ARE NOW WRONG for
   this layout, and 1c records what replaced them and why. The measurement
   is kept because it is still the reason a container-width element is never
   the right target; it is only the conclusion drawn from it that has
   moved. */

/* 1c. THE ARCHIVE'S PAGE AREA, v1.13.8 (SPEC-archive-card-design CAP-2).
       `#e4e4e4` fills the full width from the header's bottom edge to the
       footer's top edge, so the cards sit ON something rather than floating
       on the site's white chrome.

       THE TARGET WAS MEASURED, NOT REASONED, and neither element the old
       note named is right:

         - NOT `.portfolio-archive--grid`. It is already full-bleed on this
           layout (measured w=1841 l=0, max-width:none at a 1841px viewport
           on installed v1.13.7), so the gutter the note warns about does
           not apply -- but it is only as tall as its content. `.wrapper` is
           `display:flex; min-height:889px` and `#content.neve-main` has
           `flex-grow:1`, which is Neve's sticky footer. With nine cards
           both boxes run 113->1201 and the two targets are
           indistinguishable. Cut the list to ONE card and the wrapper stops
           at 587 while `.neve-main` stretches to 842 = the footer's top:
           painting the wrapper leaves a 255px white gap above the footer on
           a short or empty archive. The empty state (`.portfolio-archive__
           empty`) is exactly that case.

         - NOT `body`, the old note's recommendation. A body background
           propagates to the CANVAS, so the grey would paint the overscroll
           area above the header and would no longer be bounded by the
           header and footer at all -- which is precisely what CAP-2
           forbids ("not extending under the header or footer").

       `.neve-main` is bounded by both and stretches to reach them. The
       header is unaffected either way (`#header-grid.hfg_header` paints its
       own `--bgcolor`) and so is the dark band (site-footer.css paints
       #403f3f); measured after the change at 2301px, header 32->113 white,
       grey 113->1201, band 1201->1279 #403f3f. The empty state was rendered
       rather than assumed: with the list replaced by
       `.portfolio-archive__empty` the wrapper stops at 371 while
       `.neve-main` still reaches 1065 = the band's top.

       NEVE'S OWN `#site-footer` IS PAINTED TOO, and it is the reason this
       rule has two selectors instead of one. It sits BETWEEN `.neve-main`
       and `.joetennis-site-footer` as a sibling of both, and it is
       transparent. At desktop it measures 0px high -- which is why every
       desktop measurement above shows `.neve-main` ending exactly where the
       band begins, and why painting `.neve-main` alone LOOKS complete.
       IT IS 65px HIGH AT MOBILE: it carries a `footer--row footer-main
       hide-on-desktop` row that desktop never renders. Painting only
       `.neve-main` therefore shipped a 65px WHITE STRIP between the grey
       and the dark band at 386px, invisible to every desktop check and
       invisible before this change because the strip was white on white.
       Measured at 386px after adding it: header 46->99 white, grey
       99->2292 unbroken, band 2292->2370.

       THE LESSON, because it will recur: an element with zero height at the
       width you are measuring is not an element that is absent. Check the
       other breakpoint before concluding two boxes are adjacent.

       SCOPED TO THE GRID BODY CLASS so `?layout=stacked` -- the preserved
       dark prototype -- is untouched. That class comes from archive-
       project.php's `body_class` filter, which is registered above
       get_header() because that is what emits the body tag.

       VALUES VS RULES: `--ground-band` is declared once at :root in
       site-interactive-states.css and is not restated here. A ground
       supplies values; this is the rule that spends one. */
body.portfolio-archive-layout-grid .neve-main,
body.portfolio-archive-layout-grid #site-footer {
	background-color: var(--ground-band);
}

/* 2. The white ground's TOKEN VALUES moved out in v1.11.18, into a shared
      `.joetennis-ground-white` class, and moved again in v1.13.0 -- into
      `:root` itself, when SPEC-sitewide-white-ground made white the site
      default. There is no ground class to opt into any more: this layout is
      white because every surface is. site-interactive-states.css holds the
      reasoning, the measured contrast figures and the footer check; this
      file does not restate them.

      WHAT IS LOCAL TO THIS LAYOUT, and the only part worth recording here:
      the old block bound on `.portfolio-archive--grid` -- the WRAPPER, inside
      #content -- so its values never reached the header or footer. The shared
      class binds on `body`, as Flush Split White always did. Measured live on
      installed v1.11.17 before the build, that lifts exactly two things on
      this page, and Joe ruled both before the work started:
        1. The Portfolio nav marker (site-nav.css, under
           `body.is-portfolio-section`) moves off #a855f7 onto the white
           ground's marker. This block never carried `--current-marker`, so
           the marker was painting the DARK ground's purple on the white
           header band at 3.96:1 -- under the 4.5:1 floor, and it is
           body-sized text at 600 weight, not a non-text indicator, so 4.5:1
           is the applicable floor. That marker was #7a22ce at 7.12:1 when
           this was measured and is #8100fc at 6.29:1 since v1.13.3
           (SPEC-revision01-colour-adoption decision 3); both clear.
        2. Four header controls that fall through to the shared sheet's
           baseline `a:focus-visible` rule -- the skip link, the wordmark and
           the two hamburger toggles -- move their focus ring from #00e5ff
           (~1.5:1 on white) to #000000 (21:1).
      Nothing else moved. The footer is untouched; see the shared block's
      enumeration for why. */

/* 3. Page ink, moved by selector rather than by token (see 1).

      THIS BLOCK IS DOWN TO TWO RULES AT v1.13.9 -- the place-indicator
      and the empty-state message. It held three: the no-media title
      fallback was the third, and the grid stopped emitting that class
      when the card was rebuilt. The card's own ink is not here at all;
      it is `--ground-ink-strong` on the title and discipline rules
      further down, which is a heavier black than this block's prose ink
      and was chosen against the mockup rather than against a ground.

      The stacked layout's overlay title is untouched and stays #f8fafc
      over its scrim. */
/* SPLIT AT v1.13.5, and the two halves still differ deliberately. The
   heading follows the BREADCRUMB (#000000), because it is the same chrome
   in the same slot; the empty-state message follows the PAGE (#16191d
   ground ink), because it is prose. They were one declaration only
   because both happened to want ground ink. The no-media title used to
   be named here alongside the empty-state message; it left with the rule.

   RE-DERIVED AGAINST #e4e4e4 AT v1.13.8. All three of these render inside
   `.neve-main`, so 1c moved the ground under them from white to the band
   grey and every figure recorded here was measured against white:

     #000000 on #e4e4e4   16.52:1   (was 21:1 on white)
     #16191d on #e4e4e4   13.87:1   (was 17.63:1 on white)

   Both still clear the 4.5:1 floor by a wide margin, so nothing moved and
   no value needed re-picking -- the point of re-deriving is that the
   NUMBERS here are the ones a future contrast decision will be checked
   against, and stale ones are worse than absent ones. 16.52:1 is the same
   figure flush-split.css:393 records for black on this grey, which is now
   literally the same value from the same token.

   NOTE the white figure for #16191d was recorded here as 18.1:1 and is
   17.63:1 by computation; corrected in passing rather than carried. */
.portfolio-archive--grid .portfolio-archive__heading {
	color: #000000;
}
.portfolio-archive--grid .portfolio-archive__empty {
	color: #16191d;
}
/* THE THIRD RULE HERE IS GONE, v1.13.9. It coloured
   `.portfolio-archive--grid .portfolio-archive__title--no-media`, and the
   grid stopped emitting that modifier when the card was rebuilt -- its
   title is below the image whether or not there is an image, so there is
   no overlay to fall back FROM. Deleted rather than left inert: a rule
   that cannot match is indistinguishable from one that is simply not
   working, and the next reader has no way to tell which. The no-media
   card's ink now comes from the `.portfolio-archive__title` rule below,
   which is the same rule the with-image card uses -- one less way for the
   two to diverge. */

/* 4. The grid. The band moves from .link (where the stacked layout
      carries it, one full-width hero per row) onto .list, because here
      the band is what gets divided into columns -- so .link below has to
      give its own band back or every cell would re-centre inside itself. */
.portfolio-archive--grid .portfolio-archive__list {
	display: grid;
	grid-template-columns: repeat(3, 1fr);

	/* SPLIT AT v1.13.16, was `gap: 20px` for both axes. The row axis is
	   now owned by `.body`'s bottom padding (rule 5b), because Joe's
	   revised frame sizes the inter-row space against the card and a
	   `row-gap` in `em` would resolve against THIS element's font-size --
	   the inherited body size, which does not scale with the card. The
	   column axis keeps its 20px, which the frame says nothing about: it
	   draws one card per row, so it states no column relationship and the
	   horizontal rhythm stays the template's. */
	column-gap: 20px;
	row-gap: 0;
	max-width: var(--container);
	margin: 0 auto;
	padding: 20px var(--band-gutter, 15px) 120px;
}

.portfolio-archive--grid .portfolio-archive__item,
.portfolio-archive--grid .portfolio-archive__item:last-child {
	margin: 0;
}
/* Releases the stacked layout's band (max-width/auto margins/gutter
   padding) -- the cell is already the divided band.

   IT IS ALSO THE CARD'S QUERY CONTAINER, v1.13.14. Rule 5b sizes the
   card's type as a fraction of the CARD rather than of the viewport, and
   `cqw` needs a container to resolve against. This element is the right
   one: the three declarations below strip it to exactly the cell's box,
   so 1cqw here is 1% of the CELL's width -- which is also the image's
   width whenever there is an image, and is still the right reference on
   the no-media card (rule 5b's `:first-child` case), where there is no
   `.media` to measure at all.

   IT CANNOT GO ON `.body` ITSELF, because an element cannot query its
   own container -- the container has to be an ancestor of the text. What
   would happen instead is worse than it sounds: `container-type` appears
   nowhere else in this theme, so `.body` would find NO eligible container
   and its `cqw` would silently resolve against the SMALL VIEWPORT. See
   rule 5b's last paragraph; that is the same failure mode, and it is the
   reason this declaration and that font-size have to be maintained as one
   mechanism.

   FOUR SIDE EFFECTS OF `container-type`, ALL CHECKED, NONE ACTIVE TODAY.
   It applies `contain: layout style inline-size`, which:
     (a) stops the element being sized by its contents on the inline axis
         -- a no-op on a `1fr` grid cell, which is sized by the track;
     (b) makes it a containing block for `position: absolute` AND
         `position: fixed` descendants;
     (c) gives it an independent formatting context, so descendant margins
         no longer collapse out through it;
     (d) makes it a stacking context.
   (b) is the one that could have moved something visible: this file has
   three `position: absolute` rules, but all three are scoped to
   `.portfolio-archive--stacked` and anchored to `.media`'s own
   `position: relative` (rule 3), so none resolves against this element on
   either layout. If a future rule puts an abs-pos child on the GRID card,
   this line is what anchors it. (c) is worth knowing rather than fixing:
   rule 5b's padding note reasons explicitly about margin collapse, and
   that reasoning is about `.body` and ITS children, one box further in --
   unaffected by containment declared out here, but adjacent enough to be
   worth naming before someone conflates the two.

   AN EARLIER DRAFT OF THIS COMMENT CLAIMED TWO SIDE EFFECTS AND SAID
   `.body` WOULD RESOLVE AGAINST THE BAND. Both were wrong; corrected in
   the same version they were written. */
.portfolio-archive--grid .portfolio-archive__link {
	max-width: none;
	margin: 0;
	padding: 0;
	container-type: inline-size;
}

/* 5. The cell's crop box. 16/9 per the Figma frame (510x287), replacing
      the stacked layout's 900/188.

      This IS a crop and the cost is known and accepted (Joe, 2026-09-20):
      eleven of the fifteen published heroes are 2048x428 (4.79:1), so
      filling a 16/9 cell shows roughly their middle third horizontally.
      Measured from the live srcset, not assumed. The alternative --
      `height:auto`, which the Figma's own spec box asks for -- renders
      those eleven 107px tall at a 510px column, a third of the drawn
      height, and was rejected on sight of those numbers. If the hero
      library is ever re-exported at a taller ratio, this is the value to
      revisit and the crop mostly goes away. */
.portfolio-archive--grid .portfolio-archive__media {
	aspect-ratio: 16 / 9;

	/* 20px on all four corners, v1.13.9 (SPEC-archive-card-design CAP-1,
	   card-measurements.md). Fitted from the mockup's 1:1 PNG rather than
	   read from the file -- no Figma variables are bound on that frame --
	   by least-squares-fitting a circle to the 50%-coverage edge of the
	   top 26 rows: 20.4px best fit, i.e. a drawn 20.

	   ON `.media`, NOT ON `.image`, and the base rule's `border-radius: 0`
	   on `.image` is left exactly where it is. `.media` is the crop box and
	   already carries `overflow: hidden`, so it clips the image to this
	   radius; rounding the image instead would round the source's own box,
	   which `object-fit: cover` has already overflowed. Measured live:
	   computed 20px, corners clipped.

	   IT IS A LITERAL, NOT A `--shape-*` TOKEN, and the restraint is
	   deliberate. site-interactive-states.css owns shape sitewide, and a
	   token there would be a sitewide decision; the mockup decided a
	   radius for THIS card and nothing else. If a second archive adopts
	   it (story 3), that is when it earns a token -- and moving it then
	   is one line.

	   IT DOES NOT MAKE THE CARD "ROUND MEANS CLICKABLE", and an earlier
	   draft of this comment claimed it did. The v1.11.4 encoding is about
	   the shape of the OPERABLE element; the operable element here is the
	   `<a>`, which is unstyled, square, and takes its focus ring from the
	   baseline `a:focus-visible` rule. What is round is `.media`, a
	   decorative crop box inside it. The card is not a counter-example to
	   the encoding and does not belong on its "operable but still square"
	   exception list either -- it is simply outside the encoding's
	   subject, which is controls. */
	border-radius: 20px;
}

/* 5b. THE CARD'S TEXT, v1.13.9 (CAP-1). The name used to be burned onto
       the photograph over a gradient; it is now a sibling BELOW the image
       on the `--ground-band` grey, with the project's disciplines under
       it. `.portfolio-archive__body` wraps the pair so the inset is
       declared once and so story 3 has a single node to lift out.

       STORY 3 MUST CARRY THE QUERY CONTAINER WITH IT, v1.13.14. Since
       this node's type is sized in `cqw`, lifting it out of the grid
       without also giving its new wrapper `container-type: inline-size`
       does not throw -- it silently resolves against the small viewport
       and paints the card at the 30px ceiling on any screen >= 720px.
       Rule 4 holds the declaration today and rule 5b's last paragraph
       explains the failure mode; whatever renders this node next needs
       to satisfy the same contract.

       16px INSET FROM THE IMAGE'S LEFT EDGE, which is a changed
       relationship rather than a nudge: the overlay title sat at
       `left: 7px` INSIDE the image box. `.link` has no padding on this
       layout and `.media` fills the cell, so the cell's edge and the
       image's edge are the same line and this padding is measured from
       both. The RIGHT side is padded to match -- the mockup fixes only
       the left, because its one card never wraps, but a two-line name in
       a narrow column would otherwise run to the cell edge. Template's
       call, not the mockup's.

       THE INSET IS px WHILE THE GAPS BELOW ARE em, from the same frame,
       and the split is deliberate rather than an oversight. The inset is
       a distance from a LAYOUT EDGE -- the image's, which does not
       change size with the type -- so it is the same 16px whatever the
       name is set at. The gaps are distances between INK on consecutive
       lines, which is a property of the type and scales with it.

       THE CARD HAS NO DECLARED BOTTOM, and that is a choice not a
       default: card-measurements.md gives no bottom value, because the
       frame draws one card and never had to. What separates a card from
       the row beneath it is rule 4's 20px grid gap, which is the same
       20px used between columns. Give this a bottom padding only if the
       cards ever need to read as boxes rather than as stacks.

       ONE FONT-SIZE LIVES HERE, and the two children take 1em and
       0.667em of it. The mockup's 30/20 is a RATIO -- "roughly two-
       thirds" -- and two independent clamps cannot hold a ratio: they
       reach their floors at different viewports and the proportion drifts
       between them. Declaring the size once makes 2/3 true at every width
       by construction, and makes the em gaps below resolve against sizes
       that are always in proportion. */
.portfolio-archive--grid .portfolio-archive__body {
	/* THE TYPE IS SIZED AS A FRACTION OF THE CARD, v1.13.14, not of the
	   viewport. `4.167cqw` is 30/720 -- the mockup's drawn name over its
	   drawn image -- resolved against the query container declared on
	   `.link` in rule 4.

	   WHERE THAT RATIO COMES FROM, STATED HONESTLY, because the obvious
	   reading of it is WRONG and an earlier draft of this comment got it
	   wrong. It is tempting to write that the frame "fixes" this ratio.
	   The frame does not. card-measurements.md says the image "was drawn
	   720px wide purely because that is what fitted the frame", that the
	   frame is "explicitly not authoritative for image size", and that the
	   30px "is what the frame happens to be drawn at, not a decision";
	   SPEC.md and story 2 both repeat it. Worse, the 720px card is a
	   single-card detail frame, while the GRID frame's own columns are
	   510px (rule 3) -- so the two frames disagree about how wide this
	   card is, and neither is authoritative for type.

	   So the ratio is a TARGET THIS TEMPLATE CHOSE, which is exactly the
	   footing sizing is supposed to be on. The evidence behind it is that
	   a name and an image were drawn together by eye at some size, and
	   that Joe looked at the shipped build on 2026-09-23 and said "the
	   text seems much larger in the prototype than the Figma comp", then
	   asked to "keep three columns but have the type smaller in the
	   default view and large breakpoint, staying relative at other
	   breakpoints". This declaration is that instruction. 30/720 is how it
	   was aimed, not an authority it rests on.

	   WHAT WAS WRONG BEFORE. This was `clamp(20px, 1.875vw, 30px)`, keyed
	   to the VIEWPORT, reaching the drawn 30px at a 1600px viewport where
	   --container tops out and the columns reach 510px. It held the drawn
	   PIXEL SIZE over a card 29% narrower than the one it was drawn on, so
	   the name rendered 720/510 = 1.41x the drawn proportion.

	   WHY `cqw` AND NOT A CORRECTED `vw`. The type is sized by the CARD;
	   the card is `(viewport - padding - gaps) / column count`. Those track
	   each other only for one column count, which is why rule 6 needed a
	   second hand-fitted constant for the same design. The exact `vw` form
	   -- `calc((min(100vw, 1600px) - 70px) / 3 * 0.041667)` -- would hard-code
	   rule 4's gap, rule 4's padding and the column count into a TYPE rule,
	   and bakes in 1600, which is `neve_container_width` in
	   `theme_mods_neve-child`: a database value that appears nowhere in git
	   (CLAUDE.md). `cqw` reads the card's real width instead. It also drops
	   an error `vw` carries -- `100vw` includes the scrollbar, `100cqw` is
	   the container's content box.

	   WHAT IT COMPUTES TO, THREE COLUMNS. Card = `(min(100vw, 1400px) -
	   2 * --band-gutter - 40px gaps) / 3`. Recomputed for v1.14.0, which
	   moved BOTH terms -- the cap 1600 -> 1400 and the gutter 15px -> a ramp
	   reaching 56px:

	     viewport   card     name      name as % of card
	     1400px+    416px    20.00px   4.81%
	     1280px     379px    20.00px   5.27%
	     1024px     301px    20.00px   6.65%
	      769px     222px    20.00px   8.99%

	   THE 20px FLOOR NOW BINDS AT EVERY VIEWPORT, AND THAT IS THE ONE THING
	   TO KNOW ABOUT THIS RULE AFTER v1.14.0. The widest the card ever gets is
	   416px, where `4.167cqw` is 17.3px -- below the floor. So the `cqw` term
	   never wins, the name is a flat 20px everywhere, and the aimed 4.17%
	   proportion is no longer reachable at any width.

	   THE MECHANISM IS THEREFORE INERT IN PRACTICE, AND IT IS DELIBERATELY
	   LEFT IN PLACE RATHER THAN SIMPLIFIED TO `font-size: 20px`. Two reasons.
	   It is one dial away from live again -- lower the floor and the
	   proportion resumes, which is a DESIGN decision about how large the name
	   should read and not a cleanup. And it is the correct expression of the
	   intent either way: the type is a fraction of the card, currently
	   clamped. Deleting it would erase the reasoning above and the
	   v1.13.14 fix with it.

	   THE PREVIOUS TABLE, FOR THE RECORD, because the shape of the curve is
	   the part worth comparing. At the 1600px band the card reached 510px and
	   the name hit the aimed 21.25px / 4.17%; the floor started binding below
	   ~1510px and the type grew fatter relative to its card from there,
	   reaching 8.59% at 769px. The new curve is the same shape with its top
	   cut off -- every row moved right and down, and the best ratio available
	   is now 4.81% instead of 4.17%.

	   The 769-1100px band is the cramped medium gap already recorded at rule
	   3, and a 2-column medium tier is still its candidate fix -- now with a
	   second reason to want it.

	   THE 20px FLOOR IS SET BY THE DISCIPLINES, NOT THE NAME, which is
	   why the number looks arbitrary next to a name. They are `0.667em` of
	   this size, so a 20px body puts them at 13.3px and a 17px body at
	   11.3px. The two-thirds ratio that makes the card cohere is what pins
	   its floor.

	   THE 30px CEILING IS UNREACHABLE ON THREE COLUMNS (the card maxes at
	   510px, i.e. 21.25px) AND IS STILL LOAD-BEARING: it binds below
	   769px, where the card is at its WIDEST. One declaration, both
	   layouts -- which is why rule 6 no longer restates it.

	   THE `vw` LINE BELOW IS A FALLBACK, NOT A LEFTOVER. `container-type`
	   and `cqw` are Baseline 2023; in an older engine the `cqw`
	   declaration is invalid and dropped, and with no fallback the name
	   would inherit body size and the disciplines land near 11px. The
	   fallback is the exact clamp this change replaced, so an old engine
	   gets the previous behaviour rather than none.

	   IF `cqw` EVER RESOLVES AGAINST NOTHING IT FAILS SILENTLY, AND IT
	   FAILS BIG. With no eligible container ancestor, container units
	   resolve against the SMALL VIEWPORT -- so deleting rule 4's
	   `container-type` turns this into `4.167vw`, which hits the 30px
	   ceiling at any viewport >= 720px and paints every desktop card at
	   the ceiling with nothing erroring anywhere. These two declarations
	   are ONE MECHANISM 140 LINES APART: move or delete either and check
	   the other.

	   VERIFIED LIVE 2026-09-23, on installed v1.13.12 with this rule and
	   rule 4's container injected AT ARCHIVE-PROJECT.CSS'S OWN POSITION in
	   the head -- not appended last, for the reason rule 5c's older check
	   gives: `neve-style-css`, `neve-style-inline-css` and both Google
	   Font links were confirmed to sit AFTER the injection point and the
	   rules still won.

	     2363px viewport, 3 cols   card 510.00px   21.2517px / 14.1749px
	                               ratio 0.667, name 4.167% of card
	      762px iframe,  1 col     card 738.52px   30px (ceiling)
	      381px iframe,  1 col     card 357.27px   20px (floor); the raw
	                               ratio would have been 14.89px, and the
	                               disciplines land at 13.34px
	   The 762/381 figures are iframe inner widths (the scrollbar is inside
	   them), which is why they are not 768/386 -- the outcome at each is
	   the one the deleted `4.2vw` rule produced, which is what the check
	   was for.

	   THE OPTICAL GAPS WERE RE-MEASURED AT THE NEW SIZE and scale as the
	   `em` decision intended: image bottom -> name cap-top 10.61px against
	   10.63px predicted, name baseline -> discipline cap-top 12.01px
	   against 12.04px. Insets stayed 16.00/16.00, radius 20px, weights
	   800/400, tracking `normal` on both lines. Nothing but the font-size
	   had to move.

	   THE SILENT FAILURE ABOVE WAS REPRODUCED, NOT ASSUMED. Forcing
	   `container-type: normal` on rule 4 while leaving this clamp in place
	   took the name from 21.2517px to 30px with no error anywhere -- the
	   ceiling, on every card, at a viewport where the card is 510px. */
	font-size: clamp(20px, 1.875vw, 30px); /* fallback; see the note above */
	font-size: clamp(20px, 4.167cqw, 30px);

	/* PADDING, NOT A MARGIN ON THE <h2>, and this is structural rather
	   than cosmetic. `.body` has no border and no other top padding, so
	   a `margin-top` on its first child would COLLAPSE THROUGH it and
	   become a margin on `.body` itself. It renders identically today --
	   but story 3 lifts this node out, and the first background, border
	   or `overflow` anyone gives `.body` would stop the collapse and move
	   the card's geometry with nothing to explain why. As padding it is
	   inside the box that owns it.

	   BOTH VALUES COME FROM JOE'S REVISED FRAME, 2026-09-23, and both are
	   `em` so they scale with the card exactly as the type does. The
	   frame is still drawn at a 720px card against a real 510px one, so
	   NOTHING HERE IS A DRAWN px VALUE -- each is a drawn distance divided
	   by the drawn 30px name.

	   TOP, 0.526em (was 0.226em). The frame moves the name and the
	   discipline line DOWN as a unit: both text boxes shift +9px, and the
	   ink confirms it -- image bottom to name cap-top measures 24px at
	   1:1, against 15px at v1.13.9, on both of the frame's two cards.
	   A line box does not start at its cap, so the padding is the gap
	   minus the cap inset: at 30px with `line-height: 1.2` the cap sits
	   8.22px below the line-box top, giving 24 - 8.22 = 15.78px drawn,
	   and 15.78/30 = 0.526em. Lands 17.0px on the real 510px card.

	   THE NAME-TO-DISCIPLINE GAP IS DELIBERATELY UNTOUCHED, and it was
	   checked rather than assumed. Measured from the boxes (string-
	   independent, unlike ink): name baseline to discipline cap-top is
	   18.03px in BOTH frames. The pair moved together, so `.disciplines`
	   keeps its `0.208em` margin. Do not re-derive it from this round's
	   ink, which reads 18px against the old frame's 17px -- that 1px is
	   the lighter face crossing the sampling threshold one row higher,
	   not a design change.

	   BOTTOM, 2.254em, WHICH IS NEW AND IS WHY THE FRAME GREW A SECOND
	   CARD. A one-card frame cannot state inter-row spacing; this one
	   draws card two at y=686 with card one's discipline baseline at
	   y=614.38, so the drawn distance is 71.62px. Subtract the 4.00px
	   from that baseline to its own line-box bottom (20px at
	   `line-height: 1.3`) and 67.62px of real space is needed below the
	   box: 67.62/30 = 2.254em. Lands 47.9px on the real card, against the
	   20px the grid gap gave before.

	   IT IS PADDING HERE RATHER THAN `row-gap` ON THE GRID FOR ONE
	   CONCRETE REASON: `row-gap` in `em` resolves against the GRID
	   CONTAINER's font-size, which is the inherited body size and has
	   nothing to do with the card's clamp -- it would not scale with the
	   card. Declared here it resolves against `.body`'s own font-size,
	   which IS the clamp. Rule 4 sets `row-gap: 0` and keeps
	   `column-gap: 20px`; see the note there. The trade is that the LAST
	   row now carries this padding too, adding ~48px above the list's
	   120px bottom padding. Judged acceptable rather than overlooked --
	   the alternative is a `:last-child` exception that would have to know
	   the column count. */
	padding: 0.526em 16px 2.254em;
}

/* NO FEATURED IMAGE: `.body` is the first child because `.media` was not
   rendered at all, and `:first-child` is how this layout detects that --
   no `:has()`, which this theme uses nowhere, and no second modifier
   class for the PHP to emit and forget.

   24px, WHICH IS WHERE THE OLD FALLBACK'S NUMBER WENT. Before v1.13.9 an
   imageless project on this layout rendered `.title--no-media`, whose
   `margin: 24px auto 0` gave it exactly this clearance. The 0.226em above
   is derived from an image bottom edge that does not exist here, and at
   the drawn size it is 6.8px -- the name would sit almost flush to the
   top of its cell while its imaged neighbours start 287px lower. This is
   the answer to SPEC.md's open question "does a card with no featured
   image get the rounded, inset treatment too": it gets the INSET and the
   type, it cannot get the radius because there is no image to round, and
   it keeps the fallback's own top clearance.

   NOTHING PUBLISHED EXERCISES THIS. All fifteen projects carry a Featured
   Image (checked live), which is why deferred-work.md already records
   this branch as having no standing verification path. Rendered by hand
   in Chrome rather than reasoned about: name 16px from the cell's left
   edge, no empty crop box, no dangling anchor. */
.portfolio-archive--grid .portfolio-archive__body:first-child {
	padding-top: 24px;
}

/* THE TWO VERTICAL GAPS ARE OPTICAL -- measured from rendered INK, not
   from text boxes, and this is the one part of this card that cannot be
   copied out of Figma. The frame reports the name's box 6px below the
   image and the discipline's 2px below the name's; building to those
   would be wrong, because a text box carries half-leading above the cap
   and below the baseline. The design is:

     image bottom  -> name cap-top        15px
     name baseline -> discipline cap-top  17px

   SO THE TWO OFFSETS ARE DERIVED FROM THE LINE-HEIGHTS, and they have to
   be re-derived if either line-height moves. For Noto Sans (ascent
   1.069em, descent 0.293em, cap 0.714em) at size S and line-height L, the
   cap-top sits (L - 1.362*S)/2 + (1.069 - 0.714)*S below the element's
   top edge and the baseline (L - 1.362*S)/2 + 1.069*S below it. Worked
   through at the drawn sizes:

     name, 30px/1.2   cap-top 8.22px down -> needs 6.78px above  = 0.226em
     name, 30px/1.2   baseline 29.64px down, box is 36px
                      -> 6.36px of box remains below the baseline
     disc, 20px/1.3   cap-top 6.48px down
                      -> needs 17 - 6.36 - 6.48 = 4.16px above  = 0.208em

   THE SECOND GAP SPANS TWO ELEMENTS and is expressed in one of them. The
   17px is the name's baseline-to-box-bottom PLUS the discipline's own
   cap offset, declared as em of the discipline's size. That only tracks
   because `.body` above sizes both children from ONE clamp, so the two
   are always in a fixed 2/3 proportion -- which is exactly why the two
   independent clamps this rule was first written with were replaced.

   EXPRESSED IN `em`, NOT px, because the sizes are clamped and these are
   type relationships: 15px under a 30px name is a proportion of the type,
   not a fixed distance. In em the ink lands on 15/17 at every width. (The
   INSET is px, and the note on `.body` says why the two differ.)

   THIS ASSUMES NOTO SANS IS THE FACE ACTUALLY PAINTING. The metrics above
   are its. The fonts come from a CDN with `display=swap`, so during the
   swap -- or if the request fails -- the fallback's ascent and cap are
   different and both gaps are off by a few px until it lands. Not worth a
   `size-adjust` fallback stack for a transient on one card; worth knowing
   before anyone measures a screenshot and reports the gaps as wrong.

   VERIFIED LIVE on installed v1.13.8, at a 510px column, with the
   candidate sheet inserted AT ARCHIVE-PROJECT.CSS'S OWN POSITION in the
   head -- not appended last. That distinction is the whole point of the
   check: `neve-style-css`, Neve's inline typography and `wp-custom-css`
   all load AFTER this sheet, so an appended <style> would have proved
   nothing about whether these rules win. Measured in that order: image
   bottom -> name cap-top 14.99px, name baseline -> discipline cap-top
   17.00px, insets 16.00/16.00, computed letter-spacing `normal`, weights
   800/400, sizes 30px/20.01px (ratio 0.667).

   THAT MEASUREMENT IS STILL THE PROOF OF THESE GAPS AND IS NO LONGER A
   PICTURE OF THE LIVE CARD, v1.13.14. It was taken when the name rendered
   at its drawn 30px in a 510px column; rule 5b now sizes that same column
   at 21.25px, and no three-column width reaches 30px any more. WHAT IT
   PROVED SURVIVES INTACT -- the gaps are `em`, so 15/17 at 30px is 15/17
   at 21.25px, and the 0.667 ratio is structural -- but if anyone re-runs
   this check expecting 30px/20.01px they will find 21.25px/14.17px and
   should not read that as a regression. The px figures above are the
   measurement's conditions, not the card's specification. */
.portfolio-archive--grid .portfolio-archive__title {
	/* Every property the overlay rule used to supply is restated here,
	   because that rule is scoped to `--stacked` now and no base rule
	   reaches this element at all. `position: static` is not among them:
	   an `<h2>` is static already, and there is nothing to reset. That
	   holds only while the overlay rule stays scoped -- unscope it and
	   this element inherits `position: absolute; left: 7px; bottom: 3px`
	   and leaves the card entirely. checks/05 pins all three scopings for
	   that reason. */
	margin: 0;
	padding: 0;
	max-width: none;
	font-family: var(--font-heading);

	/* 600, DOWN from the 800 this card shipped with at v1.13.9 (Joe's
	   revised frame, 2026-09-23: "typeface changes to Semi-bold"). Noto
	   Sans SemiBold is a REAL FACE here rather than a synthesised one --
	   functions.php already requests 600, and `checks/05` asserts it; see
	   the note there, because the weight would otherwise arrive from a
	   Neve font request this repo does not control.

	   THE DISCIPLINE LINE DID NOT CHANGE and is still 400. "Semi-bold"
	   named one weight, and the frame confirms it: the name is
	   `Noto_Sans:SemiBold`, the discipline line is still
	   `Noto_Sans:Regular`. Corroborated independently -- the same string
	   measures a 415px text box here against 432px at v1.13.9, which is
	   the box narrowing a lighter face produces. */
	font-weight: 600;

	/* The card's size is declared once on `.body`; this is it. */
	font-size: 1em;
	line-height: 1.2;

	/* --ground-ink-strong (#000000), matching this layout's own heading
	   rule above, NOT --ground-ink (#16191d). 16.51:1 on #e4e4e4 against
	   13.87:1; both clear the floor by a wide margin, so this is a
	   match-the-mockup call and not a contrast-forced one. */
	color: var(--ground-ink-strong);
	text-align: left;

	/* `normal`, AND THIS LINE IS LOAD-BEARING. Neve sets
	   `letter-spacing: -1px` on every heading from a typography
	   theme_mod -- a database value that appears nowhere in git, measured
	   live on a bare <h2> in this very list. The overlay title this
	   replaces inherited it silently for its whole life.

	   The mockup did not. At v1.13.9 its name box measured 432px for
	   "Streamlined Documentation" at ExtraBold 30px, where the same string
	   measures 430.1px at `normal` tracking and about 406px at -1px -- so
	   the frame is drawn untracked and the difference is a visible 26px.
	   The same argument applies to the discipline line below, which would
	   otherwise take the BODY theme_mod's +0.3px.

	   THE REVISED FRAME RE-CONFIRMS THIS AT THE NEW WEIGHT: the same
	   string now boxes at 415px at SemiBold 30px. The box got narrower
	   because the face got lighter, NOT because tracking was applied --
	   a -1px frame would land near 391px at this weight. The 17px drop
	   from 432px is the weight change and was used as independent
	   corroboration of it, since a box width is measurable where a font
	   name in a design tool is a label.

	   NOT `inherit`, which is what `.portfolio-archive__heading` above
	   uses -- that row is deliberately chained to the breadcrumb's body
	   tracking, a different job. This value comes from the mockup, so it
	   is stated rather than borrowed. */
	letter-spacing: normal;
}

/* Every term the project carries, space-padded U+2022, alphabetical by
   name (see archive-project.php for why that order). Omitted entirely
   when a project has none -- the card is simply shorter, and grid items
   stretch to the row so nothing collapses. That is NOT a latent branch:
   `credit-health` carries zero discipline terms today and is one of the
   nine cards, so this is a rendered state on every load.

   0.667em, the mockup's 20/30, resolved against `.body`'s single clamp
   rather than against a clamp of its own -- so the ratio is two-thirds at
   every width instead of only where two clamps happen to agree. */
.portfolio-archive--grid .portfolio-archive__disciplines {
	margin: 0.208em 0 0;
	padding: 0;
	font-family: var(--font-heading);
	font-weight: 400;
	font-size: 0.667em;
	line-height: 1.3;
	color: var(--ground-ink-strong);
	text-align: left;
	letter-spacing: normal; /* see the name's note -- a <p> takes +0.3px */
}

/* 6. Small breakpoint. The Figma has no mobile frame, so this is the
      minimum honest thing rather than a designed one: collapse to a
      single column and inherit every other grid value above. The hero
      keeps the 16/9 cell here too, so a phone sees the same crop as the
      desktop grid rather than a third proportion. */
@media (max-width: 768px) {
	.portfolio-archive--grid .portfolio-archive__list {
		grid-template-columns: 1fr;
		padding: 20px 12px 64px;
	}

	/* NO FONT-SIZE HERE ANY MORE, v1.13.14. This block used to restate
	   the card's type as `clamp(20px, 4.2vw, 30px)`, because rule 5b was
	   keyed to the viewport and the card's width is keyed to the column
	   count -- so a constant calibrated for a 510px column put the name at
	   its FLOOR exactly where the card is at its WIDEST, and a second
	   hand-fitted constant was needed to undo it. Rule 5b now reads the
	   card's own width and covers both layouts; see it for the full
	   reasoning, which is not repeated here.

	   Checked against what `4.2vw` was fitted to produce -- the list
	   padding here is 12px, so the card is `100vw - 24px`:
	     768px viewport -> 744px card -> 31.0px -> takes the 30px ceiling
	     386px viewport -> 362px card -> 15.1px -> takes the 20px floor
	   The deleted comment's own figures were 30px at 768 and "16.2px,
	   takes the 20px floor" at 386. Same two outcomes, now derived rather
	   than fitted.

	   THE STEP AT THE BOUNDARY SURVIVES AND IS STILL NOT A BUG: crossing
	   769px -> 768px the name jumps 20px -> 30px because the card under it
	   triples in width in the same instant. `cqw` does not remove that --
	   nothing can, short of changing the column count gradually -- it only
	   derives it instead of needing two constants to express it. */
}

/* ---------------------------------------------------------------------
   The default (grid) layout — the light header band
   ---------------------------------------------------------------------

   Ported from flush-split-white.css in v1.11.15 and EXTRACTED AGAIN in
   v1.11.18: the nav/wordmark ink, the header ground and the header's eight
   state-token values are all `.joetennis-ground-white` in
   site-interactive-states.css now. See rule 2 above for what the move
   changed on this page.

   THIS STILL DEPARTS FROM THE FIGMA, deliberately and at Joe's request
   (2026-09-20). The "Grid Feature Heroes" frame draws a dark navy header over
   the white body; this makes the band light to match Flush Split White. If the
   mock is ever treated as the contract again, that shared class is the
   divergence to reconcile.

   ONE LINE STAYED, and it is byte-identical to the copy in
   flush-split-white.css -- deliberately, and scoped to the shared class
   rather than to this layout's body class, so the two copies diff against
   each other instead of hiding drift behind different selectors. Neve draws
   the drawer's three close bars at #E2E8F0 through a bare
   `background-color` with no custom property behind it, so nothing can
   re-bind it and overriding it needs a real declaration -- which the shared
   sheet may not carry. The sole residue of the extraction; recorded in
   deferred-work.md. */
/* RETIRED v1.13.0 -- see flush-split-white.css's rule 2 for the full note.
   White is the default, so the white drawer needs no override, and the dark
   consumers carry one rule in site-ground-dark.css instead of this line
   being copied a third time. */
