@charset "UTF-8";

html {
	scroll-behavior: smooth;
}

/* MOBILE TEXT-OVERFLOW SAFETY NET — fixes the "pretty bad on most iPhone
   selections" symptom found in Chrome's device emulator: on narrow phone
   widths, the hero module's h1 (its font-size is a fluid clamp()/vw value,
   sized for desktop columns) renders a single word wide enough that it
   becomes the widest thing inside .split-layout-text. Every .split-layout-text
   is a CSS Grid item (its parent, .split-layout, is display:grid),
   and grid items default to min-width:auto — meaning the browser refuses to
   shrink an item below its content's min-content size (here, that one long
   word) even when its track is narrower. So instead of wrapping, the whole
   text column grows past its track, bleeds past the viewport's right edge,
   and gets silently clipped by .split-layout's own overflow:hidden
   — the heading (and the body copy pushed wide along with it) simply goes
   invisible past a hard edge, with no scrollbar to reveal it. That's what
   "pretty bad" looks like: real content missing, not just a layout quirk.

   Two rules fix it together — removing either one leaves part of the bug:
   min-width:0 lets each 50/50 column actually shrink to its track's real
   width instead of being floored by its widest word; overflow-wrap (plus
   the word-break fallback for older engines) then lets that one long word
   itself break across lines once the column is too narrow to fit it whole,
   instead of overflowing the now-correctly-sized column. That break is a
   last-resort safety net only: it fires solely when a single word is
   wider than the whole line (the h1/h2 min(..., 13vw / 11vw) caps in
   typography.css exist to stop that ever happening), and never inserts
   a "-". Hyphenation itself is OFF site-wide (hyphens: none, see the
   TEXT WRAPPING block in typography.css).

   Scoped to just .split-layout's own children rather than every
   grid/flex item site-wide, since this is the one place an unconstrained
   fluid heading sits directly inside a grid track — keeps the fix targeted
   at the actual failure instead of changing sizing behaviour everywhere. */
.split-layout > * {
	min-width: 0;
}

h1,
h2,
h3,
h4,
h5,
h6,
p {
	overflow-wrap: break-word;
	word-break: break-word;
	hyphens: none;
}

/* Anchor-linked sections (e.g. the case study "Read more here" buttons)
   need breathing room equal to the fixed nav bar's height, otherwise it
   lands on top of the section heading when the browser jumps to the
   anchor. --topbar-height is measured and kept live by main.js; 100px
   is a safe fallback for the instant before that JS runs. */
.section-container[id] {
	scroll-margin-top: var(--topbar-height, 100px);
}

body {
	margin: 0;
	height: 100%;
	/* Combined left/right inset that text and chips end up sitting at
	   inside a section (section-container padding + printer-marks-container
	   padding + content-container padding, all stacked). Each of those three
	   paddings clamps on its own vw-based curve, so their sum is not a single
	   smooth clamp — summing the exact same three expressions here (rather
	   than approximating with one clamp()) is what keeps the header logo and
	   nav pills perfectly aligned with section content at every viewport
	   width, not just at the min/max breakpoints. */
	--section-inset: calc(var(--section-padding) + var(--printer-marks-padding) + var(--content-padding));
	/* Same idea as --section-inset above, but stopping one layer short —
	   section-container padding + printer-marks-container padding, without
	   the innermost content-container term. That's deliberate: a case
	   study's image block (.split-layout-media) sits inside
	   content-container but cancels content-container's own padding with
	   an equal negative margin (see .split-layout), so the image
	   bleeds out to sit flush with printer-marks-container's inner edge —
	   i.e. exactly this two-term sum from the page edge, not the full
	   three-term --section-inset. Used to align the nav logo with that
	   same edge (and with the printer-mark corner icons, which are
	   painted as printer-marks-container's own background and so sit at
	   this same inset too). */
	--module-inset: calc(var(--section-padding) + var(--printer-marks-padding));
	/* .nav-container is position:fixed (see below), so it's out of normal
	   flow — this reserves the same amount of space at the top of the
	   page that it used to occupy, so content doesn't start underneath
	   it. main.js measures the nav bar's real rendered height and keeps
	   this in sync; 100px is a safe fallback for the instant before that
	   JS runs on load/resize. */
	padding-top: var(--topbar-height, 100px);
}

body .menu-container {
	position: sticky;
	top: 0;
}

/* NAV BAR (persistent header)
   The logo used to live in its own header block above the nav, with
   main.js hiding/showing that header on scroll. Both the separate
   header and the hide/show behaviour have been removed — the logo now
   sits inside the nav bar itself, to the left of the two pills, and
   the whole bar stays pinned in place (position:fixed) at all times. */
.nav-container {
	position: fixed;
	top: 0;
	left: 0;
	right: 0;
	z-index: 20;
	background-color: var(--color-background-page);
	background-repeat: repeat-x;
	background-position: bottom;
	background-image: url("../image-assets/module-container-assets/section-divider.svg");
}

.nav-content {
	box-sizing: border-box;
	height: 80px;
	width: 100%;
	/* Kept equal to .section-container's own max-width (not just a
	   similar-looking number) — both are centred with margin:auto, so
	   matching outer widths is what keeps the nav logo aligned with a
	   module's own left edge past the point where everything stops
	   growing with the viewport. Update both together. */
	max-width: 1920px;
	margin: auto;
	/* --module-inset (not --section-inset) so the logo lines up with the
	   left edge of a case study's image block and the printer-mark corner
	   icons, rather than with the body text/chips further in. */
	padding-inline: var(--module-inset);
	display: flex;
	align-items: center;
	/* Gap between the logo and the button group — roughly double the
	   gap between the pills themselves (see .nav-buttons). Fluid rather
	   than a fixed 24px so four pills (Home/Work/Art/Idea
	   repo) keep fitting on narrow viewports without the nav overflowing. */
	gap: clamp(10px, calc(4 * var(--vw)), 24px);
}

/* Logo, sized to match the nav pills' own rendered height exactly
   (--nav-pill-height is measured live by main.js, since the pills'
   height is itself responsive via a clamp()-based font-size — see
   .nav-pill in buttons.css). 48px is a safe fallback for the instant
   before that JS runs. The source SVG is a 1:1 square, hence
   aspect-ratio: 1/1 to derive width from that height.

   The background-image (weight 8) is only the no-JS fallback:
   js/header-logo.js injects an inline SVG and adds
   .nav-logo-link--inline, which morphs between weights 8→80 by
   pointer proximity (static weight 8 on touch devices). */
.nav-logo-link {
	display: inline-flex;
	flex: none;
	aspect-ratio: 1 / 1;
	height: var(--nav-pill-height, 48px);
	background-image: url("../image-assets/logo/logo-8.svg");
	background-size: contain;
	background-position: center;
	background-repeat: no-repeat;
	/* Real <a href="index.html"> (see HTML) so the logo links back home;
	   reset the link defaults from typography.css since there's no
	   visible text here to underline. */
	text-decoration: none;
	cursor: pointer;
}

.nav-logo-link--inline {
	background-image: none;
}

.nav-logo-svg {
	color: var(--color-text-primary);
	display: block;
	width: 100%;
	height: 100%;
	overflow: visible;
}

.nav-buttons {
	display: flex;
	align-items: center;
}

/* Kept from the old condensed breakpoint: below 418px the logo-to-group
   gap holds at a fixed 16px rather than continuing to shrink with
   the fluid clamp() on .nav-content. */
/* breakpoint (max-width: 418px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-418 .nav-content {
		gap: 16px;
	}
@media (max-width: 418px) {
	html:not(.dfui) .nav-content {
		gap: 16px;
	}
}

/* NAV BUTTON GROUP — the header's navigation at every screen width.
   The four pills (Home / Work / Art / Code) are joined into a single
   "mega-button": one continuous rounded capsule with thin dividers
   between segments instead of a gap between separate pills. This used
   to be only the condensed fallback below 418px (with four separate
   pills above that); it's now the default everywhere. Sizing is
   unchanged from the pills themselves — same clamp()-based font-size
   and em padding from .nav-pill in buttons.css — so the group scales
   exactly as the pills always have.

   Each pill keeps its own :hover/:focus-visible/.nav-pill-active fill
   behaviour entirely unchanged (same ::before layer, same transform/
   color transitions) — only the shape changes here: individual pills
   lose their own border/radius/corner-clip (the group supplies one
   shared outer border and corner-rounding instead), and a 1px divider
   is added between pills via border-left so adjacent segments read as
   joined rather than touching-but-separate. A divider is the same
   #333333 as an active/hover pill's own fill, so it's only visible
   between two pills that AREN'T currently filled. */
.nav-buttons {
	gap: 0;
	border: 1px solid var(--color-border-strong);
	border-radius: 9999px;
	/* Same technique as .nav-pill's own clip-path in buttons.css (see
	   its comment for the full reasoning): clips every child to this
	   box's true rounded outer edge, round corners included, rather
	   than relying on overflow (which clips at the padding edge and
	   reintroduces the sub-pixel border/fill seam .nav-pill's own
	   clip-path was built to avoid). isolation:isolate keeps this
	   group's own stacking context self-contained, same reason
	   .nav-pill sets it on itself.

	   min-width:0 for the same reason it's needed on the flex/ellipsis
	   children below: the group itself is also a flex item (of
	   .nav-content) with no width of its own, so without this it
	   defaults to min-width:auto and refuses to shrink past its own
	   max-content size — which, with flex-grow children inside it,
	   the browser works out from THEIR natural label widths, not
	   their flex-grown ones. The two settings only compound: skip
	   this one and the group never gets narrow enough for its own
	   children's min-width:0 to have anything left to do. */
	clip-path: inset(0 round 9999px);
	isolation: isolate;
	min-width: 0;
	/* Stretch to fill whatever width .nav-content leaves beside the
	   logo, capped at 320px — so on small screens the group spans the
	   full remaining width, and on larger ones it stops at 320px. The
	   extra width goes to the two middle segments (see the nth-child
	   flex rule below); Home and Code keep hugging their labels. */
	flex: 1 1 auto;
	max-width: 320px;
}

/* Strip each pill's own standalone-button shape — the group above now
   owns the single outer border and the corner-rounding at both ends,
   so a per-pill border/radius here would draw a second, redundant
   outline between every segment instead of one shared capsule. */
.nav-buttons > .nav-pill {
	border: none;
	border-radius: 0;
	clip-path: inset(0);
	/* The group's own 1px border sits outside the pills, so each pill is
	   2px shorter than a standalone .nav-pill. The whole group then comes
	   out exactly --btn-height tall, the same as one Primary button. */
	height: calc(var(--btn-height) - 2px);
}

/* Pressed inside the group: no per-pill border to draw the gradient
   ring on, so just fill (the group's own border stays the outline). */
.nav-buttons > .nav-pill:active {
	background: none;
}

/* The divider between segments: every pill except the first gets a
   hairline left edge, 1px like the group's own outer border (every
   UI stroke is 1px since v2.35).
   Drawn as an inset box-shadow rather than a border-left so it takes
   up no layout width: a real 1px border would make pills 2–4 each 1px
   wider than Home under the equal-width flex rule below. The pill's
   ::before fill (same #333333) paints over it when filled, exactly as
   it used to cover the border. */
.nav-buttons > .nav-pill:not(:first-child) {
	box-shadow: inset 1px 0 0 var(--color-border-strong);
}

/* ::before's inset:-1px in buttons.css exists to let a pill's hover
   fill overlap and hide ITS OWN 1px border by that same amount. With
   that border gone (moved to the shared group border/1px dividers
   above), flush is correct instead — -1px here would bleed each
   pill's fill 1px into its neighbour past the thin divider line,
   which the group's own clip-path wouldn't catch since that only
   trims the FAR outer edges, not the seams between children. */
.nav-buttons > .nav-pill::before {
	inset: 0;
}

/* All four segments (Home / Work / Art / Code) are the same width and
   share the group's full width between them: a 0% basis means every
   pill grows from zero rather than from its own label width, so the
   spare space splits exactly evenly. The group itself still stretches
   to fill the nav up to its 320px max-width (see .nav-buttons above).

   min-width:0 overrides a flex item's own default (min-width:auto,
   i.e. never shrink below its own text's full width) — without it,
   a pill refuses to compress past its natural label width no matter
   how little room flex:1 leaves it, which pushes the whole group off
   the right edge of the viewport on very narrow screens. The actual
   overflow:hidden/text-overflow that turns that shrinking into an
   ellipsis lives on .nav-pill-label just below — text-overflow has no
   visible effect applied directly to a flex container, only to a
   block-level box, hence the nested span around every label in the
   HTML. */
