/* ==========================================================================
   Hector Miranda — Blueprint / Parametric Precision
   See /Website/design.md for the full rationale behind every decision here.
   ========================================================================== */

@font-face {
  font-family: "HEX Franklin";
  src: url("../fonts/hex-franklin/HEX_Franklin_v0.3_Variable.woff2") format("woff2-variations");
  font-weight: 400 800;
  font-stretch: 50% 100%;
  font-display: swap;
}

:root {
  --paper: #E4E4DD;
  --ink: #000000;
  --blueprint: #2B2CE1;
  --error: #E43224; /* Error/validation messaging only (2026-07-23, Hector) — the one deliberate exception to the three-colour system. Currently used by .gate-error; reuse this token for any future error/validation copy rather than introducing a new red. */
  --ink-muted-40: rgba(0, 0, 0, 0.40); /* hero cycling-headline debris outline color (2026-07-25: started at --annotation/30%, dialed to a flat 20% solid-fill tint; 2026-07-27: fade mechanism switched from solid fill to a 0.5px hollow outline, bumped to 40% since a hairline stroke needs more contrast than a filled area to read at the same weight — renamed from --ink-muted-20 to match, only ever used by this one feature) */
  --ink-muted-30: rgba(0, 0, 0, 0.30); /* ghosted big headlines (How I Work B1) */
  --ink-muted-55: rgba(0, 0, 0, 0.55);
  --ink-muted-65: rgba(0, 0, 0, 0.65);
  --ink-hairline: rgba(0, 0, 0, 0.15);
  --blueprint-grid: rgba(43, 44, 225, 0.14);
  --blueprint-soft: rgba(43, 44, 225, 0.08); /* fainter than --blueprint-grid; Strengths ray-field at rest */
  --annotation: var(--ink-muted-30); /* Label/Annotation split (Option B, 2026-07-20 decision) — the dimmed "notation" tier: doc-lines, fig-labels, hero fig caption, hero cursor annotation. Aliases --ink-muted-30 rather than a new value, per the 2026-07-21 finding while building the gate's doc-line — --ink-muted-65 reads the same weight as body copy, too strong to recede. Content labels (eyebrows, metric labels, logo-wall label, etc.) stay full --blueprint, unaffected. */
  --measure: 65ch;
  /* Pixel-equivalent of --measure, measured at Body tier's fixed 16px
     (2026-07-09) — `ch` is relative to the font-size of whatever element
     it's set on, so 65ch on a larger-font element (e.g. the Subtitle-tier
     .lede) renders visibly wider than 65ch on a 16px paragraph, even
     though they share the same nominal measure. Use this instead of
     --measure wherever a larger-font element needs to match the body
     paragraph column width exactly. Only valid because .section p's
     font-size is a flat 16px, not responsive — re-measure if that changes. */
  --measure-px: 644.8px;
  --section-pad-x: clamp(20px, 5vw, 64px);
  --section-gap: clamp(64px, 10vw, 120px);
}

* { box-sizing: border-box; }

html { scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: "HEX Franklin", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  /* Prevents Mobile Safari's automatic text-size boosting from inflating
     small type (e.g. .gate-back-link) unevenly relative to sibling elements
     on narrow viewports. Found 2026-07-27 on the showcase gate. */
  text-size-adjust: 100%;
  -webkit-text-size-adjust: 100%;
}

a { color: inherit; }

.accent { color: var(--blueprint); }
.muted { color: var(--ink-muted-65); }

/* Type scale — LABEL tier (confirmed 2026-07-09, see design.md).
   CONTENT-role labels only (see the Label vs. Annotation taxonomy in
   design-system.md) — the ones meant to hold attention: nav, eyebrows,
   metric labels, logo-wall label. Folded in 2026-07-12 (type-audit
   consolidation): .metric__label (was missing its weight) and
   .migration__place (was 11.5px ink) — all now share this one canonical
   11px condensed uppercase definition; each keeps only its own color/layout.
   .fig-label, .hero__fig, .hero__annotation, and (2026-07-21, same pass as
   the hero one) .partnerships__annotation moved OUT to the
   Label-sm/Annotation group below — they're NOTATION-role, not content, and
   read at 10px dimmed now, not 11px full-strength. */
