/* brand-reveal.css -- BL#17194. The landing page's ambient brand-reveal hero:
 * the looping mark+wordmark animation (docs/brand/generate_figure.py +
 * generate_wordmark.py's basalmind-lockup-wave.svg, inlined by
 * BrandRevealHero.astro so its SMIL timeline actually runs -- an <img src=...>
 * reference does not animate SMIL in any current browser) plus the two CTA
 * buttons that appear once it completes.
 *
 * An external file, not a scoped Astro <style> block, for the same reason
 * preview.css is one -- see that file's own header comment. This page's CSP is
 * `style-src 'self'` with no 'unsafe-inline', and Astro inlines any component
 * stylesheet under ~4kB by default, which that CSP would silently block.
 *
 * TIMING CONSTANTS MUST MATCH THE GENERATOR. docs/brand/generate_wordmark.py's
 * lockup_animated(): seconds=5.0, mirror=True (the ambient loop) multiplies by
 * 1.9, pin_solo_end=PIN_SOLO_END=0.22, FWD_END=HOLD_END=0.62/0.78 (shared by
 * generate_figure.py and generate_wordmark.py). Real seconds:
 *   CYCLE    = 5.0 * 1.9        = 9.5s   (one full loop)
 *   PIN_END  = CYCLE * 0.22     = 2.09s  (pin alone finishes)
 *   PEAK     = CYCLE * 0.62     = 5.89s  (arms+body+face+word all finish --
 *                                         this is also exactly when the CTAs
 *                                         should appear)
 *   HOLD_END = CYCLE * 0.78     = 7.41s  (recede begins)
 * If any of those Python constants change, --brand-reveal-peak below must be
 * re-derived by hand -- nothing currently generates this file automatically.
 */

/* 2026-10-10 (Jonah, mobile optimization pass follow-up): --brand-reveal-peak
   moved here from .brand-reveal-hero -- "let's also wait to paint the no
   credit card required chip until the same moment the CTAs appear." The
   chip (and #landing-cta-account next to it, preview.css) now live in
   index.astro, as SIBLINGS of <BrandRevealHero>, not descendants of
   .brand-reveal-hero -- a custom property declared there wouldn't be
   visible to them at all (CSS custom properties cascade to descendants
   only). :root makes it visible everywhere this page's CSS runs,
   preview.css included, without duplicating the derivation math this
   file's own header comment already documents. */
:root {
  --brand-reveal-peak: 5.89s;
}

/* 2026-10-10 (Jonah): "the top of the slideshow start after only a small
   margin below the header" -- flex-start pins content to the top (the
   "small margin below the header" is .landing's own padding-top, see
   preview.css). gap tightened to 1.25rem (20px) to match preview.css's
   own .landing gap -- "20px" reads as one consistent rhythm through
   stage -> caption -> controls -> chip/CTA.

   flex:1 -> flex:0 1 auto, SAME-DAY follow-up (Jonah): once the stage
   became a fixed 1:1 square (brand-reveal-stage-wrap, below) instead of
   something that grows to fill available space, keeping flex:1 here
   would have let this box keep consuming all of .landing's leftover
   height anyway -- with nothing after the controls row to absorb that
   extra space, it would have collected as a gap BETWEEN the controls row
   and .landing-foot-row (the chip/CTA), breaking the "controls ~20px
   above the chip/CTA" measurement two passes before this one just
   established. flex:0 1 auto sizes this box to its own content's real
   height instead; any genuinely leftover viewport space (a very tall
   phone, a short demo) now shows below the chip/CTA row, above the
   footer -- ordinary page whitespace, not space inside the demo itself.
   .landing itself keeps its own flex:1 (BL#17186's no-scroll/100dvh
   requirement is unrelated to this and still applies unchanged). */
.brand-reveal-hero {
  /* 2026-10-10 (Jonah): "on a desktop the controls bar is hiding
     underneath the cta button." flex-shrink:0 (was flex:0 1 auto,
     shrink ALLOWED) -- every child inside this column is already
     individually flex-shrink:0 (the stage, the caption, the controls
     row), but the hero ELEMENT ITSELF, as a flex item of .landing, was
     still free to shrink as a whole. Combined with its own min-height:0
     (which exists specifically to permit shrinking below content size),
     .landing's flex layout was measuring hero's own box SMALLER than
     the real combined height of its (individually rigid) children --
     confirmed live, 1280x800: heroHeight computed to 516.98px while its
     children's actual combined height was 572.16px. Since none of those
     children can themselves shrink, the overflow didn't compress
     anything -- it just spilled out past hero's own claimed box
     (default overflow:visible), with the bottom-most child (controls)
     landing visually inside the gap/foot-row space .landing's own
     justify-content:space-between (BL#17239) had carved out, overlapping
     the chip/CTA row. min-height:0 dropped too -- it has no effect once
     shrink is disabled. */
  flex: 0 0 auto;
  width: 100%;
  max-width: 1120px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: 1.25rem;
}

/* Jonah, 2026-10-09: the stage must always be a fixed size -- specifically
 * the SAME box for every slide (idle included), not a box sized to fit
 * whichever slide's content happens to be biggest. Without this, the
 * column reflowed per slide (the idle/slide-1 logo's own ~4.32:1 viewBox
 * rendered far shorter than slides 2-4's real 1280x800 video) and the
 * Forward/restart/CTA row visibly dropped once a video slide loaded --
 * confirmed live, not assumed, by measuring both states' real
 * getBoundingClientRect() before this fix.
 *
 * Box-size history since then, all same-session:
 *   fixed ~4.32:1 aspect (locked to the idle logo)
 *     -> flex:1, grows to fill available vertical space, max-height capped
 *   -> mobile max-height tightened to 300px (less empty letterboxing)
 *   -> max-height removed entirely, box fills ALL available space
 *   -> THIS pass (Jonah): "let's make the height of it exactly the same
 *      as the width of a standard iphone. that way it will be a perfect
 *      square on most devices... as long as we record our content in a
 *      square we will never have dead or truncated content." A genuine
 *      architecture fix, not another number tune -- every prior pass was
 *      chasing the same symptom (empty space around non-square content
 *      in a non-square box) with a different box size; a square box
 *      paired with square-recorded content (the real fix on the content
 *      side, outside this file's scope) makes frame and content match
 *      BY CONSTRUCTION, at any viewport width, with no breakpoint
 *      tuning ever needed again. aspect-ratio:1/1 (not a hardcoded
 *      iPhone pixel width) is what makes "exactly the same as the width"
 *      true at EVERY width, not just one reference device -- Jonah's own
 *      "I assume you will decide how to make that perfect square small
 *      enough to fit smaller devices" is exactly what a percentage-width
 *      aspect-ratio box does automatically, no media query needed.
 *      max-width keeps the square from becoming enormous on a wide
 *      desktop viewport, where 100%-of-column would otherwise make it
 *      far taller than the page has graceful room for.
 *
 * Still exactly ONE box, same size for idle and every demo slide. Content
 * inside (svg/video) is contained/centered within whatever size this
 * resolves to, never stretched to fill it -- see the sizing rules below.
 * Until content is actually re-recorded square, existing wide-aspect
 * assets (the mark, the 1280x800 draft video) will show letterboxing
 * inside this square -- a known, accepted, temporary state per Jonah's
 * own framing of this as a forward-looking fix for future recordings. */