.nav-buttons > .nav-pill {
	flex: 1 1 0%;
	min-width: 0;
}

.nav-buttons > .nav-pill .nav-pill-label {
	display: block;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}


.menu-container {
	background-repeat: repeat-x;
	background-position: bottom;
	background-image: url("../image-assets/module-container-assets/section-divider.svg");
	background-color: var(--color-background-page);
	text-align: center;
	z-index: 3;
}

.menu-content {
	box-sizing: border-box;
	height: 56px;
	width: 100%;
	max-width: 2048px;
	margin: auto;
	display: flex;
	justify-content: center;
	align-items: center;
	padding: clamp(8px, 4%, 48px);
}

.section-container {
	box-sizing: border-box;
	width: 100%;
	/* Global max-width, sized so content-container's own max-width works
	   out to exactly 1800px: 1920px here, minus this padding's own 32px
	   max per side (64px) and .printer-marks-container's 28px max per
	   side (56px) inside it, leaves 1800px for .content-container (which
	   has no max-width of its own — it's simply whatever's left once
	   every ancestor's own padding is subtracted). Update .nav-content's
	   matching max-width alongside this one, not independently — see its
	   own comment for why. */
	max-width: 1920px;
	margin: auto;
	padding: clamp(8px, 2.5%, 32px);
}

.printer-marks-container {
	display: flex;
	flex-wrap: wrap;
	box-sizing: border-box;
	width: 100%;
	padding: var(--printer-marks-padding);
	background-repeat: no-repeat;
	background-position: left top, left bottom, right bottom, right top, left, right, bottom;
	background-size:
		clamp(32px, calc(2.5 * var(--vw)) + 24px, 56px) auto,
		clamp(32px, calc(2.5 * var(--vw)) + 24px, 56px) auto,
		clamp(32px, calc(2.5 * var(--vw)) + 24px, 56px) auto,
		clamp(32px, calc(2.5 * var(--vw)) + 24px, 56px) auto,
		clamp(8px, calc(0.625 * var(--vw)) + 6px, 14px) auto,
		clamp(8px, calc(0.625 * var(--vw)) + 6px, 14px) auto,
		auto clamp(8px, calc(0.625 * var(--vw)) + 6px, 14px);
	background-image:
		url("../image-assets/module-container-assets/registration-marks-tl.svg"),
		url("../image-assets/module-container-assets/registration-marks-bl.svg"),
		url("../image-assets/module-container-assets/registration-marks-br.svg"),
		url("../image-assets/module-container-assets/registration-marks-tr.svg"),
		url("../image-assets/module-container-assets/colour-bar.svg"),
		url("../image-assets/module-container-assets/colour-bar.svg"),
		url("../image-assets/module-container-assets/tint-bar.svg");
	z-index: 1;
}

.content-container {
	box-sizing: border-box;
	width: 100%;
	padding: var(--content-padding);
	/* Light tint on every section site-wide, to keep each one visually
	   contained — scoped to .content-container (not the wider
	   .printer-marks-container) so it stops at the inner edge of the
	   crop-mark padding rather than washing out underneath the
	   registration marks themselves. The logo carousel has no
	   .content-container (it uses .logo-carousel directly, which
	   already has its own deliberate white/vignette background), so it's
	   correctly unaffected by this rule. */
	background-color: var(--color-background-surface);
}

/* REPOSITORY INDEX (homepage) used to be its own standalone card grid
   here (.repo-index-grid/.repo-index-item/.repo-index-heading) — it's
   since been rebuilt on the Card Grid module's .cards (see "CARD
   GRID" further down) so that layout lives in one
   place instead of two near-duplicates. .card-icon survives as
   the icon's own class (still referenced from index.html's markup and
   from .card-heading below) since it's just a sizing rule, not tied
   to the card shape that used to wrap it. */
.card-heading .card-icon {
	flex-shrink: 0;
	font-size: 1em;
	line-height: 1;
}

.section-divider {
	height: 1px;
	width: 100%;
	background-repeat: repeat;
	background-image: url("../image-assets/module-container-assets/section-divider.svg");
}

/* TEXT SPLIT MODULES — two-column, text-only variant of the 50/50
   pattern: a heading on one side, body copy on the other, no media
   panel. Unlike .split-layout below, this doesn't bleed past
   content-container's own padding (there's no full-bleed image here to
   cancel it back out against), so it just sits inside that padding like
   any other .text-block-container.

   Reuses .heading-container / .bodycopy-container directly as the two
   grid items — same h3/p styling as every other text block on the
   site — so only the two-column wrapper and its gutter are new. Which
   side the heading lands on is purely HTML order (heading-container
   first, or bodycopy-container first); the grid itself is symmetric.

   gap is var(--content-padding) — the same inset as the module's own
   outer padding, per spec, so the inner gutter between the two columns
   always equals the padding around them. Stacks to one column below
   the shared 1024px breakpoint, same as .split-layout.

   Top-aligned by default — the heading's own baseline lines up with
   the body copy's, rather than being centred against the copy block's
   full height. Add .two-column-text--centered for the other
   look (heading vertically centred against the copy column), used on
   the original two Google Summits modules. */
.two-column-text {
	display: grid;
	grid-template-columns: 1fr 1fr;
	gap: var(--content-padding);
	align-items: start;
}

.two-column-text--centered {
	align-items: center;
}

/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .two-column-text {
		grid-template-columns: 1fr;
	}
@media (max-width: 1024px) {
	html:not(.dfui) .two-column-text {
		grid-template-columns: 1fr;
	}
}

/* FULL WIDTH IMAGE MODULE — a single media panel spanning the entire
   content area, where a 50/50 module's image AND text columns would
   both have sat, with no text column of its own. Reuses the same
   negative-margin bleed as .split-layout just below (cancels
   out content-container's own padding so it sits flush with
   printer-marks-container's inner edge, exactly like a 50/50 module's
   image side) — just as one block instead of a two-column grid.

   Ratio is fluid at every breakpoint via the CSS aspect-ratio property
   itself — a ratio already describes the box at any width with no extra breakpoint stepping
   needed. Pick the ratio with a modifier class: --20-8 is what's used
   for now; --16-9 is defined and ready for whenever a module needs it
   — add more of these as new ratios come up, same pattern. */
.full-width-image {
	position: relative;
	overflow: hidden;
	margin: calc(-1 * var(--content-padding));
	background-size: cover;
	background-position: center;
}

.full-width-image--20-8 {
	aspect-ratio: 20 / 8;
}

.full-width-image--16-9 {
	aspect-ratio: 16 / 9;
}

/* VIDEO IN A FULL-WIDTH MODULE (google-summits.html sizzle reel): the
   <video> fills the 16:9 module edge to edge and autoplays on a loop.
   .video-loader is the site loader (cube + progress bar + "loading
   video...", page-loader__* classes from film-and-animation.css) on the
   grid paper, shown until playback starts. js/inview-video.js fills the
   bar from the buffered amount, fades the loader (.is-hidden), and
   pauses the video while it's off screen. */
.full-width-video {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	display: block;
	object-fit: cover;
}

.video-loader {
	position: absolute;
	inset: 0;
	z-index: 1;
	display: flex;
	align-items: center;
	justify-content: center;
	opacity: 1;
	visibility: visible;
	transition: opacity var(--duration-base) var(--easing-default),
		visibility 0s linear 0s;
}

.video-loader.is-hidden {
	opacity: 0;
	visibility: hidden;
	pointer-events: none;
	transition: opacity var(--duration-base) var(--easing-default),
		visibility 0s linear var(--duration-base);
}

.video-loader .page-loader__progress-fill {
	transition: width 0.25s var(--easing-default);
}

@media (prefers-reduced-motion: reduce) {
	.video-loader,
	.video-loader.is-hidden {
		transition: none;
	}
}

/* Below ~1080px a 20:8 band gets too shallow to hold a centred
   animation comfortably, so an animated instance (one that opts in
   via data-sequence-type, same attribute scroll-sequence.js already
   reads) switches to a 1:1 container instead. Scoped to
   [data-sequence-type] specifically so a future plain 20:8 image
   module isn't affected — only the animated one needs this. */
/* breakpoint (max-width: 1080px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1080 .full-width-image--20-8[data-sequence-type] {
		aspect-ratio: 1 / 1;
	}
@media (max-width: 1080px) {
	html:not(.dfui) .full-width-image--20-8[data-sequence-type] {
		aspect-ratio: 1 / 1;
	}
}

/* INTERACTIVE FRAME
   For live, interactive code experiences (the Exponential Possibility
   Architecture canvas, the Digital Twin Viewer's 3D scenes, and any
   future ones). Add .interactive-frame to the module container, e.g.
     <div class="full-width-image full-width-image--16-9 interactive-frame ...">
   It draws a 1px stroke in the default border colour (the same grey as
   the canvases' grids) just inside the container's outer edge, so the
   live area reads as a framed "stage" against the page. The stroke is
   an overlay: it sits above the canvas and panels, takes no space and
   never blocks clicks/drags. isolation:isolate keeps everything inside
   (canvases, panels, loaders, whatever their z-index) in its own
   stacking context, so nothing in it can paint over the fixed nav. */
.interactive-frame {
	position: relative;
	isolation: isolate;
}

.interactive-frame::after {
	content: "";
	position: absolute;
	inset: 0;
	z-index: 100;
	border: var(--stroke-bold) solid var(--color-border-default);
	pointer-events: none;
}

/* INTERACTIVE MODULE
   The layout for every live code experience (EPA, the Digital Twin
   Viewer's 3D scenes, future ones). Figma: 01.02.00 system library,
   "interactive_experiences-controls_and_guide_layout_example".

     <div class="interactive-module interactive-frame [experience]-widget">
       <div class="interactive-module__stage"> canvas(es), loader … </div>
       <div class="interactive-module__bar">
         <div class="interactive-module__panel"> Controls </div>
         <div class="interactive-module__panel interactive-guide"> How it works </div>
       </div>
     </div>

   - Stage: the experience itself, edge-to-edge (bleeds into the
     printer-marks container like .full-width-image). 16:9, switching
     to 1:1 below 1080px.
   - Bar: underneath, only as tall as its content. Two equal panels,
     controls left and guide right, with 1px dividers in the frame colour.
     Stacks to one column below 1024px (the same breakpoint as the 50/50
     modules).
   - The module's height = stage + bar. Nothing overlays the
     experience any more, so its whole area stays usable. */
.interactive-module {
	position: relative;
	isolation: isolate;
	margin: calc(-1 * var(--content-padding));
	background-color: var(--color-background-page);
}

.interactive-module__stage {
	position: relative;
	overflow: hidden;
	aspect-ratio: 16 / 9;
}

/* Below 1080px (--bp-full-width) the stage goes 1:1, the same switch
   the animated full-width modules make, so tall subjects (buildings in
   the 3D viewer) and the EPA drawing keep a usable area on tablets and
   phones. */
/* breakpoint (max-width: 1080px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1080 .interactive-module__stage {
		aspect-ratio: 1 / 1;
	}
@media (max-width: 1080px) {
	html:not(.dfui) .interactive-module__stage {
		aspect-ratio: 1 / 1;
	}
}

.interactive-module__bar {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	border-top: var(--stroke-bold) solid var(--color-border-default);
}

.interactive-module__panel {
	min-width: 0;
	padding: var(--space-24) var(--content-padding);
	box-sizing: border-box;
	color: var(--color-text-primary);
}

.interactive-module__panel + .interactive-module__panel {
	border-left: var(--stroke-bold) solid var(--color-border-default);
}

.interactive-module__panel .eyebrow-container {
	margin-bottom: var(--space-16);
}

/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .interactive-module__bar {
		grid-template-columns: minmax(0, 1fr);
	}
html.bp-max-1024 .interactive-module__panel + .interactive-module__panel {
		border-left: none;
		border-top: var(--stroke-bold) solid var(--color-border-default);
	}
@media (max-width: 1024px) {
	html:not(.dfui) .interactive-module__bar {
		grid-template-columns: minmax(0, 1fr);
	}
	html:not(.dfui) .interactive-module__panel + .interactive-module__panel {
		border-left: none;
		border-top: var(--stroke-bold) solid var(--color-border-default);
	}
}

/* Guide panel ("How it works"): a bold lead line, then term/definition
   pairs in two columns (each pair wrapped in a <div> inside the <dl>),
   so the bar stays short. One column on phones. */
