
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  html, body { margin: 0; height: 100%; }

  body {
    background: #0a0a0c;     /* desktop letterbox only — the stage itself is true black */
    color: var(--text);
    font: 400 16px/1.4 var(--ui);
    -webkit-font-smoothing: antialiased;
    display: grid;
    place-items: center;
    overflow: hidden;
  }

  /* The stage is ALWAYS a 600x600 square, on every display. On desktop it sits
     letterboxed in the middle; on a smaller screen it scales down as one unit so
     the layout you see is pixel-for-pixel what the glasses render. */
  #stage {
    width: var(--stage);
    height: var(--stage);
    background: var(--ink);
    display: flex;
    flex-direction: column;
    padding: 18px;
    overflow: hidden;
    position: relative;
    transform: scale(var(--fit, 1));
    transform-origin: center;
  }


  /* Status strip: language pair + mode. Small, dim, never competes. */
  #bar {
    display: flex; align-items: center; gap: 8px;
    font-size: 12px; color: var(--dim);
    letter-spacing: .04em; flex: none; margin-bottom: 12px;
  }
  #bar .pair { color: var(--text); font-weight: 500; }
  /* The pair is D-pad state (Left/Right cycles it) — but off-glasses you have a
     pointer, and an unclickable label that clearly names a setting is a lie.
     So the two language names are real buttons that open the picker. */
  #bar .lang {
    font: inherit; color: var(--text); font-weight: 500;
    background: none; border: 0; padding: 1px 4px; margin: 0 -2px;
    border-radius: 5px; cursor: pointer; letter-spacing: inherit;
  }
  #bar .lang:hover, #bar .lang.focused { color: var(--accent); background: rgba(124,245,192,.12); }
  body.on-glass #bar .lang { cursor: default; }
  #bar .spacer { margin-left: auto; }
  /* Device names are long ("Roy's iPhone 16 Microphone"). The chip is a label,
     not the truth — the picker shows the full name. Cap it so it can never
     push the status pill off the 600 square. */
  /* 130px cropped every real device name to "Micropho…", which is the one
     thing the chip exists to tell you. 190px fits "MacBook Pro" and "AirPods
     Pro" whole and still leaves the bar room to breathe. */
  #bar .mic-chip { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* A mic across the room degrades who-said-what, so it reads as the weaker
     state. No new colour: the palette is one accent (see :root) and "supporting
     info" is exactly what --dim is for. */
  #bar .mic-chip[data-mic-class="far"] { color: var(--dim); }
  /* A label, not a control. It names the mode; there is nothing to press and
     nothing to act on, so it does not get the action colour. */
  #bar .mode { color: var(--dim); font-weight: 700; letter-spacing: .1em; font-size: 11px; }

  /* What they're saying right now, in their language. Dim while interim, lit the
     moment the utterance closes, so you can see the turn land before the cards do. */
  #heard {
    flex: none; font-size: 15px; line-height: 1.3; color: var(--dim);
    padding-bottom: 6px; margin-bottom: 4px;
    border-bottom: 1px solid #22303f;
    min-height: 20px;
    /* The clipping moved to .hd-said/.hd-gist - see below. Keeping nowrap here
       would collapse the two stacked lines back into one and hide the
       translation. */
    overflow: hidden;
  }
  #heard.final { color: var(--text); }

  /* Their words and what those words mean, stacked, while they are still
     talking. The nowrap/ellipsis lives on the two child lines now rather than
     on #heard itself - #heard is a block of two lines, and clipping it as one
     line would hide the translation entirely. */
  #heard .hd-said, #heard .hd-gist {
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* The translation is the line you can actually read, so it is NOT the dim
     one - it gets the same weight as a settled transcript while the words
     above it are still arriving. Indented a little so the pair reads as one
     thought rather than two competing sentences. */
  #heard .hd-gist {
    color: var(--text); font-size: 14px; line-height: 1.3;
    margin-top: 2px; padding-left: 12px;
    border-left: 2px solid #22303f;
  }
  /* Both lines are live text on an additive display, so the pair must not add
     up to more light than the cards below it. */
  #heard.final .hd-gist { color: var(--dim); }

  /* Who just spoke. The voiceprint already knows; before this it only ever told
     the debug drawer, so your own sentence being deliberately ignored looked
     exactly like a dead mic. Two words, no chrome: on an additive display every
     lit pixel is light in the eye, so this is text and a rule, not a pill. */
  #heard .who-tag {
    display: inline-block; margin-right: 8px;
    font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
    vertical-align: 1px;
  }
  /* Their sentence is CONTENT, not something to act on. It was mint, which put
     the action colour on the one line that is purely to be read. */
  #heard.who-them .who-tag { color: var(--text); }
  /* Yours is the muted one on purpose: it marks the sentence the app is NOT
     answering, so it must not pull the eye away from their side of the talk. */
  #heard.who-me { color: var(--dim); }
  #heard.who-me .who-tag { color: var(--dim); }
  #heard.who-them { border-bottom-color: var(--text); }

  #heard .live-dot {
    display: inline-block; width: 6px; height: 6px; border-radius: 50%;
    background: var(--hot); margin-right: 6px; vertical-align: middle;
    animation: pulse 1.1s infinite;
  }

  /* Waiting on replies for a newer sentence: the old cards stay readable but
     visibly stale, so you never stare at an empty screen mid-conversation. */
  #out.pending { opacity: .4; }

  /* ---- Streaming a turn --------------------------------------------------
     The model writes the gloss first, then one reply at a time. Each card is
     shown the moment ALL FOUR of its fields are closed - a card missing its
     phonetic is a card you cannot say out loud, so a half-written one is worth
     nothing on screen. The slots not yet filled hold their space as ghosts,
     because the glasses cannot scroll and a layout that grows twice under your
     eyes is a turn you have to re-find each time it moves. */
  .card.ghost {
    border-left-color: #22303f;
    min-height: 62px;
    opacity: .5;
    /* Not a spinner. A spinner says "wait"; this says "a reply is being written
       here", which is the actual state and reads at a glance on a tiny display. */
    background:
      linear-gradient(90deg, transparent 0%, rgba(255,255,255,.07) 45%, transparent 90%)
      no-repeat -140% 0 / 140% 100%;
    animation: ghostSweep 1.35s ease-in-out infinite;
  }
  @keyframes ghostSweep {
    to { background-position: 240% 0; }
  }

  /* A card that has just landed. Only the NEW ones animate - render() redraws
     the whole turn on every frame, and re-running this on cards already read
     would make the stage flicker for the second the model spends writing. */
  .card.arriving { animation: cardIn .22s ease-out both; }
  @keyframes cardIn {
    from { opacity: 0; transform: translateY(4px); }
    to   { opacity: 1; transform: none; }
  }

  @media (prefers-reduced-motion: reduce) {
    .card.ghost { animation: none; background: #0e141b; }
    .card.arriving { animation: none; }
  }

  main { flex: 1; display: flex; flex-direction: column; gap: 16px; overflow: hidden; }

  /* What they just said. This sets up everything below it, so it is labelled and
     it is the second-largest thing on the stage — it was 16px accent-green, which
     read as UI chrome rather than as a sentence a person said. White, because the
     accent means "yours to act on" and their sentence is not yours to act on. */
  .said {
    flex: none; margin-bottom: 16px; padding-bottom: 14px;
    border-bottom: 1px solid #1c2632;
  }
  .said .eyebrow {
    font-size: 11px; font-weight: 700; letter-spacing: .16em;
    text-transform: uppercase; color: var(--dim); margin-bottom: 5px;
  }
  /* Their actual words, above the translation. Dim and small on purpose: it is
     there for recognition and continuity - the sentence the user watched arrive
     on #heard while they were still speaking - not for reading. The gloss below
     is the line that carries the meaning, so this must not compete with it.
     Two lines max; a long sentence is already fully told by the gloss. */
  .said-src {
    font-size: var(--fs-src, 13px); line-height: 1.25; color: var(--dim);
    margin-bottom: 4px;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
    overflow: hidden;
  }

  .gloss {
    font-family: var(--display);
    font-size: var(--fs-gloss); font-weight: 600; color: var(--text);
    line-height: 1.22; letter-spacing: -.015em; text-wrap: balance;
    text-shadow: var(--glow);
  }

  /* Reply cards. No fill by default: on an additive display an unlit panel is
     invisible, which is exactly right — only the focused card lights up. */
  /* Three options, three EQUAL options. They were stretched with flex:1 (dead
     vertical gaps) and the unfocused two were dimmed to 45%, which pre-picked
     option one for you. You are the one choosing: all three read at full
     brightness and are sized to their content. */
  /* --surface is transparent on-glass (an unlit panel is invisible, which is
     correct) and a faint fill on a screen, where a card with no ground reads as
     loose text rather than as one of three things you are choosing between. */
  .card {
    border-left: 3px solid var(--spine);  /* 3.45:1 on black — a real spine on all three, not a hint */
    padding: 8px 12px 8px 14px;
    flex: none;
    display: flex; flex-direction: column;
    outline: none;
    background: var(--surface);
    border-radius: 0 8px 8px 0;
  }
  .card .intent {
    font-size: var(--fs-intent); font-weight: 700;
    text-transform: uppercase; letter-spacing: .12em;
    color: var(--dim);
  }
  /* The glanceable cue, inline as a prefix to the intent label and at the same
     size - it reads as part of that line, not as a second thing to look at.
     The intent is uppercase with .12em tracking, which would otherwise push the
     emoji hard against the first letter, so the gap is a margin, not a space
     character (a space would inherit the letter-spacing and drift). */
  .card .intent .emoji {
    /* Fixed px, not em: --fs-intent is 12px here (surface.css:62), so a .4em
       gap computed to 4.8px and the glyph sat welded to the first letter.
       The gap has to stay readable at the SMALLEST type on screen, and this
       label is it. */
    margin-right: 6px;
    /* The label carries .12em uppercase tracking; on a single emoji that only
       adds a lopsided sliver AFTER the glyph, so zero it and let the margin
       above own the whole gap. */
    letter-spacing: 0;
    /* Emoji sit on a taller box than 12px text and would otherwise drag the
       line height up, shifting the label off the card's top padding. */
    font-size: 1.05em; line-height: 1; vertical-align: -.05em;
  }
  .card .mine { font-size: var(--fs-mine); color: var(--dim); margin-top: 2px; }
  /* The only thing you actually read aloud. Biggest, brightest, boldest. */
  .card .phonetic {
    font-family: var(--display);
    font-size: var(--fs-phonetic); font-weight: 800; line-height: 1.12;
    letter-spacing: -.02em; margin-top: 5px; color: var(--text);
    font-variant-ligatures: none; /* hyphens join syllables; never let them fuse */
    text-shadow: var(--glow);     /* on-glass only: survives a white wall behind it */
  }
  .card .theirs { font-size: var(--fs-theirs); color: var(--dim); margin-top: 4px; }
  /* Pronunciation, not a control. These arrows sit INSIDE the line you read
     aloud, so the action colour here put mint in the middle of body text - a
     large part of why the card stack read as mint everywhere. --dim separates
     them from the syllables without claiming to be actionable. */
  .card .phonetic .tone {
    font-size: .58em; color: var(--dim); vertical-align: .3em; margin-left: 2px;
  }

  /* D-pad focus. The Neural Band moves this — it must be unmistakable at a
     glance, so the focused card gets the accent spine and full-bright text. */
  /* Focus is a CURSOR, not a verdict. It says "Enter speaks this one", it does
     not say "this is the right answer" — so it is carried by the spine and the
     intent colour only. Nothing dims, nothing gets brighter. Never colour alone:
     the spine is a 3px position change as well as a hue change. */
  .card.focusable:focus-visible,
  .card.focused { border-left-color: var(--accent); }
  .card.focused .intent { color: var(--accent); }

  /* The card you just said out loud.
     Focus already owns the accent spine, and focus can sit on a DIFFERENT card
     than the one you spoke - so this cannot be another hue on the same edge or
     the two states become one ambiguous mark. It is carried by WIDTH instead:
     a 3px spine becomes 8px, which reads at a glance and survives being on the
     same card as focus.
     No fill, no shadow. Black is not a colour on an additive display, it is the
     street: a dark card is nothing, and a drop shadow is invisible. Brightness
     is the only thing that exists on glass, so the supporting lines come up to
     full text colour rather than a panel appearing behind them. */
  /* NOT the accent. Mint means one thing now - "act on this" - and focus owns
     it. Matched is the opposite tense: you already said this card, so it must
     recede rather than compete. It used to be mint-on-mint separated only by
     spine width (3px vs 8px), which is not a difference you can read at a
     glance, and it was the single worst offender in a screen where five
     unrelated things were all mint.
     Two channels, so focus and matched can land on the same card and still be
     one readable picture: the spine stays the FOCUS channel (dim here), and
     "said" is carried by the ✓ mark on the intent line - see .intent::after.
     On additive glass a dim spine is nearly nothing, so the width cue is kept
     there and only there (surface.css). */
  .card.matched {
    border-left-color: var(--spine);
  }
  .card.matched .intent { color: var(--dim); }
  .card.matched .intent::after {
    content: ' \2713 SAID';
    letter-spacing: .1em;
  }
  .card.matched .mine,
  .card.matched .theirs { color: var(--text); }
  /* Focus still wins the spine when it happens to sit on the card you said:
     the cursor is where you can act, and that has to stay legible. */
  .card.matched.focused,
  .card.matched.focusable:focus-visible { border-left-color: var(--accent); }

  /* The back-translation of what you just said, in place of the transcript.
     Their language, so it gets the accent - it is the one line on screen that
     is not about what to say next but about what you already said. */
  /* Past tense, exactly like .card.matched: this is what you already said, so
     it recedes instead of taking the action colour. */
  #heard.self-echo { color: var(--text); border-bottom-color: var(--spine); }
  #heard.self-echo .who-tag { color: var(--dim); }

  /* Waiting on the back-translation of your own sentence. The text stays fully
     readable - it is your transcript and it is correct - so the wait is carried
     by the underline alone, which travels while nothing else on the line moves.
     Anything that dimmed or replaced the words would trade something true for
     something decorative on a display with one readable line. */
  #heard.self-pending {
    border-bottom-color: var(--spine);
    background-image: linear-gradient(90deg, transparent, rgba(255,255,255,.45), transparent);
    background-repeat: no-repeat;
    background-size: 40% 2px;
    background-position: -40% 100%;
    animation: heardWait 1.1s ease-in-out infinite;
  }
  @keyframes heardWait {
    0%   { background-position: -40% 100%; }
    100% { background-position: 140% 100%; }
  }
  @media (prefers-reduced-motion: reduce) {
    /* Still has to say "busy" - hold the accent underline instead of sweeping. */
    #heard.self-pending { animation: none; background-image: none; }
  }

  /* Language picker. Lives inside the 600 square — it has to, the glasses have
     no second surface. Covers the stage, D-pad drives it, Enter/Esc closes. */
  #picker {
    display: none; position: absolute; inset: 0; z-index: 2;
    background: var(--ink); padding: 18px;
    flex-direction: column;
  }
  /* Under ?bg=1 the picker must go transparent like everything else — an opaque
     panel here would hide exactly the cost this mode exists to show. */
  body.has-bg #picker { background: transparent; }
  #picker.open { display: flex; }
  /* A heading. The options below are what you act on. */
  #picker .ptitle {
    font-size: 12px; letter-spacing: .12em; text-transform: uppercase;
    color: var(--dim); font-weight: 700; flex: none; margin-bottom: 10px;
  }
  #picker .plist {
    flex: 1; min-height: 0; overflow: auto;
    display: grid; grid-template-columns: 1fr 1fr; gap: 2px 10px; align-content: start;
  }
  /* Device names don't fit two to a row — one column, full width. */
  #picker .plist.one-col { grid-template-columns: 1fr; }
  #picker .opt .sub { display: block; font-size: 11px; color: var(--dim); font-weight: 400; }
  #picker .opt {
    font: inherit; font-size: 16px; text-align: left;
    background: none; border: 0; border-left: 3px solid transparent;
    color: var(--dim); padding: 6px 0 6px 10px; cursor: pointer;
  }
  #picker .opt.focused { color: var(--text); border-left-color: var(--accent); font-weight: 700; }
  #picker .opt.current::after { content: ' ✓'; color: var(--accent); }
  #picker .opt[disabled] { opacity: .3; cursor: default; }
  #picker .pfoot { flex: none; font-size: 12px; color: var(--dim); margin-top: 10px; }

  /* ── Listening state ──────────────────────────────────────────────────────
     The old build signalled "on" by swapping the button label and pulsing the
     whole button's opacity. At a glance, mid-conversation, that reads as
     nothing. State now shows in three places at once, so it is impossible to
     miss and impossible to misread:
       1. the status pill in the top bar (colour + word)
       2. the live bars, which move only when sound is actually arriving
       3. the mic button, filled when live rather than outlined
     Colour alone never carries it — the word is always there too, which is
     also what keeps it readable for a colourblind wearer. */
  #status {
    display: inline-flex; align-items: center; gap: 6px;
    font-size: 11px; font-weight: 600; letter-spacing: .1em;
    text-transform: uppercase; color: var(--dim);
  }
  #status .lamp {
    width: 7px; height: 7px; border-radius: 50%;
    background: currentColor; flex: none;
  }
  body.state-listening #status { color: var(--live); }
  body.state-listening #status .lamp { animation: breathe 1.6s ease-in-out infinite; }
  /* Arming is not idle. The pill takes the accent colour the moment the tap
     lands, so the top bar and the button change together and neither one is
     the only thing that moved. */
  body.state-starting  #status { color: var(--accent); }
  body.state-starting  #status .lamp { animation: breathe .8s ease-in-out infinite; }
  body.state-thinking  #status { color: var(--accent); }
  body.state-thinking  #status .lamp { animation: breathe .8s ease-in-out infinite; }
  /* Transcribing is the tail of listening, not the start of thinking: the words
     are still arriving. It reads as the live colour, pulsing at the faster
     thinking rate, so the handoff between the two is a change of tempo rather
     than a jump to a different colour. */
  body.state-transcribing #status { color: var(--live); }
  body.state-transcribing #status .lamp { animation: breathe .8s ease-in-out infinite; }
  body.state-error     #status { color: var(--hot); }

  /* Six bars driven by real mic RMS. Flat when the room is quiet, moving when
     someone is actually talking — that difference is the whole point: it tells
     you the mic is not just on but hearing, which a spinner cannot say.
     This used to be 12px tall and near-invisible on the glasses, which have no
     mic of their own — the only way they can show "it's hearing you" at all is
     this meter, riding the level over the pair link, so it has to actually read
     from across a 20-degree FOV on an additive display: full saturated colour,
     not a thin dim sliver, and tall enough to register in peripheral vision. */
  #vu { display: none; align-items: flex-end; gap: 3px; height: 22px; }
  body.state-listening #vu { display: inline-flex; }
  #vu i {
    display: block; width: 4px; border-radius: 1.5px;
    /* Resting floor is a visible stub, not a bar collapsed to nothing — proof
       the meter is live even while the room is silent, not just off. */
    background: var(--dim); height: 4px; opacity: .55;
    transform-origin: bottom;
    transition: height .09s linear, background-color .09s linear, opacity .09s linear;
  }
  /* Once a bar is actually carrying signal it switches to the listening colour
     and full brightness — quiet air stays --dim, real speech goes --live, so
     the two states are never just "small bar" vs "smaller bar". */
  #vu i.hot { background: var(--live); opacity: 1; }
  @keyframes breathe { 50% { opacity: .25; } }

  footer { flex: none; margin-top: 12px; }
  #mic {
    width: 100%; padding: 13px; font: inherit; font-size: 15px; font-weight: 700;
    letter-spacing: .06em; text-transform: uppercase;
    background: transparent; color: var(--accent);
    border: 2px solid var(--accent); border-radius: 10px; cursor: pointer;
    display: flex; align-items: center; justify-content: center; gap: 9px;
    transition: background .14s ease, color .14s ease, border-color .14s ease;
  }
  #mic .dot {
    width: 8px; height: 8px; border-radius: 50%;
    background: currentColor; flex: none; display: none;
  }

  /* ── Arming ────────────────────────────────────────────────────────────────
     Between the tap and the open mic there are three network legs. The button
     must not still look idle for those seconds, and it must not look LIVE
     either - the mic is shut, and a filled button that turns out to be lying
     is worse than a slow one. So: half-filled, with a spinner that is the one
     element on this screen guaranteed to be moving. A dot that only pulses
     could be a stuck frame; a rotation could not.

     Deliberately still enabled and still full opacity. This wait is
     cancellable - every await in listenRealtime re-checks `stopped` - and a
     greyed-out button during the one moment you might want out is a trap. */
  #mic.arming {
    background: rgba(124,245,192,.14);   /* --accent at low alpha, like #bar .lang */
    border-color: var(--accent);
    color: var(--accent);
  }
  #mic.arming .dot {
    display: block;
    width: 13px; height: 13px;
    background: none;
    border: 2px solid currentColor;
    border-top-color: transparent;    /* the gap is what makes rotation visible */
    animation: spin .7s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  /* Someone who has asked the OS to stop animation gets a pulse instead of a
     spin - still moving, still proof of life, no rotation. */
  @media (prefers-reduced-motion: reduce) {
    #mic.arming .dot { animation: breathe 1s ease-in-out infinite; }
  }
  #mic.focused { background: var(--accent); color: #04150e; }
  /* Live is a FILLED button, not a dimmed outline. A solid block of colour is
     the one thing that survives a 20-degree FOV and a sunlit street. */
  #mic.live {
    background: var(--live); border-color: var(--live); color: #fff;
  }
  #mic.live .dot { display: block; animation: breathe 1.6s ease-in-out infinite; }
  #mic.live.focused { box-shadow: 0 0 0 2px var(--ink), 0 0 0 4px #fff; }
  @keyframes pulse { 50% { opacity: .6; } }

  /* The empty middle used to be dead black with one line of grey text. It now
     states what the app is waiting for and what to press, centred, because a
     blank 600 square gives a first-time wearer nothing to act on. */
  /* The hero says "Tap Start listening". Once you HAVE tapped it, that line is
     an instruction to do the thing you just did - so it steps back while the
     app is arming rather than arguing with the button below it. Dimmed, not
     removed: the stage must not resize under a wearer mid-tap. */
  body.state-starting .idle-hero .sub { opacity: .4; transition: opacity .2s ease; }

  .hint.idle-hero {
    flex: 1; display: flex; flex-direction: column;
    align-items: center; justify-content: center; text-align: center; gap: 10px;
  }
  .hint.idle-hero .big {
    font-family: var(--display); font-size: 26px; font-weight: 700;
    color: var(--text); line-height: 1.15; letter-spacing: -.01em;
  }
  .hint.idle-hero .sub { font-size: 14px; color: var(--dim); max-width: 34ch; line-height: 1.45; }
  kbd {
    font: inherit; font-size: .92em; font-weight: 700; color: var(--text);
    border: 1px solid #3a4552; border-radius: 5px; padding: 1px 6px;
  }

  .hint { color: var(--dim); font-size: 15px; line-height: 1.45; }
  .hint b { color: var(--text); }
  .err { color: var(--hot); font-size: 15px; }
  /* A notice that sits ABOVE live cards instead of replacing them - see
     setStatus() in 02-picker.js. Cards are what the user is reading; an error
     is a line, not a reason to blank the stage. */
  .stage-note {
    font-size: 13px; line-height: 1.35; margin: 0 0 8px;
    padding: 6px 10px; border-radius: 8px;
    background: color-mix(in srgb, var(--hot) 12%, transparent);
  }
  .stage-note.hint { background: color-mix(in srgb, var(--dim) 14%, transparent); }


