:root {
  --color-page-background: #060606;
  --color-surface-background: #0d0d0d;
  --color-elevated-surface: #141414;
  --color-text-primary: #eee8df;
  --color-text-secondary: #bdb6ae;
  --color-text-muted: #807b75;
  --color-accent: #ef4a48;
  --color-accent-secondary: #ef4a48;
  --color-accent-contrast: #060606;
  --color-border: #2c2a29;
  --color-image-overlay: rgba(6,6,6,0.52);
  --color-focus: #ff6663;
  --font-display: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-body: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-metadata: "Space Mono", ui-monospace, "Cascadia Mono", "Segoe UI Mono", monospace;
  --font-signature: "Caveat", "Segoe Script", cursive;
  --base-font-size: 16px;
  --display-scale: 1;
  --body-line-height: 1.55;
  --metadata-letter-spacing: 0.12em;
  --content-max-width: 92.5rem;
  --desktop-gutter: 2.75rem;
  --mobile-gutter: 1.125rem;
  --section-spacing: clamp(2.75rem, 1.25rem + 6vw, 5rem);
  --project-gap: 2.5rem;
  --normal-grid-gap: 1.75rem;
  --header-height: 4.5rem;
  --step--1: clamp(0.78rem, 0.76rem + 0.1vw, 0.85rem);
  --step-0: clamp(1rem, 0.97rem + 0.15vw, 1.0625rem);
  --step-1: clamp(1.15rem, 1.05rem + 0.5vw, 1.4rem);
  --step-2: clamp(1.5rem, 0.825rem + 1.8vw, 2.4rem);
  --step-3: clamp(1.85rem, 1.018rem + 2.6vw, 3.2rem);
  --step-4: clamp(2.2rem, 1.21rem + 4.5vw, 4.4rem);
  --space-2xs: 0.375rem;
  --space-xs: 0.625rem;
  --space-s: 1rem;
  --space-m: 1.5rem;
  --space-l: clamp(1.75rem, 1.35rem + 1.5vw, 2.5rem);
  --space-xl: clamp(2.5rem, 1.85rem + 2.4vw, 4rem);
  --media-aspect-ratio: 16/9;
  --hero-overlay-strength: 0.48;
  --border-radius: 0px;
  --thumbnail-radius: 0px;
  --object-fit: cover;
  --shadow: 0 16px 48px rgba(0,0,0,.34);
  --transition-duration: 520ms;
  --slideshow-fade-duration: 1600ms;
  --slideshow-hold-duration: 6500ms;
  --reveal-distance: 12px;
  --reveal-duration: 560ms;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-size: var(--base-font-size);
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background-color: var(--color-page-background);
  color: var(--color-text-primary);
  font-family: var(--font-body);
  /* Step 0 starts at the root size and only grows on a wide screen, so a phone reads
     exactly what it read before the scale existed. */
  font-size: var(--step-0);
  line-height: var(--body-line-height);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img,
picture,
svg,
video {
  display: block;
  max-width: 100%;
}

a {
  color: var(--color-accent);
  text-underline-offset: 0.18em;
}

/* Spec §38: a visible focus ring built from the theme's focus token. */
:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 3px;
}

.pb-skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  padding: 0.75rem 1rem;
  background: var(--color-elevated-surface);
  color: var(--color-text-primary);
  z-index: 100;
}

.pb-skip-link:focus {
  left: 0;
}

.pb-container {
  width: 100%;
  max-width: var(--content-max-width);
  margin-inline: auto;
  padding-inline: var(--mobile-gutter);
}

.pb-section {
  padding-block: var(--section-spacing);
}

/* Spec §15.8: fluid display sizes, balanced headings, pretty paragraphs. The sizes
   come from the shared scale (porting brief §6.4); steps 4 and 2 are the very clamps
   these two rules carried before the scale existed. */
.pb-display {
  font-family: var(--font-display);
  font-weight: 400;
  line-height: 1.04;
  margin: 0;
  text-wrap: balance;
  font-size: var(--step-4);
}

.pb-display--section {
  font-size: var(--step-2);
}

p {
  text-wrap: pretty;
}

/*
 * Emphasis is weight **and** colour (owner request, 2026-08-05): a bold word in a
 * paragraph of secondary text was doing its work with weight alone, and on a dark theme
 * that is a small difference. The primary ink is the page's brightest text token, so the
 * phrase lifts out of the prose around it without introducing a colour of its own.
 *
 * An element rule rather than a list of field classes, and it is safe to be one: the
 * rich-text tokenizer is the only thing in this renderer that emits a bold tag, so every
 * match is a word the user marked. A field that gains the markup later inherits this
 * without a line. The weight is stated because the colour would otherwise be the only
 * signal wherever a theme bundles a single weight of its body font.
 */
b,
strong {
  font-weight: 700;
  color: var(--color-text-primary);
}

.pb-metadata {
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  text-transform: uppercase;
  color: var(--color-text-muted);
  margin: 0;
}

.pb-lead {
  color: var(--color-text-secondary);
  max-width: 46ch;
}

/* Spec §15.8: year columns in the credits list align. */
.pb-tabular {
  font-variant-numeric: tabular-nums;
}

.pb-media {
  /* The box is a div almost everywhere and a span inside a button, which may hold
     phrasing content only. An inline box ignores aspect-ratio, so the thumbnails came
     out at their intrinsic proportions and the row read as the masonry §21.2 bans. */
  display: block;
  position: relative;
  overflow: hidden;
  border-radius: var(--border-radius);
  /* Spec §33.4.6: the dominant color is painted behind every image. */
  background-color: var(--pb-dominant-color, var(--color-surface-background));
}

.pb-media > img {
  width: 100%;
  height: 100%;
  object-fit: var(--object-fit);
  object-position: var(--pb-focal-x, 50%) var(--pb-focal-y, 45%);
}

.pb-contact-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs) var(--space-m);
}

/* Only present when the theme shows contact icons (theme.contactIcons = show). */
.pb-contact-item--icon {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
}

.pb-contact__icon {
  flex: none;
  width: 1em;
  height: 1em;
}

.pb-footer {
  border-top: 1px solid var(--color-border);
  padding-block: var(--section-spacing);
  color: var(--color-text-secondary);
}

@media (min-width: 45rem) {
  .pb-container {
    padding-inline: var(--desktop-gutter);
  }
}

/*
 * Spec §33.3, §7.2: scroll-reveal hides nothing until the runtime says it has taken
 * ownership by setting data-pb-reveal="on" on the root.
 *
 * The gate is the whole point. A bare .pb-reveal rule would mean opacity: 0 for a
 * visitor with JS disabled — and for this release, whose runtime does not install the
 * observer until M4 — so every project on the page would be invisible with nothing to
 * reveal it. Marking up the elements early is fine; hiding them early is not.
 *
 * (Everything from the return below is one template literal: a backtick here, even in a
 * comment, ends the string.)
 */
[data-pb-reveal='on'] .pb-reveal {
  opacity: 0;
  transform: translateY(var(--reveal-distance));
  transition:
    opacity var(--reveal-duration) ease,
    transform var(--reveal-duration) ease;
}

[data-pb-reveal='on'] .pb-reveal.is-visible {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    /* The frame cross-fade hides finished panes via a *delayed* visibility flip;
       an uncrushed delay would leave an invisible pane focusable for the length of
       a fade that no longer exists. */
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }

  [data-pb-reveal='on'] .pb-reveal {
    opacity: 1;
    transform: none;
  }
}

/*
 * §33.3 "so it can never hide content" has one more medium than the screen: print
 * cannot scroll, so the observer can never reveal what the visitor did not scroll
 * past — and a producer saving the page as a PDF got blank gaps for every project
 * below the fold. Printing shows everything, always.
 */
@media print {
  [data-pb-reveal='on'] .pb-reveal {
    opacity: 1;
    transform: none;
  }

  /* Low-risk print preparation (brief §4.C.8): on paper a video item is its image,
     so the play mark goes. The real PDF export is a later, separate feature. */
  .pb-video__play {
    display: none;
  }
}

/* Header — spec §18, §31 (compact menu, no JS panel before M4). */
.pb-header {
  border-bottom: 1px solid var(--color-border);
}

.pb-header__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2xs) var(--space-m);
  min-height: var(--header-height);
  padding-block: 0.6rem;
}

.pb-header__logo {
  flex: 0 0 auto;
  margin-inline-end: auto;
}

.pb-header__logo--text {
  /* The markup carries the chosen font role and colour token as custom properties;
     the fallbacks are what the fixed rule said before they were choices. */
  font-family: var(--pb-logo-font, var(--font-display));
  color: var(--pb-logo-color, inherit);
  font-size: 1.2rem;
  line-height: 1.1;
}

.pb-header__logo--image > .pb-media {
  background-color: transparent;
}

.pb-header__logo--image img {
  width: auto;
  height: auto;
}

/* Below tablet the menu and the contacts each take their own row — that is the whole
   of §31's "compact optional menu" in M3; the panel toggle is M4. */
.pb-header__nav,
.pb-header__contacts {
  flex: 1 1 100%;
}

.pb-header__menu {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0 1.25rem;
}

.pb-header__link,
.pb-header__contacts a {
  display: inline-flex;
  align-items: center;
  /* §38: a comfortable target on touch, independent of the theme's font size. */
  min-height: 44px;
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  text-transform: uppercase;
  text-decoration: none;
  color: var(--color-text-secondary);
  transition: color var(--transition-duration) ease;
}

.pb-header__link:hover,
.pb-header__contacts a:hover {
  color: var(--color-accent);
}

/*
 * A menu item answers the pointer with a rule in the same paint the section headings
 * already carry (owner request, 2026-08-03): '--color-accent' is the token
 * '.pb-section__heading::after' opens with, so a repainted palette moves both together
 * and no style has to restate the colour.
 *
 * Drawn as the link's own underline rather than a bar across its box: the 44px touch
 * target is taller than the label, and in the mobile panel the link is full-width too,
 * so a box-wide rule would float away from the word it belongs to. The underline is
 * laid on transparent at rest, so hovering animates a colour rather than jumping a
 * line in from nothing — and the reduced-motion guard crushes that like any other.
 *
 * Keyboard parity: the same rule appears on ':focus-visible', beside the focus ring.
 */
.pb-header__link {
  text-decoration: underline;
  text-decoration-color: transparent;
  text-decoration-thickness: 2px;
  text-underline-offset: 0.5em;
  transition:
    color var(--transition-duration) ease,
    text-decoration-color var(--transition-duration) ease;
}

.pb-header__link:hover,
.pb-header__link:focus-visible {
  text-decoration-color: var(--color-accent);
}

.pb-header__contacts {
  gap: 0 1.25rem;
}

/*
 * Mobile menu (§18 "compact mobile panel", §33.3). Every rule below is gated on
 * data-pb-menu="on", which only the runtime sets — the same ownership pattern as the
 * reveal CSS. Without JS nothing here matches: the toggle stays hidden and the menu
 * stays the wrapped row above, so the collapsed state can never trap a no-JS visitor.
 */
.pb-header {
  /* The panel's own pace. A menu that takes the theme's full transition duration to
     drop reads as broken rather than as considered, so it runs at a fraction of the
     user's token — a snappier theme still gets a snappier menu. */
  --pb-menu-duration: calc(var(--transition-duration) * 0.45);
}

.pb-header__toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* §38: a comfortable target on touch, and the icon is drawn well inside it. */
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  /* The label is hidden, not gone (below) — it keeps the voice of the header's other
     furniture, which is also what the cross-style re-casing contract is written
     against. */
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  text-transform: uppercase;
  color: var(--color-text-secondary);
}

/* The author 'display' above would otherwise beat the UA's [hidden] rule. */
.pb-header__toggle[hidden] {
  display: none;
}

/*
 * The button's accessible name (owner request, 2026-08-02: an icon, not the word).
 * Clipped rather than 'display: none' or 'visibility: hidden' — either of those would
 * take the name out of the accessibility tree and leave the button announced as
 * nothing but "button".
 */
.pb-header__togglelabel {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/*
 * The hamburger: the middle rule is the box itself, the outer two are its pseudos, so
 * the markup stays one empty span. Open folds it into a cross — the middle fades, the
 * other two meet in the centre and cross. Sized in the metadata voice's own em so it
 * scales with the header's type rather than against it.
 */
.pb-header__toggleicon {
  position: relative;
  display: block;
  width: 1.4em;
  height: 2px;
  border-radius: 1px;
  background-color: currentColor;
}

.pb-header__toggleicon::before,
.pb-header__toggleicon::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  height: 2px;
  border-radius: inherit;
  background-color: currentColor;
}