.interactive-guide__lead,
.interactive-guide__list dt,
.interactive-guide__list dd {
	font-family: "Open Sans", sans-serif;
	font-size: clamp(0.656rem, calc(0.31 * var(--vw)) + 0.594rem, 0.844rem); /* small tier */
	line-height: 140%;
	color: var(--color-text-primary);
}

.interactive-guide__lead {
	font-weight: 600;
	margin: 0 0 var(--space-16);
}

.interactive-guide__list {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	gap: var(--space-8) var(--space-24);
	margin: 0;
	padding: 0;
}

/* Plain pairs: switch off the site-wide dt bullet and flex row
   (typography.css), which are sized for body lists. */
.interactive-guide__list dt {
	display: block;
	font-weight: 600;
	margin: 0;
}

.interactive-guide__list dt::before {
	content: none;
}

.interactive-guide__list dd {
	font-weight: 300;
	margin: 0;
}

/* breakpoint (max-width: 600px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-600 .interactive-guide__list {
		grid-template-columns: minmax(0, 1fr);
	}
@media (max-width: 600px) {
	html:not(.dfui) .interactive-guide__list {
		grid-template-columns: minmax(0, 1fr);
	}
}

/* 50/50 BLOCKS
   No min-height: a 50/50 (and its 30/70 / 70/30 variants) is exactly as
   tall as its text column needs, plus --content-padding. The image
   panel stretches to match that height (grid's default
   align-items:stretch) rather than propping the row up to a fixed
   floor. Below 1024px the image takes a 16:9 ratio instead (see the
   shared breakpoint block). */
.split-layout {
	display: grid;
	grid-template-columns: 1fr 1fr;
	overflow: hidden;
	margin: calc(-1 * var(--content-padding));
}

.split-layout-media {
	background-size: cover;
	background-position: center;
	background-repeat: no-repeat;
	position: relative;
	overflow: hidden;
}

.split-layout-media-inset {
	background-size: cover;
	background-position: center;
	margin: var(--content-padding);
	position: relative;
}

.split-layout-text {
	display: flex;
	flex-direction: column;
	justify-content: center;
	padding: var(--content-padding);
}

/* CASE STUDY TEASER (homepage featured case studies) — per-client modules
   Each client's module reuses .split-layout-media (solid brand
   colour, set inline per client like the standalone case study pages)
   but centres that client's own grid-tile logo inside it instead of a
   cover image — specifically the logo's "coloured" hover-state
   treatment from the case study grid (white silhouette, or the Google
   Cloud asset's own true colours), shown permanently rather than on
   hover, since a static 50/50 module has no hover state of its own. */
.case-study-teaser-media {
	box-sizing: border-box;
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100%;
	padding: clamp(32px, calc(6 * var(--vw)), 72px);
}

/* Centred teaser logos are sized by the shared AREA-CAPPED LOGO
   SIZING rule (see LOGO OVERLAY below), the same as corner overlays:
   every logo gets the same rendered area whatever its shape, so a 10:1
   wordmark (Datastax) and a 1:1 mark (Volkswagen) carry equal visual
   weight. Each --modifier further down supplies its exact
   --logo-aspect. */

/* Datastax uses the same pure-white treatment as every other brand's
   logo (brightness(0) invert(1)) — the hub's <span> applies this inline,
   matching the other 11 modules' convention. */

/* Case Study Teaser layouts — same teaser module, narrower image
   column / wider copy column. Applied alongside .case-study-teaser
   (adds to it, doesn't replace it). --30-70 = image left / text right
   (media panel first in the HTML); --70-30 = text left / image right
   (text column first). Height comes from the text column, like every
   other 50/50. Stacks to one column below 1024px same as every other
   .split-layout (see the shared breakpoint below). */
.split-layout.case-study-teaser.case-study-teaser--30-70 {
	grid-template-columns: 3fr 7fr;
}

.split-layout.case-study-teaser.case-study-teaser--70-30 {
	grid-template-columns: 7fr 3fr;
}

/* IMAGE + TEXT 30/70 and 70/30 — same 3fr/7fr split as the Case Study
   Teaser modifier above, but standalone: a plain image+text module, not tied to .case-study-teaser/-media. Which
   side is narrow is purely HTML order (media first = image-text 30/70,
   text first = text-image 70/30), same convention as the base 50/50
   module. Stacks to one column below 1024px like every other
   .split-layout — see the matching override in the shared
   breakpoint block below (needed for the same specificity reason the
   Case Study Teaser's own override is needed, see the comment there).
   Like every 50/50, its height comes from the text column. */
.split-layout.image-text--30-70 {
	grid-template-columns: 3fr 7fr;
}

/* Mirror of the above for Image + Text 70/30 (image right): same split,
   reversed, for a wide-text/narrow-image module rather than swapping
   which HTML column the narrow track applies to. */
.split-layout.image-text--70-30 {
	grid-template-columns: 7fr 3fr;
}

/* LOGO OVERLAY — client logo pinned to a corner of any image container:
   .split-layout-media, .full-width-image, .hero-banner-image,
   .hero-banner-image-cell, .image-gallery-item or .mosaic-gallery-item. Drop
   <div class="logo-overlay top-left logo-[client]"></div> inside the
   container (top-left is the house position; top-right / bottom-left /
   bottom-right also exist). Each logo class (css/images.css) supplies
   the artwork and its EXACT --logo-aspect (the SVG's own viewBox
   ratio).

   Sizing (equal visual weight regardless of shape): every logo's box
   is capped at the same AREA, not the same width. With area A and
   ratio r = w/h:  w = sqrt(A*r),  h = sqrt(A/r).  --logo-max-side is
   sqrt(A), so the default 64px caps every logo at 64 x 64 = 4,096px^2
   at full size (64x64, 128x32, 170x24 ...), ratio intact. Change
   --logo-max-side in css/tokens.css to resize every overlay at once.

   Fluid scaling is applied to the SIDE of that area square (not to
   the logo's width), then multiplied by sqrt(ratio). So when a panel
   is too small for full size, every logo shrinks by the same factor
   and they all stay equal in area, rather than all collapsing to the
   same width. The side tracks a share of the container
   (--logo-fluid: 16.666% here, so a ~384px panel already reaches the
   full 64px; adjusted in the overrides below), and is clamped
   between 60% of --logo-max-side and --logo-max-side itself. */
.hero-banner-image-cell,
.image-gallery-item,
.mosaic-gallery-item {
	position: relative;
}

/* AREA-CAPPED LOGO SIZING: the global rule for every logo placed
   over an image container: corner overlays (.logo-overlay) and logos
   centred in a Case Study Teaser panel (.case-study-teaser-logo inside
   .case-study-teaser-media). Add any future "logo on an image"
   selector to this list rather than giving it its own width/height.
   The logo's class only needs to supply --logo-aspect (its exact
   viewBox ratio) and its artwork. */
.logo-overlay,
.case-study-teaser-media .case-study-teaser-logo {
	--logo-fluid: 16.666%;
	width: calc(
		clamp(calc(var(--logo-max-side, 64px) * 0.6), var(--logo-fluid), var(--logo-max-side, 64px))
		* sqrt(var(--logo-aspect, 1))
	);
	height: auto;
	max-width: none;
	aspect-ratio: var(--logo-aspect, 1);
}

.logo-overlay {
	position: absolute;
	background-size: contain;
	background-repeat: no-repeat;
	z-index: 2;
}

.logo-overlay.top-left {
	top: var(--content-padding);
	left: var(--content-padding);
	background-position: top left;
}

.logo-overlay.top-right {
	top: var(--content-padding);
	right: var(--content-padding);
	background-position: top right;
}

/* Vertically centred, left-aligned: the work.html entry banners. Inside
   .hero-banner-image the left offset is measured from the inside of its
   16px border-left, so --content-padding lands the logo exactly on the
   h1's left edge below at every width. */
.logo-overlay.center-left {
	top: 50%;
	left: var(--content-padding);
	transform: translateY(-50%);
	background-position: left center;
}

.logo-overlay.bottom-left {
	bottom: var(--content-padding);
	left: var(--content-padding);
	background-position: bottom left;
}

.logo-overlay.bottom-right {
	bottom: var(--content-padding);
	right: var(--content-padding);
	background-position: bottom right;
}

/* The 5:1 hero media frame and 20:8 full-width image are much shorter
   than they are wide, so a 25%-wide logo would fill most of their
   height. Halve it there. */
.hero-banner-image > .logo-overlay,
.full-width-image--20-8 > .logo-overlay {
	--logo-fluid: 8.333%;
}

/* ============================================================
   HERO MODULE (in development — built and QA'd on staging.html
   before it replaces the first module on every page)
   A plain .split-layout — same 50/50 grid, same shared
   1024px stacking breakpoint as every other 50/50 module above — but
   with its own, much shorter min-height (see
   .split-layout.hero below) and an "L-shaped" 16px
   #333333 frame around just the left column's eyebrow+heading, not
   the fluid clamp()'d stroke .h4-indent/.h5-indent use elsewhere —
   this one's a flat 16px regardless of viewport, per spec. Both
   columns are top-aligned to the chip's baseline instead of the base
   .split-layout-text's vertical centring.

   Corrected from the first pass: the vertical stroke originally sat
   on .split-layout-text:first-child itself, which — as a grid item —
   stretches to the row's full height (align-items:stretch), so the
   border bled past the heading frame, down behind the tagline and
   (at the min-height floor the module had then) well past both
   columns' actual content. Per the corrected mockups, the vertical
   stroke should stop exactly where the horizontal one starts — it
   frames only the eyebrow+heading, not the tagline below it or the
   column's stretched height. .hero-frame (wrapping just those two
   children) is what the border now lives on instead: a plain block
   in a flex column with justify-content:flex-start, so it's only
   ever as tall as its own content no matter how tall the row is
   stretched. margin-left/-top and padding-left/-top re-create the
   same bleed trick .split-layout-media-inset uses elsewhere (cancel the
   column's own padding on that side with an equal negative margin,
   then reinstate it as padding so the chip/heading sit exactly where
   they'd be without any of this) — that's what keeps the border
   flush with the column's true left AND top edges (matching the
   reference design, which runs the stroke to the module's actual
   top-left corner) rather than inset by --content-padding on either
   side. Both margin/padding pairs use --content-padding, the same
   fluid clamp() every other module's padding already uses, so this
   frame keeps scaling at the same rate as everything else at every
   viewport rather than introducing its own fixed breakpoint.

   The eyebrow chip and heading text sit a full --content-padding in
   from the border's own inner edge on every side (left, top) — i.e.
   the white space itself (not counting the stroke's own 16px) always
   reads as the same --content-padding the module's top/bottom gaps
   already use, so all three read as one consistent rhythm rather than
   the left gap looking tighter than the other two. That does mean the
   stroke sits OUTSIDE the standard inset rather than eating into it
   (16px extra beyond --content-padding, not instead of 16px of it) —
   a deliberate choice after comparing both side by side, not an
   oversight; .content-stroke-left (Rich Text / Two-Column Text / Card Grid below) still makes
   the opposite choice on purpose, since nothing ties these two
   together beyond sharing the same 16px/#333333 stroke. */
.hero .split-layout-text {
	justify-content: flex-start;
}

.hero-frame {
	margin-left: calc(-1 * var(--content-padding));
	padding-left: var(--content-padding);
	margin-top: calc(-1 * var(--content-padding));
	padding-top: var(--content-padding);
	border-left: 16px solid var(--color-border-strong);
	box-sizing: border-box;
}

/* HERO WITH BANNER IMAGE — .hero-banner variant of the Hero
   (work.html's entries), adding a fixed 5:1 image panel
   directly above the existing chip/heading/tagline/copy, as a sibling
   inside the same .content-container rather than inside the 50/50 grid
   itself — the mockup has it spanning both columns' combined width, not
   just the text column the chip and heading sit in.

   .hero-banner-image is self-contained (aspect-ratio, bleed and the
   stroke's continuation all live on it directly) and doesn't need the
   .hero-banner modifier to do any of that. The one place
   that modifier IS needed: .split-layout's own margin-top
   (the base rule two blocks up — margin: calc(-1 * --content-padding)
   on every side), which bleeds the WHOLE grid up to content-container's
   true top edge — correct when the grid is .content-container's first
   child, but here .hero-banner-image already occupies that space, so
   the same upward bleed instead pulls the grid 48px UP INTO the image,
   overlapping it rather than reaching a true edge that's already
   spoken for. Zeroed out here so the grid sits directly below the
   image instead, with nothing left to cancel.

   (Earlier version of this comment/rule: a first attempt also zeroed
   out .hero-frame's own margin-top/padding-top below, reasoning its
   extra --content-padding was "doubling" the gap the same way the
   grid's overlap was — wrong: .hero-frame's border-left is drawn at
   its own box edge, not its padding edge, so that box's position (not
   just its content) matters for stroke continuity with the image
   above. Zeroing its padding-top kept the chip's text position correct
   but pulled its BORDER down with it, opening a 48px break in the
   stroke. Left as the base .hero-frame rule now handles it correctly
   with no override needed at all: see the math in that rule's own
   comment for why.) */
