/* everything hums — design language in homage to play_music_theory by tala:
   one mono typeface, warm neutrals, hairlines instead of shadows, circles
   and stadiums, procedural paper grain. Palette is ink on paper; color comes
   from the photo itself and the loader's spectrum. --accent survives only for
   the dev-gated ?song panel. */

:root {
  color-scheme: light;
  --paper: #faf9f7;
  --ink: #1a1916;
  --muted: #8a877e;
  --hair: #cfcabd;
  --raised: #fffefb;
  --accent: #178a58;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font: 400 15px/1.5 var(--mono);
  min-height: 100dvh;
  padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

/* paper grain: two dot fields at incommensurate tile periods read as random */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: 0.9;
  background-image:
    radial-gradient(circle, rgba(107, 94, 72, 0.06) 0.6px, transparent 0.9px),
    radial-gradient(circle, rgba(255, 255, 255, 0.5) 0.5px, transparent 0.8px);
  background-size: 7px 9px, 11px 13px;
  background-position: 0 0, 3px 5px;
}

main {
  position: relative;
  z-index: 1;
  width: min(560px, 100%);
  margin: 0 auto;
  padding: 0 16px;
}

/* The fold, made structural. It owns exactly one viewport, so the footer that
   follows it is below the fold BY CONSTRUCTION — replacing tidyFold(), which
   measured the footer after load and nudged it, wrote layout from a
   ResizeObserver, and was the prime suspect in a crash. Nothing moves after
   load now. Vertical centring is what gives the page its air: the old layout
   squeezed everything up to keep the footer visible, which is why the title
   sat against the browser chrome. */
.fold {
  min-height: 100dvh;
  padding-block: 18px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  justify-content: safe center; /* don't clip the top when content overflows */
  gap: 13px;
}

/* wordmark — plain lowercase mono; the stretched m's are texture, mark only */
header { text-align: center; }
.wordmark {
  margin: 0;
  font: 400 16px/1.4 var(--mono);
  letter-spacing: 0.02em;
}
/* the name trails off into its own hum: m's thinning into s's */
.wordmark i { font-style: normal; }
.wordmark i:nth-of-type(1) { opacity: 0.72; }
.wordmark i:nth-of-type(2) { opacity: 0.52; }
.wordmark i:nth-of-type(3) { opacity: 0.38; }
.wordmark i:nth-of-type(4) { opacity: 0.27; }
.wordmark i:nth-of-type(5) { opacity: 0.2; }
.wordmark i:nth-of-type(6) { opacity: 0.14; }
.wordmark i:nth-of-type(7) { opacity: 0.1; }
.wordmark i:nth-of-type(8) { opacity: 0.06; }
.desc {
  margin: 2px 0 0;
  font-size: 13px;
  color: var(--muted);
}

/* the stage — the photo is the interface */
.stage-wrap {
  position: relative;
  aspect-ratio: var(--ar);
  /* height-first (her canvas pattern): the paper gives up size so the controls
     never fall below the fold. ~330px = header + status + ONE note + two
     control rows + "how it works" + the gaps between them. The footer is no
     longer in this sum — it lives below the fold now — and that reclaimed space
     went into breathing room rather than a bigger stage.
     Since v3.55 a second and third note can be true at once (a limited browser,
     a photo carrying EXIF, a song playing) and the budget counted one, which
     pushed "how it works" under the fold at 620px. The extra lines come out of
     the stage — same trade as everything else here — and only while they are
     actually showing: --notes is 0 in the common case. */
  width: min(100%, calc((100dvh - 330px - var(--notes, 0px)) * var(--ar)));
  margin-block: 4px; /* breathing room from the header text */
  border-radius: 10px;
  overflow: hidden;
  background: var(--raised);
  border: 1px solid var(--hair);
  cursor: pointer;
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
}
/* two states only (Edwin, v3.10): mobile-style centered column below 900px,
   desktop rail above — the old 760–899 "tablet" middle state is gone */