/* 2026-10-10: tried flex-shrink:1 + min-height:0 first (standard flexbox+
   aspect-ratio shrinking) to handle short viewports -- measured live at
   360x640 and it shrank HEIGHT alone (233px) while WIDTH stayed pinned at
   its explicit 100% value (360px), breaking the square instead of
   preserving it. The explicit `width:100%` is what wins that conflict:
   once flex-shrink reduces the main-axis (height, in this column) below
   what aspect-ratio implies from the ALREADY-FIXED width, the box just
   ends up non-square rather than the browser reconciling the two.

   Fixed by making WIDTH itself the one computed, flexible value instead
   -- min() across three candidates: 100% (normal case, most viewports),
   480px (the desktop cap), and a height-derived budget for short
   viewports specifically. That budget is calc(100dvh - 400px): 400px is
   a measured approximation of everything else that has to fit in the
   same viewport (header, the 20px gaps, the 4-line caption bubble, the
   controls row, the chip/CTA row, the footer) at THIS page's real
   content -- not pixel-exact for every possible footer line-wrap, but a
   safety floor against the overlap this replaces, not a precision
   target. aspect-ratio then cleanly derives height from whichever
   candidate wins, with no competing explicit height to conflict with it. */
.brand-reveal-stage-wrap {
  position: relative;
  width: min(100%, 480px, calc(100dvh - 400px));
  aspect-ratio: 1 / 1;
  flex: 0 0 auto;
  /* 2026-10-10 (Jonah): "should we enable the pointer finger cursor when
     the user's mouse is over the slideshow? that would help me know it's
     clickable and where the slideshow box actually is." Honest now, not
     just decorative -- this element (#demo-pause-toggle in the markup)
     always does something on click: starts the demo during idle, toggles
     pause/resume during an active demo (brand-reveal-hero.js's own click
     handler, updated same pass so idle is no longer a no-op). */
  cursor: pointer;
}

.brand-reveal-stage {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Author styles beat the UA default [hidden]{display:none} stylesheet
   regardless of selector specificity -- .brand-reveal-stage's own
   display:flex above was silently winning and showing every stage (every
   demo video box, the close-slide copy) stacked on top of each other and
   the idle loop simultaneously. Caught live watching the real build, not
   assumed. */
.brand-reveal-stage[hidden],
.brand-reveal-demo-controls[hidden],
.brand-reveal-ctas[hidden],
.brand-reveal-pause-overlay[hidden],
/* 2026-10-10: #demo-forward's own .brand-reveal-nav--icon class sets
   display:flex at equal (class-level) specificity to the UA stylesheet's
   [hidden]{display:none} -- author rules beat UA rules at equal
   specificity regardless of source order, so forwardBtn.hidden=true
   (Jonah: "the right arrow should be hidden if we are on the final
   slide") was silently not hiding it. Same bug class as the others in
   this rule already. */
.brand-reveal-nav--icon[hidden] {
  display: none;
}

/* ---- Tap-to-pause overlay (Instagram-Reels-style) -----------------------
   2026-10-10 (Jonah): "if a user taps on the slideshow as it is playing,
   the video will pause, and in the middle and center of the screen, the
   user will see a play icon... Above that icon, and slightly smaller will
   be the mute icon." position:absolute + inset:0 against
   .brand-reveal-stage-wrap's own position:relative -- see that rule's own
   comment -- so this covers exactly the visible stage box regardless of
   which slide is showing. A faint scrim (not a hard dark panel) keeps the
   paused frame itself still readable underneath -- this is a pause
   indicator, not a modal. cursor:pointer signals the whole overlay is
   itself the "tap to resume" target (wired in brand-reveal-hero.js),
   matching how the un-paused stage was already tappable to get here. */
.brand-reveal-pause-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  background: rgba(10, 8, 4, 0.22);
  cursor: pointer;
}

/* Smaller and secondary to the play icon below it -- Jonah's own
   "slightly smaller." Its own round hit-area (not the shared
   .brand-reveal-nav--icon treatment, which is sized for the always-
   visible controls row, not this conditional overlay) with a translucent
   backing so it reads clearly against whatever frame is paused behind
   it. stopPropagation() on its click (brand-reveal-hero.js) is what
   keeps tapping it from ALSO resuming playback via the overlay's own
   click handler underneath. */
.brand-reveal-mute-toggle {
  all: unset;
  box-sizing: border-box;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 999px;
  background: rgba(10, 8, 4, 0.35);
  color: var(--basal-text-bright);
}

.brand-reveal-mute-toggle:hover,
.brand-reveal-mute-toggle:focus-visible {
  background: rgba(10, 8, 4, 0.5);
}

.brand-reveal-mute-toggle:focus-visible {
  outline: 2px solid var(--basal-accent-light);
  outline-offset: 2px;
}

.brand-reveal-mute-toggle svg {
  display: block;
}