.split-layout.hero-banner {
	margin-top: 0;
}

/* With the grid's own overlap removed just above, .hero-frame's base
   margin-top/padding-top cancel-reinstate trick (see its own rule,
   unmodified for this variant) now does exactly the right thing
   without any repo-entry-specific override: .split-layout-text's
   ordinary padding-top (--content-padding) lands its content box
   --content-padding below the image; .hero-frame's own negative
   margin-top of the same size pulls its BOX back up by that same
   amount, landing its border-left flush with the image's own
   border-left (0 gap, continuous stroke); its own padding-top of
   --content-padding then pushes its CONTENT (the chip) back down by
   --content-padding from there — ending up --content-padding below
   the image, matching the text column beside it exactly, with the
   border still flush. Previously this rule read `margin-top: 0`,
   which (before the grid's own overlap above was fixed) relied on
   that overlap to land the border flush — correct by coincidence for
   this column only, while the text column opposite it had nothing
   playing the same role and sat flush with no gap at all. */

/* aspect-ratio is fixed at 5/1 with no breakpoint override — unlike
   every other media module on the site, this one stays 5:1 at any
   viewport width, per spec (no change below the 1024px stacking
   breakpoint the way .split-layout-media/.full-width-image
   change shape or position there). It isn't a grid item (it sits
   outside/above .split-layout entirely, a plain sibling in
   .content-container), so the grid's own 1024px stacking rule has no
   effect on it regardless.

   Bleeds top and right the same way .full-width-image does
   (cancel content-container's own padding, nothing to reinstate — the
   image itself should run edge-to-edge, unlike text) — but the LEFT
   edge bleeds only as far as the stroke's own outer edge and stops
   there: border-left (not a reinstated padding-left) is what carries
   the vertical stroke up from its old starting point (the top of
   .hero-frame) to this box's own top edge instead, so the two borders
   — this one and .hero-frame's, stacked directly on top of each other
   with zero gap between the boxes — read as a single unbroken line
   from the image's top edge down to the horizontal stroke beneath the
   heading. margin-bottom is deliberately left at the default 0 for
   that same reason: any gap here would show as a break in the stroke. */
.hero-banner-image {
	position: relative;
	box-sizing: border-box;
	overflow: hidden;
	aspect-ratio: 5 / 1;
	margin-top: calc(-1 * var(--content-padding));
	margin-right: calc(-1 * var(--content-padding));
	margin-left: calc(-1 * var(--content-padding));
	border-left: 16px solid var(--color-border-strong);
	background-size: cover;
	background-position: center;
	/* A background paints under a border by default (background-clip's
	   initial value is border-box), so at the fractional sub-pixel
	   positions --content-padding's clamp() produces at most viewport
	   widths, the border-left's own edge can anti-alias thinly enough
	   to let a sliver of a busy/high-contrast background (the lego
	   tile placeholder, not a flat colour where this is invisible)
	   show through right at that edge — the "tiny sub-pixel gaps"
	   reported along this stroke. Clipping the background to the
	   padding box instead removes the possibility outright: with no
	   padding declared, that's everything inside the border, so the
	   background simply never paints under the border-left at all,
	   regardless of how its edge rounds at any given width. */
	background-clip: padding-box;
}

/* MULTI-IMAGE VARIANTS of .hero-banner-image — 2, 3 or 4 placeholder
   cells inside the same bled/bordered frame, replacing its single
   background-image with a CSS grid of child .hero-banner-image-cell elements
   (each carrying its own background-color/-image the same way the
   single-image frame does today). The frame itself — bleed, border-left,
   overflow:hidden, background-clip above — is completely unchanged;
   these modifiers only ever touch display/grid-template/aspect-ratio,
   added alongside .hero-banner-image rather than replacing it:
   class="hero-banner-image hero-banner-image--3".

   Both the grid shape and the aspect-ratio numbers below come from
   measuring the mockup directly (repo-entry-hero-module-image-update.png)
   rather than guessing: each variant holds its PER-CELL aspect ratio
   roughly constant across the desktop/mobile breakpoint, and the
   frame's own overall ratio is just that per-cell ratio multiplied out
   by however many columns/rows the grid has at a given width — not a
   single fixed ratio reused everywhere the way the base (single-image)
   frame's 5:1 is.

   2x/3x share one per-cell ratio (~5:2, measured from the mockup's 2x
   panels — desktop 1848:360 ≈ 5:1 over 2 cols ⇒ 5:2 per cell; mobile
   704:540 ≈ 5:4 over 2 stacked rows ⇒ 5:4 ÷ 2 ≈ 5:8... per ROW, i.e.
   5:2 per cell again once halved correctly) and the same side-by-
   side-desktop/fully-stacked-mobile shape, 3x just with one more
   column/row than 2x — exactly the "3x behaves the same as 2x" the
   mockup's own note describes, so this never needed its own separate
   measurement, only one more repeat() entry than .hero-banner-image--2.

   4x is a different family, not a continuation of 2x/3x's: it measures
   closer to a ~2:1 per-cell ratio (desktop 1848:240 ≈ 7.7:1 over 4
   cols; mobile 704:363 ≈ 1.94:1 over a 2x2 grid — both close to 2:1
   per cell), and — confirmed by measuring the mockup's own two 4x
   panels, not assumed — it stays a single row of 4 even on desktop
   (unlike 2x/3x, which are 2/3 cols) and reflows to a 2x2 grid on
   mobile instead of stacking 4 full-width rows, which is the one place
   its breakpoint behaviour genuinely differs from 2x/3x rather than
   just continuing their pattern with an extra column. */
.hero-banner-image--2,
.hero-banner-image--3,
.hero-banner-image--4 {
	display: grid;
	gap: 0;
}

.hero-banner-image--2 {
	grid-template-columns: repeat(2, 1fr);
	aspect-ratio: 5 / 1;
}

.hero-banner-image--3 {
	grid-template-columns: repeat(3, 1fr);
	aspect-ratio: 15 / 2;
}

.hero-banner-image--4 {
	grid-template-columns: repeat(4, 1fr);
	aspect-ratio: 8 / 1;
}

/* Each cell fills its own grid track exactly (no gap, see above), so
   cover/center here behaves the same as the single-image frame's own
   background-size/-position — individually per cell rather than once
   across the whole frame. */
.hero-banner-image-cell {
	background-size: cover;
	background-position: center;
}

/* CLIENT LOGO IN A BANNER CELL (work.html entries): the logo sits centred
   in the first cell, on the client's brand colour, sized by AREA rather
   than a fixed width so a square mark and a long wordmark carry similar
   weight: it covers ~26% of the cell's area, then is capped at 82% of the
   cell's width and 72% of its height. The cell is ~5:2 in every layout
   (see the multi-image variants above), so its height is 0.4 x its width
   and everything resolves from the cell's own width (cqw):
     width = sqrt(0.26 x 0.4 x aspect) x cell width
   --logo-aspect is the logo file's own width / height. */
.hero-banner-image-cell:has(> .banner-logo) {
	position: relative;
	container-type: inline-size;
}

.banner-logo {
	position: absolute;
	inset: 0;
	margin: auto;
	width: min(calc(sqrt(0.104 * var(--logo-aspect)) * 100cqw), 82cqw, calc(0.288 * var(--logo-aspect) * 100cqw));
	height: auto;
	aspect-ratio: var(--logo-aspect);
	background-size: contain;
	background-position: center;
	background-repeat: no-repeat;
}

/* logos only available in dark ink: shown white on their dark panel */
.banner-logo--white {
	filter: brightness(0) invert(1);
}

/* KV IN A BANNER CELL (work.html, Google Cloud Summits): an animated SVG
   filling the cell. The SVG is drawn for the cell's 5:2 window with
   preserveAspectRatio "xMaxYMax slice", so it stays pinned to the KV's
   corner whatever the cell's exact shape. --turned shows the same loop
   rotated 180deg, for variation across the two cells. */
.banner-kv-cell {
	position: relative;
	overflow: hidden;
}

.banner-kv-cell picture {
	position: absolute;
	inset: 0;
}

.banner-kv {
	display: block;
	width: 100%;
	height: 100%;
}

.banner-kv--turned {
	transform: rotate(180deg);
}

/* The horizontal stroke (border-bottom) needs to meet the vertical
   one on .hero-frame with no gap and no overlap, and then run the
   rest of the way across the column regardless of the heading's own
   text length (short titles like "Android" or "Coca-Cola" still get
   a full-width stroke, not one that stops at the last letter — this
   used to be width:fit-content, sized to the heading text itself,
   which is exactly the short-title gap this was corrected to close).

   Same bleed trick as .hero-frame/.split-layout-media-inset again: negative
   margin-left pulls this element's whole box left by .hero-frame's own
   padding-left (now the full --content-padding, matching the rule just
   above — lining its border box's left edge up with the inner edge of
   .hero-frame's own border-left, i.e. flush against the vertical
   stroke), and an equal padding-left re-indents the content so the h1
   itself still renders at exactly the position it would without any of
   this — --content-padding from the border's inner edge, same as
   .hero-frame's own content inset above. No width declared here (not
   even 100%): a block element with width:auto already fills exactly
   the rest of its containing block's content width on its own, border
   box included, so the border-bottom runs from the vertical stroke's
   inner edge all the way to the column's true right edge (the same
   inset every other heading/bodycopy-container in the module stops at)
   at any heading length. .heading-container's own max-width:960px
   (shared with every other heading/bodycopy-container site-wide) still
   applies on top of that, same as it would without this rule — this
   isn't exempting the hero from that shared cap. */
.hero-heading-frame {
	border-bottom: 16px solid var(--color-border-strong);
	margin-left: calc(-1 * var(--content-padding));
	padding-left: var(--content-padding);
}

/* The global h1 scale (typography.css) gives every h1 a 64px→~50px
   margin-bottom, sized for h1 used on its own — too large once the
   hero module frames it with the stroke above, where it reads against
   the frame's own rhythm instead. Overridden here (not in
   typography.css) since the hero module is the only place h1 is
   actually used site-wide, so this is the only h1 that needs it, and
   scoping it to .hero-heading-frame keeps the change tied to this
   module rather than touching the shared type scale.

   Set to plain --content-padding — the same value (and the same
   fluid clamp()) content-container's own padding uses, and the same
   value .hero-frame's own padding-top above the chip already uses —
   so the gap below the heading matches the gap above the chip and
   the module's own outer padding, all three at once. No 16px
   subtraction here unlike .hero-frame/.hero-heading-frame's
   padding-left just above: that subtraction exists only on axes
   where a 16px border actually occupies part of the same space (the
   vertical stroke eating into the horizontal gap) — this is a
   vertical gap with no border running through it, so it isn't
   shortened by the stroke's own width the way the horizontal insets
   next to it are.

   Targets .hero-banner too, not just .hero — both
   nest their heading in the same .hero-heading-frame, so this needs
   no separate rule once that variant exists. */
.hero-heading-frame h1,
.hero-heading-frame .h1-bold {
	margin-bottom: var(--content-padding);
}

/* .hero-tagline is a sibling of .hero-frame, not a child of it, so no
   bleed trick is needed here — it just takes .split-layout-text's own
   padding-left (--content-padding) as-is, same inset as the heading
   and every other heading/bodycopy-container on the site.

   margin-top was --content-padding (48px at the fluid max) at first,
   matching the heading's own left inset — corrected after review to a
   visually tighter gap instead: same curve .space-24 already uses
   site-wide (19px → 24px), not derived from --content-padding this
   time, since the two are chosen independently now rather than
   deliberately matching each other.

   text-align:right is desktop/tablet-only — the 1024px breakpoint
   below explicitly sets it back to left, since a right-aligned
   paragraph reads badly once the module stacks to one column and the
   tagline sits directly above the (left-aligned) body copy below it. */
.hero-tagline {
	margin-top: clamp(19px, calc(1.25 * var(--vw)) + 15px, 24px);
	text-align: right;
}