/* desktop workstation: options left, the photo owns the right half */
@media (min-width: 900px) {
  main {
    width: 100%;
    height: 100dvh;
    padding: 26px 30px;
    display: grid;
    display: grid;
    grid-template-columns: 320px minmax(0, 1fr);
    grid-template-rows: auto auto auto minmax(0, 1fr) auto auto;
    grid-template-areas:
      "header stage"
      "status stage"
      "note stage"
      "controls stage"
      "howto  stage"
      "footer stage";
    column-gap: 34px;
    row-gap: 18px;
    align-items: start;
  }
  .fold { display: contents; }  /* the rail grid owns these children directly */
  .howto-row { grid-area: howto; justify-self: start; }
  header { grid-area: header; text-align: left; }
  .wordmark { font-size: 18px; }
  .status { grid-area: status; text-align: left; min-height: 0; }
  main .notes { grid-area: note; }
  /* The `main` prefix is the same late-base-rule dodge as before, and it has to
     land on the NOTES, not the box: `.envnote { text-align: center }` is a base
     rule on the child, so setting the wrapper alone just gets overridden back. */
  main .notes .envnote { text-align: left; }
  /* `main` raises specificity over the base .controls rule, which appears
     LATER in the file and was silently winning (centred, gapped, so the
     ruled groups never rendered as designed) */
  main .controls {
    grid-area: controls;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    align-self: center; /* rail reads header → controls → footer, balanced */
  }
  .controls .sect {
    display: flex;
    flex-wrap: wrap;
    gap: 7px;
    align-items: center;
    justify-content: center;
    padding: 13px 0;
    border-top: 1px solid var(--hair);
  }
  .controls .sect:first-child { border-top: 0; padding-top: 0; }
  .stage-wrap { margin-block: 0; }
  .round.play { width: 44px; height: 44px; }
  input[type="range"] { width: 100%; max-width: 220px; }
  /* specificity beats the later base `footer { text-align: center }` */
  main > footer { grid-area: footer; align-self: end; text-align: left; }
  .stage-wrap {
    grid-area: stage;
    justify-self: center;
    align-self: center;
    width: min(100%, calc((100dvh - 52px) * var(--ar)));
    max-height: calc(100dvh - 52px);
  }
}
.stage-wrap:not(.has-photo) { border-style: dashed; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

.hint {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  color: var(--muted);
  font-size: 13px;
  pointer-events: none;
}
.hint svg { color: var(--ink); opacity: 0.75; } /* the one squiggle, in ink — same curve as the loader */
/* The whole stage opens the file picker, which makes this chip a trap door
   inside the target. Measured at 390px: it sat 12px under "tap to choose a
   photo", dead centre, 36px tall — and the stage's optical centre was 16px
   above its top edge. Aiming at the middle and getting a stranger's meme is
   not user error, and that is what Edwin's friend reported as "changes into
   random pic". So the centre belongs to the one message, and the example lives
   at the bottom where reaching for it is a decision. 44px tall because that is
   what a touch target is. */
/* Top-right, alongside where "new photo" and "random" appear once a photo is
   loaded — the corner that already means "give me a different picture". v3.46
   moved this off the stage's centre because the whole stage opens the picker
   and a chip in the middle caught taps meant for it; the corner is better
   still, because it leaves the centre entirely to the one message and puts the
   example where the other photo choices live. Matches .float-stack's inset so
   the two read as the same slot. */
.hint #example {
  pointer-events: auto;
  position: absolute;
  top: 10px;
  right: 10px;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
}
.hint[hidden] { display: none; }


/* processing loader: the app's own gesture — a squiggle, a traveling dot */
.loader {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  background: rgba(255, 254, 251, 0.85); /* old image ghosts through, doesn't compete */
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  pointer-events: none;
}
.loader[hidden] { display: none; }
.loader svg { display: block; }
.loadDot {
  position: absolute;
  top: 0; left: 0;
  width: 9px; height: 9px;
  transform: translate(1.5px, 21.5px);
  animation: hum-travel 1.4s linear infinite;
}
/* five stacked color layers crossfading by OPACITY — like transform it stays
   compositor-driven while the tracer blocks the main thread, and unlike
   filter:hue-rotate (linear-RGB matrix — rotated red never reaches vivid
   blue/purple; read as red/green only) the hues stay true. */