.pb-header__toggleicon::before {
  top: -0.42em;
}

.pb-header__toggleicon::after {
  top: 0.42em;
}

.pb-header[data-pb-menu='on'][data-pb-menu-open='true'] .pb-header__toggleicon {
  background-color: transparent;
}

.pb-header[data-pb-menu='on'][data-pb-menu-open='true'] .pb-header__toggleicon::before {
  transform: translateY(0.42em) rotate(45deg);
}

.pb-header[data-pb-menu='on'][data-pb-menu-open='true'] .pb-header__toggleicon::after {
  transform: translateY(-0.42em) rotate(-45deg);
}

/*
 * The panel drops rather than appears (owner request, 2026-08-02). A 'display' switch
 * cannot animate, so the closed state is a zero-height grid row instead: 0fr → 1fr is
 * the one collapse that reaches the content's own height without a magic number.
 *
 * 'visibility' is what keeps a closed menu out of the tab order — the M4 audit's rule
 * that hidden must mean unreachable, not merely unseen — and it flips only after the
 * row has finished closing, which is what the delayed half of the transition buys.
 */
/*
 * Until the runtime claims the menu, the panel is not a box at all: both wrappers are
 * 'display: contents', so the nav and the contact list are flex children of the header
 * row exactly as they were before the panel existed. That is the no-JS layout and the
 * tablet-and-up layout in one rule.
 */
.pb-header__panel,
.pb-header__panelinner {
  display: contents;
}

.pb-header[data-pb-menu='on'] .pb-header__panel {
  display: grid;
  grid-template-rows: 0fr;
  visibility: hidden;
  flex: 1 1 100%;
}

.pb-header[data-pb-menu='on'] .pb-header__panelinner {
  display: flex;
  flex-direction: column;
  /* The clipped child of the collapsing row — see the note above. */
  overflow: hidden;
  min-height: 0;
}

.pb-header[data-pb-menu='on'][data-pb-menu-open='true'] .pb-header__panel {
  grid-template-rows: 1fr;
  visibility: visible;
}

/* Inside the panel the contacts read as menu rows: one per line, full-width targets,
   the same rhythm the menu links carry (owner request, 2026-08-03). */
.pb-header[data-pb-menu='on'] .pb-header__contacts {
  flex-direction: column;
  flex-wrap: nowrap;
  gap: 0;
}

/*
 * The row is what animates; the list is what it clips. Both states share this, so the
 * height being animated to is the height the open panel actually has.
 *
 * The padding is zeroed on purpose. Padding is box, not content, so 'min-height: 0'
 * cannot reach it and the closed panel kept a 13px band open under the header —
 * measured, not theorised. The links carry their own 44px, which is the panel's
 * rhythm; the header's own padding gives it air below.
 */
.pb-header[data-pb-menu='on'] .pb-header__menu {
  overflow: hidden;
  min-height: 0;
  flex-direction: column;
  /* Turning the row into a column is not enough: the wrapped row above is still a
     wrapping flex container, and a *column* that wraps breaks into side-by-side
     columns whenever its height cannot hold every item. During the drop the height
     is exactly that — it climbs from zero — so the items spent the animation
     rearranging themselves from a row into a stack in full view (owner report,
     2026-08-03). With nowrap the stack exists at every height and the growing row
     simply uncovers it, which is the reveal the panel was meant to be. */
  flex-wrap: nowrap;
  gap: 0;
  padding: 0;
}

@media (prefers-reduced-motion: no-preference) {
  .pb-header[data-pb-menu='on'] .pb-header__panel {
    transition:
      grid-template-rows var(--pb-menu-duration) ease,
      visibility 0s linear var(--pb-menu-duration);
  }

  .pb-header[data-pb-menu='on'][data-pb-menu-open='true'] .pb-header__panel {
    transition:
      grid-template-rows var(--pb-menu-duration) ease,
      visibility 0s;
  }

  .pb-header__toggleicon,
  .pb-header__toggleicon::before,
  .pb-header__toggleicon::after {
    transition:
      transform var(--pb-menu-duration) ease,
      background-color var(--pb-menu-duration) ease;
  }
}

/* Hero — spec §19.2 (desktop), §19.3 (mobile order and contrast). */
.pb-hero__inner {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--project-gap);
}

/*
 * Mobile order is the DOM order: primary image → identity → secondaries (§19.3).
 * The wider bands reorder with explicit grid placement, never with a second markup.
 */
/*
 * The identity column breathes the demo's way (owner request, 2026-08-02): one
 * unhurried vertical rhythm rather than a caption-tight stack — the hero is the one
 * section whose job is air.
 */
.pb-hero__body {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-s);
  min-width: 0;
}

.pb-hero__title {
  margin: 0;
  font-size: var(--step-1);
  color: var(--color-text-secondary);
}

/*
 * Selected credits close the identity block the demo's way: a hairline opens them,
 * and each title stands as a small crest — centred, narrow, with a rule above and
 * below — so even a plain wordmark reads as one. Decoration is pseudo-content; the
 * list stays a list.
 */
.pb-hero__credits {
  list-style: none;
  margin: 0;
  padding: 0;
  align-self: stretch;
  border-top: 1px solid var(--color-border);
  padding-block-start: var(--space-m);
  margin-block-start: var(--space-xs);
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: var(--space-s) var(--space-xl);
}

.pb-hero__credits li {
  display: flex;
  flex-direction: column;
  justify-content: center;
  text-align: center;
  line-height: 1.3;
  max-width: 10ch;
  text-wrap: balance;
}

.pb-hero__credits li::before,
.pb-hero__credits li::after {
  content: "";
  height: 1px;
  width: 2.75em;
  background-color: var(--color-border);
  margin-inline: auto;
}

.pb-hero__credits li::before {
  margin-bottom: 0.55em;
}

.pb-hero__credits li::after {
  margin-top: 0.55em;
}

.pb-hero__contacts a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}

.pb-hero__media {
  position: relative;
  min-width: 0;
  border-radius: var(--border-radius);
  overflow: hidden;
}

/*
 * §19.3: no fixed viewport height that crops faces — the cap is a min() and the crop
 * itself is the asset's focal point, which '.pb-media > img' already applies.
 */
.pb-hero__image > img {
  max-height: min(78vh, 44rem);
}

/*
 * §19.3's scrim keeps text over the primary readable — and in 'editorial-split', the one
 * layout the MVP ships (§5.3), no text is ever over it: the identity column sits beside
 * the image on desktop and above it on a phone. Painting the overlay anyway dimmed the
 * photograph by the full 'imageOverlay' strength for nothing, which for an image-led
 * portfolio is the worst kind of default.
 *
 * The element stays in the markup because it is the hook a text-over-image hero layout
 * will need, and it paints nothing until a layout asks for it.
 */
.pb-hero__scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: linear-gradient(
    to top,
    var(--color-image-overlay) 0%,
    transparent 62%
  );
  opacity: 0;
}

.pb-hero--overlay .pb-hero__scrim {
  opacity: var(--hero-overlay-strength);
}

.pb-hero__secondaries {
  display: flex;
  flex-wrap: wrap;
  gap: var(--normal-grid-gap);
  min-width: 0;
}

.pb-hero__secondary {
  flex: 1 1 9rem;
  min-width: 0;
  aspect-ratio: var(--media-aspect-ratio);
}

/*
 * 'image()' owns the element it emits and has no way to carry a data attribute, so
 * 'hero.ts' puts 'data-pb-mobile' on a wrapper and the picture box is the '.pb-media'
 * inside it. The ratio lives on the wrapper (it is the flex item), so the box inside
 * has to be told to fill it — otherwise the image sizes itself and §31's "stable image
 * dimensions" stops holding for the secondaries.
 */
.pb-hero__secondary > .pb-media {
  height: 100%;
}

/* §19.1: the phone shows only the capped set; the flag is in the markup, the hiding
   is CSS, so the page stays complete without JS (§7.2). */
.pb-hero__secondary[data-pb-mobile="false"] {
  display: none;
}

/*
 * About — the long story below the hero (About brief §3.5).
 *
 * Everything here is in the **shared** layer: all ten visual styles must render the
 * section legibly with no per-theme work, so nothing below states a colour, a size or a
 * rhythm of its own — each one is the token the rest of the page already reads. A theme
 * that wants to voice the About body may, later, in a brief of its own.
 *
 * Paragraph air is a trailing margin rather than a sibling rule, which matters twice
 * over: a column break must not leave the second column's first line pushed down by a
 * top margin, and the lead's last paragraph is exactly what separates it from the fold's
 * first — so no margin is needed on the collapsing wrapper, where it could not be
 * clipped to zero anyway.
 */
.pb-about {
  --pb-about-column-gap: var(--space-xl);
}

.pb-about__body,
.pb-about .pb-fold__morebody {
  /* One column on a phone, held to a readable measure; the bands below open it. */
  max-width: 62ch;
}

.pb-about__paragraph {
  margin: 0 0 1em;
  color: var(--color-text-secondary);
}

/*
 * The fold (brief §2.4, §3.4), shared by every field that can hold one since the owner's
 * 2026-08-05 round: the About story, the hero bio, a project description, the CTA text
 * and the footer quote. One vocabulary, so this block and one runtime loop serve all of
 * them, and a field that gains a fold later needs no CSS of its own.
 *
 * Every collapsing rule is gated on data-pb-fold="on", which only the runtime sets, and
 * only after it has wired that region's toggle — §7.2's rule as the mobile menu states
 * it: with no JS nothing here matches, the button stays hidden, and the whole text is on
 * the page. The gate is per region, so a fold whose wiring failed collapses nothing while
 * its neighbours still work.
 */
[data-pb-fold] {
  /* The fold's own pace, a fraction of the theme's token — the mobile menu's reasoning:
     a collapse that takes the full transition duration reads as broken. */
  --pb-fold-duration: calc(var(--transition-duration) * 0.45);
}

[data-pb-fold='on'] .pb-fold__more {
  display: grid;
  grid-template-rows: 0fr;
  visibility: hidden;
}

[data-pb-fold='on'] .pb-fold__morebody {
  /* The clipped child of the collapsing row. Padding would be box rather than content,
     so 'min-height: 0' could not reach it — the mobile menu paid for that lesson. */
  overflow: hidden;
  min-height: 0;
}

[data-pb-fold='on'][data-pb-fold-open='true'] .pb-fold__more {
  grid-template-rows: 1fr;
  visibility: visible;
}

/* The metadata voice, start-aligned under the text, with the 44px touch target §38 asks
   for. Not a .pb-button: this opens text on the page, it does not go anywhere. */
.pb-fold__toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  margin-block-start: var(--space-s);
  padding: 0;
  min-height: 44px;
  border: 0;
  background: none;
  color: var(--color-text-secondary);
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  text-transform: uppercase;
  cursor: pointer;
}

/* 'display' beats the attribute, so the hidden button needs saying twice — otherwise a
   no-JS page shows a control that does nothing. */
.pb-fold__toggle[hidden] {
  display: none;
}

.pb-fold__toggle:hover,
.pb-fold__toggle:focus-visible {
  color: var(--color-accent);
}

/* Drawn, not a glyph and not a file: two borders of a box, turned. It points along the
   reading direction — the fold opens once and never closes (owner decision,
   2026-08-05), so the mark says "there is more this way" rather than standing for a
   state that could flip. 'border-inline-end' makes that follow the writing mode; the
   rotation is the same either way, because the borders themselves have swapped. */
.pb-fold__chevron {
  width: 0.45em;
  height: 0.45em;
  border-inline-end: 2px solid currentColor;
  border-block-end: 2px solid currentColor;
  transform: translateX(-0.1em) rotate(-45deg);
}

@media (prefers-reduced-motion: no-preference) {
  [data-pb-fold='on'] .pb-fold__more {
    transition:
      grid-template-rows var(--pb-fold-duration) ease,
      visibility 0s linear var(--pb-fold-duration);
  }

  [data-pb-fold='on'][data-pb-fold-open='true'] .pb-fold__more {
    transition:
      grid-template-rows var(--pb-fold-duration) ease,
      visibility 0s;
  }
}

/* Featured projects — spec §21. The air *between* blocks is double the air inside
   one (the demo separates its features with space-2xl against space-l inside), so
   each film reads as its own scene instead of one continuous column. Expressed as a
   multiple of the user's token, never over its head. */
.pb-featured > .pb-container {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: calc(var(--project-gap) * 2);
}