/* Only one of the two glyphs renders at a time, toggled via JS setting
   the real `hidden` ATTRIBUTE (setAttribute/removeAttribute -- see
   BrandRevealHero.astro's own comment on this element for why `.hidden =`
   doesn't work on an SVG). This [hidden] rule is still required even so --
   author styles otherwise beat the UA default [hidden]{display:none} the
   same way .brand-reveal-stage[hidden] needed its own rule above. */
.brand-reveal-mute-toggle svg[hidden] {
  display: none;
}

/* The big, centered "paused" indicator -- a solid disc (not outline, for
   legibility against any frame) inside a soft circular backdrop, the same
   translucent-disc language Instagram/YouTube's own pause glyph uses
   rather than a bare icon floating with nothing behind it. */
.brand-reveal-play-indicator {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 3.5rem;
  height: 3.5rem;
  border-radius: 999px;
  background: rgba(10, 8, 4, 0.45);
  color: var(--basal-text-bright);
}

.brand-reveal-play-indicator svg {
  display: block;
  /* The play triangle's own visual weight sits left of true-center
     (it's a triangle, not a symmetric glyph) -- a hair of right margin
     re-centers it optically inside the round backdrop. */
  margin-left: 2px;
}

/* Jonah, 2026-10-09: slide changes (Forward, restart, a progress-dot jump,
 * or auto-advance) fade the outgoing slide's audio+visual out QUICKLY --
 * "like a person interrupted," no trailing words -- then a brief pause,
 * then the new slide's video and audio start together. A true overlapping
 * cross-dissolve between two different app-capture frames reads as a
 * muddy double-exposure (Fable's review, same session) -- this is instead
 * a sequential dip: fully out, a beat of nothing, then in. JS (brand-
 * reveal-hero.js) sets the two different durations via inline
 * transitionDuration (180ms out, 320ms in -- quick exit, slightly more
 * deliberate entrance) and toggles .is-transitioning; [hidden] only gets
 * applied once the fade-out finishes; opacity can't transition across a
 * display:none boundary, which is why this can't be pure CSS. */
.brand-reveal-stage {
  opacity: 1;
  transition: opacity 320ms ease-in-out;
}
.brand-reveal-stage.is-transitioning {
  opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
  .brand-reveal-stage { transition: none; }
}

.brand-reveal-stage svg {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  display: block;
}

/* 2026-10-10 (Jonah): the earlier two-column video+caption layout inside
 * the stage box ("is a bad design choice, now the slideshow is tiny")
 * is gone -- the video now simply fills the stage box the same way the
 * idle/slide-1 SVG already does (contain-fit, centered, never stretched).
 * That two-column layout existed to keep the box visually "full" against
 * its old locked-narrow aspect ratio; now that the box itself grows to
 * use the available vertical space (.brand-reveal-stage above), a video
 * alone at a real, generous size solves the same problem without needing
 * a second column. The caption moved back to its one shared position
 * below the stage (BrandRevealHero.astro's own comment on
 * #brand-reveal-caption). */
.brand-reveal-stage--video video {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  display: block;
  border-radius: 12px;
  /* One treatment, not stacked (Fable: four effects doing one job reads
     cheap) -- a hairline border matching every other card on the site
     (main.css's own --basal-border-subtle convention), no fill, no
     shadow. The video is "a screen of the app," not a framed photograph. */
  border: 1px solid var(--basal-border-subtle);
}

.brand-reveal-stage--close {
  max-width: 640px;
  padding: 0 1rem;
}

.brand-reveal-close-copy {
  font-family: var(--basal-font-serif);
  font-size: clamp(1.1rem, 2.4vw, 1.4rem);
  line-height: 1.5;
  color: var(--basal-text-bright);
  text-align: center;
  margin: 0;
}

/* 2026-10-10 (Jonah, ADA-compliant synced captions): fixed two-line
   VIEWPORT now, not a growing block sized to whatever that slide's
   caption happens to need -- "more like a ticker." Real closed-caption/
   teleprompter behavior: height never changes (so nothing else on the
   page -- the fixed bottom row especially -- ever shifts position as
   longer or shorter lines play), overflow:hidden, and
   #brand-reveal-caption-track's own scrollTop is what moves as new words
   are revealed (see brand-reveal-hero.js's revealWordsUpTo). font-size
   lives here, not on the track, so the calc() below and the track's
   inherited size agree with each other by construction. */
/* 2026-10-10 (Jonah): "let's make the caption section look more like a
   chat bubble." Also, verbatim: "introducing this more conversational
   experience is a way to prepare them for what it will be like having
   chats with basal after they log in. we will eventually evolve our
   chat experience to mimic what we are designing here" -- this isn't
   just caption styling, it's a working sketch of the REAL future chat
   surface.

   2026-10-10 follow-up, same session: "I want the bubbles to mimic the
   way someone communicates in slack. they will share a complete thought
   in a bubble then a new complete thought will appear in a new bubble
   that appears on the bottom and the previous bubble scrolles up, and
   eventually becomes obscured from view." That's a real architecture
   change, not just a visual one -- #brand-reveal-caption is now a
   TRANSPARENT scrollable VIEWPORT (fixed height, overflow:hidden, no
   fill/border of its own -- Slack's own message list has none either),
   and each individual #brand-reveal-caption-bubble (below) carries the
   actual message-bubble look. #brand-reveal-caption-track is the plain
   flex column that stacks bubbles in order with a gap between them;
   scrolling still happens on the OUTER element (brand-reveal-hero.js's
   revealWordsUpTo/scrollTop mechanism), same technique as the original
   single-bubble ticker, just now scrolling past whole bubbles as well
   as words within the current one. */