.loadDot i {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  opacity: 0;
  animation: dot-fade 1.4s linear infinite;
}
.loadDot i:nth-child(1) { background: #e5372d; animation-delay: 0s; }
.loadDot i:nth-child(2) { background: #efa32b; animation-delay: -0.28s; }
.loadDot i:nth-child(3) { background: #1d9e75; animation-delay: -0.56s; }
.loadDot i:nth-child(4) { background: #3e5ec6; animation-delay: -0.84s; }
.loadDot i:nth-child(5) { background: #a24bcf; animation-delay: -1.12s; }
@keyframes dot-fade {
  0% { opacity: 1; }
  20% { opacity: 0; }
  80% { opacity: 0; }
  100% { opacity: 1; }
}
.loader svg, .loadDot { grid-area: 1 / 1; position: relative; }
/* transform-only travel stays compositor-driven (offset-distance froze on
   Safari; background keyframes froze at red). Waypoints are arc-length
   samples of #loadPath. */
@keyframes hum-travel {
  0% { transform: translate(1.5px, 21.5px); }
  6.25% { transform: translate(7.5px, 15.7px); }
  12.5% { transform: translate(15px, 12.2px); }
  18.75% { transform: translate(22.5px, 13px); }
  25% { transform: translate(29.3px, 17.3px); }
  31.25% { transform: translate(35.3px, 23px); }
  37.5% { transform: translate(42.8px, 26.7px); }
  43.75% { transform: translate(50.3px, 26.2px); }
  50% { transform: translate(57.8px, 21.5px); }
  56.25% { transform: translate(63px, 15.8px); }
  62.5% { transform: translate(69px, 10.5px); }
  68.75% { transform: translate(76.5px, 7.5px); }
  75% { transform: translate(84.8px, 8.8px); }
  81.25% { transform: translate(91.5px, 13.5px); }
  87.5% { transform: translate(97.6px, 18.5px); }
  93.75% { transform: translate(104.8px, 21px); }
  100% { transform: translate(109.5px, 15.5px); }
}
@media (prefers-reduced-motion: reduce) {
  .loadDot, .loadDot i { animation-duration: 2.8s; }
  /* keep the crossfade phases spread across the slower period */
  .loadDot i:nth-child(2) { animation-delay: -0.56s; }
  .loadDot i:nth-child(3) { animation-delay: -1.12s; }
  .loadDot i:nth-child(4) { animation-delay: -1.68s; }
  .loadDot i:nth-child(5) { animation-delay: -2.24s; }
}

/* drag and drop (2026-10-15): while a file hovers anywhere over the page, the
   stage says where it will land. The loader's paper wash, so a hum already
   there ghosts through rather than vanishing; the dashed edge the empty stage
   wears, in ink; the app's one voice for the words. It never takes a pointer —
   the drop belongs to the whole page — and is aria-hidden: the picker stays
   the accessible door, and nothing here can take focus. Above the stage's
   chips (.float-stack sits in the same corner it would cover). */
.drop-overlay {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: grid;
  place-items: center;
  background: rgba(255, 254, 251, 0.85);
  border: 1.5px dashed var(--ink);
  border-radius: inherit;
  color: var(--ink);
  font: 400 13px/1.4 var(--mono);
  pointer-events: none;
}
.drop-overlay[hidden] { display: none; }

.status {
  margin: 0;
  min-height: 20px;
  font-size: 13px;
  color: var(--muted);
  text-align: center;
  overflow-wrap: anywhere;
}

/* one slim control row */
.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 8px 7px;
  max-width: 100%;
  min-width: 0;
}
#lensOpts { display: contents; }
/* mobile: sections dissolve into one wrapping row (2-3 lines, full width) */
/* phone: sections dissolve so the controls flow as one wrapping row.
   Scoped to <900px — unscoped it also beat the desktop rule below (same
   specificity, later in the file), which is why the desktop rail rendered
   one control per row instead of its intended ruled groups. */
@media (max-width: 899px) {
  .controls .sect { display: contents; }
}

.chip, .round {
  appearance: none;
  -webkit-appearance: none;
  background: var(--raised);
  color: var(--ink);
  border: 1px solid var(--hair);
  font: 400 13px var(--mono);
  cursor: pointer;
  transition: transform 0.1s ease;
}
.chip {
  border-radius: 20px;
  padding: 0 14px;
  height: 36px;
  text-align: center;
  /* WebKit ignores `text-align` on <select>, and the direction/scale controls
     ARE selects — so their labels sat left of centre on iOS while every other
     chip looked fine. text-align-last is the property that moves the value. */
  text-align-last: center;
}
.round {
  border-radius: 50%;
  width: 36px;
  height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}
.chip:active, .round:active { transform: scale(0.94); }
.chip:focus-visible, .round:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}
@media (hover: hover) {
  .chip:not(:disabled):hover, .round:not(:disabled):hover { border-color: rgba(26, 25, 22, 0.35); }
}
@media (prefers-reduced-motion: reduce) {
  .chip, .round { transition: none; }
  .chip:active, .round:active { transform: none; }
}
.chip:disabled, .round:disabled { opacity: 0.3; cursor: default; transform: none; }
.round[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }

.chip.save {
  display: inline-flex;
  align-items: center;
  gap: 7px;
}
.chip.save .dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--ink); /* readiness dot — the brand is ink + the loader's rainbow, no accent */
}
#backdrop[hidden] { display: none; } /* .round's display beats the hidden attr */
/* random background (2026-10-06) — the laptop only. Measured in the line view
   at 390px: the phone's second control row has 18px to spare, and this is a
   30px button plus a 4px gap, so it would push `save` onto a third row (the
   budget the browser suite holds at two). Edwin: "it's ok if the moving
   background only available for laptop not mobile". */
#randomBg[hidden] { display: none; }
@media (max-width: 899px) { #randomBg { display: none; } }
/* On the rail it joins the paper row, which then runs past the rail's 320px
   and puts `save` on a line of its own: 23px more rail (the controls are
   centred, so half of 47). Measured 2026-10-06, that alone made the controls
   overlap "how it works" in viewports 650–690px tall — a 1366×768 laptop's
   browser — where they had just fitted. Short rails give the sections less
   air instead: 4px each from 720px down, 7px from 660px. 680px fits again;
   640px overlaps by 4px, where before this button it overlapped by 5. */
@media (min-width: 900px) and (max-height: 720px) {
  .controls .sect { padding-block: 9px; }
}
@media (min-width: 900px) and (max-height: 660px) {
  .controls .sect { padding-block: 6px; }
}

/* save menu: a real popover, not a <select> — iOS renders select options in a
   native sheet that ignores `hidden`, so the label leaked into the list */
.menu { position: relative; display: inline-flex; }
.menu-list {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  min-width: 128px;
  padding: 5px;
  gap: 2px;
  background: var(--raised);
  border: 1px solid var(--hair);
  border-radius: 12px;
  box-shadow: 0 6px 20px rgba(26, 25, 22, 0.1);
  z-index: 5;
}
.menu-list[hidden] { display: none; }
.menu-list button {
  appearance: none; -webkit-appearance: none;
  background: none; border: 0; border-radius: 8px;
  color: var(--ink); font: 400 13px var(--mono);
  padding: 9px 12px; text-align: left; cursor: pointer;
}
.menu-list button:hover, .menu-list button:focus-visible { background: color-mix(in srgb, var(--hair) 34%, transparent); }
.menu-list button[hidden] { display: none; }
/* Not the nine: the button says so without being opened. Styled off the world
   rather than aria-pressed, which would contradict aria-haspopup="dialog". An
   unset world (no hum yet) stays plain. */
#songBtn[data-world="photo"],
#songBtn[data-world="song"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }

/* Which instruments play, in words, at the top of the chooser. This is the
   whole instruments control since v3.56 — the circle of dots is gone — so it
   has room to name the options instead of hinting at them. */
.songworlds { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; }
.songworlds button {
  display: flex; align-items: center; gap: 9px;
  appearance: none; -webkit-appearance: none;
  width: 100%; padding: 9px 11px; text-align: left;
  background: none; border: 1px solid transparent; border-radius: 9px;
  color: var(--ink); font: 400 14px/1.3 var(--mono); cursor: pointer;
}
/* the radio dot belongs to the CHOICE, not to the play control beside it */
.songworlds button[role="radio"]::before {
  content: ''; flex: none;
  width: 13px; height: 13px; border-radius: 50%;
  border: 1.5px solid var(--hair);
  box-shadow: inset 0 0 0 3px var(--raised);
  background: transparent;
}
.songworlds button[aria-checked="true"] { border-color: var(--hair); background: var(--raised); }
.songworlds button[aria-checked="true"]::before { background: var(--ink); border-color: var(--ink); }
.songworlds button:disabled { color: var(--muted); cursor: default; }
.songworlds button:not(:disabled):hover,
.songworlds button:focus-visible { background: var(--raised); outline: none; }
/* A song title is somebody else's text: one line, clipped, never a paragraph.
   (min-width is belt and braces — overflow:hidden already zeroes a flex item's
   automatic minimum size.) */
.songworlds .voicename { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The cover, so which song is loaded reads before the words do. Same rounded
   square as the search results' 38px, scaled to each row it sits in. */
/* Each world is a choice plus an audition, side by side. `.songworlds button`
   above is (0,1,1) and sets width:100% — the same trap that squeezed a search
   result's title into 16px — so the row has to hand the choice the slack and
   the play control has to out-specify that width. See .songlist li, which
   solves the identical problem for the identical reason. */
.songworlds .worldrow { display: flex; align-items: center; gap: 2px; }
.songworlds .worldrow > button[role="radio"] { flex: 1 1 auto; min-width: 0; }
.songworlds button.songplay {
  flex: none; width: 30px; height: 30px; padding: 0; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 1px solid var(--hair); color: var(--ink);
}
.worldart { width: 26px; height: 26px; border-radius: 4px; flex: none; background: var(--hair); }
/* ...and small enough in the note to ride the 11px line without setting its
   height. vertical-align keeps it on the baseline of a centred <p> on mobile. */
.voiceart {
  width: 16px; height: 16px; border-radius: 3px;
  vertical-align: -3px; margin-right: 5px; background: var(--hair);
}
/* The how and the promise, under the thing they describe rather than in front
   of the choice. Same muted register as the notes in the rail. */
/* The how and the promise belong to somebody deciding whether to search. Once
   results are on screen they are pure obstruction — measured at 96px of fine
   print pinned under a 49px list at keyboard height, two lines of explanation
   for every one line of result. :empty does it with no JS. */
.songfine { display: none; }
.songpick #songList:empty ~ .songfine { display: block; margin: 14px 2px 0; font-size: 11px; line-height: 1.45; color: var(--muted); }
/* photo-derived paper swatches for the line view */
#papers { display: inline-flex; gap: 5px; align-items: center; }
/* Option A: paper colors live in the squiggle view — the only place they do
   anything — so the default photo view keeps its two tidy control rows. */
#papers[hidden], #papers[data-idle] { display: none; }
.paperchip {
  width: 20px; height: 20px; border-radius: 50%;
  border: 1px solid var(--hair); cursor: pointer; padding: 0;
  appearance: none; -webkit-appearance: none;
}
.paperchip[aria-pressed="true"] { border-color: var(--ink); box-shadow: 0 0 0 1.5px var(--ink) inset; }
/* third view: the picture alone. The squiggle glyph fades out — the line it
   draws is exactly what you've turned off, so the icon says it by going quiet. */
#view[data-ink="false"] svg { opacity: 0.28; }
/* the ghost isn't a color, so it doesn't read as one: no fill, just the photo
   glyph — "leave the photo showing" sitting at the head of the paper colors */
.paperchip.ghost {
  background: none center/cover no-repeat; color: var(--muted);
  display: inline-flex; align-items: center; justify-content: center;
}
.paperchip.ghost[aria-pressed="true"] { color: var(--ink); }
/* Five sheets (ghost, white, black, two from the photo) have to share the
   squiggle row with the controls. On a 390px phone the full-size chips spill
   and strand `save` on a row of its own, so the swatches — the smallest, most
   forgiving things in the row — give the width back. */
@media (max-width: 899px) {
  /* v3.34: with the photo-derived tints gone there are three swatches, not
     five, so the width borrowed by the last three additions goes back. */
  #papers { gap: 5px; }
  .paperchip { width: 20px; height: 20px; }
}

/* phones: the download arrow carries "save"; the word appears from the rail up.
   Selectors carry extra specificity (.controls …) on purpose: the base sizing
   rules appear LATER in this file and win ties — the recurring trap. */
.chip .dl { display: none; }
@media (max-width: 899px) {
  .chip .dl { display: inline; }
  .save-wide { display: none; }
  /* two-row budget for ≥375px phones (360px androids wrap to three, fold-safe) */
  /* v3.30: the voiceMode button takes the seat the first beat dot used to
     ride in at the end of row 1. One px of column gap and one px of round
     diameter, across ~18 gaps and ~12 rounds, buys the ~30px that keeps the
     squiggle view at two rows — measured, as ever, not assumed. */
  .controls { gap: 8px 4px; }
  .controls .chip { padding: 0 7px; }
  .controls .round { width: 30px; height: 30px; }
  .controls .chip.save { gap: 5px; }
  .controls .chip.save .dot { display: none; } /* disabled-state already says "not ready" */
  /* the voiceMode button (v3.30) costs one 31px slot; the slider gives most
     of it back — it is the only stretchy thing in the row */
  .controls input[type="range"] { width: clamp(40px, 10.5vw, 52px); }
}

/* Duet spike panel (?song only) — deliberately plain, not launch UI */
.song-panel { display: grid; gap: 8px; margin: 10px auto 0; max-width: 520px; }
.song-panel[hidden], .song-panel [hidden] { display: none; }
.song-row { display: flex; gap: 8px; align-items: center; justify-content: center; flex-wrap: wrap; }
.song-strip { width: 100%; height: 36px; border-radius: 8px; touch-action: none; cursor: grab; }
.song-knobs label { font-size: 12px; display: inline-flex; gap: 6px; align-items: center; }
.song-note { font-size: 11px; opacity: 0.7; }
/* Capturing, not queued. The whole button used to pulse — the same gesture a
   PENDING change makes since v3.43, so the two states were indistinguishable.
   Red with a blinking dot is what everyone already reads as "recording", and
   it leaves the quiet breath to mean only "waiting for the loop". */
#save.recording {
  color: #b3261e;
  border-color: #b3261e;
  animation: none;
  opacity: 1;
}
.rec-dot {
  display: inline-block;
  width: 8px; height: 8px;
  margin-right: 7px;
  border-radius: 50%;
  background: currentColor;
  animation: rec-blink 1s ease-in-out infinite;
}
@keyframes rec-blink { 50% { opacity: 0.2; } }

/* the take is waiting on a tap — same red, same thread of attention */
#status.ready { color: #b3261e; }
@media (prefers-reduced-motion: reduce) {
  .rec-dot { animation: none; }
}

.float-stack {
  position: absolute;
  top: 10px;
  right: 10px;
  display: flex;
  gap: 6px;
}
.float-stack[hidden] { display: none; }
.float-stack .chip {
  height: 32px;
  padding: 0 12px;
  font-size: 12px;
  background: rgba(255, 254, 251, 0.88);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

input[type="file"] { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; }

/* tempo — an unstyled native slider, her deliberate refusal to over-design */
input[type="range"] { width: 96px; accent-color: var(--ink); }
/* control sizing changes ONLY when the rail layout does (two states, no
   in-between: below 900 is exactly the phone state at any width) */
@media (min-width: 900px) { input[type="range"] { width: 130px; } }

/* play — raised while idle, ink while playing (her convention).
   Icon visibility lives here: the hidden attribute doesn't apply to SVG. */
.round.play.playing { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.round.play .ic-pause { display: none; }
.round.play.playing .ic-play { display: none; }
.round.play.playing .ic-pause { display: inline; }

/* the tempo, in words, where there is room for it */
.bpm { display: none; font: 400 11px var(--mono); color: var(--muted); white-space: nowrap; }
@media (min-width: 900px) { .bpm { display: inline; } }

/* rhythm layers — the dot count is the label */
.round.beat { font: 700 11px var(--mono); letter-spacing: 1px; padding-bottom: 2px; }
.round.beat[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }

/* The notes share ONE grid slot on desktop, so they need a box to stack in —
   grid would otherwise lay all three over each other in the same cell. Empty
   (every child hidden) it contributes no height, which is what keeps the
   mobile fold budget where it was. */
.notes { display: flex; flex-direction: column; gap: 4px; }
/* Each note beyond the first is ~19px (11px at 1.4, plus the 4px gap) and the
   stage gives that back. Written as a selector rather than measured in JS on
   purpose: tidyFold wrote layout from a ResizeObserver and its own write came
   back as a resize. A browser without :has() simply keeps the old budget. */
main:has(.notes > *:not([hidden]) ~ *:not([hidden])) { --notes: 20px; }
main:has(.notes > *:not([hidden]) ~ *:not([hidden]) ~ *:not([hidden])) { --notes: 40px; }
main:has(.notes > *:not([hidden]) ~ *:not([hidden]) ~ *:not([hidden]) ~ *:not([hidden])) { --notes: 60px; }

/* honest note when the browser can't run the full tracing stack */
.envnote {
  margin: 0;
  font-size: 11px;
  line-height: 1.4;
  color: var(--muted);
  opacity: 0.85;
  text-align: center;
}
.envnote[hidden] { display: none; }
#envRetry {
  appearance: none; -webkit-appearance: none;
  background: none; border: 0; padding: 0 0 0 6px;
  color: var(--ink); font: inherit; text-decoration: underline; cursor: pointer;
}
#envRetry[hidden] { display: none; }

/* ── random art: the credit line (2026-10-06) ─────────────────────────────
   The example button serves museum masterpieces now, and a masterpiece is
   shown with its name on it. It rides in .notes as an ordinary note, so the
   fold budget (--notes, above) counts it like every other note — which only
   holds while it is ONE line. On a phone the whole credit rarely fits ("The
   Great Wave off Kanagawa — Katsushika Hokusai, c. 1830–32 · The Met · public
   domain" is 84 characters), so the tail is clipped with an ellipsis: the
   title and artist survive, the element's title attribute carries the whole
   line, and the link goes to the museum's own page, which says everything.
   The desktop rail has the height to spare, so there it wraps instead. */
.artcredit { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artcredit a { color: var(--ink); text-underline-offset: 2px; }
@media (min-width: 900px) { .artcredit { white-space: normal; } }
/* …and a tap opens it (showCredit in js/app.js). A CC BY credit must be
   readable in full, and "NASA, ESA, CSA, STScI; J. DePasquale, A. Koekemoer,
   A. Pagan (STScI)" alone is 69 characters, wider than a phone's line. Only
   on that tap, so the fold budget above still holds for every work. */
.artcredit.open { white-space: normal; }
/* nowrap also makes the line's MINIMUM width its whole width, and the phone's
   column (.fold, align-items: center) sizes .notes to fit its content — so
   .notes grew past the screen and was centred on it, cutting the credit AND
   the quality note under it off at both edges ("a Wheeler — … Cleveland
   Muse", screenshot 2026-10-06). Holding .notes to the column's width is what
   lets the ellipsis happen at all. */
@media (max-width: 899px) { .notes { align-self: stretch; } }

/* how it works — a native <dialog>, so it lives in the TOP LAYER and cannot
   overlap anything. The inline version painted over the controls on the
   desktop rail, where `main` is a fixed 100dvh grid and the footer row has
   nowhere to grow; that was structural, not a spacing bug. */
.howto-open {
  appearance: none; -webkit-appearance: none;
  /* no horizontal padding: the box hugs its text, so the underline lines up
     with the footer paragraphs on the left-aligned desktop rail and still
     centres on mobile. The vertical padding keeps the tap target. */
  background: none; border: 0; padding: 5px 0; margin: 0 0 3px;
  font: inherit; color: inherit; cursor: pointer;
  text-decoration: underline; text-underline-offset: 3px;
}
.howto {
  border: 1px solid var(--hair);
  border-radius: 14px;
  background: var(--raised);
  color: var(--ink);
  padding: 0;
  width: min(34rem, calc(100vw - 28px));
  max-height: min(78dvh, 720px);
  overflow: auto;
  overscroll-behavior: contain;
  font: 400 13px/1.6 var(--mono);
}
.howto::backdrop { background: rgba(26, 25, 22, 0.32); backdrop-filter: blur(2px); }
.howto-body { padding: 16px 18px 18px; text-align: left; }
.howto-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.howto-head h2 { margin: 0; font: 400 14px/1.4 var(--mono); letter-spacing: 0.02em; }
.howto-close {
  appearance: none; -webkit-appearance: none;
  background: none; border: 0; padding: 4px 6px; margin: -4px -6px 0 0;
  font: inherit; color: var(--muted); cursor: pointer; line-height: 1;
}
.howto-lead { margin: 0 0 12px; color: var(--muted); }
.howto-note {
  margin: 0 0 12px;
  padding: 10px 12px;
  color: var(--muted);
  background: var(--raised);
  border: 1px solid var(--hair);
  border-radius: 9px;
}
.howto-note strong { color: var(--ink); font-weight: 400; }
.howto dl {
  display: grid;
  grid-template-columns: 20px 1fr;
  gap: 13px 12px;          /* roomier than the inline version was at 390px */
  margin: 0 0 14px;
  align-items: start;
  color: var(--muted);
}
.howto dt { text-align: center; line-height: 1; padding-top: 2px; color: var(--ink); }
.howto dt svg { display: block; margin: 0 auto; }
.howto dd { margin: 0; }
.howto em { font-style: normal; color: var(--ink); }
.howto-more { margin: 0; }
.howto-more a { color: var(--ink); text-underline-offset: 3px; }
@media (max-width: 899px) {
  .howto { width: calc(100vw - 20px); max-height: 82dvh; }
  .howto-body { padding: 15px 16px 18px; }
}
/* "how it works · about" share ONE row, so the fold budget in .stage-wrap still
   counts one line for them, and the link wears the button's underline. On the
   rail the row takes the howto grid slot the button used to hold alone. */
.howto-row { display: flex; align-items: baseline; justify-content: center; gap: 10px; margin: 0; }
.howto-sep { color: var(--muted); }

/* ── about (about.html, served at /about) ─────────────────────────────────
   The long version of "how it works": a reading page in the app's own ink on
   paper. Scoped to main.about, because `main` is the app's layout — a
   fixed-height grid on the rail — and none of that belongs here. Body copy is
   a softened ink rather than --muted: --muted on paper is 3.4:1, fine for the
   app's one-line notes, too faint for paragraphs. */
main.about {
  display: block;
  width: min(42rem, 100%);
  height: auto;
  margin: 0 auto;
  padding: 26px 16px 44px;
  font-size: 14px;
  line-height: 1.7;
  color: color-mix(in srgb, var(--ink) 76%, var(--paper));
}
@media (min-width: 900px) { main.about { padding: 54px 30px 72px; } }
.about p { margin: 0 0 12px; }
.about a { color: var(--ink); text-decoration-thickness: 1px; text-underline-offset: 3px; }
.about a:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 2px; }
.about em, .about b { font-style: normal; font-weight: 400; color: var(--ink); }
.about-head { text-align: left; margin-bottom: 30px; }
.about-head .about-back { font-size: 13px; color: var(--muted); text-decoration: none; }
.about-head .about-back:hover { color: var(--ink); }
.about-head h1 { margin: 26px 0 0; font: 400 16px/1.4 var(--mono); color: var(--ink); }
.about-kicker { display: block; font-size: 12px; color: var(--muted); letter-spacing: 0.04em; }
.about-head .wordmark { display: block; font-size: 18px; }
.about-head .desc { margin-top: 4px; }
.about-sect { margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--hair); }
.about-sect h2 { margin: 0 0 12px; font: 400 14px/1.4 var(--mono); letter-spacing: 0.02em; color: var(--ink); }
/* the steps wear the app's round buttons as their numbers */
.about-steps { list-style: none; counter-reset: step; margin: 0; padding: 0; display: grid; gap: 14px; }
.about-steps li { counter-increment: step; position: relative; padding-left: 40px; min-height: 28px; }
.about-steps li::before {
  content: counter(step);
  position: absolute; left: 0; top: -2px;
  width: 28px; height: 28px; border-radius: 50%;
  border: 1px solid var(--hair); background: var(--raised); color: var(--ink);
  display: grid; place-items: center; font-size: 12px; line-height: 1;
}
/* a control named in the text, drawn the way it looks in the app */
.about-ic {
  display: inline-grid; place-items: center;
  width: 22px; height: 22px; margin: 0 1px; border-radius: 50%;
  border: 1px solid var(--hair); background: var(--raised); color: var(--ink);
  vertical-align: -6px; font-size: 12px; line-height: 1;
}
.about-ic svg { display: block; }
.about-list { display: grid; grid-template-columns: 10rem minmax(0, 1fr); gap: 12px 20px; margin: 0; }
.about-list dt { color: var(--muted); font-size: 13px; padding-top: 1px; }
.about-list dd { margin: 0; overflow-wrap: anywhere; }
@media (max-width: 640px) {
  .about-list { grid-template-columns: minmax(0, 1fr); gap: 0; }
  .about-list dt { margin-top: 14px; }
  .about-list dt:first-child { margin-top: 0; }
}
.about-cta { margin: 34px 0 0; }
.about-cta .chip { display: inline-flex; align-items: center; height: 40px; text-decoration: none; }
/* `main > footer` on the rail is a grid rule for the app; here it is just text */
main.about > .about-foot { margin-top: 34px; padding-top: 18px; border-top: 1px solid var(--hair); text-align: left; }

/* the metadata offer: same muted voice as the quality note it sits beside,
   with the verb looking like the link it is */
.exifnote button {
  appearance: none; -webkit-appearance: none;
  /* 2px read as no gap at all — "playing at the song's tempoundo" in Edwin's
     screenshot. #envRetry, the same idiom two rules below, always used 6px. */
  background: none; border: 0; padding: 0 0 0 6px; margin: 0;
  font: inherit; color: var(--ink); cursor: pointer;
  text-decoration: underline; text-underline-offset: 2px;
}

footer {
  text-align: center;
  color: var(--muted);
  font-size: 12px;
  line-height: 1.5;
}
footer p { margin: 2px 0; }
footer a { color: var(--ink); }

@media (min-width: 900px) {
  .chip { height: 40px; }
  .round { width: 40px; height: 40px; }
  .float-stack { top: 12px; right: 12px; }
  .float-stack .chip { height: 34px; } /* the stage chips stay compact */
}

/* A change that has to wait for the loop says so. Edwin: waiting sounds
   cleaner, "but the user might think the app is broken bc it didn't get
   applied immediately." A slow breath rather than a blink — a hard flash reads
   as an error, this reads as "working on it" — and it stops the instant the
   new pass begins. Decoration only: the control already shows its new value,
   which is the real proof the input registered, so reduced-motion loses
   nothing. */
@keyframes eh-pending { 50% { opacity: 0.55; } }
.pending { animation: eh-pending 1s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .pending { animation: none; opacity: 0.7; }
}

/* ── choosing a song ──────────────────────────────────────────────────────
   Shares the how-it-works dialog's shell; only the list and the field are new. */
.songquery {
  width: 100%;
  margin-top: 10px;
  padding: 10px 12px;
  font: 400 15px/1.3 var(--mono);
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--hair);
  border-radius: 9px;
}
.songquery:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; }
.songstatus { margin: 8px 2px 0; min-height: 1.2em; font-size: 12px; color: var(--muted); }
.songstatus.working { animation: eh-pending 1s ease-in-out infinite; }
/* ── the chooser's shape (v3.57) ──────────────────────────────────────────
   Edwin, on a phone: "the search results look hidden on mobile, not intuitive
   initially that need to scroll down on the modal or that the modal is even
   scrollable." Measured at a keyboard-sized viewport with eight results
   loaded: the search field's bottom edge sat at 352px inside a dialog ending
   at 373, and ZERO of the eight rows were fully visible. You typed and nothing
   appeared to happen.
   The dialog was one long scroll container, so the results lived below a fold
   with no edge to see. Now it is a column and the LIST is the only thing that
   scrolls: the head, the choices and the field hold still, and results land
   directly under the field where they were typed. */