/* The pair chip. Deliberately the same shape as the mic chip next to it: this
   is another "which device is doing what" readout, not a new kind of control. */
.pair-chip { font-variant-numeric: tabular-nums; letter-spacing: .06em; opacity: .85; }
[data-host="glasses"] .pair-chip { letter-spacing: .04em; }

/* The link-state banner. The chip in the bar is too small to answer "is this
   thing even on" from across a room, so on the idle screen - the one moment
   nothing else is competing for the eye - the same information gets said in
   full sentences and full size. Connected is --accent because it is the state
   that means "you can talk now"; waiting is --dim because there is nothing to
   act on yet. */
.pair-link {
  font-variant-numeric: tabular-nums;
  font-size: 15px; font-weight: 600; margin-top: 4px;
}
.pair-link.linked { color: var(--accent); }
.pair-link.waiting { color: var(--dim); }
.pair-link .code { letter-spacing: .1em; font-weight: 700; }

/* ---- Phone ---------------------------------------------------------------
   Everything above draws a 600x600 square, because that is literally what the
   glasses render and a desktop preview should not lie about it. A phone is not
   a preview. It is the device in your hand doing the listening, and a fixed
   square either overflows it or shrinks to a stamp - both were happening.

   So on a phone the stage stops being a square and becomes the page: full
   width, full height, safe-area aware, with the mic button parked at the thumb
   and text at a size you read at arm's length rather than 2cm from an eye.
   The glasses path is untouched - it is keyed off viewport width, and the
   glasses are 600 wide, above this breakpoint. */
