/* Fractalize Studio -- a standalone product, so its own look rather than
   a copy of tuckermills.com's. Kept close to the fractal engine's own
   dark-glass aesthetic (rgba(255,255,255,0.1) panels, translucent white
   borders) since that IS the product here -- the page around it should
   read as the same family, not a different app that happens to link to
   it. No webfont, matching this project's existing convention of relying
   on the OS/browser's own system-ui stack. */

* {
  box-sizing: border-box;
}

html {
  background: #0a0c0e;
}

/* No background-color of its OWN -- html's background above already
   covers the canvas as a fallback, and .page-bg-video/.page-bg-overlay
   (fixed, negative z-index, see below) need body to stay transparent:
   since html already declares a background explicitly, the HTML5
   canvas-background-propagation rule doesn't kick in, so a background
   set here would paint as an ordinary in-flow element background --
   which paints AFTER (on top of, hiding) any negative-z-index child of
   body, defeating the whole point of putting the scroll-scrubbed
   background behind everything else. */
html,
body {
  margin: 0;
  padding: 0;
  color: #fff;
}

body {
  font-family: system-ui, sans-serif;
  line-height: 1.5;
  padding: 0 20px;
  /* Sticky-footer layout: body is at least one viewport tall (dvh so
     mobile browser chrome doesn't cause a gap/overscroll), and the
     footer's auto top margin absorbs any leftover space. Short pages
     pin the footer to the window bottom; long pages just flow. */
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

a {
  color: #fff;
}

/* Scroll-scrubbed background (see app.js) -- fixed to the viewport so
   it stays put while the page itself scrolls over it, sitting at a
   negative z-index so it paints behind ordinary in-flow page content
   without needing to coordinate with any other z-index in this file
   (the sticky nav's 500, the fractal engine's own 3100 -- both still
   correctly land on top of a negative-z-index element regardless). */
.page-bg-video {
  position: fixed;
  inset: 0;
  z-index: -2;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Keeps page text readable over what's still a busy, high-contrast
   background image -- sits directly above the video, still behind
   every normal page element. */
.page-bg-overlay {
  position: fixed;
  inset: 0;
  z-index: -1;
  background: rgba(0, 0, 0, 0.8);
}

.hero {
  /* Same as main's own max-width below, not this box's old 720px --
     centered independently, two different widths meant two different
     left edges on any viewport wide enough for both to hit their cap,
     throwing off .start-fractalizing-header/.start-fractalizing-step's
     own left alignment against .start-panel's. Every child here still
     centers itself the same as before regardless (.hero-logo/
     .hero-banner both cap out well under 720px anyway, .hero-cta
     buttons via their own margin:auto) -- only the newly left-aligned
     text actually reaches further out now, on purpose. */
  max-width: 960px;
  margin: 0 auto;
  /* Top halved (was 96px) -- less space above the logo banner. */
  padding: 48px 0;
  text-align: center;
}

.hero-title {
  /* Up 30% (was 12px) -- more space between the logo and the animated
     banner below it. */
  margin: 0 0 15.6px;
}

/* Replaces the old gradient-text wordmark -- same 1280x300 aspect ratio
   as the source art, scaled down for the hero (clamp keeps it from
   both dwarfing the tagline on mobile and staying too small on a wide
   screen, same spirit as the text title's own clamp() sizing before
   it). */
.hero-logo {
  display: block;
  width: clamp(260px, 60vw, 520px);
  height: auto;
  margin: 0 auto;
}

/* An autoplaying, muted, looping video rather than an actual .gif --
   the source gif played forward-then-reverse (a "boomerang") encoded as
   a real animated GIF came out to 13MB; the identical-looking loop as
   H.264/VP9 video is under 1MB with no palette banding. Built by
   reversing the source clip and concatenating it after itself (ffmpeg
   -filter_complex "split, reverse one half, concat"), so it plays
   through once, plays back in reverse, and repeats via the video's own
   native loop -- no JS needed. */
.hero-banner {
  display: block;
  width: min(100%, 600px);
  /* Bottom up 50% (was 20px) -- more space below the logo banner,
     before the tagline. */
  margin: 0 auto 30px;
  border-radius: 16px;
}

.hero-tagline {
  margin: 0;
  font-size: 1.1rem;
  color: rgba(255, 255, 255, 0.65);
}

/* A plain <hr> reset to a single thin line -- no border-style/height of
   its own to fight with (the default UA <hr> is a 2px inset groove,
   wrong for this dark theme), just the one 1px top border, same
   translucent-white weight borders use throughout this file. */
.section-divider {
  height: 0;
  margin: 40px 0;
  border: none;
  border-top: 1px solid rgba(255, 255, 255, 0.12);
}

/* Same pill shape/color as .action-btn (see below), just bigger --
   this is the hero's own primary call to action, not one of two
   equal-weight choices the way the upload card's pair are. */
.hero-cta {
  appearance: none;
  -webkit-appearance: none;
  display: block;
  margin: 28px auto 0;
  border: 1px solid #fff;
  border-radius: 999px;
  background: #fff;
  color: #111;
  font-size: 0.95rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  padding: 16px 36px;
  cursor: pointer;
  transition: background-color 0.2s ease, transform 0.1s ease;
}

.hero-cta:hover {
  background: rgba(255, 255, 255, 0.85);
}

.hero-cta:active {
  transform: scale(0.97);
}

/* display:block above otherwise beats the browser's own default
   [hidden]{display:none} UA rule at equal specificity -- re-assert it
   explicitly. Reachable via [data-start-fractalizing] itself (see
   index.html/app.js), toggled by script; BROWSE GALLERY reuses
   .hero-cta too but never carries [hidden]. */
.hero-cta[hidden] {
  display: none;
}

/* Swaps places with .hero-cta above once .start-panel opens (see
   app.js) -- margin-top 40px more than the button's own (68px vs
   28px), margin-bottom half what the gap below used to be (this used
   to just be .hero's own 48px bottom padding; 24px here now, the rest
   of that 48px still applies after .start-fractalizing-step below).
   Left-aligned, unlike everything else in this centered hero. No
   [hidden] override needed: unlike .hero-cta, nothing here sets its
   own `display`, so the browser's own [hidden]{display:none} already
   wins outright. scroll-margin-top matches .sticky-nav's own rendered
   height (~57px, rounded up) -- app.js's scrollIntoView({block:'start'})
   would otherwise land this flush with the true viewport top, right
   under that fixed bar (which is already visible by the time a scroll
   this size finishes, since .hero-logo above is long since scrolled
   out of view), hiding the very header it just scrolled to reveal. */
.start-fractalizing-header {
  margin: 68px 0 24px;
  text-align: left;
  scroll-margin-top: 72px;
  font-size: 1.3rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #fff;
}

/* Both of this flow's step lines share this class -- "➀ Check your
   audio settings" and "➁ Pick your image" (later just "➁", see
   .pick-image-row below) -- shown/hidden together (see app.js), both
   as soon as .start-panel opens, not gated behind finishing the other
   first. Left-aligned to match the header and .start-panel. */
.start-fractalizing-step {
  margin: 0;
  text-align: left;
  font-size: 0.95rem;
  color: rgba(255, 255, 255, 0.75);
  /* Matches .start-fractalizing-header's own fix -- app.js scrolls
     directly to ➀ once PICK YOUR IMAGE is clicked, and by then
     .sticky-nav (~57px tall) is already visible, fixed over the true
     viewport top. */
  scroll-margin-top: 72px;
}

/* ➀ specifically (not ➁'s own two forms, which share this same base
   class but live inside main, not .hero) -- it's always the LAST
   child of .hero whenever it's visible, so .hero's own 48px bottom
   padding (meant to give breathing room below whatever IS last there
   in the default, flow-closed state -- untouched on purpose) was
   stacking on top of .start-panel/[data-live-audio-confirm]'s own
   margin-top below, inflating that gap to 64px instead of the
   intended 16px. This negative margin cancels exactly that padding
   for this one case, leaving the gap below ➀ governed purely by
   whatever comes next's own margin-top, same as every other gap in
   this flow. */
[data-start-fractalizing-step] {
  margin-bottom: -48px;
}

/* Whichever step is the one actually in progress right now (see
   app.js) -- bigger and full-white instead of the dimmer default
   above, same idea a numbered wizard's own current-step highlight
   would use. */
.start-fractalizing-step.is-current {
  font-size: calc(0.95rem + 4px);
  color: #fff;
}

/* Added to ➀ once PICK YOUR IMAGE is clicked (see app.js), right
   alongside .is-current handing off to ➁ -- marks step 1 as actually
   finished, not just no-longer-current, in the same positive-state
   color the live-audio buttons use for their own "done" fill. */
.start-fractalizing-step.is-complete {
  color: #eee8ae;
}

/* Replaces [data-pick-image-step]'s full "➁ Pick your image" line once
   "Use Live Audio Input" is clicked (see app.js) -- the marker (reusing
   .start-fractalizing-step above, so .is-current applies here the same
   way) pinned to the row's own left edge, the button still centered
   the way every other .hero-cta is (margin:auto restored below, since
   the marker being absolutely positioned takes it out of flow rather
   than sharing normal flow with it). */
.pick-image-row {
  position: relative;
  /* No top margin of its own (was 28px) -- relies entirely on
     whatever precedes it (.start-panel or [data-live-audio-confirm],
     both 16px margin-bottom) collapsing through, so this gap matches
     every other 16px gap in this flow instead of winning out to 28px
     over that 16px in the collapse. */
  margin: 0;
}

.pick-image-row .start-fractalizing-step {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  margin: 0;
}

/* 36px top (60px, pulled back 24px) -- since .pick-image-row itself
   has no padding/border of its own, this collapses through the row's
   own top edge rather than growing the row's own box, so the marker
   beside it (absolutely positioned, sized to the row) shifts down
   along with the button instead of staying put while the button alone
   moves -- the whole row lands 36px further down, not just this
   button within it. */
.pick-image-row .hero-cta {
  margin: 36px auto 0;
}

/* Added once PICK YOUR IMAGE's own job is done (see app.js) --
   visibility, not [hidden]/display:none, so the row doesn't collapse
   out from under the marker sitting next to it (that marker is
   absolutely positioned, so it doesn't contribute any height of its
   own -- the button normally does). */
.pick-image-row .hero-cta.is-done {
  visibility: hidden;
  pointer-events: none;
}

/* Denotes progress between ➀ and whichever of ➁'s two forms is
   currently showing ([data-pick-image-step]'s full text, or the
   [data-pick-image-marker] alongside PICK YOUR IMAGE once "Use Live
   Audio Input" is clicked) -- only shown once both are simultaneously
   visible (see updateStepConnector in app.js, which also
   positions/sizes this: no ancestor here is a fixed size or shares one
   with both endpoints, so this is JS-measured against each line's own
   getBoundingClientRect() rather than a CSS-only span, and re-measured
   live whenever either endpoint's own size/visibility can change --
   .start-panel's own height while open, .is-current's bigger font,
   ➁'s own text/row swap). Runs from ➀'s own bottom edge to ➁'s own top
   edge, not edge-to-edge of the two lines' full height. Sits absolutely
   against the page itself (no positioned ancestor needed) since its
   two endpoints live in different containers (.hero vs main). */
.step-connector {
  position: absolute;
  width: 0;
  border-left: 2px dotted rgba(255, 255, 255, 0.35);
  pointer-events: none;
}

/* Fixed regardless of body's own side padding (position: fixed sizes
   against the viewport, not an ancestor's padding box) -- hidden by
   default and toggled by app.js's IntersectionObserver on .hero-logo,
   rather than a plain "always sticky" header, since a persistent bar
   sitting on top of the hero's own logo/banner would be redundant with
   what's already right there. */
.sticky-nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 500;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px 20px;
  background: rgba(10, 12, 14, 0.85);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  opacity: 0;
  transform: translateY(-100%);
  transition: opacity 0.25s ease, transform 0.25s ease;
  pointer-events: none;
}

.sticky-nav.is-visible {
  opacity: 1;
  transform: translateY(0);
  /* The bar's only ever interactive now that it holds real nav links
     (previously just "FRACTALIZE STUDIO" text, so this never mattered)
     -- re-enable clicks once actually visible; the base rule's own
     none keeps it from intercepting anything while still faded/
     off-screen above. */
  pointer-events: auto;
}

/* Constrains the bar's own content to the same 960px column every
   other section on this page uses (see main below), rather than
   letting title/nav spread to the viewport's full width the way
   .sticky-nav itself (a fixed, edge-to-edge bar) needs to for its own
   background/blur to reach both edges. */
.sticky-nav-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  width: 100%;
  max-width: 960px;
  margin: 0 auto;
}