/*
 * Every section's own heading — featured, normal, additional credits — is the static
 * schemes' label (owner request, 2026-08-03, superseding the display-size heading of
 * 2026-08-02): a small uppercase line in the body voice with a hairline running to
 * the container's edge. The hairline is the flex filler, so a theme that wants to
 * colour or glow it overrides only the background. It sits here, before the sections
 * that override it, so the cascade keeps their order.
 */
.pb-section__heading {
  display: flex;
  align-items: center;
  gap: var(--space-m);
  margin: 0 0 calc(var(--section-spacing) * 0.35);
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: 500;
  line-height: 1.4;
  text-transform: uppercase;
  letter-spacing: var(--metadata-letter-spacing);
  color: var(--color-text-secondary);
}

.pb-section__heading::after {
  content: "";
  flex: 1 1 0;
  height: 1px;
  background-color: var(--color-border);
}

/*
 * The rich text markup's one block-level mark (brief §2.1, §3.5). A pause inside a
 * flow of prose, drawn as the same hairline every border in the page uses, with a
 * paragraph's air on either side.
 *
 * This is the **default**, which is also classic's own answer. Since the owner's
 * 2026-08-04 round a visual style may draw this mark in its own voice — it is the one
 * piece of the About section a theme owns — so every style below overrides the paint
 * and inherits the geometry from here.
 *
 * 'column-span: none' is deliberate and is a rule, not a default: About sets its body
 * in columns, and a rule that spanned them would read as a section divider — which the
 * owner's no-section-divider decision (2026-08-04) forbids to every theme. It is a
 * pause in the text, so it stays inside the column it interrupts, and no style may
 * take that back (themeStyleRegistry.test.ts).
 */
.pb-rich__rule {
  /* The named length a style draws itself into. A style that wants a short mark sets
     this rather than inventing a width of its own, so the geometry stays in one place.
     The inline margin is stated because a UA centres an hr, and a mark inside a column
     of prose belongs on the text's own start edge unless a style says otherwise. */
  --pb-rich-rule-length: 100%;
  inline-size: var(--pb-rich-rule-length);
  margin-block: 1.2em;
  margin-inline: 0 auto;
  border: 0;
  border-block-start: 1px solid var(--color-border);
  column-span: none;
}

/*
 * Bold inside the About story used to carry the accent (owner request, 2026-08-04). The
 * owner withdrew that on 2026-08-05: emphasis speaks with **one** voice across the page,
 * the primary ink, and the rule that says so lives in base.ts as an element rule. Nothing
 * stands here now on purpose — a section-scoped override is exactly what would bring the
 * two voices back.
 */

/*
 * Featured's heading is a grid item, so the container's own gap already states the
 * air below it and the shared margin would double it; normal's sits before the grid
 * rather than in it, so it keeps the shared spacing.
 */
.pb-featured__heading {
  margin-block-end: 0;
}

/* Normal projects — spec §22.1 (two columns), §22.2 (single column, no rail). */
.pb-normal__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--normal-grid-gap);
}

/* Project block — shared by both tiers so they cannot drift (§21, §22). */
.pb-project {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--project-gap);
  margin: 0;
  min-width: 0;
}

.pb-project--normal {
  gap: calc(var(--normal-grid-gap) * 0.6);
}

/*
 * Tier hierarchy (porting brief §3.1, §6.4). Both tiers share one article template, so
 * before the scale a card in the two-column grid shouted at exactly the volume of a
 * full-width featured block. The featured title stays on step 2; a normal one steps
 * down. Nothing else about the card changes.
 */
.pb-project--normal .pb-project__title {
  font-size: var(--step-1);
}

.pb-project__media {
  min-width: 0;
}

/*
 * The decorative year hook (porting brief §5.5). The markup is shared by every theme,
 * the motif is not: out of view here, and a visual style that wants it says so. It is
 * aria-hidden in the markup, so hiding it takes nothing from anyone.
 */
.pb-project__ghost-year {
  display: none;
}

.pb-project__body {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.55em;
  min-width: 0;
}

.pb-project__role {
  margin: 0;
  color: var(--color-text-secondary);
}

/* The demo holds its one-liner at 38ch; the Builder's field takes longer prose, so
   the measure lands a little wider — still a column, no longer a slab. */
.pb-project__description {
  margin: 0;
  color: var(--color-text-secondary);
  max-width: 44ch;
  text-wrap: pretty;
}

/* §29: leaving the site is signalled by the label plus a restrained icon. */
.pb-project__link {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  min-height: 44px;
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  text-transform: uppercase;
  text-decoration: none;
  color: var(--color-accent);
  border-bottom: 1px solid var(--color-border);
  transition: border-color var(--transition-duration) ease;
}

.pb-project__link:hover {
  border-bottom-color: var(--color-accent);
}

/*
 * §29 wants the "you are leaving the site" signal to be visible, not only in the
 * accessible name. 'anchor()' cannot nest markup inside the label, so the icon is a
 * decorative pseudo-element rather than a span the markup would have to carry.
 *
 * 'a.' on purpose: when the URL is unusable 'anchor()' degrades to a '<span>' with the
 * same class, and an arrow on something that does not navigate is a lie (same split as
 * 'a.pb-button' / 'span.pb-button' below).
 */
a.pb-project__link::after {
  content: "";
  flex: none;
  width: 0.5em;
  height: 0.5em;
  border-top: 1px solid currentColor;
  border-right: 1px solid currentColor;
  transform: rotate(45deg);
  transition: transform var(--transition-duration) ease;
}

a.pb-project__link:hover::after {
  transform: rotate(45deg) translate(0.12em, 0.12em);
}

/* Project media — spec §21.2, §22.3, §25.7. */
.pb-projectmedia {
  display: flex;
  flex-direction: column;
  gap: calc(var(--normal-grid-gap) * 0.5);
}

/*
 * §22.3: frame dimensions stay stable across a thumbnail swap. The ratio comes from
 * the token and every slide is taken out of flow, so swapping which one is 'hidden'
 * cannot reflow the card.
 */
.pb-projectmedia__frame {
  position: relative;
  aspect-ratio: var(--media-aspect-ratio);
  border-radius: var(--border-radius);
  overflow: hidden;
  background-color: var(--color-surface-background);
}

/* What a frame holds is named rather than matched with '*', so the rule outranks the
   'position' each of them sets for its stand-alone use: the primary image, the video
   facade, and the non-primary slides — 'media.ts' wraps each of those in a
   'pb-projectmedia__slide' div because 'image()' cannot carry 'data-pb-slide' itself. */
.pb-projectmedia__frame > .pb-media,
.pb-projectmedia__frame > .pb-video,
.pb-projectmedia__frame > .pb-projectmedia__slide {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: inherit;
}

/* A slide holds an image or — when a trailer is attached as an additional item — the
   video facade; both fill the slide. */
.pb-projectmedia__slide > .pb-media,
.pb-projectmedia__slide > .pb-video {
  width: 100%;
  height: 100%;
  border-radius: inherit;
}

/*
 * The no-JS state of a non-primary slide is 'hidden', and it must survive any
 * 'display' this file or base.ts gives a slide — hence the explicit guard.
 */
.pb-projectmedia__frame > [hidden] {
  display: none !important;
}

/*
 * Runtime-owned frame (M4, §22.3, §21.3). The runtime sets data-pb-live="on", removes
 * each slide's 'hidden', and moves visibility to .is-active — so a thumbnail swap or a
 * slideshow step is an opacity cross-fade rather than a display jump, and the frame's
 * box never changes (§22.3 "frame dimensions stable"). Inactive panes drop pointer
 * events so a covered video facade cannot swallow a click meant for the visible pane.
 * Without JS the attribute never appears and 'hidden' keeps doing the honest work.
 */
.pb-projectmedia__frame[data-pb-live='on'] > .pb-media,
.pb-projectmedia__frame[data-pb-live='on'] > .pb-video,
.pb-projectmedia__frame[data-pb-live='on'] > .pb-projectmedia__slide {
  opacity: 0;
  /* Not opacity alone: an opacity-0 pane keeps its place in the tab order and the
     accessibility tree, and the M4 gate audit proved the consequence — Enter on an
     invisible video facade started an autoplaying embed nobody could see or stop.
     'visibility: hidden' removes both. Its transition is a delay, not a fade: the
     outgoing pane stays visible for exactly the opacity fade, then vanishes — so the
     cross-fade looks identical and only the finished state differs. */
  visibility: hidden;
  pointer-events: none;
  transition:
    opacity var(--transition-duration) ease,
    visibility 0s linear var(--transition-duration);
}

.pb-projectmedia__frame[data-pb-live='on'] > .is-active {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  /* The incoming pane must be visible from the first frame of its fade-in. */
  transition-delay: 0s, 0s;
}

/* §21.3: the slideshow cross-fade runs on the theme's fade token instead. A slideshow
   frame never holds a video (media.ts switches the mode off), so .pb-video is absent
   here on purpose. The visibility delay must track the same token, or the outgoing
   slide would vanish mid-fade. */
.pb-projectmedia__frame[data-pb-live='on'][data-pb-slideshow='true'] > .pb-media,
.pb-projectmedia__frame[data-pb-live='on'][data-pb-slideshow='true'] > .pb-projectmedia__slide {
  transition:
    opacity var(--slideshow-fade-duration) ease,
    visibility 0s linear var(--slideshow-fade-duration);
}

.pb-projectmedia__frame[data-pb-live='on'][data-pb-slideshow='true'] > .is-active {
  transition-delay: 0s, 0s;
}

/* §22.2, §31: thumbnails wrap into a small grid. Never a horizontal rail.
   The columns are banded (owner rules, 2026-08-02): a phone always draws two —
   anything more makes the pictures too small — so two is the base layer's constant;
   the tablet and desktop counts come from the markup (data-pb-cols-t/-d, written by
   media.ts, which knows the thumb count and the pair's dictate at render time). */
.pb-projectmedia__thumbs {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: calc(var(--normal-grid-gap) * 0.4);
}

/* A slideshow card's empty strip row (owner rule, 2026-08-02): pure rhythm beside a
   thumbnail-carrying neighbour, so it exists only in the band where the two cards
   actually sit side by side — a stacked phone column gives it no height at all. */
.pb-projectmedia__thumbs--spacer {
  display: none;
}

.pb-projectmedia__thumb {
  display: block;
  width: 100%;
  /* §38: the button is inert until M4 wires it, but it is already focusable and
     already has a real touch target. */
  min-height: 44px;
  padding: 0;
  border: 1px solid transparent;
  border-radius: var(--thumbnail-radius);
  background: none;
  color: inherit;
  cursor: pointer;
  overflow: hidden;
  transition: border-color var(--transition-duration) ease;
}

/* A video's thumbnail is its own facade anchor rather than a button (it has to stay
   reachable without JS), so the picture box may be the poster inside .pb-video. */
.pb-projectmedia__thumb > .pb-media,
.pb-projectmedia__thumb.pb-video > .pb-media {
  aspect-ratio: var(--media-aspect-ratio);
  border-radius: inherit;
}

.pb-projectmedia__thumb.pb-video {
  border: 1px solid transparent;
}

.pb-projectmedia__thumb:hover {
  border-color: var(--color-border);
}

/* No selected-thumbnail state exists any more (owner rule, 2026-08-02): a click
   exchanges places with the big image, so the picture on display is never in the
   strip and nothing there is "current". */

/* Video facade — spec §25.6. Poster plus a decorative play badge; the accessible
   name lives on the anchor. */
.pb-video {
  position: relative;
  display: block;
  border-radius: var(--border-radius);
  overflow: hidden;
  color: inherit;
  text-decoration: none;
  background-color: var(--color-surface-background);
}

.pb-video > .pb-media {
  width: 100%;
  height: 100%;
  border-radius: inherit;
}

/*
 * The badge measures the box it sits on, never the page (owner request, 2026-08-03).
 * A flat 4rem circle is right on a featured frame and absurd on a strip thumbnail —
 * on a phone it covered two thirds of the picture. 'min()' takes whichever binds:
 * half the poster's height, or 4rem once the poster is big enough to carry it.
 *
 * Only the height is stated, because half the *height* is the rule and a percentage
 * width would measure the wrong axis on a 16:9 box; 'aspect-ratio' brings the width
 * back and keeps the circle round. The percentage resolves against '.pb-video', the
 * badge's containing block, so every poster on the page measures its own.
 */