/* p/.p-bold's own sitewide margin-bottom (typography.css — the same
   ~21px→27px clamp() every paragraph carries, meant to separate one
   paragraph from the next one after it) has nothing to separate here:
   .hero-tagline only ever wraps the one bold line, so that trailing
   margin was just escaping past it as unexplained extra space below
   the tagline instead of doing the job it's designed for elsewhere.
   Zeroed out specifically inside .hero-tagline rather than touching
   p/.p-bold's shared rule, which other modules with multiple
   paragraphs still depend on. */
.hero-tagline p {
	margin-bottom: 0;
}

/* Text-only hero (no image panel), so the "no min-height on image
   containers" rule doesn't apply. Real hero copy measures 420–650px
   tall depending on width, comfortably above this floor, so it never
   binds today; it's only a backstop so a hero with very little copy
   still reads as a page-opening module. */
.split-layout.hero {
	min-height: clamp(320px, calc(22 * var(--vw)), 420px);
}

/* SPACING BLOCKS */

/* 4px → 3px */
.space-4 {
	height: var(--space-4);
}

/* 8px → 6px */
.space-8 {
	height: var(--space-8);
}

/* 16px → 12px */
.space-16 {
	height: var(--space-16);
}

/* 24px → 19px */
.space-24 {
	height: var(--space-24);
}

/* 32px → 25px */
.space-32 {
	height: var(--space-32);
}

/* 40px → 31px */
.space-40 {
	height: var(--space-40);
}

/* 48px → 37px */
.space-48 {
	height: var(--space-48);
}

/* 56px → 44px */
.space-56 {
	height: var(--space-56);
}

/* 64px → 50px */
.space-64 {
	height: var(--space-64);
}

/* 72px → 56px */
.space-72 {
	height: var(--space-72);
}

/* 80px → 62px */
.space-80 {
	height: var(--space-80);
}

/* 120px → 93px */
.space-120 {
	height: var(--space-120);
}

/* 160px → 124px */
.space-160 {
	height: var(--space-160);
}

/* @MEDIA
   Site-wide breakpoint — the primary one, governing the mobile/desktop
   switch for most of the page (not just the 50/50 blocks below).
   Raised from 640px to 1024px so the 50/50 image/text blocks stack to
   full-width earlier, rather than squeezing the image into an
   increasingly narrow half-width column between ~640–1024px. The case
   study grid further down has its own media queries reusing this same
   1024px value (kept next to its base rules, later in the file, so the
   cascade order stays correct — a media-query rule only wins over an
   unconditional one of equal specificity if it comes AFTER it in the
   file). */

/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .split-layout {
		grid-template-columns: 1fr;
	}
html.bp-max-1024 .split-layout.case-study-teaser.case-study-teaser--30-70,
html.bp-max-1024 .split-layout.case-study-teaser.case-study-teaser--70-30 {
		grid-template-columns: 1fr;
	}
html.bp-max-1024 .split-layout.image-text--30-70,
html.bp-max-1024 .split-layout.image-text--70-30 {
		grid-template-columns: 1fr;
	}
html.bp-max-1024 .split-layout-media,
html.bp-max-1024 .split-layout-media-inset {
		aspect-ratio: 16 / 9;
	}
html.bp-max-1024 .split-layout-media-inset {
		margin: 0;
	}
html.bp-max-1024 .split-layout-text {
		padding-left: var(--content-padding);
		padding-right: var(--content-padding);
	}
html.bp-max-1024 .hero .split-layout-text:first-child {
		padding-bottom: 0;
	}
html.bp-max-1024 .hero .split-layout-text:last-child {
		padding-top: 0;
	}
html.bp-max-1024 .hero-tagline {
		margin-bottom: clamp(1.3125rem, calc(0.63 * var(--vw)) + 1.188rem, 1.6875rem);
		text-align: left;
	}
html.bp-max-1024 .logo-overlay,
html.bp-max-1024 .case-study-teaser-media .case-study-teaser-logo {
		--logo-fluid: 11.111%;
	}
html.bp-max-1024 .hero-banner-image--2 {
		grid-template-columns: 1fr;
		grid-template-rows: repeat(2, 1fr);
		aspect-ratio: 5 / 4;
	}
html.bp-max-1024 .hero-banner-image--3 {
		grid-template-columns: 1fr;
		grid-template-rows: repeat(3, 1fr);
		aspect-ratio: 5 / 6;
	}
html.bp-max-1024 .hero-banner-image--4 {
		grid-template-columns: repeat(2, 1fr);
		grid-template-rows: repeat(2, 1fr);
		aspect-ratio: 2 / 1;
	}
@media (max-width: 1024px) {
	html:not(.dfui) .split-layout {
		grid-template-columns: 1fr;
	}
	html:not(.dfui) .split-layout.case-study-teaser.case-study-teaser--30-70,
html:not(.dfui) .split-layout.case-study-teaser.case-study-teaser--70-30 {
		grid-template-columns: 1fr;
	}
	html:not(.dfui) .split-layout.image-text--30-70,
html:not(.dfui) .split-layout.image-text--70-30 {
		grid-template-columns: 1fr;
	}
	html:not(.dfui) .split-layout-media,
html:not(.dfui) .split-layout-media-inset {
		aspect-ratio: 16 / 9;
	}
	html:not(.dfui) .split-layout-media-inset {
		margin: 0;
	}
	html:not(.dfui) .split-layout-text {
		padding-left: var(--content-padding);
		padding-right: var(--content-padding);
	}
	html:not(.dfui) .hero .split-layout-text:first-child {
		padding-bottom: 0;
	}
	html:not(.dfui) .hero .split-layout-text:last-child {
		padding-top: 0;
	}
	html:not(.dfui) .hero-tagline {
		margin-bottom: clamp(1.3125rem, calc(0.63 * var(--vw)) + 1.188rem, 1.6875rem);
		text-align: left;
	}
	html:not(.dfui) .logo-overlay,
html:not(.dfui) .case-study-teaser-media .case-study-teaser-logo {
		--logo-fluid: 11.111%;
	}
	html:not(.dfui) .hero-banner-image--2 {
		grid-template-columns: 1fr;
		grid-template-rows: repeat(2, 1fr);
		aspect-ratio: 5 / 4;
	}
	html:not(.dfui) .hero-banner-image--3 {
		grid-template-columns: 1fr;
		grid-template-rows: repeat(3, 1fr);
		aspect-ratio: 5 / 6;
	}
	html:not(.dfui) .hero-banner-image--4 {
		grid-template-columns: repeat(2, 1fr);
		grid-template-rows: repeat(2, 1fr);
		aspect-ratio: 2 / 1;
	}
}

/* TEXT CONTAINERS MAX-WIDTH LAYOUT */
.text-block-container {
	/* No display declaration here on purpose. This class is combined
	   with .split-layout-text on every real case-study page's 50/50
	   module (class="split-layout-text text-block-container") — that's
	   the universal site convention. It used to say `display: block`,
	   which — having equal (single-class) specificity to
	   .split-layout-text's `display: flex` but appearing later in this
	   file — silently won the cascade and overrode it everywhere the
	   two classes were combined, which in turn made .split-layout-text's
	   `justify-content: center` a dead declaration site-wide (content
	   stayed top-aligned instead of vertically centred). Standalone
	   usage (.content-container > .text-block-container, no
	   .split-layout-text) is unaffected either way since this is always
	   a plain <div>, whose default display is already block. */
}

/* Standalone-only (:not(.split-layout-text), so the flex combo above is
   never touched by this rule): the same margin-collapse-through-padding
   bug .content-stroke-left's own padding fixes for itself also affects every plain
   "INTRODUCTION"-style .content-container > .text-block-container section
   site-wide — a trailing child's own margin-bottom has nothing to stop it
   collapsing through this box (no padding/border of its own) and escaping
   into .content-container's padding, inflating the section's height by
   that margin (~27px) beyond the intended 48px padding.
   display:flow-root establishes a new block-formatting context, which
   contains that margin inside this box exactly like overflow:hidden would
   — but, unlike overflow:hidden, it does NOT clip anything that visually
   extends past this box's own edge. That distinction matters here: several
   real pages (index.html, work.html, code.html, art.html) have an
   eyebrow chip directly inside a plain
   text-block-container, and its .chip-scroll-line is deliberately sized to
   reach .content-container's far edge — i.e. past text-block-container's
   own right edge, into the padding gutter (confirmed by measurement: the
   line's target right edge sits a full --content-padding beyond this box's
   right edge). overflow:hidden here would silently clip that chunk of every
   such line; flow-root fixes the height bug without touching it. */
.text-block-container:not(.split-layout-text) {
	display: flow-root;
}

.eyebrow-container,
.heading-container,
.subheading-container,
.bodycopy-container {
	max-width: 960px;
}

/* CHIP SCROLL LINE
   A 1px horizontal line that grows from the chip's right edge
   towards the content-container's right edge, driven by scroll.
   Progress is mapped to scaleX(0→1) for GPU-composited perf.

   Modifier classes (applied to the .chip element):
   ─────────────────────────────────────────────────
   .chip-static     — Suppresses the scroll-line animation entirely.
                       Use when a chip should remain a plain label.
   .chip-line-hero  — For chips already near the top of the page on load.
                       Line starts at 0 on arrival and animates faster,
                       covering only the distance from the chip's initial
                       position to the viewport top (instead of the full
                       viewport height). */
.eyebrow-container {
	position: relative;
}

.chip-scroll-line {
	position: absolute;
	top: 50%;
	height: 1px;
	background-color: var(--color-background-inverse);
	transform-origin: left center;
	transform: translateY(-50%) scaleX(0);
	pointer-events: none;
	will-change: transform;
}

/* ============================================================
   LOGO CAROUSEL
   Infinite-scroll carousel used on the homepage to display
   client/partner logos. Two duplicated tracks are animated
   in parallel to create a seamless loop. Speed is smoothly
   interpolated via JavaScript on hover/touch.
   ============================================================ */

/* Outer wrapper — clips the scrolling tracks and sits flush with no
   border-radius, matching the site's rectilinear aesthetic.

   Background and shadow are verbatim from Figma dev mode. The three
   layered 90deg gradients each darken only the left and right rims,
   stacking additively at the edges while the wide transparent centre
   zone keeps the interior pure white. The box-shadow provides the
   all-edge inset depth with no directional bias.

   Because the SVG images use mix-blend-mode: multiply (see below),
   their white fills become transparent and the gradients and shadows
   show through correctly — no pseudo-element hacks needed. */
.logo-carousel {
	position: relative;
	overflow: hidden;
	display: flex;
	width: 100%;
	padding: 0;

	background:
		linear-gradient(90deg,
			rgba(34, 34, 34, 0.08) 0%,
			rgba(34, 34, 34, 0.00) 2%,
			rgba(34, 34, 34, 0.00) 98%,
			rgba(34, 34, 34, 0.08) 100%),
		linear-gradient(90deg,
			rgba(34, 34, 34, 0.08) 0%,
			rgba(34, 34, 34, 0.00) 4%,
			rgba(34, 34, 34, 0.00) 96%,
			rgba(34, 34, 34, 0.08) 100%),
		linear-gradient(90deg,
			rgba(34, 34, 34, 0.08) 0%,
			rgba(34, 34, 34, 0.00) 16%,
			rgba(34, 34, 34, 0.00) 84%,
			rgba(34, 34, 34, 0.08) 100%),
		var(--color-background-page);

	box-shadow:
		inset 0 0 8px 0 rgba(0, 0, 0, 0.14),
		inset 0 0 16px 0 rgba(0, 0, 0, 0.14);
}

/* Each track is a single row of logos. Two identical tracks are
   rendered side-by-side; when the first finishes scrolling, the
   second is already in position — creating a seamless loop.
   Gap and padding-right are both 0 so SVGs sit flush edge-to-edge. */
.logo-carousel-track {
	display: flex;
	align-items: center;
	gap: 0;
	/* No space between logo SVGs */
	padding-right: 0;
	/* No trailing gap — seam relies on SVG widths being equal */
	flex-shrink: 0;
	animation: scroll-carousel 30s linear infinite;
	will-change: transform;
	/* Promotes to GPU layer for smooth animation */
}

/* mix-blend-mode: multiply makes white pixels in each SVG fully transparent
   against the container background, so the gradients and box-shadow show
   through as if the logos were sitting behind the effect layer.
   display: block eliminates the inline baseline gap beneath each image. */