.brand-reveal-caption {
  width: 100%;
  max-width: 480px;
  font-family: var(--basal-font-sans);
  font-size: 0.85rem;
  line-height: 1.4;
  box-sizing: border-box;
  /* History, same element, two reversed decisions:
     1. Originally a FIXED height (5 lines) -- flexbox silently crushed it
        on tight viewports (no flex-shrink guard), which was ALSO the real
        cause of "the slideshow is sitting too low, bubbles obscured": the
        stage never moved, this box was being crushed into a sliver.
     2. "Fixed" blamed for the crush, so BL#17239 switched to content-
        driven min/max/height:auto -- fixed the crush, but introduced a
        NEW, worse problem: .brand-reveal-demo-controls sits right after
        this box in the hero's own column, so it visibly drifted up and
        down every time a bubble grew or shrank. 2026-10-10 (Jonah): "the
        control bar is moving up and down, depending on how much room the
        speech bubbles are taking... we should create an element in which
        to put all the speech bubbles so the element itself has a stable
        size and the bubbles are allowed to enter it without changing the
        size of the container."

     THIS pass: fixed height again (genuinely stable, like #1), but with
     flex-shrink:0 this time -- the actual missing piece both earlier
     passes lacked. flex-shrink:0 is safe to add now in a way it wasn't
     the first time: .landing's own justify-content:space-between
     (preview.css, added fixing the chip/CTA row's position) already
     decouples the chip/CTA row's position from the hero's total content
     height, so even if this rigid box ever pushes hero's natural height
     past what .landing can give it without shrinking, the chip/CTA row
     doesn't drift -- only the GAP between hero and that row shrinks.

     2026-10-10 follow-up (Jonah): "the word bubble and the element that
     contains them needs to be capable of displaying up to four rows of
     text per bubble... the text bubble is being truncated on slide 3."
     The `+12px` fudge above only budgeted for raw 4-line-height text; a
     REAL 4-row bubble carries more than that -- its own padding (1rem,
     .brand-reveal-caption-bubble below) and border (2px), and for a
     list-type segment (slide3's own 4-item list -- the one that was
     actually truncating), three inter-item gaps too (.brand-reveal-
     caption-list's own 0.3rem gap x 3, below). Confirmed live: that
     bubble rendered at 108.5px tall against an 88.16px-tall container --
     since the bubble was TALLER than the whole viewport, its top portion
     was permanently scrolled out of view no matter how the ticker
     positioned it, not a timing bug. This formula now derives from the
     bubble's own real anatomy (worst case: a 4-item list, the tallest
     thing 4 "rows" can mean) instead of a flat line-height guess, so it
     stays correct if either the bubble's own padding or the list's own
     gap ever changes. Growing this by ~24px reopens budget pressure
     everywhere this box lives (desktop's was already tight, BL#17311) --
     see main.css's own .site-header/.site-footer padding trim, Jonah's
     own suggested lever, for where that room came from instead of
     re-tightening the demo's already-tight internal gaps again. */
  height: calc(1.4em * 4 + 3 * 0.3rem + 1rem + 2px + 4px);
  flex-shrink: 0;
  overflow: hidden;
}

/* visibility:hidden, not display:none -- see BrandRevealHero.astro's own
   comment on this element for why the `hidden` attribute (tried first)
   broke the CTA's own fixed-position requirement.

   2026-10-10 (Jonah): "the button to play the demo is now sitting behind
   the credit card and create account buttons." height:0 here, reversing
   this rule's own prior "keeps this box's layout footprint identical
   whether or not it's actually showing anything" -- that reasoning
   predates .landing's own justify-content:space-between (BL#17239),
   which now protects the chip/CTA row's position independent of the
   hero's content height on its own. Once the caption's own height went
   from content-driven back to a genuinely fixed 4 lines (BL#17309,
   immediately above), idle state started reserving that full ~88px even
   though it's invisible AND empty then (no bubbles exist before a demo
   has ever started) -- on a short viewport that pushed #idle-ctas far
   enough down to visually collide with the chip/CTA row sitting at its
   own fixed position below. Nothing needs this box's footprint to stay
   constant across idle<->active any more; only ACROSS SLIDES during an
   active demo does flex-shrink:0 above need to hold. */
.brand-reveal-caption.is-inactive {
  visibility: hidden;
  height: 0;
}

.brand-reveal-caption-track {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  margin: 0;
}

.brand-reveal-caption-bubble {
  color: var(--basal-text-secondary);
  text-align: left;
  padding: 0.5rem 0.75rem;
  border-radius: 14px;
  background: var(--basal-surface-low);
  border: 1px solid var(--basal-border-faint);
  /* Each bubble only ever needs to hold ONE "complete thought" -- a
     sentence or a short list, per landing-steps.ts's own segment
     authoring -- so unlike the single-bubble ticker this replaced, a
     bubble is never expected to need more than a few lines; no height
     cap of its own, it just grows to fit its real content. */
}

/* A list-type segment's own bubble -- "like when we present a numbered
   list of the topics." Real <ol>/<li>, not just newlines -- markers,
   indent, and spacing between items are what makes the list actually
   read as a list rather than one run-on sentence. */