/* COLUMN, not the default row: as a row item the body's basis is its max-content
   width, and `min-width: auto` stops a flex item shrinking below min-content —
   so one long song title stretched the body to 762px inside a 370px dialog.
   As a column item it simply stretches to the dialog's width. */
.songpick { display: flex; flex-direction: column; overflow: hidden; }
/* ...and hide it again when it is shut. `display` here is an AUTHOR rule, and
   author origin beats the UA stylesheet's `dialog:not([open]) { display: none }`
   whatever the specificity — so v3.57 left the chooser permanently rendered in
   normal flow, outside the top layer, 544x363 at y=776 on a desktop and two
   pixels below the fold on a phone. Every test passed: they all asked
   `#songPick.open`, which is a property, not a question about visibility.
   Same trap as #backdrop[hidden] above. */
.songpick:not([open]) { display: none; }
/* `flex: 1 1 auto`, never `flex: 1` — the shorthand's 0% basis would stop the
   body's content counting toward the dialog's automatic height, and the list
   (the only shrinkable child) collapsed to 0px. Measured that way first. */
.songpick .howto-body { display: flex; flex-direction: column; min-height: 0; min-width: 0; flex: 1 1 auto; }
/* One attribute decides which step is on screen. This is the structural
   version of what v3.57 faked by hiding things on focus — and it buys the same
   room for results, because the search step carries almost no chrome above the
   list: measured, that is the difference between 0 rows and 3+ at a
   keyboard-height viewport. */