.pb-video__play {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  width: auto;
  height: min(4rem, 50%);
  aspect-ratio: 1;
  border: 1px solid var(--color-border);
  border-radius: 50%;
  background-color: var(--color-elevated-surface);
  box-shadow: var(--shadow);
  transform: translate(-50%, -50%);
  transition: transform var(--transition-duration) ease;
}

/*
 * The glyph is a fraction of the badge, so a shrunk circle keeps the proportions the
 * 4rem one established (at 4rem these percentages are the 13.6×16px triangle that was
 * drawn here before). It is a clipped box rather than the old border trick because
 * 'border-width' cannot take a percentage — nothing about the shape changed.
 */
.pb-video__play::before {
  content: "";
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 54%;
  width: 21.25%;
  height: 25%;
  background-color: var(--color-text-primary);
  clip-path: polygon(0 0, 100% 50%, 0 100%);
  transform: translate(-50%, -50%);
}

.pb-video:hover .pb-video__play {
  transform: translate(-50%, -50%) scale(1.06);
}

/*
 * §25.6's upgraded state: after the click the runtime swaps the facade anchor for a
 * div carrying the same classes plus this modifier, with the provider iframe inside —
 * so the box keeps every layout rule the facade had wherever it stood. The
 * aspect-ratio only matters when the box sizes itself; inside a frame the absolute
 * inset wins, which is what §22.3's stable dimensions need.
 */
.pb-video--embedded {
  aspect-ratio: var(--media-aspect-ratio);
}

.pb-video__iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

/* Additional credits — spec §23.2. CSS Grid, never a table; mobile stacks the row. */

/* One list holds every group, so the tracks are shared and the columns line up across
   groups (§23.2). The heading is a row of its own spanning all of them. */
.pb-credits__group {
  grid-column: 1 / -1;
}

.pb-credits__group ~ .pb-credits__group {
  margin-block-start: calc(var(--section-spacing) * 0.4);
}

.pb-credits__grouplabel {
  margin-block-end: 0.6em;
  padding-block-end: 0.5em;
  border-bottom: 1px solid var(--color-border);
  color: var(--color-text-primary);
}

.pb-credits__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  column-gap: var(--normal-grid-gap);
}

.pb-credits__row {
  display: block;
  padding-block: 0.8em;
  border-bottom: 1px solid var(--color-border);
}

/* §23.2 mobile: year / title / role on their own lines. */
.pb-credits__row > * {
  display: block;
}

/*
 * The parameter the list is grouped by: the group heading already carries the value,
 * so the cell is removed from view — and, being out of flow, from the grid — while
 * assistive technology and a copied row keep it. In the base layer, not the grid
 * band: the stacked phone rows printed the grouped value a second time under its own
 * heading before this moved out of the media query (owner report, 2026-08-02). The
 * row-scoped selector outweighs the grid band's later all-cells rule, which would
 * otherwise hand the clipped cell its padding and rule line back.
 */
.pb-credits__row > .pb-credits__cell--grouped {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.pb-credits__year {
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  color: var(--color-text-muted);
}

.pb-credits__title {
  color: var(--color-text-primary);
}

/* The short description belongs to the work, so it sits under the title inside the
   title's cell rather than in a column of its own (owner request, 2026-08-02). */
.pb-credits__description {
  display: block;
  margin-top: 0.2em;
  font-size: var(--step--1);
  color: var(--color-text-secondary);
  text-wrap: pretty;
}

.pb-credits__category,
.pb-credits__role {
  color: var(--color-text-secondary);
}

.pb-credits__link {
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  text-transform: uppercase;
}

/* CTA — spec §27. Restrained: one column, left aligned, nothing sales-funnel-like. */
.pb-cta__inner {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.6em;
}

.pb-cta__description {
  margin: 0;
}

.pb-cta__button {
  margin-block-start: 0.6em;
}

/* Button — the CTA is the only one on the page in M3. */
.pb-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 0.7em 1.6em;
  border-radius: var(--border-radius);
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  text-transform: uppercase;
  text-decoration: none;
}

a.pb-button {
  border: 1px solid var(--color-accent);
  background-color: var(--color-accent);
  color: var(--color-accent-contrast);
  transition:
    background-color var(--transition-duration) ease,
    color var(--transition-duration) ease;
}

a.pb-button:hover {
  background-color: transparent;
  color: var(--color-accent);
}

/*
 * 'anchor()' degrades to a '<span>' when the URL is unusable. The label must still
 * read, but nothing may pretend to be clickable — so the span keeps the typography
 * and drops every affordance.
 */
span.pb-button {
  padding-inline: 0;
  border: 0;
  background: none;
  color: var(--color-text-muted);
}

/* Signature — spec §26. Every knob arrives as a --pb-signature-* custom property from
   the markup, so an unknown token can never become an arbitrary declaration. */
.pb-signature {
  margin: 0;
  rotate: var(--pb-signature-rotate, 0deg);
}

.pb-signature--text {
  font-family: var(--pb-signature-font, var(--font-signature));
  font-size: var(--pb-signature-size, 2rem);
  color: var(--pb-signature-color, var(--color-text-primary));
  line-height: 1.15;
}

/*
 * A signature is transparent artwork: no dominant-colour plate behind it, and no
 * stretch — 'max-width' (when the theme sets one) governs, never 'width: 100%'.
 *
 * 'inline-block' is what makes §26's alignment real: 'signature.ts' puts 'text-align'
 * on the block, and a full-width block child would ignore it whatever the value.
 */
.pb-signature--image > .pb-media {
  display: inline-block;
  max-width: 100%;
  background-color: transparent;
  border-radius: 0;
  overflow: visible;
}

.pb-signature--image img {
  width: auto;
  height: auto;
  max-width: 100%;
}

/*
 * Footer — spec §28, §31; composition from the static demo (owner approval,
 * 2026-08-02). '.pb-footer' itself belongs to base.ts.
 *
 * A phone reads it as end credits: everything centred, one unhurried column. The
 * widest band closes the page in one composed row instead — lead beside the contact
 * columns — which is what keeps the landmark from stacking into three storeys of
 * left-aligned blocks.
 */
.pb-footer__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: calc(var(--section-spacing) * 0.5);
}

.pb-footer__lead {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1em;
}

/*
 * The quote is rich text (owner request, 2026-08-05), so the blockquote holds
 * paragraphs rather than a text node. Every visual style voices the quote class with
 * typography alone, which the paragraphs inherit — the class stays where the themes
 * already look, and only the spacing is stated here.
 */
.pb-footer__quote {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 0.688rem + 1.1vw, 1.8rem);
  line-height: 1.25;
  color: var(--color-text-primary);
  max-width: 34ch;
  text-wrap: balance;
}

/* Spacing between siblings rather than a bottom margin on each: the quote sits in a
   flex column whose gap already states the air before the signature, and a trailing
   margin would add a second helping of it. A paragraph following a rule keeps the
   rule's own air instead. */
.pb-footer__quotetext {
  margin: 0;
}

.pb-footer__quotetext + .pb-footer__quotetext {
  margin-block-start: 0.5em;
}

.pb-footer__columns {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  justify-items: center;
  gap: var(--normal-grid-gap);
}

/*
 * The reachable-contact region is two storeys, not a row of equal columns (owner
 * request, 2026-08-03): the heading opens the group on a row of its own, the contact
 * and social lists share the row under it, and the details block — location,
 * availability, the CV link — closes the region in one column below the pair.
 *
 * The group exists so the air under the heading is the heading's own (0.6em, the
 * column rhythm) while the region keeps the wider grid gap between its two storeys.
 */
.pb-footer__group {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6em;
  justify-self: stretch;
  min-width: 0;
}

.pb-footer__pair {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  justify-items: center;
  gap: var(--normal-grid-gap);
  align-self: stretch;
  min-width: 0;
}

.pb-footer__column {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6em;
  min-width: 0;
}

.pb-footer__heading {
  margin-block-end: 0.2em;
}

/* §31: large contact targets, one per line — a wrapped row is hard to hit on a phone. */
.pb-footer__contacts {
  flex-direction: column;
  gap: 0;
}

.pb-footer__contacts a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  /* A pasted URL is one long word; it breaks inside its column rather than into the
     neighbouring one. */
  max-width: 100%;
  overflow-wrap: anywhere;
}

/*
 * The closing grammar: the imprint centred in the open, then the hairline, then the
 * centred copyright. The rule sits on the copyright itself, so a footer with no
 * copyright draws no orphaned line — each piece stays optional (§28, §30).
 */
.pb-footer__base {
  display: flex;
  flex-direction: column;
  align-items: center;
  align-self: stretch;
  gap: var(--space-l);
  text-align: center;
}

.pb-footer__copyright {
  margin: 0;
  align-self: stretch;
  font-size: var(--step--1);
  color: var(--color-text-muted);
  border-top: 1px solid var(--color-border);
  padding-block-start: var(--space-m);
}

.pb-footer__legal {
  margin: 0;
  max-width: 60ch;
}