.label,
.eyebrow,
.hero__nav-list a,
.hero__nav-email,
.hero__nav-linkedin,
.slideshow-meta,
.slideshow__caption,
.slideshow__placeholder,
.logo-wall-label,
.metric__label,
.migration__place,
.gate-title,
.gate-label,
.gate-error,
.gate-submit {
  font-family: "HEX Franklin", sans-serif;
  font-stretch: 75%; /* Extra Condensed axis, approximated */
  font-weight: 600;
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* Type scale — LABEL-SM tier (2026-07-12 type-audit consolidation). One notch
   below Label: 10px instead of 11px, same condensed uppercase weight. The
   fine-print spec captions across sections. Folds in what used to be five
   near-duplicate rules drifting across 10 / 10.5px and 0.03–0.06em tracking
   (.conviction-list__spec, .conviction-list__dim-label, .pmodels__spec,
   .otc-spec, .otc-cap) plus the migration UTC tags (.migration__meta). Color
   is deliberately per-context — blueprint for technical/data annotations,
   muted ink for quiet asides — so it's set on each element, not here.
   NOTATION-role members (.fig-label, .hero__fig, .hero__annotation,
   .partnerships__annotation, .gate-doc — the Annotation half of the
   Label/Annotation taxonomy) also live here: the design system builds the
   notation tier from this same 10px size, just dimmed to var(--annotation)
   on each element rather than full-strength (site-wide rollout, 2026-07-21
   — see design-system.md). */
.label-sm,
.conviction-list__spec,
.conviction-list__dim-label,
.pmodels__spec,
.otc-spec,
.otc-cap,
.migration__meta,
.footer-copyright,
.back-to-top__label,
.gate-doc,
.fig-label,
.hero__fig,
.hero__annotation,
.partnerships__annotation {
  font-family: "HEX Franklin", sans-serif;
  font-stretch: 75%;
  font-weight: 600;
  font-size: 10px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* Type scale — INDEX (in-content numbering; 2026-07-12 type-audit, per Hector).
   The Acquired Skills numeral style (.skills-list__index), now the single
   standard for every in-content list number: Strengths tiles, How I Work
   01–04, the migration ledger, partnership models. Deliberately distinct from
   section-eyebrow numbering (e.g. "01 professional evolution"), which stays in
   the condensed Label tier above. Plain by design: normal width, no tracking,
   no uppercase, regular weight, blueprint. Per-element layout (min-width, flex
   sizing, margins, opacity/active states) stays on each element's own rule. */
.skills-list__index,
.migration__idx,
.strengths-tile__num,
.conviction-list__num,
.pmodels__num {
  font-family: "HEX Franklin", sans-serif;
  font-stretch: 100%;
  font-weight: 400;
  font-size: 11px;
  letter-spacing: normal;
  text-transform: none;
  color: var(--blueprint);
}

/* ==========================================================================
   HERO
   ========================================================================== */

.hero {
  position: relative;
  min-height: 100vh; /* js/main.js's updateHeroMinHeight() overrides this
    inline once real content is measured, growing it past 100vh if the
    headline/clients block would otherwise run past the viewport — this
    CSS value is only the pre-JS/no-JS fallback. */
  padding: 28px var(--section-pad-x) var(--section-gap); /* bottom padding
    matched to --section-gap (2026-07-17, was a fixed 64px) so the
    Hero→Professional Evolution gap is built from the same two-stacked-
    --section-gap pattern as every other section boundary, instead of a
    one-off value — see the Hero→PE spacing fix note in js/main.js. */
  overflow: hidden;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.hero__grid {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
  /* Compositing-layer promotion (2026-07-10, Safari flash-on-scroll fix):
     confirmed via direct report the signature curve (and Professional
     Evolution's, see .professional-evolution-curve-layer) vanished
     completely for one frame the instant scrolling began in Safari, on
     both curves, on both screen sizes — then reappeared and continued
     normally. Ruled out a JS error first (user checked Safari's Web
     Inspector console mid-bug: empty, no exceptions), which points at a
     paint/compositing-level issue, not a script bug. This SVG has no
     transform/opacity animation of its own, so nothing here previously
     told the browser to give it a stable, persistent GPU layer — WebKit
     is known to recompute/re-rasterize elements like this fresh right as
     scrolling starts (when the page's compositing tree gets re-evaluated
     for the new scroll-driven paint), producing exactly one dropped
     (blank) frame before the next repaint catches up. translateZ(0) is
     the standard forced-layer-promotion hack: it gives the browser a
     permanent, dedicated composited layer for this element regardless of
     scroll state, so there's nothing left to re-create at scroll-start.
     Chrome was never affected (its compositor already isolates this
     element without needing the hint), which is consistent with this
     being a Safari-specific promotion quirk rather than a bug in the
     curve's own animation logic (already ruled out across three earlier,
     unconfirmed fix attempts targeting the transition/dasharray code
     instead of compositing). */
  transform: translateZ(0);
  -webkit-transform: translateZ(0);
  will-change: transform;
}

.hero__grid .grid-lines line,
.hero__grid .grid-lines path {
  stroke: var(--blueprint);
  stroke-width: 0.6;
  opacity: 0.16;
  fill: none;
}

.signature-curve {
  stroke: var(--blueprint);
  stroke-width: 2.5; /* fallback/no-JS value — see js/main.js
    (setCurveStrokeWidth) for the actual per-viewport value used in
    practice. Was briefly `vector-effect: non-scaling-stroke` (2026-07-10)
    to fix a real cross-curve width mismatch (see .professional-evolution-curve's
    comment for the mismatch itself), but that caused a new, worse bug on
    at least one external display (LG 27" UltraFine, confirmed via direct
    report + screenshots): the scroll-driven dash-offset draw-in reliably
    stopped short of the path's true end on that display/GPU path, while
    working correctly on the reference MacBook screen — consistent with a
    driver-level precision issue in how that specific renderer handles
    non-scaling-stroke combined with an animated stroke-dasharray/
    stroke-dashoffset, which forcing dashoffset to 0 didn't fix (so it's
    the dash *length* falling short under this render path, not an
    animation-completion issue). Reverted to the normal (scaling) stroke
    and instead compensate for the viewBox-scale difference by setting
    stroke-width in JS, per curve, per viewport — same physical result,
    without vector-effect's separate rendering path. */
  fill: none;
  stroke-dasharray: 2400; /* fallback only — js/main.js sets the real measured length; bumped from 1400 now that the path bleeds off both edges and is longer */
  stroke-dashoffset: 2400;
  /* will-change: stroke-dashoffset (2026-07-10) — added after an on-screen
     diagnostic log (opacity/visibility/display/bbox sampled on every
     scroll-driven update, both curves, during a live Safari repro) showed
     NONE of those properties ever change during the flash — dashoffset
     itself moves in small, smooth, monotonic steps the whole time, and
     opacity/visibility/display/size stay exactly constant throughout.
     That rules out a state bug in this site's own code (nothing here is
     toggling visibility or collapsing the element) and points at Safari
     failing to paint/rasterize the stroke for a frame — a known WebKit
     weak spot: animating stroke-dasharray/dashoffset forces the dash
     pattern to be retessellated from scratch every frame (no caching),
     which is expensive on a long, multi-segment path like this one, and
     under scroll-driven compositing load Safari can drop that repaint
     entirely for a frame instead of reusing the previous one — reading as
     a blank flash. will-change tells the browser in advance that this
     specific property changes often, so it can prepare an optimized
     paint/raster strategy for it ahead of time instead of improvising one
     under load. Confirmed this is now the ONLY hint on this property
     (translateZ(0)/will-change:transform on the container, added earlier
     the same day for a different, GPU-layer theory, didn't resolve this
     bug on its own — left in place since it's harmless, but this is the
     first fix actually targeted at the paint cost itself). */
  will-change: stroke-dashoffset;
}

/* Transition scoped to this class only (2026-07-10, replacing an always-on
   `transition: stroke-dashoffset ...` on the base rule above) — Safari bug
   report: the curve briefly vanished entirely the instant scrolling
   started (both this curve and Professional Evolution's), then recovered.
   With the transition always active, a scroll-driven change and the
   transition could race regardless of `transition: none` being set
   inline first (that ordering isn't guaranteed across engines). js/main.js
   now adds this class only for the one-time draw-in and removes it the
   instant real scroll control begins (or once the draw-in should be done
   either way) — so outside that narrow window, no `transition` rule
   matches the element at all, nothing left to race. */
.signature-curve.is-animating-in {
  transition: stroke-dashoffset 2.5s cubic-bezier(0.16, 1, 0.3, 1); /* 1.6s -> 2s -> 2.5s, two 25% slow-downs (2026-07-13, "still too fast to catch") */
}

.signature-curve.is-drawn {
  stroke-dashoffset: 0;
}

/* Erase-out state (confirmed 2026-07-09): scrolling past the hero animates the
   curve out in the same left-to-right direction it drew in, via js/main.js
   pushing stroke-dashoffset negative (past 0) rather than back up to its
   original positive value — see main.js comment for why that direction
   matters. Also now written via setAttribute rather than .style, and with
   no transition rule active by that point (see is-animating-in above) —
   see main.js's updateCurveForScroll comment for the fuller Safari-bug
   context. */

.hero__nav {
  position: absolute;
  top: 50px;
  left: var(--section-pad-x);
  right: var(--section-pad-x);
  z-index: 3; /* above .hero__grid (0), the debris layer (1), and
    .hero__content (1) — bumped from 1 (2026-07-24, cycling-headline port)
    so scattered/knocked-out words can never paint over the nav; ties with
    .hero__annotation (also 3), unchanged pre-existing behavior between
    those two. */
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px 20px;
}

.hero__nav-list {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 4px 18px;
  margin: 0;
  padding: 0;
}

.hero__nav-list a,
.hero__nav-email,
.hero__nav-linkedin {
  text-decoration: none;
  color: var(--ink); /* #000000 — confirmed 2026-07-09, was --blueprint */
  /* inline-block (added 2026-07-10, magnetic-hover port): lets the
     mousemove-driven transform in js/main.js translate these links as a
     single box instead of a fragmentable inline run — harmless here since
     each is a short, single-line label that never wraps. */
  display: inline-block;
}

.hero__nav-list a:hover,
.hero__nav-email:hover,
.hero__nav-linkedin:hover {
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* Nav section numbers (01–05) in blueprint (2026-07-13) — the numeral is
   wrapped in its own span so only it takes the accent color; the label text
   after it stays ink. */
.hero__nav-num { color: var(--blueprint); }

/* Email + LinkedIn stacked on the nav's right side (2026-07-13) — LinkedIn
   sits directly below Email, both right-aligned so they hang off the same
   edge the single email link used to. */
.hero__nav-contact {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 6px;
}

/* Contact-section email/LinkedIn — same stacked flex column and 6px gap as the
   nav's .hero__nav-contact (2026-07-13), so the vertical spacing matches the
   top-of-page version exactly; left-aligned here rather than right. */
.contact-links {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
}

.hero__content {
  position: absolute;
  top: 259.63px; /* static fallback for the reference viewport / no-JS case — js/main.js measures the curve's actual rendered position on load/resize and overrides this so the headline always clears the curve by 50px at any viewport size (confirmed 2026-07-09) */
  left: var(--section-pad-x);
  right: var(--section-pad-x);
  z-index: 1;
  /* max-width dropped from 900px (2026-07-10, direct instruction) so
     .hero__clients below can span the full section width. Safe to remove
     here: the headline's three lines are hard-broken via individual
     <span>s (not natural wrapping), so it doesn't reflow just because the
     container got wider. */
}

.hero__headline {
  margin: 0 0 32px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: -0.01em;
  line-height: 0.98;
  font-size: clamp(28px, 6vw, 52px);
  transform: rotate(-1.5deg);
  transform-origin: left top;
}

.hero__headline span {
  display: block;
}

/* ===== Cycling-headline interaction (ported from
   explorations/hero-headline-cycle.html, 2026-07-24) — hovering a word
   slides+rotates it off into the hero (js/main.js builds/rebuilds
   .hero__headline's word-level markup and drives the animation); once
   every word in the active headline is knocked out, the next of the 3
   approved headlines pops in. See MEMORY.md for the full spec/approval
   history. ===== */

.hw-debris-layer {
  position: absolute;
  inset: 0;
  z-index: 1; /* ties with .hero__content; this layer sits earlier in the
    DOM so content paints on top when overlapping */
  pointer-events: none;
}

/* Word-level spans inside the cycling headline. `.hero__headline span`
   above is a descendant selector (not direct-child), so it would otherwise
   also force these to display:block — this override wins on specificity
   (3 classes vs. 1 class + 1 type) without needing !important. */
.hero__headline .hw-line .hw {
  display: inline-block;
  position: relative; /* anchors the shine-sweep pseudo-elements below */
  overflow: hidden; /* clips the sweep band to this word's own box, same
    purpose as .partnerships__logo-slot's overflow:hidden */
}
.hero__headline .hw-line .hw.is-out {
  visibility: hidden; /* slot stays reserved — the tile left, nothing slides in to fill it */
}
.hero__headline .hw-line .hw.pop-in {
  opacity: 0;
  transform: translate(-28px, 18px);
  transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
}
.hero__headline .hw-line .hw.pop-in.is-visible {
  opacity: 1;
  transform: translate(0, 0);
}

/* Shine-sweep on pop-in (2026-08-03, Hector) — reuses the exact glossy
   C2 recipe built for the logo wall verbatim (.partnerships__film /
   .partnerships__film::after: rgba(43,44,225,0.08) base tint, 115deg
   white-gradient sweep band, same stop percentages), adapted from a
   hover-triggered transition to a one-shot entrance animation since a
   headline word pops in once and stays, it doesn't have a persistent
   hover state to key off. Two pseudo-elements instead of the logo wall's
   separate wrapper div (words are text spans, not image containers):
   ::before carries the base tint and paints BEHIND the glyph ink (source
   order: ::before, then text, then ::after), so it reads as a soft wash
   around/behind the letterforms rather than covering them; ::after
   carries the bright diagonal band and paints ON TOP of the glyphs, so
   it visibly glints across the black letter strokes as it travels — the
   deliberate difference from the logo wall, where both layers sit above
   the flat logo image. Both run once via `animation` (not `transition`,
   since a plain transition has no way to spring back down on its own)
   triggered by the same `.pop-in.is-visible` pair the position/opacity
   entrance already uses, ending at tint-opacity 0 — the word settles to
   solid --ink same as before, per Hector's "immediately turning black". */
.hero__headline .hw-line .hw::before {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(43, 44, 225, 0.08); /* var(--blueprint) at 8% alpha, same as .partnerships__film */
  opacity: 0;
  pointer-events: none;
}
.hero__headline .hw-line .hw::after {
  content: "";
  position: absolute;
  top: -50%;
  left: -60%;
  width: 55%;
  height: 200%;
  background: linear-gradient(115deg, transparent 0%, rgba(255, 255, 255, 0.55) 45%, rgba(255, 255, 255, 0.08) 60%, transparent 100%);
  transform: translateX(-30%);
  pointer-events: none;
}
.hero__headline .hw-line .hw.pop-in.is-visible::before {
  animation: hw-shine-tint 0.65s ease forwards;
}
.hero__headline .hw-line .hw.pop-in.is-visible::after {
  animation: hw-shine-sweep 0.65s ease forwards;
}
@keyframes hw-shine-tint {
  0% { opacity: 0; }
  15% { opacity: 1; }
  100% { opacity: 0; }
}
@keyframes hw-shine-sweep {
  0% { transform: translateX(-30%); }
  100% { transform: translateX(230%); }
}

/* Hover affordance — present in the approved exploration
   (explorations/hero-headline-cycle.html) but missing from the initial
   production port (2026-07-25 fix). Signals a word is interactive before
   it gets knocked out. */
.hero__headline .hw-line .hw:not(.is-out):hover {
  cursor: pointer;
  color: var(--blueprint);
}

/* Knocked-out word clones. Same headline typography and dimensions as the
   live word (no scale change — every debris word stays the same size
   regardless of when it was knocked out). Two nested elements: the outer
   handles position (translate) + the fade (see below); the inner handles
   rotation only, on its own less front-loaded easing curve, so the spin
   stays visible through the whole slide instead of resolving in the first
   instant — a single `transform` transition can't run two of its
   sub-functions on two different easing curves, hence the split.

   Fade treatment, 3rd attempt (2026-08-03, Hector) — back to a solid fill,
   superseding the 2026-07-27 hollow-outline version (0.5px stroke fading
   to --ink-muted-40, both attempts at a hollow look now retired — see the
   two prior entries in MEMORY.md for that history). Fill now fades from
   --ink to var(--blueprint) — same hue as the hero grid's own lines
   (`.hero__grid .grid-lines line/path`) — rather than to gray. Opacity
   started at 0.16 (an exact match to that grid-line token) then was
   dialed down to **0.10** per Hector's direct follow-up, so only the hue
   traces to the grid now, not the opacity. No more `-webkit-text-stroke`
   at all — `knockOut()` in main.js now animates `color` and `opacity`
   together on the same delayed timer, nothing else. */
.hw-debris {
  position: absolute;
  top: 0;
  left: 0;
  font-family: "HEX Franklin", sans-serif;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: -0.01em;
  line-height: 0.98;
  font-size: clamp(28px, 6vw, 52px);
  color: var(--ink);
  white-space: nowrap;
  pointer-events: none;
  will-change: transform;
}
.hw-debris__spin {
  display: inline-block;
  will-change: transform;
}

.hero__clients {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: repeat(3, max-content);
  gap: 24px 200px;
  max-width: 100%; /* was 700px — now fills the full hero width (2026-07-10, direct instruction), paired with .hero__content dropping its own 900px cap */
  margin-top: 125px; /* was 100px — lowered 25px (confirmed 2026-07-09), headline/nav/curve untouched */
}

.hero__clients h3 {
  margin: 0 0 14px;
  color: var(--blueprint);
}

.hero__clients ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.hero__clients li {
  color: var(--ink);
  margin-bottom: 0; /* was 6px — removed so inter-item spacing is pure 1.35 line-height (2026-07-13) */
}

.hero__clients li:last-child {
  margin-bottom: 0;
}

@media (max-width: 640px) {
  .hero__clients {
    grid-template-columns: 1fr;
    gap: 28px;
  }
}

.hero__fig {
  position: relative;
  z-index: 1;
  align-self: flex-end;
  margin-top: 24px;
  /* Annotation-role (site-wide rollout, 2026-07-21): var(--annotation) is
     already the dimmed value, so no separate opacity hack is needed
     (was color:blueprint + opacity:0.7, an approximation of this). */
  color: var(--annotation);
}

/* Hero-only x/y/z cursor + dashed-leader annotation (A2, ported 2026-07-10
   from reference-explorations.html, merged with motion-blend.html's x/y/z
   coordinate reading — see js/main.js for the full rationale, including
   the deliberate, scoped revision of the Motion section's standing "no
   cursor-follow effects" rule for this element specifically). `.hero`
   already has `overflow: hidden`, which is what keeps the crosshair's
   5000px cross-lines clipped to the hero's own bounds rather than
   bleeding across the page. */
.hero__crosshair {
  position: absolute;
  /* Fixed top:0/left:0 anchor (2026-08-27, Cloudflare CLS fix) — the real,
     live position now comes entirely from a `transform: translate()` set
     in js/main.js on every mousemove, not from left/top. transform is a
     compositor-only property, so repositioning this way no longer
     registers as a layout shift the way animating left/top did (mousemove
     isn't exempted from CLS scoring the way a discrete click/tap is).
     Same real-time cursor tracking, same pixel math, nothing visibly
     different. */
  top: 0;
  left: 0;
  z-index: 2;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.hero__crosshair.is-active { opacity: 1; }
.hero__crosshair::before,
.hero__crosshair::after {
  content: "";
  position: absolute;
  background: var(--blueprint);
  opacity: 0.3;
}
.hero__crosshair::before { width: 1px; height: 5000px; top: -2500px; left: 0; }
.hero__crosshair::after { height: 1px; width: 5000px; left: -2500px; top: 0; }

/* Small tick on the crosshair's vertical line marking where the curve's
   surface actually is at this x — a literal on-screen anchor for the z
   reading in the annotation box. */
.hero__crosshair-ztick {
  position: absolute;
  left: -5px;
  top: 0; /* anchor — js/main.js animates the live offset via transform: translateY(), same CLS fix as .hero__crosshair above */
  width: 11px;
  height: 2px;
  background: var(--blueprint);
}

.hero__leader {
  position: absolute;
  top: 0;
  left: 0; /* anchor — js/main.js positions/rotates this live via one combined transform, same CLS fix as .hero__crosshair above */
  z-index: 2;
  pointer-events: none;
  height: 1px;
  border-top: 1px dashed var(--blueprint);
  transform-origin: left center;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.hero__leader.is-active { opacity: 0.7; }

.hero__annotation {
  /* typography from the shared Label-sm/Annotation tier (2026-07-12 sizing,
     recolored to var(--annotation) 2026-07-21 site-wide rollout — was
     full-strength blueprint; the coordinate readout is notation, not
     content, so it now recedes like the other annotation-role elements). */
  position: absolute;
  top: 0;
  left: 0; /* anchor — js/main.js positions this live via transform: translate(), same CLS fix as .hero__crosshair above */
  z-index: 3;
  pointer-events: none;
  background: var(--paper);
  border: 1px solid var(--annotation);
  color: var(--annotation);
  padding: 6px 10px;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.hero__annotation.is-active { opacity: 1; }

/* ==========================================================================
   PORTFOLIO REEL (rebuilt 2026-07-31, direct correction — "carousel" was
   the wrong word for the first pass; this is a sequential one-at-a-time
   player, not a scrolling strip). Sits between Hero and Professional
   Evolution, direct instruction. Full-bleed: deliberately NOT a .section
   (no --section-pad-x side padding, no eyebrow/fig-label of its own) —
   reads as a visual interlude, same register as the hero itself, not a
   numbered content block. 28px top/bottom padding reuses the established
   "new-component transition" token (eyebrow→content, tags→logo, etc. —
   see MEMORY.md) rather than the full --section-gap, since there's no
   heading here to clear. See index.html for the asset order/rationale and
   js/main.js ("Portfolio reel" block) for the sequencing/pause behavior
   this markup is built for. */
.portfolio-reel {
  position: relative;
  width: 100%;
  background: var(--paper);
  padding: 28px 0;
  border-top: 0.5px solid var(--ink-hairline);
  border-bottom: 0.5px solid var(--ink-hairline);
}

/* Fixed-size stage, full width — REVERSED BACK to contain 2026-07-31,
   same session (direct correction: "no assets should be cropped and only
   16:9 videos should avoid letterboxing. Maintain the contain, never
   crop"). The previous cover/crop-to-fill pass is out; nothing is ever
   cropped again. What actually eliminates bars for the majority-case
   footage is sizing the STAGE ITSELF at exactly 16:9 (`aspect-ratio:
   16/9`) rather than the earlier mismatched height clamp, which was
   wider than 16:9 and so pillarboxed even standard-ratio video — the
   real bug behind the "black bars on horizontal video" report. With the
   stage locked to 16:9: any true 16:9 asset (7 of the remaining videos)
   fills it exactly, zero bars; non-16:9 content (the two portrait
   videos, and stills of varying ratios) still letterboxes/pillarboxes
   as needed inside that frame — expected and correct under "contain,
   never crop." `--ink` backdrop is the letterbox color again (not just
   a loading-state fallback). Deliberately no `max-height` cap: capping
   height while `width` stays 100% would make the box wider than 16:9 on
   any viewport past the cap's break point, silently reintroducing bars
   for 16:9 video on ordinary desktop widths — the exact bug this fix
   removes. The stage runs full 16:9 at any width instead (e.g. ~1080px
   tall at a 1920px-wide viewport) — full-bleed video bands of this kind
   are a common pattern; flag if a height ceiling turns out to be wanted
   after all, since it can only be added back by giving up the
   zero-bars guarantee on wide monitors, not for free. */
.portfolio-reel__stage {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: #FFFFFF; /* master default changed from var(--ink) 2026-08-01, direct instruction */
  /* Per-client stage background (2026-07-31, direct instruction) — JS sets
     this inline via data-reel-bg on the active slide; transition keeps the
     color change from being an abrupt cut as slides advance. */
  transition: background-color 0.65s ease;
}

.portfolio-reel__slide {
  position: absolute;
  inset: 0;
  /* Centers the contained media on both axes — REGRESSION FIXED 2026-07-31:
     this was dropped when the stage briefly switched to `cover` (which
     needs no centering, media fills the box exactly) and never restored
     when that was reverted back to `contain`, so any non-16:9 asset
     defaulted to the browser's plain top-left block alignment instead of
     sitting centered in its letterbox/pillarbox space. Only visible for
     content that doesn't exactly fill the stage — exact-16:9 assets have
     no slack to be mis-positioned in, which is why this slipped past
     verification (only checked dimensions, not position). */
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  /* Cinematic "settle into frame" transition (2026-07-31) replacing the
     plain cross-fade — Hector: static images "shouldn't simply fade in
     and fade out," presentation should read as dynamic/modern for a
     design-leadership audience. Incoming slide scales down from a
     slight overshoot to rest while fading in, echoing the scale+fade
     reveal vocabulary this feature's own first pass already used for
     image reveals (0.92→1 scale/opacity, cubic-bezier(0.16,1,0.3,1)) —
     reapplied here to the slide-level transition instead of a scroll
     reveal. */
  transform: scale(1.045);
  transition: opacity 0.65s ease, transform 0.65s cubic-bezier(0.16, 1, 0.3, 1);
  pointer-events: none;
}
.portfolio-reel__slide.is-active {
  opacity: 1;
  transform: scale(1);
  z-index: 1;
  pointer-events: auto;
}

.portfolio-reel__media {
  display: block;
  /* FIX 2026-07-31 — root cause of the "black border" report on
     hellobank-1: width/height:auto + max-width/max-height:100% only caps
     shrinking, it never scales an asset UP past its native pixel size.
     hellobank-1 is exactly 1280x720; on any viewport where the 16:9 stage
     renders wider than 1280px (i.e. most desktop widths) the video stayed
     pinned at its native 1280x720 and the leftover stage area (real,
     measured gap, not a rendering artifact) showed through as a black
     frame. width/height:100% lets object-fit:contain scale the asset up
     or down to fill the stage's aspect-fit box exactly, while still never
     cropping (contain letterboxes on genuine aspect-ratio mismatches,
     same as before — that behavior is unchanged). */
  width: 100%;
  height: 100%;
  object-fit: contain; /* never crop — reversed back 2026-07-31 */
  object-position: center;
}

/* Ken Burns-style slow zoom while a still image holds the stage —
   BOUNDED FIX 2026-07-31 (was scale(1)→scale(1.08), flagged as cropping
   real artwork against the stage's overflow:hidden edge: under
   object-fit:contain, scale(1) is already the asset's full, exactly-
   fitted size on its limiting dimension — anything past 1.0 necessarily
   pushes real pixels outside the stage on that axis, which is a crop,
   not a zoom. Reversed direction instead of removed: now animates
   scale(0.96)→scale(1), i.e. a "settle into full view" grow that never
   exceeds the asset's natural contained size, so nothing ever crosses
   the clipping edge. This is an interim safe default, not necessarily
   the final direction — see MEMORY.md for a fuller menu of alternatives
   (blurred backdrop-fill, wipe reveal, brightness pulse, etc.) offered
   in place of any further scale-up motion. Duration must stay in sync
   with IMAGE_HOLD_MS in js/main.js (currently 2000ms). */
@keyframes portfolioReelKenBurns {
  from { transform: scale(0.96); }
  to { transform: scale(1); }
}
.portfolio-reel__slide[data-kind="image"].is-active .portfolio-reel__media {
  animation: portfolioReelKenBurns 2s ease-out forwards;
}

/* Center-out swipe reveal — per-slide opt-in (2026-08-02, direct
   instruction: video-loop-galleryc-4.jpg should swipe open from the
   center rather than use the default Ken Burns settle). clip-path
   starts as a zero-width vertical sliver at the asset's own center and
   expands left+right simultaneously to full width, so the reveal reads
   as the image "opening" from its centerline. Overrides (rather than
   stacks with) the Ken Burns zoom above via source-order, since a
   simultaneous scale+wipe would fight each other; the slide-level
   opacity/scale crossfade is untouched, so this only replaces the
   per-asset entrance motion, not the slide-to-slide transition. Opt-in
   via data-reel-reveal so every other image slide is unaffected.

   REVISED same day — Hector reported not clearly seeing the sweep, and
   suspected he was only catching it in reverse right before the next
   slide cut in. Two real bugs: (1) transition was symmetric, so it also
   animated CLOSED when the slide lost is-active — that reverse-swipe,
   overlapping the slide's own 0.65s fade-out, is what read as "the
   effect, but at the end." (2) the open used the sitewide "settle"
   curve (cubic-bezier(0.16,1,0.3,1)), which front-loads almost all its
   motion into the first ~40% of the duration then crawls — fine for a
   scale settle, wrong for a sweep meant to be watched, since it made
   the reveal look like an near-instant jump rather than a swipe. Fixed
   by making the close instant (0s, only the .is-active rule below
   declares a transition, so leaving never animates) and switching the
   open to a slower (1.3s), evenly-paced ease-in-out curve so the sweep
   is visible for its full run, independent of the box's own opacity
   crossfade. Then doubled again (2.6s) per a later "slow it down by
   100%" instruction — see data-reel-hold in main.js for why the shared
   2s image hold had to grow per-slide to fit it.

   REVISED AGAIN (2026-08-02) — Hector correctly flagged that
   hellobank-4 seemed to "disappear faster" than galleryc-4 even though
   both hold for the identical 2900ms. Root cause: clip-path was applied
   directly to .portfolio-reel__media, whose box is always the full
   16:9 stage regardless of the asset's own aspect ratio (object-fit:
   contain only affects the drawn pixels, not the element's box) — so
   the clip percentage was relative to the STAGE, not the visible
   image. A narrow/portrait asset like hellobank-4 (~68% of the stage's
   width once contained) finishes revealing its real pixels well before
   the clip-path's 50%->0% animation actually reaches the stage edges;
   the remaining swipe time is invisibly clipping empty transparent
   margin, so the image sits fully revealed and static for the tail of
   the hold — reading as a shorter, less present reveal than a wide
   asset like galleryc-4 (~88% of stage width) that's still visibly
   opening almost the whole 2.6s. Fixed by moving the clip-path off the
   media element onto a new tight-fitting .portfolio-reel__wipe-box
   wrapper, sized (via inline width/height percentages computed from
   the asset's real aspect ratio vs. the 16:9 stage, same math
   object-fit:contain does) to exactly match the asset's own contained
   box — so the swipe always travels edge-to-edge of the real image, in
   any aspect ratio, using the full transition duration. */
.portfolio-reel__wipe-box {
  display: block;
}
.portfolio-reel__slide[data-reel-reveal="center-wipe"] .portfolio-reel__wipe-box {
  clip-path: inset(0 50% 0 50%);
  transition: none;
}
.portfolio-reel__slide[data-reel-reveal="center-wipe"].is-active .portfolio-reel__wipe-box {
  clip-path: inset(0 0 0 0);
  transition: clip-path 2.6s cubic-bezier(0.65, 0, 0.35, 1);
}
.portfolio-reel__slide[data-reel-reveal="center-wipe"].is-active .portfolio-reel__media {
  animation: none;
}

/* Group-item variant of the same reveal (2026-08-02, fix — the
   elglobalito 3+6 static-pair frame had data-reel-reveal="center-wipe"
   but no visible swipe: the wrapper-based fix above only ships on the
   two single-image slides (hellobank-4, galleryc-4), and this slide's
   markup, built before that fix, never got a .portfolio-reel__wipe-box
   added — so no rule matched its images at all, no clip-path, no
   motion. Turns out group-items don't actually need the wrapper: their
   existing CSS already sizes .portfolio-reel__media as width:auto +
   height:100% + the image's own aspect-ratio, which (unlike the
   single-image case's forced width:100%/height:100%) already produces
   a box tightly fit to the real image — object-fit:contain has nothing
   left to do. So clip-path can target that media element directly here
   without the letterboxing bug the wrapper was built to fix. */
.portfolio-reel__slide[data-reel-reveal="center-wipe"] .portfolio-reel__group-item .portfolio-reel__media {
  clip-path: inset(0 50% 0 50%);
  transition: none;
}
.portfolio-reel__slide[data-reel-reveal="center-wipe"].is-active .portfolio-reel__group-item .portfolio-reel__media {
  clip-path: inset(0 0 0 0);
  transition: clip-path 2.6s cubic-bezier(0.65, 0, 0.35, 1);
}

@media (prefers-reduced-motion: reduce) {
  .portfolio-reel__slide { transition: opacity 0.25s ease; transform: none; }
  .portfolio-reel__slide[data-kind="image"].is-active .portfolio-reel__media {
    animation: none;
  }
  .portfolio-reel__slide[data-reel-reveal="center-wipe"] .portfolio-reel__wipe-box,
  .portfolio-reel__slide[data-reel-reveal="center-wipe"] .portfolio-reel__group-item .portfolio-reel__media {
    clip-path: none;
    transition: none;
  }
}

/* No mobile override needed anymore (2026-07-31): the stage's own
   aspect-ratio:16/9 already scales correctly at any width, including
   phone widths — a separate height clamp here would just reintroduce
   the width/height mismatch this fix removes. */

/* Multi-item "combo" slides (2026-07-31, direct instruction) — a few
   slides now hold MULTIPLE assets that build up into one composed group
   (several images/videos entering one at a time, landing side by side
   with a fixed gap, the finished group centered on the stage) instead of
   this component's usual one-asset-per-slide. `.portfolio-reel__group` is
   a position:relative wrapper filling the slide; each
   `.portfolio-reel__group-item` is absolutely centered within it, then
   pushed off that center point by a per-item `translateX(...)` that
   js/main.js computes and animates (see layoutGroupSlide()/placeAt() in
   main.js) — CSS only owns the transition curve/duration here, not the
   actual positions or sizes, since those depend on each item's real pixel
   aspect ratio and how many items share the row. Height is also set
   inline per item (JS) so the whole group can uniformly shrink to fit
   the stage width without ever cropping any one asset — same never-crop
   rule as the rest of this component, just applied to a cluster instead
   of a single asset. */
.portfolio-reel__group {
  position: relative;
  width: 100%;
  height: 100%;
}

.portfolio-reel__group-item {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  transition: transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}

.portfolio-reel__group-item .portfolio-reel__media {
  display: block;
  width: auto;
  height: 100%;
  object-fit: contain;
}

@media (prefers-reduced-motion: reduce) {
  .portfolio-reel__group-item {
    transition: transform 0.25s ease;
  }
}

/* Explicit vertical breathing-room opt-in for a single-asset slide
   (2026-07-31, direct instruction) — video-loop-spoonroom-1.mp4 (a
   portrait video pillarboxed inside the 16:9 stage) was reading as
   touching the stage's top+bottom edges, since object-fit:contain scales
   a portrait asset to the stage's full height by default. Capping the
   media element's own box to 87% of the stage height (same standard as
   data-group-max-height, measured off Spoon & Room 2's own natural
   scale-down) leaves genuine top/bottom margin while contain still
   centers and never crops. Opt-in via data-reel-max-height so every other
   single-asset slide (including the 16:9 videos this component's whole
   "zero bars" fix depends on) is completely unaffected. */
.portfolio-reel__slide[data-reel-max-height] .portfolio-reel__media {
  /* Reads a --reel-max-height custom property set inline per slide so any
     asset can opt into its own scale-down amount (2026-08-01, added for
     google-4's 20% size reduction: --reel-max-height: 80%). Falls back to
     the original 87% for existing slides (spoonroom-1) that only set the
     data-reel-max-height flag without their own custom property, so their
     rendered size is unchanged. */
  height: var(--reel-max-height, 87%);
}

/* ==========================================================================
   SECTION SHELL — negative-space separation, no drawn rules between sections
   ========================================================================== */

.section {
  padding: var(--section-gap) var(--section-pad-x);
  position: relative;
}

.eyebrow {
  font-size: 11px;
  color: var(--blueprint);
  margin-bottom: 28px;
}

.section p {
  max-width: var(--measure);
  font-size: 16px;
  line-height: 1.7;
  margin: 0 0 20px;
}

.fig-label {
  text-align: right;
  /* Annotation-role (site-wide rollout, 2026-07-21) — was blueprint +
     opacity:0.7; var(--annotation) is already the dimmed value. */
  color: var(--annotation);
  margin-top: 28px;
}

/* Back-to-top control (2026-07-13, revised) — a blueprint up-arrow graphic with
   a "top" caption stacked beneath it, right-aligned directly under each
   section's fig label. No box (removed per instruction). The caption uses the
   shared Label-sm / "role · full-time" spec-tag style (see Label-sm group).
   Anchors to #top (hero); nudges up on hover. */
.back-to-top {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  width: max-content;
  margin: 30px 0 0 auto; /* right-aligned, just under the fig label — 30px gap (14->20->30, 2026-07-13) */
  color: var(--blueprint);
  text-decoration: none;
  transition: transform 0.2s ease;
}
.back-to-top__arrow {
  font-size: 20px;
  line-height: 1;
}
.back-to-top__label { color: var(--ink-muted-65); } /* size/tracking from Label-sm group */
.back-to-top:hover { transform: translateY(-3px); }

/* Scroll-reveal targets (see js/main.js) — spring/overshoot easing
   (revised 2026-07-10, ported from motion-blend.html's confirmed blend).
   Was a plain ease-out fade/slide (opacity 0.28s, translateY(16px) 0.28s
   ease-out) per the Motion section's original "no bounce or overshoot
   easing" rule — that rule is deliberately revised here, sitewide, per
   direct instruction (see design.md, Motion, for the updated note).
   .hero-boot is the load-triggered counterpart used only for the Hero's
   headline/clients block (see js/main.js) — same visual language, just
   triggered on load instead of on scroll, so both share one rule rather
   than maintaining the transform/easing twice. */
.reveal,
.hero-boot {
  opacity: 0;
  transform: translate(-28px, 18px);
  transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
}
.reveal.is-visible,
.hero-boot.is-visible {
  opacity: 1;
  transform: translate(0, 0);
}

/* The Hero headline carries a permanent -1.5deg tilt (see .hero__headline).
   It also runs the shared .hero-boot spring-in, whose settled state above
   resets transform to translate(0,0) — which, being more specific than the
   base .hero__headline rule, silently flattened the tilt once the reveal
   finished (regression from the 2026-07-10 boot-reveal port, fixed 2026-07-13).
   Fold the rotation into both boot states so the spring-in preserves it.
   Both states keep an identical translate+rotate function list for clean
   interpolation. */
.hero__headline.hero-boot {
  transform: translate(-28px, 18px) rotate(-1.5deg);
}
.hero__headline.hero-boot.is-visible {
  transform: translate(0, 0) rotate(-1.5deg);
}

/* Logo wall reveal — NOT handled by the generic .reveal system above.
   Second pass (2026-07-09): direct measurement of the live site showed its
   logo reveal is a continuous, reversible scroll-scrub (opacity/scale
   tracking scroll position live, same technique as the hero/Professional
   Evolution curves), not a one-time "fade in once and stay" trigger. That
   can't be expressed as a static before/after CSS class pair, so
   js/main.js now sets `opacity`/`transform` as inline styles directly on
   each `.logo-wall li`, recomputed on every scroll event — no CSS rule
   needed here at all. See the "Logo wall" comment in js/main.js. */

/* ==========================================================================
   PROFESSIONAL EVOLUTION (formerly "About", renamed 2026-07-09 to match the
   live site's section naming — content replaced with the live site's
   "Professional Evolution" copy per direct instruction; section id/class
   and nav label renamed to match, see design.md). Still deliberately
   restrained: the background wireframe below is the section's one trace
   of the system. The pull-quote / dimension-line-bracket treatment that
   used to run here was dropped along with the old copy (no equivalent
   thesis line in the new content) — CSS rules kept below since it's a
   reusable ornament-vocabulary pattern, just unused in this section now.
   ========================================================================== */

/* Corrected 2026-07-09 — "flush left" was a misread of the actual request
   (moving the block to the RIGHT side instead, same padding as every other
   section). Reverted: this section now uses the standard shared .section
   padding on both sides again, same as About/Strengths/etc.

   First attempt at "move right" applied margin-left:auto to each content
   element individually — but .eyebrow and .skills-intro have no max-width
   of their own (they're full-width divs), so auto-margins had nothing to
   redistribute and they stayed put while the width-constrained paragraphs
   moved, breaking the "Acquired skills:" label away from its own list.
   Fixed by wrapping everything in one container and moving that instead,
   so it holds together regardless of which children are width-constrained. */
.professional-evolution-content {
  max-width: var(--measure-px);
  margin-left: auto;
  margin-right: 0;
}

.professional-evolution-bg {
  position: absolute;
  inset: 0;
  z-index: -1; /* sits behind the section's normal-flow text content without
                  needing to touch that content's own stacking/positioning */
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: hidden;
}

.professional-evolution-bg path,
.professional-evolution-bg polygon {
  stroke: var(--blueprint);
  stroke-width: 0.6;
  opacity: 0.12; /* quieter than the hero grid's 0.16 — reads calmer */
  fill: none;
}

/* Signature curve layer (split out from .professional-evolution-bg,
   2026-07-10) — same position/sizing as the wireframe layer, but
   deliberately NOT clipped. The curve used to live inside
   .professional-evolution-bg and inherit its overflow:hidden; that broke
   once the curve needed to be nudged upward for clearance above the
   eyebrow (js/main.js, positionPeCurveClearance) — the shift pushed part
   of the curve above the section's own box and it got clipped there,
   a hard flat stub instead of fading off the edge, reported as "looks
   like it's behind the hero section." Kept as a separate element (rather
   than just toggling overflow on the shared layer) so the static
   wireframe's existing crop is untouched. */
.professional-evolution-curve-layer {
  position: absolute;
  inset: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
  /* Compositing-layer promotion — see .hero__grid's matching comment for
     the full Safari flash-on-scroll diagnosis; same fix, same reasoning,
     applied here since the report was this curve had the identical bug. */
  transform: translateZ(0);
  -webkit-transform: translateZ(0);
  will-change: transform;
}

/* Scroll-linked entering/exiting curve (added 2026-07-09, visibility
   corrected same day). No CSS transition here, deliberately — js/main.js
   drives stroke-dashoffset directly off scroll position every frame, same
   lesson learned on the hero curve: a lingering transition makes
   scroll-driven changes lag instead of tracking 1:1. Initial
   stroke-dasharray/dashoffset are also set inline by JS (matching the
   path's real measured length), the values below are only a
   same-viewport/no-JS fallback.

   Promoted to fully match the hero curve (2026-07-10, direct instruction):
   Hector felt the quieter weight read as part of the geometric background
   wireframe rather than as its own organic element, breaking the intended
   contrast between the smooth signature curve and the rigid grid. Now
   shares the hero's exact stroke-width and opacity — this reverses the
   "stays hero-exclusive" call recorded in design.md 2026-07-09; see that
   doc for the updated note. Path 'd' in index.html was also redrawn as a
   multi-segment cubic-bezier sweep (matching the hero curve's construction)
   instead of the old single symmetric quadratic arc, for the same organic-
   vs-geometric reason. Now living in its own unclipped layer (see above),
   the old high-specificity selector needed to beat the shared wireframe's
   thin/quiet rule is no longer necessary — this is a plain class selector. */
.professional-evolution-curve {
  stroke: var(--blueprint);
  stroke-width: 2.5; /* fallback/no-JS value — see .signature-curve's comment:
    vector-effect: non-scaling-stroke was reverted (2026-07-10) after it
    caused a draw-in bug on an external display. js/main.js now sets the
    real per-viewport stroke-width for both curves. */
  opacity: 1;
  fill: none;
  stroke-dasharray: 1650;
  stroke-dashoffset: 1650;
}

/* Off the Clock curve layer — 3rd appearance of the signature curve device
   (2026-07-27), same unclipped/promoted-layer recipe as
   .professional-evolution-curve-layer above (see that comment for the full
   Safari flash-on-scroll rationale — identical fix, applied here
   preemptively rather than waiting for the same bug report a third time). */
.off-the-clock-curve-layer {
  position: absolute;
  inset: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
  /* Shifted down 25px (2026-08-03, Hector — reduced from an initial +50px
     after live measurement showed +50px ate desktop's clearance to Contact
     and mobile's clearance to the back-to-top arrow) on top of the
     existing translateZ(0) compositing-layer promotion (Safari
     flash-on-scroll fix, see .professional-evolution-curve-layer above) —
     transform only, so the scroll-driven draw/erase math in js/main.js
     (which reads the path's live rect) keeps working unchanged. */
  transform: translateY(25px) translateZ(0);
  -webkit-transform: translateY(25px) translateZ(0);
  will-change: transform;
}

.off-the-clock-curve {
  stroke: var(--blueprint);
  stroke-width: 2.5; /* fallback/no-JS value — js/main.js sets the real
    per-viewport value, same as the other two curves. */
  opacity: 1;
  fill: none;
  stroke-dasharray: 1450; /* fallback only — js/main.js sets the real measured length */
  stroke-dashoffset: 1450;
}

/* Acquired skills — indexed legend (2026-07-10, replacing the bordered
   pill/chip treatment). Hector wasn't convinced by the pills and wanted to
   avoid both that and a plain print-style bulleted list — see
   skills-options.html for the four alternatives mocked up, and design.md
   for the fuller rationale. This is option B (numbered parts-list /
   bill-of-materials pattern) in a 2-column grid, per direct instruction —
   the mockup itself showed 3 columns. */
.skills-intro,
.migration-intro {
  font-family: "HEX Franklin", sans-serif;
  font-weight: 600;
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  margin: 0 0 18px;
}
/* Both titled sub-blocks in Professional Evolution own a 44px top gap so they
   sit an established block-distance below whatever precedes them. .migration-intro
   already does this; give .skills-intro the same, so the paragraph now moved
   between the migration ledger and Acquired skills isn't cramped against it. */
.skills-intro { margin-top: 44px; }

.skills-list {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  grid-auto-flow: column; /* fill vertically (01-09 left column, 10-18 right)
    instead of the grid default row-major order — Hector, 2026-07-24 */
  grid-template-rows: repeat(9, auto); /* 18 items / 2 columns = 9 rows;
    needed so auto-flow:column knows where to wrap to the 2nd column */
  column-gap: 32px;
  margin: 0 0 8px;
  padding: 0;
  max-width: var(--measure-px);
}

.skills-list li {
  display: flex;
  gap: 10px;
  align-items: baseline;
  padding: 7px 0; /* hairline divider removed per Hector, 2026-07-24 —
    row rhythm/padding otherwise untouched, matches .migration__row's own
    7px-padding rows */
  font-family: "HEX Franklin", sans-serif;
  font-size: 13px; /* shared list-small size (bumped down from 14px, 2026-07-22) — matches .strengths-tile__text + .partnerships__annotation-body */
  color: var(--ink);
}

.skills-list__index {
  /* typography from the shared Index tier (2026-07-12) — this was the original
     source of that style; only the alignment min-width stays local. */
  min-width: 20px;
}

@media (max-width: 480px) {
  .skills-list { grid-template-columns: 1fr; grid-auto-flow: row; grid-template-rows: none; }
}

/* ===== Professional Migration ledger — geographic arc inside Professional
   Evolution (Concept A, ported 2026-07-12 from
   professional-migration-nocurve-options.html). Single New York; Latin America
   and the European countries are nested excursions sharing one indent and one
   continuous blueprint rule; every UTC tag lands in the same right-hand column;
   final row is the current across-timezones state. Static list, no JS. ===== */
.migration-intro { margin-top: 44px; }
.migration {
  list-style: none;
  margin: 0 0 44px;
  padding: 0;
  /* match the left column of the Acquired skills 2-col grid, so the UTC tag
     on the right sits close to the place name rather than at the far measure
     edge: (measure − skills column-gap) / 2 */
  max-width: calc((var(--measure-px) - 32px) / 2);
  position: relative; /* host for the absolutely-positioned .migration__trail overlay, 2026-07-25 */
}
.migration__row {
  display: grid;
  grid-template-columns: 20px 1fr auto;
  gap: 0 12px;
  align-items: baseline;
  /* row rhythm reused from .skills-list li (7px 0 padding, 0.5px hairline) */
  padding: 7px 0;
  position: relative; /* anchors the inset divider ::before below */
}
/* Row divider — inset (2026-07-25, Hector), replacing the old edge-to-edge
   border-top now that the 01-05 numerals are gone and the idx column instead
   hosts the airplane-trail overlay (.migration__trail below). Starts at the
   copy block's own left edge (20px idx column + 12px column gap = 32px) so
   it doesn't run under the trail line, and ends flush with the row's own
   right edge — which, by this row's own grid math (20px + 1fr + an
   auto-sized UTC column, always summing to the full row width), already
   coincides with that row's own UTC text's right edge; confirmed by
   measuring computed rects in-browser (0px diff on every row) rather than
   assumed, per explorations/migration-airplane-trail-options.html. Skipped
   on the first row (nothing above it to divide from) and on nested rows
   (.migration__row--nested keeps its own border-top:0 below, unchanged). */
.migration__row:not(.migration__row--nested):not(:first-child)::before {
  content: "";
  position: absolute;
  top: 0;
  left: 32px;
  right: 0;
  height: 0.5px;
  background: var(--ink-hairline);
}
/* .migration__idx — typography from the shared Index tier (2026-07-12); was a
   re-declared condensed Label, now the plain in-content numeral. No layout of
   its own (it sits in the row grid's fixed 20px column). Emptied of its
   "01"-"05" text content 2026-07-25 — the airplane-trail overlay
   (.migration__trail below) now marks position in that column instead; the
   span itself stays in the markup purely to reserve the grid's 20px column. */
.migration__place {
  /* typography from the shared Label tier (2026-07-12) — was 11.5px; now the
     canonical 11px, ink-colored. */
  color: var(--ink);
}
.migration__phase {
  display: block;
  margin-top: 4px;
  font-family: "HEX Franklin", sans-serif;
  font-size: 10px;
  letter-spacing: 0;
  text-transform: none;
  line-height: 1.35;
  color: var(--ink-muted-65);
}
.migration__meta {
  /* typography from the shared Label-sm tier (2026-07-12) — was 10.5px/0.04em;
     now the canonical 10px/0.06em. Blueprint = technical/data annotation. */
  justify-self: end;
  text-align: right;
  color: var(--blueprint);
  white-space: nowrap;
}
.migration__row--nested { padding: 0; } /* border-top no longer set on the base .migration__row (2026-07-25), nothing to zero here anymore */
.migration__nest {
  position: relative;
  padding: 2px 0 2px 35px; /* row rhythm — 2px+2px between stacked rows = 4px
    total, dialed down from 6px per Hector (2026-07-24). Not a reused system
    value (nearest documented ones are 0.5px hairline / 7px list-row / 8px
    tight adjacency) — a deliberate one-off past the smallest named token. */
}
/* Vertical connector — reversed order (2026-07-24): the corner arrow now
   leads (left:0) and the trunk line follows to its right, instead of the
   line sitting at the box edge behind the arrow. Line moved off the box's
   own border-left onto a ::before so it can sit inset, still running the
   full height of the nested group (Austria -> Spain). Line inset to left:21px
   (2026-07-24, dialed from 31px) — arrow is 11px wide, so this gives a 10px
   gap between arrow and line; padding-left 24px -> 35px to preserve the
   original 13px gap between the line and the place-name text. */
.migration__nest::before {
  content: "";
  position: absolute;
  left: 21px;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--blueprint);
}
.migration__nest .migration__place { font-size: 10px; } /* nested = Label-sm size (2026-07-12) */
.migration__row--nested-top .migration__nest { padding-top: 8px; }
.migration__row--nested-last .migration__nest { padding-bottom: 9px; }
.migration__corner {
  position: absolute;
  left: 0;
  top: 7px;
  color: var(--blueprint);
  font-size: 11px;
}
.migration__row--active .migration__place { color: var(--blueprint); }

/* Airplane-trail scroll indicator (2026-07-25, replaces the old 01-05
   numerals) — ported from explorations/migration-airplane-trail-options.html
   (Variation 1, refined per Hector). Purely scroll-position-driven (see
   js/main.js), the same technique already used for the hero/Professional
   Evolution curves and the logo wall: the drawn trail and the plane's
   position are both a direct function of scroll, so the animation
   inherently pauses the instant scrolling stops and resumes the instant it
   continues — no autonomous animation loop. Only the trail BEHIND the plane
   is drawn; there is deliberately no "ahead" guide line hinting at the rest
   of the route (Hector, 2026-07-25 — an earlier draft had one, removed). */
.migration__trail {
  position: absolute;
  top: 0;
  left: 0;
  width: 20px; /* matches migration__row's fixed 20px idx column exactly */
  pointer-events: none;
}
.trail-draw {
  position: absolute;
  left: 9.5px;
  top: 0;
  width: 1px;
  background: var(--blueprint);
  transform-origin: top;
}
.trail-plane {
  position: absolute;
  left: 0;
  top: -10px; /* centers the 20px icon on the drawn tip */
  width: 20px;
  display: flex;
  justify-content: center;
  opacity: 0;
  transition: opacity 0.25s ease;
  will-change: transform;
}
.trail-plane.is-visible { opacity: 1; }

/* Type scale — SUBTITLE tier (confirmed 2026-07-09, see design.md).
   Shared by the About pull-quote, Strengths item headings, the Hero
   clients block (column headers + list items), the Professional Evolution
   lede paragraph, and the Strengths & Expertise section intro. (The How I
   Work 01–04 markers were briefly in this tier too, but were pulled out
   2026-07-11 when How I Work was rebuilt as the B1 converging-headlines
   reveal — its number is now a tiny 12px eyebrow, `.conviction-list__num`,
   styled in the How I Work block below.) Weight changed to Normal/Regular
   across the board (confirmed 2026-07-09) — was Medium by default with the
   hero clients list items alone dropped to Regular; now everything in this
   tier is Regular, so that per-element override no longer applies/exists. */
.pull-quote,
.section p.lede,
.section p.section-intro,
.section p.pmodels-intro,
.partnership-industries .partnership-industries__lead,
.section p.otc-lede,
.section p.pevo-lede,
.otc-sub,
.hero__clients h3,
.hero__clients li {
  font-family: "HEX Franklin", sans-serif;
  font-weight: 400;
  font-size: clamp(19px, 2.4vw, 22px);
  line-height: 1.35;
}

.section p.lede {
  /* Corrected 2026-07-09: was inheriting max-width: var(--measure) from
     .section p, which computes 65ch at THIS element's own 19-22px font
     size — visibly wider than the 16px body paragraphs' 65ch. Use the
     fixed pixel equivalent instead so it wraps at the exact same width. */
  max-width: var(--measure-px);
  /* Standard paragraph gap (20px) x3 below the lede, more breathing room
     before the body copy starts — went 20 -> 40 -> 60px, same day
     (2026-07-09). */
  margin-bottom: 60px;
}

.pull-quote {
  display: flex;
  align-items: center;
  gap: 14px;
  max-width: var(--measure);
  margin: 0 0 26px;
}

.pull-quote__bracket {
  flex: 0 0 auto;
  width: 8px;
  height: 1.6em;
  border: solid var(--blueprint);
  border-width: 1px 0 1px 1px;
}

/* Bracket-emphasis typography (A4, ported 2026-07-10 from
   reference-explorations.html — confirmed keeper, unchanged from the
   demo). Inline "( keyword )" emphasis in running body copy — a
   lightweight way to pull a key term forward without a full pull-quote
   treatment. Used sparingly, matching this project's "one bold gesture,
   not several" ornament philosophy (see the signature curve's own "one
   per composition" rule) — currently applied once, in the Professional
   Evolution lede. Author the parentheses as part of the emphasized
   content itself: <span class="bracket-emphasis">( like this )</span>. */
.bracket-emphasis {
  color: var(--blueprint);
  font-weight: 700;
}

/* Bold Body — Body tier, bold variant (added 2026-08-17). Inline emphasis
   within running body copy: weight only (700), no color change — inherits
   currentColor so it works against any per-client retint. Distinct from
   .bracket-emphasis above, which is reserved for the "( keyword )"
   parenthetical-aside convention and always shifts to blueprint. Use for a
   plain bolded term/phrase inside a paragraph:
   <span class="body-bold">like this</span>. First use: Accor showcase
   dossier, Summary/Role paragraphs. */
.body-bold {
  font-weight: 700;
}

/* ==========================================================================
   STRENGTHS & EXPERTISE
   ========================================================================== */

.section p.section-intro {
  /* --measure-px, not --measure: this now renders at Subtitle-tier size
     (clamp 19-22px), and `ch` is relative to the LOCAL font-size — reusing
     the 65ch --measure here would wrap wider than every other measured
     block, the same bug hit and fixed on the Professional Evolution lede
     (see design.md). --measure-px is the fixed pixel-equivalent at Body's
     16px, so it stays visually consistent regardless of this element's
     own font-size. Specificity note: plain `.section-intro` (0,1,0) loses
     to `.section p`'s own max-width (0,1,1) — same trap as the lede bug —
     so this needs the extra `p` to actually win.
  */
  max-width: var(--measure-px);
  /* Standard paragraph gap (20px) x3 below the intro, more breathing room
     before the tile grid starts — went 20 -> 40 -> 60px (2026-07-09). Still
     wins the margin-collapse against .strengths-grid's own 12px margin-top
     either way. (Was .strengths-list before the 2026-07-11 tile redesign.) */
  margin-bottom: 60px;
}

/* Dynamic tile grid (2026-07-11) — replaces the static two-column
   .strengths-list. 2x2 plate grid; a tile reveals both body paragraphs on
   hover, a ghost numeral slides in from the bottom-right, and a shared
   converging ray-field (built + re-aimed in js/main.js) points its vanishing
   point at the active tile. On touch (hover:none) it collapses to a single
   always-open column with the hover-only flourishes removed. Adapted from the
   confirmed strengths-optionD.html "D3" exploration. See design.md. */
.strengths-grid {
  position: relative;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1px;
  background: var(--ink-hairline); /* shows through the 1px gaps as hairline dividers */
  border: 1px solid var(--ink-hairline);
  margin-top: 12px;
}

/* Shared converging ray-field — spans the whole grid, sits above tile
   backgrounds but below tile content (z-index). Lines are drawn/updated in JS. */
.strengths-field {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
  pointer-events: none;
}
.strengths-field line { stroke: var(--blueprint-soft); stroke-width: 1; }

.strengths-tile {
  position: relative;
  background: var(--paper);
  padding: 28px 30px 26px;
  min-height: 300px;
  overflow: hidden; /* clips the ghost numeral's bottom-right bleed to the plate */
  z-index: 1;
  transition: background 0.3s ease;
}
.strengths-tile:hover { background: #E9E9E2; }

.strengths-tile__head { position: relative; z-index: 3; }
/* .strengths-tile__num — typography from the shared Index tier (2026-07-12);
   was a re-declared condensed Label, now the plain in-content numeral. */

/* Title-only at rest; the ALL-CAPS heading carries the tile until hover.
   Prefixed with .strengths-grid to out-specify any generic .section rules. */
.strengths-grid .strengths-tile__title {
  position: relative;
  z-index: 3;
  margin: 22px 0 0;
  font-family: "HEX Franklin", sans-serif;
  font-size: clamp(19px, 2vw, 24px);
  font-weight: 800;
  font-stretch: 100%;
  text-transform: uppercase;
  letter-spacing: -0.01em;
  line-height: 1.02;
  color: var(--ink);
  max-width: 12ch;
}
/* Tile 01 only: manual <br> forces "Visual & / Interaction Design" onto two
   lines instead of the shared 12ch auto-wrap, which — since this title has 4
   words instead of 2 — was breaking it into three ("Visual &" / "Interaction"
   / "Design"). The 12ch cap is still what's needed for the other three
   (2-word) tiles, so it's widened only here rather than changed sitewide
   (2026-07-22, caught because the <br> alone had no visible effect). */
.strengths-grid .strengths-tile__title--split { max-width: 22ch; }

/* Body hidden by default; both paragraphs reveal together on hover, fading in
   place in their reserved space (they stay in flow while transparent) so the
   tile never reflows and the re-aiming field stays stable. Prefixed with
   .strengths-grid to beat `.section p`'s max-width/margin (the specificity
   trap documented in design.md). */
.strengths-grid .strengths-tile__text {
  position: relative;
  z-index: 3;
  margin: 16px 0 0;
  max-width: 34ch;
  font-size: 13px; /* shared list-small size (bumped down from 14px, 2026-07-22) — matches .skills-list li + .partnerships__annotation-body */
  line-height: 1.65;
  color: var(--ink);
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.4s ease, transform 0.4s ease;
}
.strengths-tile:hover .strengths-tile__text { opacity: 1; transform: none; }
.strengths-tile:hover .strengths-tile__text--more { transition-delay: 0.06s; } /* slight cascade for para 2 */

.strengths-tile__ghost {
  position: absolute;
  right: 14px;
  bottom: -18px;
  z-index: 1;
  font-family: "HEX Franklin", sans-serif;
  font-size: 150px;
  font-weight: 800;
  font-stretch: 100%;
  line-height: 0.8;
  letter-spacing: -0.04em;
  color: var(--blueprint-grid);
  pointer-events: none;
  opacity: 0;
  transform: translate(48px, 48px); /* parked off the bottom-right edge */
  transition: opacity 0.4s ease, transform 0.55s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.strengths-tile:hover .strengths-tile__ghost { opacity: 1; transform: translate(0, 0); }

/* narrow desktop windows still stack, but keep the mouse hover reveal */
@media (max-width: 720px) {
  .strengths-grid { grid-template-columns: 1fr; }
}
/* touch devices: always-open — all copy shown, hover-only flourishes
   (ray-field, ghost numeral) removed, regardless of screen size. .strengths-grid
   prefix keeps the text override winning over the default hidden rule above. */
@media (hover: none) {
  .strengths-grid .strengths-tile__text { opacity: 1; transform: none; }
  .strengths-field,
  .strengths-tile__ghost { display: none; }
}
/* Single-column stacking is a narrow-viewport thing, not a touch thing — keep
   it scoped to the same 720px breakpoint used above for narrow desktop
   windows. Split out from the general (hover: none) block above (2026-08-04)
   because that block alone was forcing single-column on ANY touch device
   regardless of width, collapsing the 2x2 grid to one column even on a
   12.9" iPad Pro (1024px portrait / 1366px landscape — plenty of room for
   2 columns), caught via a live-site screenshot. */
@media (hover: none) and (max-width: 720px) {
  .strengths-grid { grid-template-columns: 1fr; }
}

/* ==========================================================================
   HOW I WORK — numbered spec list
   ========================================================================== */

.conviction-list {
  list-style: none;
  margin: 0;
  padding: 0;
  position: relative; /* anchors the left dimension rail (::before/::after) */
  /* Full-width (2026-07-11, direct instruction) — B1 spans the window's full
     content width (viewport minus the section's own side padding), NOT the
     --measure-px text column. This deliberately removes How I Work from the
     "text-block typology shares --measure-px with Professional Evolution" rule
     (2026-07-09): it's now the interactive converging-headlines reveal, a
     different typology — like the Hero and Strengths, also excluded from that
     rule. Only the lede above stays at --measure-px; the headline list, its
     big headlines, and the expanded panel all run full width. */
}

/* Left dimension rail — B1 "dimension bracket" treatment (2026-07-11, direct
   instruction, ported from strengths-optionB.html's B1). A vertical blueprint
   hairline in the left margin, capped top and bottom with short end ticks (the
   ::after box's top/bottom borders), framing the numbered list as a measured
   technical drawing. Sits in the margin (negative left) so the list's own
   --measure-px width — shared with Professional Evolution — is untouched. */
.conviction-list::before {
  content: "";
  position: absolute;
  left: -28px;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--blueprint-grid);
}
.conviction-list::after {
  content: "";
  position: absolute;
  left: -31px;
  top: 0;
  bottom: 0;
  width: 7px;
  border-top: 1px solid var(--blueprint);
  border-bottom: 1px solid var(--blueprint);
  pointer-events: none;
}

.conviction-list li {
  position: relative;
  padding: clamp(16px, 2vw, 22px) 0;
  border-top: 1px solid var(--ink-hairline); /* subtle item divider, as in B1's list */
}

.conviction-list li:last-child {
  border-bottom: 1px solid var(--ink-hairline);
}

/* Headline row — tiny blueprint number eyebrow + the big ALL-CAPS headline,
   baseline-aligned. The li itself is now a block (row → dim → panel stack). */
.conviction-list__row {
  position: relative; /* anchors the per-item rail tick */
  display: flex;
  align-items: baseline;
  gap: 20px;
}

/* Per-item rail tick — a short horizontal nub poking from the left rail toward
   the headline; faint at rest, brightens to blueprint and grows on the active
   conviction. Absolutely positioned (out of the flex row) and anchored to the
   row so it tracks the headline, not the expanding panel below it. */
.conviction-list__row::before {
  content: "";
  position: absolute;
  left: -28px; /* reaches to the rail at the list's -28px */
  top: 50%;
  width: 8px;
  height: 1px;
  background: var(--blueprint-grid);
  transition: background 0.3s ease, width 0.3s ease;
}
.conviction-list li.is-active .conviction-list__row::before {
  background: var(--blueprint);
  width: 14px;
}

/* Number — tiny eyebrow (no longer the Subtitle tier it briefly shared); dim
   until its conviction is active. */
.conviction-list__num {
  /* typography from the shared Index tier (2026-07-12) — was 12px condensed
     Label; now the plain 11px in-content numeral. Flex sizing + the dim/active
     opacity state stay local. */
  flex: 0 0 30px;
  opacity: 0.4;
  transition: opacity 0.3s ease;
}
.conviction-list li.is-active .conviction-list__num { opacity: 1; }

/* Headline — big brutalist ALL-CAPS, ghosted at rest; on the active conviction
   it lights to full black and shifts right 10px (B1's core gesture). */
.conviction-list__title {
  font-family: "HEX Franklin", sans-serif;
  font-size: clamp(26px, 5vw, 52px);
  font-weight: 800;
  font-stretch: 100%;
  text-transform: uppercase;
  line-height: 0.98;
  letter-spacing: -0.01em; /* normalized 2026-07-12 (was -0.015em) to match every other Display headline */
  color: var(--ink-muted-30);
  transition: color 0.3s ease, transform 0.35s cubic-bezier(0.2, 0.7, 0.3, 1);
  transform-origin: left center;
}
.conviction-list li.is-active .conviction-list__title {
  color: var(--ink);
  transform: translateX(10px);
}

/* Shine-sweep on activation, per-word (2026-08-08, Hector: "run it per-word
   instead ... more striking and communicates better") — same recipe as the
   hero headline's per-word pop-in shine (.hero__headline .hw-line .hw::before/
   ::after), reusing its hw-shine-tint/hw-shine-sweep keyframes verbatim.
   Started as a single sweep across the whole title, then split to match the
   hero's word-level treatment: js/main.js wraps each word of
   .conviction-list__title in its own .cl-word span (same "wrap each word,
   inline-block + overflow:hidden clip" technique as the hero's .hw) and
   staggers .is-shining across them left to right when a conviction becomes
   active via hover/click. Headline #1 is pre-active in the markup, not
   activated through that function, so it never shines on initial page load;
   hovering back onto it later shines it like any other item, same
   mechanism, no special-casing. */
.conviction-list__title .cl-word {
  position: relative; /* anchors the shine-sweep pseudo-elements below */
  display: inline-block; /* matches .hero__headline .hw-line .hw — clips the sweep to this word's own box while still wrapping like a normal word */
  overflow: hidden;
}
.conviction-list__title .cl-word::before {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(43, 44, 225, 0.08); /* var(--blueprint) at 8% alpha, same as .partnerships__film / .hero__headline .hw */
  opacity: 0;
  pointer-events: none;
}
.conviction-list__title .cl-word::after {
  content: "";
  position: absolute;
  top: -50%;
  left: -60%;
  width: 55%;
  height: 200%;
  background: linear-gradient(115deg, transparent 0%, rgba(255, 255, 255, 0.55) 45%, rgba(255, 255, 255, 0.08) 60%, transparent 100%);
  transform: translateX(-30%);
  pointer-events: none;
}
.conviction-list__title .cl-word.is-shining::before {
  animation: hw-shine-tint 0.65s ease forwards;
}
.conviction-list__title .cl-word.is-shining::after {
  animation: hw-shine-sweep 0.65s ease forwards;
}

/* Dimension line — the measured annotation beneath the headline. Hidden at
   rest; on the active conviction it fades in and the line draws out from the
   left (width 0 -> ~260px), capped with vertical end ticks that travel with the
   growing line, plus a "ref. 03.n" spec label underneath. Space reserved so
   nothing reflows. Faithful to strengths-optionB.html's B1 (2026-07-11). */
.conviction-list__dim {
  position: relative;
  height: 16px;
  margin: 12px 0 4px;
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}
.conviction-list li.is-active .conviction-list__dim { opacity: 1; }
.conviction-list__dim-line {
  position: absolute;
  top: 3px;
  left: 0;
  height: 1px;
  width: 0;
  background: var(--blueprint);
  transition: width 0.45s cubic-bezier(0.2, 0.7, 0.3, 1) 0.05s;
}
.conviction-list li.is-active .conviction-list__dim-line { width: min(260px, 70%); }
.conviction-list__dim-line::before,
.conviction-list__dim-line::after {
  content: "";
  position: absolute;
  top: -3px;
  width: 1px;
  height: 7px;
  background: var(--blueprint);
}
.conviction-list__dim-line::before { left: 0; }
.conviction-list__dim-line::after { right: 0; }
.conviction-list__dim-label {
  /* typography from the shared Label-sm tier (2026-07-12) */
  position: absolute;
  top: 8px;
  left: 0;
  color: var(--blueprint);
  white-space: nowrap;
}

/* Body panel — hidden by default (collapsed grid row), expands on the active
   conviction. Two columns: body copy + a blueprint spec column. Inset 50px so
   the body aligns under the headline, past the number. */
.conviction-list__panel {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition: grid-template-rows 0.4s ease, opacity 0.3s ease;
}
.conviction-list li.is-active .conviction-list__panel {
  grid-template-rows: 1fr;
  opacity: 1;
}
.conviction-list__panel-in { overflow: hidden; }
.conviction-list__panel-grid {
  display: grid;
  grid-template-columns: 1fr 200px;
  gap: 40px;
  padding: 6px 0 6px 50px;
}
.conviction-list__text {
  display: block;
  max-width: 52ch;
  margin: 0;
  font-size: 16px; /* Body tier */
  line-height: 1.7;
  color: var(--ink);
}
.conviction-list__spec {
  /* typography from the shared Label-sm tier (2026-07-12); the wide line-height,
     blueprint color and the left spec-column rule stay local. */
  line-height: 1.9;
  color: var(--blueprint);
  border-left: 1px solid var(--blueprint-grid);
  padding-left: 16px;
}

/* Panel collapses to a single column on narrow screens. */
@media (max-width: 640px) {
  .conviction-list__panel-grid {
    grid-template-columns: 1fr;
    gap: 14px;
    padding-left: 0;
  }
}

/* Narrow screens: the section's side padding shrinks below the rail/tick
   negative offsets, so they'd clip off the viewport edge. Drop the rail and the
   per-item ticks there; headlines + copy are unaffected. */
@media (max-width: 560px) {
  .conviction-list::before,
  .conviction-list::after,
  .conviction-list__row::before { display: none; }
}

/* ==========================================================================
   PREVIOUS PARTNERSHIPS — project slideshow (placeholder) + logo wall
   ========================================================================== */

.slideshow-meta {
  color: var(--ink-muted-65);
  margin-bottom: 16px;
}

.slideshow {
  max-width: 900px;
  /* Centered on the page (confirmed 2026-07-09, matching the live site's
     logo wall + slideshow treatment) — was left-aligned like every other
     section's content by default; margin-left/right:auto centers this
     block specifically within the section, without affecting anything
     else in the section (eyebrow, slideshow-meta label stay left-aligned). */
  margin: 0 auto 56px;
}

.slideshow__slide {
  aspect-ratio: 16 / 9;
  border: 1px dashed var(--ink-hairline);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 12px;
}

.slideshow__placeholder {
  /* typography now comes from the shared Label tier rule above —
     fixes the 0.04em tracking bug flagged in the type audit (2026-07-09) */
  color: var(--ink-muted-65);
}

.slideshow__caption {
  display: flex;
  justify-content: space-between;
  /* typography now comes from the shared Label tier rule above —
     fixes the 0.04em tracking bug flagged in the type audit (2026-07-09) */
  color: var(--ink-muted-65);
  margin-bottom: 14px;
}

.slideshow__dots {
  display: flex;
  gap: 6px;
}

.slideshow__dots span {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  border: 1px solid var(--ink-hairline);
}

.slideshow__dots span.is-active {
  background: var(--blueprint);
  border-color: var(--blueprint);
}

.logo-wall-label {
  color: var(--ink-muted-65);
  margin-bottom: 14px;
}

.logo-wall {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  /* Grid/hairline-border tile treatment removed (confirmed 2026-07-09),
     matching the live site: no dividing lines or background/border on the
     grid itself, just open gaps between logos. Was gap:1px with a
     var(--ink-hairline) background showing through as border lines
     (bordered spec-sheet tile look) — replaced with real breathing room
     between logos instead. */
  gap: 32px 24px;
  /* Centered on the page (confirmed 2026-07-09, matching the live site) —
     was left-aligned like every other section's content; margin-left/
     right:auto centers this block specifically (eyebrow, logo-wall-label
     stay left-aligned, unaffected). */
  /* Bottom margin bumped 56px -> 72px (2026-07-31, direct instruction: more
     breathing room before "My career has spanned..." below). Paired with
     .partnership-industries' top margin (8px -> 24px) for a 96px total gap —
     deliberately NOT the shared --section-gap token (clamp(64px,10vw,120px),
     used between actual sections): this and the paragraph below are both
     part of the same Previous Partnerships section, so using the full
     section-to-section rhythm here would misread as a section break. Kept
     as one-off fixed values, just larger ones, same as before. */
  margin: 0 auto 72px;
  padding: 0;
  max-width: 900px;
}

.logo-wall li {
  display: flex;
  align-items: center;
  justify-content: center;
}

.logo-wall img {
  /* Sized to match the live site's near-native logo scale (each source SVG
     is a 195x111 canvas) rather than the old max-height:28px, which read
     much thinner/smaller than hector-miranda.com's actual logo wall.
     Opacity dimming + hover-to-full-strength also removed (confirmed
     2026-07-09) — live site shows logos at full strength always, no dim
     state. */
  max-width: 100%;
  max-height: 90px;
}

@media (max-width: 720px) {
  .logo-wall { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* ==========================================================================
   PREVIOUS PARTNERSHIPS — logo-wall hover + password gate (2026-07-21)
   Ported from explorations/partnership-xyz-hover.html, feel-approved and
   refined across several review rounds (see MEMORY.md). Logos stay
   unchanged at rest, matching the 2026-07-09 rule — this is additive only:
   a cursor crosshair scans the whole wall, and rolling onto one of the 5
   case-study logos (Accor, Verizon, Google, Hello bank!, EPAM) swaps in a
   dashed-leader annotation with that project's tagline. A click opens the
   password gate. Font/type props for the Label + Label-sm pieces below live
   in the shared groups at the top of this file, not repeated here.
   ========================================================================== */

.partnerships__wall-scan {
  position: relative;
  /* Clips the crosshair's 5000px cross-lines (see .partnerships__crosshair
     below) to the wall's own bounds. .hero__crosshair gets this for free
     from .hero's own overflow:hidden; this instance has no such ancestor,
     so without this the cross-lines bled clear across the page and blew
     out the document's scrollWidth (found + fixed 2026-07-22). */
  overflow: hidden;
  /* Break this element out to the section's true edge-to-edge width so the
     crosshair bleeds full-bleed left/right like .hero__crosshair does
     (Hector, 2026-07-22) — otherwise it's confined to .section's own
     inline padding (--section-pad-x). .logo-wall stays put visually: it's
     independently centered via its own margin:0 auto/max-width:900px, so
     it doesn't shift when its ancestor's box widens. */
  margin-inline: calc(-1 * var(--section-pad-x));
}


/* Crosshair — reproduced from .hero__crosshair (no z-tick: this instance
   has no curve/z reading, just x/y). */
.partnerships__crosshair {
  position: absolute;
  /* Fixed top:0/left:0 anchor (2026-08-27, Cloudflare CLS fix) — live
     position now comes entirely from transform: translate() in
     js/main.js, not left/top. Same fix/reasoning as .hero__crosshair. */
  top: 0;
  left: 0;
  z-index: 2;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.partnerships__crosshair.is-active { opacity: 1; }
.partnerships__crosshair::before,
.partnerships__crosshair::after {
  content: "";
  position: absolute;
  background: var(--blueprint);
  opacity: 0.3;
}
.partnerships__crosshair::before { width: 1px; height: 5000px; top: -2500px; left: 0; }
.partnerships__crosshair::after { height: 1px; width: 5000px; left: -2500px; top: 0; }

/* Translucent-film hover cue (ported 2026-08-03 from
   explorations/logo-wall-annotation-cue-options.html; upgraded same day
   to Variant C2 — glossy animated shine sweep, confirmed by Hector as the
   final pick over the flat Variant C tint and the static-sheen Variant C1
   ("looks great!"). Wraps each has-case logo's <img> in a slot sized to
   the image's own rectangle (inline-flex, no padding — same fix the
   corner-tick Variant B needed: padding on the wrapper puts the effect in
   the empty gutter around the logo instead of on the logo itself). The
   film itself is two layers: a flat var(--blueprint) 8% base tint that
   fades in/out on hover (unchanged from the original flat Variant C), plus
   a ::after pseudo-element carrying a bright diagonal highlight band that
   sweeps across the logo once per hover — like light catching a glossy
   sheet of colored film as it moves, per Hector's reference photos of blue
   PVC/x-ray-style protective sheeting. The two layers use independent
   transitions (opacity 0.2s on the base, transform 0.65s on the sweep) so
   the tint appears quickly while the sweep travels more slowly across it.
   Plain CSS :hover/:focus-within, no JS.

   Extended from the 5 has-case logos to all 16 (2026-08-08, Hector) —
   now a plain glossy hover accent across the whole wall, not a "links to
   a dossier" tell. The has-case class and its cursor: pointer rule were
   removed entirely (2026-09-07) once the wall stopped linking anywhere —
   see MEMORY.md's "Logo wall navigation removed" entry. */
.partnerships__logo-slot { position: relative; display: inline-flex; }
.partnerships__film {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: rgba(43, 44, 225, 0.08); /* var(--blueprint) at 8% alpha */
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
.partnerships__film::after {
  content: "";
  position: absolute;
  top: -50%;
  left: -60%;
  width: 55%;
  height: 200%;
  background: linear-gradient(115deg, transparent 0%, rgba(255, 255, 255, 0.55) 45%, rgba(255, 255, 255, 0.08) 60%, transparent 100%);
  transform: translateX(-30%);
  transition: transform 0.65s ease;
}
.logo-wall li:hover .partnerships__film,
.logo-wall li:focus-within .partnerships__film { opacity: 1; }
.logo-wall li:hover .partnerships__film::after,
.logo-wall li:focus-within .partnerships__film::after { transform: translateX(255%); /* was 230% — sweep extended further right, 2026-09-07, Hector: trying 255% first (see MEMORY.md for the measured range: ~260%+ overshoots the tile entirely) */ }

/* Leader — reproduced from .hero__leader. */
.partnerships__leader {
  position: absolute;
  top: 0;
  left: 0; /* anchor — js/main.js positions/rotates this live via one combined transform, same CLS fix as .partnerships__crosshair above */
  z-index: 2;
  pointer-events: none;
  height: 1px;
  border-top: 1px dashed var(--blueprint);
  transform-origin: left center;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.partnerships__leader.is-active { opacity: 0.7; }

/* Annotation — reproduced from .hero__annotation, sized straight to its
   tagline content (this instance never shows a plain coordinate readout,
   unlike the hero's, so there's no separate at-rest/tagline split here).
   Spring-in entrance matches the same signature reveal easing used
   throughout the site. Dimmed to var(--annotation) (2026-07-21, same pass
   as the hero box) rather than full blueprint — Hector's call, to match
   the hero's now-dimmed cursor annotation rather than stand out from it.

   2026-07-28: tried swapping the gray hairline outline for .gate-panel-
   style blueprint corner registration ticks — reverted, didn't work for
   Hector. Back to the plain outline below. */
.partnerships__annotation {
  position: absolute;
  top: 0;
  left: 0; /* anchor — js/main.js positions this live via the standalone `translate` property below */
  z-index: 3;
  pointer-events: none;
  background: var(--paper);
  border: 0.5px solid var(--annotation); /* softened from 1px (2026-07-23), exploratory */
  color: var(--annotation);
  padding: 10px 12px;
  max-width: 260px;
  white-space: normal;
  opacity: 0;
  /* Position and the entrance animation are deliberately kept on two
     separate CSS properties (2026-08-27, Cloudflare CLS fix, revised
     after the first attempt): js/main.js sets the standalone `translate`
     property on every mousemove for cursor-tracked position (compositor-
     only, untransitioned, so the browser doesn't score it as a layout
     shift, and there's nothing to animate — same fix as
     .partnerships__crosshair above). `transform` stays exactly as it was
     before this fix, doing only the entrance spring (translateY/scale).
     `translate` and `transform` are separate CSS properties that compose
     automatically at render time — first attempt tried folding the
     position into `transform` itself via a var()-driven custom property,
     which is what this element's own working copy on disk looked like
     for a short time this session, but real-browser testing (not just
     assumption) found that a custom property changing in the same tick
     as a `transition`-ed `transform` doesn't reliably reflow — the
     position silently stuck at its fallback value. Using the standalone
     `translate` property (Baseline since 2021, safe for this project)
     sidesteps that entirely by not putting the position inside the
     transitioned property at all. */
  transform: translateY(16px) scale(0.94);
  transition: opacity 0.3s ease, transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.partnerships__annotation.is-active {
  opacity: 1;
  transform: translateY(0) scale(1);
}
.partnerships__annotation-head { display: block; margin-bottom: 6px; }
/* Blue "showcase →" cue REMOVED (2026-09-07, direct instruction) — the
   logo wall no longer links out to showcase pages, so this "clickable"
   signal no longer applies. Every logo's hover box now reads a plain
   "key · partner" head line (see js/main.js). */
.partnerships__annotation-body {
  /* 2026-07-22: switched from an invented 12px/1.45 (used nowhere else) to
     the shared "list-small" size — now 13px/1.65 (bumped down from 14px
     same day, all 3 members moved together) — the same value as
     .skills-list li (Acquired skills) and .strengths-tile__text (Strengths
     & Expertise) — so this box's tagline copy traces to an existing,
     already-multiply-used size instead of a one-off. font-weight reset to
     400 too — without it this rule was inheriting the parent Label-sm
     group's 500 (Medium), reading visibly bolder than the skills-list/
     strengths-tile source it's supposed to match (caught in review). */
  display: block;
  text-transform: none;
  letter-spacing: normal;
  font-stretch: 100%;
  font-weight: 400;
  font-size: 13px;
  line-height: 1.65;
  color: var(--ink);
}

/* Password gate — server-side PHP cookie gate (see MEMORY.md), not an overlay
   sitting on top of index.html. Since 2026-09-15 it is not a page of its own
   either: the gate was merged into showcase/index.php, which renders it as
   that page's LOCKED state and reuses this same .gate-panel box for the
   titleblock once unlocked. (It lived in a standalone gate.php between
   2026-07-23 and 2026-09-15; that file is gone.) Shared styling across all 7
   showcase dossiers. No new color introduced (blueprint + ink + hairline
   only.) NOTE: .gate-overlay.is-open sets pointer-events:auto, which the
   unlocked titleblock overrides with a THREE-class selector in index.php —
   see the comment there before changing anything here. */
.gate-overlay {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.55);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}
.gate-overlay.is-open { opacity: 1; pointer-events: auto; }
.gate-panel {
  position: relative;
  width: min(420px, 92vw);
  background: var(--paper);
  padding: 32px; /* symmetric — a single deliberate value on all sides */
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); /* soft edge replacing the 1px solid --ink stroke (2026-07-23) */
  opacity: 0;
  transform: translateY(16px) scale(0.97);
  transition: opacity 0.3s ease, transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.gate-overlay.is-open .gate-panel { opacity: 1; transform: translateY(0) scale(1); }
.gate-panel.is-shaking { animation: gate-shake 0.4s ease; }
@keyframes gate-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-8px); }
  40% { transform: translateX(7px); }
  60% { transform: translateX(-5px); }
  80% { transform: translateX(3px); }
}
/* corner registration ticks — reproduced verbatim from .otc-frame */
.gate-panel::before,
.gate-panel::after {
  content: "";
  position: absolute;
  width: 9px;
  height: 9px;
  border: 1px solid var(--blueprint);
  pointer-events: none;
}
.gate-panel::before { top: 6px; left: 6px; border-right: 0; border-bottom: 0; }
.gate-panel::after { bottom: 6px; right: 6px; border-left: 0; border-top: 0; }

/* Way back for visitors without the access code (2026-07-23) — the gate is
   its own page rather than an overlay on index.html (standalone gate.php
   then, showcase/index.php's locked state since 2026-09-15), so there was
   previously no in-page escape besides the browser back button. Two options
   were built and compared: this quiet corner "×" (Option A, reusing CSS
   already designed for the old client-side overlay), and a visible "← back,
   no access code" text link under the Enter button (Option B, see
   .gate-back-link below). Hector chose Option B — this rule stays defined
   but unused (matches its original dormant state before 2026-07-23) in case
   a future revision wants the quieter treatment back. */
.gate-close-x {
  position: absolute;
  top: 16px;
  right: 18px;
  width: 11px;
  height: 11px;
  cursor: pointer;
}
.gate-close-x::before,
.gate-close-x::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 0;
  width: 100%;
  height: 1px;
  background: var(--ink-muted-55);
  transition: background 0.2s ease;
}
.gate-close-x::before { transform: rotate(45deg); }
.gate-close-x::after { transform: rotate(-45deg); }
.gate-close-x:hover::before,
.gate-close-x:hover::after { background: var(--ink); }

.gate-doc {
  /* Annotation-role (Label/Annotation split) — recedes below content-level
     labels. Now references the named var(--annotation) token directly
     (site-wide rollout, 2026-07-21) rather than --ink-muted-30 inline —
     same value, but the semantic name is now the tokenized site standard. */
  color: var(--annotation);
}
.gate-title {
  /* doc-line -> title: 8px, the .otc-cap tight-adjacency token (reused
     twice more below) — no documented token sits between 8px and the
     .section p paragraph-gap token (20px), so 8px was the deliberate
     choice when this was tightened. */
  margin-top: 8px;
  color: var(--blueprint);
}
/* Was `.section p.gate-copy` (a specificity bump against .section p's own
   margin:0 0 20px) back when the gate was a client-side overlay living
   inside .section--partnerships on index.html. Found 2026-07-23 (Hector,
   reviewing what was then the standalone gate.php page): that ancestor no
   longer exists — the gate renders at the top level (<body><div
   class="gate-overlay">, no .section wrapper anywhere; since 2026-09-15 that
   is showcase/index.php's locked state), so the bumped selector silently
   never
   matched and this rule was dead code. Every value below was being lost to
   the browser's default <p> margin/line-height instead (16px/16px/normal),
   not the intended 28px/0/1.7 — same failure mode the old comment warned
   about, just triggered by the gate becoming its own page instead of the
   original cause. Dropped the unneeded `.section` — no other bare `p` rule
   exists outside `.section` for this to lose a specificity fight against. */
p.gate-copy {
  /* title -> copy: the eyebrow->content shape, reusing its 28px gap. Real
     Body tier verbatim (16px/lh 1.7/full --ink) since this is genuinely
     paragraph-level copy. */
  margin-top: 28px;
  margin-bottom: 0;
  font-size: 16px;
  line-height: 1.7;
  color: var(--ink);
}
.gate-field { margin-top: 28px; } /* copy -> field: same 28px component-transition gap */
/* Visible "access code" label removed 2026-07-23 (Hector: too wordy, not
   necessary above a single obvious password field) — the input now carries
   an aria-label instead so screen readers still get the same context. Rule
   stays defined but unused, same as .gate-close-x above, in case a future
   revision wants it back. */
.gate-label {
  display: block;
  color: var(--ink-muted-65);
  margin-bottom: 8px; /* label -> input: .otc-cap's tight-adjacency gap */
}
.gate-input {
  display: block;
  width: 100%;
  border: none;
  border-bottom: 1px solid var(--ink-hairline);
  background: transparent;
  font-family: "HEX Franklin", sans-serif;
  font-size: 16px;
  color: var(--ink);
  padding: 8px 0;
  outline: none;
  transition: border-color 0.2s ease;
}
.gate-input:focus { border-bottom-color: var(--blueprint); }
.gate-error {
  display: none;
  margin-top: 8px; /* input -> inline error: same tight adjacency */
  /* Error red (2026-07-23, Hector) — the one deliberate exception to the
     three-colour system (--paper/--ink/--blueprint), scoped to error/
     validation messaging only. Tokenized as --error; see :root. */
  color: var(--error);
}
.gate-error.is-shown { display: block; }
.gate-success {
  display: none;
  margin-top: 8px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--ink-muted-65);
}
.gate-success.is-shown { display: block; }
.gate-actions { margin-top: 28px; } /* field -> actions: same component-transition gap */
/* Full-width primary action (Hector 2026-07-21) — the button spans the
   input's own width, reading as its natural continuation. Was "no visible
   cancel; dismiss via the corner × / Esc / click-outside only" until
   2026-07-23, when Hector chose a visible back-link (.gate-back-link,
   below) over the quiet corner-× approach — see the .gate-close-x comment
   above for the full A/B comparison. */
.gate-submit {
  display: block;
  width: 100%;
  color: var(--blueprint);
  background: transparent;
  border: 1px solid var(--blueprint);
  padding: 10px 20px;
  cursor: pointer;
  transition: background 0.2s ease, color 0.2s ease;
}
.gate-submit:hover { background: var(--blueprint); color: var(--paper); }
/* Way back, Option B (chosen 2026-07-23 over the corner-× Option A) — a
   visible secondary link under the primary button rather than a dismiss-
   only corner mark. Left-aligned per Hector's preference after comparing
   centered vs. flush-left (flush-left read better against the button's own
   left edge). List-small size (13px, matches the site's secondary-copy
   tier) at --ink-muted-65 (same tone as .gate-label) so it stays visibly
   secondary to "Enter →"; brightens to --blueprint on hover like every
   other link on the site. */
.gate-back-link {
  display: block;
  text-align: left;
  margin-top: 16px;
  font-size: 13px;
  color: var(--ink-muted-65);
  text-decoration: none;
  transition: color 0.2s ease;
}
.gate-back-link:hover { color: var(--blueprint); }

/* Industries statement + "request access" CTA below the logo wall (added
   2026-07-11, live-site copy). Left-aligned like the rest of the section
   (only the logo wall itself is centered). */
.partnership-industries {
  /* Block centered on the page like the live site (and the logo wall above);
     text stays left-aligned within it. */
  max-width: var(--measure-px);
  /* Top margin bumped 8px -> 24px (2026-07-31, direct instruction) — paired
     with .logo-wall's bottom margin (56px -> 72px) for a 96px total gap
     above this paragraph. See that rule's comment for why this stays a
     fixed one-off value rather than the shared --section-gap token. */
  margin: 24px auto 44px;
}
/* First paragraph = Subtitle tier — typography now from the shared Subtitle
   group (2026-07-12 consolidation); only its width stays local. Two-class
   selector still needed to beat `.section p`'s own max-width. */
.partnership-industries .partnership-industries__lead {
  max-width: var(--measure-px);
}
.partnership-industries__cta {
  margin-bottom: 0;
}
.partnership-industries__cta a {
  color: var(--blueprint);
  text-decoration: none;
}
.partnership-industries__cta a:hover {
  text-decoration: underline;
}

/* ===== What I'm Looking For — partnership models, folded into Previous
   Partnerships (2026-07-12, mockup Option B / looking-for-options.html).
   Stacked spec rows: the model name reuses the Strengths headline style
   (.strengths-tile__title); number + spec tag use the site's label vocabulary;
   row hairlines follow the list convention (0.5px). Aligned to the same
   measure-px width and centering as .partnership-industries above. ===== */
.pmodels-wrap {
  max-width: var(--measure-px);
  margin: 0 auto 44px;
}
.section p.pmodels-intro { /* Subtitle tier — typography from the shared Subtitle group (2026-07-12); width + margin stay local. Two-class selector beats .section p */
  max-width: var(--measure-px);
  margin: 40px 0 22px;
}
.pmodels__row {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: 0 36px;
  align-items: start;
  padding: 26px 0;
  border-top: 0.5px solid var(--ink-hairline);
}
.pmodels__row:first-of-type { border-top: 0; } /* rule under the pmodels intro removed 2026-07-13 (was 0.5px solid ink) */
.pmodels__num {
  /* typography from the shared Index tier (2026-07-12); block + gap stay local */
  display: block;
  margin-bottom: 10px;
}
.pmodels__name {
  margin: 0;
  font-family: "HEX Franklin", sans-serif;
  font-size: clamp(19px, 2vw, 24px);
  font-weight: 800;
  font-stretch: 100%;
  text-transform: uppercase;
  letter-spacing: -0.01em;
  line-height: 1.02;
  color: var(--ink);
}
.pmodels__spec {
  /* typography from the shared Label-sm tier (2026-07-12) — was drifted to
     weight 400 / 0.03em; now the canonical 500 / 0.06em. Muted ink = quiet
     aside (vs blueprint's technical register). */
  display: block;
  margin-top: 12px;
  color: var(--ink-muted-65);
}
.pmodels__body p {
  max-width: none;
  margin: 0 0 14px;
  font-size: 16px;
  line-height: 1.7;
  color: var(--ink);
}
.pmodels__body p:last-child { margin-bottom: 0; }

@media (max-width: 640px) {
  .pmodels__row { grid-template-columns: 1fr; gap: 12px 0; }
}

/* ==========================================================================
   OFF THE CLOCK (rebuilt 2026-07-11) — live-site "Hobbies & Interests" content
   in the blueprint register. Option A "plate index" layout with Option D's
   subtitle + balanced display headline. See design.md / MEMORY.md.
   ========================================================================== */

.section--off-the-clock .otc-openrow {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: end;
  gap: 24px;
  border-bottom: 1px solid var(--ink);
  padding-bottom: 20px;
}

/* Subtitle tier (type-specimen.html Tier 2: Normal 400, clamp 19–22, lh 1.35,
   sentence case) as the headline lead-in. */
.otc-sub {
  /* typography from the shared Subtitle group (2026-07-12) */
  display: block;
  color: var(--ink);
  margin-bottom: 12px;
}

/* Display line — Extrabold uppercase, widened to fit "and vintage
   instruments." on one line, balanced for even line lengths. */
.otc-display {
  display: block;
  font-weight: 800;
  font-size: clamp(26px, 4vw, 46px);
  line-height: 1.02;
  letter-spacing: -0.01em;
  text-transform: uppercase;
  max-width: 26ch;
  text-wrap: balance;
}
.otc-cur { color: var(--blueprint); }
.otc-nowrap { white-space: nowrap; }

.otc-spec {
  /* typography from the shared Label-sm tier (2026-07-12) — was 10.5px/0.05em;
     now 10px/0.06em. Muted ink = quiet aside. */
  color: var(--ink-muted-65);
  text-align: right;
  line-height: 1.5;
  white-space: nowrap;
}

/* Four-across plate strip (image strip + caption strip below it). Both carry
   margin: 40px 0 12px; the caption strip's top margin is zeroed so it sits
   12px under the images (matching the mockup exactly). */
.otc-strip {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: clamp(12px, 1.6vw, 22px);
  margin: 40px 0 12px;
}

.otc-frame {
  position: relative;
  margin: 0;
  background: var(--paper);
  border: 1px solid var(--ink);
  overflow: hidden;
}
/* corner registration ticks — recolored to var(--paper) (#E4E4DD, 2026-07-22,
   was --blueprint) so they read as light marks over the desaturated photos
   rather than an accent color. */
.otc-frame::before,
.otc-frame::after {
  content: "";
  position: absolute;
  width: 9px;
  height: 9px;
  border: 1px solid var(--paper);
  pointer-events: none;
  z-index: 2;
}
.otc-frame::before { top: 6px; left: 6px; border-right: 0; border-bottom: 0; }
.otc-frame::after  { bottom: 6px; right: 6px; border-left: 0; border-top: 0; }

/* Plate entrance — slide & latch, alternating (2026-07-22, chosen from three
   options built in explorations/off-the-clock-anim-options.html). Was no
   reveal at all — .otc-plate opted out of the generic .reveal system (see
   main.js) since it just appeared on scroll. Odd plates enter from the
   left, even from the right, landing with the site's signature overshoot on
   a tight 40ms stagger — reads as a zipper/comb closing across the row.
   Triggered by a dedicated IntersectionObserver in main.js that adds
   .is-in to .otc-strip once — the alternating per-child direction doesn't
   fit the shared reveal transform, hence the bespoke rule set instead of
   reusing .reveal. */
.otc-plate {
  opacity: 0;
  transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.35s ease;
}
.otc-strip .otc-plate:nth-child(odd)  { transform: translateX(-56px); }
.otc-strip .otc-plate:nth-child(even) { transform: translateX(56px); }
.otc-strip.is-in .otc-plate { opacity: 1; transform: translateX(0); }
.otc-strip .otc-plate:nth-child(1) { transition-delay: 0ms; }
.otc-strip .otc-plate:nth-child(2) { transition-delay: 40ms; }
.otc-strip .otc-plate:nth-child(3) { transition-delay: 80ms; }
.otc-strip .otc-plate:nth-child(4) { transition-delay: 120ms; }

/* Photos desaturated into the three-color palette; native ratio by default. */
.otc-photo {
  display: block;
  width: 100%;
  height: auto;
  filter: grayscale(1) contrast(1.03) brightness(1.02);
}
/* Crop the two LEFT plates (05.1 vinyl, 05.2 moto — natively 1.4) to the right
   pair's 1.867 ratio so all four sit at equal height in the row. */
.otc-strip .otc-plate:nth-child(1) .otc-frame,
.otc-strip .otc-plate:nth-child(2) .otc-frame { aspect-ratio: 420 / 225; }
.otc-strip .otc-plate:nth-child(1) .otc-photo,
.otc-strip .otc-plate:nth-child(2) .otc-photo { height: 100%; object-fit: cover; }

.otc-cap {
  /* typography from the shared Label-sm tier (2026-07-12) — was 10.5px/0.05em;
     now 10px/0.06em. Blueprint = plate caption / technical register. */
  display: block;
  color: var(--blueprint);
  margin-top: 8px;
}

/* Single body column, flush left, 2/3 of the stage width. */
.otc-body {
  max-width: 66.66%;
  margin-top: 60px; /* was 28px (2026-07-17). NOTE: adjacent sibling
    margins collapse to the larger value in normal block flow, not the
    sum — .otc-strip's 12px bottom margin and this top margin collapse to
    just max(12, this), so this value IS the real rendered gap, not one
    half of an additive total. Verified live via getBoundingClientRect()
    after an initial wrong assumption (48px) landed short. 60px matches
    the sitewide rhythm for a Subtitle-tier line sitting above body copy
    (see design.md, Strengths & Professional Evolution ledes). */
}
.section--off-the-clock .otc-body p { max-width: none; }

@media (max-width: 720px) {
  .otc-strip { grid-template-columns: repeat(2, 1fr); }
  .section--off-the-clock .otc-openrow { grid-template-columns: 1fr; }
  .otc-spec { text-align: left; }
  .otc-body { max-width: none; }

  /* Off the Clock curve, mobile only (2026-07-28): the path's y-range was
     calibrated against the desktop/wide-viewport layout only (see the
     index.html comment above the SVG) and at phone widths its drawn
     portion lands mid-paragraph, cutting through .otc-body's 2nd
     paragraph — confirmed directly in a real mobile viewport via
     getPointAtLength sampling, not eyeballed. Measured at both 375px and
     386px widths: the curve's visible band sits a consistent ~99-120px
     above where it needs to be to clear the paragraph and the fig-label.
     Pushing the whole (already-unclipped) curve layer down 120px lands
     it centered in the gap between the fig-label and the back-to-top
     arrow at both widths, clear of all text. Transform only, so the
     scroll-driven draw/erase math in js/main.js (which reads the path's
     live rect) keeps working unchanged. Desktop is untouched — this rule
     only exists inside this media query.

     Bumped 120px -> 125px only (2026-08-03, Hector) — NOT matching the
     desktop base rule's +25px. Live measurement (getPointAtLength
     sampling, real mobile viewport) showed this gap only ever had ~5-6px
     of slack before the curve's band touches the back-to-top arrow's own
     box (it was tuned to sit centered in a tight 30px gap, not a roomy
     one) — a first try at +25px here put the ENTIRE curve band inside
     back-to-top's box, not just tighter clearance. Hector's call: cap the
     mobile shift at +5px instead, desktop keeps its own full +25px. */
  .off-the-clock-curve-layer { transform: translateY(125px); }
}

/* ==========================================================================
   CONTACT / FOOTER
   ========================================================================== */

.section--contact {
  padding-bottom: 80px;
}

.section--contact p {
  margin: 0 0 8px;
}

.section--contact a {
  text-decoration: none;
  display: inline-block; /* magnetic-hover port, 2026-07-10 — see .hero__nav-list a's matching note */
}

.section--contact a:hover {
  text-decoration: underline;
}

.footer-copyright {
  /* Type from the shared Label-sm tier (2026-07-13) — matches the "role ·
     full-time" spec-tag style per instruction (condensed uppercase 10px,
     +0.06em); was Micro tier (sentence case, no tracking). */
  margin-top: 24px;
  color: var(--ink-muted-65);
}

/* ==========================================================================
   METRIC COUNT-UP — reusable component (ported 2026-07-10 from
   motion-blend.html's confirmed blend). Not applied to any real element
   yet: no metric/percentage content exists on the site currently
   (case-study numbers are deferred to Hector, see design.md open items).
   Ready to use once real numbers land — mark up as:
   <div class="metric">
     <span class="metric__value" data-metric-target="140">0%</span>
     <div class="metric__label">Increase in ... — Client Name</div>
   </div>
   See js/main.js for the count-up/easing logic this pairs with.
   ========================================================================== */

.metric__value {
  font-family: "HEX Franklin", sans-serif;
  font-weight: 700;
  /* Bumped 2026-07-21 (Hector): first live use (Accor dossier) read too small
     next to Headline (clamp 28-52px) and Body (16px) — the "largest element
     on a page" billing wasn't landing, especially at the mobile floor. Scaled
     up proportionally (~2.2x Headline at both ends, was ~1.7x) rather than
     picking an arbitrary number: was clamp(48px,8vw,88px).
     Reverted 2026-09-08 (Hector): read too big in practice once live across
     the showcase dossiers. Back to the pre-2026-07-21 value on DESKTOP only
     (see the mobile override just below) — applies globally to every dossier
     using .metric__value, not just one page. */
  font-size: clamp(48px, 8vw, 88px);
  line-height: 1;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
  display: block;
}

/* Mobile carve-out (2026-09-08, Hector): keep phones at the original 112px/64px
   pair. The 8vw/10vw middle term of this clamp never actually reaches at a real
   phone width (8vw of 390px is ~31px, 10vw is ~39px) - both are always below
   their own floor - so every phone renders pinned to the floor value regardless
   of what it's set to. Without this override, the desktop-only reduction above
   would silently also shrink mobile from 64px down to 48px. 720px matches this
   site's standard mobile breakpoint (used throughout styles.css and every
   showcase dossier's own inline styles). */
@media (max-width: 720px) {
  .metric__value { font-size: clamp(64px, 10vw, 112px); }
}

.metric__label {
  /* typography from the shared Label tier (2026-07-12) — was missing its
     explicit weight; now inherits the canonical 500. */
  color: var(--blueprint);
  margin-top: 10px;
}

/* ==========================================================================
   MOBILE NAV
   ========================================================================== */

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.hero__nav-toggle {
  display: none;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 28px;
  height: 22px;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
}

.hero__nav-toggle span {
  display: block;
  height: 2px;
  background: var(--blueprint);
}

@media (max-width: 640px) {
  .hero__nav-toggle {
    display: flex;
  }
  .hero__nav-list {
    display: none;
    flex-direction: column;
    gap: 10px;
    width: 100%;
    margin-top: 12px;
    order: 3;
  }
  .hero__nav-list.is-open {
    display: flex;
  }
}