/* Phone layout. The width test alone is NOT enough: the Ray-Ban Display is a
   600x600 square, so `max-width: 700px` matched the glasses too and handed them
   the phone's `main { overflow: visible }` + scrolling #stage. main then grew to
   695px inside a 600px stage and the third card landed at bottom=652, off the
   glass and behind the footer. Geometry separates them - a phone in portrait is
   far taller than wide (390/844 = 0.46), the glasses are exactly 1:1. Same
   reasoning the JS glasses check uses: the UA lies, the box does not. */
@media (max-width: 700px) and (max-aspect-ratio: 4/5) {
  body {
    display: block;
    overflow: auto;
    background: var(--ink);       /* no letterbox: the stage IS the page */
    -webkit-text-size-adjust: 100%;
  }

  #stage {
    width: 100%;
    height: 100dvh;               /* dvh, so the Safari URL bar cannot crop it */
    transform: none;              /* --fit is a preview scale; never on a phone */
    padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
    padding-top: calc(12px + env(safe-area-inset-top));
    /* On glasses #stage never scrolls - the square must always fit. On a phone
       there's no such guarantee, and footer's sticky-bottom button only has
       something to stick to if the ancestor that scrolls is the one it sits
       in. footer is #stage's child, not #out's, so #stage is what has to
       scroll here - making #out scroll instead would leave the sticky footer
       with no scrolling ancestor and it would just sit inline, which is the
       cropping bug this fixes. */
    overflow-y: auto;
  }

  /* main keeps flex:1 so the idle hero still centers in the empty state, but
     it must stop clipping its own content - that hidden was correct for the
     glasses (nothing may scroll there) and wrong here (it hid the very
     overflow #stage now scrolls to reach). */
  main { overflow: visible; }

  /* The bar wraps rather than pushing the status pill off the edge. */
  #bar { flex-wrap: wrap; row-gap: 6px; font-size: 13px; }
  #bar .mic-chip { max-width: 58vw; }

  /* Bigger, because this is read at arm's length, not on a monocular display
     a couple of centimetres from your eye. */
  :root {
    --fs-gloss: 20px;
    --fs-phonetic: 30px;
    --fs-theirs: 16px;
    --fs-mine: 17px;
  }

  #out {
    -webkit-overflow-scrolling: touch;
    /* The sticky footer is drawn on top of #stage's content, not pushed below
       it - that's what "sticky" means. Without this, the last card's bottom
       few lines sit right where the button paints and get covered, which
       reads exactly like the crop bug being fixed here even though the pixels
       technically exist. 88px clears the mic button (17px padding + text +
       border, ~57px) plus footer's own spacing (~18px) plus slack for the
       safe-area inset on notched phones - rounded up, not tuned to the pixel. */
    padding-bottom: 88px;
    scroll-padding-bottom: 88px;
  }

  /* Thumb-sized and stuck to the bottom. On the phone this button is the whole
     interaction - you press it once and put the phone in your pocket. */
  #mic {
    padding: 17px;
    font-size: 16px;
    border-radius: 14px;
  }
  footer {
    position: sticky;
    bottom: 0;
    margin-top: 10px;
    background: linear-gradient(to top, var(--ink) 62%, transparent);
    padding-top: 8px;
  }

  .hint.idle-hero .big { font-size: 24px; }
  .hint.idle-hero .sub { font-size: 15px; max-width: 30ch; }

}