@media (min-width: 45rem) {
  /* Header: the menu and the contacts rejoin the logo's row. */
  .pb-header__nav,
  .pb-header__contacts {
    flex: 0 1 auto;
  }

  /* The compact panel is a phone affordance: from the tablet band up the toggle
     disappears and the menu is always inline, whatever state the runtime left it in.
     "Whatever state" has to include the open one — data-pb-menu-open survives a
     breakpoint crossing (rotate a phone with the panel open) and nothing up here can
     clear it, so every collapsed-panel rule needs its inline counterpart, or the wide
     band renders a stacked column with the only closing control display:none. The
     M4 gate audit measured exactly that: a 165px header where 73px belongs. The
     counterparts below deliberately ignore the open state: the collapse they undo is
     now the closed state's, so undoing it unconditionally is the stronger guarantee. */
  .pb-header__toggle {
    display: none;
  }

  /* Both selectors, deliberately: the open state's collapse rule carries one attribute
     more than the closed state's, so a single-attribute reset loses the cascade to it
     and only 'display: block' would be saving the layout. Measured at 768 with the
     panel left open — the reset must win on its own terms, not by accident. */
  .pb-header[data-pb-menu='on'] .pb-header__panel,
  .pb-header[data-pb-menu='on'][data-pb-menu-open='true'] .pb-header__panel,
  .pb-header[data-pb-menu='on'] .pb-header__panelinner {
    display: contents;
    grid-template-rows: none;
    visibility: visible;
  }

  .pb-header[data-pb-menu='on'] .pb-header__contacts {
    flex-direction: row;
    flex-wrap: wrap;
    gap: 0 1.25rem;
  }

  .pb-header[data-pb-menu='on'] .pb-header__menu {
    overflow: visible;
    flex-direction: row;
    /* The collapsed panel's counterpart, per the note above: it needs nowrap so the
       drop cannot break the stack into columns, and an inline row needs wrap back —
       a long menu belongs on a second line, not off the side of the header. */
    flex-wrap: wrap;
    gap: 0 1.25rem;
  }

  /*
   * Hero (§19.2, proportions from the static demo — owner request, 2026-08-02):
   * identity left, primary right and slightly dominant, decorative secondaries below.
   * The three-track desktop arrangement waits for the width that can carry it.
   *
   * The air is deliberate: the text column reserves a demo-proportioned minimum
   * height and centres its content in it, so the primary beside it gets a zone
   * rather than a slot. Both minimums are min() caps, never fixed viewport heights
   * (§19.3) — and the crop they may cost the image is the focal point's to steer.
   */
  .pb-hero__inner {
    grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
    align-items: center;
  }

  .pb-hero__body {
    grid-column: 1;
    grid-row: 1;
    justify-content: center;
    min-block-size: min(40vw, 26rem);
  }

  .pb-hero__media {
    grid-column: 2;
    grid-row: 1;
    align-self: center;
    block-size: 100%;
    max-block-size: min(78vh, 44rem);
  }

  .pb-hero__image {
    block-size: 100%;
  }

  .pb-hero__image > img {
    block-size: 100%;
  }

  .pb-hero__secondaries {
    grid-column: 1 / -1;
    grid-row: 2;
  }

  /* No primary yet: the identity takes one readable column instead of half a page,
     and the reserved zone goes with the image it was reserved for. */
  .pb-hero--nomedia .pb-hero__inner {
    grid-template-columns: minmax(0, 1fr);
  }

  .pb-hero--nomedia .pb-hero__body {
    min-block-size: 0;
    max-width: 62ch;
  }

  .pb-hero--nomedia .pb-hero__secondaries {
    grid-column: 1;
  }

  /* An image-only hero: the primary takes the whole row rather than leaving the
     text's track open beside it. */
  .pb-hero__inner > .pb-hero__media:only-child {
    grid-column: 1 / -1;
  }

  .pb-hero__secondary[data-pb-mobile="false"] {
    display: block;
  }

  /*
   * About: from the tablet band up the story sets in the columns the portfolio chose —
   * two by default, one if it asked (brief §3.5, owner request 2026-08-05).
   *
   * The lead and the fold are two multicol blocks stacked, not one — which makes the
   * fold boundary a column-balance boundary too, and that is what keeps the collapse a
   * plain height animation instead of a reflow of the whole section.
   *
   * **The story takes the container's full width** (owner request, 2026-08-05): the
   * 92ch cap that used to hold it short is gone, so two columns land in the fifties
   * rather than the forties. One column across the widest band is a long line by any
   * typographic measure — that is the owner's call, stated here so the next session
   * does not read it as an oversight and "fix" it.
   */
  .pb-about__body,
  .pb-about .pb-fold__morebody {
    max-width: none;
    column-count: 2;
    column-gap: var(--pb-about-column-gap);
    column-fill: balance;
  }

  /* A phone was always one column, so this says nothing new below the tablet band. */
  .pb-about[data-pb-columns='1'] .pb-about__body,
  .pb-about[data-pb-columns='1'] .pb-fold__morebody {
    column-count: 1;
  }

  /* §22.1: two editorial cards side by side. */
  .pb-normal__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  /*
   * Two cards in a row are as tall as the taller one, and a stretched card used to
   * hand that surplus to *both* of its rows: the image row grew and the text row grew
   * with it, so the shorter card's prose started half the surplus lower than its
   * neighbour's and the pair read as vertically centred. The text belongs at the top
   * of what the image leaves (owner rule, 2026-08-02).
   */
  .pb-project--normal {
    align-content: start;
  }

  /*
   * And "the top" has to be the same top on both sides. Sizing each card against its
   * own rows only aligns a pair whose media happen to be equally tall — one strip row
   * more on one side and the two texts part company again. Subgrid hands the row
   * sizing to the grid the pair already shares, so the image row is the taller image's
   * and both texts start on the row below it, whatever each card carries.
   *
   * The card keeps its own row gap (a subgrid may override the gutters it inherits),
   * so the air between an image and its caption stays the card's, not the grid's.
   * Where subgrid is missing the rule above still top-aligns each card: the fallback
   * loses the shared start line, not the fix.
   */
  @supports (grid-template-rows: subgrid) {
    .pb-normal__grid > .pb-project--normal {
      grid-row: span 2;
      grid-template-rows: subgrid;
    }

    /* No media yet — the state every project starts in. Holding the image row open
       above the text would print exactly the blank half the featured tier already
       refuses, so the text takes both rows and stays where it started. */
    .pb-normal__grid > .pb-project--nomedia > .pb-project__body {
      grid-row: 1 / -1;
    }
  }

  /* The tablet band's strip columns (owner rule, 2026-08-02): three is acceptable
     here, but the renderer prefers pairs unless the count fills threes exactly —
     two needs no rule, it is the base layer's constant. */
  .pb-projectmedia__thumbs[data-pb-cols-t='3'] {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  /* The slideshow card's reserved band: the same per-band columns as the
     neighbour's strip, one cell shaped exactly like a thumbnail (same ratio, same
     transparent border), so the two text blocks below start at one height. */
  .pb-project--normal .pb-projectmedia__thumbs--spacer {
    display: grid;
  }

  .pb-project--normal .pb-projectmedia__spacercell {
    display: block;
    aspect-ratio: var(--media-aspect-ratio);
    /* A real thumb is a bordered button with a 44px touch floor — the ghost cell
       mirrors both so the reserved band measures what the neighbour's strip does. */
    border: 1px solid transparent;
    min-height: 44px;
  }

  /* §21.1: a large split block, and the side the media takes alternates down the
     list. The proportions are the demo's 7 : 4 — the image owns the block, the text
     is its caption column — and the split arrives with the tablet band (the demo
     splits at 48rem; §31 allows exactly this breakpoint and 68.75rem, so it rides
     the one that exists). Only the phone keeps media-above-text. */
  .pb-project--featured {
    grid-template-columns: minmax(0, 7fr) minmax(0, 4fr);
    align-items: center;
  }

  /* Which side the media takes must never change how wide it is (owner rule,
     2026-08-02): the demo mirrors the *tracks* on the even rows, so the image owns
     the 7fr share on either side and the text column keeps one width down the page. */
  .pb-project--media-end {
    grid-template-columns: minmax(0, 4fr) minmax(0, 7fr);
  }

  .pb-project--media-start .pb-project__media,
  .pb-project--media-end .pb-project__body {
    grid-column: 1;
    grid-row: 1;
  }

  /* No media yet — every project starts here, and half a blank page is not a layout. */
  .pb-project--featured.pb-project--nomedia {
    grid-template-columns: minmax(0, 1fr);
  }

  .pb-project--featured.pb-project--nomedia .pb-project__body {
    grid-column: 1;
    max-width: 62ch;
  }

  .pb-project--media-start .pb-project__body,
  .pb-project--media-end .pb-project__media {
    grid-column: 2;
    grid-row: 1;
  }

  /*
   * Credits (§23.2): the list is the grid and the row disappears into it, so every
   * cell lands in its own track. Each cell carries the rule line, because a
   * 'display: contents' row can no longer draw one.
   *
   * The tracks come from the markup (--pb-credits-tracks, written by html/credits.ts):
   * which columns exist is a per-project choice since the owner made every grouping
   * parameter switchable, and this stylesheet is generated from the theme alone. Cells
   * auto-flow into those tracks — every row emits the same cells, in the same order, so
   * there is nothing left for an explicit grid-column to protect against.
   */
  .pb-credits__row {
    display: contents;
  }

  .pb-credits__row > * {
    padding-block: 0.8em;
    border-bottom: 1px solid var(--color-border);
    /* The cells speak different voices (the link is small metadata caps, the title
       body text), and a description can make the title cell two lines tall — the
       row reads as one line when every cell centres in the row's height (owner
       request, 2026-08-02). The border above keeps drawing the shared row rule,
       which is why the cells stay equal-height rather than baseline-aligned. */
    align-content: center;
  }

  .pb-credits__list {
    grid-template-columns: var(--pb-credits-tracks, minmax(0, 1fr));
  }

  /* §31: the two link lists take real columns from here up. Explicit tracks rather
     than 'auto-fit', because the pair holds at most two children and the details
     block is no longer one of them; a lone list takes the whole width instead of
     leaving a hole, since either column is independently optional. */
  .pb-footer__pair {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--project-gap);
  }

  .pb-footer__pair > :only-child {
    grid-column: 1 / -1;
  }

  .pb-footer__columns {
    gap: var(--project-gap);
  }
}