.brand-reveal-caption-list {
  margin: 0;
  padding-left: 1.1rem;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.brand-reveal-caption-list li {
  padding-left: 0.1rem;
}

/* Each word starts invisible and fades in at its own real Cartesia
   timestamp (revealWordsUpTo, driven by <audio>'s timeupdate) -- "every
   word... displayed in the moment he says it." Spoken words brighten to
   --basal-text-bright, a quiet closed-caption-style emphasis distinguishing
   "already said" from "about to be said" without relying on color alone
   (opacity also changes, so this isn't a color-only distinction). */
.caption-word {
  opacity: 0;
  transition: opacity 150ms ease;
}

.caption-word.is-said {
  opacity: 1;
  color: var(--basal-text-bright);
}

/* landing-steps.ts's own hand-authored `emphasize` word indices -- the
   four life-category names, Decide/Prepare/Do, and the BasalMind/Basal
   brand name. Weight alone (not color alone) carries the distinction, so
   this still reads correctly for a viewer who can't perceive the color
   shift. Applies on top of .is-said -- an emphasized word not yet spoken
   is still invisible like any other word, it just arrives bold once its
   moment comes. */
.caption-word.is-emphasis {
  font-weight: 700;
}

.caption-word.is-emphasis.is-said {
  color: var(--basal-accent-light);
}

@media (prefers-reduced-motion: reduce) {
  .caption-word { transition: none; }
}

/* The stage's SVG is aria-hidden (it's a looping ambient animation, not
   content) -- this is the real accessible-name text standing in for it. */
.brand-reveal-hero .sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.brand-reveal-ctas {
  display: flex;
  gap: 0.75rem;
  flex-wrap: wrap;
  justify-content: center;
  flex-shrink: 0;
}

.brand-reveal-cta {
  all: unset;
  box-sizing: border-box;
  cursor: pointer;
  font-family: var(--basal-font-sans);
  font-size: 0.9rem;
  line-height: 1.35;
  padding: 0.65rem 1.4rem;
  border-radius: 999px;
  border: 1px solid var(--basal-accent-border);
  color: var(--basal-text-secondary);
  text-align: center;
  transition: background-color 120ms ease, color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
  opacity: 0;
  transform: translateY(6px);
  /* Fires exactly at the peak (see header), fades in fast (.18s -- generate_
     figure.py/generate_wordmark.py switched calcMode "spline" -> "linear"
     this same session specifically so this instant is trustworthy: with the
     old decelerating spline, the icon LOOKED finished up to ~1.5s before the
     keyframe math agreed, and no fixed delay here could get both the fast
     and the slow part of that right at once), then holds -- never tied back
     into the loop's later recede, Jonah's explicit call ("do not fade them
     away as the wave recedes... they will stay on the page").

     Longhand, not the `animation` shorthand, specifically so #landing-cta-
     account.is-quiet (below) can drop ONLY brand-reveal-cta-pulse from
     animation-name while brand-reveal-cta-in stays in the exact same list
     position with every other longhand (duration/delay/iteration-count/
     fill-mode) unchanged -- per spec, an animation-name entry that doesn't
     change position/value across a style recalc keeps running rather than
     restarting. 2026-10-10 bug (Jonah: "the cta button got hidden once i
     started the demo"): the old `animation: none` shorthand on .is-quiet
     cancelled BOTH animations, including brand-reveal-cta-in -- which was
     the only thing holding opacity at 1 after its one-shot fill:both ended.
     Cancelling it reverted the element to this rule's own base opacity:0,
     exactly when startDemo() adds .is-quiet. The button was never meant to
     hide again once revealed -- only the pulse was meant to pause. */
  animation-name: brand-reveal-cta-in, brand-reveal-cta-pulse;
  animation-duration: 0.18s, 2.6s;
  animation-delay: var(--brand-reveal-peak), calc(var(--brand-reveal-peak) + 0.18s);
  animation-iteration-count: 1, infinite;
  animation-fill-mode: both, none;
}

.brand-reveal-cta:hover,
.brand-reveal-cta:focus-visible {
  background: var(--basal-accent-glow);
  color: var(--basal-text-bright);
  transform: translateY(-1px);
}

.brand-reveal-cta:active { transform: translateY(0); }
.brand-reveal-cta:focus-visible { outline: 2px solid var(--basal-accent-light); outline-offset: 2px; }

.brand-reveal-cta--primary {
  background: var(--basal-accent);
  border-color: var(--basal-accent);
  color: var(--basal-bg);
  font-weight: 600;
}

.brand-reveal-cta--primary:hover,
.brand-reveal-cta--primary:focus-visible {
  background: var(--basal-accent-light);
  color: var(--basal-bg);
}

@keyframes brand-reveal-cta-in {
  to { opacity: 1; transform: translateY(0); }
}

/* Jonah: "a gentle pulse... not in an annoying way, just enough to know
   there is power behind the button." A quiet glow, not a scale/bounce --
   scale pulsing on a CTA reads as nagging, not inviting. */
@keyframes brand-reveal-cta-pulse {
  0%, 100% { box-shadow: 0 0 0 rgba(196, 154, 60, 0); }
  50% { box-shadow: 0 0 0 6px rgba(196, 154, 60, 0.12); }
}

@media (prefers-reduced-motion: reduce) {
  .brand-reveal-cta {
    animation: brand-reveal-cta-in 0.01s var(--brand-reveal-peak) both;
  }
}

/* ---- Demo controls: restart | breadcrumbs | forward, one row -----------
   2026-10-10 (Jonah), three passes this same session:
   1. Dots folded into the nav row (restart/forward/mute), dots-left/
      nav-right, once the persistent CTA moved out of this row entirely.
   2. Reversed: dots spread full-width in their OWN row, nav-group
      (restart/forward/mute) centered in a separate row below -- "the
      breadcrumb placement does not make sense... spread the breadcrumbs
      across the full width."
   3. THIS pass, reversed again, differently: "we can move the video
      controls into the same row as the breadcrumbs. the restart icon
      will be to the far left of all the breadcrumbs... there is a bit
      of separation between the replay icon to the left and to the right
      will be the right arrow icon." One row again, but restart/forward
      now FLANK the dot cluster (not grouped together on one side of it)
      -- .brand-reveal-demo-row below is that row; .brand-reveal-progress
      no longer spreads edge-to-edge, it's a tight, centered cluster
      between the two nav icons (gap below, not space-between). Mute
      moved out of this row entirely, into the tap-to-pause overlay (see
      that element's own comment) -- "a more conditional location." */
.brand-reveal-demo-controls {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  max-width: 480px;
  gap: 0.6rem;
  /* Explicit, not just incidentally true -- same "every row in this
     column must be rigid, not just the stage" reasoning as the caption's
     own flex-shrink:0 above (2026-10-10, stable control-bar position). */
  flex-shrink: 0;
}

.brand-reveal-demo-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1rem;
}

/* Clustered, not spread -- 0.5rem between dots is "still far enough apart
   to tap on with a finger" (Jonah's own words) without reading as spaced
   out across the row the way the full-width version did. The 1rem gap on
   .brand-reveal-demo-row above (between restart/cluster/forward) is the
   "bit of separation" on either side of the cluster. */
.brand-reveal-progress {
  display: flex;
  /* 2026-10-10 (Jonah): "on mobile, the breadcrumbs are a bit too close
     together in order to be able to accurately tap them." Up from
     0.5rem -- paired with each dot's own expanded invisible hit area
     below, so the REAL tappable regions (not just the 8px visual dots)
     have genuine separation, not just the dots themselves. */
  gap: 0.75rem;
}

/* Real <button>s now (Jonah, 2026-10-09 -- jump straight to any slide),
   not a passive <span> indicator -- reset the browser's own button chrome
   (border/padding/background) so only the intended dot renders; visual
   size unchanged from the original indicator. */