.sticky-nav-title {
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  color: #fff;
  white-space: nowrap;
}

/* --- Site nav (pills on desktop, hamburger dropdown on mobile) -------- */
/* Same structural pattern as tuckermills.com's own header nav -- a
   row of pill links that collapses into a single hamburger button +
   dropdown below this page's one breakpoint -- styled with this page's
   own glass/pill language (.action-btn-secondary, the fractal
   toolbar's own buttons) instead of copying that site's look. */
.site-nav {
  display: flex;
  align-items: center;
  gap: 8px;
}

.site-nav-link {
  appearance: none;
  -webkit-appearance: none;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  font: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  padding: 8px 16px;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.site-nav-link:hover {
  background: rgba(255, 255, 255, 0.2);
}

.site-nav-link[aria-current="page"] {
  border-color: #fff;
  background: rgba(255, 255, 255, 0.2);
}

.site-nav-hamburger {
  position: relative;
  display: none;
}

@media (max-width: 600px) {
  .site-nav {
    display: none;
  }

  .site-nav-hamburger {
    display: block;
  }
}

.site-nav-hamburger-toggle {
  appearance: none;
  -webkit-appearance: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.1);
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.site-nav-hamburger-toggle:hover {
  background: rgba(255, 255, 255, 0.2);
}

/* Same glass-sheet language as the fractal toolbar/panels (see
   fractalize-core.css) rather than tuckermills.com's own flat, more
   opaque dropdown -- floats over whatever's scrolled beneath it. */
.site-nav-hamburger-menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  min-width: 160px;
  list-style: none;
  margin: 0;
  padding: 6px;
  background: rgba(20, 20, 20, 0.92);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 12px;
}