@media (min-width: 68.75rem) {
  /* The desktop band keeps About's two columns and only opens the gutter between them:
     the same air the section rhythm uses everywhere else, never a length of its own. */
  .pb-about {
    --pb-about-column-gap: calc(var(--space-xl) * 1.5);
  }

  /* The desktop band's strip columns (the demo's grammar): 2 and 3 fill the frame
     width in equal columns, anything else wraps at four per row. Two is the base
     constant; these rules sit after the tablet band's, so they win the cascade. */
  .pb-projectmedia__thumbs[data-pb-cols-d='3'] {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  .pb-projectmedia__thumbs[data-pb-cols-d='4'] {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }

  /*
   * A normal title steps down from a featured one so the two tiers cannot shout at one
   * volume — but the desktop band hands a two-column card far more width than that step
   * was chosen against, and the film's name came out small in it. It takes 15% back
   * here, and only here (owner rule, 2026-08-02): the tablet band's narrower cards
   * still read at the step itself.
   *
   * It is a multiplier on the user's own scale rather than a size of its own, so a
   * retuned type scale carries the increase with it, and every visual style inherits
   * it — none of them sizes this title.
   */
  .pb-project--normal .pb-project__title {
    font-size: calc(var(--step-1) * 1.15);
  }

  /*
   * §19.1/§19.2, proportions from the static demo (owner request, 2026-08-02):
   * identity left, dominant primary in the centre zone, the smaller behind-the-scenes
   * images in a vertically centred rail at the inline end — the demo's 4 : 4.6 : 2.9,
   * with the rail content-sized so a hero without secondaries simply has no rail.
   * The section reserves the demo's height — a min() cap, never a fixed viewport
   * height (§19.3) — and everything centres inside it.
   */
  .pb-hero__inner {
    grid-template-columns: minmax(0, 4fr) minmax(0, 4.6fr) auto;
    min-block-size: min(50vw, 46rem);
  }

  .pb-hero--nomedia .pb-hero__inner {
    grid-template-columns: minmax(0, 1fr);
    min-block-size: 0;
  }

  .pb-hero__secondaries {
    grid-column: 3;
    grid-row: 1;
    flex-direction: column;
    flex-wrap: nowrap;
    align-self: center;
    width: min(16rem, 20vw);
  }

  /* Without the primary there is no rail to hang beside: the stills return to the
     wrap row under the text, exactly as the tablet band draws them. */
  .pb-hero--nomedia .pb-hero__secondaries {
    grid-column: 1;
    grid-row: 2;
    flex-direction: row;
    flex-wrap: wrap;
    align-self: auto;
    width: auto;
  }

  .pb-hero__secondary {
    flex: 0 0 auto;
    width: 100%;
  }

  .pb-hero--nomedia .pb-hero__secondary {
    flex: 1 1 9rem;
    width: auto;
  }

  /*
   * The footer's one-row close (the demo's 1.2 : 1 : 1, with our columns keeping
   * their own grid inside the wider track). Only the base row stays centred — the
   * demo centres its copyright at every width. The split grid asks for both halves;
   * the renderer withholds the modifier otherwise, so a lead-only or columns-only
   * footer keeps the stacked layout and no track is held open for a missing block.
   */
  .pb-footer__inner {
    align-items: flex-start;
    text-align: start;
  }

  .pb-footer__lead {
    align-items: flex-start;
  }

  .pb-footer__columns {
    justify-items: start;
  }

  .pb-footer__pair {
    justify-items: start;
  }

  /* Both are flex columns; the pair above is the grid. */
  .pb-footer__group,
  .pb-footer__column {
    align-items: flex-start;
  }

  .pb-footer--split .pb-footer__inner {
    display: grid;
    grid-template-columns: minmax(0, 1.2fr) minmax(0, 2fr);
    align-items: start;
    column-gap: var(--project-gap);
  }

  .pb-footer--split .pb-footer__lead {
    grid-column: 1;
  }

  .pb-footer--split .pb-footer__columns {
    grid-column: 2;
  }

  .pb-footer--split .pb-footer__base {
    grid-column: 1 / -1;
  }

  .pb-footer__base {
    align-items: center;
    text-align: center;
  }
}

/* Darkroom v2 — editorial contact sheet under a safelight. */
:root {
  /*
   * The three texture layers (brief §7B.1), read from the top down: grain over
   * mottle over vignette. Grain alone is noise on a screen; grain over blotching is
   * paper, and that is the whole difference between a flat dark background and an
   * underexposed print.
   *
   * The light alphas are what a body-text zone has to survive, so both stay at or
   * under the 0.04 the brief sets (§8.1). The dark ones may run higher: darkening a
   * near-black page cannot cost light text a single point of contrast.
   *
   * Both light alphas were cut hard on 2026-08-04 (owner: the ground must be at least
   * as dark as the older Darkroom's — this scheme is near-black and red). The measured
   * cause was not the palette: the tokens were *already* darker than v1's (#080808
   * against #0a0a0a), but the white speckle in these two tiles lifted the **rendered**
   * ground to [16,16,16] against v1's [10,10,10]. The texture is now carried almost
   * entirely by the dark speckle, which deepens the page instead of raising it.
   */
  --pb-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' seed='3'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.012 0'/%3E%3C/filter%3E%3Cfilter id='b'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' seed='8'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0'/%3E%3C/filter%3E%3Crect width='260' height='260' filter='url(%23a)'/%3E%3Crect width='260' height='260' filter='url(%23b)'/%3E%3C/svg%3E");
  --pb-mottle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='900' height='900'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.009' numOctaves='3' seed='5'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.012 0'/%3E%3C/filter%3E%3Cfilter id='b'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.006' numOctaves='3' seed='11'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.062 0'/%3E%3C/filter%3E%3Crect width='900' height='900' filter='url(%23a)'/%3E%3Crect width='900' height='900' filter='url(%23b)'/%3E%3C/svg%3E");
  --pb-vignette: radial-gradient(140% 108% at 50% 36%, transparent 52%, transparent 100%);

  /* The display voice's tracking, and the geometry of the decorative marks, named
     once so the sections below reason about a value instead of restating it. */
  --pb-caps-tracking: 0.03em;

  /*
   * The section divider's own switch (owner decision, 2026-08-04 — the same call was
   * made for Golden Hour v2, and the owner asked for it in neither theme).
   *
   * Set to 'none' because the page carries too many horizontal rules: every section
   * heading already draws one in this exact grammar — a hairline running to the
   * container's edge — so the divider above it was the same sentence said twice. On a
   * style whose whole vocabulary is hairlines that repetition is the loudest, not the
   * least. The rule below is kept whole, and its test with it, because the decision is
   * about density rather than about the device; restoring it is this one word.
   */
  --pb-section-rule: none;
  /*
   * One number governs how strongly the safelight burns (owner request, 2026-08-04 —
   * the same lever Golden Hour v2 carries). Every red layer below states its own base
   * strength and multiplies by this, so the whole atmosphere is tuned in one place
   * rather than in six.
   */
  --pb-light: 1;

  /* The safelight, mixed from the accent so a repainted palette repaints the room.
     Every fallback below is a real token or 'transparent', so a browser without
     color-mix gets a quieter page rather than a wrong one. */
  --pb-red-veil: var(--color-elevated-surface);
  --pb-red-wash: transparent;
  --pb-red-deep: var(--color-accent);
  --pb-red-edge: var(--color-border);
  /*
   * The footer's own ground, a step below the page (owner decision, 2026-08-04: the
   * footer should always sit a little darker and read as its own block). The fallback
   * is the page colour itself — without color-mix there is no way to take a token
   * *down*, and matching the page is the honest miss.
   */
  --pb-footer-bg: var(--color-page-background);
  /* The closing shade, multiplied into the page's foot rather than laid over it. */
  --pb-footer-shade: var(--color-accent-contrast);
}

@supports (color: color-mix(in srgb, currentColor 50%, transparent)) {
  :root {
    --pb-vignette: radial-gradient(140% 108% at 50% 36%, transparent 52%, color-mix(in srgb, #000 62%, transparent) 100%);
    /* The red carries further on the deeper ground (owner, 2026-08-04: the scheme is
       near-black and red, and the safelight is where the red lives at scale). Each
       strength is its base times the master dimmer above. */
    --pb-red-veil: color-mix(in srgb, var(--color-accent) calc(19% * var(--pb-light)), transparent);
    --pb-red-wash: color-mix(in srgb, var(--color-accent) calc(7% * var(--pb-light)), transparent);
    /* The structural red the brief asked for as a token and the contrast guard would
       not allow as one: 'accent-secondary-on-page' holds a 4.5 floor because the
       Theme panel offers that token as a logo and signature colour, and a red dark
       enough to be structure is nowhere near it. Mixed here it is decoration only —
       it is never behind text and never IS text. */
    --pb-red-deep: color-mix(in srgb, var(--color-accent) 58%, #000);
    --pb-red-edge: color-mix(in srgb, var(--color-accent) calc(34% * var(--pb-light)), transparent);
    /* The page's own colour taken toward black — the token stays the source, black is
       only the direction, which is how Glass and Golden Hour v2 reach their deep
       bases. */
    --pb-footer-bg: color-mix(in srgb, var(--color-page-background) 45%, #000);
  }
}

/*
 * The ground. The vignette is fixed to the viewport because it is a lighting
 * condition rather than a texture — the light does not scroll with the paper — while
 * the grain and the mottle travel with the page, which is what makes them read as
 * the sheet's own surface. The vignette costs no file and no request: it is a
 * gradient mixed from black, and without color-mix it resolves to 'transparent' at
 * both stops and paints nothing at all.
 */
body {
  background-image:
    var(--pb-grain),
    var(--pb-mottle),
    var(--pb-vignette);
  background-repeat: repeat, repeat, no-repeat;
  background-attachment: scroll, scroll, fixed;
  background-size: 260px 260px, 900px 900px, cover;
}

/*
 * A technical, tightly set display voice: Archivo 700 in caps, closer than v1's. The
 * re-cased display class is a sanctioned theme decision (themeStyleRegistry.test.ts),
 * and v1 already speaks caps here — v2 differs by setting them tighter and heavier,
 * never by sizing what the shared layer sizes.
 */
.pb-display {
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--pb-caps-tracking);
  line-height: 1;
}

/* The quote closes the page in the same voice, one register down and left as
   written — a sentence is not a label. */
.pb-footer__quote {
  font-weight: 400;
  line-height: 1.3;
  letter-spacing: 0.01em;
}

/*
 * The hairline grid (brief §5.3). Every section after the first opens with a rule,
 * and the rule stops where the content does rather than running to the viewport's
 * edge (§9 desktop: no edge decoration independent of the content grid). Drawn as a
 * pseudo-element so it can sit on the section's own top edge instead of below the
 * section padding, and inset to exactly the container's inline edges.
 *
 * The hero is never preceded by a section, so its own plate keeps '::before' to
 * itself; the CTA's plate takes '::after' for the same reason.
 *
 * Currently switched off — see '--pb-section-rule' at the top for why, and for the one
 * word that brings it back.
 */
.pb-section {
  position: relative;
}

.pb-section + .pb-section::before {
  content: "";
  display: var(--pb-section-rule);
  position: absolute;
  inset-block-start: 0;
  inset-inline: var(--mobile-gutter);
  height: 1px;
  background-color: var(--color-border);
  pointer-events: none;
}

/*
 * The two project sections sit closer to what precedes them (owner request,
 * 2026-08-04). They open with a heading and a picture rather than with prose, so the
 * full rhythm above them read as a break in the page rather than as air.
 *
 * Only the *opening* side is shortened, and as a proportion of the user's own token
 * rather than as a number of pixels, so a retuned rhythm still carries it.
 */
.pb-featured,
.pb-normal {
  padding-block-start: calc(var(--section-spacing) * 0.55);
}

/*
 * The section label is the design's real label (brief §6.3): the user's own heading
 * in the mono data voice, red, opened by a neutral crosshair and closed by a long
 * grey rule. The crosshair is two 1px gradients in a square box — no glyph, no text,
 * nothing to announce — and the filler's anatomy stays the shared layer's.
 */
.pb-section__heading {
  font-family: var(--font-metadata);
  font-weight: 400;
  letter-spacing: 0.24em;
  color: var(--color-accent);
  gap: var(--space-m);
}

.pb-section__heading::before {
  content: "";
  flex: none;
  width: 0.7em;
  height: 0.7em;
  pointer-events: none;
  background-image:
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent));
  background-size: 100% 1px, 1px 100%;
  background-position: center, center;
  background-repeat: no-repeat;
}

/*
 * The red grammar (brief §5.2), and the whole of it: the section label, the role
 * lines, the years, the wordmark, the external links, the button. Everything else —
 * every border, every metadata line, every caption — stays grey. No text-shadow
 * anywhere: v1 lights its letters, v2 lights the room.
 */
.pb-hero__title,
.pb-project__role,
.pb-credits__role {
  color: var(--color-accent);
}

.pb-project__year,
.pb-credits__year {
  color: var(--color-accent);
  font-variant-numeric: tabular-nums;
}

.pb-header__logo--text {
  color: var(--color-accent);
  letter-spacing: 0.06em;
}

/*
 * Media treatment (brief §5.6). The work keeps the colour it was shot in: the filter
 * below reaches the contact sheet and nothing else — not the framed picture, not the
 * hero, not a video poster — and the exported bytes are the photographer's own
 * either way, because this is a stylesheet and nothing more.
 */
.pb-projectmedia__thumb > .pb-media > img {
  filter: grayscale(1) contrast(1.1) brightness(0.94);
  opacity: 0.74;
  transition:
    filter var(--transition-duration) ease,
    opacity var(--transition-duration) ease;
}

/* Colour returns under the pointer, like a proof coming up in the tray. */
.pb-projectmedia__thumb:hover > .pb-media > img,
.pb-projectmedia__thumb:focus-visible > .pb-media > img {
  filter: none;
  opacity: 1;
}

/* A guard rather than a fix: a filter inherits, so this is what keeps an embedded
   player in colour if a later rule ever sets one above it. */
.pb-video__iframe {
  filter: none;
}

/* Every proof on the sheet carries its own 1px edge (brief §6.4), and answers in
   red. Both selectors, because a video's thumbnail is an anchor with a border rule
   of its own one class more specific than the button's. */
/*
 * A proof is a button, so it is the one picture on the page that may answer the
 * pointer. Its 1px border is declared transparent by the shared layer and stays that
 * way at rest — always present, so lighting it up costs no reflow and the contact
 * sheet never twitches. Both selectors, because a video's thumbnail is an anchor whose
 * own border rule is one class more specific than the button's.
 */
.pb-projectmedia__thumb,
.pb-projectmedia__thumb.pb-video {
  border-color: transparent;
}

.pb-projectmedia__thumb:hover,
.pb-projectmedia__thumb:focus-visible {
  border-color: var(--color-accent);
}

/*
 * A picture wears no frame until it is pointed at (owner decision, 2026-08-04). The
 * letterbox chrome of §7B.4 and the corner marks of §5.4 are gone: on a page whose
 * whole grammar is already hairlines they read as one more rule rather than as a
 * camera mark.
 *
 * Nothing lights up under the pointer either, except what can actually be pressed: the
 * proofs, which are buttons, keep the accent edge the older Darkroom gave them. A
 * framed picture that answered the pointer would be promising an interaction it does
 * not have.
 *
 * The corner vocabulary keeps its one remaining home: the CTA's closing block, where it
 * frames a *composition* rather than a photograph.
 */

/*
 * The button is an outline safelight control (brief §6.7): the accent as a ring and
 * a label rather than as a slab, lit faintly at rest and more under the pointer. The
 * ring's label is 'accent on page' — the pair the guard already measures at 5.5:1 —
 * and the filled hover state is the pair it measures as 'accent contrast on accent'.
 *
 * 'a.' throughout, exactly as the shared layer splits it: when the URL is unusable
 * the markup degrades to a span, and a lit ring around something that does not
 * navigate is a lie.
 */
a.pb-button {
  background-color: transparent;
  color: var(--color-accent);
  border-color: var(--color-accent);
  box-shadow: 0 0 12px -4px var(--color-accent);
  transition:
    background-color var(--transition-duration) ease,
    color var(--transition-duration) ease,
    box-shadow var(--transition-duration) ease;
}

a.pb-button:hover,
a.pb-button:focus-visible {
  background-color: var(--color-accent);
  color: var(--color-accent-contrast);
  box-shadow: 0 0 22px -4px var(--color-accent);
}

/*
 * Header (brief §6.1) — slim and technical, and nothing more than the navigation the
 * portfolio actually has. No status dot, no readout, no active item: an item marked
 * active would be a claim about where the visitor is that nothing here can keep.
 * Deliberately not sticky either (brief §12 lists it P3): the anchor targets, the
 * mobile panel and the no-JS path all pay for it, and none of them gains.
 *
 * The contact row reads one step quieter than the menu, which is the order the brief
 * asks for — the header's job is the navigation.
 */
.pb-header__contacts a {
  color: var(--color-text-muted);
}

.pb-header__link:hover,
.pb-header__link:focus-visible,
.pb-header__contacts a:hover,
.pb-header__contacts a:focus-visible {
  color: var(--color-accent);
}

/*
 * Hero (brief §6.2). The name is the page's loudest thing by a wide margin — v1's
 * hero is a paragraph with a red rule under it — so it takes a fifth again of the
 * display step and closes right up. The step is the user's own scale multiplied,
 * never a size of this style's invention, so a retuned type scale still governs it.
 */
.pb-hero__name {
  font-size: calc(var(--step-4) * 1.05);
  letter-spacing: 0;
  line-height: 0.94;
  /*
   * The identity column is a flex column aligned to its start, so every child sizes
   * itself to its content and a name wider than the column simply grows past it —
   * measured at 1440, where the last word of a three-part name disappeared behind the
   * photograph. The cap is what makes the wrap rules below reachable at all.
   *
   * 'break-word' then only acts when a single word cannot fit on a line by itself,
   * which is precisely the case where the alternative is a name hidden under the
   * picture. Ordinary names still break between words.
   */
  max-width: 100%;
  overflow-wrap: break-word;
}

/* v1 draws a red rule under the name. v2 draws it *above*, thinner and longer, as
   the opening rule of the identity block — the hairline grammar, not a flourish. */
.pb-hero__name::after {
  content: "";
  display: block;
  width: 5.5rem;
  height: 1px;
  margin-block-start: var(--space-m);
  background-color: var(--color-accent);
}

/* The place is the eyebrow, marked by a short neutral tick rather than coloured:
   red is for roles, years and marks, and a location is none of those. */
.pb-hero__location {
  display: flex;
  align-items: center;
  gap: 0.7em;
}

.pb-hero__location::before {
  content: "";
  flex: none;
  width: 1.5em;
  height: 1px;
  background-color: var(--color-text-muted);
}

/* The professional title is the hero's role line: red, in the data voice, and
   re-cased — '.pb-hero__title' is on the shared layer's re-casing whitelist. */
.pb-hero__title {
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  text-transform: uppercase;
  letter-spacing: 0.18em;
}

/*
 * Availability and representation read as one quiet status block under the bio, each
 * opened by its own textless marker (brief §7B.5).
 *
 * The dot is legitimate here and is exactly what the header's forbidden status light
 * is not: there is a real field behind it, and the sentence the user wrote is beside
 * it — so nothing is communicated by colour alone (§8.1).
 */
.pb-hero__availability,
.pb-hero__representation {
  display: flex;
  align-items: baseline;
  gap: 0.7em;
  /* In the primary ink (owner request, 2026-08-04, carried over from Golden Hour v2):
     these are facts a producer scans for, not the quiet end of the identity block. */
  color: var(--color-text-primary);
}

/*
 * The crest's rules sit centred on the label, not on its box (owner report,
 * 2026-08-04, measured on Golden Hour v2 and true of every theme that shows these).
 * The shared layer caps a crest at 10ch and a single word longer than that cannot
 * wrap, so it overflowed to the inline end while the rules stayed put. A min-width
 * beats a max-width: the box grows to the word, and a label that *can* wrap still
 * meets its measure.
 */
.pb-hero__credits li {
  min-width: min-content;
}

/*
 * One marker for both status lines (owner request, 2026-08-04): the same red bullet
 * opens availability and representation. They answer the same kind of question — is
 * this person free, and who speaks for them — so a grey tick on one and a red dot on
 * the other read as two different kinds of fact when they are one.
 */
.pb-hero__availability::before,
.pb-hero__representation::before {
  content: "";
  flex: none;
  width: 0.45em;
  height: 0.45em;
  border-radius: 50%;
  background-color: var(--color-accent);
}

/*
 * The contacts are a technical link row. Deliberately *not* re-cased: only the
 * header's contact list is on the shared layer's whitelist, and what a person typed
 * into a contact field is theirs as typed.
 */
.pb-hero__contacts a {
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  color: var(--color-text-secondary);
  text-decoration: underline;
  text-decoration-color: transparent;
  text-underline-offset: 0.4em;
  transition:
    color var(--transition-duration) ease,
    text-decoration-color var(--transition-duration) ease;
}

.pb-hero__contacts a:hover,
.pb-hero__contacts a:focus-visible {
  color: var(--color-accent);
  text-decoration-color: var(--color-accent);
}

/*
 * The safelight, directed (brief §7B.2): a pool bleeding into the space between the
 * text column and the picture, a much wider wash under it, and the grain over both —
 * the speckle reads *through* the light, which is what makes it a lamp in a room
 * rather than a neon panel. Both pools resolve through the custom properties declared
 * at the top, so the color-mix version and its solid fallback are stated once.
 *
 * The mask fades the plate out before the section ends; currentcolor there is only an
 * alpha ramp, since a mask reads nothing else.
 */
.pb-hero::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image:
    var(--pb-grain),
    radial-gradient(42rem 26rem at 46% 16%, var(--pb-red-veil), transparent 66%),
    radial-gradient(100rem 58rem at 55% 52%, var(--pb-red-wash), transparent 78%);
  -webkit-mask-image: linear-gradient(to bottom, currentcolor 60%, transparent 98%);
          mask-image: linear-gradient(to bottom, currentcolor 60%, transparent 98%);
}

/* Without a picture the room has no gap to light, so the pool moves to the side the
   text is not using — the decoration adapts to the empty frame rather than marking
   where one would have been (brief §6.2, text-only hero). */
.pb-hero--nomedia::before {
  background-image:
    var(--pb-grain),
    radial-gradient(38rem 24rem at 82% 20%, var(--pb-red-veil), transparent 68%),
    radial-gradient(90rem 54rem at 50% 55%, var(--pb-red-wash), transparent 78%);
}

/* The plate is decoration behind the section's own content — the ghost year's
   pattern: the layer at 0, the content lifted to 1. */
.pb-hero > .pb-container {
  position: relative;
  z-index: 1;
}

/*
 * Featured projects (brief §6.4) — a film-like editorial spread: a dominant picture,
 * and a body that is a structured column rather than a card. The metadata row opens
 * it under its own hairline, so year and category read as the block's header line
 * instead of as a first paragraph.
 *
 * Only what the project model actually holds is shown. No director, production,
 * camera, lens, ratio or runtime line: there is no field behind any of them, and
 * inventing one is the failure mode §1.3 exists to prevent.
 */
.pb-project--featured > .pb-project__body {
  gap: 0.75em;
}

.pb-project--featured .pb-project__meta {
  align-self: stretch;
  padding-block-end: 0.7em;
  border-block-end: 1px solid var(--color-border);
  color: var(--color-text-muted);
}

.pb-project__category {
  color: var(--color-text-muted);
}

.pb-project--featured .pb-project__description {
  max-width: 42ch;
}

/*
 * Normal projects (brief §6.5). Of the two separations the brief allows, this is the
 * per-card frame rather than a rule down the gutter: the grid gap already spaces the
 * pair, and a rule drawn in a gap has no element to hang on that survives the
 * subgrid the shared layer uses to align the two texts.
 *
 * One top rule per card, opening with a short accent segment and closing with a tick
 * at each corner — four background layers, no markup, no overflow, and the same
 * grammar in both bands, where a stacked phone reads it as the horizontal rule
 * between cards that the brief asks for.
 */
.pb-project--normal {
  padding-block-start: var(--space-m);
  background-image:
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-border), var(--color-border)),
    linear-gradient(var(--color-border), var(--color-border)),
    linear-gradient(var(--color-border), var(--color-border));
  background-size: 2.25rem 1px, 100% 1px, 1px 0.5rem, 1px 0.5rem;
  background-position: top left, top left, top left, top right;
  background-repeat: no-repeat;
}