.songpick[data-step="menu"] .songstep-search,
.songpick[data-step="search"] .songstep-menu,
.songpick[data-step="menu"] .songback { display: none; }
.songpick[data-step="search"] .songstep-search {
  display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto;
}
/* The 82dvh cap keeps a dialog from looking like it has swallowed the page.
   On the search step it IS the page — nothing else is on screen and the
   keyboard has taken half of what is left — so it gets the room. Worth ~40px
   at keyboard height, which is the difference between two results and three. */
.songpick[data-step="search"] { max-height: min(92dvh, 760px); }
.songback {
  appearance: none; -webkit-appearance: none;
  background: none; border: 0; padding: 0 8px 0 0; margin: 0;
  font: 400 15px/1 var(--mono); color: var(--muted); cursor: pointer;
}
.songback:hover, .songback:focus-visible { color: var(--ink); }
.songswap { margin: 10px 2px 0; font-size: 12px; color: var(--muted); }
.songswap[hidden] { display: none; }
/* The keyboard is the whole problem and dvh cannot see it: on iOS the layout
   viewport does not shrink, so a 692px dialog simply extends underneath. Only
   visualViewport knows, so JS pins the dialog to it — and ONLY while a
   keyboard is actually up, so every other case keeps the centred default. */
.songpick.vv {
  position: fixed;
  top: var(--vv-top, 8px);
  left: 50%;
  transform: translateX(-50%);
  margin: 0;
  max-height: var(--vv-h, 82dvh);
}
/* `flex: 0 1 auto` + a max-height, not `flex-grow`: with the dialog's height on
   `auto`, a growing item contributes nothing to the box's intrinsic size and
   the list collapsed to 0 on a tall screen. A capped basis gives the dialog a
   real height to be, and min-height:0 lets the same item shrink when a
   keyboard takes the room away. */
