/* @layer components — nav, sections, cards, forms, buttons */

@layer components {

  /* ─────────────────────────────────────────────
     NAVIGATION
     Visual treatment only — layout in layout.css.
     Attribute selector drives mobile open/close state;
     no JS class toggling required for visuals.
     ───────────────────────────────────────────── */

  .nav {
    background-color: var(--nav-bg);
    border-block-end: var(--border-hairline);
    /* Slight blur when content scrolls behind nav */
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    /* Compensate for semi-transparency: darken bg slightly */
    background-color: color-mix(in srgb, var(--nav-bg) 92%, transparent);
  }

  .nav__wordmark {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: 700;
    letter-spacing: var(--tracking-wider);
    color: var(--color-text-primary);
    text-transform: uppercase;
    transition: var(--transition-color);
    /* Session 30 (author-reported): at the horizontal-nav breakpoint, a
       fuller admin-state link row (Doctrine/The Ninety/Disciplines/The
       Sage/Begin/Shop/Dashboard/Admin/Sign Out -- 9 items, more than the
       public 6 this breakpoint was originally tuned against) left each
       flex item too little width, so text wrapped mid-word ("SAGE" /
       "TRAINING" split onto two lines). white-space: nowrap turns "doesn't
       fit" into a visible overflow rather than a silent word-split --
       paired with the wider breakpoint below, which is the actual fix. */
    white-space: nowrap;
  }

  .nav__wordmark:hover {
    color: var(--color-accent);
  }

  /* The Forged Crown (Phase 18a) -- fixed brand gold, independent of the
     wordmark text's hover-color transition. The mark is the identity;
     only the text link should visually react to hover. */
  .nav__mark {
    inline-size: 1.35em;
    block-size: 1.5em;
    flex-shrink: 0;
    fill: var(--color-accent);
  }

  .nav__link {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--nav-link-color);
    transition: var(--transition-color);
    padding-block: var(--space-1);
    /* Session 30: see .nav__wordmark's comment above -- same mid-word wrap
       bug, same fix. */
    white-space: nowrap;
  }

  .nav__link:hover {
    color: var(--nav-link-color-hover);
  }

  /* aria-current set by JS IntersectionObserver as sections enter viewport */
  .nav__link[aria-current="true"] {
    color: var(--nav-link-color-active);
  }

  /* Mobile: open state adds background to the panel.
     Breakpoint widened 64rem -> 78rem, Session 30 -- see .nav__wordmark's
     comment above for why. Must stay matched to the same value as
     layout.css's two 64rem->78rem changes (.nav__links row layout,
     .nav__toggle desktop-hidden); the three moved together deliberately,
     not independently -- a mismatch between any of them would open a
     viewport range where either both nav states show at once or neither
     does. */
  @media (max-width: 78rem) {
    .nav__links {
      background-color: var(--nav-bg);
      border-block-end: var(--border-hairline);
    }

    .nav__link {
      font-size: var(--text-base);
      padding-block: var(--space-2);
    }
  }

  /* Sign Out renders as a <button> inside the nav so it can submit a
     CSRF-protected POST. reset.css already strips button chrome and
     inherits font -- .nav__link supplies the rest, so no extra rule
     is needed beyond removing the form's own default spacing. */
  .nav__logout-form {
    margin: 0;
  }

  .nav__toggle {
    color: var(--color-text-secondary);
    transition: var(--transition-color);
  }

  .nav__toggle:hover {
    color: var(--color-accent);
  }

  /* Hamburger icon — drawn in CSS, no SVG dependency for a 3-line icon */
  .nav__toggle-icon {
    display: block;
    position: relative;
    inline-size: 1.25rem;
    block-size: 2px;
    background-color: currentColor;
    transition: background-color var(--duration-fast) var(--ease-out);
  }

  .nav__toggle-icon::before,
  .nav__toggle-icon::after {
    content: '';
    position: absolute;
    inset-inline: 0;
    block-size: 2px;
    background-color: currentColor;
    transition: transform var(--duration-base) var(--ease-out);
  }

  .nav__toggle-icon::before { inset-block-start: -6px; }
  .nav__toggle-icon::after  { inset-block-start:  6px; }

  /* When open: middle bar disappears, top/bottom cross */
  .nav__toggle[aria-expanded="true"] .nav__toggle-icon {
    background-color: transparent;
  }

  .nav__toggle[aria-expanded="true"] .nav__toggle-icon::before {
    transform: translateY(6px) rotate(45deg);
  }

  .nav__toggle[aria-expanded="true"] .nav__toggle-icon::after {
    transform: translateY(-6px) rotate(-45deg);
  }

  /* ─────────────────────────────────────────────
     SKIP LINK
     Visible only on keyboard focus.
     Positioned to appear at top-left of viewport.
     ───────────────────────────────────────────── */

  .skip-link {
    position: fixed;
    inset-block-start: var(--space-2);
    inset-inline-start: var(--space-2);
    z-index: calc(var(--z-modal) + 1);
    padding: var(--space-2) var(--space-4);
    background-color: var(--color-accent);
    color: var(--color-text-inverse);
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    border-radius: var(--radius-md);
    /* Hidden until focused */
    clip-path: inset(0 0 0 0);
    transform: translateY(-200%);
    transition: transform var(--duration-fast) var(--ease-out);
  }

  .skip-link:focus-visible {
    transform: translateY(0);
    outline: 2px solid var(--color-text-inverse);
    outline-offset: 2px;
  }

  /* ─────────────────────────────────────────────
     HERO
     ───────────────────────────────────────────── */

  .hero {
    background-color: var(--hero-bg);
  }

  /* Photographic texture (design-analysis session, 2026-08-23) -- the fifth
     placement's counterpart for the hero specifically. Same grading recipe
     as .section-texture::before (see that component's own comment, later
     in this file) applied to a real element instead of a pseudo-element,
     since .hero::before and .hero::after are both already claimed by the
     light bloom and vignette below. Markup: index.php, first child of
     .hero, before .hero__smoke. */
  .hero__texture {
    position: absolute;
    inset: 0;
    z-index: var(--hero-z-texture);
    pointer-events: none;
    background-image: url('../img/texture/fog-forest.jpg');
    background-size: cover;
    background-position: center;
    filter: grayscale(88%) brightness(0.45) contrast(1.15) sepia(10%) hue-rotate(210deg);
    opacity: 0.08;
  }

  /* Golden embers (design-analysis session, 2026-08-23). Sparks lifting off the same source the smoke rises from -- the hero reads as smoke off burning sage, so embers continue that one idea rather than adding a second one. Real elements (.hero__embers > .hero__ember > .hero__ember-dot, index.php), not SVG: plain circles with a box-shadow glow are the whole visual, no turbulence/mask machinery like .hero__smoke needs, and transform + opacity only keeps this GPU-composited and cheap regardless of count.

     Two nested animations, not one (author feedback, 2026-08-23: the
     single combined version "floats like a jittery caterpillar crawling",
     not ash). Root cause: that version redeclared transform at four
     separate keyframe stops, each carrying the full easing curve -- so
     motion visibly surged and settled four times per cycle instead of
     flowing continuously, which reads as segmented/many-legged, not as one
     drifting object. Fix is architectural, not a number tweak:
     .hero__ember (outer) owns ONLY the rise -- opacity and a single
     translateY declared just at 0% and 100%, so the browser interpolates
     the whole climb as one unbroken ease, with opacity given its own
     independent timing via intermediate stops rather than forcing
     transform through those same stops. .hero__ember-dot (inner) owns
     ONLY the sway -- a single gentle translateX oscillation, 0% to 50% to
     100%, ease-in-out, its own short period looping independently of the
     parent's longer rise -- so the spark drifts side to side several
     times while it climbs, the way ash actually catches small gusts,
     rather than one deterministic zigzag baked into the same timeline as
     the climb.

     Trigger: js/hero-smoke.js's existing IntersectionObserver toggles an
     .is-active class on .hero__embers; CSS selectors below cascade that to
     BOTH the rise and the sway independently (animation-play-state does
     not cascade through a parent's inline style at all -- see this file's
     own history/lessons.md for the bug that taught this the first time).

     Static fallback deliberately differs from .hero__smoke's (a fixed gold
     shape). A single ember frozen mid-air reads as a stray dot, not a
     considered element -- so paused-from-load here means invisible (each
     ember's own 0% keyframe is opacity: 0), not "visible but still." */
  .hero__embers {
    position: absolute;
    inset: 0;
    z-index: var(--hero-z-embers);
    pointer-events: none;
    overflow: hidden;
  }

  .hero__ember {
    position: absolute;
    bottom: -4%;
    inline-size: 4px;
    block-size: 4px;
    animation: hero-ember-rise 7s var(--ease-inout) infinite;
    animation-play-state: paused;
  }

  .hero__ember-dot {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    border-radius: 50%;
    background: var(--hero-ember-color);
    box-shadow:
      0 0 4px 1px var(--hero-ember-color),
      0 0 10px 3px color-mix(in srgb, var(--hero-ember-color) 55%, transparent);
    animation: hero-ember-sway 3.2s ease-in-out infinite;
    animation-play-state: paused;
  }

  .hero__embers.is-active .hero__ember,
  .hero__embers.is-active .hero__ember-dot {
    animation-play-state: running;
  }

  /* Seven sparks, individually varied (position/size/timing/drift/rise-height) so the set reads as organic rather than as one shape repeated on a grid. Negative animation-delay values mean each spark's rise is already mid-cycle the instant it starts running, instead of all seven visibly igniting together on the first frame. --ember-drift is declared once here on the outer .hero__ember and inherited down into .hero__ember-dot's sway keyframe -- one value drives both how far the whole spark wanders and how it's tied to that spark's own identity, rather than two separate numbers to keep in sync. --ember-rise varies per spark (was a single hardcoded -46vh for all seven in an earlier version, which was exactly why they all capped out at the same height regardless of speed or start point) so each one travels a genuinely different distance. */
  .hero__ember:nth-child(1) { inset-inline-start: 52%; animation-duration: 6.5s; animation-delay: -1.2s; --ember-drift: 16px; --ember-rise: -38vh; }
  .hero__ember:nth-child(2) { inset-inline-start: 61%; inline-size: 3px; block-size: 3px; animation-duration: 8s; animation-delay: -4.6s; --ember-drift: -13px; --ember-rise: -52vh; }
  .hero__ember:nth-child(3) { inset-inline-start: 68%; animation-duration: 7.2s; animation-delay: -2.1s; --ember-drift: 10px; --ember-rise: -44vh; }
  .hero__ember:nth-child(4) { inset-inline-start: 75%; inline-size: 5px; block-size: 5px; animation-duration: 9s; animation-delay: -6.3s; --ember-drift: -18px; --ember-rise: -58vh; }
  .hero__ember:nth-child(5) { inset-inline-start: 81%; animation-duration: 6.8s; animation-delay: -3.4s; --ember-drift: 14px; --ember-rise: -33vh; }
  .hero__ember:nth-child(6) { inset-inline-start: 87%; inline-size: 3px; block-size: 3px; animation-duration: 7.6s; animation-delay: -5.8s; --ember-drift: -11px; --ember-rise: -49vh; }
  .hero__ember:nth-child(7) { inset-inline-start: 91%; animation-duration: 8.4s; animation-delay: -0.6s; --ember-drift: 13px; --ember-rise: -41vh; }

  /* Sway phase offsets only -- amplitude (--ember-drift) already varies per
     spark above and is inherited straight into this keyframe, so this is
     just staggering WHERE in the 3.2s sway cycle each spark starts, spread
     across roughly one full period so no two sparks visibly sway in
     lockstep. */
  .hero__ember:nth-child(1) > .hero__ember-dot { animation-delay: -0.2s; }
  .hero__ember:nth-child(2) > .hero__ember-dot { animation-delay: -1.6s; }
  .hero__ember:nth-child(3) > .hero__ember-dot { animation-delay: -0.7s; }
  .hero__ember:nth-child(4) > .hero__ember-dot { animation-delay: -2.4s; }
  .hero__ember:nth-child(5) > .hero__ember-dot { animation-delay: -1.1s; }
  .hero__ember:nth-child(6) > .hero__ember-dot { animation-delay: -2.9s; }
  .hero__ember:nth-child(7) > .hero__ember-dot { animation-delay: -0.4s; }

  @keyframes hero-ember-rise {
    0% {
      opacity: 0;
      transform: translateY(0) scale(0.6);
    }
    12% {
      opacity: var(--hero-ember-opacity-peak);
    }
    70% {
      opacity: calc(var(--hero-ember-opacity-peak) * 0.4);
    }
    100% {
      opacity: 0;
      transform: translateY(var(--ember-rise)) scale(1);
    }
  }

  @keyframes hero-ember-sway {
    0%, 100% {
      transform: translateX(0);
    }
    50% {
      transform: translateX(var(--ember-drift));
    }
  }

  /* Light bloom (Phase 24b): one soft source, offset toward the crown, so
     the mark reads as catching light rather than as a shape painted gold.
     The shimmer has existed since Phase 18a but had no environment to be
     lit by; this is that environment. Sits under the mark and the type. */
  .hero::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--hero-z-bloom);
    pointer-events: none;
    background: radial-gradient(
      58% 46% at 72% 42%,
      rgb(200 169 126 / 0.13) 0%,
      rgb(200 169 126 / 0.05) 38%,
      transparent 72%
    );
  }

  /* Vignette (Phase 24b): darkens the hero's edges so the eye lands on the
     headline and the crown's bleed off the inline-end edge reads as depth
     rather than as a crop.

     Deliberately layered at --hero-z-veil, BELOW --hero-z-content: it veils
     the mark, never the type. Painting it over the content would quietly
     erode the AAA contrast ratios the palette reference was built to lock,
     which is exactly the kind of regression a decorative overlay makes
     invisible until someone measures it. */
  .hero::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--hero-z-veil);
    pointer-events: none;
    background: radial-gradient(
      125% 95% at 50% 42%,
      transparent 42%,
      rgb(0 0 0 / 0.62) 100%
    );
  }

  /* ── The Forged Crown at monument scale (Phase 24a) ──────────────────
     Replaces the horizontal lockup that lived here through Phase 18a.

     Why the mark kept coming out small, and why this fixes it: the old
     lockup was a flex row pairing the crown with 48px Cinzel, and a mark
     standing beside cap-height text can only be about twice its height
     before the pairing looks wrong and the row wraps. That capped it at
     clamp(3.25rem, 9vw, 6.5rem), measured 104px. Worse, .hero is a locked
     100dvh box, so every pixel the mark gained was taken from the same
     budget as the CTA -- which is why each attempt to scale it up got
     walked back rather than kept.

     Positioning the mark removes it from that budget entirely. It now
     renders ~700px tall at 1440x900, roughly 7x the old lockup, and costs
     the layout nothing. The brand sheet's stacked lockup was the other
     candidate and was rejected for the same budget reason: stacked spends
     vertical, and vertical is the scarce resource in a full-viewport hero.

     The nav wordmark still carries the brand as an identifier; this is the
     mark as environment, which is why it is aria-hidden in the markup. */
  .hero__watermark {
    position: absolute;
    z-index: var(--hero-z-mark);
    inset-block-start: 50%;
    inset-inline-end: -6%;
    translate: 0 -50%;
    pointer-events: none;
  }

  .hero__watermark-mark {
    display: block;
    block-size: min(88vh, 55rem);
    inline-size: auto;
    opacity: var(--hero-mark-opacity);
  }

  /* Gold shimmer, carried forward from Phase 18a unchanged in geometry and
     masking -- the single-axis sweep and the 28% band were established by
     verification then and are not being relitigated here. Two values did
     change, both because the mark is now ~7x larger:

       - opacity, previously implicit at 1 on a 104px mark. At that size the
         author reported the effect as easy to miss; at 700px the identical
         sweep is far too strong and reads as a loading bar, so it is now
         scoped by --hero-shimmer-opacity.
       - duration 4.5s -> 6s. The sweep crosses ~7x the distance, so the
         same duration is a much faster apparent movement.

     If the glint wants tuning, --hero-shimmer-opacity in tokens.css is the
     one value to turn. Reduced motion is handled by reset.css's global
     animation-duration override; no media query is needed here. */
  .hero__watermark::after {
    content: "";
    position: absolute;
    inset: 0;
    mask-image: url("../img/brand/crown-flat.svg");
    mask-size: 100% 100%;
    mask-repeat: no-repeat;
    -webkit-mask-image: url("../img/brand/crown-flat.svg");
    -webkit-mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
    /* white, not a gold token -- this is a highlight-white for contrast
       against the gold underneath, not a brand colour, so it is
       deliberately not derived from --color-accent-*. */
    background: linear-gradient(
      75deg,
      transparent 28%,
      white 50%,
      transparent 72%
    );
    background-size: 200% 100%;
    background-position: -100% 0%;
    mix-blend-mode: screen;
    opacity: var(--hero-shimmer-opacity);
    pointer-events: none;
    animation: hero-shimmer 6s ease-out infinite;
  }

  @keyframes hero-shimmer {
    0%   { background-position: -100% 0%; }
    28%  { background-position: 200% 0%; }
    100% { background-position: 200% 0%; }
  }

  /* ─────────────────────────────────────────────
     HERO SMOKE -- WebGL/GLSL rebuild, 2026-08-24, replacing the original
     SVG/feTurbulence implementation (Concept B, design_analysis/phase-
     hero-smoke-session-prompt-v2.md). That version is archived in full at
     archive/hero-smoke-svg-version/ -- restorable if ever needed.

     Why: author-reported scroll jitter through the hero, profiled this
     session (Playwright frame-timing + PerformanceObserver longtask
     measurements, not assumed) to the animated feTurbulence + double SVG
     mask -- feTurbulence is software-rasterized in essentially every
     browser engine, not GPU-accelerated, which is the actual reason it
     cost what it did. In-place SVG optimizations (numOctaves 3->2,
     half-resolution rendering) recovered roughly 60-70% of the
     main-thread blocking time but couldn't close the remaining gap.
     Moving the noise generation onto the GPU via a canvas/WebGL shader
     was the actual fix -- confirmed by the author on real hardware:
     locked 60fps in Chrome, 60fps reported in Safari (a minor feel
     difference there is flagged in js/hero-smoke.js's own comment, not
     yet root-caused, not blocking).

     All of the visual construction this component used to describe here
     (mask layering, gradient reuse, fade taper values, why no ambient
     drift transform) is superseded by the shader itself -- js/hero-
     smoke.js carries the full technique rationale now (domain-warped
     simplex noise for the curl/rise motion, the softened-contrast "haze"
     pass, the slow billow modulation), since CSS no longer does any of
     that construction. What CSS still owns: position/stacking (this
     element sits at --hero-z-smoke, between the light bloom and the
     crown mark, same as before) and the one opacity value.

     --hero-smoke-opacity (tokens.css) is applied here, on the wrapper,
     and NOT also baked into the shader's own alpha math -- deliberately
     a single source of truth. The shader computes its noise/fade mask at
     full [0,1] alpha; this opacity is the only place the site's actual
     "barely visible, felt more than seen" intensity is set. Doubling it
     in both places (a real risk when porting a value from one technique
     to another) would have made the effect nearly imperceptible --
     checked and avoided, not something that slipped through. */

  .hero__smoke {
    position: absolute;
    inset: 0;
    z-index: var(--hero-z-smoke);
    pointer-events: none;
    opacity: var(--hero-smoke-opacity);
    /* Hints the compositor ahead of the scroll-parallax transform js/hero-smoke.js applies. */
    will-change: transform;
  }

  /* Canvas fills its wrapper via CSS; js/hero-smoke.js sets the actual
     backing-store pixel dimensions (canvas.width/height) to match,
     capped at 2x device pixel ratio -- a canvas has no equivalent to an
     SVG viewBox that scales independently of its CSS box, so this pairing
     (CSS for display size, JS for render resolution) is required, not
     optional, the way it wasn't for the old .hero__smoke-svg. */
  .hero__smoke-canvas {
    display: block;
    inline-size: 100%;
    block-size: 100%;
  }

  .hero__title {
    font-family: var(--font-heading);
    /* Was var(--text-2xl) (up to 6.854rem). Inside the old 48rem container
       that scale could not set "Forge the Body." on one line, so the locked
       tagline broke to four lines, split mid-phrase, and consumed 484px --
       40% of the hero.

       Both bounds are derived from a measurement of real Cinzel, not chosen
       by eye: "Forge the Body." sets 10.168em wide at weight 700 with
       --tracking-wide. The 6rem cap therefore needs 976px, against the 1088px
       of content box --container-max provides. The floor is 1.75rem
       rather than 2rem because at 2rem/32px the line needs 325px against the
       326px a 390px viewport actually offers -- a 0.6px margin, which is not
       a margin. At 1.75rem it holds down to ~349px viewports.

       The earlier 2rem/64rem pair was verified in a fallback serif after
       Google Fonts became unreachable mid-session; the fallback is narrower
       than Cinzel, so it passed there and wrapped in production. That gap is
       the reason both numbers now trace to a measured em value. */
    font-size: clamp(1.75rem, 6.2vw, 6rem);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    color: var(--hero-title-color);
    line-height: var(--leading-tight);
    text-transform: uppercase;
    /* pretty, not balance: balance evens line lengths, which on narrow
       viewports actively produced the worse break ("Forge / the Body.")
       that this change exists to remove. */
    text-wrap: pretty;
  }

  /* Cinzel has no italic; use accent colour as the visual distinction */
  .hero__title em {
    font-style: normal;
    color: var(--color-accent);
    display: block;
  }

  /* Short gold rule between headline and lede. Does the grouping work the
     old uniform --space-6 gaps were failing to do, at 1px instead of 48. */
  .hero__rule {
    inline-size: 3rem;
    block-size: 1px;
    background-color: var(--color-accent);
    opacity: 0.55;
  }

  .hero__body {
    font-size: var(--text-md);
    line-height: var(--leading-loose);
    color: var(--color-text-secondary);
    max-inline-size: 46ch;
  }

  .hero__cta-group {
    display: flex;
    gap: var(--space-3);
    flex-wrap: wrap;
    align-items: center;
    margin-block-start: var(--space-1);
  }

  /* Generic horizontal CTA pair -- same flex/gap/wrap recipe as
     .hero__cta-group above, extracted rather than reused verbatim
     because .hero__cta-group is BEM-scoped to the hero specifically
     (this codebase's naming convention ties a __-scoped class to its
     block). This is the second place a two-button primary/secondary CTA
     row is needed (the-ninety.php's closing section), which is the
     reasonable bar for pulling out a shared, generically-named component
     rather than a third bespoke one-off. justify-content: center, not
     inherited text-align:center from a parent -- .btn is
     display: inline-flex, which text-align does not center once the
     buttons are flex children of this wrapper instead of a plain <p>.
     flex-wrap ensures this still stacks and centers correctly on narrow
     viewports where both labels can't fit on one line, same fallback
     behavior the hero's version already relies on. */
  .cta-group {
    display: flex;
    justify-content: center;
    gap: var(--space-3);
    flex-wrap: wrap;
    align-items: center;
  }

  /* Scroll cue (Phase 24a). A full-viewport hero with five sections beneath
     it gave no signal that anything followed. A real anchor, not a
     decorative flourish, so it works by keyboard and carries a destination.

     Occupies its own grid row on .hero (see layout.css) rather than being
     absolutely positioned against the bottom edge. The absolute version
     collided with the CTA buttons on large desktops as soon as the headline
     took an extra line: nothing reserved space for it, so growing content
     simply grew into it. A real track cannot be overlapped.

     Shown only where it fits: hidden below the nav's own 64rem breakpoint,
     and hidden on short viewports, where .hero has already given up its
     locked height and a scroll cue pinned under the fold is meaningless. */
  .hero__cue {
    display: none;
  }

  @media (min-width: 64rem) and (min-height: 43.75rem) {
    .hero__cue {
      z-index: var(--hero-z-content);
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: var(--space-1);
      text-decoration: none;
      font-family: var(--font-heading);
      font-size: 0.625rem;
      letter-spacing: var(--tracking-widest);
      text-transform: uppercase;
      color: var(--color-text-muted);
      transition: var(--transition-color);
    }

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

    .hero__cue-line {
      inline-size: 1px;
      block-size: 2.5rem;
      background: linear-gradient(to bottom, var(--color-accent), transparent);
      opacity: 0.6;
      transform-origin: top;
      animation: hero-cue-drift 2.8s ease-in-out infinite;
    }
  }

  @keyframes hero-cue-drift {
    0%, 100% { opacity: 0.25; scale: 1 0.6; }
    50%      { opacity: 0.7;  scale: 1 1; }
  }

  /* ─────────────────────────────────────────────
     DOCTRINE
     ───────────────────────────────────────────── */

  .doctrine {
    background-color: var(--color-bg);
  }

  .doctrine__intro {
    font-size: var(--text-md);
    line-height: var(--leading-loose);
    color: var(--color-text-secondary);
    max-inline-size: 60ch;
  }

  .doctrine__stat {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    padding-block: var(--space-4);
    border-block-start: var(--border-accent);
  }

  /* Phase 24c: crown-socket badge, replacing the old plain-text Cinzel
     numeral (--stat-numeral-color is no longer referenced anywhere --
     the numeral now lives inside the badge SVG's own gradient fill, same
     pattern as .sage__emblem-badge). Left-aligned, not centered like the
     Sage emblem -- this sits inside a left-aligned prose column, not a
     standalone centered block.
     Session 22 continued: author's own direction (discussed prior to this
     entry) -- 5rem read as lost inside the 1fr grid column at desktop,
     with most of the column empty around it. Scaled up to fill the column
     rather than staying a fixed small size; author explicitly accepted
     that this may crop the badge's own artwork at its bounding edges and
     may sit closer to/against the label below it as a deliberate
     trade-off for a bolder mark, not a bug to fix later. */
  .doctrine__stat-badge {
    inline-size: min(11rem, 60%);
    block-size: auto;
  }

  .doctrine__stat-label {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    color: var(--stat-label-color);
  }

  /* ─────────────────────────────────────────────
     DISCIPLINE CARDS
     Hover: border-color transition only.
     No scale, no shadow — the platform is austere.
     ───────────────────────────────────────────── */

  .disciplines {
    background-color: var(--color-bg);
  }

  .discipline-card {
    background-color: var(--card-bg);
    border: 1px solid var(--card-border);
    padding: var(--card-padding);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    /* Transition only border-color — no layout jank */
    transition: border-color var(--duration-base) var(--ease-out);
  }

  .discipline-card:hover {
    border-color: var(--card-border-hover);
  }

  /* Session 26 (continued): literal-discipline icons on 5 of 12 cards
     (Fuel the Body, Forge Strength, Command Flexibility, Run Long, Move
     With Ease) -- the other seven are metaphor-driven and deliberately
     stay text-only rather than forced into an icon that wouldn't mean
     anything. Source: Tabler Icons (MIT), see img/icons/SOURCES.md.
     Command Flexibility uses "stretching-2", not the more obvious "yoga"
     icon -- confirmed via a rendered side-by-side comparison that yoga's
     lunge pose is visually near-identical to Run Long's running stride,
     undermining the point of giving these five a distinct visual
     language; stretching-2's open, symmetric stance is the one
     alternative that's actually distinct at a glance. stroke="currentColor"
     is set on the SVG itself, color set here via the `color` property --
     same technique already used by .stream-entry__type-icon and the
     hero's "Enter the Doctrine" arrow.

     .discipline-card__icon-slot: a reserved, fixed-height wrapper present
     on all twelve cards, empty for the seven that have no icon. Caught
     live, not assumed: index.php's fixed discipline order scatters the
     five icon cards across grid rows that also contain non-icon cards
     (row 3 is 07/08/09, only 09 has an icon; row 4 is 10/11/12, only 10
     does) -- without a reserved slot, the icon-bearing card's index number
     sits visibly lower than its row-mates, breaking a baseline rhythm
     that read as consistent everywhere else. The standalone sample never
     caught this because it grouped all five icon cards together, which
     the real numbered grid never does. ".discipline-card__rule" fills that
     empty slot on the seven metaphor cards -- reuses .hero__rule's exact
     color and opacity (the hero's headline underscore) rather than
     inventing a new gold treatment, and deliberately isn't a pictogram:
     a generic icon for "No Excuses" or "Radical Honesty" would imply a
     literal meaning that isn't there, repeating the exact mismatch the
     yoga-vs-run icon swap was fixed for. A quiet mark, not a picture. */
  .discipline-card__icon-slot {
    display: flex;
    align-items: center;
    block-size: 1.5rem;
  }

  .discipline-card__rule {
    display: block;
    inline-size: 1.5rem;
    block-size: 1px;
    background-color: var(--color-accent);
    opacity: 0.55;
  }

  .discipline-card__icon {
    inline-size: 1.5rem;
    block-size: 1.5rem;
    color: var(--color-accent);
    flex-shrink: 0;
  }

  .discipline-card__index {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-widest);
    color: var(--color-text-muted);
    text-transform: uppercase;
  }

  .discipline-card__title {
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    color: var(--color-text-primary);
    text-transform: uppercase;
    line-height: var(--leading-snug);
  }

  .discipline-card__body {
    font-size: var(--text-sm);
    line-height: var(--leading-loose);
    color: var(--color-text-secondary);
    /* Spectral reads well down to sm size at loose leading */
  }

  /* ─────────────────────────────────────────────
     THE SAGE
     ───────────────────────────────────────────── */

  .sage {
    background-color: var(--color-surface);
    border-block: var(--border-hairline);
  }

  .sage__credential {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    color: var(--color-accent);
    margin-block-end: var(--space-2);
  }

  /* Same 0px heading-to-body gap as .section-heading had; .sage__credential
     above it already carries its own margin, so only the trailing side needs
     restoring here. */
  .sage__heading {
    margin-block-end: var(--space-4);
    font-family: var(--font-heading);
    font-size: var(--text-lg);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    color: var(--color-text-primary);
    text-transform: uppercase;
    line-height: var(--leading-tight);
  }

  .sage__body {
    font-size: var(--text-base);
    line-height: var(--leading-loose);
    color: var(--color-text-secondary);
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    /* 2fr column at desktop reaches ~45rem; constrain measure here */
    max-inline-size: 65ch;
  }

  /* Link to the-sage.php full bio page (Phase 18c) -- sits below the
     teaser body, same measure so the CTA doesn't outrun the prose column */
  .sage__link {
    margin-block-start: var(--space-4);
    max-inline-size: 65ch;
  }

  /* Credential emblem: The Forged Crown milestone badge (Phase 18a) + label,
     centered block. Replaces the earlier plain-text "30" numeral -- the
     numeral now lives inside the badge SVG itself (sage-training-brand-
     sheet_1.html's badge/token system), so updating it later (e.g. to "40")
     is a one-line text edit inside the SVG, not a redesign. The wrapper no
     longer draws its own circle/border -- the badge SVG draws its own
     circular frame around the numeral. */
  .sage__emblem {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    margin-inline: auto;
    /* Widened from 14rem alongside the badge size-up below, at the
       author's request -- the wrapper no longer draws its own frame,
       so this is purely a container width for the badge + label. */
    max-inline-size: 20rem;
  }

  .sage__emblem-badge {
    /* Sized up from 9rem at the author's request. */
    inline-size: 15rem;
    block-size: auto;
  }

  .sage__emblem-label {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-widest);
    text-transform: uppercase;
    color: var(--color-text-muted);
    text-align: center;
    line-height: var(--leading-snug);
  }

  /* Generic badge + label emblem -- same construction as .sage__emblem
     above (centered crown-socket badge, short label beneath), extracted
     into its own generic name rather than reused directly since
     .sage__emblem is BEM-scoped to The Sage. Second use: The Ninety's
     homepage teaser (index.php), reusing the same badge SVG construction
     already proven on Doctrine's stats, the Sage emblem, and the three
     badges on the full /the-ninety.php page -- no new visual language. */
  .emblem {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    margin-inline: auto;
    max-inline-size: 20rem;
  }

  .emblem-badge {
    inline-size: 15rem;
    block-size: auto;
  }

  .emblem-label {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-widest);
    text-transform: uppercase;
    color: var(--color-text-muted);
    text-align: center;
    line-height: var(--leading-snug);
  }

  /* ─────────────────────────────────────────────
     THE NINETY -- COMPLETION SEAL, EARNED-STATE COMPANION
     (queue.md, Build-ready section; creative_direction Direction 20,
     foundry/2026-09-07/ninety-completion-seal-earned-state-concept.html)
     Visual-only companion state for .emblem/.emblem-badge/.emblem-label
     as used by the-ninety.php's shipped "Not Yet Earned" Challenge badge
     (id="ninety-challenge-badge-gold"). Answers only what an "earned"
     state would look like -- it does NOT wire one up. Direction 20's own
     brief (creative_direction/creative-direction_2026-09-08.md) is
     explicit that today is not the time to place this: no completion
     mechanic exists yet (self-reported vs. verified, public/private,
     what "finished" even means against the schema are all still open),
     so there is nothing for this state to attach to on any live page.
     These classes are therefore dormant by design -- not referenced by
     any current markup -- ready to adopt the moment that mechanic is
     scoped and built.

     To apply once that day comes: add `emblem--earned` to the `.emblem`
     wrapper, add `emblem-label--earned` to the `.emblem-label`, and swap
     the badge's own <svg> for the earned variant -- same gradient/path
     data verbatim (no redrawing), with three changes, copied from the
     foundry concept: (1) a new flat-gold <circle cx="160" cy="178"
     r="66"> ring, class="emblem-badge__earned-ring", drawn before the
     existing facet paths; (2) the two crown-facet paths' opacity raised
     0.94/0.6 -> 1/0.78 and the two base paths' opacity raised 0.96/0.62
     -> 1/0.8 (same gradient fill, no new color); (3) the badge circle's
     stroke-width raised 2 -> 2.5. All three are plain presentation
     attributes on the SVG shapes themselves, the same pattern every
     other badge on this site already uses (not an inline `style`, which
     architecture.md's Code Philosophy locks against).

     Idle motion (a shimmer/glow animation on the ring or glow) was
     deliberately left out, not overlooked -- foundry's separate badge-
     idle-shimmer question (2026-09-06) is still unresolved and applies
     to this badge too; this state is intentionally still.

     No new color: the ring and glow both reuse --color-accent, the same
     token every other gold UI-state signal on this site already draws
     from (vs. the badge's own crown gradient, which stays untouched). */
  .emblem--earned {
    position: relative;
    isolation: isolate;
  }

  .emblem--earned::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: var(--z-below);
    background: radial-gradient(circle at 50% 38%, color-mix(in srgb, var(--color-accent) 18%, transparent), transparent 65%);
    pointer-events: none;
  }

  .emblem-badge__earned-ring {
    fill: none;
    stroke: var(--color-accent);
    stroke-width: 2;
    opacity: 0.55;
  }

  .emblem-label--earned {
    color: var(--color-accent);
  }

  /* ─────────────────────────────────────────────
     BIO PORTRAIT PLACEHOLDER (Phase 18c, the-sage.php)
     Author has not yet supplied a photo. This is a plain bordered box,
     not an <img> with a missing src -- no broken-image icon, no network
     request for an asset that doesn't exist. Replace the whole element
     with a real <img> (matching aspect-ratio and border-radius, so the
     surrounding grid doesn't need to change) once a photo is supplied.
     ───────────────────────────────────────────── */

  /* Session 20 fix. The label was measuring 1px from the gold border on both
     sides, and padding alone would not have fixed it -- the box was collapsing
     to the width of its own text. `margin-inline: auto` on a grid item with an
     auto inline-size overrides the default stretch and makes the box
     shrink-to-fit, so `max-inline-size: 20rem` never applied and the portrait
     rendered 145px wide instead of the intended 320px, with the aspect-ratio
     then deriving its height from that wrong width. `inline-size: 100%` gives
     it a definite size to be centred within, so the auto margins centre the box
     in its column rather than shrink-wrapping it to the caption. */
  .bio-portrait {
    aspect-ratio: 4 / 5;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-4);
    background-color: var(--color-surface);
    border: var(--border-hairline);
    border-color: var(--color-accent);
    inline-size: 100%;
    max-inline-size: 20rem;
    margin-inline: auto;
  }

  .bio-portrait__label {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-widest);
    text-transform: uppercase;
    color: var(--color-text-muted);
    text-align: center;
    line-height: var(--leading-snug);
  }

  /* ─────────────────────────────────────────────
     BEGIN — EMAIL CAPTURE
     ───────────────────────────────────────────── */

  .begin {
    background-color: var(--color-bg);
  }

  .begin__heading {
    font-family: var(--font-heading);
    font-size: var(--text-lg);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    color: var(--color-text-primary);
    text-transform: uppercase;
    line-height: var(--leading-tight);
  }

  .begin__sub {
    font-size: var(--text-base);
    color: var(--color-text-muted);
    line-height: var(--leading-loose);
    font-style: italic;
  }

  /* ─────────────────────────────────────────────
     SECTION HEADINGS — SHARED PATTERN
     ───────────────────────────────────────────── */

  .section-heading {
    font-family: var(--font-heading);
    font-size: var(--text-lg);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    color: var(--color-text-primary);
    text-transform: uppercase;
    line-height: var(--leading-tight);
    /* One step above the eyebrow's --space-2: the eyebrow belongs TO the
       heading and should sit close, while the heading is separated FROM the
       prose it introduces. Equal gaps would leave the eyebrow floating
       between the two rather than reading as a label on the heading. */
    margin-block-end: var(--space-4);
  }

  /* Page-level h1 modifier (Phase 18c, the-sage.php) -- same treatment as
     .section-heading, one step up the fluid scale since it stands alone
     as the page's only h1 rather than sitting among several h2 sections. */
  .section-heading--xl {
    font-size: var(--text-xl);
  }

  /* Vertical rhythm restored in Session 20. reset.css zeroes margins on every
     element, and the heading scale never had any given back, so measured on the
     landing page: eyebrow-to-heading 0px and heading-to-body 0px in both the
     Doctrine and Move sections. The eyebrow, the h2, and the opening paragraph
     rendered as one undifferentiated mass -- the single largest readability
     cost on the page, and the reason those sections read as a wall.

     Applied to the elements rather than by wrapping markup: .section-header
     already solves this with flex `gap`, but it is used on exactly 1 of the 22
     eyebrow/heading pairs in the codebase. Retrofitting it to the other 21
     would mean touching 13 files; these two declarations fix all of them in the
     presentation layer, which is where the problem was introduced. */
  .section-eyebrow {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-widest);
    text-transform: uppercase;
    color: var(--color-accent);
    margin-block-end: var(--space-2);
  }

  /* Parents that already space their children with `gap`. Margin and gap add up
     in flex and grid, so without this the one .section-header on the site and
     the Begin form would both get spacing twice. Higher specificity (0,2,0)
     than the rules it corrects, so this does not depend on source order. */
  .section-header > .section-eyebrow,
  .section-header > .section-heading,
  .begin__inner > .section-eyebrow {
    margin-block-end: 0;
  }

  /* .section-body is used two different ways in this codebase: as the paragraph
     itself (<p class="section-body">, 29 instances across 10 pages) and as a
     wrapper around several paragraphs (<div class="section-body"><p>...</p>,
     used by the-sage.php and discipline.php). In the wrapper case reset.css's
     zeroed <p> margins left nothing between paragraphs at all -- measured 0px
     between every one of the 15 paragraphs on the-sage.php, so an entire
     long-form biography rendered as a single unbroken wall of text with its
     paragraph breaks semantically present but visually invisible.

     A child combinator rather than a rewrite of either usage: <p
     class="section-body"> has no <p> children, so this cannot match it, and no
     markup has to change on any of the ten pages.

     The second selector covers the same bug in its other shape, found on the
     landing page after the first was fixed: where .section-body IS each
     paragraph, consecutive ones are siblings rather than children, so the child
     combinator never reached them. Measured 0px across three paragraph pairs in
     index.php's Doctrine intro and one in the Move section -- the same
     invisible-paragraph-break failure as the-sage.php, in markup that looks
     different enough to have hidden it. */
  .section-body > p + p,
  p.section-body + p.section-body {
    margin-block-start: var(--space-3);
  }

  /* The pull-quote keeps its own larger breathing room. Without this the rule
     above out-specifies .move-callout's margin-block and would quietly shrink
     it from --space-6 to --space-3. */
  .section-body > p + p.move-callout {
    margin-block-start: var(--space-6);
  }

  .section-body {
    font-size: var(--text-base);
    line-height: var(--leading-loose);
    color: var(--color-text-secondary);
    /* Measure: 65ch keeps Spectral within the 45-75 char optimal range */
    max-inline-size: 65ch;
  }

  /* Session 22 continued -- legal pages (privacy-policy.php, terms.php,
     accessibility.php). .section-body has no heading treatment because
     nothing before this needed subsection headings inside a prose body.
     Reuses .section-heading's exact type ramp one step down (--text-md,
     not --text-lg) so clause titles read as subordinate to the page's own
     h1 rather than competing with it. Scoped to .legal-body rather than
     bare .section-body h2 so this never applies to the-sage.php's or
     discipline.php's wrapped paragraphs, which have no headings today and
     should not silently gain this styling if one is ever added. */
  .legal-body h2 {
    font-family: var(--font-heading);
    font-size: var(--text-md);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    color: var(--color-text-primary);
    text-transform: uppercase;
    line-height: var(--leading-tight);
    margin-block: var(--space-6) var(--space-2);
  }

  /* First heading sits directly under the "Last updated" line -- the
     standard --space-6 top margin would double up with that line's own
     .section-body > p + p spacing already provided above. */
  .legal-body > h2:first-child {
    margin-block-start: 0;
  }

  /* Phase 22 continuation: workout-plan.php / meal-plan.php's meta line
     (duration, sessions-per-week/dietary approach, difficulty/target
     calories). Same small-uppercase-muted treatment already established
     for .discipline-card__index and .stream-entry__type, lifted to
     paragraph level -- not a new visual pattern. */
  .plan-meta {
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
    margin-block-end: var(--space-4);
  }

  /* Disclaimer note -- a quiet, in-voice safety/legal aside for content
     pages with direct exercise/nutrition instruction (added for
     the-ninety.php; reusable anywhere else this need recurs, e.g.
     workout-plan.php/meal-plan.php later). Not .move-callout -- that
     component is reserved for the single most important sentence on a
     page and carries its own pulse animation; a disclaimer needs to stay
     visible without competing with that for attention. Not .field-hint
     or .begin__sub -- both are named to their specific block context
     (forms, the Begin section) rather than being a generic, reusable
     page-level note. terms.php already carries the full "Not Medical
     Advice" clause (business.md, Legal Foundation); this links to it
     rather than restating it. */
  .disclaimer-note {
    font-size: var(--text-sm);
    font-style: italic;
    line-height: var(--leading-snug);
    color: var(--color-text-muted);
    margin-block: var(--space-4);
  }

  .disclaimer-note a {
    color: var(--color-accent);
    transition: var(--transition-color);
  }

  .disclaimer-note a:hover {
    color: var(--color-accent-hover);
  }

  /* Move section pull-quote: border-inline-start accent, larger type.
     Author's request: a light pulse to give this line an "active cadence"
     without the animation touching the text itself, since this is meant to
     be the single most important sentence on the page and needs to stay
     fully readable and stationary. Pulses a soft glow off the existing
     gold accent border rather than animating color/text/layout. box-shadow
     has no logical-property offset syntax yet (CSS Masking/Box spec gap,
     not an oversight) -- the -2px horizontal offset below is a deliberate,
     documented exception to the project's logical-properties standard,
     acceptable here since the site is English/LTR-only in practice.
     Respects prefers-reduced-motion via reset.css's global
     animation-duration override -- no separate media query needed here. */
  /* Spacing corrected in Session 20. The block previously had padding only on
     its inline-start edge: padding-block was 0 and padding-inline-end was 0,
     so the pulse glow (a 14px-blur box-shadow) bloomed directly off the text's
     own cap-height, baseline, and last character rather than off a block. The
     apparent right-hand gap was nothing but ragged-line slack, which measured
     27px at 1600px wide and **3px** at 390px, so on a phone the glow was
     effectively touching the final glyph.

     Padding is inset on all four sides now. Block padding (--space-3, 24px) is
     deliberately one step smaller than inline (--space-4, 32px): --leading-snug
     already contributes roughly 5px of half-leading above the cap and below the
     baseline, so 24px of padding reads as the optical equal of 32px on the
     sides. Equal numeric padding would have looked vertically loose. */
  .move-callout {
    font-size: var(--text-md);
    font-style: italic;
    line-height: var(--leading-snug);
    color: var(--color-text-primary);
    padding-block: var(--space-3);
    padding-inline: var(--space-3);
    border-inline-start: 2px solid var(--color-accent);
    margin-block: var(--space-6);
    /* 40ch, not 55ch. The old value contradicted its own comment, which claimed
       the quote was "slightly tighter than body prose": because --text-md is a
       larger size than the --text-base the prose is set in, 55ch measured 712px
       against the prose's 520px, so the pull-quote was in fact 37% WIDER and
       visibly jutted past the paragraphs above and below it. At 40ch the border
       box measures 518px against that 520px, so the callout now shares both
       column edges with the surrounding prose, and stays narrower than it at
       every viewport below the point where --text-md stops scaling. */
    max-inline-size: 40ch;
    animation: move-callout-pulse 3s var(--ease-inout) infinite;
  }

  /* Mobile-first step-up, matching the codebase's min-width convention. At 32px
     the inline padding would consume 20% of a 390px viewport's line length. */
  @media (min-width: 48rem) {
    .move-callout {
      padding-inline: var(--space-4);
    }
  }

  @keyframes move-callout-pulse {
    0%, 100% {
      box-shadow: -2px 0 0 0 transparent;
    }
    50% {
      box-shadow: -2px 0 14px 0 color-mix(in srgb, var(--color-accent) 45%, transparent);
    }
  }

  /* ─────────────────────────────────────────────
     THE RHYTHM  (Session 20)
     Move section only. Markup in includes/rhythm.php.

     Two animations, deliberately on different elements and different drivers,
     because they say different things:

       travel  -- the wave propagates. Driven by SCROLL, not by time, wherever
                  the browser supports it. The visitor's own movement is the
                  frequency input: scroll faster and the wave moves faster,
                  stop and it rests. On a section whose entire message is
                  "stop reading, start moving," a loop that runs whether or not
                  anyone is there would be decoration saying the opposite.
       breath  -- the whole motif swells on --rhythm-period, which is locked to
                  move-callout-pulse's 3s so the section beats once rather than
                  twice.

     Time-based travel is the fallback, declared first and overridden inside
     @supports, so browsers without scroll-driven animation still get motion
     rather than nothing.
     ───────────────────────────────────────────── */

  .section--rhythm {
    position: relative;
    /* `clip`, NOT `hidden`, and this is load-bearing rather than stylistic.
       overflow: hidden creates a scroll container, and a view timeline measures
       its subject against the nearest scroll container -- so with `hidden` the
       section itself became the scrollport, and since a section never scrolls
       the timeline froze. Verified directly: currentTime sat at 30.27% across a
       0-to-5358px page scroll with `hidden`, and tracked 0.77% -> 45% -> 89%
       with `clip`. overflow: clip does the same visual clipping without
       creating a scroll container.
       `hidden` is declared first as the fallback for browsers without `clip`:
       they lose the scroll-driven upgrade anyway (which is gated in @supports)
       but still need the overscan clipped. */
    overflow: hidden;
    overflow: clip;
  }

  /* The container is the positioning context so the motif tracks the content
     column rather than the full-bleed section, and z-index keeps it under the
     prose without needing a stacking value on every child. */
  .section--rhythm > .container {
    position: relative;
    z-index: var(--z-base);
  }

  .section--rhythm > .container > *:not(.rhythm) {
    position: relative;
    z-index: var(--z-raised);
  }

  /* Session 49 continued (fourth pass) -- author flagged two more things
     from a real full-page screenshot at a wide desktop width: the motif
     read small relative to how much genuinely empty space sat to its
     right (349px of unused margin at 1850px wide, confirmed by direct
     measurement -- .rhythm was still flush against .container's own
     edge, and .container itself never grows past --container-max/72rem
     no matter how wide the browser gets), and it "loses size... does not
     fit the vertical space properly" at narrower widths, i.e. the third
     pass's clamp() floor still read too small/short.

     Root cause of the first complaint: .rhythm's positioning context is
     .container, and .container is capped at 72rem regardless of viewport
     -- so no matter how wide the window gets, .rhythm's own "100%"/"vw"-
     relative math was still measured against a box that stopped growing
     at 1152px. The fix is the standard "breakout" technique for exactly
     this situation: let the element's inset be computed with a calc()
     that mixes vw (viewport-relative) and rem (container-cap-relative),
     so it does nothing below the cap and pulls the element's edge past
     the container's own edge, toward the actual viewport edge, once the
     viewport outgrows the cap. No markup change needed -- .rhythm stays
     exactly where it is in includes/rhythm.php/index.php, still a child
     of .container, still governed by the same z-index rule below
     (.section--rhythm > .container > *:not(.rhythm)) exactly as before.

     calc((var(--container-max) / 2) - 50vw - 2rem): --container-max/2 is
     .container's own half-width once capped (36rem). 50vw is half the
     real viewport. Below the cap (viewport < 72rem) this expression is
     positive, and min(0px, ...) clamps it to a no-op flush-right
     position, identical to before. Past the cap it goes negative,
     pulling .rhythm's right edge past .container's edge by exactly the
     unused margin a wide monitor was otherwise wasting -- the extra
     -2rem lets it bleed a little past the viewport edge itself once
     uncapped, the same "allowed to run past the edge" quality
     .hero__watermark already has (that mark uses a flat -6% for the same
     reason; this uses a calc() instead because .rhythm's positioning
     context, unlike the hero's, is a capped container, not a full-bleed
     section).

     Sizing switched to one continuous clamp(18rem, 33vw, 56rem), no
     breakpoint at all now. 18rem (288px, ~388px tall via the fixed
     aspect ratio) replaces the previous 13rem floor -- the earlier value
     was still shrinking down to a shape too short to read as filling any
     real vertical space on a phone. 33vw lands within about 1% of the
     old fixed 30rem/480px desktop value at a typical 1440px width (474px
     here), so the desktop size the author already approved is preserved
     rather than accidentally shrunk while fixing the other two
     complaints. The ceiling is raised to 56rem specifically so the
     breakout above has real room to grow into on genuinely wide
     monitors, instead of stalling at the old flat 480px regardless of
     how much wider the window got -- the two changes work together, not
     independently: freeing the position without raising the ceiling
     would have moved a still-small motif further right; raising the
     ceiling without the breakout would have hit .container's own cap
     before it could ever use the new size. */
  .rhythm {
    display: block;
    position: absolute;
    inset-block-start: 50%;
    inset-inline-end: min(0px, calc((var(--container-max) / 2) - 50vw - 2rem));
    translate: 0 -50%;
    inline-size: clamp(18rem, 33vw, 56rem);
    pointer-events: none;
    opacity: var(--rhythm-opacity);
    animation: rhythm-breath var(--rhythm-period) var(--ease-inout) infinite;
  }

  .rhythm__svg {
    display: block;
    inline-size: 100%;
    block-size: auto;
  }

  /* -276px is exactly one wavelength. See the geometry note in
     includes/rhythm.php: the shape and the alternating facet opacity both
     repeat over that distance, so the loop point is invisible. Any other
     value will visibly jump. Resolved in the SVG's own user-coordinate
     space (established by its viewBox), not outer-document CSS pixels --
     confirmed by this same value already working correctly across every
     size this clamp() produces, floor to ceiling, with no further
     adjustment needed at any of them. */
  .rhythm__wave {
    animation: rhythm-travel var(--rhythm-travel) linear infinite;
  }

  @keyframes rhythm-breath {
    0%, 100% { opacity: var(--rhythm-opacity); }
    50%      { opacity: var(--rhythm-opacity-peak); }
  }

  @keyframes rhythm-travel {
    from { transform: translateY(0); }
    to   { transform: translateY(-276px); }
  }

  /* Scroll-driven upgrade.

     The timeline is declared as a NAMED timeline on .rhythm, the wrapper div,
     and referenced from .rhythm__wave -- not declared inline as `view()` on the
     wave itself. That first attempt was verified broken, not assumed working: a
     ViewTimeline attached and reported playState "running", but its currentTime
     stayed frozen at 30.36% across a scroll from 0 to 5358px. .rhythm__wave is
     an SVG <g>, which has no principal CSS box for a view timeline to measure,
     so the timeline resolves to a constant and the wave never moves. Named
     timelines are resolved up the ancestor chain, so declaring it on the div
     that does have a box and referencing it from the <g> gives the animation a
     real subject while keeping the transform on the element that needs it --
     the <g> has to be what moves, because the SVG viewBox is what clips the
     overscan.

     animation-duration is ignored once a scroll timeline is attached, so `auto`
     is documentation rather than a value. The range spans the motif's whole
     passage through the viewport, so it travels two full wavelengths end to end
     and every loop point lands on an exact wavelength boundary. */
  @supports (animation-timeline: view()) {
    /* Session 49: the inner >=75rem gate is removed -- this upgrade now
       applies at every width @supports resolves true for, mobile
       included, so a touch scroll drives the wave exactly like a mouse
       scroll does on desktop. Nothing about the timeline math changes
       with the new mobile size; see .rhythm__wave's own travel-fallback
       comment above for why the loop distance is resolution-independent. */
    .rhythm {
      view-timeline: --rhythm-view block;
    }

    .rhythm__wave {
      animation: rhythm-scroll linear;
      animation-timeline: --rhythm-view;
      animation-range: entry 0% exit 100%;
      animation-duration: auto;
    }

    @keyframes rhythm-scroll {
      from { transform: translateY(0); }
      to   { transform: translateY(-552px); }
    }
  }

  /* Explicit, and NOT redundant with reset.css's global reduced-motion override.
     That override works by forcing animation-duration to 0.01ms, and duration is
     ignored on a scroll-driven animation -- so the travel would keep running for
     exactly the users who asked it not to. This is the one place in the codebase
     where relying on the global rule silently fails. */
  @media (prefers-reduced-motion: reduce) {
    .rhythm,
    .rhythm__wave {
      animation: none;
    }

    .rhythm {
      opacity: var(--rhythm-opacity);
    }

    .rhythm__wave {
      transform: none;
    }
  }

  /* ─────────────────────────────────────────────
     TRI-DIVIDER
     Three-stripe section separator — brand signature.
     Carries the three-tier visual rhythm of the platform.
     ───────────────────────────────────────────── */

  .tri-divider {
    display: flex;
    block-size: 2px;
    inline-size: 100%;
  }

  .tri-divider span:nth-child(1) { flex: 1; background-color: var(--color-border); }
  .tri-divider span:nth-child(2) { flex: 1; background-color: var(--color-accent); }
  .tri-divider span:nth-child(3) { flex: 1; background-color: var(--color-surface-raised); }

  /* ─────────────────────────────────────────────
     DIVIDER
     ───────────────────────────────────────────── */

  .divider {
    border: none;
    border-block-start: var(--border-hairline);
    margin-block: var(--space-6);
  }

  .divider--accent {
    border-block-start-color: var(--color-accent);
  }

  /* ─────────────────────────────────────────────
     BUTTONS
     Two variants: accent (filled) and ghost (outlined).
     No bare values — all from Tier 3 tokens.
     ───────────────────────────────────────────── */

  .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-1);
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    padding-block: var(--btn-padding-y);
    padding-inline: var(--btn-padding-x);
    border-radius: var(--radius-sm);
    border: 1px solid transparent;
    cursor: pointer;
    transition: var(--transition-color);
    /* Prevent text selection on repeated clicks */
    user-select: none;
    white-space: nowrap;
  }

  /* Accent — filled gold */
  .btn--accent {
    background-color: var(--btn-accent-bg);
    color: var(--btn-accent-text);
    border-color: var(--btn-accent-bg);
  }

  .btn--accent:hover {
    background-color: var(--btn-accent-bg-hover);
    border-color: var(--btn-accent-bg-hover);
  }

  .btn--accent:active {
    background-color: var(--color-accent-active);
    border-color: var(--color-accent-active);
  }

  /* Ghost — outlined gold */
  .btn--ghost {
    background-color: transparent;
    color: var(--btn-ghost-text);
    border-color: var(--btn-ghost-border);
  }

  .btn--ghost:hover {
    background-color: var(--btn-ghost-bg-hover);
  }

  /* Size modifier */
  .btn--sm {
    font-size: 0.6875rem;
    padding-block: var(--space-1);
    padding-inline: var(--space-2);
  }

  /* ─────────────────────────────────────────────
     FORM ELEMENTS
     ───────────────────────────────────────────── */

  .form-group {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
  }

  /* Phase 22a fix: the browser's own [hidden] { display: none } rule is a
     very low-priority default -- any author rule setting display on the
     same element beats it automatically, regardless of selector
     specificity. .form-group's own `display: flex` above was silently
     defeating the `hidden` attribute js/dashboard.js sets on
     #log-content-group / #log-mealtype-group, so the entry-type toggle
     never actually hid anything even though the attribute itself was being
     set and removed correctly. This re-asserts display: none whenever a
     .form-group also carries [hidden]. */
  .form-group[hidden] {
    display: none;
  }

  .form-label {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-secondary);
  }

  .form-input {
    background-color: var(--input-bg);
    border: 1px solid var(--input-border);
    color: var(--input-text);
    padding-block: var(--input-padding-y);
    padding-inline: var(--input-padding-x);
    border-radius: var(--radius-sm);
    font-size: var(--text-base);
    transition: border-color var(--duration-fast) var(--ease-out);
    inline-size: 100%;
  }

  .form-input::placeholder {
    color: var(--input-placeholder);
  }

  .form-input:focus {
    outline: none;
    border-color: var(--input-border-focus);
  }

  /* Native <select> arrow -- author-reported (meals.php's Sort control,
     but this reaches every <select class="form-input"> sitewide,
     including every admin content-type/status/pillar/difficulty select):
     the browser's own dropdown affordance renders flush against the
     border regardless of this rule's own padding-inline-end -- confirmed
     live via getComputedStyle before assuming a cause: padding-inline-end
     correctly read 24px, the arrow still hugged the edge anyway. A known
     cross-browser inconsistency, not a bug in this stylesheet's own
     padding math -- the native arrow is drawn in a UA-reserved region
     that doesn't respect the padding box the way ordinary text content
     does. appearance: none strips it; the replacement is a plain inline
     SVG chevron using #9999BB (--primitive-gray-400, the same value
     --color-text-muted already resolves to -- no new color), positioned
     with real inset from the edge via background-position, with
     padding-inline-end widened so text never runs under it. Scoped to
     `select.form-input` specifically, not the bare `.form-input` class --
     text <input>/<textarea> elements sharing that class have no native
     arrow to fix and should not gain this extra end padding. `right`
     in background-position is physical, not logical -- CSS has no
     logical-property equivalent for this shorthand, the same documented,
     narrow exception .move-callout-pulse's box-shadow comment already
     makes for this English/LTR-only site. Confirmed live before
     shipping: the chevron now sits with real breathing room from the
     border, matching every other custom-styled control on this site
     rather than raw OS chrome. */
  select.form-input {
    appearance: none;
    -webkit-appearance: none;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239999BB' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 0.75rem center;
    background-size: 1rem;
    padding-inline-end: 2.5rem;
  }

  /* Phase 23: outdoor checkbox row. Row layout (not the default column
     .form-group stack) since a single checkbox + its own label reads
     better inline than stacked. accent-color is a native platform API for
     tinting a checkbox/radio -- no custom SVG or dependency needed to get
     a gold check, matching this codebase's own stated preference for a
     native API over any dependency, even a self-hosted one. */
  .form-group--checkbox {
    flex-direction: row;
    align-items: center;
    gap: var(--space-2);
  }

  .form-checkbox-label {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-secondary);
    cursor: pointer;
  }

  .form-checkbox-label input[type="checkbox"] {
    accent-color: var(--color-accent);
    inline-size: 1.1rem;
    block-size: 1.1rem;
    cursor: pointer;
  }

  /* Phase 23: quick-pick duration chips above the Duration input -- fill
     the field but don't replace it, so it stays directly editable for any
     value the five presets don't cover. Reuses .btn--ghost.btn--sm
     verbatim; only the wrapper's gap/wrap is new. */
  .duration-presets {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    margin-block-end: var(--space-1);
  }

  /* Screen reader live region for form status messages */
  .form-status {
    font-size: var(--text-sm);
    line-height: var(--leading-snug);
    min-block-size: 1.4em; /* Reserve space to prevent layout shift */
  }

  .form-status--success {
    color: var(--color-accent);
  }

  .form-status--error {
    color: #E07070; /* Error red — not in token system yet, isolated here */
  }

  /* Resend-verification link inside the login page's unverified error
     state (js/auth.js's showStatusWithLink()) -- same treatment already
     used by .disclaimer-note a and .auth-links a:not(.btn), not a third
     near-identical pair of rules invented for this one case. */
  .form-status a {
    color: var(--color-accent);
    transition: var(--transition-color);
  }

  .form-status a:hover {
    color: var(--color-accent-hover);
  }

  /* ─────────────────────────────────────────────
     FOOTER
     ───────────────────────────────────────────── */

  .footer {
    background-color: var(--footer-bg);
    border-block-start: var(--footer-border);
  }

  .footer__copy {
    font-size: var(--text-sm);
    color: var(--footer-text);
  }

  .footer__links {
    display: flex;
    gap: var(--space-3);
  }

  .footer__link {
    /* 0.6875rem, not var(--text-sm) -- text-sm clamps up to 14px at desktop
       widths, the same size as the primary nav links, which let this row of
       four uppercase/tracked labels visually outweigh the wordmark and the
       copyright line beside it. Matches .btn--sm's already-established
       smaller scale rather than introducing a new magic number. */
    font-size: 0.6875rem;
    color: var(--footer-text);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    transition: var(--transition-color);
  }

  .footer__link:hover {
    color: var(--color-accent);
  }

  /* ─────────────────────────────────────────────
     DASHBOARD COMPONENTS (Phase 14+)
     ───────────────────────────────────────────── */

  .profile-sidebar {
    background-color: var(--color-surface);
    border: var(--border-hairline);
    padding: var(--space-4);
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
  }

  .profile-sidebar__name {
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-primary);
  }

  /* "Message the Sage" (schema-messages.sql, Session 34) -- quiet
     icon+badge link beside the display name. Muted by default, same
     inherited color every other quiet icon on this page uses
     (.stream-entry__type-icon, .discipline-card__icon-slot's rule);
     shifts to accent gold only on hover, never on its own. The badge dot
     is the ONLY thing that signals state -- no persistent count, no
     always-visible color change -- per the author's explicit instruction
     that this feature stay quiet and optional, not something that
     competes for attention. */
  .profile-sidebar__name-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
  }

  .profile-sidebar__message-link {
    position: relative;
    display: inline-flex;
    flex-shrink: 0;
    color: var(--color-text-muted);
    transition: var(--transition-color);
  }

  .profile-sidebar__message-link:hover {
    color: var(--color-accent);
  }

  .profile-sidebar__message-icon {
    inline-size: 1.15rem;
    block-size: 1.15rem;
  }

  /* Presence/absence only -- rendered in the markup only when an unread
     count exists (dashboard.php), never present-but-hidden, so there is
     no empty badge state to accidentally show. */
  .profile-sidebar__message-badge {
    position: absolute;
    inset-block-start: -2px;
    inset-inline-end: -2px;
    inline-size: 0.5rem;
    block-size: 0.5rem;
    border-radius: 50%;
    background-color: var(--color-accent);
  }

  /* Second entry point (Session 34 continued, author follow-up request):
     same feature, same icon, placed in the Activity stream header row
     instead of the profile sidebar -- author's own call that the sidebar
     icon alone wasn't discoverable enough. Lives in .stream__header-actions
     (layout.css handles that row's flex/gap; this file handles this link's
     own typography/color, matching this codebase's own layout-vs-component
     layer split). Matches its two .btn--ghost.btn--sm neighbors'
     font-size/padding-block for row alignment, but carries no border or
     background -- the deliberate absence of button chrome signals "present
     and findable, not a third primary action on equal footing with Browse
     Disciplines/Log an Entry." */
  .stream__message-link {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    font-family: var(--font-body);
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    color: var(--color-text-muted);
    transition: var(--transition-color);
    padding-block: var(--space-1);
    padding-inline: var(--space-2);
  }

  .stream__message-link:hover {
    color: var(--color-accent);
  }

  .stream__message-icon {
    inline-size: 1rem;
    block-size: 1rem;
    flex-shrink: 0;
  }

  /* Same presence/absence rule as .profile-sidebar__message-badge above --
     rendered only when an unread count exists. Plain inline dot, not
     absolutely positioned -- this link already has flex + gap to place it
     correctly, unlike the sidebar icon's cornered version. */
  .stream__message-badge {
    inline-size: 0.4rem;
    block-size: 0.4rem;
    border-radius: 50%;
    background-color: var(--color-accent);
    flex-shrink: 0;
  }

  .profile-sidebar__stat {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
  }

  .profile-sidebar__stat-value {
    color: var(--color-accent);
    font-weight: 600;
  }

  /* Session 26: streak stat only -- the one profile stat with an icon this
     phase, kept bounded rather than adding icons to all four rows. Gold,
     not muted: pairs with .profile-sidebar__stat-value's own accent color
     on the same row, reinforcing the streak as the one stat meant to read
     as a reward. */
  .profile-sidebar__stat-label {
    display: inline-flex;
    align-items: center;
    gap: 0.4em;
  }

  .profile-sidebar__stat-icon {
    inline-size: 0.9em;
    block-size: 0.9em;
    flex-shrink: 0;
    color: var(--color-accent);
  }

  .stream-entry {
    border-block-start: var(--border-hairline);
    padding-block: var(--space-4);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
  }

  .stream-entry__date {
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  .stream-entry__title {
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: 400;
    letter-spacing: var(--tracking-normal);
    text-transform: uppercase;
    color: var(--color-text-primary);
  }

  .stream-entry__notes {
    font-size: var(--text-sm);
    line-height: var(--leading-loose);
    color: var(--color-text-secondary);
  }

  /* Regions-in-stream follow-up (2026-08-24): tagged regions shown inline
     on the entry itself, same treatment as .stream-entry__notes (this is
     also just plain descriptive text about the entry) -- declared as its
     own rule with identical properties rather than adding a second class
     to the markup, so .stream-entry__notes stays semantically "the
     member's own free-text note" and this stays semantically "what got
     tagged," even though they render identically today. */
  .stream-entry__regions {
    font-size: var(--text-sm);
    line-height: var(--leading-loose);
    color: var(--color-text-secondary);
  }

  /* Phase 22a: entry type label (Session/Meal/General) -- same treatment as
     .discipline-card__index (small uppercase muted label), not a new pattern. */
  .stream-entry__type {
    display: inline-flex;
    align-items: center;
    gap: 0.35em;
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-widest);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  /* Session 26: icon paired with the type label above. stroke="currentColor"
     on the SVG source (see img/icons/SOURCES.md) means this inherits
     .stream-entry__type's own color with no separate color rule needed --
     same technique the hero's "Enter the Doctrine" arrow already uses.
     Sized up from 0.85em after live review: at that size (~10-12px against
     --text-sm) the barbell and apple glyphs lost enough shape definition to
     read as ambiguous marks rather than identifiable pictograms -- the
     notes icon (a bold rectangle silhouette) survived the compression fine,
     but the other two didn't. 1.15em keeps the icon reading as a quiet,
     muted mark (color unchanged) while giving the stroke enough size to
     actually carry the shape -- stroke-width scales with it automatically,
     since it's set in the SVG's own userSpace units against the 24x24
     viewBox, not fixed in CSS pixels. */
  .stream-entry__type-icon {
    inline-size: 1.15em;
    block-size: 1.15em;
    flex-shrink: 0;
  }

  /* Duration chip (2026-08-24 follow-up): duration_minutes was captured via
     the Log an Entry dialog and stored since Phase 23, but never rendered
     anywhere in the stream -- this closes that gap. Nested inline inside
     .stream-entry__type rather than given its own row, matching the
     existing precedent of meal_type folding into the same line
     (entryTypeLabel()'s "Meal - Breakfast") rather than a second element.
     color/text-transform/tracking all inherit from .stream-entry__type --
     no separate declarations needed here for either. */
  .stream-entry__type-sep {
    opacity: 0.5;
    margin-inline: 0.3em;
  }

  .stream-entry__duration {
    display: inline-flex;
    align-items: center;
    gap: 0.35em;
  }

  .stream-entry__duration-icon {
    inline-size: 1.15em;
    block-size: 1.15em;
    flex-shrink: 0;
  }

  /* Phase 22a: progress/meal photo thumbnail attached to a stream entry.
     Fixed square, object-fit: cover -- width/height attributes are set in
     the markup itself (dashboard.php and js/dashboard.js) so the browser
     reserves layout space before the image loads (CLS). */
  .stream-entry__photo {
    inline-size: 6rem;
    block-size: 6rem;
    object-fit: cover;
    border: 1px solid var(--card-border);
    border-radius: var(--radius-sm);
  }

  /* Phase 22a follow-up: thumbnail is now wrapped in a real <button> (not a
     bare clickable <img>) so it's keyboard-operable and announced as a
     control, not just decoration. Reset button chrome, keep the same
     inline-block footprint as the image itself. */
  .stream-entry__photo-btn {
    display: inline-block;
    padding: 0;
    border: none;
    background: none;
    cursor: pointer;
    border-radius: var(--radius-sm);
    line-height: 0; /* Prevents a few stray pixels of inline-image whitespace below the thumbnail */
  }

  .stream-entry__photo-btn:hover .stream-entry__photo {
    border-color: var(--card-border-hover);
  }

  /* Phase 22a follow-up: full-size photo lightbox. Reuses .modal's base
     surface/backdrop -- this is a size modifier only, not a new component. */
  .modal--lightbox {
    max-inline-size: min(48rem, 92vw);
    padding: var(--space-4);
  }

  .lightbox__image {
    display: block;
    inline-size: 100%;
    max-block-size: 75vh;
    object-fit: contain;
  }

  /* ─────────────────────────────────────────────
     PROFILE SIDEBAR -- IS-EDITING STATE (Phase 17)
     JS adds .is-editing to .profile-sidebar; CSS drives visibility.
     Edit form is hidden until the class is present -- no JS inline styles.
     ───────────────────────────────────────────── */

  /* Display block: visible by default, hidden when editing */
  .profile-sidebar__display {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
  }

  .profile-sidebar.is-editing .profile-sidebar__display {
    display: none;
  }

  /* Edit form: hidden by default, shown as column when editing */
  .profile-sidebar__edit-form {
    display: none;
    flex-direction: column;
    gap: var(--space-3);
  }

  .profile-sidebar.is-editing .profile-sidebar__edit-form {
    display: flex;
  }

  /* Save + Cancel row inside edit form */
  .profile-sidebar__form-actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
  }

  /* Stream empty state */
  .stream__empty {
    padding-block: var(--space-8);
    font-size: var(--text-sm);
    font-style: italic;
    color: var(--color-text-muted);
    line-height: var(--leading-loose);
  }

  /* Dashboard length/UX pass (this session): "Load More" button wrapper,
     replacing the old auto-load .stream-sentinel div. The button itself
     needs no new rule -- .btn.btn--ghost.btn--sm plus the shared
     .is-loading utility (utilities.css) already cover its resting and
     loading states, matching every other async button on this page
     (.profile-sidebar__edit-form's Save button, the Log an Entry dialog's
     Save button). Centered the same way .anatomy__try-link already is --
     text-align on the block, since .btn is inline-flex. */
  .stream__load-more-wrap {
    margin-block-start: var(--space-4);
    text-align: center;
  }

  /* ─────────────────────────────────────────────
     YOUR TRACK (Stage 2 of Training Tracks, this session)
     Assigned program content -- weekly packets, unlocked/locked computed
     server-side in dashboard.php. Deliberately reuses .plan-meta (the
     goal/duration meta line, already established by workout-plan.php/
     meal-plan.php) and .section-body (packet prose -- gets its own
     p+p paragraph spacing for free, the same wrapper discipline.php's
     own body text already uses) rather than inventing parallel classes
     for either. Only what's genuinely new to this component is declared
     here.
     ───────────────────────────────────────────── */

  .track-card {
    background-color: var(--card-bg);
    border: 1px solid var(--card-border);
    padding: var(--card-padding);
  }

  .track-card + .track-card {
    margin-block-start: var(--space-6);
  }

  .track-card__title {
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-primary);
    margin-block-end: var(--space-2);
  }

  .track-card__summary {
    font-size: var(--text-sm);
    line-height: var(--leading-loose);
    color: var(--color-text-secondary);
    margin-block-end: var(--space-2);
  }

  /* Same border-block-start/padding-block rhythm as .stream-entry -- a
     packet is the same shape of thing (a dated, titled unit in a list),
     so it gets the same established treatment rather than a new one. */
  .track-packet {
    border-block-start: var(--border-hairline);
    padding-block: var(--space-4);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
  }

  .track-packet__index {
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  .track-packet__title {
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: 400;
    letter-spacing: var(--tracking-normal);
    text-transform: uppercase;
    color: var(--color-text-primary);
  }

  .track-packet__link {
    margin-block-start: var(--space-1);
  }

  /* Locked packet: dimmed, title/body never rendered at all (see
     dashboard.php -- locked state gets no title/body markup, not just
     hidden CSS) -- the earned-not-given pacing this feature exists to
     reinforce. Opacity only, no lock icon or strikethrough; the copy
     itself ("Unlocks in N days") already carries the meaning. */
  .track-packet.is-locked {
    opacity: 0.55;
  }

  .track-packet__locked {
    font-size: var(--text-sm);
    font-style: italic;
    color: var(--color-text-muted);
  }

  /* Dashboard length/UX pass (this session): "This Week" callout + track
     switcher, replacing the previous uncapped full-packet-list-per-card
     layout that prompted this pass (session-start.md, "Recommended Next
     Direction (Session 52 continued)"). */

  /* Track switcher -- rendered only when a member has more than one
     active enrollment (dashboard.php). Same flex-row-with-gap toggle
     pattern already used by .region-picker__toggle-group and
     .muscle-balance__toggle elsewhere on this page, but without their
     joined-pill first-child/last-child radius zeroing -- that treatment
     is built for exactly two buttons and would misrender a third, middle
     button once a member has 3+ simultaneous tracks (a real, if less
     common, case this component has to support). Plain gapped buttons
     scale to any count without a special case. */
  .track-tabs {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    margin-block-end: var(--space-4);
  }

  .track-tabs__btn[aria-pressed="true"] {
    background-color: var(--btn-accent-bg);
    color: var(--btn-accent-text);
    border-color: var(--btn-accent-bg);
  }

  /* Defensive guard, same reasoning as every other [hidden] rule already
     in this file (.form-group[hidden], .anatomy__try[hidden],
     .muscle-balance__figures[hidden], .signals-mine__avatar-review[hidden]):
     a bare-class display rule can beat the browser's own low-priority
     [hidden] default regardless of specificity. .track-card does not
     currently declare its own `display`, so this isn't fixing a live bug
     today -- it's the same pre-emptive guard this codebase has written in
     from the start every other time JS toggles [hidden] on an element,
     rather than waiting to rediscover the trap a third time. */
  .track-card[hidden] {
    display: none;
  }

  /* "This Week" -- the single most relevant packet, always shown in full
     regardless of how many other weeks exist. Reuses .track-packet's own
     flex/gap/padding wholesale; the only addition is the same gold
     border-inline-start emphasis already established by
     .thread-entry--sage and .move-callout for "this is the one thing that
     matters right now" -- not a new visual language. */
  .track-packet--current {
    border-inline-start: 2px solid var(--color-accent);
    padding-inline-start: var(--space-3);
  }

  /* Native disclosure, zero JS -- holds every packet except the current
     one (older unlocked weeks, future locked weeks). Minimal styling: the
     browser's own triangle marker is kept rather than replaced with an
     icon, matching this project's stated preference for a native
     platform affordance over custom-built chrome when the native one
     already does the job. */
  .track-details {
    margin-block-start: var(--space-2);
  }

  .track-details__summary {
    cursor: pointer;
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
    transition: var(--transition-color);
    padding-block: var(--space-2);
  }

  .track-details__summary:hover {
    color: var(--color-accent);
  }

  /* YOUR STANDING (Phase 23)
     Four Pillars, rolling 90-day window, self-tagged. Geometry (polygon
     points, axis positions) is computed entirely server-side in
     dashboard.php and streamed as plain SVG -- no client-side trig, no
     charting library, consistent with this codebase's "native API over
     dependency" rule and with the crown-socket badges elsewhere on the
     site, which are also plain server-rendered SVG.

     Deliberately uses ONLY the flat gold ladder (--color-accent and its
     hover/active steps) -- never the crown/badge gradient gold, which
     architecture.md's Brand section reserves for the mark and badge
     frames specifically. A data visualization is UI work, the same job
     every button and link on the site already does in flat gold.

     Two shapes, two different visual jobs: the dashed reference diamond
     is static (what balance looks like relative to the member's own
     current strongest pillar right now) and never pulses; the filled
     "standing" shape is the living, current one, and breathes gently via
     standing-pulse -- same governing idea as .move-callout's pulse
     (the important, current thing moves; the constant does not). */

  .standing {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3);
    text-align: center;
    /* Session 30 (second pass): no card chrome of its own anymore -- it's
       nested inside .profile-sidebar, which already supplies the
       border/background/padding every element in this column shares.
       A second border here would read as a box nested inside a box. */
  }

  .standing .section-heading {
    align-self: flex-start;
    text-align: start;
    /* Real bug, found and fixed 2026-08-23 via direct Playwright measurement
       (not eyeballed): .standing is a column flex container with
       align-items: center (not stretch), so a flex item with no width of
       its own sizes to its own max-content width along the cross axis --
       align-self: flex-start only pins its POSITION, it does not constrain
       its SIZE. At this sidebar's fixed 18rem column width, "Your
       Standing" set in --text-lg (a page-relative fluid clamp meant for
       full-width headings, not an 18rem card) computed a max-content width
       wider than the sidebar's own available content box -- confirmed
       directly: the heading's own rendered box measured 251px against a
       224px available content width (288px sidebar minus 32px padding on
       each side), which is why the wrapped "STANDING" line rendered with
       only ~4px of right margin against "YOUR" line's correct ~33px.
       max-inline-size: 100% constrains the flex item to the container's
       real available width (verified: both margins measure 33px after this
       fix) -- but that alone exposes a second problem: --text-lg's fluid
       size (~42px at common viewport widths) doesn't fit "STANDING" in the
       now-correctly-constrained 224px, so overflow-wrap: break-word (reset.css)
       split the word mid-letter ("STANDIN" / "G"). A fluid, viewport-relative
       clamp is also the wrong tool here regardless -- this column's width is
       fixed at 18rem by the .dashboard grid, not viewport-relative, so a
       vw-based clamp was never going to track its real available space
       correctly. Fixed font-size (not a token, matching this codebase's own
       .btn--sm precedent of a bare rem value for a one-off scale step
       outside the fluid --text-* system) measured directly against the
       actual available width, with real margin ("STANDING" measures 216px
       at this size against the 224px budget): confirmed both lines wrap at
       the natural word boundary with symmetric 33px margins on both sides. */
    max-inline-size: 100%;
    font-size: 2.25rem;
  }

  /* Quiet radial bloom behind the chart -- the exact same recipe (color,
     stops, opacity) already used by .hero::before, .anatomy__figures::before,
     and .error-atmosphere::before. No new color introduced: rgb(200 169 126)
     is #C8A97E, --primitive-gold-400, read directly off tokens.css. */
  .standing__chart {
    position: relative;
    isolation: isolate;
    /* Session 30 (third pass, author-reported both ends of this): a single
       fixed 14rem cap couldn't serve both contexts this renders in. It
       exactly matched the desktop sidebar's available inner width (18rem
       column minus its own padding), so the chart touched the card's
       padding edge with zero margin -- then the SAME fixed value left a
       lot of unused space on either side once the sidebar goes full-width
       on mobile, recreating a milder version of the original oversized-
       card problem. A percentage-based size scales proportionally to
       whatever its actual container width is: real margin on the narrow
       desktop column, a fuller (but still capped, so it never balloons on
       a wide single-column tablet view) chart on mobile. */
    inline-size: min(75%, 15rem);
    margin-inline: auto;
  }

  .standing__chart::before {
    content: "";
    position: absolute;
    inset: -10%;
    z-index: var(--z-below);
    pointer-events: none;
    background: radial-gradient(
      55% 55% at 50% 50%,
      rgb(200 169 126 / 0.08) 0%,
      rgb(200 169 126 / 0.03) 45%,
      transparent 75%
    );
  }

  /* overflow: visible, matching .anatomy__figure's own established reason --
     axis labels sit just outside the nominal viewBox by design (see the
     margin math in dashboard.php's own comment), and SVG's default
     overflow: hidden would clip them without this. */
  .standing__svg {
    display: block;
    inline-size: 100%;
    block-size: auto;
    position: relative;
    z-index: var(--z-base);
    overflow: visible;
  }

  .standing__axis {
    stroke: var(--color-border);
    stroke-width: 1;
  }

  .standing__reference {
    fill: none;
    stroke: var(--color-accent);
    stroke-width: 1;
    stroke-dasharray: 4 4;
    opacity: 0.45;
  }

  .standing__fill {
    fill: color-mix(in srgb, var(--color-accent) 22%, transparent);
    stroke: var(--color-accent);
    stroke-width: 1.5;
    animation: standing-pulse 3s var(--ease-inout) infinite;
  }

  @keyframes standing-pulse {
    0%, 100% { opacity: 0.75; }
    50%      { opacity: 1; }
  }

  .standing__axis-label {
    font-family: var(--font-body);
    font-weight: 600;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    fill: var(--color-text-muted);
  }

  /* The pillar(s) currently at the member's own max -- not a number, just a
     quiet acknowledgment of what's leading right now. */
  .standing__axis-label--leading {
    fill: var(--color-accent);
  }

  .standing__note {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    line-height: var(--leading-loose);
  }

  @media (prefers-reduced-motion: reduce) {
    .standing__fill {
      animation: none;
      opacity: 0.85;
    }
  }

  /* LUNAR CREST PHASE STRIP (Session 59)
     creative_direction Direction 1 -- the moonstone-gradient cyclical
     counterpart to Standing's own gold chart, both inside the same card.
     Reuses standing-pulse (declared above) BY NAME, not redeclared -- the
     brief's own instruction was "reuse the identical pulse already in
     production," so the keyframe travels with it, not just the numbers.
     No caption -- .standing__note above already states the window/count;
     the SVG's own aria-label carries the full accessible description.
     Moonstone's gradient stops are hardcoded hex in the SVG markup itself
     (dashboard.php), matching how .doctrine__stat-badge's gold gradient
     is built -- see --primitive-moonstone-400's own comment in
     tokens.css for why no CSS custom property is consumed here. */
  .lunar-strip {
    inline-size: 100%;
    margin-block-start: var(--space-1);
    padding-block-start: var(--space-2);
    border-block-start: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
  }

  .lunar-strip svg {
    display: block;
    inline-size: 100%;
    block-size: auto;
    overflow: visible;
  }

  .lunar-strip__current {
    animation: standing-pulse 3s var(--ease-inout) infinite;
  }

  @media (prefers-reduced-motion: reduce) {
    .lunar-strip__current {
      animation: none;
      opacity: 0.85;
    }
  }

  /* ─────────────────────────────────────────────
     NATIVE DIALOG (Phase 20)
     ───────────────────────────────────────────── */

  .modal {
    background-color: var(--color-surface);
    border: var(--border-hairline);
    padding: var(--space-6);
    max-inline-size: min(40rem, 90vw);
    max-block-size: 90vh;
    overflow-y: auto;
  }

  .modal::backdrop {
    background-color: color-mix(in srgb, var(--color-bg) 80%, transparent);
    backdrop-filter: blur(4px);
  }

  .modal__actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin-block-start: var(--space-2);
  }

  /* ─────────────────────────────────────────────
     ERROR-PAGE ATMOSPHERE (404.php/500.php, design-analysis audit
     2026-08-17 Stage 2, author-greenlit)
     Modifier on .auth-page, scoped to just these two pages -- NOT added to
     the bare .auth-page selector, which login/register/forgot/reset/shop
     all share and would otherwise all pick this up unintentionally.
     Grain needs nothing here: body::after in layout.css is already
     sitewide. Bloom and vignette are the hero's own Phase 24b gradient
     recipes, reused verbatim (same governing principle as the hero: layer
     decorative below content, never over it) rather than modified --
     "reduced scale" here means a plain 26rem card instead of a full-
     viewport hero, not weaker gradient values. .auth-card gets its own
     stacking order explicitly, same reason .hero__watermark needed
     --hero-z-content: an absolutely positioned decorative pseudo-element
     paints above static in-flow content unless something says otherwise. */
  .error-atmosphere {
    position: relative;
    isolation: isolate;
  }

  .error-atmosphere::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background: radial-gradient(
      58% 46% at 72% 42%,
      rgb(200 169 126 / 0.13) 0%,
      rgb(200 169 126 / 0.05) 38%,
      transparent 72%
    );
  }

  .error-atmosphere::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    background: radial-gradient(
      125% 95% at 50% 42%,
      transparent 42%,
      rgb(0 0 0 / 0.62) 100%
    );
  }

  .error-atmosphere .auth-card {
    position: relative;
    z-index: 2;
  }

  /* ─────────────────────────────────────────────
     AUTH COMPONENTS (Phase 15)
     Card, heading, form layout, link row, field hint.
     Full-width button modifier shared with any form.
     ───────────────────────────────────────────── */

  .auth-card {
    background-color: var(--color-surface);
    border: var(--border-hairline);
    padding: var(--space-8);
    inline-size: min(26rem, 100%);
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
  }

  .auth-heading {
    font-family: var(--font-heading);
    font-size: var(--text-lg);
    font-weight: 400;
    letter-spacing: var(--tracking-wide);
    color: var(--color-text-primary);
    text-transform: uppercase;
    line-height: var(--leading-tight);
  }

  .auth-form {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
  }

  .field-hint {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    line-height: var(--leading-snug);
    margin-block-start: var(--space-1);
  }

  .auth-links {
    font-size: var(--text-sm);
    font-family: var(--font-body);
    color: var(--color-text-muted);
    line-height: var(--leading-snug);
    text-align: center;
  }

  /* Phase 22 continuation fix: on the four content-detail pages
     (discipline.php, disciplines.php, workout-plan.php, meal-plan.php),
     .auth-links sits directly in a plain block-flow .section with no
     sibling gap, so its "Back to Dashboard" link landed flush against
     whatever content precedes it. Scoped to .section > .auth-links rather
     than the bare class -- .auth-links also renders inside .auth-card
     (login.php, register.php, shop.php, etc.), which is a flex column
     already spacing every child via `gap`; adding margin there too would
     double the gap, the same class of bug flagged in lessons.md (hero
     lockup, Session 17). This selector only reaches the plain-section
     usage. */
  .section > .auth-links {
    margin-block-start: var(--space-6);
  }

  /* Found this session, via direct measurement on The Ninety's homepage
     teaser (0px gap between the intro paragraph and its CTA button,
     confirmed with getBoundingClientRect, not eyeballed): the rule above
     assumes .auth-links sits immediately inside .section with nothing
     between them, but index.php wraps section content in .container
     (and often .fade-in on top of that), and the-sage.php's own closing
     CTA has the same .container wrapper -- so `.section > .auth-links`
     has never actually matched either of the site's two "prose, then a
     CTA button" closings, not just this new one. Rather than replace the
     original rule (it may still be exactly correct for the four
     content-detail pages it was written for, and this session hasn't
     read those files to confirm one way or the other), this adds a second
     rule keyed to actual DOM adjacency instead of assumed nesting depth:
     wherever .auth-links directly follows a .section-body sibling, which
     is the real shape on every page this was checked against. Both rules
     set the same value, so if a page ever matches both, nothing doubles. */
  .section-body + .auth-links {
    margin-block-start: var(--space-6);
  }

  /* :not(.btn) fix: .auth-links a (class + element type, specificity 0-1-1)
     was beating .btn--accent (single class, 0-1-0) on the `color` property
     for any <a class="btn ..."> placed inside .auth-links -- the-sage.php's
     "Begin Your Path" CTA rendered with gold text on its own gold
     background, effectively invisible, even though the button's box
     (background/border from .btn--accent) was unaffected. Plain text links
     (the original use case -- "Forgot your password?" etc. on the auth
     pages) are untouched by this exclusion; only .btn-classed elements are
     left to their own component styling. */
  .auth-links a:not(.btn) {
    color: var(--color-accent);
    transition: var(--transition-color);
  }

  .auth-links a:not(.btn):hover {
    color: var(--color-accent-hover);
  }

  /* Full-width modifier -- used on auth forms and any single-column submit */
  .btn--full {
    inline-size: 100%;
  }

  /* ─────────────────────────────────────────────
     TEXTAREA (Phase 16)
     Vertical resize only; minimum height prevents
     collapse; font-family inherits from form-input
     but textareas reset to monospace by default.
     ───────────────────────────────────────────── */

  textarea.form-input {
    resize: vertical;
    min-block-size: 8rem;
    font-family: var(--font-body);
  }

  /* ─────────────────────────────────────────────
     MESSAGE THREAD ("Message the Sage", schema-messages.sql, Session 34)
     Single continuous thread, oldest to newest -- shared between
     messages.php (member) and admin/messages.php (admin thread view), so
     the visual language never drifts between the two sides of the same
     conversation. Reuses .stream__empty verbatim for the empty state
     rather than a second near-identical class.
     ───────────────────────────────────────────── */

  .thread {
    max-inline-size: var(--container-narrow);
  }

  .thread__entries {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    margin-block: var(--space-6);
  }

  .thread-entry {
    border-block-start: var(--border-hairline);
    padding-block-start: var(--space-3);
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
  }

  /* Sage-sent messages get the same gold border-inline-start treatment
     already used for .move-callout and .anatomy__reading's wide layout --
     a quiet visual distinction from the member's own messages, not a
     chat-bubble/color-block pattern (off-brand for this platform's
     restraint). */
  .thread-entry--sage {
    border-inline-start: 2px solid var(--color-accent);
    padding-inline-start: var(--space-3);
  }

  .thread-entry__meta {
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  .thread-entry__body {
    font-size: var(--text-base);
    line-height: var(--leading-loose);
    color: var(--color-text-secondary);
    /* Preserves the member's own line breaks -- this is conversational
       text, not admin-authored HTML content like discipline.php's body. */
    white-space: pre-wrap;
  }

  .thread__form {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    border-block-start: var(--border-hairline);
    padding-block-start: var(--space-4);
  }

  /* ─────────────────────────────────────────────
     ADMIN TABLE (Phase 16)
     Content management list view.
     ───────────────────────────────────────────── */

  .admin-table {
    inline-size: 100%;
    border-collapse: collapse;
    font-family: var(--font-body);
    font-size: var(--text-sm);
  }

  .admin-table th {
    font-weight: 600;
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    color: var(--color-text-muted);
    text-align: start;
    padding-block: var(--space-2);
    padding-inline: var(--space-3);
    border-block-end: var(--border-hairline);
    white-space: nowrap;
  }

  .admin-table td {
    padding-block: var(--space-2);
    padding-inline: var(--space-3);
    border-block-end: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
    color: var(--color-text-secondary);
    vertical-align: middle;
  }

  .admin-table tbody tr:hover td {
    background-color: var(--color-surface);
  }

  /* Actions cell: Edit link + Delete form side by side */
  .admin-table__actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-wrap: wrap;
  }

  /* form inside flex container: display contents lets the button
     participate directly in the flex layout */
  .admin-table__actions form {
    display: contents;
  }


  /* ─────────────────────────────────────────────
     THE FORM (anatomy section, homepage, between Doctrine and Disciplines)
     A literal demonstration of the Doctrine's own claim ("the body is a
     system") rather than a second block of prose making the same claim.
     Public, no gate -- same free layer as the rest of the homepage.

     Region fill reuses --color-surface-raised (an existing Tier 2 token,
     "elevated surface") rather than a new bespoke hex -- resting muscle
     groups read as a shade lifted off the void, same relationship every
     other elevated surface on the site already has to the background.
     Hover/pinned reuses --color-accent, the same gold every other
     interactive accent on the page already uses -- no new color
     introduced anywhere in this component.

     Source: github.com/HichamELBSI/react-native-body-highlighter (MIT),
     front-view male + female figures only (back view omitted this phase
     to keep the section light -- same source data, easy fast-follow).
     Path data run through SVGO (--precision=1), ~35-45% smaller than the
     unoptimized source. No gradients/defs in this markup, so the Session
     22 "SVG ids are document-global" lesson does not apply here -- flagged
     for whoever adds a gold-gradient treatment later, since the badge
     SVGs elsewhere on this page do use per-instance gradient ids.
     ───────────────────────────────────────────── */

  .anatomy {
    background-color: var(--color-bg);
  }

  .anatomy__toggle {
    display: flex;
    justify-content: center;
    gap: 0;
    margin-block: var(--space-6);
  }

  .anatomy__toggle-btn:first-child {
    border-start-end-radius: 0;
    border-end-end-radius: 0;
  }

  .anatomy__toggle-btn:last-child {
    border-start-start-radius: 0;
    border-end-start-radius: 0;
    margin-inline-start: -1px;
  }

  /* Active state. aria-pressed is the actual state -- this selector reads
     it directly rather than depending on a JS-toggled class staying in
     sync with the attribute. */
  .anatomy__toggle-btn[aria-pressed="true"] {
    background-color: var(--btn-accent-bg);
    color: var(--btn-accent-text);
    border-color: var(--btn-accent-bg);
  }

  /* Session 27: at rest the figure's fill (--color-surface-raised) sits
     close to --color-bg by design, so muscle groups read as a shade
     lifted off the void rather than a stock-photo silhouette -- but
     combined with the figure's modest size that let it recede into the
     background rather than anchor the section. A soft gold bloom behind
     it reuses .hero::before's exact radial-gradient recipe (Phase 24b),
     scoped lower (0.08 vs the hero's 0.13) since this sits behind a much
     smaller subject in a shorter section, not a full-viewport hero.
     isolation: isolate keeps the pseudo-element's negative z-index
     contained to this box, same technique .hero already uses for its
     own ::before/::after stack, so it can't paint behind unrelated
     siblings outside this component. */
  .anatomy__figures {
    position: relative;
    isolation: isolate;
    max-inline-size: 18rem;
    margin-inline: auto;
  }

  .anatomy__figures::before {
    content: "";
    position: absolute;
    inset: -15%;
    z-index: var(--z-below);
    pointer-events: none;
    background: radial-gradient(
      55% 55% at 50% 42%,
      rgb(200 169 126 / 0.08) 0%,
      rgb(200 169 126 / 0.03) 45%,
      transparent 75%
    );
  }

  .anatomy__figure-set {
    display: none;
  }

  .anatomy__figure-set.is-active {
    display: block;
  }

  .anatomy__figure {
    display: block;
    inline-size: 100%;
    block-size: auto;
    overflow: visible;
  }

  .anatomy__outline {
    fill: var(--color-surface);
    stroke: var(--color-border);
    stroke-width: 2;
  }

  /* Two selector shapes needed, not one: 17 of 19 regions are
     <g class="anatomy__region"><path/></g>, but head/hair in the source
     data are bare <path class="anatomy__region"> with no wrapping <g> --
     confirmed via screenshot (both rendered in their original source
     grays, #bebebe/#3f3f3f, while every other region correctly showed
     --color-surface-raised) then via markup grep, not assumed. A
     descendant-only selector (`.anatomy__region path`) silently misses
     the two self-classed paths, since presentation-attribute overrides
     via CSS require the rule to actually match the element carrying the
     fill, not just any element under it. */
  .anatomy__region,
  .anatomy__region path {
    fill: var(--color-surface-raised);
    transition: fill var(--duration-fast) var(--ease-out);
    cursor: pointer;
  }

  .anatomy__region:hover,
  .anatomy__region:hover path,
  .anatomy__region.is-pinned,
  .anatomy__region.is-pinned path {
    fill: var(--color-accent) !important;
  }

  /* Session 27 (continued): companion horizontal accent for the same-name
     vertical trim the >=90rem side panel carries via border-inline-start
     below -- without this, the section had a gold accent only at the wide
     breakpoint and none at all below it. Centered, matches .hero__rule's
     exact recipe (same 3rem/1px/0.55-opacity treatment already used for
     the hero's headline-to-lede rule) rather than inventing a new one. */
  .anatomy__reading-rule {
    inline-size: 3rem;
    block-size: 1px;
    background-color: var(--color-accent);
    opacity: 0.55;
    margin-inline: auto;
    margin-block-end: var(--space-2);
  }

  /* Hidden at >=90rem: the vertical border-inline-start trim on
     .anatomy__reading already carries this accent there; showing both
     would be redundant, not additive. */
  @media (min-width: 90rem) {
    .anatomy__reading-rule {
      display: none;
    }
  }

  /* Session 27: split into two lines -- the region name (or the idle
     prompt), styled exactly as before, and a new secondary line naming
     which discipline(s) train that region. Container is layout-only now;
     min-block-size covers the two-line hover state so switching out of
     the one-line idle state doesn't shift the summary line below it.

     Session 27 (continued), stability fix: min-block-size is computed
     from the actual font tokens involved (--text-md is what the name line
     uses at >=90rem, --text-sm is what it uses below that and what the
     trains line always uses) rather than a flat magic number, so one
     reservation is correct at every breakpoint without a second override.
     Confirmed via direct measurement this was necessary, not theoretical:
     the flat 3.2em this replaced left the trains line 0.0125px of slack
     before wrapping to a second line on the longest real discipline
     string ("Trained through Command Flexibility, Move With Ease") at
     390px -- close enough to zero that a different browser's font metrics
     would tip it into a 2-line wrap, growing this block and reflowing the
     figure above it, which is the actual mechanism behind the reported
     hover jitter (the region under the mouse shifts, re-triggering its own
     mouseenter/mouseleave in a loop). See .anatomy__reading-trains below
     for the other half of this fix -- reserving a fixed 2-line height
     there regardless of actual line count is what removes the
     region-to-region jitter; this container reservation only smooths the
     one-time idle-to-active transition. */
  .anatomy__reading {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    text-align: center;
    margin-block-start: var(--space-4);
    /* Session 28: reservation raised from 2 lines to 3, base breakpoint --
       measured directly against the live page (Playwright, 390px viewport)
       across all 19 real region-insight strings (Session 28's authored
       content, replacing the old "Trained through X" discipline label):
       the longest wraps to 3 lines at this width, none need more. See
       .anatomy__reading-trains below for the matching change on the child
       element itself -- both need to move together or the container will
       clip what the child now reserves. */
    min-block-size: calc(var(--text-sm) * var(--leading-snug) + var(--space-1) + 3 * var(--text-sm) * var(--leading-snug));
  }

  .anatomy__reading-name {
    font-size: var(--text-sm);
    font-style: italic;
    line-height: var(--leading-snug);
    color: var(--color-text-muted);
    transition: var(--transition-color);
  }

  .anatomy__reading.is-active .anatomy__reading-name {
    font-family: var(--font-heading);
    font-style: normal;
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-accent);
  }

  /* Session 27 (continued): min-block-size reserves exactly 2 lines
     (this element's own font-size * line-height * 2), always, whenever
     it has text -- NOT sized to whatever the current string actually
     needs. This is the load-bearing half of the jitter fix: every
     hovered/pinned region now produces the identical block height,
     whether its real discipline text fits on 1 line (most regions) or
     wraps to 2 (the longest combinations, at narrow viewports), so moving
     the mouse from region to region never changes this element's height
     and therefore never moves anything above it. :empty still collapses
     it to zero during the idle state, before any region has been touched. */
  .anatomy__reading-trains {
    font-size: var(--text-sm);
    line-height: var(--leading-snug);
    color: var(--color-text-muted);
    /* Session 28: 2 -> 3 lines, matching the container reservation above --
       see that rule's comment for the measurement this is based on. */
    min-block-size: calc(3 * var(--text-sm) * var(--leading-snug));
  }

  /* Empty during the idle prompt (no discipline line yet) -- collapses
     rather than reserving a blank line, since textContent is only ever
     set once a region is hovered/pinned (see js/anatomy.js). */
  .anatomy__reading-trains:empty {
    display: none;
  }

  /* Session 27: closes the section, same small-uppercase-muted treatment
     already used for .discipline-card__index and .plan-meta -- a quiet
     summary line, not a second Doctrine-style stat block, so it doesn't
     compete with the figure for attention. */
  .anatomy__summary {
    text-align: center;
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
    margin-block-start: var(--space-8);
  }

  /* .anatomy__stage: design-audit follow-up (2026-08-20). Below 90rem
     this wrapper has no layout effect -- .anatomy__figures and
     .anatomy__reading render in normal block flow exactly as before,
     figure centered, caption below. At 90rem (--container-wide, the
     widest section width used on this site) and up, the figure no
     longer has enough flanking content to justify sitting alone in the
     full-width container -- .anatomy__reading moves beside it as a
     bordered panel reusing --border-accent (the same 1px gold rule
     token used throughout the site), so the previously-empty space
     does the same job the caption always did, just with more room and
     more presence. No new color, no new motion.

     Bug fix, same day, caught by the author live: .anatomy__reading
     originally had only max-inline-size (a cap, not a fixed width), so
     its flex-item box shrank to fit whatever text was currently inside
     it -- the long default sentence versus a short region name like
     "Abs" are very different widths. Since .anatomy__stage centers the
     row as a whole, that width change re-centered the entire row on
     every hover/pin change, visibly shifting the figure left/right.
     Fixed by making inline-size a fixed 16rem (not a max) plus
     flex-shrink: 0, so the panel's box never resizes -- only the text
     inside it changes.

     Balance fix, same day, author's own visual read after the twitch
     fix shipped: centering the figure+pane group as one flex unit
     (justify-content: center) centers their combined width, not the
     figure itself -- since the figure (18rem) and the pane (16rem plus
     its gap) aren't the same width, the group's center and the figure's
     own center are different points, so the figure visibly sat left of
     the heading/toggle above it, with a much larger empty stretch past
     the pane on the right than on the figure's left. Switched to a
     3-column grid (1fr auto 1fr) instead of centering the flex group:
     the middle "auto" column always lands at the true center of the
     row regardless of what the flanking columns hold, so the figure
     now lines up with the heading/toggle above it. The pane sits at
     the start of the third column; the first column stays empty,
     giving the layout the same total flanking-space budget on each
     side even though only one side carries content.

     Scale-up, same day, author's own visual read after the centering
     fix: correctly aligned but still reading sparse -- the figure and
     pane together were a thin strip in a lot of empty canvas. Two
     scoped bumps, both only active at this same 90rem+ breakpoint (no
     effect on tablet/mobile, which never reach this media query at
     all): .anatomy__figures's cap raised from 18rem to 26rem, and
     .anatomy__reading's font-size raised from --text-sm to --text-md
     (its inline-size widened from 16rem to 18rem to give the larger
     text room). No new copy was added -- the pane still only ever
     shows a region name or the existing idle sentence; per this
     project's standing rule not to assume content, an actual added
     description per region would need the author's own material, not
     invented text.

     Real bug caught before shipping the scale-up: raising
     .anatomy__figures's *max*-inline-size to 26rem had no visible
     effect at all -- confirmed live via a temporary CSSOM check, not
     assumed. Root cause: .anatomy__figure sets inline-size: 100%, a
     percentage, and .anatomy__stage is a CSS Grid with the figure in
     an auto-sized column. Grid's auto-track sizing pass needs a
     definite containing-block width to resolve that percentage before
     it can size the column, and max-inline-size is a constraint, not a
     definite value -- so the SVG fell back to the browser's default
     replaced-element size (300x600) regardless of what the cap said.
     Same live check confirmed a definite inline-size resolves the
     circularity correctly. Fixed by changing max-inline-size to a
     fixed inline-size: 26rem here (still scoped to this same 90rem+
     query only -- the base rule below, used by every viewport under
     90rem, is untouched and still uses max-inline-size: 18rem, which
     works fine there since that context is plain block flow, not a
     grid auto-track).

     Second bug, same fix, caught the same way after the first upload:
     the figure still rendered at 288px even with inline-size: 26rem
     in place. The base .anatomy__figures rule's max-inline-size: 18rem
     has no media-query scope of its own -- it's unconditional, so it
     was still active (and still capping the used inline-size, since
     max-inline-size always wins over inline-size per spec) at every
     viewport including 90rem+. Live CSSOM check confirmed adding
     max-inline-size: none here, alongside inline-size: 26rem, produces
     the correct 416x832px figure. Both declarations now sit together
     in this same 90rem+-only block.

     Size call, same day, author's own visual read once 26rem was live:
     416x832px is correct per this fix's own math, but at 832px tall it
     read as nearly the full height of a typical 900px browser window --
     more dominant than the section needed once the width problem this
     whole pass exists to solve was actually fixed. Dialed back to
     inline-size: 22rem (352x704px) -- still a real, deliberate size
     increase over the original 18rem/288px, just not one that competes
     with the viewport's own height. */
  @media (min-width: 90rem) {
    .anatomy__stage {
      display: grid;
      grid-template-columns: 1fr auto 1fr;
      align-items: center;
      gap: var(--space-8);
    }

    .anatomy__figures {
      grid-column: 2;
      justify-self: center;
      margin-inline: 0;
      max-inline-size: none;
      /* Session 27: was a fixed 22rem regardless of viewport, so on
         screens wider than the 1440px this was tuned at (1920, 2560) the
         figure stayed the same absolute size while the section's own
         empty gutters grew even wider. clamp() keeps the same 22rem
         already reviewed as the floor (protects the 1440px case this was
         tuned against) and lets it grow slowly past that, capped at
         30rem so it never approaches the height concern that got 26rem
         dialed back earlier in this same component's history. */
      inline-size: clamp(22rem, 18vw, 30rem);
    }

    .anatomy__reading {
      grid-column: 3;
      justify-self: start;
      text-align: start;
      margin-block-start: 0;
      inline-size: 18rem;
      border-inline-start: var(--border-accent);
      padding-inline-start: var(--space-4);
      /* Override: name renders at --text-md here (see .anatomy__reading-name
         below), a bigger font than the base rule's reservation assumes, so
         the container needs its own larger reservation to match -- same
         calc shape as the base rule, just with --text-md standing in for
         the name line's font-size.
         Session 28: trains portion raised 2 -> 4 lines here specifically
         (not just to 3, matching the base rule) -- this 18rem panel is
         narrower than the mobile viewport itself (measured 255px content
         width vs. 326px at 390px mobile), so the same 19 authored strings
         wrap further here. Measured directly, not assumed: the longest
         (trapezius, after trimming to fit) reaches exactly 4 lines at this
         width; nothing needs 5. */
      min-block-size: calc(var(--text-md) * var(--leading-snug) + var(--space-1) + 4 * var(--text-sm) * var(--leading-snug));
    }

    .anatomy__reading-name {
      font-size: var(--text-md);
    }

    /* Session 28: companion to the container override above -- the child
       element's own reservation has to move with it or the container's
       extra room does nothing (the child would still cut off/reflow at
       its own smaller 3-line minimum). */
    .anatomy__reading-trains {
      min-block-size: calc(4 * var(--text-sm) * var(--leading-snug));
    }
  }

  /* Try It card (design consultation session, 2026-08-23). Pin-only reveal
     (js/anatomy.js) -- deliberately outside .anatomy__stage, so nothing
     here shares layout math with that grid's own hard-won centering fix
     above. Sits in normal block flow at every breakpoint, centered under
     the stage via margin-inline: auto, rather than joining the >=90rem
     grid as a fourth tracked item.

     .anatomy__try[hidden]: the same lesson this codebase already paid for
     once (.form-group[hidden], Phase 22a) -- the browser's own
     `[hidden] { display: none }` is a low-priority UA default, and any
     author rule that sets `display` on the same element beats it
     regardless of selector specificity. Declaring `display: flex` below
     on the bare .anatomy__try class would silently defeat the `hidden`
     attribute js/anatomy.js sets exactly the way .form-group's own
     `display: flex` once did. This rule pre-empts that rather than
     waiting to rediscover it. */
  .anatomy__try {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    max-inline-size: 40rem;
    margin-inline: auto;
    margin-block-start: var(--space-6);
    padding: var(--space-4);
    border: var(--border-accent);
  }

  .anatomy__try[hidden] {
    display: none;
  }

  .anatomy__try-eyebrow {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-widest);
    text-transform: uppercase;
    color: var(--color-accent);
    text-align: center;
  }

  .anatomy__try-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-3);
  }

  @media (min-width: 30rem) {
    .anatomy__try-grid {
      grid-template-columns: 1fr 1fr;
    }
  }

  .anatomy__try-label {
    display: block;
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
    margin-block-end: var(--space-1);
  }

  .anatomy__try-text {
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
    color: var(--color-text-secondary);
  }

  .anatomy__try-link {
    text-align: center;
  }

  /* Members-account tease (same session). Reuses .disclaimer-note's own
     link treatment (color + hover) rather than a third near-identical
     pair of rules -- this is the only property that differs from plain
     muted body text, so it's declared directly rather than extracted. */
  .anatomy__account-tease {
    text-align: center;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    margin-block-start: var(--space-2);
  }

  .anatomy__account-tease a {
    color: var(--color-accent);
    transition: var(--transition-color);
  }

  .anatomy__account-tease a:hover {
    color: var(--color-accent-hover);
  }

  /* ─────────────────────────────────────────────
     REGION PICKER (Muscle Balance, dashboard Log an Entry dialog,
     Session-type entries only -- js/dashboard.js's syncEntryTypeFields).

     Deliberately NOT a copy of The Form's own .anatomy__figures/
     .anatomy__toggle sizing -- those are tuned for a full homepage
     section (large decorative bloom, 18-30rem figure) and would
     overwhelm a dialog already holding six other fields. What IS reused,
     for free, with zero duplication: .anatomy__figure/.anatomy__region/
     .anatomy__outline/.anatomy__figure-set are declared globally above,
     not scoped under .anatomy, so the base fill, hover-to-gold feedback,
     and show/hide-by-.is-active mechanics all apply here automatically.
     This block only adds what's genuinely new for this compact,
     multi-select context. ───────────────────────────────────────────── */

  .region-picker {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-2);
  }

  .region-picker__toggles {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-3);
  }

  .region-picker__toggle-group {
    display: flex;
    gap: var(--space-1);
  }

  /* Same active-state recipe as .anatomy__toggle-btn[aria-pressed="true"]
     (filled gold), a new selector rather than reusing that class directly
     because .anatomy__toggle-btn's own margin-block: var(--space-6) is
     sized for a single centered row under a full section heading -- two
     of these stacked/wrapped side by side in a dialog need much tighter
     spacing, which .region-picker__toggles above already supplies via a
     plain flex gap instead. */
  .region-picker__toggle-btn[aria-pressed="true"] {
    background-color: var(--btn-accent-bg);
    color: var(--btn-accent-text);
    border-color: var(--btn-accent-bg);
  }

  /* Sized at 13rem after real click-testing (Playwright) showed 9rem was
     too small for reliable tapping: several regions (chest in particular)
     have irregular, non-convex shapes that don't fill their own bounding
     box, so a tap anywhere in that box can land on .anatomy__outline
     underneath instead of the intended region. The Form's own homepage
     figure (22-30rem) has never had this complaint through many sessions
     of iteration -- the interaction model itself is proven, this widget's
     original size just wasn't. 13rem is a deliberate middle ground: big
     enough for real region shapes to have a real tappable area, still
     compact enough to sit comfortably in a dialog alongside six other
     fields (the .modal container already scrolls past 90vh if needed). */
  .region-picker__figures {
    max-inline-size: 13rem;
  }

  .region-picker__selected {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    text-align: center;
    /* Reserves one line's height always, so toggling between the idle
       prompt and a real selection (or between a one-region and a
       several-region selection) never shifts the Save/Cancel row below
       it -- same jitter-prevention principle as .anatomy__reading-trains'
       own min-block-size, just a single fixed line here since this
       widget's summary text is plain and short by design, not a full
       authored sentence that might wrap. */
    min-block-size: calc(var(--text-sm) * var(--leading-snug));
  }

  /* Persistent multi-select highlight. Visually identical to The Form's
     own .is-pinned (var(--color-accent), !important to win over the
     :hover rule's own !important) but a distinct class name -- .is-pinned
     is The Form's single-pin reading-mode state, and reusing it here
     would conflate two different interaction models under one name even
     though the two rules would look the same today. */
  .anatomy__region.is-selected,
  .anatomy__region.is-selected path {
    fill: var(--color-accent) !important;
  }

  /* ─────────────────────────────────────────────
     PHOTOGRAPHIC TEXTURE (design-analysis session, 2026-08-23)
     Low-opacity, graded section backgrounds -- real photography used as
     atmosphere/macro texture behind specific sections, never as a
     featured or centerpiece image. See img/texture/SOURCES.md for
     licensing and provenance; docs/photo-treatment-demo.html is the
     reference demo the grading values below were judged against before
     being wired into a real page -- that demo runs its comparison panels
     far more visible than this; opacity here is deliberately much lower,
     meant to be felt rather than seen, same restraint already governing
     --hero-smoke-opacity and --rhythm-opacity.

     .section-texture + a --modifier per photo, same "extract into a
     shared class once reused a second time" rule this codebase already
     follows for .emblem/.cta-group -- only the background-image differs
     per placement, everything else (grading, opacity, stacking) is
     identical across every use. Modifiers are added one at a time, only
     when actually wired to a real section -- not pre-registered for all
     six sourced photos ahead of need.

     isolation: isolate + z-index: var(--z-below) on ::before is the same
     technique already used by .anatomy__figures::before and
     .standing__chart::before -- scopes the pseudo-element's negative
     z-index to this component only, so it can never paint behind an
     unrelated sibling outside it.

     NOT used on .hero -- .hero::before and .hero::after are both already
     spoken for (the light bloom and the vignette), so a texture layer
     there needs an actual markup addition (a real element, not a
     pseudo-element), not this pattern. Deferred, flagged rather than
     forced into a conflicting selector.
     ───────────────────────────────────────────── */

  .section-texture {
    position: relative;
    isolation: isolate;
  }

  .section-texture::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--z-below);
    pointer-events: none;
    background-size: cover;
    background-position: center;
    /* Grayscale removes the source photo's own color story; brightness/
       contrast push it into this site's near-black register; sepia +
       hue-rotate lean the remaining tone toward gold/purple instead of
       neutral grey -- same recipe proven in docs/photo-treatment-demo.html,
       just at a fraction of that demo's opacity. */
    filter: grayscale(88%) brightness(0.45) contrast(1.15) sepia(10%) hue-rotate(210deg);
    opacity: 0.08;
  }

  .section-texture--bark::before {
    background-image: url('../img/texture/bark-macro.jpg');
  }

  .section-texture--water::before {
    background-image: url('../img/texture/water-ripples.jpg');
  }

  .section-texture--fire::before {
    background-image: url('../img/texture/fire-embers.jpg');
  }

  .section-texture--stone::before {
    background-image: url('../img/texture/stone-macro.jpg');
  }

  .section-texture--mountain::before {
    background-image: url('../img/texture/mountain.jpg');
  }

  .section-texture--barbell::before {
    background-image: url('../img/texture/barbell-floor.jpg');
  }

  /* Move section (Session 63, wired in a later session) -- the one
     section-wide gap flagged in this file's own header comment above:
     the copy names squats/pushups directly and had zero photographic
     treatment before this. trail-runner-dusk.jpg (img/texture/SOURCES.md
     has full provenance) uses the same grayscale/brightness/contrast/
     sepia/hue-rotate recipe as every other .section-texture placement,
     but NOT the shared 0.08 felt baseline -- built and compared both
     ways against a live rendering (1440px and 390px) before deciding, per
     session-start.md's own framing, rather than picked in the abstract.
     At 0.08 the runner read as an ambient shadow, easy to miss entirely.
     This is the one section on the page whose own copy is a direct
     command to move, not a section merely describing an idea -- a real
     photograph of a person mid-stride is content that supports that
     command, not just atmosphere behind it, and it earns the same felt-
     before-seen-but-let-it-be-seen treatment this codebase already
     established once for signals.php's header band (see that class's
     own comment). Matches that existing 0.16 value exactly rather than
     inventing a third opacity tier -- confirmed at 0.16 the runner and
     dusk skyline read clearly beside the Rhythm motif at desktop width,
     and the section-eyebrow/heading/body copy stayed fully legible at
     both viewports tested, no separate tint/scrim layer needed beyond
     the filter's own brightness(0.45)/contrast(1.15). */
  .section-texture--trail-runner::before {
    background-image: url('../img/texture/trail-runner-dusk.jpg');
    opacity: 0.16;
  }

  /* ─────────────────────────
     NINETY PULSE STRIP (design-review, 2026-08-23)
     Compact three-month grid+line strip for index.php's Ninety teaser,
     sitting directly under the teaser heading -- "behind/near the
     headline," per the design brief that led to this build. Server-
     rendered static markup (includes/ninety-pulse.php), not JS-generated:
     the only motion here is a pure-CSS idle opacity/glow breathe, so
     there is no reason to build this at runtime -- same "precomputed
     static SVG" pattern already established by includes/rhythm.php.

     Each cell's fill level (a 1-5 opacity ladder, not a continuous
     percentage) and each month's line path were computed once from a
     fixed relative-effort data model and baked directly into the markup,
     the same way Rhythm's own sine-sampled path is precomputed rather
     than generated live. Gold values are the same literal hex stops
     already repeated in every crown-badge gradient on this site
     (#8A6F45/#C8A97E/#F2E3BE), not tokenized -- matching that existing
     convention rather than introducing a new one.
     ───────────────────────── */

  .ninety-pulse {
    display: flex;
    flex-wrap: wrap;
    gap: 1.1rem;
    margin-block: 1.5rem 1.75rem;
  }

  .ninety-pulse__month {
    flex: 1 1 7.5rem;
  }

  .ninety-pulse__label {
    display: block;
    font-family: var(--font-heading);
    font-size: 0.55rem;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    margin-bottom: 0.4rem;
  }
  .ninety-pulse__month--m0 .ninety-pulse__label { color: #8A6F45; }
  .ninety-pulse__month--m1 .ninety-pulse__label { color: #C8A97E; }
  .ninety-pulse__month--m2 .ninety-pulse__label { color: #F2E3BE; }

  .ninety-pulse__body {
    position: relative;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: 3px;
    padding: 0.5rem;
    animation: ninety-pulse-breathe var(--rhythm-period) ease-in-out infinite;
  }

  /* Staggered per month so the three read as one signal moving through
     time rather than three synced blinks -- same clock, offset phase. */
  .ninety-pulse__month--m0 .ninety-pulse__body { animation-delay: 0s; }
  .ninety-pulse__month--m1 .ninety-pulse__body { animation-delay: 1s; }
  .ninety-pulse__month--m2 .ninety-pulse__body { animation-delay: 2s; }

  @keyframes ninety-pulse-breathe {
    0%, 100% { box-shadow: 0 0 0 rgba(200, 169, 126, 0); }
    50% { box-shadow: 0 0 14px rgba(200, 169, 126, 0.22); }
  }

  @media (prefers-reduced-motion: reduce) {
    .ninety-pulse__body { animation: none; }
  }

  /* Definite height on the grid's own wrapper, not on .ninety-pulse__body
     (whose height is auto -- padding plus content). A percentage height
     only resolves against a DEFINITE containing-block height; against
     auto it silently falls through to the SVG's own replaced-element
     sizing, which derives height from width via the viewBox's intrinsic
     1:1 ratio instead of the intended thin strip. Confirmed as a real,
     reproducible bug via direct browser_evaluate measurement during the
     design-review demo pass, not a defensive guess -- full root-cause
     trace lives in docs/the-ninety-three-months-visual-demo.css's own
     comment on the equivalent .mini-cal__grid-wrap rule. */
  .ninety-pulse__grid-wrap {
    position: relative;
    height: 22px;
  }

  .ninety-pulse__grid {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    grid-template-rows: repeat(2, 1fr);
    gap: 2px;
    height: 22px;
  }

  .ninety-pulse__cell {
    border-radius: 1px;
    opacity: 0;
  }
  .ninety-pulse__month--m0 .ninety-pulse__cell { background: #8A6F45; }
  .ninety-pulse__month--m1 .ninety-pulse__cell { background: #C8A97E; }
  .ninety-pulse__month--m2 .ninety-pulse__cell { background: #F2E3BE; }

  .ninety-pulse__cell.lvl-1 { opacity: 0.25; }
  .ninety-pulse__cell.lvl-2 { opacity: 0.45; }
  .ninety-pulse__cell.lvl-3 { opacity: 0.65; }
  .ninety-pulse__cell.lvl-4 { opacity: 0.85; }
  .ninety-pulse__cell.lvl-5 { opacity: 1; }
  .ninety-pulse__cell.is-rest {
    background: transparent !important;
    border: 1px solid var(--color-border);
    opacity: 1;
  }

  .ninety-pulse__line {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
  }
  .ninety-pulse__line path {
    fill: none;
    stroke-width: 1.2;
    stroke-linejoin: round;
    stroke-linecap: round;
    opacity: 0.85;
  }
  .ninety-pulse__month--m0 .ninety-pulse__line path { stroke: #8A6F45; }
  .ninety-pulse__month--m1 .ninety-pulse__line path { stroke: #C8A97E; }
  .ninety-pulse__month--m2 .ninety-pulse__line path { stroke: #F2E3BE; }

  /* ─────────────────────────
     NINETY CALENDARS (design-review, 2026-08-23)
     Three abstract 7x4 day-grids in shallow left-vanishing-point
     perspective, for the-ninety.php's own intro section -- "Three
     Calendars, Perspective, Static" from the design-review demo. Purely
     static: the-ninety.php carries zero motion anywhere else on the page
     by deliberate existing convention (see that file's own header
     comment), and this component matches that rather than introducing
     the page's first animated element. Server-rendered, precomputed
     markup (includes/ninety-calendars.php), same reasoning as
     .ninety-pulse above.

     Depth reads left-to-right as approaching the viewer: month one
     (furthest, most rotated, dimmest, smallest) recedes toward the
     vanishing point pinned at the row's left edge; month three (nearest,
     upright, full size, full opacity) sits forward, matching "now."
     ───────────────────────── */

  .ninety-calendars {
    display: flex;
    flex-wrap: wrap;
    gap: 2.75rem;
    align-items: flex-end;
    justify-content: center;
    perspective: 1400px;
    perspective-origin: 0% 55%;
    margin-block: 2.5rem;
  }

  .ninety-calendars__card {
    flex: 0 0 auto;
    transform-style: preserve-3d;
    transform-origin: left center;
  }
  .ninety-calendars__card--m0 { transform: rotateY(32deg) scale(0.8); opacity: 0.55; }
  .ninety-calendars__card--m1 { transform: rotateY(18deg) scale(0.9); opacity: 0.8; }
  .ninety-calendars__card--m2 { transform: rotateY(0deg) scale(1); opacity: 1; }

  .ninety-calendars__label {
    display: block;
    font-family: var(--font-heading);
    font-size: 0.6rem;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    text-align: center;
    margin-bottom: 0.75rem;
  }
  .ninety-calendars__card--m0 .ninety-calendars__label { color: #8A6F45; }
  .ninety-calendars__card--m1 .ninety-calendars__label { color: #C8A97E; }
  .ninety-calendars__card--m2 .ninety-calendars__label { color: #F2E3BE; }

  .ninety-calendars__grid-wrap {
    position: relative;
    width: 168px;
    height: 150px;
  }

  .ninety-calendars__grid {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    grid-template-rows: repeat(4, 1fr);
    gap: 3px;
    width: 100%;
    height: 100%;
  }

  .ninety-calendars__cell {
    border-radius: 1px;
    border: 1px solid transparent;
    opacity: 0;
  }
  .ninety-calendars__card--m0 .ninety-calendars__cell { background: #8A6F45; border-color: #8A6F45; }
  .ninety-calendars__card--m1 .ninety-calendars__cell { background: #C8A97E; border-color: #C8A97E; }
  .ninety-calendars__card--m2 .ninety-calendars__cell { background: #F2E3BE; border-color: #F2E3BE; }

  .ninety-calendars__cell.lvl-1 { opacity: 0.22; }
  .ninety-calendars__cell.lvl-2 { opacity: 0.42; }
  .ninety-calendars__cell.lvl-3 { opacity: 0.62; }
  .ninety-calendars__cell.lvl-4 { opacity: 0.82; }
  .ninety-calendars__cell.lvl-5 { opacity: 1; }
  .ninety-calendars__cell.is-rest { background: transparent !important; opacity: 0.5; }

  .ninety-calendars__line {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
  }
  .ninety-calendars__line path {
    fill: none;
    stroke-width: 1.6;
    stroke-linejoin: round;
    stroke-linecap: round;
  }
  .ninety-calendars__card--m0 .ninety-calendars__line path { stroke: #8A6F45; }
  .ninety-calendars__card--m1 .ninety-calendars__line path { stroke: #C8A97E; }
  .ninety-calendars__card--m2 .ninety-calendars__line path { stroke: #F2E3BE; }

  /* ─────────────────────────────────────────────
     FEATURED CARD (design-analysis session, 2026-08-25)
     Glass surface, structural purple as an actual whisper, brushed-
     graphite nap, flat-gold chiseled inlay. Reserved for a small,
     deliberately featured set of surfaces -- NOT applied to the twelve
     discipline cards. Full exploration and the reasoning against a
     three-of-twelve "signature" split (it would quietly contradict
     Doctrine's own "all pillars work in tandem, no hierarchy" claim) live
     in design_analysis/depth-concept-glass-velvet-gold-demo.html --
     read that file before touching this one, the reasoning isn't
     repeated here.

     First use: The Ninety's homepage teaser, wrapping .teaser-inner. The
     flagship free program is genuinely different in kind from the twelve
     equally-weighted disciplines, so giving it more visual weight doesn't
     introduce a false hierarchy the way elevating three disciplines
     would. Second candidate, once it exists: a paid coaching surface
     (business.md, Stream 2). Named generically (not .ninety-*) since a
     second real use is already anticipated, same reasoning already
     applied to .emblem and .cta-group elsewhere in this file.

     Two-golds rule (architecture.md, Brand) respected throughout: the
     gradient gold stays confined to the existing "90" emblem badge, which
     already carried it before this change. The inlay below is the flat
     --color-accent token, same as every button and link on the site --
     nothing here adds gradient gold to text or UI.

     Opacity is tuned more conservatively than the design_analysis demo's
     small cards (which could afford to go quite translucent because each
     had its own tight text scrim). This card wraps a whole teaser --
     heading, the ninety-pulse strip, two paragraphs, a CTA button -- with
     no per-element scrim, so the glass itself has to stay legible-enough
     on its own. Flagged honestly: this was tuned by eye against a local
     render, not measured -- a real contrast check against the actual
     rendered backdrop (screenshot + sampled pixel color, not just visual
     review) should happen before this is treated as finished, not just
     reviewed. */
  .ninety-teaser {
    position: relative;
    isolation: isolate;
  }

  /* Bloom pair, reusing the exact rgb(200 169 126 / ...) recipe already
     used behind the hero, The Form, and Your Standing's chart -- gives
     the featured card's glass something real to show through, since this
     section previously had no background texture of any kind. No new
     color, no new photo asset. Two offset sources (gold + a muted purple),
     not one -- confirmed in the design_analysis exploration that a single
     centered bloom reads as flat once blurred; two offset sources give
     the glass genuine tonal variation to reveal.

     inset: 0, NOT a negative inset. Author-reported bleed (screenshot,
     2026-08-25): the bloom visibly spilled past this section's own
     bottom edge, through the tri-divider, into the top of The Form
     section below it. Root cause: this was first written with
     inset: -10%, copying the small-inner-element bloom pattern used
     elsewhere in this file (.anatomy__figures::before, inset: -15%;
     .standing__chart::before, inset: -10%) -- correct there because those
     are small boxes with real margin around them inside a section, so a
     bit of overflow just enlarges the glow locally. Applied to a full
     <section> sitting flush against the next one, the same negative
     inset pushes the glow past the section's own box into whatever
     follows. Every OTHER full-section bloom in this file (.hero::before,
     .error-atmosphere::before) already uses inset: 0 for exactly this
     reason -- this was the one inconsistent copy, now corrected to match. */
  .ninety-teaser::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--z-below);
    pointer-events: none;
    background: radial-gradient(
      55% 55% at 30% 40%,
      rgb(200 169 126 / 0.14) 0%,
      rgb(200 169 126 / 0.05) 45%,
      transparent 75%
    );
  }

  .ninety-teaser::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--z-below);
    pointer-events: none;
    background: radial-gradient(
      45% 60% at 80% 65%,
      color-mix(in srgb, var(--color-accent-purple) 30%, transparent) 0%,
      transparent 70%
    );
  }

  .featured-card {
    position: relative;
    isolation: isolate;
    padding: var(--space-8);
    border-radius: var(--radius-sm);
    background: linear-gradient(
      165deg,
      color-mix(in srgb, color-mix(in srgb, var(--primitive-gray-950) 88%, var(--color-accent-purple) 12%) 78%, transparent) 0%,
      color-mix(in srgb, var(--primitive-gray-950) 45%, transparent) 100%
    );
    backdrop-filter: blur(20px) saturate(125%);
    -webkit-backdrop-filter: blur(20px) saturate(125%);
    border: 1px solid color-mix(in srgb, var(--color-accent) 45%, transparent);
    transition: border-color var(--duration-base) var(--ease-out);
  }

  .featured-card:hover {
    border-color: color-mix(in srgb, var(--color-accent) 70%, transparent);
  }

  /* Brushed-graphite nap -- re-hued from the demo's original purple
     velvet (read too soft/feminine, author feedback) to the existing
     border-gray token. Same crossed repeating-gradient technique, no new
     color either way. */
  .featured-card::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background:
      repeating-linear-gradient(
        115deg,
        color-mix(in srgb, var(--primitive-gray-700) 32%, transparent) 0px,
        color-mix(in srgb, var(--primitive-gray-700) 32%, transparent) 1px,
        transparent 1px,
        transparent 3px
      ),
      repeating-linear-gradient(
        25deg,
        color-mix(in srgb, var(--primitive-white) 4%, transparent) 0px,
        transparent 1px,
        transparent 4px
      );
    mix-blend-mode: overlay;
    opacity: 0.55;
  }

  /* Chiseled inlay -- inset "groove" line, not a single border, so it
     reads as cut into the surface rather than a soft outline. Flat gold
     only, never the gradient. */
  .featured-card::after {
    content: "";
    position: absolute;
    inset: 7px;
    z-index: 0;
    pointer-events: none;
    border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
  }

  .featured-card > * {
    position: relative;
    z-index: 1;
  }

  .discipline-card--depth {
    position: relative;
    isolation: isolate;
    background: linear-gradient(
      165deg,
      color-mix(in srgb, color-mix(in srgb, var(--primitive-gray-950) 88%, var(--color-accent-purple) 12%) 78%, transparent) 0%,
      color-mix(in srgb, var(--primitive-gray-950) 45%, transparent) 100%
    );
    backdrop-filter: blur(10px) saturate(120%);
    -webkit-backdrop-filter: blur(10px) saturate(120%);
    border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);
  }

  .discipline-card--depth::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background:
      repeating-linear-gradient(
        115deg,
        color-mix(in srgb, var(--primitive-gray-700) 28%, transparent) 0px,
        color-mix(in srgb, var(--primitive-gray-700) 28%, transparent) 1px,
        transparent 1px,
        transparent 3px
      ),
      repeating-linear-gradient(
        25deg,
        color-mix(in srgb, var(--primitive-white) 4%, transparent) 0px,
        transparent 1px,
        transparent 4px
      );
    mix-blend-mode: overlay;
    opacity: 0.45;
  }

  .discipline-card--depth::after {
    content: "";
    position: absolute;
    inset: 6px;
    z-index: 0;
    pointer-events: none;
    border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
  }

  .discipline-card--depth > * {
    position: relative;
    z-index: 1;
  }

  /* Depth cards get their own hover step, not the plain .discipline-card
     :hover rule above. That rule flips border-color to solid, full-
     opacity --color-accent -- correct for the flat cards it was written
     for (disciplines.php's listing page still uses bare .discipline-card
     with no --depth modifier and should still get exactly that), but on
     a depth card it only ever reaches the OUTER edge: the inset inlay
     line is a separate ::after element with its own fixed 35% opacity
     that the flat-card hover rule never touches.
     Found live (Session 49, mobile bug report): on hover the outer ring
     jumps to solid gold while the inset ring stays dim, and next to the
     now-vivid outer ring the inset one reads as pale/whitish rather than
     gold. Worse on touch devices specifically, where :hover commonly
     sticks after a tap/scroll since these cards are plain <li>, not
     links or buttons -- so what looked like a rendering bug on a phone
     was actually two hover rules from two different sessions never
     reconciled against each other.
     Fixed by giving depth cards their own hover step on BOTH borders
     together: the outer ring reuses .featured-card:hover's own 70% value
     (same glass-card family, one hover language instead of two), and the
     inset ring steps up proportionally (35% -> 60%) so the two rings
     move as a matched pair instead of one jumping past the other.
     .discipline-card.discipline-card--depth:hover (two classes) beats
     the plain .discipline-card:hover rule on specificity alone (0,3,0
     against 0,2,0) rather than depending on source order -- same
     discipline this file already applies elsewhere, see
     .section-header > .section-eyebrow's own comment. */
  .discipline-card.discipline-card--depth:hover {
    border-color: color-mix(in srgb, var(--color-accent) 70%, transparent);
  }

  .discipline-card--depth:hover::after {
    border-color: color-mix(in srgb, var(--color-accent) 60%, transparent);
  }

  /* ─────────────────────────────────────────────
     PAUSED, 2026-08-25 (author feedback, same session): reverted from
     index.php's #the-sage section. The card's own opaque-ish glass surface
     and gold frame competed with and muted the fire-embers texture that
     gave this section its identity, and the section read worse for it --
     "lost the feel without the burnt fire." Not deleted: this section
     needs its own dedicated reassessment (how depth and the existing fire
     register combine, if at all, for The Sage specifically) rather than
     reusing Ninety's recipe unexamined a second time. These two rules are
     inert with no `sage--depth` class in the markup; safe to leave until
     that reassessment happens. */
  .sage--depth {
    position: relative;
    isolation: isolate;
  }

  .sage--depth::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--z-below);
    pointer-events: none;
    background: radial-gradient(
      55% 55% at 25% 45%,
      rgb(200 169 126 / 0.14) 0%,
      rgb(200 169 126 / 0.05) 45%,
      transparent 75%
    );
  }

  .sage--depth::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--z-below);
    pointer-events: none;
    background: radial-gradient(
      45% 60% at 78% 60%,
      color-mix(in srgb, var(--color-accent-purple) 30%, transparent) 0%,
      transparent 70%
    );
  }

  /* ─────────────────────────────────────────────
     MUSCLE BALANCE (dashboard, this session -- follow-on to the Region
     Picker above). 90-day rolling heatmap over the same 23-region
     taxonomy, matching Your Standing's own window (author's explicit
     call -- one consistent definition of "recent" on this page rather
     than two competing ones).

     Figures are NOT re-embedded here. js/dashboard.js's
     initMuscleBalance() clones the same four <svg> nodes the Log an
     Entry dialog's own region-picker already ships in the DOM, rather
     than shipping a second ~115KB copy of structurally identical markup
     on every dashboard load -- The Form's own homepage section made the
     same call in the other direction (it deliberately left back-view
     figures OUT of its embed to control weight); reusing an
     already-shipped embed here is this feature's version of the same
     discipline, not a new rule invented for it.

     Fill intensity is computed entirely server-side: dashboard.php emits
     a small request-scoped <style> block, attribute-selector keyed to
     data-slug, so the cloned figures' own markup is never touched, only
     referenced -- no client-side color math, matching Your Standing's
     own "no charting library, server computes the geometry" convention.

     --mb-lvl-1 through --mb-lvl-5: a quantized 5-step ladder off ONLY
     the flat --color-accent (architecture.md's "gold has two jobs" rule
     -- the gradient gold stays reserved for the mark and badge frames,
     never data or UI), scaled relative to the member's own most-trained
     region in the window, not a fixed count -- same governing principle
     Your Standing's own comment states directly ("a shared scale is the
     only way this chart can actually show imbalance"). Matches the
     precedent already set by .ninety-pulse/.ninety-calendars' own 1-5
     opacity ladders rather than continuous per-region opacity math.
     Declared here (component-scoped) rather than in tokens.css: these
     are derived display values used by exactly one component in two
     places (the legend swatches below and dashboard.php's generated
     <style> block, both descendants of .muscle-balance), not a reusable
     design token -- same non-tokenized treatment .standing__fill's own
     inline color-mix() already gets. */
  .muscle-balance {
    padding-block-start: var(--space-8);
    border-block-start: var(--border-hairline);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-4);
    text-align: center;
    --mb-lvl-1: color-mix(in srgb, var(--color-accent) 20%, var(--color-surface-raised));
    --mb-lvl-2: color-mix(in srgb, var(--color-accent) 40%, var(--color-surface-raised));
    --mb-lvl-3: color-mix(in srgb, var(--color-accent) 60%, var(--color-surface-raised));
    --mb-lvl-4: color-mix(in srgb, var(--color-accent) 80%, var(--color-surface-raised));
    --mb-lvl-5: var(--color-accent);
  }

  .muscle-balance__toggle {
    display: flex;
    gap: 0;
  }
  .muscle-balance__toggle-btn:first-child {
    border-start-end-radius: 0;
    border-end-end-radius: 0;
  }
  .muscle-balance__toggle-btn:last-child {
    border-start-start-radius: 0;
    border-end-start-radius: 0;
    margin-inline-start: -1px;
  }
  .muscle-balance__toggle-btn[aria-pressed="true"] {
    background-color: var(--btn-accent-bg);
    color: var(--btn-accent-text);
    border-color: var(--btn-accent-bg);
  }

  .muscle-balance__figures {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-6);
  }
  .muscle-balance__figures[hidden] {
    display: none;
  }

  /* Cloned figures are pure display here, not a picker -- js/dashboard.js
     strips the region-picker's own role="button"/tabindex/aria-pressed
     from each clone (see that function's own comment), so the pointer
     cursor the shared .anatomy__region rule sets is misleading with no
     click handler attached. Reset scoped to this component only; the
     region-picker's own live copy inside the dialog is untouched. */
  .muscle-balance .anatomy__region {
    cursor: default;
  }

  .muscle-balance__figure-wrap {
    inline-size: min(45vw, 15rem);
  }
  @media (min-width: 30rem) {
    .muscle-balance__figure-wrap {
      inline-size: 14rem;
    }
  }

  .muscle-balance__legend {
    display: flex;
    align-items: center;
    gap: var(--space-1);
  }
  .muscle-balance__legend-label {
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }
  .muscle-balance__legend-swatch {
    inline-size: 1rem;
    block-size: 1rem;
    border-radius: var(--radius-sm);
  }
  .muscle-balance__legend-swatch--lvl-1 { background-color: var(--mb-lvl-1); }
  .muscle-balance__legend-swatch--lvl-2 { background-color: var(--mb-lvl-2); }
  .muscle-balance__legend-swatch--lvl-3 { background-color: var(--mb-lvl-3); }
  .muscle-balance__legend-swatch--lvl-4 { background-color: var(--mb-lvl-4); }
  .muscle-balance__legend-swatch--lvl-5 { background-color: var(--mb-lvl-5); }

  /* Region fill, corrected (live-testing bug, 2026-08-24): the first version
     of this feature emitted a per-request <style> block from dashboard.php
     with data-slug attribute selectors -- CSP's style-src 'self' (this
     site's own .htaccess, confirmed against a real console violation, not
     guessed) blocks ALL inline styling, and "inline" here turned out to mean
     more than declarative markup: confirmed via live testing that even
     el.style.setProperty(...) from JavaScript gets silently written into the
     DOM's style attribute but never actually rendered -- CSP blocks the
     CSSOM mutation's visual effect too, not just <style>/style="" written in
     markup. A real, external stylesheet rule (this block) plus a CLASS
     toggled via JS (unrestricted by style-src -- CSP governs style content,
     not which classes an element carries) is the only path that actually
     works under this CSP. dashboard.php now emits the computed levels as a
     plain data-mb-levels="..." JSON attribute (data-* attributes are not
     style content either); js/dashboard.js's initMuscleBalance() reads it
     and adds the matching .mb-fill-N class to each tagged region after
     cloning. Both selector forms (bare class and class + path) for the same
     reason this codebase's own .anatomy__region.is-selected rule already
     carries both -- the class lands on the <g> (or bare <path> for head/hair,
     per that region's own known structural difference), and matching the
     child path explicitly, not just relying on inheritance, is the same
     defensive convention already established here. */
  .mb-fill-1, .mb-fill-1 path { fill: var(--mb-lvl-1) !important; }
  .mb-fill-2, .mb-fill-2 path { fill: var(--mb-lvl-2) !important; }
  .mb-fill-3, .mb-fill-3 path { fill: var(--mb-lvl-3) !important; }
  .mb-fill-4, .mb-fill-4 path { fill: var(--mb-lvl-4) !important; }
  .mb-fill-5, .mb-fill-5 path { fill: var(--mb-lvl-5) !important; }

  /* Same quiet caption treatment as .standing__note (real numbers, never
     a score) -- declared as its own rule rather than extending that
     selector, since the two live far apart in this file and duplicating
     three declarations here is simpler and safer than an edit anchored
     on exact text in a distant, already-large block. */
  .muscle-balance__note {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    line-height: var(--leading-loose);
  }

  /* ──────────────────────────────────────────────────
     YOUR TIMELINE (this session) -- 90-day daily activity heatmap,
     GitHub-contributions style. Complements Your Standing (balance across
     pillars) and Muscle Balance (balance across body regions): neither
     shows change over time, this does. Deliberately NOT combined with a
     trend line -- author's own call, given this dashboard's established
     one-visual-plus-one-caption restraint (Standing, Muscle Balance), so
     this stays a single quiet grid rather than introducing a busier
     hybrid. The marketing-only grid+line component (.ninety-pulse, above)
     is a real precedent for the shape, not for the density -- that one is
     decorative and aria-hidden with fake data; this one carries a real
     member's own numbers and stays deliberately simpler as a result.

     Level (lvl-1..lvl-5) is driven by ENTRY COUNT per day, never
     duration_minutes -- Phase 23 already locked "duration is journaling,
     never scoring" (a 10-minute and a 60-minute entry earn the same
     single point on Your Standing); this chart extends that same
     principle rather than quietly reversing it for a different feature.
     Minutes still surface, just in the quiet caption line below the grid,
     same restrained treatment Standing's own note already gets.

     Local --timeline-lvl-* custom properties, scoped to .timeline itself
     rather than reusing Muscle Balance's --mb-lvl-*: those are declared on
     .muscle-balance, a sibling section, and CSS custom properties only
     cascade to descendants of the element that declares them -- a sibling
     section reading them would resolve to nothing. Same color-mix recipe,
     independently declared, so the two components stay decoupled (one can
     change without silently affecting the other) -- same reasoning
     .muscle-balance's own local vars are already scoped this way instead
     of living in tokens.css.
     ────────────────────────────────────────────────── */

  .timeline {
    padding-block-start: var(--space-8);
    border-block-start: var(--border-hairline);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3);
    text-align: center;
    --timeline-lvl-1: color-mix(in srgb, var(--color-accent) 20%, var(--color-surface-raised));
    --timeline-lvl-2: color-mix(in srgb, var(--color-accent) 40%, var(--color-surface-raised));
    --timeline-lvl-3: color-mix(in srgb, var(--color-accent) 60%, var(--color-surface-raised));
    --timeline-lvl-4: color-mix(in srgb, var(--color-accent) 80%, var(--color-surface-raised));
    --timeline-lvl-5: var(--color-accent);
    /* Base (mobile) cell size. Kept small and deliberately quiet -- author
       feedback on the visualization pass was explicit about not wanting
       density, so this should read as a fine texture, not a feature
       demanding attention. */
    --timeline-cell: 0.7rem;
  }

  @media (min-width: 48rem) {
    .timeline {
      --timeline-cell: 0.95rem;
    }
  }

  .timeline__timespan {
    display: flex;
    justify-content: space-between;
    inline-size: 100%;
    max-inline-size: 28rem;
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  /* overflow-x: auto as a structural safety net, not a fallback for a
     known problem -- 13 weeks at --timeline-cell's largest size measures
     well under any real viewport, but this codebase has hit more than one
     horizontal-overflow bug from a fixed-size element in a narrow
     container (the-ninety.php's CTA button, Session 29; Standing's own
     heading, Session 34) that nothing caught until a live screenshot did.
     Scoping the scroll to this wrapper means a future change to
     --timeline-cell or the window length can never break the page layout,
     only this one element's own content. */
  .timeline__grid-wrap {
    max-inline-size: 100%;
    overflow-x: auto;
  }

  .timeline__grid {
    display: grid;
    grid-auto-flow: column;
    /* Real bug, found via live testing against production, not caught by
       review: repeat(7, 1fr) requires the GRID CONTAINER to have a
       definite block-size for the fr unit to distribute against. This
       grid has none (its height is meant to come FROM the cells, not be
       imposed on them), and each .timeline__cell is an empty div with no
       intrinsic content height -- so every 1fr row resolved to its
       auto-minimum of 0px. Confirmed directly: getComputedStyle showed
       grid-template-rows as "0px 0px 0px 0px 0px 0px 0px", a 12px total
       grid height (nothing but the 6 row gaps), while the columns (never
       explicitly sized, so genuinely implicit and governed by
       grid-auto-columns below) computed correctly -- confirming the
       column sizing mechanism was never the problem, only the rows.
       grid-auto-rows below does NOT fix this: it only sizes IMPLICIT
       tracks, and these 7 rows are explicit (declared via
       grid-template-rows), so grid-auto-rows never applied to them at
       all. Fixed by giving the row tracks the same explicit, definite
       size as the columns get implicitly -- var(--timeline-cell) -- which
       removes the circular "row height needs container height needs row
       height" dependency entirely. */
    grid-template-rows: repeat(7, var(--timeline-cell));
    grid-auto-columns: var(--timeline-cell);
    gap: 2px;
    margin-inline: auto;
  }

  .timeline__cell {
    border-radius: 1px;
    background-color: var(--color-surface-raised);
  }

  /* Padding cells outside the real 90-day window (the partial edge weeks
     needed to keep every column a full 7-day week) -- fully transparent,
     never counted, never colored. */
  .timeline__cell.is-outside {
    background-color: transparent;
  }

  .timeline__cell.lvl-1 { background-color: var(--timeline-lvl-1); }
  .timeline__cell.lvl-2 { background-color: var(--timeline-lvl-2); }
  .timeline__cell.lvl-3 { background-color: var(--timeline-lvl-3); }
  .timeline__cell.lvl-4 { background-color: var(--timeline-lvl-4); }
  .timeline__cell.lvl-5 { background-color: var(--timeline-lvl-5); }

  /* Legend -- identical construction to .muscle-balance__legend, a second,
     independently-scoped copy rather than a shared class (see this
     component's own header comment on why the two --*-lvl-* custom
     property sets are deliberately not shared). */
  .timeline__legend {
    display: flex;
    align-items: center;
    gap: var(--space-1);
  }

  .timeline__legend-label {
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  .timeline__legend-swatch {
    inline-size: 1rem;
    block-size: 1rem;
    border-radius: var(--radius-sm);
  }

  .timeline__legend-swatch--lvl-1 { background-color: var(--timeline-lvl-1); }
  .timeline__legend-swatch--lvl-2 { background-color: var(--timeline-lvl-2); }
  .timeline__legend-swatch--lvl-3 { background-color: var(--timeline-lvl-3); }
  .timeline__legend-swatch--lvl-4 { background-color: var(--timeline-lvl-4); }
  .timeline__legend-swatch--lvl-5 { background-color: var(--timeline-lvl-5); }

  /* Same quiet caption treatment as .standing__note / .muscle-balance__note
     -- real numbers, never a score. Declared as its own rule rather than
     joining either existing selector, matching this file's own established
     precedent of one small rule per component even when the declarations
     are identical (see .muscle-balance__note's own comment). */
  .timeline__note {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    line-height: var(--leading-loose);
  }

  /* -----------------------------------------------
     SIGNAL FIRE (opt-in member directory + member-to-member
     messaging). signals.php, connections.php.
     ----------------------------------------------- */

  .section-heading--sm {
    font-size: var(--text-md);
  }

  .avatar {
    display: block;
    border-radius: 50%;
    object-fit: cover;
    flex-shrink: 0;
  }

  .avatar--sm { inline-size: 2.5rem; block-size: 2.5rem; }
  .avatar--md { inline-size: 4rem;   block-size: 4rem; }
  .avatar--lg { inline-size: 6rem;   block-size: 6rem; }

  .avatar--placeholder {
    background-color: var(--color-surface-raised);
    border: var(--border-hairline);
  }

  .signals-mine {
    margin-block: var(--space-8);
    padding: var(--space-4);
    background-color: var(--color-surface);
    border: var(--border-hairline);
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
  }

  .signals-mine__header {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
  }

  /* Row, not column -- avatar and its upload control read as one tight unit
     (2026-08-25 fix: was a column inside a row alongside .signals-mine__visibility,
     which stretched to fill leftover width at wide viewports since it's the
     only flex-grow sibling; a live check confirmed avatarBlockRect at 151px
     tall vs. visBlockRect at 56px tall, both pinned to align-items: flex-start,
     leaving ~95px of unexplained dead space under the shorter column -- the
     "doesn't look finished" report was this height mismatch, not a bug in
     either child alone). Visibility now gets its own full-width row below
     instead of competing for the same row's leftover space. */
  .signals-mine__avatar-block {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: var(--space-3);
    flex-wrap: wrap;
  }

  /* max-inline-size removed (2026-08-25 follow-up, real bug from a live
     author screenshot): the original 14rem cap applied to the WHOLE native
     control -- button plus the browser's own "No file chosen" caption
     text combined -- and was carried forward unexamined when the button
     itself was styled below. With the button alone eating most of 224px,
     the caption had almost no room left and the browser silently
     ellipsis-truncated it ("No fi...osen"), which is native rendering
     behavior for an undersized file input, not a text-overflow rule
     anywhere in this stylesheet. No cap needed: .signals-mine__avatar-block
     already wraps (flex-wrap: wrap), so an unconstrained-width file input
     simply wraps to its own line on a narrow viewport instead of
     overflowing. */

  .signals-mine__visibility {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
  }

  /* Same shape as .signals-mine__visibility above -- a select plus its own
     opt-in toggle, not folded into the existing visibility control, since
     being in the directory at all and showing a location are two
     separate decisions (schema-signal-fire-location.sql's own header
     comment). max-inline-size caps the native select so it doesn't
     stretch full-width on this page's wider single-column layout -- the
     longest real label ("Dallas-Fort Worth Metro") is the sizing floor,
     not an arbitrary round number. */
  .signals-mine__location {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
  }

  .signals-mine__location select {
    max-inline-size: 18rem;
  }

  .signals-mine__tags {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
  }

  /* Cap counters -- "Seeking: 3/6" / "Offering: 2/6" (schema-signal-tags-
     unified.sql; api/signals.php enforces the same 6-per-direction number
     server-side, a UX/scannability limit, not a security control -- see
     that file's own header comment). js/signals.js's updateCapCounters()
     recomputes and writes into these spans after every toggle; this rule
     is just the render target. */
  .signal-tag-caps {
    display: flex;
    gap: var(--space-4);
    flex-wrap: wrap;
  }

  .signal-tag-cap {
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  .signal-tag-list {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-3);
  }

  /* One unified catalog now (schema-signal-tags-unified.sql), not a
     category/pillar split -- a tag is no longer one toggle+message pair,
     it's a label (plus an optional loose pillar hint) over up to two
     independent direction rows (.signal-tag__direction), each with its
     own toggle+message, since a member can hold the same tag in both
     directions at once. A light card frame (border) replaces the old
     bare column shape -- with two possible direction rows now living
     inside one unit, a visible boundary keeps each tag legible as one
     thing at a glance. */
  .signal-tag {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    padding: var(--space-2);
    border: var(--border-hairline);
    border-radius: var(--radius-sm);
  }

  .signal-tag__head {
    display: flex;
    align-items: baseline;
    gap: var(--space-2);
    flex-wrap: wrap;
  }

  .signal-tag__label {
    font-size: var(--text-sm);
    font-weight: 600;
    color: var(--color-text-primary);
  }

  /* Loose, optional hint only -- NOT a grouping (see signals.php's own
     header comment on why pillar-per-tag subgrouping was dropped from
     this picker: forcing every activity into exactly one pillar makes a
     claim the platform has no basis for). Deliberately quieter than the
     label itself, so it reads as a footnote, not a second heading. */
  .signal-tag__pillar {
    font-size: 0.6875rem;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  .signal-tag__directions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-3);
  }

  .signal-tag__direction {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
  }

  .signal-tag__toggle[aria-pressed="true"] {
    background-color: var(--btn-accent-bg);
    color: var(--btn-accent-text);
    border-color: var(--btn-accent-bg);
  }

  /* Purple for Seeking, gold for Offering (the rule above already covers
     gold as the default pressed state) -- same two tokens the palette
     already reserves for this exact distinction, moved from a per-
     section class (.signal-tag-group--seeking, now removed) to a
     per-button modifier now that direction is a per-tag toggle, not a
     page-level grouping. */
  .signal-tag__toggle--seeking[aria-pressed="true"] {
    background-color: var(--color-accent-purple);
    border-color: var(--color-accent-purple);
    color: var(--color-text-primary);
  }

  /* Cap reached (js/signals.js's updateCapCounters()) -- a not-yet-held
     toggle in a direction already at 6 is disabled, not hidden, so the
     member can still see what exists, just can't add more without
     removing one first (the title attribute explains why on hover). */
  .signal-tag__toggle:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .signal-tag__message {
    inline-size: 14rem;
    font-size: var(--text-sm);
  }

  .signal-tag__message[hidden] {
    display: none;
  }

  /* Tag search + progressive disclosure (catalog expansion session:
     signal_tags grew from 13 to 55 rows across category/pillar,
     schema-signal-tags-expansion.sql). At that size a flat list is no
     longer scannable in either place tags render (this member's own
     tag picker above, the directory filter below) -- the standard fix
     for a large fixed-option catalog is the most likely options visible
     immediately, everything else reachable by expanding or typing.
     Reused identically by both "Your Tags" and the Filter section
     (signals.php), not duplicated per-section, since the pattern and the
     underlying catalog are the same either way -- only whether a
     .signal-tag or a filter <a> pill is the searchable unit differs, and
     that's handled by both carrying the same .tag-option contract
     (js/signals.js's initTagSearch reads data-tag-label off it), not by
     separate CSS per section. */

  .tag-group-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
    flex-wrap: wrap;
  }

  .tag-search {
    flex: 1 1 12rem;
    max-inline-size: 16rem;
  }

  /* Native <details>/<summary>, zero JS for the collapse itself -- same
     component this codebase already established for "This Week" / "View
     All N Weeks" on the dashboard's Training Track cards (session-start.md,
     dashboard length/UX pass). Browser's own disclosure triangle kept
     rather than replaced with a custom icon, matching that precedent. */
  .tag-disclosure {
    margin-block-start: var(--space-2);
  }

  .tag-disclosure__summary {
    cursor: pointer;
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
    transition: var(--transition-color);
    padding-block: var(--space-2);
  }

  .tag-disclosure__summary:hover {
    color: var(--color-accent);
  }

  .tag-disclosure .signal-tag-list,
  .tag-disclosure .signals-filter__row {
    margin-block-start: var(--space-2);
  }

  /* Same defensive [hidden] guard already applied throughout this file
     (.form-group[hidden], .track-card[hidden], .anatomy__try[hidden]) --
     .signal-tag sets its own display: flex and the filter's .btn sets
     display: inline-flex, either of which beats the browser's own
     low-priority [hidden] default regardless of specificity. One shared
     class on both the "Your Tags" wrapper div and the filter pill anchor
     means a single rule covers both searchable-element shapes. */
  .tag-option[hidden] {
    display: none;
  }

  .tag-search-empty {
    font-size: var(--text-sm);
    font-style: italic;
    color: var(--color-text-muted);
    margin-block-start: var(--space-2);
  }

  /* Optional pillar quick-filter (Filter panel only) -- narrows which
     tags are shown, never a grouping, never touches the directory query
     itself. Plain button-row toggle, one active at a time -- same
     .is-active convention as .muscle-balance__toggle-btn's own
     aria-pressed pattern, but a class here rather than aria-pressed
     since these buttons filter a list rather than represent a held
     state of the member's own. */
  .tag-pillar-quickfilter {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin-block-end: var(--space-2);
  }

  .tag-pillar-quickfilter__btn.is-active {
    background-color: var(--btn-accent-bg);
    color: var(--btn-accent-text);
    border-color: var(--btn-accent-bg);
  }

  /* flex-direction: column -- real bug, found via live computed-style check
     (author screenshot showed "All" rendering as a tall vertical box, not a
     pill): this rule never set flex-direction, so it defaulted to row, and
     the three children (the All wrapper, plus the two now-stacked category
     groups added in this same session's earlier pass) sat side by side with
     default align-items: stretch -- the short "All" wrapper stretched to
     match its tallest sibling (a label + a row of pills). Confirmed directly:
     getComputedStyle on production returned flexDirection: "row" before this
     fix. */
  .signals-filter {
    display: flex;
    flex-direction: column;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin-block: var(--space-6);
  }

  .signal-card {
    background-color: var(--card-bg);
    border: 1px solid var(--card-border);
    padding: var(--card-padding);
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-2);
    transition: border-color var(--duration-base) var(--ease-out);
  }

  .signal-card:hover {
    border-color: var(--card-border-hover);
  }

  .signal-card__name {
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-primary);
  }

  /* Same small-uppercase-muted treatment as .signal-tag-cap / .signal-tag__pillar
     -- secondary metadata under the name, not a second bio-weight line. Only rendered when the member has opted into
     location_visible (signals.php's own directory query gates this via
     the location_regions LEFT JOIN, so location_label is simply absent
     from the array otherwise -- no visibility logic needed here). */
  .signal-card__location {
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  .signal-card__bio {
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
    color: var(--color-text-secondary);
  }

  .signal-card__tags {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
  }

  .tag-chip {
    font-size: 0.6875rem;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    padding-block: 0.2rem;
    padding-inline: var(--space-2);
  }

  .connection-item__name {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: 400;
    letter-spacing: var(--tracking-normal);
    text-transform: uppercase;
    color: var(--color-text-primary);
    transition: var(--transition-color);
  }

  .connection-item__name:hover {
    color: var(--color-accent);
  }

  /* Dashboard profile-sidebar unread note. Same link treatment already
     established by .disclaimer-note a / .anatomy__account-tease a --
     third near-identical pair of declarations avoided by keeping this a
     one-off rule with the same two properties, not a new shared class. */
  .profile-sidebar__signals-note {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
  }

  .profile-sidebar__signals-note a {
    color: var(--color-accent);
    transition: var(--transition-color);
  }

  .profile-sidebar__signals-note a:hover {
    color: var(--color-accent-hover);
  }

  /* ─────────────────────────────────────────────
     SIGNAL FIRE -- visual design pass (this session)
     Function-first build (Session 43-44) confirmed fully working before
     this pass opened -- everything below is presentation only, no schema/
     API/JS logic touched. Screenshotted the live page first (both
     viewports, logged in as a real member) rather than guessing from the
     markup -- see changelog.md for what that screenshot actually showed.
     ───────────────────────────────────────────── */

  /* Signature moment: a real photographic band opening signals.php only
     (connections.php is a plain inbox/thread utility and stays quiet).
     Reuses .section-texture's exact mechanism (own ::before, isolate,
     z-index: var(--z-below)) rather than inventing a parallel one -- this
     is precisely the extension point that shared class was built for (see
     its own header comment: "a .section-texture + a --modifier per photo").
     Tuned a step more visible than the 0.08 baseline (bumped to 0.16 via
     this override) since this is a real page-open moment on its own
     utility page, not ambient texture behind several paragraphs of prose
     -- still the same graded, near-black-register recipe, still "felt
     before seen," just less diluted. Framed with the same hairline border
     every other surface on this page uses (.signals-mine, .signal-card)
     rather than a full-bleed cinematic treatment -- a member utility page
     earns a considered header, not a marketing hero.

     Two modifiers, not one -- author's own suggestion, after flagging that
     a single fixed photo (community/joy) read as more "health clinic" than
     athletic: rather than force a two-photo composition (a real
     compositional risk neither of us was confident in) or replace the
     photo outright, signals.php picks one of the two at random server-side
     on every load (see its own PHP for the array_rand() call). Both share
     the same opacity; only the image and its crop position differ. */
  .section-texture--signals-1::before,
  .section-texture--signals-2::before {
    opacity: 0.16;
  }

  .section-texture--signals-1::before {
    background-image: url('../img/texture/signal-fire-hero.jpg');
    background-position: center 55%;
  }

  .section-texture--signals-2::before {
    background-image: url('../img/texture/signal-fire-hero-2.jpg');
    background-position: center 40%;
  }

  .signals-hero {
    padding: var(--space-6) var(--space-4);
    margin-block-end: var(--space-6);
    border: var(--border-hairline);
    border-radius: var(--radius-sm);
  }

  @media (min-width: 40rem) {
    .signals-hero {
      padding-block: var(--space-8);
    }
  }

  /* .section-texture's own children need no z-index of their own -- the
     texture layer sits at --z-below (a negative index), so normal content
     already paints above it without help. Stated here, not left implicit,
     since every other .section-texture placement wraps prose that was
     already there; this is the first one built specifically to contain a
     heading + intro copy as a single considered unit. */

  /* Utility-page rhythm: both Signal Fire pages are member tools reached
     after the member is already deep in the app, not marketing sections --
     the sitewide .section padding-block-start (--space-16, 8rem) read as
     dead air above a photo band (signals.php) and as a genuinely empty
     128px gap above a one-line heading on a sparse inbox/thread (Session
     44 screenshot, connections.php). Scoped modifiers, not an edit to
     .section itself -- every other page keeps the marketing-page rhythm
     unchanged. Bottom padding is untouched on both: the transition into
     the footer still deserves the standard closing breath. */
  .signals-section {
    padding-block-start: var(--space-8);
  }

  .connections-section {
    padding-block-start: var(--space-8);
  }

  /* Avatar ring -- every avatar (a real photo or the placeholder) now gets
     a considered frame instead of floating as a bare circle on #0D0A1A.
     One rule on the shared base class, not per-size -- .avatar--placeholder
     already carried its own border for the no-photo case; this simply
     extends the same treatment to a real <img> too, which had none before. */
  .avatar {
    border: 1px solid var(--color-border);
    background-color: var(--color-surface-raised);
  }

  /* Native file input, avatar upload -- was completely unstyled (raw OS
     chrome, confirmed via live screenshot: "Choose File / No file chosen"
     sitting directly against the dark page with zero relationship to the
     rest of the form). ::file-selector-button is the standards-track hook
     for exactly this -- no JS-driven fake-input pattern needed. The
     surrounding .form-input box (border/background/padding) is reset to
     nothing for this one input: with the button itself now visibly boxed,
     a second outer box around the whole control read as a redundant
     double-frame in a live comparison. */
  .signals-mine__avatar-block .form-input[type="file"] {
    background: none;
    border: none;
    padding: 0;
    color: var(--color-text-muted);
    font-size: var(--text-sm);
  }

  .signals-mine__avatar-block .form-input[type="file"]::file-selector-button {
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    padding-block: var(--space-1);
    padding-inline: var(--space-2);
    border-radius: var(--radius-sm);
    border: var(--border-accent);
    background-color: transparent;
    color: var(--color-accent);
    cursor: pointer;
    margin-inline-end: var(--space-2);
    transition: var(--transition-color);
  }

  .signals-mine__avatar-block .form-input[type="file"]::file-selector-button:hover {
    background-color: var(--btn-ghost-bg-hover);
  }

  /* Direction color, Seeking = purple, Offering = gold. Uses the two
     tokens the palette already reserves for exactly this job
     (--color-accent: "earned warmth"; --color-accent-purple: "structural
     purple -- blockquote borders, chips," its own primitive comment names
     chips directly) -- zero new colors, matching this project's own
     asset-creation rule. Purple reads as "seeking/structural" (what a
     member is looking to find), gold as "offer" (this brand's own
     achievement/give color everywhere else on the site). Originally keyed
     off signal_tags.category (a fixed property of the tag); now keyed off
     ms.direction (a per-member choice against a unified catalog, schema-
     signal-tags-unified.sql) -- same two colors, same meaning, different
     source column. The toggle side of this lives on
     .signal-tag__toggle--seeking[aria-pressed="true"] (declared above,
     alongside .signal-tag__toggle); the directory-card side is the two
     .tag-chip rules directly below. */

  .tag-chip--seeking {
    border-color: var(--color-accent-purple);
    color: var(--color-text-secondary);
  }

  /* Renamed from .tag-chip--skill -- signal_tags.category is gone
     (schema-signal-tags-unified.sql); a directory card's chip color now
     keys off ms.direction (which member holds it, in which direction),
     not a fixed property of the tag itself. */
  .tag-chip--offering {
    border-color: var(--color-accent);
    color: var(--color-text-secondary);
  }

  .signals-filter__row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
  }

  /* Empty directory state -- was a single plain line (.stream__empty,
     shared sitewide). Left that shared class untouched (used by the
     dashboard stream, messages.php, connections.php's own empty states)
     and added a bit more vertical room only where Signal Fire's own
     directory is empty, so the invitation-to-act reads as a real pause in
     the page rather than a clipped afterthought. */
  .signals-directory-empty {
    padding-block: var(--space-8);
    text-align: center;
  }

  /* ── connections.php-only additions ──────────────────────────────────
     Scoped under .connections-section so nothing here reaches
     messages.php, which shares .section.thread/.thread-entry/
     .thread__entries verbatim (schema-messages.sql, Session 34) --
     confirmed by reading messages.php itself before touching any of
     those shared base rules. New elements only, added to connections.php's
     own markup; the shared classes above are never modified. */
  .connections-section .connection-list-item {
    display: flex;
    align-items: center;
    gap: var(--space-3);
  }

  .connections-section .connection-item__name {
    gap: var(--space-2);
  }

  .connections-section .thread-heading-row {
    display: flex;
    align-items: center;
    gap: var(--space-3);
  }

  /* Icons added this pass (author's own request: "something to visualize
     inline with industry standards") -- inline, decorative-scale glyphs
     sitting directly in front of a label's own text, sized in em so they
     track whatever font-size the surrounding label happens to use rather
     than a fixed pixel size. Reuses eye.svg (already sourced, was
     reference-only) and a newly-fetched camera.svg, both Tabler/MIT, both
     documented in img/icons/SOURCES.md -- no JS added, no interaction
     changed, purely a label decoration. */
  .signals-mine__icon {
    display: inline-block;
    inline-size: 1em;
    block-size: 1em;
    vertical-align: -0.15em;
    margin-inline-end: 0.4em;
  }

  /* Signal Fire brand mark -- Beacon Cresset, Trinity (final, bespoke;
     img/brand/signal-fire.svg has the design history). Filled gradient
     shape now, not a stroke icon -- no `stroke` property needed, the
     gold gradient is baked into each path's own `fill`. block-size is
     `auto` rather than a fixed square: the mark's viewBox (200x340) is
     taller than it is wide, and forcing it into a 2.5rem square would
     squash the facets. Both mark-layer and
     smoke-layer now render here too -- matching index.php's homepage
     treatment (author reviewed the live corner mark and asked for the
     smoke there as well; design testing against a flat background had
     found it hard to read that small, but the real photo-textured
     header band reads with more contrast). Quiet corner watermark,
     same spirit as the homepage hero's crown watermark but scaled down
     to this page's own considered-utility register rather than a
     monumental one -- this is a header band on a member tool, not the
     site's primary hero. */
  .signals-hero__mark {
    position: absolute;
    inset-block-start: var(--space-4);
    inset-inline-end: var(--space-4);
    inline-size: 1.6rem;
    block-size: auto;
    opacity: 0.35;
    pointer-events: none;
  }

  /* "Your conversations" moved out from the very bottom of the page
     (author: "buried at the bottom") to right under the header band, and
     upgraded from a plain .auth-links text link to a real .btn--ghost
     (author: "is so small, should it be that size?") -- see signals.php's
     own comment for the reasoning. Margin only; the button's own
     .btn/.btn--ghost/.btn--sm rules already carry everything else. */
  .signals-conversations-link {
    margin-block: var(--space-4);
  }

  /* .btn's own `gap` already spaces the icon from the label text -- the
     icon's usual margin-inline-end (set for its label-decoration use
     elsewhere on this page) would double that spacing here, so it's reset
     to zero specifically inside this one button. */
  .signals-conversations-link .signals-mine__icon {
    margin-inline-end: 0;
  }

  /* ──────────────────────────────────────────────
     SIGNAL FIRE -- homepage teaser mark (final, bespoke)
     Beacon Cresset, Trinity -- img/brand/signal-fire.svg has the full
     four-round design history (SOURCES.md, same directory). Resolves
     the placeholder this comment previously flagged (a reused flame.svg
     stroke icon) -- this is now a first-party mark built for this
     feature, same faceted gradient-gold construction as The Forged
     Crown, not a generic icon. Renders both <g class="mark-layer"> and
     <g class="smoke-layer"> -- signals.php's own corner watermark now
     renders both layers too (author reviewed the live page and asked
     for the smoke there as well, see that rule's own comment).
     Positioning context is `.container`, not the outer `.signal-fire-
     teaser` section -- the section is full-bleed width but the content
     is a single centered text column, and anchoring to the wider section
     would let the mark drift far from the text at wide viewports, same
     trap `architecture.md` already documents for percentage sizing
     against the wrong ancestor. */
  .signal-fire-teaser .container {
    position: relative;
    isolation: isolate;
  }

  .signal-fire-teaser__mark {
    position: absolute;
    z-index: var(--z-below);
    inset-block-start: 50%;
    inset-inline-end: var(--space-4);
    translate: 0 -50%;
    inline-size: min(28vw, 12rem);
    block-size: auto;
    opacity: 0.14;
    pointer-events: none;
  }

  /* Mobile (Session 49, author-requested): keep the mark visible rather
     than hiding it -- losing it lost the section's only visual anchor on
     narrow viewports. Standard responsive pattern for a side-anchored
     watermark with no side room left to sit in: recenter it behind the
     text column and let it grow, rather than disappearing it -- the same
     idea .hero__watermark already uses at full scale (a mark behind the
     type, not beside it). Enlarged relative to viewport width since
     there's no longer a text column competing with it for horizontal
     space; opacity dropped (0.14 -> 0.10) to match this codebase's own
     "felt, not seen" convention for anything sitting directly behind
     running text (.section-texture's 0.08 baseline, --rhythm-opacity's
     0.10) -- this sits closer to the text than the desktop side
     placement did, so it drops into that same quiet register rather than
     keeping the stronger 0.14. z-index/pointer-events are untouched from
     the base rule -- still strictly behind, never intercepts a tap.
     inline-size capped at 16rem (not just vw) for the same reason every
     other viewport-relative size in this file is capped -- an
     uncapped vw value on a wide phone-in-landscape or a small tablet
     would grow past the point of being a quiet mark. */
  @media (max-width: 48rem) {
    .signal-fire-teaser__mark {
      inset-inline-start: 50%;
      inset-inline-end: auto;
      translate: -50% -50%;
      inline-size: min(70vw, 16rem);
      opacity: 0.1;
    }
  }

  /* Signal Fire -- avatar upload confirm step (author-requested,
     2026-08-26). A selected photo previews locally; nothing reaches the
     server until Confirm is clicked. Reuses .field-hint and
     .btn/.btn--accent/.btn--ghost/.btn--sm verbatim -- no new button or
     text style needed. */
  .signals-mine__avatar-review {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin-block-start: var(--space-2);
  }

  /* Defensive guard, same reasoning as .anatomy__try[hidden] and
     .form-group[hidden] elsewhere in this file: a bare-class display
     rule can beat the browser's own low-priority [hidden] default
     regardless of specificity, so this is written in from the start
     rather than waiting to rediscover the bug. */
  .signals-mine__avatar-review[hidden] {
    display: none;
  }

  /* ─────────────────────────────────────────────
     VERIFY BADGE (verify.php, medal verification MVP)
     Same crown-socket badge construction as .sage__emblem-badge
     (index.php) -- gold gradient mark, dark backing circle, gold-stroke
     ring -- but with a checkmark glyph in the socket instead of a
     numeral, since this badge marks "verified," not a fixed milestone
     count. Rendered only on the found/verified state in verify.php -- an
     invalid-link page gets no badge, nothing to seal.

     Enlarged and given a warm gold bloom (this session, author request:
     "make it bigger and make it feel like an award") -- this badge is the
     entire reason the page exists, the one thing on it that should feel
     like a genuine moment rather than a small icon next to text. Bloom
     reuses the exact rgb(200 169 126 / ...) recipe already used behind
     the hero, The Form, and Your Standing's chart -- tuned a step
     stronger than that 0.08 ambient baseline (0.22 core), same "a real
     hero moment earns more visibility than ambient texture" call already
     made for signals.php's own header band. inset: -20%, same small-inner-
     element convention as .anatomy__figures::before (-15%) and
     .standing__chart::before (-10%) -- .verify-badge sits well inside
     .auth-card's own 4rem padding, so a modest negative inset has real
     room and won't bleed past the card's border.

     Static, not animated/pulsing -- deliberate. This is a certificate,
     not a marketing surface; the Typography section's own "carved, not
     printed, permanent" register argues for something that holds still
     rather than breathes, the opposite governing idea from e.g.
     .standing__fill's living pulse. drop-shadow on the mark itself (not
     the bloom layer) adds a second, tighter layer of glint straight off
     the metal -- same gold, no new color introduced anywhere in this
     component. */
  .verify-badge {
    position: relative;
    isolation: isolate;
    display: flex;
    justify-content: center;
    margin-block: var(--space-2) var(--space-4);
    padding-block: var(--space-4);
  }

  .verify-badge::before {
    content: "";
    position: absolute;
    inset: -20%;
    z-index: var(--z-below);
    pointer-events: none;
    background: radial-gradient(
      55% 55% at 50% 50%,
      rgb(200 169 126 / 0.22) 0%,
      rgb(200 169 126 / 0.08) 45%,
      transparent 75%
    );
  }

  .verify-badge__mark {
    position: relative;
    z-index: var(--z-base);
    inline-size: 10rem;
    block-size: auto;
    filter: drop-shadow(0 0 14px rgb(200 169 126 / 0.35));
  }

  /* ─────────────────────────────────────────────
     MEALS GALLERY (meals.php)
     Public, indexed, real-food card grid. New component family, not a
     reuse of .disc-card/.discipline-card -- those are text-first with an
     optional icon; a meal card is photo-first, and forcing the same
     class would mean fighting that base rule's own image-optional
     assumptions rather than extending it cleanly.
     ───────────────────────────────────────────── */

  /* The whole card is a <button> (native, keyboard-operable, no role/
     tabindex needed) rather than an <a> -- clicking opens the shared
     lightbox dialog below, it never navigates. Button reset (reset.css)
     already strips background/border/padding/cursor, so this only adds
     what a card actually needs. */
  .meal-card {
    display: flex;
    flex-direction: column;
    inline-size: 100%;
    text-align: start;
    background-color: var(--card-bg);
    border: var(--border-hairline);
    border-radius: var(--radius-md);
    overflow: hidden;
    transition: var(--transition-color);
  }

  .meal-card:hover,
  .meal-card:focus-visible {
    border-color: var(--card-border-hover);
  }

  /* Defensive guard, same reasoning as .anatomy__try[hidden]/
     .form-group[hidden]/.signals-mine__avatar-review[hidden] elsewhere in
     this file -- .meal-card's own bare `display: flex` would otherwise
     beat the browser's low-priority [hidden] default regardless of
     specificity. js/meals.js toggles [hidden] on this element directly
     for the meal-type filter. */
  .meal-card[hidden] {
    display: none;
  }

  /* Fixed aspect ratio so mixed source-photo dimensions (a portrait plate
     shot next to a landscape one) still form a clean, even grid --
     object-fit: cover crops rather than distorts. reset.css's own `img`
     rule already sets display: block/max-inline-size: 100%; this adds
     only what's specific to the card context.
     block-size: auto is the actual fix for a real, confirmed bug (session
     following the auto-fill grid change): meals.php sets real width/
     height HTML attributes on this <img> from getimagesize(), for CLS
     prevention, matching this codebase's own established pattern. Those
     attributes map to a presentational-hint `height` declaration in the
     cascade -- and since this rule never explicitly set block-size/height
     to auto, that attribute-derived height (the photo's own raw pixel
     height, confirmed live: 2000px and 2048px for the two real photos)
     won over aspect-ratio's own computation, since aspect-ratio only
     computes a dimension that is still `auto`. The box rendered at its
     correct CSS width (aspect-ratio correctly read as "4 / 3" in
     getComputedStyle) but a completely unconstrained height matching the
     source file's own dimensions -- confirmed via live measurement
     against production (getComputedStyle height read literally "2000px"),
     and confirmed as the fix by toggling block-size: auto live and
     re-measuring: height corrected to exactly 262.5px, matching 350px
     width * 3/4. This bug predates the auto-fill grid-sizing fix entirely
     (it has been live since this rule was first written, Session 58) --
     the grid fix was a real, separate, correct improvement (unbounded
     column stretching at low item counts), but never touched this, which
     is why oversized images were still visible after that fix alone. */
  .meal-card__image {
    inline-size: 100%;
    aspect-ratio: 4 / 3;
    block-size: auto;
    object-fit: cover;
  }

  .meal-card__body {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    padding: var(--space-3);
  }

  .meal-card__type {
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    color: var(--color-accent);
  }

  .meal-card__title {
    font-family: var(--font-heading);
    font-size: var(--text-md);
    line-height: var(--leading-tight);
    color: var(--color-text-primary);
  }

  .meal-card__tags {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    margin-block-start: var(--space-1);
  }

  /* Deliberately its own small chip, not a reuse of .tag-chip--seeking/
     --offering -- those two carry Signal Fire's specific direction
     semantics (purple/gold, seeking-vs-offering). A meal tag has no
     direction to encode, so a neutral, self-contained chip is the
     honest choice rather than borrowing a meaning that doesn't apply. */
  .meal-card__tag {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    border: var(--border-hairline);
    border-radius: var(--radius-sm);
    padding-block: 0.15em;
    padding-inline: var(--space-2);
  }

  /* ── Toolbar: sort + meal-type filter pills ─────────────────────── */

  .meals-toolbar__sort-label {
    font-size: var(--text-sm);
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-muted);
  }

  .meals-sort {
    max-inline-size: 12rem;
  }

  /* Same toggle-pill idiom as Signal Fire's own filter row
     (.signals-filter__row's children) -- aria-pressed carries the actual
     state, CSS reads it, JS only ever flips the attribute. */
  .meals-filter__pill {
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    padding-block: var(--space-1);
    padding-inline: var(--space-3);
    border: var(--border-hairline);
    border-radius: var(--radius-sm);
    background-color: transparent;
    color: var(--color-text-secondary);
    cursor: pointer;
    transition: var(--transition-color);
  }

  .meals-filter__pill:hover {
    border-color: var(--color-accent);
  }

  .meals-filter__pill[aria-pressed="true"] {
    border-color: var(--color-accent);
    color: var(--color-accent);
  }

  /* ── Lightbox: one shared <dialog>, populated by JS per card ─────── */

  /* reset.css strips dialog's UA padding/border/background entirely --
     everything here is authored fresh, same as this project's other
     native <dialog> uses (.modal). */
  .meal-dialog {
    inline-size: min(34rem, calc(100vw - var(--space-6)));
    max-block-size: calc(100dvh - var(--space-8));
    overflow-y: auto;
    background-color: var(--color-surface);
    border: var(--border-hairline);
    border-radius: var(--radius-md);
    padding: var(--space-4);
  }

  .meal-dialog::backdrop {
    background-color: rgb(7 5 15 / 0.75);
  }

  .meal-dialog__close {
    float: inline-end;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    transition: var(--transition-color);
  }

  .meal-dialog__close:hover {
    color: var(--color-accent);
  }

  .meal-dialog__image {
    inline-size: 100%;
    border-radius: var(--radius-sm);
    margin-block-end: var(--space-3);
  }

  .meal-dialog__type {
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    color: var(--color-accent);
  }

  .meal-dialog__title {
    font-family: var(--font-heading);
    font-size: var(--text-lg);
    line-height: var(--leading-tight);
    color: var(--color-text-primary);
    margin-block: var(--space-1) var(--space-2);
  }

  .meal-dialog__meta {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    margin-block-end: var(--space-2);
  }

  .meal-dialog__description {
    color: var(--color-text-secondary);
    line-height: var(--leading-loose);
  }

  .meal-dialog__tags {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    margin-block-start: var(--space-3);
  }

  /* Same defensive [hidden] guard already applied throughout this file
     (.meal-card[hidden], .form-group[hidden], .tag-option[hidden]) --
     this rule's own bare display: flex would otherwise beat the
     browser's low-priority [hidden] default regardless of specificity.
     js/meals.js sets this when a meal has no tags entered. */
  .meal-dialog__tags[hidden] {
    display: none;
  }

  /* Empty gallery state -- no published meals yet. Reuses .stream__empty
     verbatim rather than a fourth near-identical empty-state rule; see
     that class's own precedent (dashboard stream, messages.php,
     connections.php, Signal Fire's directory) -- this project already
     treats it as the shared "nothing here yet" pattern. */

  /* ─────────────────────────────────────────────
     MEAL MACRO DISPLAY (meal_detail.protein_g/carbs_g/fat_g -- live in
     the schema since Session 58, never surfaced in the UI until now,
     per that session's own note. Foundry, 2026-09-03,
     meal-card-macro-display-concept.html.)
     Deliberately its own small chip family, not a reuse of
     .meal-card__tag or Signal Fire's .tag-chip--seeking/--offering --
     neither carries the right semantics for a numeric nutrient value
     (see .meal-card__tag's own header comment on the identical
     reasoning for tags). Each of the three fields is independently
     optional, matching the schema's own three separately-nullable
     columns -- a meal logged with only protein noted renders one chip,
     not three with two reading blank. A meal with no macro data at all
     renders with no .macro-row in the markup at all (meals.php only
     emits it when at least one field is present), zero visual change
     from before this feature existed.
     A single shared border/text treatment, not three different chip
     colors -- three competing colors on one small card would read
     louder than the rest of this deliberately quiet component family
     (.meal-card__tag and .meal-card__type are both single-color); the
     P/C/F letter alone carries the distinction, nothing else needs to.
     ───────────────────────────────────────────── */
  .macro-row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    margin-block-start: var(--space-1);
  }

  .macro-chip {
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: var(--tracking-normal);
    color: var(--color-text-secondary);
    border: var(--border-hairline);
    border-radius: var(--radius-sm);
    padding-block: 0.1em;
    padding-inline: var(--space-2);
  }

  /* Lightbox: card chips stay terse (grid density matters more there);
     the dialog gets a plain-language line instead, sitting between
     .meal-dialog__meta and .meal-dialog__description -- matching that
     panel's own existing spacing rhythm (each sibling paragraph already
     carries its own margin-block-end). No display override here, so
     js/meals.js's [hidden] toggle needs no defensive [hidden] guard --
     same reason .meal-dialog__description has none. */
  .meal-dialog__macros {
    font-size: var(--text-sm);
    color: var(--color-accent);
    margin-block-end: var(--space-2);
  }

  /* ─────────────────────────────────────────────
     MEALS GALLERY -- "MORE TO COME" PLACEHOLDER TILE
     Foundry, 2026-09-03 (meals-more-to-come-placeholder-card-concept.
     html, Variant A). Fills out the grid rhythm at low real photo
     counts -- the gallery grows one dropped-in photo at a time
     (img/meals/README.md's own model), so a 1- or 2-photo state is a
     real, expected stretch, not a rare edge case. Rendered by
     meals.php only while count($meals) is below a small threshold,
     removed automatically the moment enough real meals are published.
     Same footprint and column behavior as a real .meal-card so the row
     fills out evenly, but everything about it signals "not a real
     entry": a dashed border instead of the real card's solid hairline,
     an italic muted title instead of the real heading treatment, a
     plain flat gradient instead of any photograph, and no <button>
     semantics at all -- a plain, non-interactive, aria-hidden <li>, so
     it can never be mistaken for a working lightbox trigger and is
     never announced to assistive tech as a real entry. js/meals.js
     keeps it pinned to the end of the grid through every sort, and
     hides it whenever a specific (non-"all") meal-type filter is
     active, since it doesn't represent any real type.
     ───────────────────────────────────────────── */
  .meal-card-placeholder {
    display: flex;
    flex-direction: column;
    inline-size: 100%;
    background-color: var(--card-bg);
    border: 1px dashed var(--color-border);
    border-radius: var(--radius-md);
    overflow: hidden;
  }

  /* Same defensive [hidden] guard already applied throughout this file
     (.meal-card[hidden], .form-group[hidden], .tag-option[hidden]) --
     this rule's own bare display: flex would otherwise beat the
     browser's low-priority [hidden] default regardless of specificity.
     js/meals.js toggles this during meal-type filtering. */
  .meal-card-placeholder[hidden] {
    display: none;
  }

  .meal-card-placeholder__image {
    aspect-ratio: 4 / 3;
    display: flex;
    align-items: center;
    justify-content: center;
    background: linear-gradient(
      160deg,
      var(--color-surface-raised) 0%,
      var(--color-surface) 100%
    );
  }

  .meal-card-placeholder__glyph {
    opacity: 0.35;
  }

  .meal-card-placeholder__body {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    padding: var(--space-3);
  }

  .meal-card-placeholder__title {
    font-family: var(--font-heading);
    font-size: var(--text-md);
    line-height: var(--leading-tight);
    color: var(--color-text-muted);
    font-style: italic;
  }

  /* ─────────────────────────────────────────────
     TAPERED CLEARING DIVIDER (Session 59)
     creative_direction Direction 2, foundry/2026-08-30/tapered-clearing-
     divider-the-sage-ready-to-ship.html. A soft in-and-out gold fade,
     distinct from .tri-divider (a hard three-stripe section boundary) --
     this divider is a mid-section breathing point inside a single long
     prose run, not a section break, so it needed its own quieter
     treatment rather than reusing .tri-divider at a smaller scale.
     Centers the SVG and gives it sane block-level spacing; the SVG itself
     carries its own gradient (id-namespaced per instance so multiple uses
     on one page never collide) and needs no other CSS.
     ───────────────────────────────────────────── */

  .tapered-divider {
    display: flex;
    justify-content: center;
    margin-block: var(--space-6);
  }

  /* MOVEMENT SNACKS ICON (Session 59, creative_direction Direction 4)
     Reuses img/icons/activity.svg byte-for-byte (already sourced/
     license-confirmed, Session 22 icon batch -- see img/icons/SOURCES.md)
     inline inside a .move-callout paragraph on the-ninety.php. Sized
     relative to its own font-size and colored via currentColor
     inheritance from --color-accent, matching this codebase's standing
     rule that no icon placement hardcodes a pixel size or a literal
     color (foundry/2026-08-28/movement-snacks-callout-prototype.html's
     own sizing note). Static, not pulsing -- .move-callout-pulse's own
     box-shadow glow animates the callout's border, not its contents;
     adding motion to the icon too was considered and left out; a single
     moving element per callout is enough. */
  .move-callout__icon {
    inline-size: 1.1em;
    block-size: 1.1em;
    vertical-align: -0.15em;
    color: var(--color-accent);
    margin-inline-end: 0.35em;
  }

  /* ─────────────────────────────────────────────
     BMI CALCULATOR (the-ninety.php, The Diet section)
     Pure client-side arithmetic -- js/bmi-calculator.js. No schema, no
     auth, no server round-trip: nothing a visitor enters here ever
     leaves their own browser. Sits right after "Learn your actual
     numbers, your real weight, your real calories, measured against
     your own height and frame" -- the tool this line was already
     gesturing at.

     Reuses .form-group/.form-label/.form-input/.btn/.btn--accent
     verbatim for the fields themselves, and .form-status/
     .form-status--success for the result line (same "reserve space,
     avoid layout shift" plus gold-on-result treatment already
     established there for every other computed/confirmed value on this
     site -- .profile-sidebar__stat-value, .doctrine__stat-badge, this is
     that same idiom, not a celebratory color choice specific to a good
     result). .tag-disclosure/.tag-disclosure__summary (Signal Fire) are
     reused as-is for the "View the Full Height Chart" collapse -- zero
     new disclosure styling needed. Only the layout-specific wrapper
     classes below, plus the table itself, are genuinely new.

     Table values are independently calculated from the standard CDC/NIH
     BMI formula (weight_lb * 703 / height_in^2) and CDC's own published
     cutoffs (Underweight <18.5, Healthy Weight 18.5-24.9, Overweight
     25-29.9, Obesity 30+) -- not copied from a published table. Rounding
     verified directly against CDC's own worked example (5'8", 200 lb ->
     BMI 30.4, Obesity) before this shipped. BMI does not differentiate
     by sex -- the same categories apply at the same height regardless --
     so this is one table, not two; see js/bmi-calculator.js's own header
     comment for why a specific target weight is deliberately never shown,
     only a category. */
  /* 50rem, not the 34rem the form alone would need -- the table below
     (5 columns of real numbers) measures ~44.4rem/700px at its natural
     size, and capping this wrapper to the form's own narrower width
     forced an unnecessary horizontal scroll on the table even at
     ordinary desktop widths. First correction (46rem) still came up
     31px short once the padding-inline on both sides was accounted for
     -- confirmed by direct measurement (wrap.scrollWidth 701 vs.
     wrap.clientWidth 670), not assumed fixed from the arithmetic alone.
     50rem leaves a real margin past the table's own natural width
     (measured content width ~736px against a 700.64px table) rather
     than landing exactly on the boundary a font-rendering difference
     could tip over. .bmi-calc__form below keeps its own narrower cap so
     the two-field form doesn't sprawl just because its wider sibling
     needs the room. */
  .bmi-calc {
    max-inline-size: 50rem;
    margin-block: var(--space-6);
    padding: var(--space-4);
    border: var(--border-hairline);
  }

  .bmi-calc__heading {
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: 700;
    letter-spacing: var(--tracking-wide);
    text-transform: uppercase;
    color: var(--color-text-primary);
    margin-block-end: var(--space-3);
  }

  .bmi-calc__form {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    max-inline-size: 26rem;
  }

  .bmi-calc__height-row {
    display: flex;
    gap: var(--space-2);
  }

  .bmi-calc__height-row .form-input {
    inline-size: 50%;
  }

  /* Same structural safety net already used for .timeline__grid-wrap --
     a fixed min-inline-size on the table below is the correct choice for
     legibility (five columns of real numbers need real room), so the
     scroll has to live on this wrapper instead of shrinking the table. */
  .bmi-calc__table-wrap {
    max-inline-size: 100%;
    overflow-x: auto;
    margin-block: var(--space-3);
  }

  .bmi-table {
    inline-size: 100%;
    min-inline-size: 32rem;
    border-collapse: collapse;
    font-family: var(--font-body);
    font-size: var(--text-sm);
  }

  .bmi-table caption {
    text-align: start;
    font-style: italic;
    color: var(--color-text-muted);
    line-height: var(--leading-snug);
    margin-block-end: var(--space-2);
  }

  .bmi-table th {
    font-weight: 600;
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    color: var(--color-text-muted);
    text-align: start;
    padding-block: var(--space-2);
    padding-inline: var(--space-2);
    border-block-end: var(--border-hairline);
    white-space: nowrap;
  }

  .bmi-table td {
    padding-block: var(--space-2);
    padding-inline: var(--space-2);
    border-block-end: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
    color: var(--color-text-secondary);
  }

  /* ─────────────────────────────────────────────
     THE DIET -- TDEE calculator, second calculator in #ninety-diet
     (queue.md carried-forward #1; creative_direction Direction 19,
     2026-09-07/2026-09-08/2026-09-09 briefs; foundry/2026-09-08/
     tdee-calculator-in-context.html verified this exact placement
     against the live section markup before this shipped). Sits directly
     after the existing .bmi-calc block, before the "Hunger is a
     sensation" callout -- the one slot that doesn't interrupt "The
     Deficit" copy with the tool it introduces.

     Reuses the entire .bmi-calc component family verbatim (.bmi-calc,
     .bmi-calc__heading, .bmi-calc__form, .form-group, .form-input,
     .form-status, .disclaimer-note) -- no new component family, per the
     brief's own framing. Only genuinely new pieces: a spacing modifier
     so the second calculator doesn't collide with the disclaimer/margin
     above it, and a two-column field-row layout for the two rows the
     BMI form never needed (sex+age together, then a full-width activity
     select) -- js/tdee-calculator.js's own DOM matches this shape
     exactly. No new color anywhere. */
  .bmi-calc--tdee {
    margin-block-start: var(--space-4);
  }

  .bmi-calc__row {
    display: flex;
    gap: var(--space-2);
  }

  .bmi-calc__row .form-group {
    flex: 1;
    min-width: 0;
  }

  /* ─────────────────────────────────────────────
     ADMIN -- meal photo preview (admin/index.php)
     Shown above the file input on Edit when a meal already has a photo,
     so replacing it is an informed choice, not a guess. No inline style
     per this project's own Code Philosophy rule (no inline styles) --
     this exists specifically so that constraint didn't get quietly
     broken for a one-off thumbnail.
     ─────────────────────────────────────────────  */
  .admin-form__photo-preview {
    max-inline-size: 8rem;
    border-radius: var(--radius-sm);
    display: block;
    margin-block-end: var(--space-2);
  }

  /* ─────────────────────────────────────────────
     MEALS GALLERY -- header bloom (creative_direction Direction 1,
     09-01/09-02/09-03 briefs; foundry/2026-08-31/meals-gallery-header-
     bloom-concept.html, Option A). #meals-intro was the one section-
     with-a-heading on the live site with zero background treatment --
     every comparable intro (.hero, .ninety-teaser, .disciplines,
     .error-atmosphere) has one. .hero::before's exact single-source
     gradient, copied verbatim -- no purple source, since a gold+purple
     pair signals a glass/quote surface (Featured Card, .ninety-teaser),
     which this plain photo gallery page is not. ID selector, not a new
     class -- #meals-intro is already a unique, stable hook.
     ───────────────────────────────────────────── */
  #meals-intro {
    position: relative;
    isolation: isolate;
  }

  #meals-intro::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--z-below);
    pointer-events: none;
    background: radial-gradient(
      58% 46% at 72% 42%,
      rgb(200 169 126 / 0.13) 0%,
      rgb(200 169 126 / 0.05) 38%,
      transparent 72%
    );
  }

  /* ─────────────────────────────────────────────
     DISCIPLINES LISTING -- header bloom (creative_direction Direction 6,
     09-02/09-03 briefs; foundry/2026-09-01/disciplines-listing-header-
     bloom-concept.html). Same gap pattern as #meals-intro above, found a
     second time on disciplines.php's own intro -- identical recipe,
     lower priority (a gated member utility page, not public/shareable
     content). Deliberately NOT paired with .discipline-card--depth on
     this page's cards -- that modifier needs a marketing-weight surface
     behind it to justify translucency, and this is a plain logged-in
     tool, not the homepage grid.
     ───────────────────────────────────────────── */
  .disciplines-listing {
    position: relative;
    isolation: isolate;
  }

  .disciplines-listing::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--z-below);
    pointer-events: none;
    background: radial-gradient(
      58% 46% at 72% 42%,
      rgb(200 169 126 / 0.13) 0%,
      rgb(200 169 126 / 0.05) 38%,
      transparent 72%
    );
  }

  /* ─────────────────────────────────────────────
     FACETED POINT DIVIDER (creative_direction Direction 2 [09-01/09-02/
     09-03 numbering], foundry/2026-08-31/faceted-point-divider-concept.
     html). A third ornament, distinct from .tri-divider (a hard section
     boundary) and .tapered-divider (a mid-prose breathing point) -- this
     one marks the specific seam where a short intro ends and a content
     grid begins, on meals.php and disciplines.php. Flanking fade reuses
     .tapered-divider's own opacity-only gradient technique verbatim; the
     center diamond reuses .doctrine__stat-badge's own two-facet opacity
     pairing (0.92/0.55) at miniature scale. Gold ladder only, zero new
     tokens. The SVG carries its own gradient defs (id-namespaced per
     placement, never shared -- two instances of one gradient id on one
     page silently collide in SVG); this rule only centers it and gives
     it sane block spacing, matching .tapered-divider's own precedent.
     ───────────────────────────────────────────── */
  .facet-divider {
    display: flex;
    justify-content: center;
    margin-block: var(--space-6);
  }

  /* ─────────────────────────────────────────────
     THE SAGE -- ember-ash grain intensification (creative_direction
     Direction 9, Variant D, creative-direction_2026-09-03.md; foundry/
     2026-09-03/the-sage-teaser-depth-reassessment-concept.html).
     Answers a real, five-consecutive-audit-cycle-old open item ("Featured
     Card -> the-sage.php hero", audit_2026-09-02_2109.md's own Stage 2
     table) without repeating the Session 47 failure mode -- the author's
     objection there was specifically the glass surface (backdrop-filter
     + frosted card) muting the fire-embers photo, not depth or gold as
     concepts. This adds visual weight with no backdrop-filter and no
     opaque card surface: a second, section-scoped feTurbulence grain
     layered on top of the existing fire-embers texture (amber-shifted
     via sepia/hue-rotate so it reads as this section's own warmer ash,
     not a duplicate of the sitewide neutral body::after grain at 0.045),
     plus a small gold-only bloom near the emblem reusing .hero::before's
     exact recipe -- no purple source, since this is not a glass/quote
     surface (Direction 6's own reasoning, creative-direction_2026-09-02.
     md). Author chose Variant D over Variant C (a wood-grain underlay
     using the still-unplaced Reserved-table photo) specifically to avoid
     a second, unproven photographic layer and ship with zero new asset
     sourcing. Two new selectors, not an edit to .sage--depth (still
     PAUSED, left untouched as reference/history) or to the shared
     .section-texture--fire::before rule (which other sections also use).
     .sage__inner gets an explicit stacking context here specifically --
     without it, the new z-index:0 ::after (a positioned layer) would
     paint above .sage__inner's own unpositioned content per the normal
     CSS stacking order, the same class of bug lessons.md documents
     elsewhere in this project; scoped to .sage--ember-ash only so the
     section's default (non-ember-ash) stacking is untouched. */
  .sage--ember-ash.section-texture--fire::before {
    background-image:
      url('../img/texture/fire-embers.jpg'),
      radial-gradient(
        50% 60% at 14% 40%,
        rgb(200 169 126 / 0.16) 0%,
        rgb(200 169 126 / 0.05) 45%,
        transparent 72%
      );
    background-size: cover, auto;
    background-position: center, center;
    opacity: 0.1;
  }

  .sage--ember-ash::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    opacity: 0.09;
    mix-blend-mode: overlay;
    filter: sepia(35%) hue-rotate(-8deg) saturate(1.4);
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='ea'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23ea)' opacity='0.55'/%3E%3C/svg%3E");
    background-size: 160px 160px;
  }

  .sage--ember-ash .sage__inner {
    position: relative;
    z-index: 1;
  }

} /* @layer components */