.pb-project--normal .pb-project__meta {
  color: var(--color-text-muted);
}

/* The external link keeps the shared layer's arrow and gains the frame the rest of
   the page is drawn with — a rule under the label, red only once it is pointed at. */
.pb-project__link {
  border-bottom-color: var(--color-border);
}

/*
 * Additional credits (brief §6.6) — a quiet editorial filmography, never a lab sheet
 * and never a table. The group label is the editorial heading of its block; a row
 * lifts by one surface step under the pointer or when it holds focus, which is
 * feedback rather than a selected database row. No thumbnail column: the text tier
 * deliberately renders no media, and adding pictures here would contradict the
 * user's own choice of tier.
 */
.pb-credits__grouplabel {
  color: var(--color-accent);
  letter-spacing: 0.2em;
  border-block-end-color: var(--color-border);
}

.pb-credits__title {
  font-weight: 500;
}

.pb-credits__row:hover > *,
.pb-credits__row:focus-within > * {
  background-color: var(--color-surface-background);
}

.pb-credits__link {
  color: var(--color-accent);
}

/*
 * CTA (brief §6.7) — the page's closing statement, lit from one corner.
 *
 * The sketches put a status panel beside their closing block; there is no data behind
 * one here, so the composition carries the weight instead: a corner-lit plate, a
 * framed block with corner marks, generous air, and an outline button. The eyebrow is
 * this section's label — it has no section heading of its own — so it takes the same
 * red mono voice the headings elsewhere carry.
 */
.pb-cta__eyebrow {
  color: var(--color-accent);
  letter-spacing: 0.24em;
}

/* The block padding went with the corner frame it was holding away from the text
   (owner, 2026-08-04); on the section's own rhythm it was air on top of air. */
.pb-cta__inner {
  gap: 0.8em;
}

.pb-cta__description {
  max-width: 46ch;
}

/*
 * The corner marks are gone from here too, and from this style altogether (owner
 * decision, 2026-08-04). The viewfinder corner is **Golden Hour's** signature; having
 * played it there, playing it again in a second theme makes both of them read as
 * variations of one house style rather than as two languages. Darkroom's own marks are
 * the hairline, the crosshair on a section label and the perforation that closes the
 * page — none of which Golden Hour uses.
 */

/*
 * The safelight bleeding in from the top inline-start corner, grain over it — the
 * plate takes '::after' because the section rule above already owns '::before'.
 * Behind the content by z-index rather than by source order, exactly as the hero's
 * plate is.
 */
.pb-cta::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image:
    var(--pb-grain),
    radial-gradient(34rem 22rem at 6% 0%, var(--pb-red-veil), transparent 68%),
    radial-gradient(80rem 46rem at 40% 40%, var(--pb-red-wash), transparent 78%);
}

.pb-cta > .pb-container {
  position: relative;
  z-index: 1;
}

/*
 * Footer (brief §6.8) — a precise, multi-zone close over the same safelight and
 * grain, structuring only what the footer markup actually renders. No closing
 * telemetry strip, no end-of-roll, no archive time: those are captions with nothing
 * behind them.
 */
/*
 * The footer is its own block, and always a little darker than the page (owner
 * decision, 2026-08-04). The separation is made with colour rather than with new
 * decoration: the ground steps below the page, the safelight pool on its top edge
 * stays — it is the light crossing the boundary — and the closing shade below takes
 * the last centimetres down to almost nothing. The wide wash that used to sit under
 * the pool is gone: it reached the far corners and lifted the darker ground back to
 * the page's own colour exactly where the step has to be read.
 */
.pb-footer {
  position: relative;
  background-color: var(--pb-footer-bg);
  background-image:
    var(--pb-grain),
    radial-gradient(54rem 20rem at 50% 0%, var(--pb-red-veil), transparent 70%);
}

/*
 * The closing shade. Multiplied, because accentContrast is *lighter* than the page —
 * painting it normally would lift the footer's foot rather than settle it — and
 * multiplying a near-black over a near-black is what gives the page's last centimetres
 * their weight. It can only darken, so it can only help the text above it. Withheld
 * entirely where blend modes are unsupported: no blend, no layer, rather than a layer
 * doing the opposite of its job.
 */
@supports (mix-blend-mode: multiply) {
  .pb-footer::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    mix-blend-mode: multiply;
    background-image: linear-gradient(
      to top,
      var(--pb-footer-shade) 0%,
      transparent 74%
    );
  }

  .pb-footer > .pb-container {
    position: relative;
    z-index: 1;
  }
}

/* The heading names the reachable-contact block in the same voice the section labels
   use, which is what ties the close back to the page above it. */
.pb-footer__heading {
  color: var(--color-accent);
  letter-spacing: 0.24em;
}

.pb-footer__contacts a {
  font-family: var(--font-metadata);
  font-size: var(--step--1);
  letter-spacing: var(--metadata-letter-spacing);
  color: var(--color-text-secondary);
}

.pb-footer__contacts a:hover,
.pb-footer__contacts a:focus-visible {
  color: var(--color-accent);
}

/* The same two markers as the hero, so the status block reads identically wherever
   the user chose to place it (brief §7B.5). */
.pb-footer__location,
.pb-footer__availability {
  display: flex;
  align-items: baseline;
  gap: 0.7em;
}

/* Availability carries the primary ink wherever the user put it; the location beside
   it stays the quiet metadata it always was. */
.pb-footer__availability {
  color: var(--color-text-primary);
}

.pb-footer__availability::before {
  content: "";
  flex: none;
  width: 0.45em;
  height: 0.45em;
  border-radius: 50%;
  background-color: var(--color-accent);
}

.pb-footer__location::before {
  content: "";
  flex: none;
  width: 1.5em;
  height: 1px;
  background-color: var(--color-text-muted);
}

.pb-footer__legal,
.pb-footer__copyright {
  color: var(--color-text-muted);
}