.site-nav-hamburger-menu .site-nav-link {
  display: block;
  width: 100%;
  border: none;
  border-radius: 8px;
  background: transparent;
  text-align: left;
  padding: 10px 12px;
}

.site-nav-hamburger-menu .site-nav-link:hover {
  background: rgba(255, 255, 255, 0.15);
}

.site-nav-hamburger-menu .site-nav-link[aria-current="page"] {
  background: rgba(255, 255, 255, 0.15);
}

.site-nav-hamburger-menu li + li {
  margin-top: 2px;
}

/* A second reminder riding directly below .sticky-nav (see its own
   comment in index.html) -- absolutely positioned against .sticky-nav
   itself (the nearest positioned ancestor) rather than a fixed sibling
   of its own, so it never needs to know that bar's own height
   (intrinsic, not a fixed px value) to line up flush against it.
   top:100% (not bottom:0, which used to sit this INSIDE the nav's own
   box, overlapping "FRACTALIZE STUDIO" itself since the nav is barely
   taller than this banner's own 24px) anchors its own top edge to the
   nav's bottom edge, so it extends downward from there instead --
   locked directly beneath the header, never over it. translateY(-100%)
   when hidden tucks it back up by its own height, out of view (behind
   the now-opaque nav, where it doesn't matter since opacity is 0 too)
   -- .is-visible (toggled by app.js) slides it down to its natural
   resting spot right below. Same positive-state-animated gradient the
   live-audio buttons themselves use (see that keyframes' own comment)
   -- no background-attachment:fixed here the way those buttons' own
   label patch needed, since there's no overlapping sibling of its own
   to stay in sync with. animation runs continuously regardless of
   .is-visible -- cheap (just a repainted gradient, no layout), and
   keeps it already mid-motion instead of restarting fresh every time
   it's revealed. */