/* A phone has no keyboard, so "Press Enter" is a lie there. The copy is swapped
   in JS (see idleHero) - this only hides the key cap if one slips through. */
@media (max-width: 700px) and (max-aspect-ratio: 4/5) and (pointer: coarse) {
  .hint.idle-hero kbd { display: none; }
}

/* Glasses in view mode: the phone listens, so every mic-shaped control here is
   an offer this device cannot honour. Hide them rather than fail on tap. */
[data-pair="view"] #micName,
[data-pair="view"] footer { display: none; }
/* The meter used to get dimmed to .35 here on the theory that it was the
   phone's business, not the glasses'. Backwards: the glasses have no mic at
   all, so this bar riding the phone's level over the pair link is the ONLY
   place they can see "it's hearing you" - dimming it took away the one signal
   this device actually needs. Full strength, same as the phone. */

/* The pairing status lives in ONE place: .pair-link, under the idle hero copy
   above. There used to be a second one - a fixed pill called #pairOverlay,
   pinned over the phone UI - and it was wrong twice over. Pinned to the top it
   covered #bar (languages, mic chip, status lamp); moved to the bottom it
   covered #sayText, the say-first input. There is no free edge on a phone: the
   header is status and the footer is the two controls, so a fixed banner is
   always on top of something.
   It was also saying what the screen already said. "Waiting for glasses · code
   9SBB" was rendered by .pair-link at the same moment, four lines up. The pill
   existed for the card screen, where setIdle()'s markup is gone - but a phone
   whose glasses dropped mid-conversation finds that out from the cards not
   updating, and can look at the idle screen for the code. One status, no
   overlay, nothing covered. */