.logo-carousel-track img {
	display: block;
	mix-blend-mode: multiply;
	/* White SVG fills become transparent; shadows show through */
	width: clamp(150px, calc(9.94 * var(--vw)) + 113px, 240px);
	height: clamp(150px, calc(9.94 * var(--vw)) + 113px, 240px);
	object-fit: contain;
	user-select: none;
	-webkit-user-drag: none;
}

/* Keyframe: translates each track exactly one full width to the left.
   Because the two tracks together equal 2× the content width, this
   produces a perfectly seamless infinite scroll. */
@keyframes scroll-carousel {
	from {
		transform: translateX(0);
	}

	to {
		transform: translateX(-100%);
	}
}

/* CASE STUDY TEASER LOGOS
   Client logos for the Case Study Teaser's media panel, one modifier
   per client, each sized to its own file's aspect ratio and centred
   with background-size:contain. The base filter:brightness(0) gives a
   solid silhouette; teasers override it inline (white silhouette, or
   none for full-colour artwork). */
.case-study-teaser-logo {
	display: block;
	background-repeat: no-repeat;
	background-position: center;
	background-size: contain;
	filter: brightness(0);
	transition: filter 0.3s ease;
}

.case-study-teaser-logo--google {
	--logo-aspect: 238 / 42;
	background-image: url("../image-assets/case-study-images/client_logo-google_cloud-inverted-238x42.svg");
}

/* Work's Google entry: full-colour "Google" wordmark, its own
   modifier (not a swap of --google above, which keeps the inverted
   Google Cloud lockup). */
.case-study-teaser-logo--google-colour {
	--logo-aspect: 256 / 82;
	background-image: url("../image-assets/case-study-images/client_logo-google-brand_color-256x82.svg");
}

.case-study-teaser-logo--stan {
	--logo-aspect: 145 / 44;
	background-image: url("../image-assets/case-study-images/stan-logo.svg");
}

.case-study-teaser-logo--inspace {
	--logo-aspect: 145 / 36;
	background-image: url("../image-assets/case-study-images/inspace-logo.svg");
}

.case-study-teaser-logo--ibm {
	--logo-aspect: 512 / 192;
	background-image: url("../image-assets/case-study-images/client_logo-ibm-brand_color-512x192.svg");
}

.case-study-teaser-logo--optus {
	--logo-aspect: 512 / 102;
	background-image: url("../image-assets/case-study-images/client_logo-optus-brand_color-512x102.svg");
}

.case-study-teaser-logo--datastax {
	--logo-aspect: 512 / 48;
	background-image: url("../image-assets/case-study-images/client_logo-datastax-brand_color-512x48.svg");
}

.case-study-teaser-logo--coca-cola {
	--logo-aspect: 512 / 161;
	background-image: url("../image-assets/case-study-images/client_logo-coca_cola-brand_color-512x161.svg");
}

.case-study-teaser-logo--android {
	--logo-aspect: 512 / 123;
	background-image: url("../image-assets/case-study-images/client_logo-android-brand_color-512x123.svg");
}

.case-study-teaser-logo--corporate-traveller {
	--logo-aspect: 512 / 115;
	background-image: url("../image-assets/case-study-images/client_logo-corporate_traveller-brand_color-512x115.svg");
}

.case-study-teaser-logo--cricket-for-climate {
	--logo-aspect: 516 / 147;
	background-image: url("../image-assets/case-study-images/client_logo-cricket_for_climate-monochrome-516x147.svg");
}

.case-study-teaser-logo--adidas {
	--logo-aspect: 512 / 119;
	background-image: url("../image-assets/case-study-images/client_logo-adidas-monochrome-512x119.svg");
}

/* The old Audi entry/logo asset has been fully retired — Audi is one of
   the brands now folded into the single Volkswagen Australia case
   study. */
.case-study-teaser-logo--volkswagen-australia {
	--logo-aspect: 128 / 128;
	background-image: url("../image-assets/case-study-images/client_logo-volkswagen_australia-monochrome-128x128.svg");
}

.case-study-teaser-logo--rocos {
	--logo-aspect: 256 / 35;
	background-image: url("../image-assets/case-study-images/client_logo-rocos-monochrome-256x35.svg");
}





/* Work's Inspace entry: new logo asset, white silhouette (inline
   filter in work.html) over the radial gradient lifted from the
   logo's own icon mark (#8FE9B3 → #005DCB, set inline there too, since
   no other Case Study Teaser needs a gradient background). Its own modifier
   (not a swap of --inspace above, which keeps the original white-
   silhouette/#0B68C9 treatment). */
.case-study-teaser-logo--inspace-colour {
	--logo-aspect: 212 / 52;
	background-image: url("../image-assets/case-study-images/client_logo-inspace-brand_color-212x52.svg");
}

/* FOOTER — same section-container/printer-marks-container/
   content-container structure as every other module (so it gets the
   crop marks and #F8F8F8 background for free), just with its own
   3-part content: logo, site nav, copyright.

   grid-template-columns: 1fr auto 1fr with the nav justify-self:center
   is what keeps the nav genuinely centred in the row regardless of the
   logo/copyright's own (different) widths — the two 1fr tracks stay
   equal no matter what sits in them, so the middle (auto-width) track
   is always the true horizontal centre, not just vertically centred
   between two mismatched side items. Stacks to a single column below
   the shared 1024px breakpoint, same as every other grid on the site,
   with a 24px gap — there's no existing rule tying a stacked gap to
   padding/type size to reuse here, so this is a plain fixed value for
   now, per spec. */
.footer-container {
	display: grid;
	grid-template-columns: 1fr auto 1fr;
	align-items: center;
	gap: 24px;
}

/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .footer-container {
		grid-template-columns: 1fr;
		justify-items: center;
	}
@media (max-width: 1024px) {
	html:not(.dfui) .footer-container {
		grid-template-columns: 1fr;
		justify-items: center;
	}
}

/* Fixed 40x40px per spec (not matched to the nav bar's own logo, which
   tracks --nav-pill-height instead — this one's size is spec'd
   directly). Same source SVG as the nav bar logo. */
.footer-logo-link {
	display: inline-flex;
	justify-self: start;
	width: 40px;
	height: 40px;
	background-image: url("../image-assets/logo/logo-32.svg");
	background-size: contain;
	background-position: center;
	background-repeat: no-repeat;
	text-decoration: none;
}

/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .footer-logo-link {
		justify-self: center;
	}
@media (max-width: 1024px) {
	html:not(.dfui) .footer-logo-link {
		justify-self: center;
	}
}

.footer-nav {
	justify-self: center;
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	justify-content: center;
	column-gap: 0.5em;
	row-gap: 8px;
}

/* Sized to match the nav bar pills' own text (same clamp() values as
   .nav-pill in buttons.css) — this is utility chrome like the nav, not
   body copy, so it reads at that smaller size rather than p's. */
.footer-nav-link,
.footer-nav-active,
.footer-nav-divider,
.footer-copyright {
	font-family: "Open Sans", sans-serif;
	font-size: clamp(0.656rem, calc(0.31 * var(--vw)) + 0.594rem, 0.844rem);
	line-height: 140%;
	color: var(--color-text-primary);
}

.footer-nav-link {
	font-weight: 300;
	text-decoration: none;
	transition: color 0.3s ease;
}

.footer-nav-link:hover,
.footer-nav-link:focus-visible {
	color: var(--color-text-secondary);
	outline: none;
}

/* The current page's own entry — a plain, non-interactive <span>, same
   pattern as .nav-pill-active up in the nav bar (no href, no hover),
   just bold instead of filled since this is plain text, not a pill. */
.footer-nav-active {
	font-weight: 700;
	cursor: default;
}

.footer-nav-divider {
	font-weight: 300;
	user-select: none;
}

.footer-copyright {
	justify-self: end;
	font-weight: 300;
	white-space: nowrap;
}

/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .footer-copyright {
		justify-self: center;
	}
@media (max-width: 1024px) {
	html:not(.dfui) .footer-copyright {
		justify-self: center;
	}
}

/* Corner registration marks only, no colour-bar/tint-bar swatches —
   scoped narrowly to the footer (not every .printer-marks-container
   site-wide) via this second class alongside the base one. Reuses the
   base rule's four corner-mark images/position/size values exactly,
   just with the colour-bar (left/right) and tint-bar (bottom) layers
   dropped. Same specificity as the base .printer-marks-container rule
   (one class each), so this has to come after it in the file to win —
   it does, since it's appended down here. */
.footer-printer-marks {
	background-position: left top, left bottom, right bottom, right top;
	background-size:
		clamp(32px, calc(2.5 * var(--vw)) + 24px, 56px) auto,
		clamp(32px, calc(2.5 * var(--vw)) + 24px, 56px) auto,
		clamp(32px, calc(2.5 * var(--vw)) + 24px, 56px) auto,
		clamp(32px, calc(2.5 * var(--vw)) + 24px, 56px) auto;
	background-image:
		url("../image-assets/module-container-assets/registration-marks-tl.svg"),
		url("../image-assets/module-container-assets/registration-marks-bl.svg"),
		url("../image-assets/module-container-assets/registration-marks-br.svg"),
		url("../image-assets/module-container-assets/registration-marks-tr.svg");
}

/* ==========================================================================
   COMPONENT LIBRARY — STAGING MODULES
   New modules built out on staging.html from the Oct 2026 Figma module-
   library export. Each block below is scoped to one (or a mirrored pair
   of) numbered module from that spec. Grouped here rather than spliced
   into the sections above so this whole batch stays easy to find/review
   together — nothing above this point was touched to add it.
   ========================================================================== */

/* RICH TEXT, TWO-COLUMN TEXT & CARD GRID — shared 16px vertical stroke, left-hand side,
   running the module's TRUE full height (content-container's own top
   edge to its own bottom edge), not just the height of this wrapper's
   own text content. Started as Rich Text's own .content-stroke-right
   (right-hand side) but moved to the left per the corrected mockup —
   renamed rather than kept alongside it, since nothing on the site
   used the right-hand version once this one replaced it everywhere.
   General enough to drop onto any direct content-container child that
   wants the frame: a plain .text-block-container (Rich Text and Card Grid) or
   a .two-column-text (Two-Column Text) — both already size themselves to
   content-container's available width on their own, so this only ever
   needs to add the stroke/bleed, nothing layout-specific to either host.

   Horizontal bleed: same trick as .split-layout-media-inset/.hero-frame
   elsewhere in this file — cancel content-container's own padding with
   an equal negative margin-left, then re-apply the full, unreduced
   --content-padding as this wrapper's own padding-left, same as
   .hero-frame does it (see that rule's own comment: "the stroke sits
   OUTSIDE the standard inset rather than eating into it"). The stroke
   is additional to that padding, not carved out of it, so the actual
   whitespace gap between the stroke and the text reads as the same
   --content-padding every other edge on the module uses.

   (This file used to subtract the border's own 16px from this padding
   instead — border-left(16px) + padding-left(--content-padding−16px)
   summing back to exactly --content-padding measured from the box's
   TRUE edge, treating the stroke itself as part of that inset's
   budget. Mathematically tidy, but it reads wrong once rendered: on a
   mobile viewport --content-padding is only 24px, so a stroke-eats-
   into-it gap shrank to just 8px of actual whitespace next to a 16px
   stroke — visibly tighter than the --content-padding gap on every
   other edge, confirmed by measuring the rendered gap in a screenshot
   rather than trusting the computed style alone. Reverted to match
   .hero-frame's approach instead, now that mobile rendering is what
   settles which of the two reads correctly.)

   Vertical bleed: the same bleed trick again, but on BOTH the top and
   bottom edges this time (.hero-frame only ever needed one). This is
   what actually fixes the "stroke falls short of the module's true top/
   bottom" bug: before, this wrapper had no margin/padding override on
   either vertical edge, so its own box (and the border riding on it)
   was only ever as tall as its own content — stopping well short of
   content-container's real top and bottom edges, which sit a further
   --content-padding away on each side. Bleeding both edges out to meet
   them is what makes the border actually run content-container's full
   height regardless of how little or much text is inside.

   That non-zero padding-top/padding-bottom also incidentally fixes the
   same margin-collapse bug the old right-hand version used
   overflow:hidden for (a trailing child's own margin-bottom escaping
   through zero padding into content-container's padding, inflating the
   module's apparent height by that margin) — margins stop collapsing
   through an edge once that edge has non-zero padding, so this needs
   no overflow declaration of its own. That's deliberate, not an
   oversight: unlike the bled-flush right edge the old version had,
   this class's right edge is left exactly where it always was, and a
   chip-scroll-line (see that class's own comment, and main.js) is
   deliberately sized to grow past a plain .text-block-container's own
   right edge into content-container's right-hand gutter — overflow:
   hidden here would silently clip that line every time this class is
   paired with a chip that has one. */