.brand-reveal-progress-dot {
  all: unset;
  box-sizing: border-box;
  position: relative;
  display: block;
  width: 8px;
  height: 8px;
  border-radius: 999px;
  background: var(--basal-border-strong);
  cursor: pointer;
  transition: background-color 150ms ease, transform 150ms ease;
}

/* 2026-10-10 (Jonah): "the breadcrumbs are a bit too close together in
   order to be able to accurately tap them." The visual dot stays small
   (8px, matching the rest of this row's deliberately compact rhythm) --
   this invisible ::after expands the REAL tappable region around it to
   something closer to a real touch target (roughly 32px square,
   contained by .brand-reveal-progress's own gap above so adjacent dots'
   hit areas don't overlap each other) without changing how the row
   looks. */
/* Asymmetric, not a uniform inset -- confirmed live a uniform -12px made
   ADJACENT dots' hit areas overlap each other (the row's own gap, 0.75rem,
   is narrower than two 12px insets combined), which would have let a tap
   meant for one dot register on its neighbor instead -- a worse failure
   than the original complaint. Vertically there's no neighboring row to
   collide with, so -14px there gives real thumb-sized reach; horizontally
   stays small enough (-4px) that adjacent hit areas never touch. */
.brand-reveal-progress-dot::after {
  content: '';
  position: absolute;
  inset: -14px -4px;
}

.brand-reveal-progress-dot:hover {
  background: var(--basal-accent-border);
}

.brand-reveal-progress-dot:focus-visible {
  outline: 2px solid var(--basal-accent-light);
  outline-offset: 2px;
}

.brand-reveal-progress-dot.is-done {
  background: var(--basal-accent-border);
}

.brand-reveal-progress-dot.is-active {
  background: var(--basal-accent);
  transform: scale(1.3);
}

/* "The replay icon should be grouped to the left of the forward button" --
   restart immediately precedes forward here, mute at the end. Its own full
   row now (no .brand-reveal-demo-row wrapper any more -- that markup
   element is gone, see .brand-reveal-demo-controls' own comment above),
   centered: nothing else shares this row to anchor against any more. */
.brand-reveal-nav-group {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  gap: 0.75rem;
}

.brand-reveal-nav {
  all: unset;
  box-sizing: border-box;
  cursor: pointer;
  font-family: var(--basal-font-sans);
  font-size: 0.85rem;
  color: var(--basal-text-secondary);
  padding: 0.5rem 0.25rem;
  transition: color 120ms ease, opacity 120ms ease;
}

.brand-reveal-nav:hover,
.brand-reveal-nav:focus-visible {
  color: var(--basal-text-bright);
}

.brand-reveal-nav:focus-visible {
  outline: 2px solid var(--basal-accent-light);
  outline-offset: 2px;
}

.brand-reveal-nav:disabled {
  opacity: 0.35;
  cursor: default;
}

/* Restart, Forward, and the mute toggle are ALL icon-only now (2026-10-10,
   Jonah: "the styling of the Forward with the right arrow is wrapping in
   an ugly way... can we communicate the replay, next, and mute concepts
   with iconography and no words?") -- one shared round hit-area class for
   all three instead of a separate .brand-reveal-mute-toggle block
   duplicating the same rules. 2.75rem (44px), up from 2.25rem (36px) --
   a real touch target, matching main.css's own .cta-primary min-height:
   44px convention at its mobile breakpoint rather than a smaller one
   invented just for this row. */
.brand-reveal-nav--icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border-radius: 999px;
}

.brand-reveal-nav--icon svg {
  display: block;
}

/* 2026-10-10 (Jonah): "when the right arrow disappears... the other
   elements in the control bar should not shift... hide it, not delete
   it, so the other elements don't have to find a new middle."
   .brand-reveal-nav-group centers its children (restart | dot cluster |
   forward) -- the OLD `hidden` attribute removed forwardBtn from that
   row's flow entirely, so restart+dots re-centered across a now-narrower
   row and visibly jumped right on the final slide. visibility:hidden
   keeps this element's layout box exactly in place while making it
   invisible and (implicitly, per the visibility property itself)
   unclickable -- same technique #brand-reveal-caption.is-inactive
   already uses for the identical reason, see that rule's own comment. */
.brand-reveal-nav--icon.is-invisible {
  visibility: hidden;
}

/* The mute button's two glyphs: only one renders at a time, toggled via JS
   swapping which <svg> has [hidden] -- simpler and more robust than
   swapping innerHTML, and keeps both paths statically in the markup for
   anyone reading the source. Scoped by id since restart/forward each only
   ever carry one <svg> and don't need this rule. */
#demo-mute-toggle svg[hidden] {
  display: none;
}

/* 2026-10-10 (Jonah, mobile optimization pass): #landing-cta-account is the
   persistent "Create Account" button, now living in index.astro next to the
   "No credit card required" chip rather than inside this component (see
   BrandRevealHero.astro's own comment on idle-ctas) -- an external
   stylesheet's classes still apply regardless of which file the markup
   lives in.

   2026-10-10 follow-up, same session (Jonah): "let's also wait to paint the
   no credit card required chip until the same moment the CTAs appear."
   This REVERSES the original call right above -- that comment's own
   "skips the fade-in... reads as a position/appearance change" reasoning
   doesn't hold once the chip waits too: with both the chip (preview.css's
   own .landing-foot rule) and this button sharing the exact same
   --brand-reveal-peak timing, nothing in that fixed-position row ever
   appears mid-session relative to anything else beside it -- the WHOLE
   row fades in together, once, same moment as the hero's other CTAs. So
   this override is gone; #landing-cta-account now just inherits
   .brand-reveal-cta's own default fade-in-at-peak-then-pulse animation,
   unchanged, same as the demo CTA already does.

   2026-10-10, two more follow-ups, same session:
   .is-quiet (Jonah): "if the user is on the final slide and the video of
   that final slide has finished playing, the cta button should be
   pulsing because that is the next step the user needs to take" --
   phrased as a specific trigger, not a restatement of the pulse this
   button already always had. Read as: the constant pulse makes sense
   while idling (inviting a click when nothing else is happening) but
   competes with the demo's own content while slides 1-4 are actively
   playing, so brand-reveal-hero.js now quiets it for exactly that
   stretch (.is-quiet, applied when the demo starts, removed again once
   slide 5's audio actually ends) -- pulsing resumes right when there's
   genuinely nothing left to watch and this button IS the next step. */