/* Fit-to-square. The glasses cannot scroll, so a long turn has to get smaller
   rather than get cut off - see fitTurn() in 02-picker.js. Each step trims the
   type and the gaps together, because leading is what actually eats the square
   on a three-card turn, not the glyphs. */
#out.fit-1 { --fs-gloss: 20px; --fs-phonetic: 24px; --fs-mine: 14px; --fs-theirs: 13px; --fs-src: 13px; }
#out.fit-2 { --fs-gloss: 18px; --fs-phonetic: 22px; --fs-mine: 13px; --fs-theirs: 12px; --fs-src: 12px; }
#out.fit-3 { --fs-gloss: 17px; --fs-phonetic: 20px; --fs-mine: 13px; --fs-theirs: 12px; --fs-src: 12px; }
#out.fit-4 { --fs-gloss: 16px; --fs-phonetic: 18px; --fs-mine: 12px; --fs-theirs: 11px; --fs-src: 11px; }
#out.fit-2, #out.fit-3, #out.fit-4 { gap: 10px; }
#out.fit-2 .said, #out.fit-3 .said, #out.fit-4 .said { margin-bottom: 10px; padding-bottom: 9px; }
/* At the tightest fit the source line is the first thing to go. It is the only
   element on the stage whose content is fully restated by another (the gloss),
   so dropping it costs the least - and the alternative is clamping a reply card
   off the bottom of a display that cannot scroll. */