.content-stroke-left {
	position: relative;
	box-sizing: border-box;
	margin-left: calc(-1 * var(--content-padding));
	padding-left: var(--content-padding);
	margin-top: calc(-1 * var(--content-padding));
	padding-top: var(--content-padding);
	margin-bottom: calc(-1 * var(--content-padding));
	padding-bottom: var(--content-padding);
	border-left: 16px solid var(--color-border-strong);
}

/* PULL QUOTE + TEXT (quote left / quote right) — "pull quote"
   modules: a large heading sits in an L-shaped bracket — a 16px stroke
   running the full height of one edge, plus a second 16px stroke along
   the module's true bottom edge, extending 50% of that column's width
   in from the same side. --left is Pull Quote + Text, quote left (heading first, bracket on
   the left); --right is the quote-right mirror (heading last, bracket on
   the right) — which edge the stroke sits on is the only difference,
   so the HTML is free to put the heading column first or last either
   way, just matching the modifier to it.

   Applied directly to .split-layout-text (not a separate inner wrapper
   like .hero-frame needed): .split-layout-text is already a grid item
   stretched to the row's full height by .split-layout's
   default align-items:stretch, and its own edge already sits flush
   with the module's true left/right edge (no bleed needed, unlike
   .hero-frame which was nested a layer deeper). So border-left/-right
   here already both reaches full height AND sits at the true edge for
   free — padding-left/-right needs no bleed-cancelling either, since
   .split-layout-text's base rule already sets padding: --content-padding
   on all four sides. Left at that plain --content-padding rather than
   clawed back by the border's own 16px (an earlier version did reduce
   it, matching .content-stroke-left's own now-reverted approach — see
   that rule's comment for why both were reverted together): the stroke
   sits OUTSIDE the heading's standard inset, same as .hero-frame, so
   the actual whitespace gap next to it reads as a full --content-padding
   rather than visibly thinner than the gap on every other edge.

   The bottom stroke is an absolutely-positioned ::after rather than a
   border, since a border can only ever run a box's full edge — this
   needs to stop at 50% width. bottom:0/left:0/right:0 on a
   position:relative .split-layout-text resolve against its padding box
   (i.e. the true inside edge of its border), so this sits flush with
   the module's real bottom edge regardless of the column's own
   padding-bottom, exactly like the vertical stroke needs no separate
   bleed to reach the true edge.

   Two classes (not three, e.g. not combined with .pullquote-module)
   because specificity needs to beat the shared 1024px breakpoint's own
   .split-layout-text padding-left/-right reset below — two classes
   outranks that rule's one, so no extra breakpoint override is needed
   the way the 30/70 variants above needed one for grid-template-columns.

   justify-content: flex-start is needed too, overriding the base
   .split-layout-text rule's own justify-content:center — without it, the
   heading (this column's only child) sits vertically centred in the
   row's full stretched height, so its gap to the TOP of the bracket
   grows past --content-padding (to however much centring leaves above
   it) while its gap to the LEFT of the bracket stays pinned at exactly
   --content-padding. The two insets read as visibly unequal — the
   heading just isn't hugging the bracket's corner the way an "L-shaped
   bracket" implies it should. Same fix .hero's own
   .split-layout-text override already makes, for the same reason, just
   needed separately here since nothing ties this class to that one. */
.split-layout-text.pull-quote-left {
	position: relative;
	justify-content: flex-start;
	border-left: 16px solid var(--color-border-strong);
	padding-left: var(--content-padding);
}

.split-layout-text.pull-quote-left::after {
	content: "";
	position: absolute;
	left: 0;
	bottom: 0;
	width: 50%;
	height: 16px;
	background-color: var(--color-background-inverse);
}

.split-layout-text.pull-quote-right {
	position: relative;
	justify-content: flex-start;
	border-right: 16px solid var(--color-border-strong);
	padding-right: var(--content-padding);
}

.split-layout-text.pull-quote-right::after {
	content: "";
	position: absolute;
	right: 0;
	bottom: 0;
	width: 50%;
	height: 16px;
	background-color: var(--color-background-inverse);
}

/* PULL QUOTE + TEXT — centred quote (.pull-quote-text--centered)
   Optional modifier on the .split-layout: the quote sits vertically
   centred against the text column's full height instead of hugging
   the bracket's top corner. Use it when the text column is much taller
   than the quote (first used on google-summits.html). Three classes, so
   it beats the two-class justify-content:flex-start above. Once the
   columns stack below 1024px there's no spare height, so it has no
   visible effect there. */
.pull-quote-text--centered .split-layout-text.pull-quote-left,
.pull-quote-text--centered .split-layout-text.pull-quote-right {
	justify-content: center;
}

/* TWO-COLUMN TEXT ONLY (.two-column-text-only)
   Two columns of ordinary text blocks under the same L-shaped bracket
   as Pull Quote + Text: the 16px stroke down the left edge (from
   .content-stroke-left, used alongside this class) plus a 16px bar
   along the bottom.

   Two ways to use it, set by the markup only:
   - Full-width heading: a .heading-container with
     .two-column-text-only__heading as the first child spans both
     columns. The two .text-block-container columns sit top-aligned
     beneath it.
   - Column headings: no spanning heading. Each column starts with its
     own heading (h4 on google-summits.html).
   Either column may also open with a subheading, as long as the two
   columns still start level with each other.

   The gap between the columns is 2 x --content-padding, the same
   gutter as a 50/50 .split-layout (each half carries its own padding
   there), so text lines up with the 50/50 modules above and below.
   The bottom bar is 25% of the module, i.e. half the left column, the
   same length as the bar on a Pull Quote + Text module.

   Below 1024px the second column wraps back under the first, as one
   continuous column (row-gap 0, the paragraphs' own margins do the
   spacing), and the bar goes to 50%, the same as a stacked Pull Quote
   + Text. */
.two-column-text-only {
	display: grid;
	grid-template-columns: 1fr 1fr;
	column-gap: calc(var(--content-padding) * 2);
	row-gap: 0;
	align-items: start;
}

.two-column-text-only__heading {
	grid-column: 1 / -1;
}

.two-column-text-only::after {
	content: "";
	position: absolute;
	left: 0;
	bottom: 0;
	/* 25% of the module's full width. The box itself stops short of the
	   module's right edge by --content-padding, so add that back first. */
	width: calc((100% + var(--content-padding)) / 4);
	height: 16px;
	background-color: var(--color-background-inverse);
}

/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .two-column-text-only {
		grid-template-columns: 1fr;
	}
html.bp-max-1024 .two-column-text-only::after {
		width: 50%;
	}
@media (max-width: 1024px) {
	html:not(.dfui) .two-column-text-only {
		grid-template-columns: 1fr;
	}
	html:not(.dfui) .two-column-text-only::after {
		width: 50%;
	}
}

/* CARD GRID — intro text plus a three-up
   card grid, #F8F8F8 cards with a 1px #333333 dashed outline (4px dash /
   4px gap, the same line as the module dividers) at calc(--content-padding * 0.75)
   padding/gap. The homepage's REPOSITORY INDEX section (see above) now
   runs on this same grid — it used to have its own separate
   .repo-index-grid/.repo-index-item card classes, which were retired
   once this module covered the same shape. */
.cards {
	display: grid;
	grid-template-columns: repeat(3, 1fr);
	gap: calc(var(--content-padding) * 0.75);
}

/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .cards {
		grid-template-columns: 1fr;
	}
@media (max-width: 1024px) {
	html:not(.dfui) .cards {
		grid-template-columns: 1fr;
	}
}

.card {
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	background-color: var(--color-background-surface);
	/* 1px #333333 dashed outline, 4px dash / 4px gap: the same weight
	   and dash as the module divider lines (section-divider.svg). CSS's own
	   `border: dashed` doesn't let you set the dash length, so each edge
	   is drawn as a repeating gradient strip instead (top, bottom, left,
	   right). Each edge starts with a dash at its own corner, so the
	   top-left corners always meet cleanly. Being background layers,
	   they sit inside the box and add nothing to its size, and they're
	   unaffected by the hover background-color change below. */
	background-image:
		repeating-linear-gradient(90deg, var(--color-border-strong) 0 4px, transparent 4px 8px),
		repeating-linear-gradient(90deg, var(--color-border-strong) 0 4px, transparent 4px 8px),
		repeating-linear-gradient(180deg, var(--color-border-strong) 0 4px, transparent 4px 8px),
		repeating-linear-gradient(180deg, var(--color-border-strong) 0 4px, transparent 4px 8px);
	background-size: 100% 1px, 100% 1px, 1px 100%, 1px 100%;
	background-position: left top, left bottom, left top, right top;
	background-repeat: no-repeat;
	padding: calc(var(--content-padding) * 0.75);
	transition: background-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease;
}

/* Markup uses a real <h4> for this, which already inherits Nunito
   Sans/weight 900 from the sitewide h1–h6 rule in typography.css —
   font-family is repeated explicitly here anyway (rather than relying
   on that inheritance alone) since the first build of this card used a
   plain, untagged <div> here with no font-family of its own, which
   silently fell back to the browser's default serif font instead of
   the site's. */
.card-heading {
	/* An icon is optional (plain text-only headings are unaffected
	   either way) — when one's present (see REPOSITORY INDEX's cards)
	   it sits inline with the label instead of stacking above it. */
	display: flex;
	align-items: center;
	gap: 10px;
	font-family: "Nunito Sans", sans-serif;
	font-weight: 700;
	font-size: clamp(1.166rem, calc(0.56 * var(--vw)) + 1.055rem, 1.5rem);
	margin-bottom: clamp(0.933rem, calc(0.45 * var(--vw)) + 0.843rem, 1.2rem);
}

.card-body {
	margin-bottom: clamp(1.3125rem, calc(0.63 * var(--vw)) + 1.188rem, 1.6875rem);
}

/* Button reuses .nav-pill rather than .btn-secondary — same transparent
   -fill / 1px-stroke / liquid hover-fill component as the nav bar's own
   buttons (see NAV PILLS in buttons.css), per feedback that this card's
   CTA should match the nav pills exactly, hover state included. That
   also happens to be what "no fill at all in the default state, so the
   card's own background always shows through" needs: .nav-pill's resting
   background-color is already `transparent`, not a solid white the way
   .btn-secondary's is — important here specifically because this card's
   own background changes on hover (#F8F8F8 → #FFFFFF, see .card:hover
   below), so a solid-filled button would otherwise mismatch the card
   behind it in one of the two states. */
.card .nav-pill {
	margin-top: auto;
	align-self: flex-start;
}

/* Hover: the card lightens from #F8F8F8 to white and lifts slightly, a
   soft layered shadow standing in for a single light source centred
   above the row. Three very-low-opacity layers (a tight contact
   shadow, a mid-range layer, and a soft far falloff) read as a more
   realistic ambient shadow than one flat box-shadow would — real cast
   shadows fall off in bands like this rather than a single hard edge.
   The horizontal offset on each layer is what gives the "light source
   above the centre of the page" effect: .card--left's shadow leans
   left+down, --right leans right+down, and --center stays straight
   down, as if all three were lit from the same point above the row's
   middle. Assign the modifier by the card's position in the row (first
   /middle/last), not automatically via nth-child, since the grid
   becomes a single column below 1024px and position-in-row stops being
   meaningful there anyway (see the override below). */
.card:hover {
	background-color: var(--color-background-surface-raised);
	transform: translateY(-4px);
}

.card--left:hover {
	box-shadow:
		-1px 2px 3px rgba(0, 0, 0, 0.05),
		-3px 7px 9px rgba(0, 0, 0, 0.07),
		-7px 15px 20px rgba(0, 0, 0, 0.06);
}

.card--center:hover {
	box-shadow:
		0px 2px 3px rgba(0, 0, 0, 0.05),
		0px 7px 9px rgba(0, 0, 0, 0.07),
		0px 15px 20px rgba(0, 0, 0, 0.06);
}

.card--right:hover {
	box-shadow:
		1px 2px 3px rgba(0, 0, 0, 0.05),
		3px 7px 9px rgba(0, 0, 0, 0.07),
		7px 15px 20px rgba(0, 0, 0, 0.06);
}

/* Below 1024px the cards stack to a single column (see .cards
   above) — with no row left to spread a centred light source across,
   every card collapses to the same straight-down shadow rather than
   keeping an arbitrary left/right lean that no longer means anything
   once there's only one card per row. */