/* Drop ONLY the pulse (animation-name's 2nd slot -> none) -- brand-reveal-
   cta-in's 1st slot, and every other longhand property for it, stays
   identical to the base rule above so it never restarts (see that rule's
   own comment for why "animation: none" broke this). The button stays
   visible and clickable throughout the demo, just without the attention-
   grabbing glow while the demo itself has focus. */
#landing-cta-account.is-quiet {
  animation-name: brand-reveal-cta-in, none;
}

/* ---- Phone, landscape: two real 50/50 columns ----------------------------
   2026-10-10 (Jonah, first pass): "when i flip the phone sideways, the
   layout is terrible... i assume our square slideshow and the chat bubble
   scroller would sit side by side if the phone is in landscape position."

   2026-10-10 follow-up (Jonah, this pass -- supersedes the first grid):
   "the landscape mode is still only giving about 20 percent of the width
   to the slideshow and 80 percent to the chat bubbles and you didn't move
   the control bar like i asked" -- an explicit, more specific spec: "lets
   make the main section two 50/50 columns. the left column will have the
   slideshow and the control bar below it. the right column will have the
   transcript bubbles and the credit card and cta button."

   The chip/CTA row (#landing-cta-account + .landing-foot, inside
   .landing-foot-row) is a STRUCTURAL SIBLING of <BrandRevealHero> in
   index.astro, not one of its children -- the first grid (scoped to
   .brand-reveal-hero alone) could never place it in the right column no
   matter how its own internal grid was tuned. .landing itself has to be
   the grid now, with .brand-reveal-hero set to display:contents so ITS
   children (stage-wrap, caption, idle-ctas, demo-controls) become direct
   grid items of .landing alongside .landing-foot-row -- display:contents
   makes an element generate no box of its own; its children are treated
   as the parent's direct children for layout purposes, which is exactly
   what's needed to place elements from two different markup subtrees into
   one shared grid without restructuring the actual HTML.

   grid-template-columns: 1fr 1fr is the literal "50/50" -- not the auto-
   sized column the previous grid used (which measured the stage's own
   square size and gave it only whatever that happened to be, nowhere
   close to half the viewport). .brand-reveal-stage-wrap's square still
   derives its SIZE from row 1's height (unchanged reasoning from the
   first pass), but now sits centered within a column that's a real 50% of
   the width, not sized to fit it.

   No explicit height on .landing itself -- it already gets a real,
   definite height from its own existing flex:1 behavior inside <main>
   (preview.css), same mechanism portrait already relies on; grid rows
   resolve cleanly against that without needing a duplicated magic-number
   budget calc() here.

   Same max-height:500px/orientation:landscape condition as preview.css's
   own block -- see that file's comment for why (common phone landscape
   heights vs. a genuine desktop window). */