#out.fit-4 .said-src { -webkit-line-clamp: 1; }

/* ---- Say-first ------------------------------------------------------------
   The compose row under the mic button. Hidden by default and unhidden in JS
   only where it can actually be used (see initSay in 07-shell.js): a phone, not
   the glasses, not a paired display. Default-hidden rather than default-shown
   because the failure modes are asymmetric - a compose box that never appears
   on a phone is an annoyance, one that appears on 600px of glass costs a reply
   card and cannot be typed into anyway. */
#say {
  display: none;
  gap: 8px;
  margin-bottom: 8px;
}
#say[data-on="1"] { display: flex; }

#sayText {
  flex: 1;
  min-width: 0;
  font: inherit;
  font-size: 16px;              /* below 16 and iOS Safari zooms the page on focus */
  color: var(--text);
  background: var(--surface);
  border: 1px solid rgba(255,255,255,.14);
  border-radius: 12px;
  padding: 14px 14px;
  -webkit-appearance: none;
}
#sayText::placeholder { color: var(--dim); }
#sayText:focus {
  outline: none;
  border-color: var(--accent);
}

#sayGo {
  flex: 0 0 auto;
  width: 52px;
  font: inherit;
  font-size: 20px;
  font-weight: 700;
  color: var(--ink);
  background: var(--accent);
  border: 0;
  border-radius: 12px;
  /* The accent means "yours to act on" and this button is exactly that, so it
     gets the fill. It is also the only filled control on the phone screen -
     the mic button is outlined - which is correct: they do different things
     and looking identical was the first thing that read as wrong. */
}
/* Nothing typed yet, so the arrow is not an offer. Dim rather than disabled so
   the row does not change size the moment the first character lands. */
#say:not([data-ready="1"]) #sayGo { opacity: .4; }

@media (prefers-reduced-motion: no-preference) {
  #sayText, #sayGo { transition: border-color .12s ease, opacity .12s ease; }
}

/* ---- Set the scene --------------------------------------------------------
   The secondary control in the footer, above the mic button. Secondary is the
   whole design: #mic is the outlined accent block that starts the product, and
   this must not compete with it or a first-time user has two equal offers and
   no idea which one is the app. So it is quieter - dim text, hairline border,
   no accent until it is doing something.

   It also has to survive the glasses, where every lit pixel dims the street
   behind it. An unfilled control with thin text is close to invisible there,
   which is correct for something you use once at the start and never look at
   again. */
#scene-btn {
  width: 100%;
  margin-bottom: 8px;
  padding: 10px 14px;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  color: var(--dim);
  background: transparent;
  /* Dashed, not solid. The mic button below is a solid accent block that STARTS
     the conversation; this one describes the room before it. Reading as a
     quieter twin of that button was the first thing that went wrong - same
     shape, same weight, two things to press and no way to tell which was the
     app. A dashed edge says "setup", which is what this is. */
  border: 1px dashed rgba(255,255,255,.22);
  border-radius: 10px;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 9px;
  text-align: left;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  touch-action: manipulation;
}
/* A speech mark, not a mic. The mic glyph belongs to the button that opens the
   mic on the conversation; reusing it here is what made the two read as the
   same control in two states. */
/* A map pin. The old glyph was a speech-mark rectangle, which is wrong twice:
   it reads as "a message" (this is not one) and at 13x11 with a clipped corner
   it reads as nothing at all - a small grey box. The scene is WHERE YOU ARE and
   who is in front of you, and a pin says that with no label. Drawn in CSS with
   a rotated teardrop rather than an SVG, because the glasses render additively
   and a stroked shape stays legible where a filled one glows. */
.scene-ico {
  width: 11px; height: 11px; flex: none;
  position: relative;
  border: 1.5px solid currentColor;
  border-radius: 50% 50% 50% 0;
  transform: rotate(-45deg);
}
/* The hole in the middle. Without it the pin is a lopsided blob at this size. */
.scene-ico::after {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 3px; height: 3px;
  margin: -1.5px 0 0 -1.5px;
  border-radius: 50%;
  background: currentColor;
}
/* Focus is the D-pad cursor, same meaning as everywhere else: this is what
   Enter would act on. Border and text, not a fill - a filled secondary button
   outshouts the primary one sitting right below it. */
