@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT@9..144,300..900,0..100&family=Work+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
:root{--type-display-size:clamp(2.44rem,5.4vw,3.9rem);--type-display-weight:500;--type-display-line-height:1.08;--type-display-letter-spacing:-.01em;--type-h1-size:clamp(1.95rem,4.1vw,2.44rem);--type-h1-weight:700;--type-h1-line-height:1.1;--type-h1-letter-spacing:-.028em;--type-h1-measure:22ch;--type-h2-size:clamp(1.25rem,3.3vw,1.95rem);--type-h2-weight:700;--type-h2-line-height:1.14;--type-h2-letter-spacing:-.022em;--type-h2-measure:26ch;--type-h3-size:1.25rem;--type-h3-weight:650;--type-h3-line-height:1.25;--type-h3-letter-spacing:-.01em;--type-h3-measure:30ch;--type-h4-size:1.0rem;--type-h4-weight:600;--type-h4-line-height:1.35;--type-h4-letter-spacing:-.005em;--type-h4-measure:34ch;--type-lede-size:1.2rem;--type-lede-weight:400;--type-lede-line-height:1.55;--type-lede-letter-spacing:0;--type-lede-measure:56ch;--type-body-size:1.0rem;--type-body-weight:400;--type-body-line-height:1.62;--type-body-letter-spacing:0;--type-body-measure:66ch;--type-body-small-size:0.9rem;--type-body-small-weight:400;--type-body-small-line-height:1.55;--type-body-small-letter-spacing:0;--type-body-small-measure:60ch;--type-quote-size:1.0rem;--type-quote-weight:400;--type-quote-line-height:1.58;--type-quote-letter-spacing:-.005em;--type-quote-measure:54ch;--type-label-size:0.82rem;--type-label-weight:600;--type-label-line-height:1.3;--type-label-letter-spacing:.08em;--type-label-measure:24ch;--type-label-strong-size:0.88rem;--type-label-strong-weight:600;--type-label-strong-line-height:1.4;--type-label-strong-letter-spacing:0;--type-label-strong-measure:24ch;--type-meta-size:0.82rem;--type-meta-weight:400;--type-meta-line-height:1.45;--type-meta-letter-spacing:0;--type-meta-measure:48ch;--type-eyebrow-size:0.78rem;--type-eyebrow-weight:600;--type-eyebrow-line-height:1.3;--type-eyebrow-letter-spacing:.14em;--type-eyebrow-measure:34ch;--type-nav-size:0.85rem;--type-nav-weight:500;--type-nav-line-height:1.2;--type-nav-letter-spacing:0;--type-cta-size:0.85rem;--type-cta-weight:600;--type-cta-line-height:1.15;--type-cta-letter-spacing:.01em;--type-cta-small-size:0.88rem;--type-cta-small-weight:500;--type-cta-small-line-height:1.4;--type-cta-small-letter-spacing:.01em;--type-cta-small-measure:24ch;--type-brand-size:1.05rem;--type-brand-weight:600;--type-brand-line-height:1.2;--type-brand-letter-spacing:-.005em;--type-brand-measure:20ch;--type-rating-size:1.05rem;--type-rating-weight:400;--type-rating-line-height:1;--type-rating-letter-spacing:-.02em;--type-data-hero-size:3.05rem;--type-data-hero-weight:500;--type-data-hero-line-height:1;--type-data-hero-letter-spacing:-.03em;--type-data-hero-measure:8ch;--type-data-size:1.56rem;--type-data-weight:700;--type-data-line-height:1;--type-data-letter-spacing:-.02em;--type-data-small-size:0.82rem;--type-data-small-weight:500;--type-data-small-line-height:1.2;--type-data-small-letter-spacing:.02em;--type-data-small-measure:10ch;--space-2xs:0.25rem;--space-xs:0.5rem;--space-xs-plus:0.75rem;--space-sm:1rem;--space-md:1.5rem;--space-md-plus:2rem;--space-lg:2.5rem;--space-lg-plus:3rem;--space-xl:4rem;--space-xl-plus:5rem;--space-2xl:6rem;--space-3xl:8rem;--space-section:10rem;--announce-h:38px;--rhythm-major:8rem;--rhythm-standard:6rem;--rhythm-tight:4rem;--rhythm-signature-top:10rem;--rhythm-signature-bottom:6rem;--layout-container:1160px;--layout-container-narrow:820px;--layout-measure:66ch;--layout-measure-narrow:48ch;--layout-gutter:1.5rem;--layout-grid-gap:2.5rem;--layout-grid-gap-tight:1.5rem;--layout-section-y:8rem;--layout-section-y-tight:6rem;--layout-bp-sm:600px;--layout-bp-md:900px;--layout-bp-lg:1200px;--radius-sm:6px;--radius-md:12px;--radius-lg:20px;--radius-pill:999px;--border:1px;--border-strong:2px;--focus-ring:2px;--focus-offset:2px;--focus-halo:4px;--shadow-none:none;--shadow-sm:0 1px 2px rgba(36,29,39,.05),0 2px 8px rgba(36,29,39,.04);--shadow-md:0 2px 6px rgba(36,29,39,.06),0 12px 32px rgba(36,29,39,.08);--shadow-lg:0 4px 10px rgba(36,29,39,.07),0 24px 64px rgba(36,29,39,.10);--type-caption-size:0.75rem;--type-caption-weight:400;--type-caption-line-height:1.5;--type-caption-letter-spacing:.01em;--type-caption-measure:44ch;--bg:#f8f3ee;--fg:#241d27;--brand:#5a4860;--accent:#9f5336;--secondary:#636b53;--status:#83623c;--flush:#c1442e;--ink:var(--fg);--paper:var(--bg);--plum:var(--brand);--plum-deep:#342a38;--clay:var(--accent);--sage:var(--secondary);--gold:var(--status);--cream:#efe6da;--surface:#fffdfa;--ground-alt:#f2ebe3;--border:#8c837b;--border-width:1px;--focus:#342a38;--focus-halo-color:#f8f3ee;--muted:#6b5f70;--shadow:0 24px 70px -30px rgba(36,29,39,.4);--font-display:"Fraunces",Georgia,'Times New Roman',serif;--font-body:"Work Sans",-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--fsa-display:none;--fsa-body:none;--fsa-mono:none;--br:var(--brand);--ac:var(--accent);--sc:var(--secondary);--st:var(--status)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--type-display-size:clamp(2.44rem,5.4vw,3.9rem);--type-display-weight:500;--type-display-line-height:1.08;--type-display-letter-spacing:-.01em;--type-h1-size:clamp(1.95rem,4.1vw,2.44rem);--type-h1-weight:700;--type-h1-line-height:1.1;--type-h1-letter-spacing:-.028em;--type-h1-measure:22ch;--type-h2-size:clamp(1.25rem,3.3vw,1.95rem);--type-h2-weight:700;--type-h2-line-height:1.14;--type-h2-letter-spacing:-.022em;--type-h2-measure:26ch;--type-h3-size:1.25rem;--type-h3-weight:650;--type-h3-line-height:1.25;--type-h3-letter-spacing:-.01em;--type-h3-measure:30ch;--type-h4-size:1.0rem;--type-h4-weight:600;--type-h4-line-height:1.35;--type-h4-letter-spacing:-.005em;--type-h4-measure:34ch;--type-lede-size:1.2rem;--type-lede-weight:400;--type-lede-line-height:1.55;--type-lede-letter-spacing:0;--type-lede-measure:56ch;--type-body-size:1.0rem;--type-body-weight:400;--type-body-line-height:1.62;--type-body-letter-spacing:0;--type-body-measure:66ch;--type-body-small-size:0.9rem;--type-body-small-weight:400;--type-body-small-line-height:1.55;--type-body-small-letter-spacing:0;--type-body-small-measure:60ch;--type-quote-size:1.0rem;--type-quote-weight:400;--type-quote-line-height:1.58;--type-quote-letter-spacing:-.005em;--type-quote-measure:54ch;--type-label-size:0.82rem;--type-label-weight:600;--type-label-line-height:1.3;--type-label-letter-spacing:.08em;--type-label-measure:24ch;--type-label-strong-size:0.88rem;--type-label-strong-weight:600;--type-label-strong-line-height:1.4;--type-label-strong-letter-spacing:0;--type-label-strong-measure:24ch;--type-meta-size:0.82rem;--type-meta-weight:400;--type-meta-line-height:1.45;--type-meta-letter-spacing:0;--type-meta-measure:48ch;--type-eyebrow-size:0.78rem;--type-eyebrow-weight:600;--type-eyebrow-line-height:1.3;--type-eyebrow-letter-spacing:.14em;--type-eyebrow-measure:34ch;--type-nav-size:0.85rem;--type-nav-weight:500;--type-nav-line-height:1.2;--type-nav-letter-spacing:0;--type-cta-size:0.85rem;--type-cta-weight:600;--type-cta-line-height:1.15;--type-cta-letter-spacing:.01em;--type-cta-small-size:0.88rem;--type-cta-small-weight:500;--type-cta-small-line-height:1.4;--type-cta-small-letter-spacing:.01em;--type-cta-small-measure:24ch;--type-brand-size:1.05rem;--type-brand-weight:600;--type-brand-line-height:1.2;--type-brand-letter-spacing:-.005em;--type-brand-measure:20ch;--type-rating-size:1.05rem;--type-rating-weight:400;--type-rating-line-height:1;--type-rating-letter-spacing:-.02em;--type-data-hero-size:3.05rem;--type-data-hero-weight:500;--type-data-hero-line-height:1;--type-data-hero-letter-spacing:-.03em;--type-data-hero-measure:8ch;--type-data-size:1.56rem;--type-data-weight:700;--type-data-line-height:1;--type-data-letter-spacing:-.02em;--type-data-small-size:0.82rem;--type-data-small-weight:500;--type-data-small-line-height:1.2;--type-data-small-letter-spacing:.02em;--type-data-small-measure:10ch;--space-2xs:0.25rem;--space-xs:0.5rem;--space-xs-plus:0.75rem;--space-sm:1rem;--space-md:1.5rem;--space-md-plus:2rem;--space-lg:2.5rem;--space-lg-plus:3rem;--space-xl:4rem;--space-xl-plus:5rem;--space-2xl:6rem;--space-3xl:8rem;--space-section:10rem;--announce-h:38px;--rhythm-major:8rem;--rhythm-standard:6rem;--rhythm-tight:4rem;--rhythm-signature-top:10rem;--rhythm-signature-bottom:6rem;--layout-container:1160px;--layout-container-narrow:820px;--layout-measure:66ch;--layout-measure-narrow:48ch;--layout-gutter:1.5rem;--layout-grid-gap:2.5rem;--layout-grid-gap-tight:1.5rem;--layout-section-y:8rem;--layout-section-y-tight:6rem;--layout-bp-sm:600px;--layout-bp-md:900px;--layout-bp-lg:1200px;--radius-sm:6px;--radius-md:12px;--radius-lg:20px;--radius-pill:999px;--border:1px;--border-strong:2px;--focus-ring:2px;--focus-offset:2px;--focus-halo:4px;--shadow-none:none;--shadow-sm:0 1px 2px rgba(36,29,39,.05),0 2px 8px rgba(36,29,39,.04);--shadow-md:0 2px 6px rgba(36,29,39,.06),0 12px 32px rgba(36,29,39,.08);--shadow-lg:0 4px 10px rgba(36,29,39,.07),0 24px 64px rgba(36,29,39,.10);--type-caption-size:0.75rem;--type-caption-weight:400;--type-caption-line-height:1.5;--type-caption-letter-spacing:.01em;--type-caption-measure:44ch;--bg:#1c1620;--fg:#f0e9ea;--brand:#bab2bc;--accent:#bd8670;--secondary:#a3ab93;--status:#d0ab7c;--flush:#e2836e;--ink:var(--fg);--paper:var(--bg);--plum:var(--brand);--plum-deep:#dedadf;--clay:var(--accent);--sage:var(--secondary);--gold:var(--status);--cream:#2a2130;--surface:#221b24;--ground-alt:#241d29;--border:#726c74;--border-width:1px;--focus:#dedadf;--focus-halo-color:#1c1620;--muted:#a2949e;--shadow:0 24px 70px -30px rgba(0,0,0,.65);--font-display:"Fraunces",Georgia,'Times New Roman',serif;--font-body:"Work Sans",-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--fsa-display:none;--fsa-body:none;--fsa-mono:none;--br:var(--brand);--ac:var(--accent);--sc:var(--secondary);--st:var(--status)}}
:root[data-theme="dark"]{--type-display-size:clamp(2.44rem,5.4vw,3.9rem);--type-display-weight:500;--type-display-line-height:1.08;--type-display-letter-spacing:-.01em;--type-h1-size:clamp(1.95rem,4.1vw,2.44rem);--type-h1-weight:700;--type-h1-line-height:1.1;--type-h1-letter-spacing:-.028em;--type-h1-measure:22ch;--type-h2-size:clamp(1.25rem,3.3vw,1.95rem);--type-h2-weight:700;--type-h2-line-height:1.14;--type-h2-letter-spacing:-.022em;--type-h2-measure:26ch;--type-h3-size:1.25rem;--type-h3-weight:650;--type-h3-line-height:1.25;--type-h3-letter-spacing:-.01em;--type-h3-measure:30ch;--type-h4-size:1.0rem;--type-h4-weight:600;--type-h4-line-height:1.35;--type-h4-letter-spacing:-.005em;--type-h4-measure:34ch;--type-lede-size:1.2rem;--type-lede-weight:400;--type-lede-line-height:1.55;--type-lede-letter-spacing:0;--type-lede-measure:56ch;--type-body-size:1.0rem;--type-body-weight:400;--type-body-line-height:1.62;--type-body-letter-spacing:0;--type-body-measure:66ch;--type-body-small-size:0.9rem;--type-body-small-weight:400;--type-body-small-line-height:1.55;--type-body-small-letter-spacing:0;--type-body-small-measure:60ch;--type-quote-size:1.0rem;--type-quote-weight:400;--type-quote-line-height:1.58;--type-quote-letter-spacing:-.005em;--type-quote-measure:54ch;--type-label-size:0.82rem;--type-label-weight:600;--type-label-line-height:1.3;--type-label-letter-spacing:.08em;--type-label-measure:24ch;--type-label-strong-size:0.88rem;--type-label-strong-weight:600;--type-label-strong-line-height:1.4;--type-label-strong-letter-spacing:0;--type-label-strong-measure:24ch;--type-meta-size:0.82rem;--type-meta-weight:400;--type-meta-line-height:1.45;--type-meta-letter-spacing:0;--type-meta-measure:48ch;--type-eyebrow-size:0.78rem;--type-eyebrow-weight:600;--type-eyebrow-line-height:1.3;--type-eyebrow-letter-spacing:.14em;--type-eyebrow-measure:34ch;--type-nav-size:0.85rem;--type-nav-weight:500;--type-nav-line-height:1.2;--type-nav-letter-spacing:0;--type-cta-size:0.85rem;--type-cta-weight:600;--type-cta-line-height:1.15;--type-cta-letter-spacing:.01em;--type-cta-small-size:0.88rem;--type-cta-small-weight:500;--type-cta-small-line-height:1.4;--type-cta-small-letter-spacing:.01em;--type-cta-small-measure:24ch;--type-brand-size:1.05rem;--type-brand-weight:600;--type-brand-line-height:1.2;--type-brand-letter-spacing:-.005em;--type-brand-measure:20ch;--type-rating-size:1.05rem;--type-rating-weight:400;--type-rating-line-height:1;--type-rating-letter-spacing:-.02em;--type-data-hero-size:3.05rem;--type-data-hero-weight:500;--type-data-hero-line-height:1;--type-data-hero-letter-spacing:-.03em;--type-data-hero-measure:8ch;--type-data-size:1.56rem;--type-data-weight:700;--type-data-line-height:1;--type-data-letter-spacing:-.02em;--type-data-small-size:0.82rem;--type-data-small-weight:500;--type-data-small-line-height:1.2;--type-data-small-letter-spacing:.02em;--type-data-small-measure:10ch;--space-2xs:0.25rem;--space-xs:0.5rem;--space-xs-plus:0.75rem;--space-sm:1rem;--space-md:1.5rem;--space-md-plus:2rem;--space-lg:2.5rem;--space-lg-plus:3rem;--space-xl:4rem;--space-xl-plus:5rem;--space-2xl:6rem;--space-3xl:8rem;--space-section:10rem;--announce-h:38px;--rhythm-major:8rem;--rhythm-standard:6rem;--rhythm-tight:4rem;--rhythm-signature-top:10rem;--rhythm-signature-bottom:6rem;--layout-container:1160px;--layout-container-narrow:820px;--layout-measure:66ch;--layout-measure-narrow:48ch;--layout-gutter:1.5rem;--layout-grid-gap:2.5rem;--layout-grid-gap-tight:1.5rem;--layout-section-y:8rem;--layout-section-y-tight:6rem;--layout-bp-sm:600px;--layout-bp-md:900px;--layout-bp-lg:1200px;--radius-sm:6px;--radius-md:12px;--radius-lg:20px;--radius-pill:999px;--border:1px;--border-strong:2px;--focus-ring:2px;--focus-offset:2px;--focus-halo:4px;--shadow-none:none;--shadow-sm:0 1px 2px rgba(36,29,39,.05),0 2px 8px rgba(36,29,39,.04);--shadow-md:0 2px 6px rgba(36,29,39,.06),0 12px 32px rgba(36,29,39,.08);--shadow-lg:0 4px 10px rgba(36,29,39,.07),0 24px 64px rgba(36,29,39,.10);--type-caption-size:0.75rem;--type-caption-weight:400;--type-caption-line-height:1.5;--type-caption-letter-spacing:.01em;--type-caption-measure:44ch;--bg:#1c1620;--fg:#f0e9ea;--brand:#bab2bc;--accent:#bd8670;--secondary:#a3ab93;--status:#d0ab7c;--flush:#e2836e;--ink:var(--fg);--paper:var(--bg);--plum:var(--brand);--plum-deep:#dedadf;--clay:var(--accent);--sage:var(--secondary);--gold:var(--status);--cream:#2a2130;--surface:#221b24;--ground-alt:#241d29;--border:#726c74;--border-width:1px;--focus:#dedadf;--focus-halo-color:#1c1620;--muted:#a2949e;--shadow:0 24px 70px -30px rgba(0,0,0,.65);--font-display:"Fraunces",Georgia,'Times New Roman',serif;--font-body:"Work Sans",-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--fsa-display:none;--fsa-body:none;--fsa-mono:none;--br:var(--brand);--ac:var(--accent);--sc:var(--secondary);--st:var(--status)}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--font-body);font-size-adjust:var(--fsa-body);line-height:1.7;-webkit-font-smoothing:antialiased;overflow-x:hidden}
@media (prefers-reduced-motion:reduce){ *{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}
.reveal, .stag>*, .reveal .shead, .why-photo, .t-photo, .t-milestones, .t-social, .core-panel, .reveal .final-lede{opacity:1!important;transform:none!important}
.t-milestones::before{transform:scaleX(1)!important;transition:none!important}
.track, .find-card, .find-teach{translate:none!important}}
/* A headline may wrap BETWEEN words. It may never break INSIDE one -- a brand read as
YOGA+BARR / E+PILATES is the most visible defect a page can ship. So: word-break stays
`normal` (never break-all, which would split any word to fill a line), hyphens stay
`manual` (no automatic hyphenation inventing a break mid-word), and overflow-wrap is
`break-word` -- the last-resort guard that only fires when a SINGLE token is wider than
the column, where the alternative is horizontal overflow.
text-wrap reads a token rather than being hardcoded to `balance`. Balance is right for a
headline made of words; it is actively unsafe for one set as spaced capitals, because the
browser sees each letter as its own word and is free to balance the two lines by breaking
in the middle of the brand -- which is exactly how nolosol-hot-yoga's
"Y O G A + B A R R E + P I L A T E S" measured. _headline_wrap() below hands such a
profile plain `wrap`, so the line fills to the column edge and the break lands at a
separator rather than at the mathematically prettiest letter. */
/* The same last-resort guard headings get, extended to body copy and links. A single
token wider than its column pushes the whole page sideways: shakti-power-yoga's
contact page overflowed by 17px at 390px on one 30-character email address. This
only fires when a token genuinely cannot fit -- ordinary words are unaffected,
because word-break stays `normal` and hyphens stay `manual`. */
p, li, a, dd, dt, address, figcaption{overflow-wrap:break-word;word-break:normal;hyphens:manual}
h1, h2, h3{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-weight:440;letter-spacing:-.01em;text-wrap:var(--headline-wrap,balance);word-break:normal;overflow-wrap:break-word;hyphens:manual;margin:0;color:var(--plum-deep)}
a{color:inherit}
img{max-width:100%;display:block}
.container{max-width:var(--layout-container);margin:0 auto;padding:0 var(--space-md)}
.eyebrow{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);text-transform:uppercase;color:var(--clay);font-weight:var(--type-meta-weight)}
.num{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-variant-numeric:tabular-nums}
.lede{font-size:var(--type-body-size);font-weight:var(--type-body-weight);letter-spacing:var(--type-body-letter-spacing);color:var(--muted);max-width:var(--type-body-small-measure)}
section{position:relative;z-index:1}
.btn{display:inline-flex;align-items:center;gap:var(--space-2xs);padding:var(--space-sm) var(--space-md);border-radius:var(--radius-pill);font-size:var(--type-cta-size);font-weight:var(--type-cta-weight);letter-spacing:var(--type-cta-letter-spacing);text-decoration:none;border:var(--border-width) solid transparent;transition:transform .25s ease,box-shadow .25s ease;position:relative;overflow:hidden}
.btn-primary{background:var(--plum-deep);color:var(--paper)}
.btn-primary:hover{transform:translateY(-2px);box-shadow:var(--shadow-md)}
.btn-ghost{border-color:var(--border);color:var(--ink)}
.btn-ghost:hover{border-color:var(--clay);color:var(--clay)}
/* THIRD BUTTON TIER. The reference shipped two -- .btn-primary (commit) and .btn-ghost
(consider) -- which forces every link-weight action into one of them: an "explore" or a
"read the policy" arrives dressed as a considered choice and competes with the real CTA.
.btn-text is the tertiary tier: no fill, no border, the same type role and the same hit
area as its siblings, distinguished only by an underline that appears on hover and focus.
It is deliberately used by no existing section -- which section uses which tier is a
composition decision, and this change only makes the tier exist. html.py's _btn() takes it
as style=BTN_TEXT. */
.btn-text{border-color:transparent;background:none;color:var(--plum);padding-left:var(--space-xs);padding-right:var(--space-xs);text-underline-offset:3px}
.btn-text:hover{color:var(--clay);text-decoration:underline}
.btn-text::after{display:none}
/* INTERACTION STATES -- hover, focus, press and disabled, in one place.
The page shipped 21 hover rules and zero focus rules. A pointer user could see every
affordance and a keyboard user could see none: tabbing through the nav moved an invisible
caret, which is WCAG 2.4.7 (Focus Visible) failed outright, not a matter of taste.
Rules here are grouped by STATE rather than by component on purpose. The defect happened
because each component's styles were written in its own block and the pointer state was
the only one anybody remembered to write there; a state that lives in one list can be
audited by reading one list, which is what visualqa's new no_focus_state check now does.
The keyboard-only pseudo-class is used rather than the plain one -- a mouse click on a
button must not leave a ring behind, and the browser itself is the only thing that knows
whether focus arrived from a key. No rule anywhere sets outline:none without painting this
ring in its place.
Every rule below is ONE line. standalone_css() resolves the  scope line by line, so a
selector list wrapped across two lines silently ships as two broken rules -- which is
exactly how the first version of this block rendered as no focus ring at all. */
.btn:focus-visible, .nav-links a:focus-visible, .nav-cta:focus-visible, .find-card:focus-visible, .find-go:focus-visible, .rev-card:focus-visible, .rev-card a:focus-visible, .rev-src:focus-visible, .fq:focus-visible, .reel:focus-visible, .co-tile:focus-visible, .offer-chip:focus-visible, .foot-col a:focus-visible, .foot-legal a:focus-visible, .fsoc a:focus-visible, .rating-link:focus-visible, .inside-tile:focus-visible, .inside-tile a:focus-visible, .final-addr a:focus-visible, .brand-mark:focus-visible, .t2-socials a:focus-visible{outline:var(--focus-ring) solid var(--focus);outline-offset:var(--focus-offset);box-shadow:0 0 0 var(--focus-halo) var(--focus-halo-color);border-radius:var(--radius-sm)}
/* An element that already carries its own radius keeps it -- a pill button ringed by a 6px
rectangle reads as a rendering fault rather than as focus. */
.btn:focus-visible, .nav-cta:focus-visible, .offer-chip:focus-visible, .find-go:focus-visible{border-radius:var(--radius-pill)}
.find-card:focus-visible, .rev-card:focus-visible, .rev-card a:focus-visible{border-radius:var(--radius-lg)}
.reel:focus-visible, .co-tile:focus-visible, .inside-tile:focus-visible, .inside-tile a:focus-visible{border-radius:var(--radius-md)}
/* PRESSED. The press is the one moment the visitor is asking "did that register?", so it is
fast and small: the element settles by roughly the distance hover lifted it, over a
duration short enough to read as contact rather than as a second animation. */
.btn:active, .nav-cta:active, .offer-chip:active{transform:translateY(1px);transition-duration:.08s}
.find-card:active, .rev-card:active, .co-tile:active, .reel:active{transform:translateY(-2px);transition-duration:.08s}
.fq:active .pm{transform:scale(.92)}
/* DISABLED. Both spellings: [disabled] is the real attribute on a <button>, and
aria-disabled is what an <a> must carry because an anchor cannot be disabled. Hover and
press are cancelled explicitly -- a dimmed control that still lifts under the pointer is
telling the visitor it works. */
.btn[disabled], .btn[aria-disabled="true"], button[disabled], [aria-disabled="true"]{opacity:.45;cursor:not-allowed}
.btn[disabled]:hover, .btn[aria-disabled="true"]:hover, button[disabled]:hover, [aria-disabled="true"]:hover{transform:none;box-shadow:none;border-color:var(--border)}
.btn[disabled]:hover::after, .btn[aria-disabled="true"]:hover::after{opacity:0}
.btn[disabled]:active, .btn[aria-disabled="true"]:active{transform:none}
/* NAV -- the reference's fixed header, present on every page. Its own selector, `nav` itself,
was never given here: html.py's nav() builder always emits <nav id="t2nav">, but nothing
in this stylesheet ever styled a bare `nav` element or its child classes, so the entire
header rendered as unstyled browser-default text -- confirmed live before this fix. */
nav{position:fixed;top:0;left:0;right:0;z-index:100;display:flex;align-items:center;justify-content:space-between;padding:var(--space-sm) max(var(--space-lg),calc((100vw - var(--layout-container)) / 2 + var(--layout-gutter)));transition:background .4s,box-shadow .4s,backdrop-filter .4s;background:var(--paper)}
nav.scrolled{background:color-mix(in srgb,var(--paper) 88%,transparent);backdrop-filter:blur(14px);box-shadow:0 var(--border-width) 0 var(--border),var(--shadow-lg)}
.brand-mark{display:flex;align-items:center;gap:var(--space-2xs);text-decoration:none;line-height:0}
.brand-mark img{height:42px;width:auto}
.brand-mark .logo-white{display:none}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"])  .logo-color{display:none}:root:not([data-theme="light"])  .logo-white{display:block}}
:root[data-theme="dark"]  .logo-color{display:none}
:root[data-theme="dark"]  .logo-white{display:block}
.brand-word{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-h4-size);font-weight:var(--type-h4-weight);letter-spacing:var(--type-h4-letter-spacing);color:var(--plum-deep)}
.nav-links{display:flex;gap:var(--space-md);align-items:center;font-size:var(--type-nav-size);font-weight:var(--type-nav-weight);letter-spacing:var(--type-nav-letter-spacing)}
.nav-links a:not(.nav-cta){text-decoration:none;color:var(--ink);opacity:.82;position:relative;padding:var(--space-xs) 0}
.nav-links a:not(.nav-cta)::after{content:'';position:absolute;left:0;bottom:0;width:0;height:1px;background:var(--clay);transition:width .3s}
.nav-links a:not(.nav-cta):hover{opacity:1}
.nav-links a:not(.nav-cta):hover::after{width:100%}
.nav-cta{font-size:var(--type-cta-size);font-weight:var(--type-cta-weight);letter-spacing:var(--type-cta-letter-spacing);padding:var(--space-xs-plus) var(--space-sm);border-radius:var(--radius-pill);background:var(--plum-deep);color:var(--paper)!important;text-decoration:none;white-space:nowrap;transition:background .25s,transform .25s;opacity:1!important}
.nav-cta:hover{background:var(--clay);transform:translateY(-1px)}
.nav-cta::after{display:none}
@media (max-width:900px){ .brand-mark img{height:34px}}
/* MOBILE NAV -- the header below 900px. What was here before was `.nav-links{display:none}`
and nothing else: no hamburger, no drawer, no script. The `.nav-cta` ("Book Now") is emitted
INSIDE `.nav-links` by html.py, so hiding that one box took the whole menu AND the only
booking button with it. Home pages have a `.mobile-bar` to fall back on; the other 39 pages
in this build do not, so a phone visitor on Offers, Contact, FAQ or any legal page had no
navigation at all and no way to book.
This is solved WITHOUT JavaScript and WITHOUT new markup, deliberately:
* No JavaScript, because the failure mode of a JS menu is the one this project has already
shipped once -- CSS hides the content, the script does not run, and the page is a dead
end with no fallback. A toggle would also need a button in html.py, which this change
does not own. Nothing below hides anything; every link is in the document and painted.
* The nav becomes two rows. Row one is the brand and the CTA; row two is the link list,
laid out as a horizontal rail that scrolls when it is longer than the screen. Scrolling
a rail is a gesture a phone user already has; a menu they cannot see is not.
* The CTA is lifted OUT of the scrolling rail (absolutely positioned against `nav`, which
is already `position:fixed` and therefore its own containing block) so that booking is
on screen at all times and can never be the item that happens to be scrolled off. That
is the one link that must never need a gesture to reach.
Everything here is inside `@media (max-width:900px)`, so desktop rendering is byte-identical
to what it was -- checked against the frozen tara-glazer reference at 1440x900. */
/* row-gap was 0, so the brand row and the link rail below it touched at exactly 0px --
WCAG 2.5.8 wants 8px between targets, and with every link now a full 44px box the two
rows were literally edge to edge (measured: brand 64-108, rail 108-152). An explicit
row-gap separates them, and --nav-h below (the one number the hero's top padding is
derived from) goes 100px -> 116px so the hero still starts below the whole taller stack
rather than under half of it. */
@media (max-width:900px){ nav{flex-wrap:wrap;row-gap:var(--space-sm);padding:var(--space-xs) var(--space-md)}}
@media (max-width:900px){ .brand-mark{order:1;min-height:40px;max-width:calc(100% - 9.5rem);line-height:var(--type-brand-line-height)}}
@media (max-width:900px){ .brand-word{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
/* The CTA is absolutely positioned out of flow against row one, so it never grew with the
rest of the header -- once every control became 44px tall it hung down past the brand row
and overlapped the first link of the scrolling rail (measured at 390px: CTA 64-111 across
the rail's 108-152). Centring it on row one's 44px band keeps it out of row two. */
@media (max-width:900px){ .nav-cta{position:absolute;top:var(--space-xs);right:var(--space-md);z-index:2;height:44px;display:inline-flex;align-items:center}}
@media (max-width:900px){ .nav-links{order:2;display:flex;flex:1 0 100%;min-width:0;flex-wrap:nowrap;gap:var(--space-md);align-items:center;overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;scrollbar-width:none;-ms-overflow-style:none}}
@media (max-width:900px){ .nav-links::-webkit-scrollbar{width:0;height:0}}
@media (max-width:900px){ .nav-links a:not(.nav-cta){flex:0 0 auto;white-space:nowrap;opacity:1}}
/* The header is now two rows, so it is taller than the one row it replaced, and the home
hero -- whose only top padding was the height of the announcement bar -- had its own <h1>
underneath it. Measured before this line: nav ends at y=138, the h1 began at y=102 on
three of the four profiles. The stack declares its own height once, here, and the hero
starts below the whole of it rather than below half of it. */
@media (max-width:900px){
body{--nav-h:116px}
}
#top{display:block;position:relative;top:-1px}
/* SCROLL ANCHORING UNDER A FIXED HEADER.
`nav` is `position:fixed`, so it is out of flow and the browser's jump-to-anchor lands the
target's top edge at y=0 -- underneath the header, not below it. Measured before this line
on the built pages: every in-page jump put its own heading behind the bar. tara-glazer's
"#schedule" landed "Find your workshop." at y=100 with the nav ending at y=117; seek-yoga,
kathryn-hocking and nomastayinkc were the same, and on a phone (nav 156px tall) the heading
was half a bar deep. That is the "sticky header over content" report: not a paint-order bug
-- z-index is right and the bar is meant to be on top -- but the scroll position being
computed as though the header did not exist.
`scroll-margin-top` is the mechanism the platform provides for exactly this: it offsets
where a scroll operation *stops*, without moving, resizing or padding the element itself.
Nothing in the page's layout changes; only the resting scroll position does. It is applied
to every element that can be an anchor target rather than to a hand-listed set of ids, so a
section the composer adds later cannot arrive without it.
--nav-h has to be a real number at every width for this to be correct. It existed only
inside the <=900px block, so above that it resolved to nothing and the calc collapsed.
Declared here for the one-row desktop header and overridden by the two-row phone value
that already exists below. --announce-h is included because nav sits below the
announcement bar when one is present (`nav{top:var(--announce-h)}`); it is 0 otherwise. */
body{--nav-h:80px}
[id]{scroll-margin-top:calc(var(--announce-h,0px) + var(--nav-h) + var(--space-md))}
/* #top is the "back to the top" target and must land at the actual top of the document, not
a header's height below it -- there is nothing above it to be covered by. */
#top{scroll-margin-top:0}
/* FOOTER -- same bug as nav: html.py's footer builder emits a real <footer> with a .fsoc
social-icon row, and neither the element nor the class had a single rule. */
footer{padding:var(--space-xl) 0 var(--space-2xl);font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);color:var(--muted);border-top:var(--border-width) solid var(--border)}
.foot-grid{display:grid;gap:var(--space-lg);align-items:start;grid-template-columns:1.5fr 1fr 1fr 1fr}
.foot-grid.cols-2{grid-template-columns:1.5fr 1fr}
.foot-grid.cols-3{grid-template-columns:1.5fr 1fr 1fr}
.foot-grid.cols-4{grid-template-columns:1.5fr 1fr 1fr 1fr}
@media (max-width:900px){ .foot-grid{grid-template-columns:1fr 1fr;gap:var(--space-lg)}}
@media (max-width:600px){ .foot-grid{grid-template-columns:1fr;gap:var(--space-md)}}
.foot-brand img{height:34px;width:auto;margin:0 0 var(--space-sm);display:block;filter:brightness(0) saturate(100%) invert(13%) sepia(12%) saturate(1400%) hue-rotate(255deg);opacity:.9}
.foot-brand p{margin:0 0 var(--space-sm);max-width:34ch;line-height:1.65}
.foot-col h3{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-label-size);font-weight:var(--type-label-weight);letter-spacing:var(--type-label-letter-spacing);text-transform:uppercase;color:var(--ink);margin:0 0 var(--space-sm);font-weight:var(--type-label-weight)}
.foot-col a, .foot-col div{display:block;color:var(--muted);text-decoration:none;margin:0 0 var(--space-xs-plus);line-height:1.5}
.foot-col a:hover{color:var(--clay)}
.fday{display:inline-block;min-width:42px;color:var(--ink);font-weight:500}
.foot-legal{display:flex;flex-wrap:wrap;gap:0 var(--space-xs);margin-top:var(--space-lg);padding-top:var(--space-md);border-top:var(--border-width) solid var(--border);font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing)}
.foot-legal a{color:var(--muted);text-decoration:none}
.foot-legal a:hover{color:var(--clay)}
.foot-bottom{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:var(--space-sm);margin-top:var(--space-sm)}
@media (max-width:600px){ footer{padding-bottom:var(--space-2xl)}}
@media (max-width:600px){ footer{padding-bottom:var(--space-lg)}}
.fsoc{display:flex;gap:var(--space-sm)}
.fsoc a{color:var(--muted);transition:color .25s}
.fsoc a:hover{color:var(--clay)}
.fsoc svg{width:18px;height:18px}
.mobile-bar{display:none}
@media (max-width:600px){
.mobile-bar{display:flex;position:fixed;bottom:0;left:0;right:0;z-index:90;background:var(--surface);border-top:var(--border-width) solid var(--border);padding:var(--space-sm) var(--space-sm) calc(var(--space-sm) + env(safe-area-inset-bottom));gap:var(--space-xs);box-shadow:var(--shadow-lg)}
.mobile-bar .btn{flex:1;justify-content:center;padding:var(--space-sm)}
/* THE FIXED CTA BAR MUST NOT COVER THE END OF THE PAGE.
The bar is `position:fixed`, so it is out of flow and occupies no space: whatever the page
ends with is painted underneath it. The reserve below is what buys that space back, and it
was a bare `--space-xl` -- 64px -- guessed against a bar that measures 90px in the built
pages. Measured before this line at 390px on energy-works-healing: the bar covered "Claim
the intro offer" and the line beneath it, and the footer's last row sat behind it on every
profile with a bar.
The guess cannot be made a better guess, because the bar's height is not a constant: it is
two lines of button text when a CTA label wraps, one when it does not, plus
env(safe-area-inset-bottom), which is 0 on most phones and ~34px on a notched one. Any
fixed number is wrong for some page on some device.
So the number is derived instead of guessed. --bar-h is built from the same padding and
line-height the bar itself is built from, and the reserve spends exactly that plus one gap.
Reserving is also made conditional: `:has(.mobile-bar)` means a page with no bar -- every
interior page -- no longer carries dead space at its foot, which the unconditional rule
gave it.
The reserve goes on the scope root, not on <main>. <footer> is a sibling of <main>, so
padding <main> leaves the footer exactly as exposed as it was: measured with the reserve on
<main>, the mid-page coverage cleared but the footer's legal row ("Terms Privacy
Cancellation Refund Shipping") was still behind the bar on four profiles. The last thing on
the page is in the footer, so the space has to be bought after the footer. */
body{--bar-h:calc(var(--space-sm) * 2 + var(--space-sm) * 2 + 1.2em + env(safe-area-inset-bottom))}
body:has(.mobile-bar){padding-bottom:calc(var(--bar-h) + var(--space-md))}
/* `:has()` is the whole mechanism above, and a browser without it would reserve nothing and
put the bar back over the footer. This is the same reserve, unconditional, for that case
only: a browser that supports :has() never evaluates it. */
@supports not selector(:has(*)){ body{padding-bottom:calc(var(--bar-h) + var(--space-md))}}
}
/* Full-bleed hero (RESEARCH-hero-section-2026-09-07.md, owner-approved 2026-09-07, DeepSeek
round 4 score 9/10). Additive: only .hero-fullbleed carries these rules, so a profile
whose hero() call omits `design=` renders the pre-existing .hero-grid markup above,
unchanged. --tint is the one per-profile setting (see html.py::_hero_full_bleed) -- a
peak alpha the calc()s below fade from, never a flat tint across the whole photo (that
was the flaw the owner's own review caught in an earlier draft). */
.hero-fullbleed{padding:0}
/* min-height is a viewport-relative floor (min(Nsvh,px)), never a bare pixel -- STANDARDS-
sizing-and-sections-2026-08-01.md rule #1, independently confirmed by Beautify's own
heightToken system (min(svh,px)) in the cross-thread exchange 2026-09-08. svh, not vh:
vh jumps when a mobile browser's address bar shows/hides; svh does not. */
/* Real bug, found live at ~700-1024px ("tab mode"): aspect-ratio:16/9 is a HARD height cap
at that width, but the trust-line's actual content (xl headline + subtitle + CTA + trust
row, stacked) needs more room than a 16:9 box gives at a narrower width -- so the trust
line was pushed past the box's own bottom edge and `overflow:hidden` silently clipped it.
Never visible at 1440 (box tall enough) or at 390 (mobile's own shorter stack), which is
exactly why it went unnoticed until a real "tab mode" width was tested.
Fix: aspect-ratio is gone. min-height is a FLOOR only; the box's real height is
`max(that floor, however tall the flex content actually needs)` -- ordinary block/flex
sizing, not a ratio that can cap content out of the box. The photo (position:absolute,
inset:0, height:100%) covers whatever height results -- it never needs the box to be a
specific ratio, object-fit:cover adapts to any height cleanly. */
.hero-full{position:relative;width:100%;min-height:min(55svh,420px);overflow:hidden;display:flex}
@media (max-width:600px){ .hero-full{min-height:min(45svh,360px)}}
/* Owner, 2026-09-08: at ~1400px-wide the hero "feels small" -- because 55svh never wins past
~764px of viewport HEIGHT (55% of 764 = 420, the px ceiling), so every laptop/desktop screen
was capped at the exact same 420px band regardless of how wide the window got. Raised for
wide viewports specifically, same min(Nsvh,Mpx) shape (never a bare px, STANDARDS rule #1) --
see RESEARCH-hero-section-2026-09-07.md addendum for the 70-85vh industry band this cites. */
@media (min-width:1200px){ .hero-full{min-height:min(78svh,720px)}}
.hero-full img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center 30%}
/* QA-hero-2026-09-08-v2.md External-trend gap: no real class video exists for this profile to
swap in here (research.json's "video" entries are YouTube thumbnails and expiring, signed
Instagram CDN urls -- neither a stable local asset), so a slow Ken Burns pan/zoom on the
static photo closes the gap 2025-2026 premium wellness heroes are graded against instead.
The animation is on the <img> alone, never on .hero-full or .hero-tint/.hero-text-col, so
the scrim and the text sitting on top of it stay perfectly still -- only the photo breathes.
scale(1.12) keeps the zoom inside object-fit:cover's already-cropped frame, so no edge of
the photo is ever visible sliding in. No JS and no bespoke reduced-motion rule needed: the
`@media (prefers-reduced-motion:reduce)` block above already zeroes animation-duration for
every element site-wide, the same mechanism .reveal/.stag already depend on, so a
reduced-motion visitor gets the plain static photo for free. Runs ONCE and holds
(`forwards`, no `infinite`) -- test_design_restraint.py's own rule 31 bans an infinite
decorative loop repo-wide (one entrance keyframe, never a breathing loop), so this is a
single slow settle into the zoomed frame rather than a repeating pan. */
/* Ken Burns WITHOUT growing the box. transform:scale(1.12) on the <img> grew its border
box to 112% of the viewport (measured: 1453px wide inside a 1440px window, left:-7).
.hero-full's overflow:hidden hid it visually, but the layout box really was that wide --
tools/visual_qa.py reads getBoundingClientRect(), so it reported img.hero-full-kb as
overflowing the viewport at all four widths, and any future ancestor that stops clipping
turns that into a real sideways scroll. The photo is already object-fit:cover, so the same
slow drift is available by animating object-position -- the crop window pans across the
source pixels while the element's box stays exactly 100% x 100%. Identical motion, zero
overflow, and no scale means no resampling blur either. */
.hero-full-kb{animation:t2kenburns 12s ease-out forwards;will-change:object-position}
@keyframes t2kenburns{from{object-position:center 30%}to{object-position:center 62%}}
.hero-tint{position:absolute;inset:0}
/* Owner, 2026-09-08 (verbatim): "hero ke andar sab kuch center karo -- heading, CTA, sab
ek ke niche ek center me". This OVERRIDES the earlier zone-anchored left/right text
placement for alignment purposes -- the zone-based left/right override block that used
to live here (keyed on data-zone-desktop) is deleted, not just hidden, per this repo's
own SSOT rule: one visual truth, not a dead rule shadowed by a later one. The
text_zone_for() algorithm in engine/compose/design.py still runs and still decides
`zone` -- that value is still read for the AA-contrast tint SAMPLING band
(_tint_for_contrast's zone= arg) and is still emitted as data-zone-desktop/-mobile on
the DOM for that reason -- it just no longer drives text alignment. Vertical centering
(flex-end -> center) chosen to match the centered-content pattern (no split grid) this
session's research doc found for Apple/Gucci/Prada, and because a bottom-anchored block
in the new ~720px-tall wide box left a visibly empty top two-thirds. */
.hero-full{align-items:center}
/* Uniform flat tint across the WHOLE photo -- owner's explicit, repeated instruction
("poori image pe tint chahiye, gradient/box nahi"). A concentrated-gradient scrim
was tried first and rejected for reading as barely-there overall. */
.hero-full .hero-tint{background:rgba(0,0,0,var(--tint))}
.hero-text-col{position:relative;color:#fff;display:flex;flex-direction:column;min-width:0;width:100%;padding:var(--space-lg) var(--space-md);text-align:center;align-items:center}
.hero-full .hero-eyebrow{color:#fff;opacity:.95;text-shadow:0 1px 3px rgba(0,0,0,.5)}
.hero-full h1{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-weight:var(--type-display-weight);font-size:var(--type-display-size);line-height:var(--type-display-line-height);letter-spacing:var(--type-display-letter-spacing);color:#fff;text-shadow:0 2px 10px rgba(0,0,0,.45);margin:0}
.hero-full h1 em{display:block;margin-top:var(--space-sm);font-style:italic;color:#fff}
.hero-full .lede{margin-top:var(--space-sm);color:#fff;opacity:.95;text-shadow:0 1px 4px rgba(0,0,0,.45);max-width:var(--type-body-small-measure)}
.hero-full .btn-primary{margin-top:var(--space-md)}
.hero-full .trust-line{margin-top:var(--space-md);color:#fff}
/* QA-hero-2026-09-08-v4.md: the box grows to min(78svh,720px) at >=1200px (the rule above),
but the text column's own padding and inter-element gaps stayed on the SAME token steps
as the 420px-tall box below that breakpoint -- so the extra height bought nothing but
empty photo, measured at ~86-106px of dead space above/below the text column at 1920px.
Fix stays on the existing SPACE_STEPS ladder (grammar.py), one tier up per gap, rather
than inventing a new number: the box got taller, so the rhythm inside it should too. */
@media (min-width:1200px){
.hero-text-col{padding:var(--space-2xl) var(--space-lg)}
.hero-full h1 em{margin-top:var(--space-md)}
.hero-full .lede{margin-top:var(--space-md)}
.hero-full .btn-primary{margin-top:var(--space-lg)}
.hero-full .trust-line{margin-top:var(--space-lg)}
}
/* QA-hero-2026-09-08-v2.md gap #2: the $3,550 Teacher Training pride-point (one of the
persona doc's 3 real buyers) got zero signal in the hero, which otherwise names only the
$68 intro offer. A second .btn-primary or .btn-ghost here would be the "two considered
options" failure the ONE-DOMINANT-ACTION rule above already exists to prevent, so this is
plain underlined text, smaller than the trust line, sitting quietly below it rather than
competing with the one real button. */
.hero-training-link{display:inline-block;margin-top:var(--space-sm);color:#fff;opacity:.85;font-size:var(--type-body-small-size);text-decoration:underline;text-underline-offset:3px}
.hero-training-link:hover, .hero-training-link:focus-visible{opacity:1}
.hero-training-link:focus-visible{outline:2px solid #fff;outline-offset:3px}
.hero{padding:var(--space-2xl) 0;position:relative;overflow:hidden}
/* Sizing only. min-height fills the fold beside the home hero's photo, and align-items centres the copy inside it; an interior page is `class="hero page-hero"` and inherited both, so ~150px of eyebrow/title/CTA sat centred in a ~790px box and the page opened on a ~320px blank band. An interior hero sizes to its content. */
.hero:not(.page-hero){display:flex;align-items:center}
.hero::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 70vw 55vh at 18% 12%,color-mix(in srgb,var(--clay) 11%,transparent),transparent 70%),radial-gradient(ellipse 60vw 50vh at 88% 72%,color-mix(in srgb,var(--sage) 12%,transparent),transparent 70%);pointer-events:none}
.hero-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:var(--space-lg);align-items:center;position:relative}
@media (max-width:900px){ .hero-grid{grid-template-columns:1fr;gap:var(--space-lg)}}
.hero h1{font-size:var(--type-display-size);font-weight:var(--type-display-weight);letter-spacing:var(--type-display-letter-spacing);line-height:var(--type-display-line-height)}
/* Same clipping promise as the later .hero h1 em rule, and the same correction: balance the
phrase across lines when it cannot fit on one, rather than overflowing the copy column. */
.hero h1 em{display:block;margin-top:var(--space-sm);font-style:italic;color:var(--clay);font-weight:var(--type-display-weight);font-size:var(--type-h1-size);line-height:1.05;white-space:normal;text-wrap:balance}
@media (max-width:900px){ .hero h1 em{white-space:normal;font-size:inherit}}
.hero .lede{margin-top:var(--space-sm)}
.offer-chip{gap:var(--space-sm);padding:var(--space-xs);background:var(--plum-deep);color:var(--paper);margin:var(--space-md) 0}
.offer-chip-tag{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);text-transform:uppercase;padding:var(--space-2xs) var(--space-xs-plus);border-radius:var(--radius-pill);background:var(--paper);color:var(--plum-deep);line-height:1.2}
.offer-chip-amt{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-data-size);font-weight:var(--type-data-weight);letter-spacing:var(--type-data-letter-spacing)}
.offer-chip-txt{font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);opacity:.88;padding-right:var(--space-xs)}
.hero-ctas{margin-top:var(--space-md);display:flex;gap:var(--space-sm);flex-wrap:wrap}
/* Rule 31: ONE entrance for the hero, not six staggered ones. Six delayed t2rise rules
(h1 .1s, lede .22s, chip .06s, ctas .34s, trust .44s, photo .2s) made the reader wait
half a second while the page assembled itself piece by piece, and every one of them
held its element at opacity:0 until the animation ran. Now the hero rises once, as the
single block it reads as, and animates a TRANSFORM only -- nothing is ever invisible. */
.hero-copy, .hero-photo{animation:t2rise .7s cubic-bezier(.22,.8,.3,1) both}
@keyframes t2rise{from{transform:translateY(20px)}to{transform:translateY(0)}}
.trust-line{margin-top:var(--space-md);display:flex;align-items:center;gap:var(--space-xs) var(--space-sm);flex-wrap:wrap;font-size:var(--type-label-size);font-weight:var(--type-label-weight);letter-spacing:var(--type-label-letter-spacing);color:var(--muted);line-height:1.9}
.trust-line .stars{display:flex;gap:2px;color:var(--gold)}
.trust-line .stars svg{width:14px;height:14px}
.trust-line strong{color:var(--plum-deep);font-weight:600}
.trust-line .dot{width:3px;height:3px;border-radius:50%;background:var(--muted);opacity:.5}
.hero-photo{position:relative;border-radius:var(--radius-lg);overflow:hidden;aspect-ratio:1/1;height:min(100%,620px);max-height:620px;box-shadow:var(--shadow-sm)}
/* IMAGE FOCAL POINT. Every photo here is cropped by object-fit:cover inside a fixed
aspect-ratio box, and until now every one of them landed on `center` -- which is the
wrong default for the images this template mostly carries. A standing person's face is
in the upper third of the frame; centred into a 1/1 or 4/5 box, the crop keeps the torso
and loses the head.
So each cropped image reads `--focal` off its wrapper and falls back to `center` when
nothing set it. html.py's _focal() emits that property on the two slots that are people
BY CONSTRUCTION (the hero portrait, the teacher portrait) and on any slot a caller
explicitly declares. Everything else keeps centre.
The fallback is what makes this safe: an unknown image, an unset property, or an older
caller all render exactly the crop they rendered before, so this can never make an
image worse than the default it replaces -- only better where something is known. */
.hero-photo img{width:100%;height:100%;object-fit:cover;object-position:var(--focal,center)}
.shead{max-width:640px;margin:0 0 var(--space-lg)}
.shead.center{margin:0 auto var(--space-lg);text-align:center}
.shead h2{font-size:var(--type-h2-size);font-weight:var(--type-h2-weight);letter-spacing:var(--type-h2-letter-spacing);margin-top:var(--space-xs)}
.shead .lede{margin-top:var(--space-xs)}
.find{padding:var(--rhythm-major) 0}
.find-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:var(--space-sm);align-items:stretch}
@media (max-width:900px){ .find-grid{grid-template-columns:1fr}}
/* --- THREE CARD SHAPES, NOT EIGHT (rule 19) -------------------------------------------
"Do not put every piece of information in a card" -- and if you must, they had better be
the SAME card. Measured, this stylesheet declared eight independent families:
.find-card .rev-card .co-tile .inside-tile .pillar .cred-chip .offer-chip .reel, each
with its own padding, radius, border and hover. Eight boxes that differ slightly read as
eight unrelated systems; the reader cannot learn the page because nothing repeats.
They collapse onto THREE shapes, by what the box actually holds:
CONTENT CARD  .find-card, .rev-card, .pillar   -- bordered box of text on a ground
MEDIA TILE    .inside-tile, .co-tile, .reel    -- a cropped image, radius-md, no border
CHIP          .cred-chip, .offer-chip          -- pill, one line, inline in a row
Each shape is declared ONCE below and the families are listed on it. Where a family
still needs something of its own (a reel's 9/16 aspect, a pillar's centring) that stays
as a short modifier -- a variant of the shape, never a second shape. */
.find-card, .rev-card, .pillar{box-shadow:var(--shadow-none);background:var(--paper);border:var(--border-width) solid var(--border);border-radius:var(--radius-lg);padding:var(--space-md);display:flex;flex-direction:column;gap:var(--space-xs);height:100%;text-decoration:none;color:inherit;transition:transform .32s,box-shadow .32s,border-color .32s}
.find-card:hover, .rev-card:hover{transform:translateY(-6px);box-shadow:var(--shadow-sm);border-color:transparent}
.find-ico{width:44px;height:44px;border-radius:var(--radius-md);display:grid;place-items:center;background:color-mix(in srgb,var(--clay) 15%,transparent);color:var(--clay)}
.find-ico svg{width:22px;height:22px}
/* Text carries the tier ladder where the border-top colour can't: cards stacked one-per-
screen on mobile have nothing beside them to compare a border against, so this reads the
same stacked or side-by-side -- QA-find-2026-09-08.md's mobile-comparability gap. */
.find-tier-badge{align-self:flex-start;font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-eyebrow-letter-spacing);text-transform:uppercase;color:var(--clay)}
.find-card h3{font-size:var(--type-h3-size);font-weight:var(--type-h3-weight);letter-spacing:var(--type-h3-letter-spacing)}
.find-card p{margin:0;font-size:var(--type-body-small-size);font-weight:var(--type-body-small-weight);letter-spacing:var(--type-body-small-letter-spacing);color:var(--muted);max-width:var(--type-body-small-measure)}
/* .find-detail: the real term/cadence line (QA-find-2026-09-08), styled like the tier badge
above it -- same size token, same clay ink -- so it reads as a fact chip, not a second
sentence competing with .find-card p. No new box: no radius/border/background of its own. */
.find-detail{font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-eyebrow-letter-spacing);color:var(--clay)}
.find-go{margin-top:auto;font-size:var(--type-cta-size);font-weight:var(--type-cta-weight);letter-spacing:var(--type-cta-letter-spacing);color:var(--plum);display:inline-flex;align-items:center;gap:var(--space-2xs)}
.find-go svg{width:12px;height:12px;transition:transform .25s}
.find-card:hover .find-go svg{transform:translateX(3px)}
.find-cta{background:var(--plum-deep)}
.find-cta .shead .eyebrow, .find-cta .shead h2{color:var(--paper)}
.find-cta .shead .lede{color:color-mix(in srgb,var(--paper) 74%,transparent);max-width:var(--type-body-small-measure)}
.find-cta .find-card{background:var(--surface);border:none;padding:var(--space-lg) var(--space-md);box-shadow:var(--shadow-md)}
.find-cta .find-card:hover{transform:translateY(-8px) scale(1.015);box-shadow:var(--shadow-lg)}
.find-cta .find-ico{background:var(--plum-deep);color:var(--paper)}
.find-cta .find-go{margin-top:var(--space-sm);font-weight:var(--type-cta-weight);font-size:var(--type-cta-size);font-weight:var(--type-cta-weight);letter-spacing:var(--type-cta-letter-spacing);color:var(--plum-deep);display:inline-flex;align-items:center;gap:var(--space-2xs);padding:var(--space-xs-plus) var(--space-sm);border-radius:var(--radius-pill);background:color-mix(in srgb,var(--clay) 14%,transparent)}
.find-cta .find-go svg{width:13px;height:13px}
/* .find-teach is authored on --plum-deep and every one of its parts -- h3, p at 74%,
the credential bullets, the ghost button's border -- iswhite --paper chosen against THAT
ground. Repainting the block in --clay kept all of them and changed the ground alone,
so the whole band's contrast then depended on whatever accent a profile happened to
get. Measured on rod-wave-elite (--clay #cf0d0d): the body copy came out at 3.70:1
where AA asks 4.5, and the band read as red text on red.
The accent still marks this card as the emphasised one -- as a rule that cannot
change the text's contrast: a border and a tinted edge over the ground the copy was
designed for. */
.find-cta .find-teach{border:var(--border-width) solid var(--clay);
box-shadow:inset 0 0 0 999px color-mix(in srgb,var(--clay) 12%,transparent)}
.find-teach{margin-top:var(--space-sm);display:flex;align-items:center;justify-content:space-between;gap:var(--space-sm);flex-wrap:wrap;padding:var(--space-md) var(--space-md);border-radius:var(--radius-lg);background:var(--plum-deep);color:var(--paper)}
.find-teach-l{display:flex;align-items:center;gap:var(--space-xs-plus)}
.find-teach-ico{width:44px;height:44px;border-radius:var(--radius-md);display:grid;place-items:center;background:color-mix(in srgb,var(--paper) 14%,transparent);flex-shrink:0}
.find-teach-ico svg{width:22px;height:22px}
.find-teach h3{color:var(--paper);font-size:var(--type-h4-size);font-weight:var(--type-h4-weight);letter-spacing:var(--type-h4-letter-spacing);margin-bottom:var(--space-xs)}
.find-teach p{margin:0;font-size:var(--type-body-small-size);font-weight:var(--type-body-small-weight);letter-spacing:var(--type-body-small-letter-spacing);color:color-mix(in srgb,var(--paper) 74%,transparent);max-width:var(--type-body-small-measure)}
/* .find-teach-creds: the credential facts as real bullets (QA-find-2026-09-08), not a
clause inside the sentence above. No border/radius/background of its own -- reuses the
band's own paper-on-plum ink, so it never grows a ninth card shape. */
.find-teach-creds{list-style:none;margin:var(--space-2xs) 0 0;padding:0;display:flex;flex-wrap:wrap;gap:var(--space-2xs) var(--space-sm)}
.find-teach-creds li{font-size:var(--type-body-small-size);font-weight:700;letter-spacing:var(--type-body-small-letter-spacing);color:var(--paper)}
.find-teach-creds li::before{content:"";display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--gold);margin-right:var(--space-2xs);vertical-align:middle}
.find-teach .btn-ghost{border-color:color-mix(in srgb,var(--paper) 30%,transparent);color:var(--paper)}
.find-teach .btn-ghost:hover{border-color:var(--paper)}
/* --- COMMITMENT LADDER -- html.py::find() ranks the doors by their OWN price field and
writes data-tier="1|2|3" (cta_style only); this section only ever draws what that field
already says, nothing here invents an ordering. A $68 trial and a $350 package no longer
read as the identical decision -- the pricier door sits a little taller and a little
louder, and the training band above them (`data-tier="top"`) reads as one rung further
still rather than a fourth, unrelated tile. Old design: three cards + one band, all one
flat weight. This one is a visible ladder -- the >30% bar this redesign has to clear. */
/* QA-find-2026-09-08.md: tier 1 (the cheapest priced door) had NO rule at all, so it was
pixel-identical to the unpriced callout card beside it -- a 3-tier profile's cheapest
door would read as broken/unstyled rather than as the first, lightest rung of the same
ladder tier 2/3 already draw. Deliberately the lightest touch of the three: a hairline
in --clay at low opacity, not a fight for attention against the tiers meant to escalate. */
.find-cta .find-card[data-tier="1"]{border-top:3px solid color-mix(in srgb,var(--clay) 25%,transparent)}
.find-cta .find-card[data-tier="2"]{border-top:3px solid color-mix(in srgb,var(--clay) 60%,transparent)}
/* PRICE TIER IS A FACT, NOT A RECOMMENDATION -- QA-find-2026-09-08-v2.md's root critique:
the priciest door used to get a taller box (extra padding-top), a lifted position
(translateY), a bigger heading and a filled icon on top of its border -- which reads as
"priciest = best", the exact conflation the finding named ("ranks visual boldness purely
by raw price, not best-fit-for-most"). Price tier now escalates by border weight alone,
same treatment as tiers 1/2, so it stays an honest, subtle fact instead of a verdict.
The actual "pick this one" signal is .find-recommend below, and it is never the priciest
door -- see html.py::find()'s _is_recommended(). */
.find-cta .find-card[data-tier="3"]{border-top:3px solid var(--clay)}
.find-teach[data-tier="top"]{border-top:3px solid var(--gold)}
/* RECOMMENDED FOR YOU -- independent from the price-tier border above, and deliberately never
on the same card as the plain-text .find-tier-badge (html.py only ever renders one of the
two per card). A filled pill in the page's flush accent, distinct from the tier badge's
plain uppercase text, so a reader cannot mistake "this is honestly the most expensive door"
for "we recommend this door" -- they are now two different shapes as well as two different
claims. */
.find-recommend{align-self:flex-start;font-size:var(--type-meta-size);font-weight:700;letter-spacing:var(--type-eyebrow-letter-spacing);text-transform:uppercase;color:var(--paper);background:var(--flush);padding:var(--space-2xs) var(--space-xs-plus);border-radius:var(--radius-pill)}
/* Motion: the ladder settles in low-to-high, same no-JS-visible-at-rest rule as everywhere
else on this page -- transform only, fully painted before .in ever lands. */
.find-cta .find-card{transition:transform .32s,box-shadow .32s,border-color .32s,translate .6s cubic-bezier(.22,.8,.3,1)}
.find-cta .find-grid > .find-card{translate:0 16px}
.find-cta .find-grid.in > .find-card{translate:0 0}
.find-cta .find-grid > .find-card[data-tier="2"]{transition-delay:.1s}
.find-cta .find-grid > .find-card[data-tier="3"]{transition-delay:.2s}
.find-teach[data-tier="top"]{transition:translate .6s cubic-bezier(.22,.8,.3,1) .3s;translate:0 16px}
.find-teach.in[data-tier="top"]{translate:0 0}
/* REDESIGN (2026-09-08, corrective): the first pass here kept .pillar's bordered/rounded
box (the SAME shape .find-card/.rev-card/.why-stat all use) and called it done with an
underline accent -- the owner's live read was "font tak wahi hai, italic mein likha hai,
sab kuch waisa hi hai" (same font, still italic, nothing changed), and he's right: an
accent colour swap on an identical box is not a different section. This band now uses NO
card box at all -- a hairline rule, not a border+radius+padding+background box -- so it
never enters the CARD_FAMILIES shape count in test_design_restraint.py (no border-radius
on .core-panel, so `_shapes()` never sees it) rather than fighting for a 4th shape slot.
Upright (not italic) numerals on --sage -- the one accent colour nothing else on the page
uses -- left-aligned copy instead of centred: this is the one "editorial index" room on
the page, not an eighth bordered card. */
.core{padding:var(--rhythm-standard) 0}
.core-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-xl);max-width:960px;margin:0 auto}
@media (max-width:900px){ .core-grid{grid-template-columns:1fr;gap:var(--space-lg)}}
.core-panel{border-top:3px solid var(--sage);padding-top:var(--space-sm);text-align:left}
.core-num{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-style:normal;font-size:var(--type-display-size);font-weight:var(--type-data-weight);letter-spacing:var(--type-data-letter-spacing);line-height:1;color:var(--sage);margin-bottom:var(--space-xs)}
.core-panel h3{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-style:normal;font-size:var(--type-h3-size);font-weight:var(--type-h3-weight);letter-spacing:var(--type-h3-letter-spacing);margin:0 0 var(--space-xs)}
.core-panel p{margin:0;color:var(--muted);font-size:var(--type-body-size);font-weight:var(--type-body-weight);letter-spacing:var(--type-body-letter-spacing);max-width:32ch}
/* Terse variant (2 of 3 practice types have no creator-written description): don't leave an
empty paragraph slot -- promote the title itself, the one thing every panel actually has,
to a bigger standalone statement so the gap reads as an intentional editorial choice. */
.core-terse .core-panel h3{font-size:var(--type-h2-size)}
/* REDESIGN (2026-09-08): a checkmark list read as a form field, not proof -- and buried
"Rated 4.9 by 83 people" at the same weight as every other line, which was the owner's
specific complaint. The four claims are stat cards now, reusing .pillar's own shape
(box-shadow:none;paper;border;radius-lg;padding -- no new card family, see the shape
ceiling in test_design_restraint.py) so a rating, a year count and a follower count each
get a real numeral (--type-data-size, the same italic-Fraunces treatment .pillar-num and
.step-n already carry) instead of small print beside a tick. */
/* SIGNATURE RHYTHM (2026-09-08 token overhaul, finding #3): .why is the one band on the page
that breathes unevenly on purpose -- more air above it than below, so the section reads as
an editorial break rather than another uniform --rhythm-standard slab. Every other
standard-tier band keeps the symmetric value; applying the asymmetric pair everywhere would
make it the new default instead of a signature moment. */
.why{padding:var(--rhythm-signature-top) 0 var(--rhythm-signature-bottom)}
/* SIGNATURE ACCENT (finding #1/#2): scoped to .why's own stat cards, not .pillar generally --
.core reuses the same shape and must not pick this up, or the "signature" stops being one. */
.why .pillar{border-top:3px solid var(--flush);padding-top:calc(var(--space-md) - 3px)}
/* Owner, live: "hum website bana rahe hain, agar ek single image hai toh kam se kam uski
achhi khasi height honi chahiye" -- a lone photo next to a 2x2 stat grid was losing the
column-share fight (.85fr, the MINORITY share) and cropped to a shallow 4/3, so it read as
an afterthought beside four cards that were together taller and wider. Two changes, same
fix: give the photo the majority column share, and let it run the full stack height
(align-items:stretch, not :center) with a taller aspect-ratio so a portrait/scene photo
reads as the section's visual anchor -- the pattern real sites use for a supporting photo
beside proof content (a wide dominant image column, stat/proof content in the narrower
remainder), not a thumbnail wedged beside the real content. */
.why-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:var(--space-xl);align-items:stretch}
@media (max-width:900px){ .why-grid{grid-template-columns:1fr;gap:var(--space-lg)}}
.why-photo{border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-sm);aspect-ratio:3/4;min-height:100%}
@media (max-width:900px){ .why-photo{aspect-ratio:16/10}}
.why-photo img{width:100%;height:100%;object-fit:cover;object-position:var(--focal,center)}
.why-stats{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--space-md)}
@media (max-width:600px){ .why-stats{grid-template-columns:1fr}}
.why-stat-num{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-style:italic;font-size:var(--type-data-size);font-weight:var(--type-data-weight);letter-spacing:var(--type-data-letter-spacing);color:var(--clay);margin-bottom:var(--space-xs)}
/* The icon fallback shares .why-stat-num's own font-size/color (set above, unconditionally)
so its box already matches a numeral's -- the one thing left to align is stroke weight:
the shared _STROKE (1.8) reads thin next to --type-data-weight:700 text, so this one slot
gets a heavier stroke to sit at the same visual weight as its numeral neighbours. */
.why-stat-num.icon svg{width:1em;height:1em;display:block;margin:0 auto;stroke-width:2.6}
.why-stat-detail{display:block;margin-top:var(--space-2xs);font-size:var(--type-body-small-size);font-weight:var(--type-body-small-weight);letter-spacing:var(--type-body-small-letter-spacing);color:var(--muted)}
/* GOOGLE RATING CARD -- REDONE (owner, twice now: "kuch unique kiya hi nahi... isse
bhayanak, badhiya banao" -- a gold border and a small grey "Google" chip on an
identically-sized card was still just a colour swap on the same box, not a card that
reads as more impressive than its 3 siblings. This pass makes the one thing a rating
card can uniquely have -- scale -- do the work: the numeral jumps to
--type-data-hero-size, the SAME size the reviews band's own headline score renders at
(reused token, not a new one), so 4.9 is visibly the loudest thing in the grid instead
of matching "15"/"26"/"Hundreds" stroke-for-stroke. The star row grows with it (2x the
sibling cards' implicit 1em glyphs) and moves to sit directly beside the numeral, and
"Google &#183; N reviews" is now a real all-caps kicker carrying its own weight -- typography
doing the highlighting the reference band (hero-4up.personacart-demo2.pages.dev) also
leans on -- rather than a pill badge parked in front of plain prose. --gold is already
documented above (finding #3 header) as "stars and rating numerals only"; this remains
the one card where a numeral IS a rating. Card background/top-edge/lift unchanged from
the first pass -- those were fine, the numeral scale was the actual gap. */
.why .pillar.why-stat-google{background:color-mix(in srgb,var(--gold) 8%,var(--surface));border-top-color:var(--gold);box-shadow:var(--shadow-sm)}
.why-stat-num-google{font-size:var(--type-data-hero-size);color:var(--gold);display:inline-block;margin-bottom:0}
.why-stat-google .why-stat-num-google{margin-right:var(--space-2xs)}
.why-stat-stars{display:inline-flex;vertical-align:middle;margin-bottom:var(--space-xs)}
.why-stat-stars svg{width:1.4em;height:1.4em}
.why-stat-google h3{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-style:normal;text-transform:uppercase;font-size:var(--type-meta-size);font-weight:700;letter-spacing:var(--type-meta-letter-spacing);color:var(--plum-deep)}
/* Four cards revealing in lock-step read as one flat arrival; a few beats of stagger between
them is the difference between "the page loaded" and "the numbers land". Delay only --
.why-stat already inherits the fully-visible, transform-only .reveal contract above, so a
stripped-<script> page still shows every card, just without the stagger's timing. */
.why-stat:nth-child(2){transition-delay:.08s}
.why-stat:nth-child(3){transition-delay:.16s}
.why-stat:nth-child(4){transition-delay:.24s}
/* STAT-BAND NUMERAL EMPHASIS (RESEARCH-motion-by-section-role-2026-09-08.md).
Every other reveal target on the page gets the identical translateY(18px)->0 fade -- right
for a band head or a photo, wrong for a proof number, whose whole job is to be the one thing
the eye is drawn to land on. Research on stat/proof bands converges on a count-up: "the eye
catches the movement and waits for the number to land". A REAL 0->N JS counter is rejected
here on purpose -- this stylesheet's own non-negotiable (line ~1491) is that a stripped-
<script> page must render every number fully legible at rest, and a live counter's honest
at-rest state is "0", which is worse than never having animated it.
So the emphasis is a transform-only POP instead: the numeral scales in from .74 on the same
`.in` class its parent `.why-stat` already gets from the existing reveal observer (no new JS,
no new .reveal-scoped rule -- this selector never appears in the reveal-rule count because
".reveal" isn't in it). At rest (`.in` absent, i.e. any stripped-<script> render) the numeral
sits at its default scale(1) -- fully visible, un-popped, exactly the safe fallback the fade
rules above already guarantee for their own targets. prefers-reduced-motion is covered by the
sheet-wide `*{animation-duration:.01ms!important}` rule already in the reduce block, so no
separate override is needed here. */
.why-stat.in .why-stat-num{animation:t2countpop .5s cubic-bezier(.34,1.56,.64,1) both}
@keyframes t2countpop{from{transform:scale(.74)}to{transform:scale(1)}}
.reviews{padding:var(--rhythm-major) 0}
.stars{display:flex;gap:var(--space-2xs);color:var(--gold)}
.stars svg{width:16px;height:16px}
.rating-link{font-size:var(--type-nav-size);font-weight:var(--type-nav-weight);letter-spacing:var(--type-nav-letter-spacing);color:var(--plum);text-decoration:none;display:inline-flex;align-items:center;gap:var(--space-2xs);border-bottom:var(--border-width) solid var(--border);padding-bottom:2px}
.rating-link:hover{color:var(--clay);border-color:var(--clay)}
.rating-link svg{width:13px;height:13px;transition:transform .25s}
.rating-link:hover svg{transform:translateX(3px)}
/* REV-TRUST -- the aggregate score, demoted to a strip instead of a giant numeral.
The number is already stated in words in the band's own heading (h.reviews()'s `title`
arg -- "Rated 4.9 by 83 people..."), so repeating it as a hero-sized digit here would be
the exact "numeral says it twice" duplication .why's own Google stat card already owns.
.trust-line is the hero's own "stars + a fact or two" row, reused rather than a new
rating-card shape (RESEARCH-reviews-section-2026-09-08.md, finding #1: named layouts,
not the un-named grid-plus-numeral default this replaces). */
.rev-trust{padding-bottom:var(--space-md);margin-bottom:var(--space-lg);border-bottom:var(--border-width) solid var(--border);justify-content:space-between}
.rev-trust .stars svg{width:15px;height:15px}
.rev-trust-score{display:flex;align-items:center;gap:var(--space-xs) var(--space-sm);flex-wrap:wrap}
.rev-trust-score>span{color:var(--ink)}
@media (max-width:600px){ .rev-trust{flex-direction:column;align-items:flex-start;gap:var(--space-xs)}}
/* REV-FEATURE -- the section's actual lead: one full-length real review at editorial
pull-quote scale, ahead of the grid rather than inside it. Fraunces italic at
--type-h2-size is a real contrast against .rev-card's --type-quote-size (finding #3),
not a bigger version of the same card. No border-radius/background here on purpose --
test_design_restraint.py's 3-shape ceiling counts a boxed treatment as a new card
family, and a plain left-rule accent (the oldest pull-quote convention there is) reads
MORE editorial than another bordered box would, not less. */
.rev-feature{margin:0 0 var(--space-xl);padding-left:var(--space-lg);border-left:3px solid var(--gold);position:relative}
.rev-feature blockquote{margin:0;font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-style:italic;font-size:var(--type-h2-size);font-weight:440;line-height:1.28;letter-spacing:-.01em;color:var(--plum-deep);max-width:34ch}
.rev-feature figcaption{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-xs) var(--space-sm);margin-top:var(--space-md);font-size:var(--type-label-size);font-weight:var(--type-label-weight);letter-spacing:var(--type-label-letter-spacing);color:var(--muted)}
.rev-feature-who{color:var(--plum-deep);font-weight:600}
.rev-feature .stars{margin-bottom:var(--space-sm)}
@media (max-width:600px){ .rev-feature{padding-left:var(--space-md)} .rev-feature blockquote{font-size:var(--type-h3-size)}}
.rev-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-md);align-items:stretch}
@media (max-width:900px){ .rev-grid{grid-template-columns:1fr}}
/* .rev-card is the content card on the alternate ground -- a variant, not a shape. */
.rev-card{background:var(--surface)}
.rev-tag{font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);text-transform:uppercase;letter-spacing:var(--type-meta-letter-spacing);color:var(--clay);font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);margin-bottom:var(--space-xs)}
.rev-card p{margin:var(--space-sm) 0 0;font-size:var(--type-quote-size);font-weight:var(--type-quote-weight);letter-spacing:var(--type-quote-letter-spacing);color:var(--ink);opacity:.9;max-width:var(--type-quote-measure)}
.rev-foot{margin-top:var(--space-sm);padding-top:var(--space-sm);border-top:var(--border-width) solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:var(--space-xs)}
.rev-who{font-size:var(--type-label-size);font-weight:var(--type-label-weight);letter-spacing:var(--type-label-letter-spacing)}
.rev-src{font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);color:var(--muted);text-decoration:none;display:inline-flex;align-items:center;gap:var(--space-2xs)}
.rev-src:hover{color:var(--clay)}
.rev-src svg{width:11px;height:11px}
.teacher{padding:var(--rhythm-standard) 0}
.t-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:var(--space-xl);align-items:center}
/* No portrait, no second column: the band is a single measure of text rather than a
column beside an empty half. Same shape .foot-grid already uses for its variants. */
.t-grid.solo{grid-template-columns:minmax(0,var(--type-body-measure))}
@media (max-width:900px){ .t-grid{grid-template-columns:1fr;gap:var(--space-md)}}
.t-photo{border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-sm);aspect-ratio:4/5}
.t-photo img{width:100%;height:100%;object-fit:cover;object-position:var(--focal,center)}
.t-body h2{margin-top:var(--space-xs)}
.t-body p{color:var(--ink);opacity:.92;margin:0 0 var(--space-sm);max-width:var(--type-body-measure);font-size:var(--type-lede-size);line-height:1.55}
.t-body p:first-of-type{margin-top:var(--space-sm)}
.quote{margin:0 0 var(--space-sm);padding:0 0 0 var(--space-sm);border-left:var(--border-width) solid var(--border)}
.t-quote p{margin:0;color:var(--ink);opacity:.86;font-style:italic;max-width:var(--type-quote-measure)}
.creds{display:flex;flex-wrap:wrap;gap:var(--space-xs);margin:var(--space-md) 0 0}
/* CHIP -- one pill shape. .offer-chip adds only its dark ground and its own inner parts. */
.cred-chip, .offer-chip{box-shadow:var(--shadow-none);display:inline-flex;align-items:center;border-radius:var(--radius-pill);text-decoration:none}
.cred-chip{gap:var(--space-2xs);padding:var(--space-2xs) var(--space-xs-plus);border:var(--border-width) solid var(--border);color:var(--plum-deep);background:var(--paper);font-size:var(--type-label-size);font-weight:var(--type-label-weight);letter-spacing:var(--type-label-letter-spacing)}
.cred-chip svg{width:13px;height:13px;color:var(--clay)}
.t-cta{margin-top:var(--space-md)}
/* --- MILESTONE RAIL: the founder's own dated sentences, promoted out of the paragraph body
into a scannable timeline. Full-width row under the photo/body pair -- t-grid's two-column
track is for the photo+bio only, so both new rows opt out of it explicitly. Never rendered
when the bio carries no year (most profiles): teacher() passes "" and there is nothing to
collapse. */
.t-milestones{grid-column:1/-1;position:relative;display:flex;flex-wrap:wrap;gap:var(--space-lg);margin-top:var(--space-sm);padding-top:calc(var(--space-sm) + var(--border-width) * 2)}
/* SIGNATURE ACCENT (2026-09-08 token overhaul, finding #1/#2): the milestone rail was --clay,
the same colour every other accent moment on the page already uses (nav CTA, eyebrows, the
pillar numerals) -- a sequence marker gets the new signature accent instead, so a reader's
eye can tell "this is a dated timeline" from colour alone, not just from position. */
/* CONNECTED TRACK (QA-teacher-2026-09-08-v2.md, External-trend-research 85): 2025-2026
founder/timeline sections increasingly draw ONE connecting line through dated milestones
rather than showing each as its own bordered block -- this was two independent
`border-top`s, one per beat, which is exactly the "separate blocks" read the QA note
flagged. A single pseudo-element spans the whole row instead, so 2010 and 2015 sit on one
track. It fills left-to-right (scaleX 0->1) the moment `.t-milestones` gets `.in` --
motion.py's IntersectionObserver already adds that class to this exact element (see
line ~1252 below); this reuses that one guaranteed toggle rather than adding a second
observer, so the reduced-motion / no-IntersectionObserver / 4s-timeout safety net above
covers it for free. Same reasoning as `.track` under FIRST-TIME JOURNEY below, transcribed
for a horizontal two-item row instead of a numbered step sequence. */
.t-milestones::before{content:"";position:absolute;top:0;left:0;right:0;height:calc(var(--border-width) * 2);background:var(--flush);transform-origin:left;transform:scaleX(0);transition:transform .9s cubic-bezier(.22,.8,.3,1) .15s}
.t-milestones.in::before{transform:scaleX(1)}
.t-beat{flex:1 1 220px;min-width:0}
.t-beat-year{display:block;font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-h2-size);font-weight:var(--type-h2-weight);letter-spacing:var(--type-h2-letter-spacing);color:var(--plum-deep)}
.t-beat-label{display:block;margin-top:var(--space-2xs);color:var(--ink);opacity:.85;font-size:var(--type-body-small-size);line-height:var(--type-body-line-height);max-width:var(--type-body-small-measure)}
/* --- SOCIAL PROOF, ON THE PERSON: a Google review that names the founder specifically,
not the studio in general -- a stronger reference than an unattributed pull-quote because
a stranger is vouching for a face the visitor just saw. Optional, same reason as above. */
.t-social{grid-column:1/-1;margin:var(--space-sm) 0 0;padding:var(--space-md) var(--space-lg);border-left:calc(var(--border-width) * 2) solid var(--clay);background:var(--surface);border-radius:var(--radius-md)}
.t-social p{margin:0;font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-style:italic;font-size:var(--type-quote-size);font-weight:var(--type-quote-weight);letter-spacing:var(--type-quote-letter-spacing);color:var(--ink);max-width:var(--type-quote-measure)}
.t-social cite{display:block;margin-top:var(--space-xs);font-style:normal;font-size:var(--type-label-size);font-weight:var(--type-label-weight);letter-spacing:var(--type-label-letter-spacing);color:var(--muted)}
/* Stacked column: a shared line above the whole row no longer sits beside the second beat
once beats wrap to their own rows, so each beat goes back to carrying its own hairline --
same visual language (a dated rule above the year), adapted to a vertical list instead of
a side-by-side track. */
@media (max-width:600px){
.t-milestones{padding-top:0}
.t-milestones::before{display:none}
.t-beat{flex:1 1 100%;padding-top:var(--space-sm);border-top:var(--border-width) solid var(--flush)}}
/* --- FIRST-TIME JOURNEY -- a real ordered sequence, so it gets a track, not a card row ----
The old design was five identical cards with a muted number floating above each -- nothing
told the eye they were STEPS rather than five unrelated facts. `.track` is one hairline
rail behind the numbered nodes (horizontal desktop, left-aligned vertical under 700px);
each `.step-node` sits ON it, so "step 2" visibly follows "step 1" instead of just being
labelled that it does. This is the one section on the page where a stagger is honest motion
(RULE 31's own reasoning): five UNRELATED tiles arriving together taught the eye nothing,
but five ORDERED nodes lighting up in order is the sequence itself, restated as motion. */
.first{padding:var(--rhythm-standard) 0}
.steps{position:relative;display:flex;flex-wrap:wrap;justify-content:center;gap:var(--space-md) var(--space-sm)}
.steps > .step{flex:1 1 clamp(110px,17%,220px);max-width:240px;position:relative;min-width:0}
@media (max-width:600px){ .steps{display:block;padding-left:var(--space-md)} .steps > .step{max-width:none;margin-bottom:var(--space-lg)} .steps > .step:last-child{margin-bottom:0}}
.track{position:absolute;top:22px;left:calc(100% / 10);right:calc(100% / 10);height:2px;background:var(--border);z-index:0}
@media (max-width:600px){ .track{top:0;bottom:0;left:22px;right:auto;width:2px;height:auto}}
.step{text-align:center;padding:var(--space-xs)}
@media (max-width:600px){ .step{text-align:left;padding:0 0 0 var(--space-md)}}
/* SIGNATURE ACCENT (2026-09-08 token overhaul, finding #1/#2): same reasoning as .t-beat --
a sequence marker (the journey's numbered nodes) takes the new flush accent instead of
--clay, so the two "this is an ordered sequence" moments on the page (the milestone rail,
the step track) share one signature colour that nothing else on the page uses. */
.step-node{position:relative;z-index:1;width:44px;height:44px;margin:0 auto var(--space-xs);border-radius:var(--radius-pill);background:var(--paper);border:2px solid var(--flush);display:grid;place-items:center;transition:transform .5s cubic-bezier(.22,.8,.3,1),background .5s,border-color .5s}
@media (max-width:600px){ .step-node{margin:0 0 0 calc(-1 * var(--space-md) - 22px);position:absolute;left:0;top:0}}
.step-n{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-h4-size);font-weight:700;color:var(--clay);font-style:italic}
.step h3{font-size:var(--type-h4-size);font-weight:var(--type-h4-weight);letter-spacing:var(--type-h4-letter-spacing);margin-bottom:var(--space-xs);color:var(--plum-deep);font-family:var(--font-display);font-size-adjust:var(--fsa-display)}
.step p{margin:0;font-size:var(--type-body-small-size);font-weight:var(--type-body-small-weight);letter-spacing:var(--type-body-small-letter-spacing);color:var(--muted);max-width:var(--type-body-measure)}
/* Motion: fully visible at rest (no-JS safe, see RULE at the earlier MOTION block), only
`transform` moves, and each node's delay is its own position in the sequence -- so the
line the eye reads left-to-right and the order the animation plays are the same order. */
.steps.in .step-node{background:var(--flush);border-color:var(--flush)}
.steps.in .step-node .step-n{color:var(--paper)}
/* .track is the steps container's own first child, so its second child (nth-child(3)) is
the first .step -- these indices are +1 for exactly that reason, verified against the
real markup in html.py::first_time(), not guessed from a five-item mental model. */
.step:nth-child(3) .step-node{transition-delay:.1s}
.step:nth-child(4) .step-node{transition-delay:.2s}
.step:nth-child(5) .step-node{transition-delay:.3s}
.step:nth-child(6) .step-node{transition-delay:.4s}
.track{transform:scaleX(0);transform-origin:left;transition:transform 1s cubic-bezier(.22,.8,.3,1) .15s}
@media (max-width:600px){ .track{transform:scaleY(0);transform-origin:top}}
.steps.in .track{transform:scaleX(1)}
@media (max-width:600px){ .steps.in .track{transform:scaleY(1)}}
.schedule{padding:var(--rhythm-tight) 0}
/* 2026-09-08 redesign (RESEARCH-schedule doc): cards-per-offer, not a dense comparison-table
row grid -- 2026 pricing-page research converges on cards over rows once the option count
is small (here: 2-4). Same breakpoint(600px) and single-column mobile behaviour the old
.sched-row already had, carried onto .sched-card instead of reinvented. */
/* align-items:start (2026-09-08 fix, live-verified): grid's default stretch was forcing every
plain card in a row to match the tallest card's height (the featured card, taller because
of its badge) -- measured at 768/1024/1440 the plain cards' price sat ~50-55px below where
its own content ends, a dead gap absorbed by .prog-price's margin-top:auto. Each card now
sizes to its own content; mobile (1-col, already one-card-per-row) was unaffected either way. */
.sched-grid{display:grid;align-items:start;grid-template-columns:repeat(3,1fr);gap:var(--space-md);list-style:none}
@media (max-width:900px){ .sched-grid{grid-template-columns:repeat(2,1fr)}}
@media (max-width:600px){ .sched-grid{grid-template-columns:1fr}}
.sched-card{display:flex;flex-direction:column;gap:var(--space-xs);padding:var(--space-md);border:var(--border-width) solid var(--border);border-radius:var(--radius-lg);background:var(--paper);transform:translateY(18px);transition:transform .5s cubic-bezier(.22,.8,.3,1) var(--sd,0s)}
.sched-when{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-label-size);font-weight:var(--type-label-weight);letter-spacing:var(--type-label-letter-spacing);text-transform:uppercase;color:var(--muted)}
.sched-what{font-size:var(--type-h4-size);font-weight:var(--type-h4-weight);letter-spacing:var(--type-h4-letter-spacing);font-family:var(--font-display);font-size-adjust:var(--fsa-display);color:var(--plum-deep)}
.sched-card .prog-price{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-h3-size);font-weight:700;margin-top:auto;padding-top:var(--space-xs)}
/* The highest-commitment card (by parsed price, see html.py::schedule) escalates to its own
elevated card rather than a merely-tinted row -- existing tokens only: --clay for the
accent (already this page's accent hue), --type-h2-size for the price (an existing
heading tier). Duke pricing-page research: a genuinely separated card reads as "the
recommended path" where a tinted row under-delivers the same signal. */
.sched-card.featured{border-color:var(--clay);background:color-mix(in srgb,var(--clay) 6%,var(--paper));box-shadow:0 10px 28px -12px color-mix(in srgb,var(--clay) 45%,transparent)}
.sched-card.featured .sched-when{color:var(--clay)}
.sched-card.featured .prog-price{font-size:var(--type-h2-size);color:var(--plum-deep)}
/* Desktop-only scale escalation (2026-09-08): 2025-2026 pricing-card research names a subtle
scale-up on the highlighted plan as the standard companion to a top badge/accent border --
the border+tint+badge alone read as "different" but not clearly "the recommended one" next
to same-size neighbours. `scale` is its own CSS property (not `transform`), so it composes
with the existing reveal transform:translateY() instead of fighting it. Multi-column only
(>600px, matching the grid's own card-columns breakpoint) -- at 1-column mobile every card
already reads top-to-bottom in order, so a scaled card there would only risk clipping. */
@media (min-width:601px){ .sched-card.featured{scale:1.035}}
.sched-badge{align-self:flex-start;font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-meta-size);font-weight:700;letter-spacing:var(--type-label-letter-spacing);text-transform:uppercase;color:var(--paper);background:var(--clay);padding:var(--space-2xs) var(--space-xs);border-radius:var(--radius-pill)}
.sched-note{font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);color:var(--muted);margin-top:var(--space-md);padding-top:var(--space-sm);border-top:var(--border-width) dashed var(--border)}
/* Per-card entrance stagger, same opt-in pattern as .steps/.step-node -- transform ONLY,
never opacity, on an individual card (test_no_band_is_invisible_without_js's contract:
the storefront port strips <script>, so a card gated on its OWN opacity:0 ships
permanently blank there; the band-level .sched-grid already carries the real opacity
gate via its shared band class, and motion.py's fallbacks all target that shared
mechanism, not this per-card rule). A SECOND `.sched-card{...}` rule here was silently
dropped by standalone_css()'s duplicate-selector handling (verified: only the first
same-selector rule survives) -- the transform/transition above lives on the one true
.sched-card rule instead, and this stays a single toggle rule. */
.sched-grid.in .sched-card{transform:translateY(0)}
.sched-card:nth-child(2){--sd:.1s}
.sched-card:nth-child(3){--sd:.2s}
.sched-card:nth-child(4){--sd:.3s}
.sched-card:nth-child(5){--sd:.4s}
.sched-card:nth-child(6){--sd:.5s}
.faq{padding:var(--rhythm-tight) 0}
.faq-list{max-width:none}
/* Redesign 2026-09-08 (owner: "everywhere something should improve" -- FAQ was the one home
band not touched in the day's rubric pass). Research (RESEARCH-faq-2026-09-08.md) found
premium accordions win on HIERARCHY + hover/open affordance, not on card chrome or a
search box this profile doesn't need -- so the list stays a plain divided list (the same
pattern Stripe/Apple use) and the actual changes are: a tabular-numeral index per row
(`.fnum`, the same font-mono/tabular-nums convention `.num`/`.t2-facts .k` already use, so
this is not a new SSOT), a real hover/open state on the question (measured gap: today
nothing on the row changes until you click), and the answer indented under the question
text rather than the number, so opened content visually continues the row it belongs to. */
.fitem{border-bottom:var(--border-width) solid var(--border);transition:background-color .25s ease;padding-inline:var(--space-sm)}
.fitem:hover, .fitem.open{background:var(--surface)}
.fitem h3{margin:0}
/* .fitem rows are divided by a 1px border and the .fq button filled its row edge to edge,
so two adjacent question buttons touched at gap 0 -- WCAG 2.5.8 wants 8px between
targets, and on a phone the row you land on is genuinely ambiguous. The rows themselves
must stay flush (the divider line IS the design), so the separation goes on the button
inside the row rather than on the row: a small vertical margin insets each target from
its neighbour while .fitem keeps its full-bleed border and hover ground. Net row height
is unchanged -- the margin comes out of the existing --space-md padding. */
.fq{width:100%;text-align:left;background:none;border:none;cursor:pointer;padding:var(--space-sm) 0;margin:var(--space-xs) 0;min-height:44px;display:flex;align-items:center;gap:var(--space-sm)}
.fnum{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-variant-numeric:tabular-nums;font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);color:var(--muted);flex:0 0 auto;width:2.25rem}
.fitem.open .fnum, .fq:hover .fnum{color:var(--clay)}
.fq-text{flex:1 1 auto;font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-h4-size);font-weight:var(--type-h4-weight);letter-spacing:var(--type-h4-letter-spacing);color:var(--plum-deep);transition:color .25s ease}
.fq:hover .fq-text{color:var(--clay)}
.fq .pm{width:20px;height:20px;flex-shrink:0;position:relative}
.fq .pm::before, .fq .pm::after{content:'';position:absolute;background:var(--clay);border-radius:var(--radius-sm);transition:transform .3s,opacity .3s}
.fq .pm::before{left:0;top:50%;width:100%;height:2px;transform:translateY(-50%)}
.fq .pm::after{top:0;left:50%;width:2px;height:100%;transform:translateX(-50%)}
.fitem.open .pm::after{transform:translateX(-50%) rotate(90deg);opacity:0}
.fa{max-height:0;overflow:hidden;transition:max-height .4s ease;padding-left:calc(2.25rem + var(--space-sm))}
.fitem.open .fa{max-height:60rem;padding-bottom:var(--space-sm)}
.fa p{margin:0 0 var(--space-md);color:var(--muted);font-size:var(--type-body-size);font-weight:var(--type-body-weight);letter-spacing:var(--type-body-letter-spacing);max-width:var(--layout-measure)}
.fa p:last-child{margin-bottom:var(--space-md)}
.fa ul{margin:0 0 var(--space-md);padding:0 0 0 var(--space-sm);max-width:var(--layout-measure);list-style:none}
.fa li{position:relative;margin:0 0 var(--space-xs);color:var(--muted);font-size:var(--type-body-size);font-weight:var(--type-body-weight);letter-spacing:var(--type-body-letter-spacing);line-height:var(--type-body-line-height)}
.fa li:last-child{margin-bottom:0}
.fa li:before{content:'';position:absolute;left:-16px;top:.62em;width:5px;height:5px;border-radius:50%;background:var(--clay);opacity:.75}
.fa p + ul{margin-top:calc(-1 * var(--space-xs))}
/* --- PHOTO DENSITY: TWO ROLES (rule 22) -----------------------------------------------
Four photo modules ran on one page -- the .inside mosaic, the .insta reel rail, the
.collage video grid and the media gallery -- and a reader cannot tell four grids of
cropped images apart. Four modules is not four kinds of proof; it is the same proof,
shown four times, and it is why the lower half of the page reads as filler.
The two roles that survive, and WHY THESE TWO:
1. MOSAIC (.inside, .collage) -- the STATIC role. A fixed grid of stills the reader
takes in at a glance, sized by importance (a long-form video tile is 6x2, a still
is 3x3), read WITHOUT scrolling sideways. This is the role that answers "what does
the place look like", and a grid answers it in one look.
2. RAIL (.insta) -- the SEQUENTIAL role. A horizontal scroll-snap run of same-shape
9/16 items, deliberately longer than the viewport because the point is that there
IS more. This is the role that answers "is this person active", which a fixed grid
cannot state -- a grid of six implies exactly six.
They differ in the axis the reader moves along, which is the only difference a reader
actually perceives between two photo grids -- so it is the only one worth keeping.
.collage shares the mosaic role rather than being a third module: same tile shape (the
MEDIA TILE above), same dense grid, its own spans. The gallery is not a module at all;
it renders into the mosaic. Both roles now take --rhythm-tight so the media run reads
as ONE related group under the content it supports, not as separate top-level bands. */
.inside{padding:var(--rhythm-tight) 0}
/* auto-rows, not a fixed two-row template: the mosaic now sizes itself to however many
photos genuinely exist, and a hardcoded row count silently clipped anything past four. */
.inside-grid{display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:180px;gap:var(--space-sm)}
@media (max-width:900px){ .inside-grid{grid-template-columns:repeat(2,1fr);grid-auto-rows:160px}
/* Halve every span so the 4-column layouts fold cleanly onto 2 columns and stay complete. */
.inside-tile{grid-column:span 1 !important;grid-row:span 1 !important}
.inside-tile:first-child{grid-column:span 2 !important;grid-row:span 2 !important}}
/* MEDIA TILE -- the one cropped-image box. .inside-tile, .co-tile and .reel had three
near-identical declarations (same radius, same overflow, same 1.06/1.07 hover zoom);
they are one shape now, and the families below add only their own aspect or link-ness. */
/* A media tile crops to a ratio; without one, each image keeps its own and a row of
eight tiles rendered at 1.42, 1.44, 1.48 and 1.50 -- differences too small to read as
intentional and large enough to look crooked. .reel already declared 9/16; these two
never did, so every profile shipped a ragged grid. 4/3 is the family's landscape ratio,
already used by .why-photo. */
.inside-tile, .co-tile, .reel{border-radius:var(--radius-md);overflow:hidden;position:relative}
.inside-tile, .co-tile{aspect-ratio:4/3}
/* ...but NOT while the tile is a .collage item, where the row track already decides the
height and the two systems fight.
.collage is `grid-auto-rows:148px` and every tile carries an inline `grid-row:span N` from
the composer, so a tile's height is settled: 3 rows plus 2 gaps. `aspect-ratio:4/3` then
claims the same height from the tile's own width and wins, because aspect-ratio sizes the
item while the track sizes the area -- and a grid item is free to be taller than the area
it is placed in. It does not push the rows apart; it simply paints over whatever is below.
Measured before this line at 1440px on keyznthingz: the first tile spans 8 columns, so it
is 736px wide and 4/3 makes it 552px tall, against a 3-row area of 476px. It ended at
y=3489 while the tile placed on the next row began at y=3429 -- 60px of one tile drawn on
top of another, which is the "overlapping media tiles" report, and why captions appeared to
bleed across neighbours: the caption sat inside a box overhanging the tile below it. The
same 60/83px overlaps measured on yin-and-meditation, pause-breathwork and tara-glazer, so
this was every collage on every profile, not a property of anyone's images.
The track is the correct authority here -- it is what the composer's spans are expressed
in, and it is what makes the mosaic a mosaic. The ratio is dropped for this one context so
the tile fills exactly the area it was placed in. The image inside already carries
`height:100%;object-fit:cover`, so it crops to that area and the tiles stay a clean grid;
.inside-tile and .reel, which are not in a spanned track, keep their ratio untouched. */
.collage>.co-tile{aspect-ratio:auto;min-height:0}
/* And .inside-grid>.inside-tile, for exactly the same reason -- which the comment above got
wrong. It says .inside-tile "is not in a spanned track", and html.py:1172 writes
`style="grid-column:span N;grid-row:span N"` onto every single one of them, against
.inside-grid's `grid-auto-rows:180px`. So the ratio and the track fight there too, and the
fix that was reasoned out in full for the collage was never applied to the mosaic.
Measured at 1440px across the eight largest profiles: nine overlapping tile pairs, up to
200px of one tile painted over another, and 231px of tiles painted BELOW the grid box. On
maggie-taylor the roses tile is drawn on top of the crystal-tray tile; on tara-glazer one
tile is visible above roughly 600px of dead white. Mobile escaped it only because the
900px query resets every span with !important -- which is why the phone layout looked
BETTER than the desktop one.
This single line is the owner's "images missing", "content placement sahi nahi hai" and
"card mein data aligned nahi hai" -- three reported symptoms, one cause. */
.inside-grid>.inside-tile{aspect-ratio:auto;min-height:0}
.inside-tile img, .co-tile img, .reel img{width:100%;height:100%;object-fit:cover;object-position:var(--focal,center);transition:transform .5s}
.inside-tile:hover img, .co-tile:hover img, .reel:hover img{transform:scale(1.06)}
/* LEAD TILE, not a fourth shape -- a radius variant on the one tile that IS the room (rule
19 lets a family add "an aspect to an existing shape"; --radius-lg is already one of the
file's four, just never on this family). A grid of seven equally-cornered crops reads as a
contact sheet; softening only the lead tile's corners is the same "one large photo, the
rest supporting it" idea the mosaic spans already state in size, now also stated in shape.
:first-child is always the mosaic's designed lead (_mosaic_spans puts the biggest span at
index 0 for every count), never a random tile. */
.inside-grid>.inside-tile:first-child{border-radius:var(--radius-lg);box-shadow:var(--shadow-sm)}
/* Real photos shot at different times of day rarely share a white balance, and a mosaic
sits them edge to edge -- so one warm tile next to one cool one reads as a mistake rather
than a room. A shared, mild grade unifies them at rest; hovering lifts the grade so the
photograph a visitor is actually looking at reads true. Plain hover filter, no scroll-
triggered visibility class involved, so it carries none of that mechanism's risk. */
.inside-tile img{filter:saturate(.9) contrast(1.02);transition:transform .5s,filter .4s}
.inside-tile:hover img{filter:saturate(1) contrast(1)}
/* VIDEO/REEL COLLAGE -- one grid, two aspect ratios.
12 columns. A landscape YouTube tile is 6x2, a portrait reel is 3x3: each tile is correct
in its own aspect, and the long-form video reads as roughly four reels' worth of area,
which is the ranking the section exists to state. grid-auto-flow:dense lets a 3-wide reel
backfill a hole a 6-wide video could not fit into, so the grid never ends ragged.
The row unit is what makes the aspects true at 1440: container ~1160, so one column is
~90px and a 6-col tile is ~544 wide -- 306 tall at 16:9, which is 2 rows of 148 plus one
gap. A 3-col tile is ~256 wide -> 456 tall at 9:16, which is 3 rows of 148 plus two gaps. */
/* `section{position:relative;z-index:1}` above gives every section an explicit z-index,
which (per spec) makes EVERY section its own stacking context -- fine for ordinary content,
but it TRAPS a fixed-position descendant: the reel lightbox's z-index:200 only ever competes
inside this section's context, never against the page's real top layer. Measured: the fixed
nav (z-index:100) and the mobile sticky CTA bar (z-index:90) both painted OVER an open
lightbox, because the section carrying it was sitting at the page's z-index:1. Resetting
z-index to auto here removes the containment (position:relative with no z-index does not
establish a stacking context), so the lightbox's own z-index is now compared where it
actually needs to win. */
.collage-section, .video-section{z-index:auto}
.collage-section{padding:var(--rhythm-tight) 0}
.collage{display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:148px;grid-auto-flow:dense;gap:var(--space-sm)}
.co-tile{text-decoration:none;background:var(--paper)}
/* Spans come from the composer per tile (see pages/content.py _collage), because the layout
is a property of what the band contains, not of the tile's kind alone. What CSS owns is
the grid the bands land in and the one behaviour they cannot express: the fallbacks below
apply only if a tile arrives with no inline span at all. */
.co-tile{grid-column:span 6;grid-row:span 2}
.co-meta{margin-top:var(--space-xs);font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-data-small-size);font-weight:var(--type-data-small-weight);letter-spacing:var(--type-data-small-letter-spacing);opacity:.9;display:flex;align-items:center;gap:var(--space-xs)}
.co-meta svg{width:10px;height:10px}
.co-foot{margin-top:var(--space-lg);display:flex;justify-content:center}
/* Tablet: 6 columns, so a video is full width and two reels sit side by side. */
/* Tablet: the 12-column bands are meaningless at this width, so every tile is given a half
row and the composition becomes a two-up. The bands stay complete because every band this
composer emits holds an even number of tiles, or one. */
/* Bounded at both ends. Left open-ended, its `:first-child{span 2}` survived into the
single-column phone layout below and forced a second implicit 2px column track there,
slicing every other tile into a sliver. A tablet rule has no business applying at 390px. */
@media (min-width:601px) and (max-width:900px){ .collage{grid-template-columns:repeat(2,1fr);grid-auto-rows:110px}
.co-tile{grid-column:span 1 !important;grid-row:span 3 !important}
.co-tile:first-child{grid-column:span 2 !important;grid-row:span 3 !important}}
/* Phone: one column each, stacked, still in their own aspects. */
/* Phone: one tile per row. Rows stay an explicit track rather than auto -- aspect-ratio on a
grid item whose row track is `auto` resolves against a zero-height track and collapses the
tile to a 3x2px sliver, which is what a 390px capture showed. Fixed rows, spanned, are
unambiguous. */
@media (max-width:600px){ .collage{grid-template-columns:1fr;grid-auto-rows:90px}
.co-tile{grid-column:span 1 !important;grid-row:span 3 !important}}
.insta{padding:var(--rhythm-tight) 0}
.rail{display:flex;gap:var(--space-sm);overflow-x:auto;max-width:100%;scroll-snap-type:x mandatory;padding-bottom:var(--space-sm);scrollbar-width:thin}
.rail::-webkit-scrollbar{height:6px}
.rail::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-pill)}
.reel{flex:0 0 280px;scroll-snap-align:start;aspect-ratio:9/16;text-decoration:none;background:var(--paper)}
@media (max-width:900px){ .reel{flex:0 0 78vw}}
.reel-veil{position:absolute;inset:0;background:linear-gradient(180deg,transparent 42%,rgba(0,0,0,.78) 100%)}
.reel-play{position:absolute;top:12px;right:12px;width:36px;height:36px;border-radius:50%;background:rgba(255,255,255,.95);display:grid;place-items:center;box-shadow:0 1px 4px rgba(0,0,0,.2)}
.reel-play svg{width:16px;height:16px;color:#241d27;margin-left:2px}
.reel-body{position:absolute;bottom:0;left:0;right:0;padding:var(--space-sm);color:#fff}
/* text-overflow:clip is deliberate: -webkit-line-clamp paints its own "..." at the cut by
default, which is the owner's absolute banned look regardless of mechanism. The caption is
already pre-trimmed to a clean sentence/word boundary in Python (content.py::_insta_posts),
so this clamp is a backstop for an unusually long single word, never the thing doing the
cutting -- and it must never draw an ellipsis glyph even in that backstop case. */
.reel-cap{font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);line-height:var(--type-meta-line-height);opacity:.95;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:clip}
.reel-likes{display:flex;align-items:center;gap:var(--space-xs);font-size:var(--type-data-small-size);font-weight:var(--type-data-small-weight);letter-spacing:var(--type-data-small-letter-spacing);margin-top:var(--space-xs);font-family:var(--font-mono);font-size-adjust:var(--fsa-mono)}
.reel-likes svg{width:10px;height:10px}
.reel-wide{flex:0 0 300px;aspect-ratio:16/9}
@media (max-width:600px){ .reel-wide{flex:0 0 78vw}}
.reel-dur{position:absolute;bottom:10px;right:10px;padding:2px var(--space-xs);border-radius:var(--radius-sm);background:rgba(0,0,0,.78);color:#fff;font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-data-small-size);font-weight:var(--type-data-small-weight);letter-spacing:var(--type-data-small-letter-spacing)}
.reel-when{margin-top:var(--space-xs);font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-data-small-size);font-weight:var(--type-data-small-weight);letter-spacing:var(--type-data-small-letter-spacing);opacity:.85}
.handle-bar{margin-top:var(--space-md);display:flex;align-items:center;justify-content:space-between;gap:var(--space-sm);flex-wrap:wrap;padding-top:var(--space-md);border-top:var(--border-width) solid var(--border)}
.handle-l{display:flex;align-items:center;gap:var(--space-xs);font-size:var(--type-label-size);font-weight:var(--type-label-weight);letter-spacing:var(--type-label-letter-spacing)}
.handle-l svg{width:26px;height:26px;color:var(--clay)}
.handle-l .who{font-weight:500}
.handle-l .sub{font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);color:var(--muted)}
.stat-n{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-weight:600;color:var(--ink)}
.video-section{padding:var(--space-2xl) 0}
.empty-state{padding:var(--space-lg) var(--space-md);text-align:center;border:var(--border-width) dashed var(--border);border-radius:var(--radius-lg);color:var(--muted)}
.empty-state strong{display:block;font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-h4-size);font-weight:var(--type-h4-weight);letter-spacing:var(--type-h4-letter-spacing);color:var(--plum-deep);margin-bottom:var(--space-xs)}
.empty-state p{margin:0 0 var(--space-sm);max-width:var(--type-body-small-measure)}
/* REEL LIGHTBOX -- pure CSS, no script. A YouTube-sourced thumbnail links to `#reel-<id>`;
this rule is what turns that fragment navigation into an inline player. Hidden by default,
:target shows it -- the browser itself does the toggling, so this works with JS fully off,
which this export always is (see html.py::_yt_embed). The iframe's src is written into the
markup unconditionally rather than swapped in on open: there is no script here to swap it. */
.lightbox{display:none;position:fixed;inset:0;z-index:200;padding:var(--space-md);background:rgba(20,14,22,.92);align-items:center;justify-content:center}
.lightbox:target{display:flex}
.lightbox-frame{position:relative;width:min(100%,960px);aspect-ratio:16/9}
.lightbox-frame iframe{width:100%;height:100%;border:0;border-radius:var(--radius-md)}
.lightbox-close{position:absolute;top:calc(-1 * var(--space-xl));right:0;width:36px;height:36px;border-radius:50%;background:var(--paper);color:var(--ink);display:grid;place-items:center;text-decoration:none;font-size:var(--type-h4-size);line-height:1}
/* The global `@media (prefers-reduced-motion:reduce)` block above already zeroes
animation-duration for every element site-wide, so this entrance settle needs no
reduced-motion rule of its own -- same mechanism .hero-full-kb already relies on. */
.lightbox-frame{animation:lightbox-in .2s ease-out}
@keyframes lightbox-in{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}
/* Instagram's own videos have no no-JS embeddable form (their oEmbed player is JS-only, and
the scraped CDN URL is a signed link that expires) -- those tiles stay a real link-out, but
`.reel-ext` marks the affordance so it never looks like the YouTube tiles that truly play
inline: a small "opens app" arrow badge instead of the round play button. */
.reel-ext{position:absolute;top:12px;right:12px;width:28px;height:28px;border-radius:50%;background:rgba(255,255,255,.95);display:grid;place-items:center;box-shadow:0 1px 4px rgba(0,0,0,.2)}
.reel-ext svg{width:13px;height:13px;color:#241d27}
.final{padding:var(--rhythm-major) 0;text-align:center;background:var(--plum-deep);color:var(--paper);position:relative} .final.has-photo{background-size:cover;background-position:center}
.final h2{color:var(--paper);font-size:var(--type-h2-size);font-weight:var(--type-h2-weight);letter-spacing:var(--type-h2-letter-spacing);max-width:16ch;margin:0 auto}
.final .price-line{font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-h3-size);font-weight:var(--type-h3-weight);letter-spacing:var(--type-h3-letter-spacing);color:var(--paper);margin-bottom:var(--space-md)}
.final-ctas{display:flex;justify-content:center;gap:var(--space-sm);margin-top:var(--space-lg);flex-wrap:wrap}
.final .btn-primary{background:var(--paper);color:var(--plum-deep)}
.final .btn-ghost{border-color:color-mix(in srgb,var(--paper) 30%,transparent);color:var(--paper)}
.final .btn-ghost:hover{border-color:var(--paper);color:var(--paper)}
.final-addr{margin-top:var(--space-lg);font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);opacity:.72}
.final-addr a{color:inherit;border-bottom:var(--border-width) solid color-mix(in srgb,var(--paper) 30%,transparent);text-decoration:none}
.final-quote{margin:var(--space-md) auto 0;max-width:30ch;font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-style:italic;font-size:var(--type-quote-size);font-weight:var(--type-quote-weight);letter-spacing:var(--type-quote-letter-spacing);color:var(--paper);opacity:.92}
.final-quote cite{display:block;margin-top:var(--space-2xs);font-style:normal;font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);opacity:.7}
.final-quote cite::before{content:'\2014\00a0'}
.t2-doc{padding:var(--space-2xl) 0 var(--space-2xl)}
.t2-doc .inner{max-width:var(--layout-container-narrow);margin:0 auto}
.t2-doc h2{font-size:var(--type-h3-size);font-weight:var(--type-h3-weight);letter-spacing:var(--type-h3-letter-spacing);margin:var(--space-lg) 0 var(--space-xs)}
.t2-doc h2:first-child{margin-top:0}
.t2-doc p, .t2-doc li{color:var(--ink);opacity:.9;font-size:var(--type-body-size);font-weight:var(--type-body-weight);letter-spacing:var(--type-body-letter-spacing);max-width:var(--type-body-measure)}
.t2-doc ul{padding-left:var(--space-md)}
.t2-doc li{margin-bottom:var(--space-xs)}
.t2-doc .meta{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-meta-letter-spacing);text-transform:uppercase;color:var(--muted);margin-bottom:var(--space-md)}
.page-hero{padding:calc(var(--announce-h) + var(--space-3xl)) 0 var(--space-xl);position:relative;overflow:hidden}
.page-hero::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 70vw 50vh at 22% 10%,color-mix(in srgb,var(--clay) 10%,transparent),transparent 70%);pointer-events:none}
.page-hero h1{font-size:var(--type-h1-size);font-weight:var(--type-h1-weight);letter-spacing:var(--type-h1-letter-spacing);line-height:var(--type-h1-line-height);margin-top:var(--space-sm)}
.page-hero .lede{margin-top:var(--space-sm)}
.t2-facts{display:grid;gap:var(--space-xs);list-style:none;margin:0;padding:0;max-width:640px}
/* A flex item will not shrink below its content by default, so a 30-character email
beside a 7rem label pushed shakti-power-yoga's contact page 17px wider than a 390px
phone -- the whole page scrolled sideways over one address. The value column may
shrink and break; the label keeps its width down to the point where it cannot. */
.t2-facts li{display:flex;gap:var(--space-sm);align-items:baseline;padding:var(--space-sm) 0;border-bottom:var(--border-width) solid var(--border);min-width:0}
.t2-facts li>span:last-child{min-width:0;flex:1 1 auto;overflow-wrap:break-word}
/* The anchor itself has to be the thing that breaks. An inline element establishes no width
of its own, so break-word on its parent never reached the unbroken 30-character mailto text
inside it -- the link still measured 407px in a 390px viewport. inline-block plus a
max-width gives it a box to break inside, without reaching for overflow-wrap:anywhere,
which the sheet forbids: it is what splits "YOGA+BARR / E+PILATES" mid-token. */
.t2-facts a{display:inline-block;max-width:100%;min-width:0;overflow-wrap:break-word;word-break:break-word}
.t2-facts .k{font-family:var(--font-mono);font-size-adjust:var(--fsa-mono);font-size:var(--type-label-size);font-weight:var(--type-label-weight);letter-spacing:var(--type-label-letter-spacing);text-transform:uppercase;color:var(--muted);min-width:7rem;flex:0 1 auto}
.t2-socials{display:flex;flex-wrap:wrap;gap:var(--space-xs);margin-top:var(--space-md)}
/* --- MOTION: ONE ENTRANCE, ON BAND HEADS ONLY ----------------------------------------
Rule 31 -- animation must have a purpose, and a page that animates everything has
animated nothing. Measured, the built theme moved 24 `.reveal` elements plus a 5-step
stagger on every grid, so every card, chip and tile arrived on its own delay: the eye
had nothing to attend to because everything was equally in motion.
What survives is the one thing the entrance was ever for -- telling the reader a NEW
BAND has begun. `.reveal` is therefore scoped to the band head (`.shead`) and to the
two lead photos that ARE their band's subject. Grids keep their `.reveal stag` classes
in the markup (html.py owns those and is another agent's file) but this stylesheet no
longer gives `.stag > *` any transition, so the children simply appear with the band.
Cutting the stagger here rather than in the markup also means no card is ever left
waiting on a `.in` class that a stripped page will never add.
TWO PROPERTIES THAT ARE NOT NEGOTIABLE
1. NO-JS VISIBILITY. The storefront port strips <script>, so `opacity:0` at rest is a
blank page -- which has actually shipped here before. Every rule below therefore
starts FULLY VISIBLE and animates a transform only. With no JS the band is legible
and merely un-animated; the `.in` class is an enhancement, never a reveal switch.
2. prefers-reduced-motion still zeroes all of it (see the reduce block above). */
.reveal .shead, .why-photo, .t-photo, .why-stat, .core-panel, .reveal .final-lede{transform:translateY(18px);transition:transform .6s cubic-bezier(.22,.8,.3,1)}
.reveal.in .shead, .why-photo.in, .t-photo.in, .why-stat.in, .core-panel.in, .reveal.in .final-lede{transform:translateY(0)}
.t-milestones, .t-social{transform:translateY(18px);transition:transform .6s cubic-bezier(.22,.8,.3,1) .08s}
.t-milestones.in, .t-social.in{transform:translateY(0)}
.find-card .find-go{margin-top:auto} .rev-card{height:100%;display:flex;flex-direction:column} .rev-card .rev-foot{margin-top:auto} .site-foot{background:var(--cream);border-top:var(--border-width) solid var(--border)}
/* --- THE THREE-TONE SECTION RHYTHM ---------------------------------------------------
Two grounds cannot separate three things. Measured, the page alternated --paper and
--surface only, and the middle of the page ran .teacher / .first / .schedule / .faq --
four consecutive bands on --surface. Alternation had nothing left to alternate with, so
those four read as one continuous slab and the banding did no work at all.
The fix is a third stop on the same warm ramp (--ground-alt, defined in css.py's token
map) and an EXPLICIT ground for every band, cycling surface -> paper -> ground-alt.
Assigning each section by name rather than with :nth-of-type is deliberate: sections are
conditional (a profile with no schedule drops that band), so an index-based cycle would
re-pair differently for every profile and could still land two identical grounds side by
side. Named assignment follows the ORDER sections are emitted in tree.py, which is fixed.
Home order, and the ground each band takes:
hero      paper        (the page ground; the hero is not a banded section)
core      ground-alt
why       surface
reviews   paper
teacher   ground-alt
team      paper        (the instructor roster, when the plan finds one)
first     surface
schedule  paper
faq       ground-alt
inside    surface
insta     paper
collage   ground-alt
find      surface
final     plum-deep    (inverted -- its own thing, and always follows a light band)
No two adjacent entries share a ground, which is the whole property being bought. */
.core{background:var(--ground-alt)}
.why{background:var(--surface)}
.reviews{background:var(--paper)}
.teacher{background:var(--ground-alt)}
/* .team is <section class="find team">: it borrows .find's card grammar and rhythm and needs
only its own ground, which keeps the rotation intact between .teacher and .first. */
.team{background:var(--paper)}
.first{background:var(--surface)}
.schedule{background:var(--paper)}
.faq{background:var(--ground-alt)}
.inside{background:var(--surface)}
.insta{background:var(--paper)}
.collage-section{background:var(--ground-alt)}
/* .find is deliberately absent from the band rotation: .find-cta declares its own dark
ground, and a later same-specificity rule silently overrode it -- the heading and eyebrow
kept the paper colour written for that dark ground and rendered white on white. A section
that brings its own ground is not available to the cycle. */
/* A hairline at every band edge. A 2% tonal step is enough to read as a change of ground
but not enough to draw its own edge, so the border is what makes the boundary crisp --
the same border/soft-fill pairing the shadow tiers are built on. */
.core, .why, .reviews, .teacher, .first, .schedule, .faq, .inside,
.insta, .collage-section, .find{border-top:var(--border-width) solid var(--border);border-bottom:var(--border-width) solid var(--border)}
/* Two consecutive bands sharing one ground read as a single section -- measured, .find ended
at the exact y where .final began and both were rgb(52,42,56), so the offers grid and the
closing CTA merged into one dark slab. A band's ground is what separates it from its
neighbour; two in a row must differ. */
.find + .final, .final + .find{border-top:var(--border-width) solid rgba(255,255,255,.14)}
.final{background:var(--plum)}
/* Cards in a row are one object, so they share edges and baselines whatever their copy
does. Measured, the how-it-works steps ran 6/2/4/4/2 lines and each card took its own
height, which reads as a rendering fault rather than as five different amounts of text. */
.step{display:flex;flex-direction:column;height:100%}
.pillar{display:flex;flex-direction:column;height:100%}
/* Stars are one mark, not five words. At 13.12px with a 2px gap they read as scattered
punctuation; larger and tighter, they read as a rating. */
.stars{gap:1px;font-size:var(--type-rating-size);letter-spacing:var(--type-rating-letter-spacing)}
/* The feed sells the room. At 210px wide in a six-column grid the tiles were thumbnails of
thumbnails; four across gives each one enough size to be worth looking at. */
/* The steps row is flex-wrap, so a card takes whatever width its own copy needs and the row
ends up ragged -- measured, five cards ran 6/2/4/4/2 lines at five different heights. A
fixed basis makes them one object; align-items stretches them to a shared height. */
.steps{align-items:stretch}
.step{flex:1 1 190px;max-width:230px}
/* The feed tiles were 210px wide -- thumbnails of thumbnails. 280 is enough to read a room
in, and the rail still scrolls. */
/* Long-form video is 16:9, so its rail card is wider than a reel's -- declared after
the .reel widening above so the modifier is not silently overridden by it. */
.reel-wide{flex:0 0 360px}
.hero h1{margin-top:var(--space-md)}
.hero .offer-chip{margin-top:var(--space-lg)}
.hero-ctas{margin-top:var(--space-lg)}
.hero .trust-line{margin-top:var(--space-md)}
/* THE TRUST ROW, ONE RULE. This selector was declared four times across this file --
nowrap+overflow-x:auto (a horizontal scroller), then wrap+overflow:visible (its exact
opposite) twice more, each later declaration silently winning the cascade over the one
before it whatever the file's own comments said the row was supposed to do. That is the
actual reason "E-RYT 500+" clipped to "E-RY" on tara-glazer's hero: not one bad value, but
four competing ones, and no one rule was ever the row's real behaviour. wrap is correct --
a scroll strip with nothing on screen inviting a visitor to scroll it hides content instead
of degrading it -- so it is declared exactly once, here. */
.hero .trust-line{flex-wrap:wrap;overflow:visible;gap:var(--space-md) var(--space-lg);align-items:flex-start;line-height:1.3}
.proof{display:flex;flex-direction:column;gap:2px;flex:0 0 auto}
.proof-fig{display:flex;align-items:center;gap:var(--space-xs);font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-data-size);font-weight:var(--type-data-weight);letter-spacing:var(--type-data-letter-spacing);line-height:1;color:var(--ink)}
.proof-fig .num{font-weight:inherit;font-size:inherit}
.proof-lab{font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-eyebrow-letter-spacing);text-transform:uppercase;color:var(--muted)}
.hero .trust-line>.dot{display:none}
/* --- HERO: space around the message, not under it ---------------------------
Measured before this: 788px band, 25% text, 21% image, 54% empty -- and the
empty half sat BELOW the content rather than around it, which reads as an
unfinished page instead of a spacious one. The rulebook's own words: use
space to create hierarchy, not to fill space.
Three changes. The band centres its content rather than starting it under the
nav, so the air distributes above and below. The copy column takes a reading
measure instead of the full track, so the headline breaks where it should. And
the headline gets room of its own above the chip -- a display line needs a
silent margin or it reads as the first item in a list. */
/* One primary action. The rulebook is explicit -- a hero has one dominant
message, one dominant visual, one primary action -- and this hero had two
filled buttons competing, which is the same as having none: a visitor asked to
choose between two equally loud invitations takes neither. "Explore Classes"
becomes the quiet one. */
.hero .hero-ctas .btn-ghost{background:transparent;border-color:transparent;text-decoration:underline;text-underline-offset:5px;padding-left:0;padding-right:0}
.hero .hero-ctas .btn-ghost:hover{background:transparent;text-decoration-thickness:2px}
/* --- HERO: the photograph is half the composition ---------------------------
Measured before this: the image occupied 21% of the band while the copy took
25% and 54% was empty ground below both. A hero whose subject is a room has
to show the room. The photo column now runs the full height of the band, so
the band's proportions are set by the image rather than by whatever height
the copy happened to need.
The headline stacks. Three words on three lines can carry .88 leading because
no descender meets the next ascender; a wrapped sentence cannot, which is why
the previous version read as prose set large rather than as a poster. */
/* THE HERO GRID, PER THE OWNER'S SPEC (2026-09-07). Two grid columns, content left / image
right, each sized by minmax(0, Nfr) -- the 0 floor is not decoration, it is what makes the
column shrinkable below its content's natural width, which is the actual mechanism a
flex/grid child needs before it will wrap instead of pushing its sibling around or
overflowing past it. Every earlier version of this rule was missing that floor on one side
or the other, which is the root cause behind three different symptoms fixed today: the
trust row overflowing the photo, the headline overflowing the photo, and the photo
appearing to "drift" at odd zoom levels -- all three were the same missing constraint.
The image column is visually the larger half (per the request), never a fixed pixel width. */
.hero:not(.page-hero){align-items:stretch;padding:0}
.hero:not(.page-hero) .hero-grid{align-items:center;gap:var(--space-xl);grid-template-columns:minmax(0,1.02fr) minmax(0,.98fr);min-height:inherit}
/* CONTENT COLUMN. align-self:center (via the flex column's justify-content:center) balances
the block vertically without positioning any child individually -- eyebrow, h1, lede, CTA
stay in normal flow and simply share the column's centred cross-axis. min-width:0 is the
flex-item half of the same floor the grid track sets above; a flex item's default is
min-width:auto ("never narrower than my least-breakable content"), which is what let the
trust row's fixed-width numerals hold this column open past its own track. */
.hero-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;padding:var(--space-2xl) 0}
/* IMAGE CONTAINER. The container -- not the image -- owns the column's box; the image fills
it with object-fit:cover and object-position:center, so it is centred INSIDE its own
container rather than relative to the viewport. That distinction is what the owner's zoom
report was actually about: nothing here ever moved with viewport zoom, because nothing here
references the viewport at all -- height:100% is 100% of the grid row (set by
align-items:stretch above), not of any viewport unit, so the framing is stable at 50%, 70%,
100%, whatever the browser's zoom level does to the surrounding chrome. */
.hero-photo{aspect-ratio:3/4;height:auto;max-height:none;border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);margin:var(--space-lg) var(--space-xl) var(--space-lg) 0;width:calc(100% - var(--space-xl));position:relative;overflow:hidden}
.hero-photo img{position:absolute;inset:0;height:100%;width:100%;object-fit:cover;object-position:var(--focal,center)}
.hero:not(.page-hero) h1{max-width:min(16ch,100%);margin:0 0 var(--space-md);font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-size:var(--type-display-size);line-height:var(--type-display-line-height);letter-spacing:var(--type-display-letter-spacing);font-weight:var(--type-display-weight);color:var(--ink);overflow-wrap:break-word}
@media (max-width:900px){ .hero:not(.page-hero) h1{white-space:normal}}
/* THE HERO'S SECOND LINE MUST NOT BE CLIPPED BY THE PHOTO.
This carried `white-space:nowrap`, which is a promise the content cannot keep. The <em> is
whatever phrase the composer put after the headline; on the reference profiles it is short
("Align | Flow | Restore.", "in Rosemary Beach.") and one line is the right setting for it.
On a long one it is not, and nowrap does not degrade -- it lays the line out at whatever
width the words need and lets it run out of the column. `.hero{overflow:hidden}` then cuts
it off at the photo's edge, mid-word.
Measured before this line at 1440px: the-soundbath-studio's h1 overflowed its own box by
392px and read "Vibrational Sound Therap"; kathryn-hocking's overflowed by 193px and read
"into a scalable business mo". Both were correct and complete on a phone, because the
<=900px override below already returned them to `normal` -- the desktop rule was the only
thing breaking them.
`text-wrap:balance` is the setting that actually expresses the intent. It keeps the phrase
on one line whenever one line fits, and when it does not it breaks at a space and evens the
lines instead of overflowing. Words are never split, so tests/test_headline_wrap.py's ban
on `overflow-wrap:anywhere` is honoured -- this is the opposite mechanism, breaking only at
opportunities the text already offers. `max-width` bounds the phrase to the display measure
so it wraps inside the copy column rather than growing to whatever the grid will concede. */
.hero h1 em{display:block;margin-top:var(--space-sm);font-family:var(--font-display);font-size-adjust:var(--fsa-display);font-style:normal;font-size:var(--type-h1-size);line-height:var(--type-display-line-height);letter-spacing:var(--type-display-letter-spacing);font-weight:var(--type-display-weight);color:var(--ink);white-space:normal;text-wrap:balance;max-width:100%}
@media (max-width:900px){ .hero h1 em{white-space:normal}}
@media (max-width:900px){ .hero h1 em{white-space:normal}}
.hero .offer-chip{margin:0 0 var(--space-md)}
.hero-ctas{margin:0 0 var(--space-lg)}
.hero .trust-line{margin:0}
/* MOBILE: content stacks above image -- an intentional single column, not the desktop
grid shrunk. The photo gets its own aspect-ratio (portrait, matching the spec's 4/5) instead
of a viewport-height slice, so its framing is set by the box, the same way the desktop
container is -- consistent behaviour at every width rather than two different sizing
mechanisms for one column. 8px side padding is the owner's own measured ask (the photo ran
fully edge-to-edge with none). */
@media (max-width:900px){ .hero:not(.page-hero) .hero-grid{grid-template-columns:1fr;gap:var(--space-lg)} .hero:not(.page-hero) .hero-photo{aspect-ratio:3/4;height:auto;min-height:0;border-left:none;padding:0;margin:0 var(--space-xs);width:calc(100% - var(--space-xs) * 2);box-sizing:border-box;border-radius:var(--radius-lg);box-shadow:var(--shadow-sm)} .hero:not(.page-hero) .hero-photo img{border-radius:var(--radius-lg)} .hero:not(.page-hero) .hero-copy{padding:var(--space-xl) var(--space-md)} .hero:not(.page-hero) h1{white-space:normal;max-width:none}}
/* --- ANNOUNCEMENT BAR ------------------------------------------------------
A standing offer above the page rather than inside the hero. Full-bleed, one
line, quiet enough that the headline below is still the first thing read. */
.announce{position:fixed;top:0;left:0;right:0;z-index:101;display:block;background:var(--plum-deep);color:var(--paper);text-decoration:none;text-align:center;padding:var(--space-xs) var(--space-md);font-size:var(--type-meta-size);font-weight:var(--type-meta-weight);letter-spacing:var(--type-eyebrow-letter-spacing);text-transform:uppercase;transition:background .2s}
.announce:hover{background:var(--plum)}
.announce-in{display:inline-block}
/* The hero's own eyebrow: the brand name, small and quiet above a headline that
is the creator's words rather than their name. */
.hero-eyebrow{display:block;margin:0 0 var(--space-md);font-size:var(--type-eyebrow-size);font-weight:var(--type-eyebrow-weight);letter-spacing:var(--type-eyebrow-letter-spacing);text-transform:uppercase;color:var(--muted)}
/* The bar and the nav are two pinned strips in one stack: the bar owns the top edge, the
nav sits directly beneath it, and the page begins below both. Declaring the bar's height
once keeps the three offsets from drifting apart. */
/* line-height was the bar's ONLY height mechanism, and --announce-h is injected per-profile
from an estimate of the copy. When the real announcement text is longer than that estimate
it wraps to two lines and the bar renders taller than the number nav's `top` is derived
from -- measured on abhaya-yoga at 1024/1440: bar 0-44 against a nav pinned at top:38, so
the fixed bar sat 6px over the header it is supposed to stack above. Giving the bar an
explicit min-height of --announce-h (instead of faking height with line-height) and a real
line-height means a short bar is still exactly --announce-h tall, while a long one is
allowed to grow; nav then clears the taller of the two rather than the guess. */
.announce{min-height:var(--announce-h);line-height:1.3;align-items:center;justify-content:center}
nav{top:max(var(--announce-h),44px)}
.hero:not(.page-hero){padding-top:calc(var(--announce-h) + var(--nav-h))}
@media (max-width:900px){ .hero:not(.page-hero){padding-top:calc(var(--announce-h) + var(--nav-h) + var(--space-sm))}}
/* :not(.find-card) -- this touch-target enlarger is meant for plain text links (nav/footer),
but .find-card is ALSO an <a> with no .btn class, so its own align-items:center was winning
on specificity (0-2-1 beats .find-card's 0-1-0) and re-centering the card's column layout
under 900px -- only the badges survived left-aligned because they carry their own explicit
align-self. Found 2026-09-08 re-looking at the find section at 768px. */
/* TOUCH TARGETS -- WCAG 2.5.5 is 44x44 at EVERY width, not just under 900px.
Two real defects lived in the old pair of rules:
1. They were wrapped in `@media (max-width:900px)`, so at 1024px and 1440px the nav
links measured 35x39, the footer links 429x20 and the social icons 18x25. A pointer
target is a target on a laptop too -- WCAG has no viewport exemption -- and the
desktop numbers were the worse ones.
2. Even inside the query they set min-height only, never min-width, so short labels
stayed pencil-thin: "FAQ" measured 23x44 and each social icon 18x44. Height alone
is half a target.
So: unconditional, and both axes. Nav/footer/legal links get padding rather than a bare
min-width so a wide label keeps hugging its text while a short one grows to 44 from the
centre; the social icons, whose 18px svg has no text to pad around, get a square
min-width/min-height box and centre the glyph inside it. Negative inline margins on the
horizontal rows keep the added padding from visibly loosening the existing spacing --
the hit area grows, the design does not move. */
a:not(.btn):not(.brand-mark):not(.find-card):not(.inside-tile):not(.reel):not(.co-tile):not(.rev-card):not(.offer-chip){min-height:44px;display:inline-flex;align-items:center}
.nav-links a:not(.nav-cta), .foot-legal a:not(.btn){min-height:44px;min-width:44px;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box}
.nav-links{margin:0 calc(var(--space-xs) * -1)}
.nav-links a:not(.nav-cta){padding:var(--space-xs)}
.foot-legal{margin-left:calc(var(--space-xs) * -1);margin-right:calc(var(--space-xs) * -1)}
.foot-legal a:not(.btn){padding:0 var(--space-xs)}
/* .foot-col is a stacked column of links. Making them plain inline-flex to reach 44px turned
the whole column into a single edge-to-edge ROW (measured: five links side by side at
gap 0), because an inline-flex box shrink-wraps its text instead of filling the column.
width:100% restores the one-per-line stack, flex supplies the vertical centring inside the
44px box, and flex-start keeps the label on the left where the design has it. */
.foot-col a:not(.btn){min-height:44px;min-width:44px;display:flex;align-items:center;justify-content:flex-start;width:100%;box-sizing:border-box}
.fsoc a{min-width:44px;min-height:44px;display:inline-flex;align-items:center;justify-content:center}
.fsoc{margin:0 -13px}
/* .brand-mark is a link too, and on a logo-image profile (duke-s-flight-school) it carries a
narrow mark instead of a wordmark -- measured 35x44 at 390px and 43x44 at 1440px, so the
height alone was not enough. min-width squares it off; a wide wordmark is unaffected
because min-width only ever grows a box that is already narrower. */
.brand-mark{min-height:44px;min-width:44px;align-items:center}
/* WCAG 2.5.8 target SPACING (8px). Every control above is now exactly 44px tall with no
margin of its own, so two stacked or wrapped links abut at gap 0 -- measured on
.foot-col's column and on .foot-legal once it wraps to a second line. The 44px boxes must
therefore be separated by real space, not just sized. Row-gap on each wrapping row, and
the footer column keeps its existing bottom margin between entries. */
.foot-legal{row-gap:var(--space-sm)}
.nav-links{row-gap:var(--space-sm)}
/* --space-xs is 8px exactly -- the WCAG 2.5.8 minimum. The 44px target boxes are otherwise
flush in the column (line ~619's own margin is overridden by this rule, so this IS the
whole gap); --space-2xs (4px) was tried and measured as a failure on two profiles. */
.foot-col a:not(.btn){margin-bottom:var(--space-xs)}
/* --announce-h is written per-profile into an earlier :root block (38px on abhaya-yoga), so
redeclaring it here does not win -- but nav's `top` is derived from it while the bar's own
height is forced to a 44px tap target by the rule below. That mismatch left the bar's
bottom edge 2px from the nav's first control: adjacent targets with no thumb-width between
them. Rather than duel with the injected variable, nav gets the clearance directly --
max() keeps the profile's own offset whenever it is already the larger of the two, so a
profile with a tall wrapped bar is unaffected and only the too-short ones are pushed
clear of the 44px bar plus 8px of WCAG 2.5.8 spacing. */
@media (max-width:900px){ nav{top:max(var(--announce-h),52px)}}
@media (max-width:900px){ .announce{min-height:44px;display:flex;align-items:center;justify-content:center;line-height:1.3}}
@media (max-width:900px){ .stars{min-height:0} .proof{min-height:0}}
@media (max-width:600px){ .hero .trust-line{gap:var(--space-sm) var(--space-md)}}
@media (max-width:900px){ .foot-grid.cols-2, .foot-grid.cols-3, .foot-grid.cols-4{grid-template-columns:1fr 1fr}}
@media (max-width:600px){ .foot-grid.cols-2, .foot-grid.cols-3, .foot-grid.cols-4{grid-template-columns:1fr}}
/* --- THE ANNOUNCEMENT BAR ON A PHONE ---------------------------------------
The bar was pinned to one line at every width (`white-space:nowrap` with an
ellipsis), so at 390px it stopped being a sentence: kate-amber rendered
"PRIVATE YOGA SESSIONS -- 1 HO $150" -- the word HOUR cut in half, the price
still there -- and nolosol-hot-yoga simply trailed off into "...". A standing
offer a visitor cannot read is worse than no bar at all, and clipping INSIDE a
word is the version of that failure that looks like a rendering fault.
So the narrow bar wraps instead of clipping: the type steps down with the
viewport, the line-height stops being the full bar height (which only makes
sense for a single line), and the bar's declared height grows to the two or
three lines a wrapped offer needs. --announce-h is the one number the nav's
`top` and the hero's `padding-top` are both derived from, so raising it here
keeps all three strips in the same stack rather than letting a taller bar sit
on top of the nav. */
@media (max-width:600px){
body{--announce-h:56px}
.announce{font-size:clamp(.6rem,2.7vw,var(--type-data-small-size));padding:var(--space-2xs) var(--space-sm);letter-spacing:.04em;white-space:normal;overflow:visible;text-overflow:clip;line-height:1.3;height:auto;min-height:var(--announce-h);text-wrap:pretty}
.announce-in{display:block;overflow:visible;text-overflow:clip;white-space:normal;word-break:normal;overflow-wrap:break-word;hyphens:manual}
}
/* One voice in the hero. Measured before this: three families (display, body, mono) and
five colours in a single band, with the two halves of the headline in different colours
and one of them italic. A visitor reads that as a page that has not decided what it is.
The figures take the display face like the headline, the labels stay quiet, and the
accent is spent once -- on the primary action. */
.hero .proof-fig, .hero .proof-fig .num{font-family:var(--font-display);font-size-adjust:var(--fsa-display);color:var(--ink)}
.hero .proof-lab{color:var(--muted)}
.hero .hero-eyebrow{color:var(--muted)}
/* Owner caught this live: "ERYT 500+ / founder" unreadable. The rule above sets .proof-fig
to --ink (dark) and .proof-lab to --muted (grey) for the ORIGINAL boxed hero, where they
sit on a light --paper ground -- correct there. .hero-full's white/photo ground needs the
opposite, and because these child-element rules come AFTER the (higher-level) .hero-full
.trust-line{color:#fff} rule in source order, they were winning by cascade order even
though that rule already tried to set white -- text-shadow alone does not fix a color
that was never white to begin with. Placed HERE, immediately after, at equal specificity,
so THIS wins for full-bleed instead. */
.hero-full .proof-fig, .hero-full .proof-fig .num, .hero-full .proof-lab, .hero-full .hero-eyebrow{color:#fff;text-shadow:0 1px 3px rgba(0,0,0,.5)}
/* The breathe zoom grew from the centre, so 2% of every edge left the frame -- including
the top, where this photograph's raised hand already sits at the source's edge. Growing
from the top edge keeps that edge where it is; the overflow goes down and sideways, where
there is nothing to lose. */
.hero:not(.page-hero) .hero-photo img{transform-origin:50% 0%}
/* .hero-full's text sits directly on a photo and must be white -- but `.hero:not(.page-
hero) h1` and `.hero h1 em` further up this file both set color:var(--ink)/var(--clay)
for the ORIGINAL boxed hero (correct there, on --paper). Owner caught this live: the
headline rendered in dark ink, nearly invisible against the photo, because those rules
come after `.hero-full h1{color:#fff}` earlier in this file and win the cascade at equal
specificity. Placed HERE, after every other hero rule in this file, so nothing written
above can ever re-win against it -- the single fix for this whole recurring class of bug
rather than chasing one more conflicting selector each time a new one turns up. */
.hero.hero-fullbleed h1, .hero-full h1 em{color:#fff}
/* type_scale="xl" -- a photo that clears the full-bleed bar CONFIDENTLY (design.py's
XL_KEEP_CONFIDENT, not just the minimum floor) earns bigger type. Same clamp() SHAPE as
the base --type-display-size token (2.44rem,4.6vw,3.81rem), every number scaled by ONE
ratio (1.15) -- never three independently-guessed numbers (research doc finding #9). */
.hero-full[data-type-scale="xl"] h1{font-size:clamp(2.81rem,5.29vw,4.38rem)}
/* Owner caught this live too: the trust-line sat jammed against the CTA button above it,
and its dot separators never appeared, reading as randomly-scattered items instead of one
row. Two more `.hero`-scoped rules from the ORIGINAL boxed hero (line ~1209's
`.hero .trust-line>.dot{display:none}` -- right there because the boxed layout uses a
plain gap with no visible dots -- and line ~1297's `.hero .trust-line{margin:0}`, a
shorthand that zeroes the margin-top this file sets earlier) both still match
`.hero-fullbleed` and win by the same later-in-file cascade rule. Fixed at the source
this time (matching or exceeding specificity AND placed last), not patched twice more. */
.hero.hero-fullbleed .trust-line{margin-top:var(--space-md)}
.hero.hero-fullbleed .trust-line>.dot{display:inline-block;width:4px;height:4px;background:#fff;opacity:.85}
/* --- INTERIOR PAGES: ONE COLUMN, ONE LEFT EDGE -----------------------------
Measured on tara-glazer, nolosol-hot-yoga and kate-amber alike: /contact/ and
all five legal routes rendered as a half-empty LEFT column. The h1 started at
x=115 and every body paragraph at x=216 -- a 100px step visible as a
misalignment on every one of those pages -- and the right ~40% of a 1440px
viewport stayed empty for the page's full height.
Two separate causes, one shape. The hero laid its copy across the full
`.container` (1160px), while `.t2-doc .inner` centred a narrower measure
INSIDE that container, so the two bands started at different x. And on
/contact/ the fact list carried its own `max-width:640px` left-aligned, which
is a column, not a page.
The fix makes the document itself the column: on a page whose main holds a
prose document (.t2-doc) or the contact fact list (.t2-facts), every
.container -- hero, body and closing CTA -- takes the same reading measure and
the same centre. The hero and the body then share a left edge by construction
rather than by two numbers happening to agree, and the empty space is
symmetric margin around a centred document instead of a void on one side.
The measure is set in `ch`, so it tracks the body face rather than a pixel
count invented for one profile: ~65 characters is the span a line of prose can
be read across without the eye losing its place on the return sweep.
The gap under the hero goes with it. --space-xl (4rem) of hero padding met
--space-2xl (6rem) of document padding: 160px of nothing between a subline and
the first heading, which reads as a missing band rather than as air. */
main:has(.t2-doc) .container, main:has(.t2-facts) .container{max-width:min(100%,calc(65ch + var(--space-md) * 2))}
.t2-doc .inner{max-width:none;margin:0}
.t2-facts{max-width:none}
main:has(.t2-doc) .page-hero, main:has(.t2-facts) .page-hero{padding-bottom:var(--space-md)}
main:has(.t2-doc) .t2-doc{padding-top:var(--space-md)}
main:has(.t2-facts) .core{padding-top:var(--space-lg)}
/* --- ONE CALL TO ACTION PER BREAKPOINT -------------------------------------
The home page ships two copies of the same primary button: one in the hero
copy, one in the `.mobile-bar` pinned to the bottom of a phone screen. Both
render below 640px, so nolosol-hot-yoga's "Begin Your Practice" appeared
twice on one screen -- the second copy a full-width bar laid across the hero
photograph, splitting it and covering the model's head.
The pinned bar is the better of the two on a phone (it is reachable from
anywhere on a long page), so it is the one that survives; the hero's filled
button stands down and its quiet companion stays, which leaves the hero with
exactly one visible action. Scoped with :has() so a home page that renders no
mobile bar -- the bar is gated on there being a booking link at all -- keeps
the button it does have. */
@media (max-width:600px){ main:has(.mobile-bar) .hero:not(.page-hero) .hero-ctas .btn-primary{display:none}}
/* --- A RAIL HAS TO LOOK LIKE A RAIL ----------------------------------------
The reel carousel held four tiles in a 1160px track and cut the fourth one off
at the container edge with nothing to say it was scrollable: no arrow, no
fade, and on a platform with overlay scrollbars no scrollbar either. A tile
sliced down the middle reads as a broken layout, not as an invitation to
scroll.
The fade is the affordance: content dissolving at the edge is the one signal
that says "there is more this way" without adding chrome. It is painted with a
mask so it works over any ground, and the last tile carries a scroll margin so
a snap never parks a tile underneath the fade. Where the rail does not
overflow, the fade falls on empty ground and costs nothing. */
.rail{scroll-padding-inline:var(--space-md);-webkit-mask-image:linear-gradient(to right,#000 0,#000 calc(100% - 96px),transparent 100%);mask-image:linear-gradient(to right,#000 0,#000 calc(100% - 96px),transparent 100%)}
.rail>:last-child{scroll-snap-align:end;margin-inline-end:96px}
.rail::-webkit-scrollbar{height:6px}
.rail::-webkit-scrollbar-track{background:transparent}
.rail::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-pill)}
@media (hover:none){ .rail{scrollbar-width:none} .rail::-webkit-scrollbar{height:0}}
/* --- A CARD ROW FILLS ITS ROW ----------------------------------------------
`repeat(auto-fit,minmax(280px,1fr))` in a 1160px track resolves to three
columns (four would need 1168px), so tara-glazer's four offers rendered 3 + 1
and left ~370px of dead space beside the orphan; nolosol's /offers/ and
/contact/ bands left 60%+ of their width empty for the same reason. A grid
column count is chosen before the items are counted, which is why it can be
wrong for the items it gets.
Flex chooses after. Each card asks for a comfortable basis and is allowed to
grow, so a row takes as many cards as fit and then shares the remainder among
them -- whatever the count, the last row is full. The basis is smaller than
the old minimum precisely so four cards make one row rather than two. */
.find-grid{display:flex;flex-wrap:wrap;gap:var(--space-sm);align-items:stretch}
.find-grid>*{flex:1 1 min(100%,240px);min-width:min(100%,240px);max-width:100%}
@media (max-width:900px){ .find-grid{display:flex} .find-grid>*{flex:1 1 100%}}
/* --- ONE H1 SIZE PER ROLE --------------------------------------------------
The home hero sets its headline at --type-display-size and an interior page
sets its at --type-h1-size: two roles, deliberately -- a poster line and a
page title are not the same object. What matters is that each role is one
size everywhere it appears, and that the size comes from the scale rather
than from a rule written for one page. Declared here, once, against the token
the scale generates for every profile. */
.page-hero h1{font-size:var(--type-h1-size)}