@media (min-width: 45rem) {
  :root {
    --pb-corner: 1rem;
  }

  /* The section rule follows the container's own inline edges: the gutter on a
     narrow window, and the centred content's edge once the page is wider than the
     measure the theme allows it. */
  .pb-section + .pb-section::before {
    inset-inline: max(
      var(--desktop-gutter),
      calc((100% - var(--content-max-width)) / 2 + var(--desktop-gutter))
    );
  }

  /*
   * The tablet hero opens across the full measure (owner request, 2026-08-04, carried
   * over from Golden Hour v2 — the constraint is the shared layer's, not one theme's).
   * The band is narrow enough that a name sharing its row with the picture had nowhere
   * to go, so the place and the name take a full-width row each and only below them
   * does the page split into text and photograph.
   *
   * All three of those blocks live inside '.pb-hero__body', so the body stops being a
   * box and its children are placed by hand in the hero's own grid. Every child is
   * placed rather than auto-flowed, which is what keeps the layout deterministic when
   * a block is switched off; the picture spans the text rows beside it. The widest
   * band undoes all of it.
   */
  .pb-hero__inner {
    row-gap: var(--space-s);
  }

  .pb-hero__body {
    display: contents;
  }

  .pb-hero__location {
    grid-column: 1 / -1;
    grid-row: 1;
  }

  .pb-hero__name {
    grid-column: 1 / -1;
    grid-row: 2;
  }

  .pb-hero__title {
    grid-column: 1;
    grid-row: 3;
  }

  .pb-hero__bio {
    grid-column: 1;
    grid-row: 4;
  }

  .pb-hero__availability {
    grid-column: 1;
    grid-row: 5;
  }

  .pb-hero__representation {
    grid-column: 1;
    grid-row: 6;
  }

  .pb-hero__contacts {
    grid-column: 1;
    grid-row: 7;
  }

  .pb-hero__media {
    grid-column: 2;
    grid-row: 3 / 8;
  }

  .pb-hero__credits {
    grid-column: 1 / -1;
    grid-row: 8;
    justify-content: center;
    text-align: center;
  }

  /* Unless there is no photograph: then the hero is a single text block down the start
     edge, and a centred row would be the only thing not lining up with it. */
  .pb-hero--nomedia .pb-hero__credits {
    justify-content: flex-start;
    text-align: start;
  }

  .pb-hero__secondaries {
    grid-column: 1 / -1;
    grid-row: 9;
  }

  /*
   * A featured project set to slideshow takes the whole measure at tablet (owner
   * request, 2026-08-04): the picture spans the width and the text settles beneath it
   * on the start edge, at two thirds. A rotating frame is the strongest thing a
   * portfolio can put on a page, and in this band the split gave it barely more room
   * than a still.
   *
   * Recognised by ':has()' rather than by a class, because the renderer already tells
   * the page what it is — 'data-pb-slideshow' on the frame — and adding a second signal
   * to the markup for one theme's benefit is what the brief rules out. Where ':has()'
   * is unsupported the block keeps the ordinary split, which is a layout rather than a
   * defect.
   */
  .pb-project--featured:has(.pb-projectmedia__frame[data-pb-slideshow='true']) {
    grid-template-columns: minmax(0, 1fr);
  }

  .pb-project--featured:has(.pb-projectmedia__frame[data-pb-slideshow='true'])
    > .pb-project__media {
    grid-column: 1;
    grid-row: 1;
  }

  .pb-project--featured:has(.pb-projectmedia__frame[data-pb-slideshow='true'])
    > .pb-project__body {
    grid-column: 1;
    grid-row: 2;
    max-width: 66%;
    padding-inline: 0;
  }

  /*
   * The description follows the block out (owner request, 2026-08-04). 42ch is the
   * measure for a caption column standing beside a picture; under a full-width frame,
   * inside a block two thirds of the page wide, it read as a narrow ribbon with the
   * rest of the line empty. 62ch is the measure this repository already reaches for
   * when a text block loses the picture it was sized against — wider as asked, and
   * still a line the eye can return from.
   */
  .pb-project--featured:has(.pb-projectmedia__frame[data-pb-slideshow='true'])
    .pb-project__description {
    max-width: 62ch;
  }

  /* A vertical divider closes the logo zone once the header is one row (brief §6.1).
     Below this band the logo sits alone above the panel and has nothing to divide
     itself from. */
  .pb-header__logo {
    padding-inline-end: var(--space-m);
    border-inline-end: 1px solid var(--color-border);
  }

  /* The picture takes a little more of the row than the shared layer gives it: v2 is
     an image-led composition, and the identity column is its caption. */
  .pb-hero__inner {
    grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr);
  }

  /*
   * The featured text column starts at the picture's top edge rather than centring
   * against it (brief §6.4): a caption column reads editorially from the top, and it
   * is also what gives the ghost numeral below a top edge it can be measured from.
   */
  .pb-project--featured {
    align-items: start;
    position: relative;
  }

  .pb-project--featured > .pb-project__media,
  .pb-project--featured > .pb-project__body {
    position: relative;
    z-index: 1;
  }

  /*
   * No rule runs between a featured project's text and its picture (owner decision,
   * 2026-08-04). The article's own gap already separates the two, and a hairline in it
   * added a third vertical line to a page that carries the section rules and the normal
   * grid's spine — the gutter says "these are two columns" perfectly well on its own.
   *
   * The column keeps the air the rule used to stand in, so the text still starts off
   * the picture rather than against it.
   */
  .pb-project--media-start:not(.pb-project--nomedia) > .pb-project__body {
    padding-inline-start: var(--space-l);
  }

  .pb-project--media-end:not(.pb-project--nomedia) > .pb-project__body {
    padding-inline-end: var(--space-l);
  }

  /*
   * The ghost year, tuned the way the sketches use it (brief §7B.7): a giant, faint
   * numeral sitting *behind and above* the title rather than beside it. It is the
   * project's real year and nothing else — no year, no numeral, and no CSS may
   * supply one. Hidden below this band, where a phone stacks the picture above the
   * text and the numeral would only hide behind the photograph.
   *
   * Out of flow, unclickable, aria-hidden in the markup, and under the content at
   * z-index 0 — the ghost year's standing pattern.
   */
  .pb-project--featured > .pb-project__ghost-year {
    display: block;
    position: absolute;
    inset-block-start: 0;
    z-index: 0;
    pointer-events: none;
    font-family: var(--font-display);
    font-weight: 700;
    font-size: calc(var(--step-4) * 1.7);
    font-variant-numeric: tabular-nums;
    line-height: 0.78;
    letter-spacing: -0.02em;
    color: var(--color-accent);
    /* A quarter of its own cap height above the column's top, so the metadata line
       and the title read across its lower half. The lift follows the display step
       at every viewport because it is stated in the numeral's own em. */
    transform: translateY(-0.24em);
    opacity: 0.085;
  }

  /* It hangs on whichever side the text column takes: the inline end when the media
     leads, the inline start when the media closes. */
  .pb-project--media-start > .pb-project__ghost-year {
    inset-inline-end: 0;
  }

  .pb-project--media-end > .pb-project__ghost-year {
    inset-inline-start: 0;
  }

  /* With no picture there is no column to hang beside: the article is one full-width
     track, so an inline-end numeral would float alone at the far edge with the text
     it belongs to hundreds of pixels away. It returns to the text's own side. */
  .pb-project--nomedia > .pb-project__ghost-year {
    inset-inline: 0 auto;
  }

  /*
   * The film-edge perforation closing the page (brief §7B.3): a repeating gradient of
   * small blocks, drawn once at the foot of the footer. Strictly geometry — no frame
   * numbers, no stock name, nothing the sketches print along their film edges — and
   * dropped on a phone, where the page has no width to spare for decoration.
   */
  .pb-footer::after {
    content: "";
    position: absolute;
    /* Aligned with the content, like every other rule in this style — a strip running
       to the window's edges is the one thing §9 asks decoration not to do. */
    inset-inline: max(
      var(--desktop-gutter),
      calc((100% - var(--content-max-width)) / 2 + var(--desktop-gutter))
    );
    inset-block-end: var(--space-m);
    height: 0.34rem;
    pointer-events: none;
    background-image: repeating-linear-gradient(
      to right,
      var(--color-border) 0 0.55rem,
      transparent 0.55rem 1.45rem
    );
    opacity: 0.7;
  }
}

@media (min-width: 68.75rem) {
  /*
   * The tablet composition is undone here, in full. The repository allows exactly two
   * breakpoints and no max-width query, so a band-only layout is always "set it in the
   * narrower band, take it back in the wider one" — and taking it back has to name
   * every property the narrower band set, or a rule meant for one width governs both.
   */
  .pb-hero__body {
    display: flex;
  }

  .pb-hero__location,
  .pb-hero__name,
  .pb-hero__title,
  .pb-hero__bio,
  .pb-hero__availability,
  .pb-hero__representation,
  .pb-hero__contacts,
  .pb-hero__credits {
    grid-column: auto;
    grid-row: auto;
  }

  .pb-hero__credits {
    justify-content: flex-start;
    text-align: start;
  }

  .pb-hero__inner {
    row-gap: var(--project-gap);
  }

  /* Re-asserted: the shared layer placed both, and the tablet block above overrode
     them from a later stylesheet. */
  .pb-hero__media {
    grid-column: 2;
    grid-row: 1;
  }

  .pb-hero__secondaries {
    grid-column: 3;
    grid-row: 1;
  }

  /*
   * With no photograph the identity has the whole row to itself, and the shared
   * layer's 62ch measure left most of this band empty beside it (owner request,
   * 2026-08-04, carried over from Golden Hour v2). The biography follows the block
   * out: 46ch is the measure beside a picture, and a ribbon without one.
   */
  .pb-hero--nomedia .pb-hero__body {
    max-width: 66%;
  }

  .pb-hero--nomedia .pb-hero__bio {
    max-width: 62ch;
  }

  /* The imprint runs the full measure here: the shared layer's 60ch is a reading
     measure for prose, and a legal line is scanned rather than read. */
  .pb-footer__legal {
    max-width: none;
  }

  /*
   * The widest band's controlled asymmetry: the identity column against a clearly
   * dominant primary, with the secondaries as a narrow proof rail rather than a third
   * column (brief §6.2 — the secondaries may be read as a smaller proof strip).
   *
   * The rail's width is the lever that makes the name work. Measured at 1440: the
   * shared layer's 16rem rail leaves the identity track at 416px, and the audit
   * fixture's longest word needs 494px at this size — so the name broke inside a word.
   * A 12rem rail returns 64px to the two main tracks, which puts the identity column
   * past 500px and the name back to three whole lines, without taking anything from
   * the picture. It is the picture that gains the rest.
   */
  .pb-hero__secondaries {
    width: min(12rem, 14vw);
  }

  .pb-hero__inner {
    grid-template-columns: minmax(0, 4.2fr) minmax(0, 4.8fr) auto;
  }

  /*
   * The closing statement at closing-statement size. This sizes the CTA headline
   * alone — never '.pb-display--section' itself and never a project title, which the
   * cross-style contract reserves for the shared layer, since the tier hierarchy is
   * one decision for every theme. The CTA headline is not part of that hierarchy: it
   * is the page's last sentence.
   */
  .pb-cta__headline {
    font-size: var(--step-3);
  }
}

/*
 * The pause inside the About story (owner request, 2026-08-04): this style's own
 * perforation, the mark that already closes the page, at text scale. Strictly geometry
 * — no frame number, no stock name — and it stays inside its column, so it reads as a
 * gap between two strips of text rather than as a divider across the section.
 */
.pb-rich__rule {
  border: 0;
  block-size: 0.22rem;
  background-image: repeating-linear-gradient(
    to right,
    var(--color-border) 0 0.4rem,
    transparent 0.4rem 1.05rem
  );
  opacity: 0.75;
}

@font-face {
  font-family: "Archivo";
  font-weight: 400;
  font-style: normal;
  src: url("../assets/fonts/archivo-400.woff2") format("woff2");
  font-display: swap;
}
@font-face {
  font-family: "Archivo";
  font-weight: 700;
  font-style: normal;
  src: url("../assets/fonts/archivo-700.woff2") format("woff2");
  font-display: swap;
}
@font-face {
  font-family: "Caveat";
  font-weight: 400;
  font-style: normal;
  src: url("../assets/fonts/caveat-400.woff2") format("woff2");
  font-display: swap;
}
@font-face {
  font-family: "Space Mono";
  font-weight: 400;
  font-style: normal;
  src: url("../assets/fonts/space-mono-400.woff2") format("woff2");
  font-display: swap;
}