.sticky-audio-banner {
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  height: 24px;
  background: linear-gradient(-45deg, #b0d1ca, #9dced2, #e0e3c2, #b0d1ca);
  background-size: 400% 400%;
  animation: positive-state-animated 8s ease infinite;
  opacity: 0;
  transform: translateY(-100%);
  transition: opacity 0.25s ease, transform 0.25s ease;
  pointer-events: none;
  overflow: hidden;
}

.sticky-audio-banner.is-visible {
  opacity: 1;
  transform: translateY(0);
}

.sticky-audio-banner .fractal-audio-waveform {
  display: block;
  width: 100%;
  height: 100%;
  /* Same black-on-positive-state look the confirm button's own
     waveform uses -- see its own comment below. */
  filter: invert(1);
}

/* Used inside .start-panel only now (see below) -- an earlier
   always-visible checkbox card of its own, styled with this same
   class, was removed once .start-panel covered the same ground in a
   more guided way. */
.live-audio-help-step {
  margin: 0 0 8px;
  font-size: 0.9rem;
  color: rgba(255, 255, 255, 0.75);
}

.live-audio-help-step:last-of-type {
  margin-bottom: 18px;
}

main {
  max-width: 960px;
  margin: 0 auto 80px;
}

/* --- Buttons ---------------------------------------------------------- */
/* General-purpose pill buttons, not tied to any one section -- used by
   .start-panel's own two buttons and BROWSE GALLERY (composed with
   .hero-cta, see index.html) elsewhere in this file. */

.action-btn {
  appearance: none;
  -webkit-appearance: none;
  border: 1px solid #fff;
  border-radius: 999px;
  background: #fff;
  color: #111;
  font-size: 0.85rem;
  font-weight: 600;
  padding: 10px 18px;
  cursor: pointer;
  transition: background-color 0.2s ease, transform 0.1s ease;
}

.action-btn:hover {
  background: rgba(255, 255, 255, 0.85);
}

.action-btn:active {
  transform: scale(0.97);
}

.action-btn-secondary {
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  border-color: rgba(255, 255, 255, 0.3);
}

.action-btn-secondary:hover {
  background: rgba(255, 255, 255, 0.2);
}

/* --- Your uploads ------------------------------------------------------- */
/* Reuses .catalog-heading/.catalog-row/.catalog-thumb/.thumb-action below
   verbatim (a grid of thumbnails is a grid of thumbnails, whether the
   src is a curated URL or a blob: URL from IndexedDB), plus its own
   .catalog-add-thumb (also below) for the row's first, permanent "add
   photos" tile -- this page's one upload entry point now (see
   index.html). Only its own bottom margin needed, matching .catalog's,
   to keep the same rhythm of spacing before the curated catalog
   starts. */
.uploads-catalog {
  margin-bottom: 64px;
}

/* --- Curated catalog -------------------------------------------------- */

.catalog-heading {
  font-size: 1.1rem;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.85);
  margin: 0 0 24px;
}

/* Scoped to the Your Uploads heading specifically (not the shared
   .catalog-heading class -- Tuko's Photography's own copy has no note
   under it) -- tighter bottom margin since .uploads-catalog-note now
   sits directly beneath it, carrying its own margin-bottom instead.
   scroll-margin-top matches .start-fractalizing-header's own fix for
   the same reason: app.js scrolls here on PICK YOUR IMAGE, and by then
   .sticky-nav (~57px tall) is already visible, fixed over the true
   viewport top. */
[data-uploads-heading] {
  margin-bottom: 4px;
  scroll-margin-top: 72px;
}

/* Same claim the footer already makes, repeated here since a visitor
   may never scroll that far -- same dim, small treatment as that
   footer text. */
.uploads-catalog-note {
  margin: 0 0 20px;
  font-size: 0.8rem;
  color: rgba(255, 255, 255, 0.4);
}

/* --- Collection cards -> expanded view ---------------------------------- */
/* Landing spot once PICK YOUR IMAGE reveals .photo-collection -- one card
   per collection (cover photo, name, description), rather than every
   collection's own full row of thumbs all at once (too long a scroll with
   six collections' worth of photos). Only the collection actually clicked
   expands to its own full grid -- see .collection-slider below for how. */

/* Two panels (the card grid, then [data-catalog-expanded]), cross-fading
   rather than swapped in place -- position:relative here is what gives
   the absolutely-positioned inactive panel below something to overlay;
   overflow:hidden clips it if it's taller than whichever is active.
   Scroll target once a card (or "All Collections") is clicked (see
   app.js) -- matches every other scroll-margin-top fix in this file,
   for the same reason: .sticky-nav is already visible by the time that
   scroll finishes. */
[data-collection-slider] {
  position: relative;
  overflow: hidden;
  scroll-margin-top: 72px;
}

/* Inactive by default -- absolutely positioned (out of flow, so it
   doesn't affect the slider's own height) and transparent. .is-active
   below (toggled by app.js) is what actually promotes one to
   position:relative, putting it back in normal flow -- since that's
   the only panel now contributing to layout, the slider's own height
   already matches it with no JS measuring a height property needed.
   That promotion happens instantly (position isn't something CSS can
   transition), right as the opacity fade starts -- only the crossfade
   itself animates, not the height. pointer-events:none keeps the
   invisible one from intercepting clicks meant for whatever's under/
   around it. */