.songlist {
  list-style: none; margin: 6px 0 0; padding: 0;
  flex: 0 1 auto; min-height: 0; max-height: 46vh; overflow-y: auto;
}
/* the edge says there is more, and stops saying it at the bottom */
.songlist.more { mask-image: linear-gradient(to bottom, #000 calc(100% - 24px), transparent); }
.songlist li { margin: 0; display: flex; align-items: center; gap: 2px; }
.songlist button {
  display: flex; align-items: center; gap: 10px;
  width: 100%; padding: 7px 8px;
  font: 400 13px/1.35 var(--mono); text-align: left; color: var(--ink);
  background: none; border: 0; border-radius: 8px; cursor: pointer;
}
.songlist button:hover, .songlist button:focus-visible { background: var(--raised); outline: none; }
.songlist img { width: 38px; height: 38px; border-radius: 5px; flex: none; background: var(--raised); }
/* `.songlist button { width: 100% }` above is (0,1,1) and beats a bare
   `.songplay`, so this has to out-specify it or the play control stretches to
   the full row and squeezes the title into a 16px column. */
.songlist li > button:first-child { flex: 1 1 auto; min-width: 0; }
.songlist button.songplay {
  flex: none; width: 34px; height: 34px; padding: 0; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 1px solid var(--hair); color: var(--ink);
}
.songlist button.songplay:hover, .songlist button.songplay:focus-visible { background: var(--raised); outline: none; }
.songplay .ic-pause { display: none; }
.songplay.playing { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.songplay.playing .ic-play { display: none; }
.songplay.playing .ic-pause { display: inline; }
.songplay.loading { animation: eh-pending 1s ease-in-out infinite; }
.songlist .songmeta { min-width: 0; }
.songlist .songname { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.songlist .songartist { display: block; color: var(--muted); font-size: 12px; }
.songhint { color: var(--muted); }
.songor { margin: 12px 2px 0; font-size: 12px; color: var(--muted); }
/* In progress, said plainly. A rule rather than a tinted card: this is a note
   about the work, not a warning, and the dialog already carries enough boxes. */
.songwip { margin: 0 2px 14px; padding-top: 10px; border-top: 1px solid var(--hair);
  font-size: 11.5px; line-height: 1.5; color: var(--muted); }
.songwip b { color: var(--ink); font-weight: 600; }
.songtell { margin: 12px 2px 0; font-size: 12px; }
/* the ?tune diagnostic — deliberately plain, like the Duet panel it inherits from */
.songhear { margin: 10px 2px 0; font-size: 12px; color: var(--muted); }
.songhear[hidden] { display: none; }
/* Apple's mark, unaltered, next to the credit their terms require. A row
   rather than a stack so the two sit "proximate" the way (ii) means it, and
   flex-wrap lets the credit drop under the badge on a narrow phone rather than
   forcing the strip wider than the dialog. In the normal flow on BOTH steps:
   an overlay was tried for the search step and printed the credit on top of
   "or use a file from this device", with the badge against the dialog's edge.
   It only shows while a preview sounds, and starting one is a tap that takes
   the field's focus — so the keyboard is down whenever it needs the room. */
.songstore { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 12px 2px 0; }
.songstore[hidden] { display: none; }
/* The mark is Apple's all-black lockup (v2026-10-06; it was the pill badge).
   Their floor is 25px for a lockup and clear space a quarter of its height —
   7.5px at 30, inside the 10px gap. Height only, so a wide result title never
   has grounds to squeeze it thinner. */
.songstore img { height: 30px; width: auto; display: block; }
.songcredit { font-size: 11px; color: var(--muted); }
.songfilebtn {
  padding: 0; font: inherit; color: var(--ink);
  background: none; border: 0; border-bottom: 1px solid var(--hair); cursor: pointer;
}
/* the voice button says which world it is in */
#voiceMode[data-world="song"] { border-color: var(--ink); }

/* ── the gallery, from the app (js/gallery/submit.js) ─────────────────────
   "how it works" and "gallery" share one row under the controls, the same
   height the button had alone, so the fold budget does not move. On the rail
   the row takes the grid cell the button used to. */
/* the save menu's last item sends rather than saves: a hairline sets it apart */
.menu-list .menu-gallery { margin-top: 3px; border-top: 1px solid var(--hair); border-radius: 0 0 8px 8px; }
.gallerysend { width: min(26rem, calc(100vw - 28px)); }
.gallerysend-label { display: block; font-size: 12px; color: var(--muted); }
.gallerysend .songquery { margin-top: 6px; }
.gallerysend-note { margin: 10px 2px 0; font-size: 12px; color: var(--muted); }
.gallerysend-go { margin-top: 14px; }
.gallerysend-done { margin: 2px 0 0; }
.gallerysend-done a { color: var(--ink); text-underline-offset: 3px; }