#scene-btn.focused {
  color: var(--accent);
  border-color: var(--accent);
}
/* Blocked mic. Dimmed and not clickable, with the recovery in the title - the
   copy itself goes to the status pill, which is where this app says things. */
#scene-btn.is-denied {
  opacity: .4;
  cursor: default;
}
@media (max-width: 700px) and (max-aspect-ratio: 4/5) {
  #scene-btn { padding: 14px; font-size: 14px; border-radius: 14px; }
}
/* A paired display has no mic and no user in front of it. */
[data-pair="view"] #scene-btn,
[data-pair="view"] #sceneChip { display: none; }

/* The situation, echoed on the idle screen. This is a receipt - it exists so
   that after speaking into the button you can see the app took what you said,
   before any card is on screen. */
.hint.idle-hero .scene-set {
  margin-top: 14px;
  font-size: 13px;
  /* A receipt, not a control - it reports what the app heard. It is not
     something to act on, so it does not wear the action colour. */
  color: var(--dim);
  max-width: 34ch;
}

/* The bar chip. Same shape as the language and mic chips, because it is the
   same kind of thing: a setting that is currently in force. */
/* Two adjoining controls sharing one outline, so it still reads as a single
   chip while being two real targets. The x used to be a bare glyph with no
   padding: nothing to aim at with a thumb, and nothing for the D-pad to stop on.
   Both halves are .focusable now, so Enter on the glasses can reach either. */
/* [hidden] FIRST and with the same specificity, or this display rule silently
   beats it. `hidden` is only display:none in the UA stylesheet, so ANY author
   display declaration overrides it - which is why the chip stayed on screen
   after the x cleared the scene: the JS set hidden=true and the CSS put it
   straight back. */
#bar .scene-chip[hidden] { display: none; }
#bar .scene-chip {
  display: inline-flex;
  align-items: stretch;
  max-width: 16ch;
  border-radius: 999px;
  overflow: hidden;
  background: rgba(255,255,255,.06);
}
#bar .scene-chip .scene-chip-body {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-width: 0;
  border-radius: 0;
  background: none;
  border: 0;
}
#bar .scene-chip .scene-ico { opacity: .8; }
#bar .scene-chip #sceneChipText {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Its own target, with real padding and a divider so the two halves are
   visibly separate things to press. */
#bar .scene-chip .scene-x {
  font: inherit;
  font-size: 15px;
  line-height: 1;
  padding: 0 8px;
  border: 0;
  border-left: 1px solid rgba(255,255,255,.14);
  background: none;
  color: var(--dim);
  cursor: pointer;
  flex: none;
}
#bar .scene-chip .scene-x:hover { color: var(--text); }
#bar .scene-chip .scene-x.focused { color: var(--accent); background: rgba(124,245,192,.14); }
#bar .scene-chip .scene-chip-body.focused { color: var(--accent); }

/* ── Set the scene, the modal ────────────────────────────────────────────────
   Same surface as #picker and for the same reason: on a 600x600 display there
   is exactly one surface, so a mode that needs the user's full attention takes
   all of it. As a footer button this competed with "Start listening" and the
   two read as alternatives - which is the confusion this replaces. */
#sceneModal {
  display: none; position: absolute; inset: 0; z-index: 3;
  background: var(--ink); padding: 22px 20px;
  flex-direction: column; align-items: stretch; justify-content: center;
  gap: 12px;
}
#sceneModal.open { display: flex; }
/* Under ?bg=1 every panel goes transparent - an opaque one would hide exactly
   the cost that mode exists to show. */
body.has-bg #sceneModal { background: transparent; }
/* A heading, not a control. The thing to act on in this modal is the record
   button; the title taking the same colour made them read as equals. */
#sceneModal .sm-title {
  font-size: 12px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--dim); font-weight: 700;
}
#sceneModal .sm-sub {
  font-size: 15px; color: var(--dim); line-height: 1.35; max-width: 36ch;
}
/* The waveform. Sized in CSS, drawn at device pixels - sceneWavePaint() reads
   clientWidth every frame and resizes the backing store when it changes, which
   is the only way a canvas laid out by flex ever gets the right resolution. */
#sceneWave {
  width: 100%; height: 72px; display: block;
  border-radius: 10px;
  background: rgba(255,255,255,.03);
}
#sceneModal .sm-heard {
  font-size: 13px; color: var(--dim); min-height: 1.2em;
}
/* The one action. Filled accent, because inside this modal it IS the primary
   control - there is nothing else to press. Outside, the thing that opens the
   modal stays a quiet dashed outline; the weight belongs where the work is. */
#sceneModal .sm-rec {
  font: inherit; font-size: 15px; font-weight: 700;
  padding: 12px 16px; border-radius: 12px; cursor: pointer;
  border: 1px solid var(--accent);
  background: rgba(124,245,192,.12); color: var(--accent);
  display: flex; align-items: center; justify-content: center; gap: 10px;
  -webkit-user-select: none; user-select: none; touch-action: manipulation;
}
#sceneModal .sm-rec.focused { background: rgba(124,245,192,.22); }
#sceneModal .sm-ico {
  width: 11px; height: 11px; border-radius: 50%; flex: none;
  background: currentColor; opacity: .5;
}

/* ── Recording is RED, everywhere at once ────────────────────────────────────
   This modal used to stay accent-green while the mic was open: the dot changed
   opacity and nothing else did. Green is the app's "you may act on this"
   colour, so a live mic wearing it says the opposite of what is true — and the
   footer mic pill (#mic.live) had already gone red for exactly this reason, so
   the app had one live colour on one screen and another on the next.

   The three surfaces of the modal move together, because a state that only
   half-changes reads as a rendering bug rather than a state: the title, the
   button, and the waveform. No new element, no badge — the same controls in
   the live colour. */