.collection-slider-panel {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.collection-slider-panel.is-active {
  position: relative;
  opacity: 1;
  pointer-events: auto;
}

.collection-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 16px;
  margin-bottom: 64px;
}

.collection-card {
  appearance: none;
  -webkit-appearance: none;
  display: block;
  width: 100%;
  padding: 0;
  text-align: left;
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.03);
  overflow: hidden;
  cursor: pointer;
  transition: border-color 0.2s ease, background-color 0.2s ease;
}

.collection-card:hover {
  border-color: rgba(255, 255, 255, 0.3);
  background: rgba(255, 255, 255, 0.06);
}

.collection-card-cover {
  aspect-ratio: 1;
  background: rgba(255, 255, 255, 0.06);
}

.collection-card-cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Your Uploads before anything's actually been added yet -- the same
   "+" invitation .catalog-add-thumb itself uses below, filling this
   card's own cover slot instead of a whole dedicated tile. */
.collection-card-cover.is-empty {
  display: flex;
  align-items: center;
  justify-content: center;
}

.collection-card-cover-empty {
  font-size: 2rem;
  line-height: 1;
  color: rgba(255, 255, 255, 0.3);
}

.collection-card-name {
  margin: 12px 12px 2px;
  font-size: 0.95rem;
  font-weight: 600;
  color: #fff;
}

.collection-card-description {
  margin: 0 12px 12px;
  font-size: 0.78rem;
  color: rgba(255, 255, 255, 0.5);
}

.catalog-back-btn {
  appearance: none;
  -webkit-appearance: none;
  display: block;
  background: none;
  border: none;
  padding: 0;
  margin-bottom: 24px;
  color: rgba(255, 255, 255, 0.7);
  font-size: 0.85rem;
  font-weight: 600;
  text-decoration: underline;
  cursor: pointer;
}

.catalog-back-btn:hover {
  color: #fff;
}

.catalog-row {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: 12px;
}

.catalog-thumb {
  position: relative;
  aspect-ratio: 1;
  border-radius: 12px;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.08);
}

.catalog-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  cursor: pointer;
}

/* Uploads-only: the one action that isn't "tap the image to preview it"
   (see openPhotoPreview in app.js), so it stays a small standalone button
   in the corner rather than living inside the preview popup. */
.catalog-thumb-delete {
  position: absolute;
  top: 6px;
  right: 6px;
}

.thumb-action {
  appearance: none;
  -webkit-appearance: none;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.9);
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.thumb-action:hover {
  background: #fff;
}

/* .catalog-thumb above gives this its size/shape (aspect-ratio, radius,
   overflow) -- these override its plain-fill look with a dashed "empty
   dropzone" treatment instead. Always the row's first tile (see
   index.html), a <label> wrapping its own hidden file input (accept
   multiple) so clicking it opens the file picker with no JS wiring
   needed for that part. */
.catalog-add-thumb {
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2px dashed rgba(255, 255, 255, 0.25);
  background: rgba(255, 255, 255, 0.03);
  cursor: pointer;
  transition: border-color 0.2s ease, background-color 0.2s ease;
}

.catalog-add-thumb:hover {
  border-color: rgba(255, 255, 255, 0.6);
  background: rgba(255, 255, 255, 0.06);
}

.catalog-add-thumb svg {
  opacity: 0.8;
}

/* --- Photo preview popup ------------------------------------------------ */
/* Tap-to-open replacement for the old always-visible small Fractal/
   Visualize buttons on each thumb -- see openPhotoPreview and the
   rewritten renderCuratedThumb/renderUploadThumb in app.js. One shared
   overlay reused for every thumb; z-index sits above .sticky-nav's own
   500 but below the fractal engine's own 3100, since the two are never
   open at the same time regardless. */
.photo-preview-overlay {
  position: fixed;
  inset: 0;
  z-index: 2000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(0, 0, 0, 0.85);
}

/* display:flex above otherwise beats the browser's own default
   [hidden]{display:none} UA rule at equal specificity -- re-assert it
   explicitly, same fix .hero-cta[hidden]/.start-panel-stop-btn[hidden]
   above use for the same reason. */
.photo-preview-overlay[hidden] {
  display: none;
}

.photo-preview-frame {
  position: relative;
  /* Bounded by vh too, not just vw -- being square, an image sized off
     width alone would overflow the top/bottom edge on any viewport
     short enough that 90vw exceeds 100vh (a wide/short window, not just
     narrow/tall mobile). */
  width: min(90vw, 90vh, 480px);
  aspect-ratio: 1;
  border-radius: 16px;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.06);
}

.photo-preview-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Each half is its own full-width tap target -- 60%-black over the
   image per the spec, brightening slightly on hover as the only
   affordance that it's tappable (matches .thumb-action's own hover
   treatment elsewhere in this file). */