@media (orientation: landscape) and (max-height: 500px) {
  /* 2026-10-10 (Jonah): "if there is extra space after putting the
     square slideshow and the controls bar under it on the left side...
     lets allow the right side to use as much of the remaining space
     that's available, with a reasonable separation between right and
     left side." Supersedes this rule's own prior literal 1fr 1fr (a real
     50/50 split, per an earlier, more literal reading of "two 50/50
     columns") -- auto sizes the left column to exactly what the
     height-driven square stage actually needs at this viewport (often
     well under half the width), minmax(0, 1fr) on the right then
     absorbs every pixel that frees up, with column-gap as the
     "reasonable separation" between them. Jonah's own guess ("40/60 or
     maybe a 1/3 to 2/3 split") was a description of the expected OUTCOME
     of this rule, not a hardcoded ratio to target directly -- letting
     the real square size drive it stays correct at every landscape
     width/height combination instead of needing to be re-tuned per
     device the way a fixed ratio would. */
  /* 2026-10-10 follow-up (Jonah): "should we move the playback controls
     into a vertically oriented column, when we are in mobile landscape
     mode... [it] gives back some space so the slideshow can use all of
     its available vertical space." Jonah's own immediate follow-up
     caught the real risk of the obvious version of this (a column on the
     far right, next to the chip/CTA row): "this would also put the right
     arrow very close to the cta button, so the user will be more likely
     to click the cta if they've gotten used to clicking on the right."
     Resolved by putting the vertical column adjacent to the STAGE
     (far left) instead of the far right -- captures the stated benefit
     (stage's own row no longer needs to share height with a horizontal
     controls row below it, so it can use the column's FULL height) with
     none of the accidental-click risk, at the cost of the original
     right-thumb ergonomic framing (an explicit, deliberate trade --
     Jonah's own call when asked). Three real columns now, not two:
     stage | vertical controls | caption+chip/CTA. */
  .landing {
    display: grid;
    grid-template-columns: auto auto minmax(0, 1fr);
    grid-template-rows: minmax(0, 1fr) auto;
    column-gap: 0.75rem;
    row-gap: 0.4rem;
  }

  .brand-reveal-hero {
    display: contents;
  }

  .brand-reveal-stage-wrap {
    grid-column: 1;
    /* Spans both rows now -- there's no longer a separate "controls row"
       sharing this column underneath it, so the square gets this
       column's FULL height instead of competing with a second row for
       it (exactly Jonah's own "give back space so the slideshow can use
       all of its available vertical space"). */
    grid-row: 1 / 3;
    /* Height-driven square (unchanged logic from the first landscape
       pass). justify-self:center is a no-op now that the column is
       auto-sized tightly around the stage's own width (no slack left to
       center within) -- harmless to leave; removing it would add
       nothing. */
    width: auto;
    height: 100%;
    max-width: none;
    justify-self: center;
    /* 2026-10-10 (Jonah): "the final slide... is taking the entire space
       on the left side... so the play controls are covered up." The
       close slide's own text (.brand-reveal-close-copy, below) sized
       itself off viewport WIDTH (clamp(..., 2.4vw, ...)), which tracked
       the old single-column layouts' stage reasonably but has no
       relationship to THIS layout's much narrower, height-driven square
       -- confirmed live: the text's own rendered box measured 486px tall
       against a 178px-tall square, spilling out both above and below it
       and visually painting over the controls row beneath. overflow:
       hidden here is the backstop matching this box's own "always
       exactly one fixed box, nothing escapes it" design (video content
       already respects this via its own contain-fit sizing, this is the
       same guarantee extended to text) -- the real fix is the close-
       copy's own font-size override below, this is insurance underneath
       it. */
    overflow: hidden;
  }

  /* Demo-active controls (restart/dots/forward) now live in their own
     narrow vertical column between the stage and the caption, spanning
     the same two rows the stage does -- see .brand-reveal-demo-row and
     .brand-reveal-progress below for the actual row->column flip that
     makes them stack vertically instead of sitting side by side. The
     idle "90-second demo" button is a single horizontal pill, not an
     icon stack -- cramming it into this same narrow column would wrap
     it badly, so it moves to the wide caption/CTA column instead
     (sharing a cell with #brand-reveal-caption, below -- safe, since
     the caption is always .is-inactive with zero height while idle-ctas
     is visible, same mutual-exclusion the two already had before this
     change, just in a different cell). */
  .brand-reveal-demo-controls {
    grid-column: 2;
    grid-row: 1 / 3;
    width: auto;
    max-width: none;
    justify-content: center;
  }

  .brand-reveal-ctas {
    grid-column: 3;
    grid-row: 1;
    align-self: center;
    justify-self: center;
    width: auto;
    max-width: none;
  }

  /* 2026-10-10 (Jonah): "In the landscape mobile, we have more available
     space for the word bubbles than we are using, causing the older
     bubbles to be obscured out of view above, even when we have plenty
     of space below... lets make the entire space available on the right
     side in the main section available to contain word bubbles."
     Reverses this rule's own prior align-self:start + the base rule's
     fixed height -- those were tuned for portrait/desktop, where the
     caption deliberately stays small relative to a much taller page.
     Here, row 1's real height is set by the stage's own column (often
     250-300px+, confirmed live), and capping the caption at the
     universal ~113px four-row formula left most of that height
     genuinely unused. height:100% + align-self:stretch instead -- the
     caption now fills however tall row 1 actually resolves to at this
     viewport, same "derive from real geometry, don't hardcode a number"
     principle the column-width rule above already uses. */
  #brand-reveal-caption {
    grid-column: 3;
    grid-row: 1;
    width: 100%;
    max-width: none;
    height: 100%;
    align-self: stretch;
  }

  /* "lets start the word bubbles on the bottom of its element so they
     can then be pushed up as new bubbles come in." justify-content:
     flex-end + min-height:100% -- the track now fills the (now much
     taller) caption viewport instead of shrink-wrapping its own content,
     so when there are only one or two bubbles so far, they sit flush
     against the BOTTOM of the available space (empty room above, not
     below) rather than stranded at the top with empty space wasted
     underneath. Once real content eventually exceeds the viewport's
     height, this has no further effect -- brand-reveal-hero.js's own
     scrollTop logic (revealWordsUpTo) explicitly recomputes the exact
     scroll position every tick regardless of how the track's own
     content happens to be aligned, so the "reveal as it's spoken"
     mechanism is unaffected either way. */
  .brand-reveal-caption-track {
    justify-content: flex-end;
    min-height: 100%;
  }

  .landing-foot-row {
    grid-column: 3;
    grid-row: 2;
  }

  /* 2026-10-10: restart/dots/forward stack vertically in this column
     instead of sitting in a horizontal row -- the actual "vertically
     oriented column" Jonah asked for. Scoped to THIS breakpoint only;
     every other layout (portrait, desktop) keeps the horizontal row
     these same two rules define elsewhere (brand-reveal.css's own base
     rules, unchanged). */
  .brand-reveal-demo-row {
    flex-direction: column;
  }

  .brand-reveal-progress {
    flex-direction: column;
  }

  /* 2026-10-10 (Jonah): see .brand-reveal-stage-wrap's own comment above
     for the full story -- this is the real fix (a font-size that
     actually fits this layout's small, height-driven square), the
     overflow:hidden on the stage is just a backstop. vh-based, not vw --
     this box's size tracks available HEIGHT at this orientation, not
     viewport width, so sizing text off width (the base rule's own
     clamp(..., 2.4vw, ...), correct for the old single-column layouts)
     was measuring the wrong axis entirely. Tighter line-height
     (unitless, scales with the smaller font) and reduced side padding
     claw back a bit more room inside an already-small box. */
  .brand-reveal-stage--close {
    padding: 0 0.5rem;
  }

  .brand-reveal-close-copy {
    font-size: clamp(0.6rem, 3.2vh, 0.85rem);
    line-height: 1.3;
  }
}

/* ---- Desktop: reclaim budget so the chip/CTA row stays pinned ----------
   2026-10-10 (Jonah): "on a desktop the controls bar is hiding underneath
   the cta button." Lives here (not preview.css, which owns this same
   900px breakpoint for .landing's own padding/gap) because this file
   loads AFTER preview.css in index.astro's cssFiles array -- a same-
   specificity override for .brand-reveal-hero's own base rule (above,
   this file) written in the EARLIER-loading file would lose the cascade
   regardless of the media query, the exact cross-file ordering trap this
   session already hit once for the landscape grid (see that rule's own
   comment). .brand-reveal-hero's base gap (1.25rem) is correct and
   unchanged for portrait/mobile -- only desktop's real, measured budget
   deficit (~35px at 1280x900, see preview.css's own .landing rule at
   this same breakpoint for the full reasoning) needs trimming. Two gaps
   exist inside this column (stage-to-caption, caption-to-controls), so
   0.75rem here reclaims 2 * (1.25rem - 0.75rem) = 16px toward that
   deficit; the rest comes from preview.css's own .landing padding/gap
   trim at this same breakpoint. */
@media (min-width: 900px) {
  .brand-reveal-hero {
    gap: 0.75rem;
  }
}