/* ONE carrier, and it is the waveform — the only element that is actually
   moving with your voice. Red on a live meter is a signal; red on a fixed
   title and a filled button is three things shouting the same word, which is
   what the first pass shipped and what made it read as an alarm rather than a
   state. The dot is the exception: it is 11px and it breathes, so it belongs
   to the meter's signal, not to the button's chrome.

   The title and the button stay in the neutral voice. Nothing about "Stop"
   needs colour to be understood — the label already says it. */
#sceneModal.rec .sm-title { color: var(--text); }
#sceneModal.rec .sm-rec {
  border-color: rgba(255,255,255,.20);
  background: rgba(255,255,255,.05);
  color: var(--text);
}
#sceneModal.rec .sm-rec.focused {
  background: rgba(255,255,255,.10);
  border-color: rgba(255,255,255,.34);
}
/* The live dot. The one piece of red on the control, and it is the piece that
   moves — same language as #mic.live's dot and the meter it sits under. */
#sceneModal.rec .sm-ico { background: var(--live); }
/* Recording: the dot goes solid and breathes. The waveform is doing the real
   work of saying "this is live", so the button only has to agree with it. */
#sceneModal.rec .sm-ico { opacity: 1; animation: breathe 1.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  #sceneModal.rec .sm-ico { animation: none; }
}
/* The canvas ground warms with it. Bare .03 white under red bars reads as a
   grey box someone painted red bars into; this makes the meter one object. */
#sceneModal.rec #sceneWave { background: rgba(255,90,90,.05); }

/* ── Working is AMBER ────────────────────────────────────────────────────────
   The stop -> transcript window is neither "the mic is open" nor "here is your
   result", and painting it in either colour is a lie about which one it is.
   Amber is the third state, and it is the only place in the app that uses it,
   so it cannot be confused with anything else.

   Same restraint as .rec: the sweeping bar is the one thing moving, so it is
   the one thing that carries the colour. The title and the dead button stay
   neutral - a disabled control does not need a hue to say it is disabled, the
   .55 opacity below already does that. */
#sceneModal.working .sm-title { color: var(--text); }
#sceneModal.working .sm-ico { background: var(--work); }
#sceneModal .sm-foot { font-size: 12px; color: var(--dim); }
/* Phone: the same flow with room to breathe, since there is more of it. */
@media (max-width: 700px) and (max-aspect-ratio: 4/5) {
  #sceneModal { padding: 26px 22px; gap: 14px; }
  #sceneWave { height: 96px; }
  #sceneModal .sm-rec { padding: 16px; font-size: 16px; border-radius: 14px; }
}

/* ── Who is speaking ─────────────────────────────────────────────────────────
   One control, one selected side, always. Two words in the status strip - not
   two buttons in the footer, which is where this started: too heavy for what it
   says, and under the compose box, which is where you go to START a
   conversation while this is only ever about the sentence happening right now.

   Exactly one side is lit at any moment. Both dim would read as a question
   ("pick one") rather than a readout, and the answer is never genuinely absent:
   see paintWho(), which holds the last side rather than blanking. */
#bar #who {
  display: inline-flex;
  align-items: center;
  flex: none;
  border: 1px solid rgba(255,255,255,.12);
  border-radius: 6px;
  overflow: hidden;
}

.who-opt {
  font: inherit;
  font-size: 11px;
  letter-spacing: .06em;
  line-height: 1;
  color: rgba(255,255,255,.34);
  background: none;
  border: 0;
  padding: 4px 7px;
  margin: 0;
  cursor: pointer;
}

/* The selected side. Filled rather than just coloured, so which one is active
   survives a glance at arm's length on a phone you are holding at a table. */
.who-opt[data-live="1"] {
  color: var(--ink);
  background: var(--accent);
  font-weight: 600;
}

/* You overrode it. Same fill (it IS the selected side) plus an underline, so an
   override is still distinguishable from a measurement - you can tell what the
   app worked out from what you told it. */
.who-opt[aria-pressed="true"] {
  text-decoration: underline;
  text-underline-offset: 2px;
}

body.on-glass #bar #who { display: none; }

@media (prefers-reduced-motion: no-preference) {
  .who-opt { transition: color .12s ease, background-color .12s ease; }
}

/* ---- Scene modal: the stop -> transcript window --------------------------
   The recorder stops instantly, the transcribe round-trip takes ~1-3s. That
   gap used to show the RECORDING copy over a waveform frozen at zero, which
   reads as a crash, not as work in progress. The bar replaces the canvas for
   exactly that window so the screen is never still while the app is busy. */
#sceneModal .sm-prog {
  width: 100%; height: 72px; border-radius: 10px;
  background: rgba(255,255,255,.03);
  overflow: hidden; position: relative;
  display: flex; align-items: center;
}
#sceneModal .sm-prog span {
  position: absolute; left: 0; top: 50%;
  height: 3px; width: 38%; border-radius: 3px;
  background: linear-gradient(90deg, transparent, var(--work), transparent);
  animation: smSweep 1.15s ease-in-out infinite;
}
@keyframes smSweep {
  0%   { transform: translate(-100%, -50%); }
  100% { transform: translate(340%, -50%); }
}
/* Reduced motion still needs to say "busy" - hold a steady band instead of
   removing the only cue that anything is happening. */
@media (prefers-reduced-motion: reduce) {
  #sceneModal .sm-prog span {
    animation: none; width: 100%;
    background: rgba(255,196,107,.30);
    transform: translate(0, -50%);
  }
}
/* Disabled while the request is in flight: dimmed, and it stops reporting as
   pressable so the second Stop tap has nothing to land on. */
#sceneModal.working .sm-rec {
  opacity: .55; cursor: default; pointer-events: none;
}
#sceneModal.working .sm-ico { animation: breathe 1.2s ease-in-out infinite; opacity: 1; }
@media (max-width: 700px) and (max-aspect-ratio: 4/5) {
  #sceneModal .sm-prog { height: 96px; }
}