.photo-preview-action {
  appearance: none;
  -webkit-appearance: none;
  position: absolute;
  left: 0;
  right: 0;
  height: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: none;
  background: rgba(0, 0, 0, 0.6);
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.photo-preview-action:hover {
  background: rgba(0, 0, 0, 0.72);
}

.photo-preview-action-top {
  top: 0;
  /* Marks the split between the two tap zones -- lives on the top
     half's own bottom edge (exactly the frame's midline, since both
     halves are each exactly 50% tall) rather than a separate element,
     so it never needs its own positioning. */
  border-bottom: 1px solid rgba(255, 255, 255, 0.6);
}

.photo-preview-action-bottom {
  bottom: 0;
}

/* Overrides FRACTAL_ICON/VISUALIZE_ICON's own inline fill="#111" --
   app.js injects those strings as-is (see index.html's own comment) so
   their path data stays in one place; a presentation attribute like
   that carries no real specificity and loses to this rule. */
.photo-preview-action svg {
  width: 48px;
  height: 48px;
  fill: #fff;
}

.photo-preview-action-label {
  color: #fff;
  font-size: 0.85rem;
  font-weight: 600;
  letter-spacing: 0.02em;
}

/* Sits after the two action buttons in the DOM (see index.html) so it
   paints above their full-half tap targets despite all three sharing
   the same z-index:auto stacking context. */
.photo-preview-close {
  appearance: none;
  -webkit-appearance: none;
  position: absolute;
  top: 10px;
  right: 10px;
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  font-size: 26px;
  line-height: 1;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.photo-preview-close:hover {
  background: rgba(0, 0, 0, 0.8);
}

/* --- "Start fractalizing" panel ----------------------------------------- */
/* An inline disclosure, not a popup -- toggled open by .hero-cta, sits
   right above .uploads-catalog in the page's own flow (see index.html).
   No width override of its own, same as .uploads-catalog/.catalog
   below -- a plain child of main, full width. The same faint-fill card
   treatment an earlier always-visible live-audio checkbox card used,
   just because it reads well here too, not because this is layered
   above anything (no position/z-index needed either). */
.start-panel {
  /* Flat 16px both sides now (top was 4.8px, bottom was still the
     original 32px -- never actually touched by the "reduce to 16px"
     request two rounds ago, which only meant to hit the top). Matches
     [data-live-audio-confirm]'s own margin below, so the gap from ➀
     down to whichever of the two (panel, then later that button) is
     showing, and the gap from there down to ➁, all land on the same
     16px regardless of which stage of the flow it is. */
  margin: 16px 0;
  padding: 20px 24px;
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid rgba(255, 255, 255, 0.08);
}

.start-panel-live-audio-btn,
.start-panel-skip-btn {
  display: block;
  width: 100%;
  text-align: center;
}

.start-panel-live-audio-btn {
  margin-bottom: 16px;
}

/* Green/blue/yellow sampled directly from .hero-banner's own footage
   (a canvas-sampled average of each hue's most saturated pixels across
   several frames, the blue then lightened to match the other two's own
   lightness -- the real sampled blue was much darker, which would have
   fought #111 text's own contrast partway through the cycle). Slides
   through a 400%-oversized copy of itself rather than animating the
   gradient's own colors directly (not something CSS can do) --
   background-position 0%->100%->0% on that oversized layer reads as
   continuous, directionless drift, and returns to its own exact
   starting value every loop, so `infinite` restarts with no visible
   jump. */
@keyframes positive-state-animated {
  0% {
    background-position: 0% 50%;
  }
  50% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0% 50%;
  }
}

/* Set once live audio is actually granted (see app.js's
   liveaudiostatechange listener, which only ever fires this once a
   real grant lands, never just on click) -- a clear "already on"
   signal replacing the button's own default white pill look, now
   animated (see positive-state-animated above) rather than a flat
   fill -- part of making the page read as more alive while a live
   session is actually running. Text stays #111, the same weight the
   white default already relies on for contrast -- every stop in the
   gradient is light enough to keep that working throughout the loop,
   not just at rest. border-color: transparent instead of matching the
   fill (there's no one fill color to match anymore) -- same net
   effect, an invisible border seam. */
.start-panel-live-audio-btn.is-active {
  background: linear-gradient(-45deg, #b0d1ca, #9dced2, #e0e3c2, #b0d1ca);
  background-size: 400% 400%;
  animation: positive-state-animated 8s ease infinite;
  border-color: transparent;
  /* Same list .hero-cta/.action-btn already declare, plus filter --
     :hover below only adds filter, but transition is a shorthand (the
     more specific rule here would otherwise replace the base classes'
     own list outright, not merge into it), so the other two need
     repeating here to keep working. */
  transition: background-color 0.2s ease, transform 0.1s ease, filter 0.2s ease;
}

/* A flat #d1cc99 used to replace the gradient outright on hover here --
   swapped a still-image in for a moving one, so the animation visibly
   froze for as long as the cursor stayed put. brightness() instead
   darkens the ALREADY-ANIMATING gradient in place (a post-process over
   the whole rendered element, gradient included) -- it keeps drifting
   under the cursor, just dimmed, rather than pausing. */
.start-panel-live-audio-btn.is-active:hover {
  filter: brightness(0.85);
}

/* Icon + "Stop Live Audio" side by side, centered -- .action-btn itself
   has no display/gap of its own to handle that (its other use,
   .start-panel-skip-btn, is text-only). Full width and bottom margin
   match its two siblings in this panel (.start-panel-live-audio-btn/
   .start-panel-skip-btn), even though it isn't one of the selectors
   those set that on -- kept as its own rule since this button also
   needs the flex/gap those two never do. */
.start-panel-stop-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  margin: 0 0 16px;
}

/* display:flex above otherwise beats the browser's own default
   [hidden]{display:none} UA rule at equal specificity -- re-assert it
   explicitly, same fix .hero-cta[hidden] below uses for the same
   reason. */
.start-panel-stop-btn[hidden] {
  display: none;
}

/* The two instruction lines sit between the two buttons rather than
   using .live-audio-help-step's own trailing margin (meant for a
   checkbox row directly after, not another full-width button several
   elements later). */
.start-panel .live-audio-help-step {
  margin: 0 0 8px;
}

.start-panel .live-audio-help-step:last-of-type {
  margin-bottom: 20px;
}

.start-panel-skip-btn {
  margin-top: 20px;
}

/* Added once "Start Live Audio Input" is actually clicked (see app.js)
   -- .action-btn/.action-btn-secondary's own pill styling no longer
   fits once [data-pick-image] appears as the panel's real next step;
   this keeps the same button (same text, same click behavior) but
   reads as a lesser, skip-this-step option instead of an equal-weight
   choice. Higher specificity than .action-btn-secondary above wins
   regardless of source order, so no !important needed. */
.start-panel-skip-btn.is-link {
  appearance: none;
  -webkit-appearance: none;
  background: none;
  border: none;
  padding: 0;
  color: rgba(255, 255, 255, 0.7);
  font-size: 0.85rem;
  font-weight: 600;
  text-decoration: underline;
}

.start-panel-skip-btn.is-link:hover {
  background: none;
  color: #fff;
}

/* Shown in .start-panel's own place once PICK YOUR IMAGE is actually
   clicked (see app.js's closeStartPanel, which hides the panel
   entirely at that point) -- a duplicate of the panel's own live-audio
   button, standalone rather than inside that panel's card. Full width
   (unlike every other auto-width .hero-cta) so the waveform canvas
   below has real room to read. Margin is 1.6px's own value (already
   an 80% cut from the original 8px) cut by 80% again (0.32px) -- was
   already the minimal gap keeping ➀/➁ from ending up far apart with
   this in between; this pulls them closer still. position/overflow
   here (not on .hero-cta generally) are for the waveform canvas
   below: relative gives it something to anchor against, hidden clips
   it to this button's own pill shape instead of letting it show
   square corners underneath. */
[data-live-audio-confirm] {
  position: relative;
  overflow: hidden;
  width: 100%;
  /* Set to a flat 16px (was 0.32px) on both sides -- matches
     .start-panel's own top margin above (this button takes that
     panel's place once PICK YOUR IMAGE is clicked) and gives ➁'s own
     line below the same 16px breathing room. */
  margin: 16px 0;
}

/* Same "already on" look .start-panel-live-audio-btn.is-active above
   uses (including the positive-state-animated gradient itself),
   duplicated here rather than shared -- this button isn't a
   .start-panel-live-audio-btn, so that selector doesn't reach it.
   background-attachment: fixed (not needed on that other button, which
   has no overlapping child of its own to line up with) is what makes
   this one and its own label below -- see that rule's comment --
   sample the exact same gradient layer, positioned against the
   viewport instead of each element's own, differently-sized box, so
   the label's patch reads as a true continuation of what's under it
   instead of a visibly separate rectangle drifting out of step. */
[data-live-audio-confirm].is-active {
  background: linear-gradient(-45deg, #b0d1ca, #9dced2, #e0e3c2, #b0d1ca);
  background-size: 400% 400%;
  background-attachment: fixed;
  animation: positive-state-animated 8s ease infinite;
  border-color: transparent;
  /* Same reasoning as .start-panel-live-audio-btn.is-active's own
     comment -- repeats the base classes' own transition list (a
     shorthand, so the more specific rule here would otherwise replace
     it outright) plus filter, for the smooth hover darken below. */
  transition: background-color 0.2s ease, transform 0.1s ease, filter 0.2s ease;
}

/* Same reasoning as .start-panel-live-audio-btn.is-active:hover's own
   comment -- brightness() darkens the already-animating gradient in
   place instead of swapping a static color in over it, so it keeps
   drifting under the cursor rather than freezing. A single post-
   process filter over the whole rendered element, so it reaches the
   waveform canvas and the label's own patch underneath too (both keep
   their own separate filter/background unchanged -- this just darkens
   the finished, composited result) -- no separate hover rule needed
   for the label anymore the way its own background needed one before,
   just to stay matched to this same darkening. */
[data-live-audio-confirm].is-active:hover {
  filter: brightness(0.85);
}

/* The live waveform meter, drawn directly onto this button (see its
   own [data-audio-waveform] hook and comment in index.html) rather
   than sitting in a card above/below it. Absolutely positioned to
   cover the button's full padding box -- position:absolute here lands
   it above the button's own in-flow text in paint order regardless of
   DOM order (a positioned box with z-index:auto always paints above
   in-flow content within the same stacking context), so the label
   below still needs its own z-index to reliably win back the top
   layer. Faded down so the trace reads as texture behind the text,
   not competing with it; pointer-events:none so it never intercepts
   the button's own click. filter:invert(1) turns the shared
   drawWaveforms loop's own hardcoded white stroke black, to match
   this button's own #111 text -- a pure CSS fix scoped to just this
   canvas, rather than touching fractalize-core.js's shared drawing
   code (which the audio-device panel's own meter elsewhere on the
   page still relies on staying white). invert only affects RGB,
   leaving the canvas's own transparent background (alpha) untouched.
   The sticky banner's canvas below reuses this same trick, for the
   same reason. */
[data-live-audio-confirm] .fractal-audio-waveform {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0.4;
  filter: invert(1);
  pointer-events: none;
}

/* Wins back the top layer over the waveform canvas above -- a
   positioned descendant with z-index:auto/0 always paints above
   in-flow content in the same stacking context, so without this the
   canvas (itself positioned) would cover the text even though it
   comes first in the DOM. */
[data-live-audio-confirm-label] {
  position: relative;
  z-index: 1;
}

/* Only in the active ("Live Audio In Use") state -- the inactive
   label ("Use Live Audio Input") sits on a white, waveform-free
   button, so it has nothing to block. A solid positive-state
   background here (matching the button's own .is-active fill) paints
   over the waveform trace directly behind the text's own line box,
   so the moving line never cuts through the letters themselves --
   everywhere else on the button the trace still shows through. */
/* Same positive-state-animated gradient the button itself uses (see
   its own comment above), including background-attachment: fixed --
   with matching background-size and the same animation running in
   phase (same class toggle, same moment, same keyframes/duration),
   that makes this patch sample the exact same gradient layer, at the
   exact same viewport-relative position, as the button underneath it
   -- a true continuation of it rather than a visibly separate
   rectangle drifting out of step. */
[data-live-audio-confirm].is-active [data-live-audio-confirm-label] {
  background: linear-gradient(-45deg, #b0d1ca, #9dced2, #e0e3c2, #b0d1ca);
  background-size: 400% 400%;
  background-attachment: fixed;
  animation: positive-state-animated 8s ease infinite;
}

/* --- Gallery page (BROWSE GALLERY's own destination) ------------------ */
/* A plain showcase, deliberately simpler than .catalog above (no
   thumb-actions, no fractalize/visualize wiring) -- each entry is just
   its own title followed by its image, stacked. Same 960px column as
   main. */
.fractal-gallery {
  max-width: 960px;
  margin: 0 auto 80px;
  /* Real padding, not just scroll-margin-top (compare
     .start-fractalizing-header/[data-uploads-heading]'s own, which get
     away with scroll-margin-top alone) -- .app-home is hidden by the
     time this is on screen (see app.js's showPage), so this section is
     the very first thing in document flow with nothing scrollable
     above it. scroll-margin-top can't create clearance the page has no
     room to scroll into; only real padding-top can push this content
     down clear of .sticky-nav (~57px tall, always visible on this page
     now) regardless of scroll position. Transitions alongside the
     banner's own 0.25s fade below so a mid-visit state change (a
     device disconnecting, say) doesn't make the title jump instantly. */
  padding-top: 72px;
  transition: padding-top 0.25s ease;
}

/* .has-audio-banner (toggled by app.js's updateStickyAudioBanner
   alongside .sticky-audio-banner's own is-visible, see index.html's
   comment on that element) means the banner is riding directly below
   .sticky-nav right now -- its own 24px extends the chrome height
   .fractal-gallery's base padding-top above already clears, so this
   page's title would otherwise sit half-hidden underneath it. Scoped
   to body, not this class alone, so a future page with the same
   "first thing in document flow" padding-top problem (see that rule's
   own comment) only needs to add its own selector here, not repeat
   this whole mechanism. */
body.has-audio-banner .fractal-gallery {
  padding-top: 96px;
}

.fractal-gallery-title {
  text-align: left;
  font-size: 2rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #fff;
  margin: 0 0 32px;
}

.fractal-gallery-list {
  display: flex;
  flex-direction: column;
  gap: 40px;
}

.fractal-gallery-item {
  margin: 0;
}

.fractal-gallery-item-title {
  text-align: left;
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.85);
  margin: 0 0 12px;
}

.fractal-gallery-item-img {
  display: block;
  width: 100%;
  /* 25% max-width on desktop, up to 80% on mobile (see the 600px
     override below) -- these are samples to admire, not full-bleed
     hero art, so they stay modestly sized even at their cap. */
  max-width: 25%;
  height: auto;
  border-radius: 12px;
  cursor: pointer;
  transition: opacity 0.2s ease;
}

.fractal-gallery-item-img:hover {
  opacity: 0.9;
}

@media (max-width: 600px) {
  .fractal-gallery-item-img {
    max-width: 80%;
  }
}

/* --- Gallery lightbox --------------------------------------------------- */
/* Full-size view for a tapped gallery image -- no fractalize/visualize
   actions (unlike .photo-preview-overlay above), since these ARE the
   finished art already. Same fixed/centered/backdrop-click-to-close
   shell as that overlay, object-fit: contain instead of cover since
   there's no fixed frame to crop into here. */
.gallery-lightbox {
  position: fixed;
  inset: 0;
  z-index: 2000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(0, 0, 0, 0.85);
}

.gallery-lightbox[hidden] {
  display: none;
}

.gallery-lightbox-img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  border-radius: 8px;
}

.gallery-lightbox-close {
  appearance: none;
  -webkit-appearance: none;
  position: absolute;
  top: 20px;
  right: 20px;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 50%;
  background: rgba(20, 20, 20, 0.78);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  color: #fff;
  font-size: 1.6rem;
  line-height: 1;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.gallery-lightbox-close:hover {
  background: rgba(20, 20, 20, 0.92);
}

/* --- Footer ----------------------------------------------------------- */

.site-footer {
  max-width: 720px;
  margin: auto auto 0;
  width: 100%;
  box-sizing: border-box;
  padding: 24px 0 64px;
  text-align: center;
  font-size: 0.8rem;
  color: rgba(255, 255, 255, 0.4);
}

@media (max-width: 600px) {
  .hero {
    /* Top halved too (was 64px), same as the base rule above -- keeps
       this smaller than the desktop value, as it was before. */
    padding: 32px 0;
  }
}