/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .card--left:hover,
html.bp-max-1024 .card--center:hover,
html.bp-max-1024 .card--right:hover {
		box-shadow:
			0px 2px 3px rgba(0, 0, 0, 0.05),
			0px 7px 9px rgba(0, 0, 0, 0.07),
			0px 15px 20px rgba(0, 0, 0, 0.06);
	}
@media (max-width: 1024px) {
	html:not(.dfui) .card--left:hover,
html:not(.dfui) .card--center:hover,
html:not(.dfui) .card--right:hover {
		box-shadow:
			0px 2px 3px rgba(0, 0, 0, 0.05),
			0px 7px 9px rgba(0, 0, 0, 0.07),
			0px 15px 20px rgba(0, 0, 0, 0.06);
	}
}

/* Cards are a modular add-on (see MODULE KIT on staging.html): inside a
   narrow column (a 50/50, 30/70 or 70/30 text column, or either column
   of Two-Column Text) three-up doesn't fit, so they stack to a single column
   there at every width. */
.split-layout-text .cards,
.two-column-text .cards {
	grid-template-columns: 1fr;
}

/* MODULE LABEL — staging.html only: the module's name, shown just above
   each module so every one (image-only modules included) can be
   identified at a glance. Inset to line up with the module's left edge. */
.module-label {
	box-sizing: border-box;
	max-width: 1920px;
	margin: 0 auto 8px;
	padding-inline: var(--module-inset);
	font-family: Consolas, "Courier New", monospace;
	font-size: 0.75rem;
	font-weight: 400;
	letter-spacing: 0.06em;
	text-transform: uppercase;
	color: var(--color-text-secondary);
}

/* Jump targets for the staging index: clear the fixed nav. */
.module-label[id] {
	scroll-margin-top: calc(var(--topbar-height, 100px) + 16px);
}

/* STAGING INDEX — jump links at the top of staging.html only, one per
   module label (+ the module kit). Flows into columns as width allows. */
.staging-index {
	margin: var(--space-24) 0 0;
	padding: 0 0 0 1.5em;
	columns: 3 18em;
	column-gap: var(--space-40, 40px);
	font-family: "Nunito Sans", sans-serif;
	font-size: clamp(0.656rem, calc(0.31 * var(--vw)) + 0.594rem, 0.844rem);
	line-height: 160%;
	color: var(--color-text-secondary);
}

.staging-index li {
	break-inside: avoid;
	padding: 2px 0;
}

.staging-index a {
	color: var(--color-text-primary);
	text-decoration: underline;
	text-decoration-color: var(--color-border-default);
	text-underline-offset: 3px;
}

.staging-index a:hover,
.staging-index a:focus-visible {
	text-decoration-color: currentColor;
}

/* MODULE KIT — documentation table at the top of staging.html only. */
.module-kit-table {
	width: 100%;
	border-collapse: collapse;
}

.module-kit-table th,
.module-kit-table td {
	text-align: left;
	vertical-align: top;
	padding: 12px 16px 12px 0;
	border-bottom: 1px solid var(--color-border-default);
}

.module-kit-table th {
	border-bottom: 1px solid var(--color-border-strong);
}

.module-kit-table td:first-child {
	width: 16%;
}

.module-kit-table td:nth-child(2) {
	width: 42%;
}

.module-kit-table td p {
	margin-bottom: 0;
}

.module-kit-code {
	margin: 0;
	padding: 8px 12px;
	background-color: var(--color-background-surface);
	font-family: Consolas, "Courier New", monospace;
	font-size: 0.75rem;
	line-height: 150%;
	color: var(--color-text-primary);
	white-space: pre-wrap;
	overflow-wrap: anywhere;
}

/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .module-kit-table,
html.bp-max-1024 .module-kit-table tbody,
html.bp-max-1024 .module-kit-table tr,
html.bp-max-1024 .module-kit-table td {
		display: block;
		width: 100% !important;
	}
html.bp-max-1024 .module-kit-table thead {
		display: none;
	}
html.bp-max-1024 .module-kit-table tr {
		border-bottom: 1px solid var(--color-border-default);
		padding: 12px 0;
	}
html.bp-max-1024 .module-kit-table td {
		border: none;
		padding: 4px 0;
	}
@media (max-width: 1024px) {
	html:not(.dfui) .module-kit-table,
html:not(.dfui) .module-kit-table tbody,
html:not(.dfui) .module-kit-table tr,
html:not(.dfui) .module-kit-table td {
		display: block;
		width: 100% !important;
	}
	html:not(.dfui) .module-kit-table thead {
		display: none;
	}
	html:not(.dfui) .module-kit-table tr {
		border-bottom: 1px solid var(--color-border-default);
		padding: 12px 0;
	}
	html:not(.dfui) .module-kit-table td {
		border: none;
		padding: 4px 0;
	}
}

/* IMAGE GALLERY — uniform 1:1 image grids (4×2, 4×1, 3×2, 3×1, 2×2,
   2×1). One generic system handles all six: pick a
   column count with a modifier class, drop in that many
   .image-gallery-item children, and the row count falls out automatically
   (4 items in a 4-col grid reads as one row, 8 reads as two, etc) — no
   separate class per shape needed.

   Cells are forced square with the CSS aspect-ratio property, the same
   modern, no-markup-hack technique already used elsewhere in this file
   (.split-layout-media's breakpoint rule) —
   deliberately NOT the padding-top percentage hack in the older
   image-modules.css, which predates aspect-ratio support and needed an
   inner absolutely-positioned element to work; aspect-ratio gets the
   same fluid-at-any-width result with a single declaration and no
   extra wrapper markup, so image-modules.css is left as unused legacy
   rather than reused here.

   Bleeds past content-container's own padding the same way
   .split-layout does (equal negative margin), so every grid's
   outer edge lines up with every other full-bleed module on the page.
   gap:0 since the Figma reference has cells sitting flush edge-to-edge
   with no visible gutter between them — only the placeholder colour
   change marks each cell. */
.image-gallery {
	display: grid;
	gap: 0;
	margin: calc(-1 * var(--content-padding));
	overflow: hidden;
}

.image-gallery-item {
	background-size: cover;
	background-position: center;
	aspect-ratio: 1 / 1;
}

.image-gallery--cols-4 {
	grid-template-columns: repeat(4, 1fr);
}

.image-gallery--cols-3 {
	grid-template-columns: repeat(3, 1fr);
}

.image-gallery--cols-2 {
	grid-template-columns: repeat(2, 1fr);
}

/* 4- and 3-column grids step down to 2 columns below 1024px (the
   site's one shared breakpoint) rather than collapsing all the way to
   a single column — these are decorative image walls, not text, so
   keeping two squares per row reads better than one very tall single
   file of cells. A 3-item row (3x1) ends up with an orphan cell on its
   own final row at 2 columns, which is an acceptable trade-off for
   staying consistent with every other grid here rather than carving
   out a one-off exception. The 2-column grids are already at their
   mobile shape, so they're intentionally untouched here. */
/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .image-gallery--cols-4,
html.bp-max-1024 .image-gallery--cols-3 {
		grid-template-columns: repeat(2, 1fr);
	}
@media (max-width: 1024px) {
	html:not(.dfui) .image-gallery--cols-4,
html:not(.dfui) .image-gallery--cols-3 {
		grid-template-columns: repeat(2, 1fr);
	}
}

/* MOSAIC GALLERY (bento grid) — mixed-size 1:1 image grid, fully
   customisable. Rather
   than hard-coding the specific 2-big-cells-over-4-small-cells layout
   from the Figma reference as its own one-off class, this is a general
   system: set --grid-cols / --grid-rows on .mosaic-gallery to
   define the grid's overall unit shape (4 columns × 3 row-units
   matches the reference exactly), then give each .mosaic-gallery-item an
   inline --col-span / --row-span to say how many unit cells, in each
   direction, it should occupy.

   The trick that keeps every unit cell a true 1:1 square at any
   viewport width, with no fixed pixel heights or JS: the grid's own
   aspect-ratio is locked to (--grid-cols / --grid-rows), and
   grid-template-rows splits that height into exactly --grid-rows equal
   1fr tracks. For an N-col/M-row grid with that aspect-ratio, each
   column's width and each row's height both work out to the
   container's width ÷ N — i.e. identical — so a 1×1 span is always
   square, and a 2×2 span is always a perfect square at exactly twice
   the size. Changing a cell's footprint is then just editing its two
   custom properties; changing the grid's overall shape (per the brief,
   altering the aspect ratio of the cells/layout) is just editing
   --grid-cols/--grid-rows once on the grid itself — no new CSS rule
   needed either way. */
.mosaic-gallery {
	display: grid;
	grid-template-columns: repeat(var(--grid-cols, 4), 1fr);
	grid-template-rows: repeat(var(--grid-rows, 3), 1fr);
	aspect-ratio: var(--grid-cols, 4) / var(--grid-rows, 3);
	gap: 0;
	margin: calc(-1 * var(--content-padding));
	overflow: hidden;
}

.mosaic-gallery-item {
	background-size: cover;
	background-position: center;
	grid-column: span var(--col-span, 1);
	grid-row: span var(--row-span, 1);
}

/* Below 1024px a mosaic's deliberately uneven cell sizes stop being
   legible at narrow column widths, so it collapses to the same plain
   2-column grid of uniform squares every uniform .image-gallery variant
   above uses — every item's span resets to 1×1 and gets its own
   aspect-ratio back (the grid's own aspect-ratio, sized for the
   unstacked unit-cell shape, no longer applies once the columns/rows
   are reset). */
/* breakpoint (max-width: 1024px): applied by class from js/ui-prefs.js (effective
   width = window width / UI scale); the @media copy is the no-JS fallback. */
html.bp-max-1024 .mosaic-gallery {
		grid-template-columns: repeat(2, 1fr);
		grid-template-rows: none;
		aspect-ratio: auto;
	}
html.bp-max-1024 .mosaic-gallery-item {
		grid-column: span 1;
		grid-row: span 1;
		aspect-ratio: 1 / 1;
	}
@media (max-width: 1024px) {
	html:not(.dfui) .mosaic-gallery {
		grid-template-columns: repeat(2, 1fr);
		grid-template-rows: none;
		aspect-ratio: auto;
	}
	html:not(.dfui) .mosaic-gallery-item {
		grid-column: span 1;
		grid-row: span 1;
		aspect-ratio: 1 / 1;
	}
}

/* Tiled placeholder texture for empty/unfinished image slots (real
   content not supplied yet) — applied alongside .split-layout-media,
   .full-width-image, .image-gallery-item and .mosaic-gallery-item,
   never as part of their own base rules. Those base classes default to
   background-size: cover for real photos, which is the opposite of what
   a repeating tile needs (auto size, repeat), so this stays a separate
   additive class rather than changing their shared defaults — that also
   keeps it from silently affecting any future real photo dropped onto
   the same classes. Elements whose background-color is only a loading
   fallback for a data-sequence-driven animation (frame-sequence/
   svg-animation canvases injected by scroll-sequence.js) are genuine
   content, not placeholders, and never get this class. */
.placeholder-media {
	background-image: url("../image-assets/placeholders/lego.png");
	background-repeat: repeat;
	background-size: auto;
}

/* GRID PAPER
   The site's background grid for 3D scenes and their loading screens
   (Figma: 01.02.00 system library, tokens page, "new_grid" / Grid
   component). Minor lines every 8px at 0.5px, major lines every 32px
   at 1px, both in the subtle border colour, on the page background.
   Every layer uses the same 32px tile (the minor lines are a repeating
   gradient inside it), so minor and major lines always line up and the
   grid can be centred on its box. Not used by the EPA canvas, which
   keeps its own blueprint mesh.

     <div class="grid-paper"> ... </div>

   Override --grid-paper-line / --grid-paper-bg on the element to
   recolour it (both follow the theme tokens, so dark mode comes free). */
.grid-paper {
	--grid-paper-line: var(--color-border-subtle);
	--grid-paper-bg: var(--color-background-page);
	background-color: var(--grid-paper-bg);
	background-image:
		linear-gradient(var(--grid-paper-line) 1px, transparent 1px),
		linear-gradient(90deg, var(--grid-paper-line) 1px, transparent 1px),
		repeating-linear-gradient(var(--grid-paper-line) 0 0.5px, transparent 0.5px 8px),
		repeating-linear-gradient(90deg, var(--grid-paper-line) 0 0.5px, transparent 0.5px 8px);
	background-size: 32px 32px;
	background-position: center center;
	background-repeat: repeat;
}

/* COMPONENT LAYOUT END */