/* Layout and components.
 *
 * Colours come only from tokens.css, which is generated by
 * design/build-tokens.mjs from design/tokens.json — the same file that
 * generates the iOS palette, so the two clients cannot drift apart. That file
 * defines both schemes; nothing here hardcodes a colour.
 *
 * The tint variables are `--tint-diamond`, not `--diamond`. Getting that wrong
 * is silent: an undefined custom property makes the whole declaration invalid,
 * so `background: var(--diamond)` produced a button with NO background — dark
 * text on a dark card, invisible. `web_styles.test.ts` now asserts every
 * variable this file uses is actually defined.
 */

:root {
  /* Set per-cell at runtime by grid.js (0–1, how many others can make that
     time). Declared here with a default so color-mix() stays valid on a cell
     nobody has picked — an undefined custom property invalidates the whole
     declaration rather than falling back. */
  --heat: 0;
}

*, *::before, *::after { box-sizing: border-box; }

html {
  /* Rubber-banding stops at the page instead of dragging the whole browser,
     which is most of what makes a web page feel like a document rather than
     an app. */
  overscroll-behavior-y: contain;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--canvas);
  color: var(--primary-text);
  font-family: -apple-system, BlinkMacSystemFont, "PingFang TC", "Noto Sans TC",
               "Segoe UI", system-ui, sans-serif;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  /* No blue flash on tap. Nothing says "web page" louder. */
  -webkit-tap-highlight-color: transparent;
  /* Content must clear the notch and the home indicator. */
  padding: env(safe-area-inset-top) env(safe-area-inset-right)
           env(safe-area-inset-bottom) env(safe-area-inset-left);
}

/* Chrome is not selectable; content is. Long-pressing a button to get a
   selection handle is a web behaviour with no app equivalent, but selecting
   somebody's 都不行 reason to copy it is legitimate. */
button, a.primary, a.ghost, .slot, .tick, h1, h2 {
  user-select: none; -webkit-user-select: none;
}
p, .person, input { user-select: text; -webkit-user-select: text; }

.wrap { max-width: 34rem; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; }

/* The wrappers exist so the desktop block below has something to place. Here
   they are nothing at all: `display: contents` removes the box while keeping
   document order, so the phone renders exactly the boxes it rendered before
   they were introduced. Delete the media query and this is a no-op. */
.col-main, .col-side { display: contents; }

h1 { font-size: 1.6rem; font-weight: 700; margin: 0 0 .35rem; letter-spacing: -.02em; }
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 .6rem; letter-spacing: -.01em; }
p  { margin: 0 0 .8rem; color: var(--secondary-text); }
.muted  { color: var(--tertiary-text); font-size: .85rem; }
.hidden { display: none !important; }

.card {
  background: var(--card-fill-opaque);
  border: 1px solid var(--card-stroke);
  border-radius: 18px;
  padding: 1rem 1.1rem;
  margin-bottom: .9rem;
}

/* Inputs are 16px or larger, without exception: below that, iOS Safari zooms
   the viewport on focus and the layout jumps. */
input[type="text"], input[type="password"] {
  width: 100%;
  font-size: 16px;
  font-family: inherit;
  color: var(--primary-text);
  background: var(--card-fill);
  border: 1px solid var(--card-stroke);
  border-radius: 12px;
  padding: .8rem .9rem;
  -webkit-appearance: none;
  appearance: none;
}
input::placeholder { color: var(--tertiary-text); }
input:focus-visible, button:focus-visible,
a.primary:focus-visible, a.ghost:focus-visible {
  outline: 2px solid var(--tint-diamond);
  outline-offset: 2px;
}

/* The control shape belongs to the CLASS, not to <button>.
 *
 * Five of these are anchors — landing.js's 建立快速投票 (the site's only way
 * into /new), create.js's 我先填 and 現在去管理, manage.js's 回首頁,
 * vote.js's 管理這個投票 — and an <a> matches no `button` rule. With padding,
 * radius, border-style and font-size living here under `button` alone, they
 * kept only what .primary/.ghost declare: 建立快速投票 rendered as an
 * underlined text link on a square unpadded slab of --tint-diamond, and every
 * .ghost anchor got a border-color on the UA's `border-style: none` and so
 * read as body text. Same silent class as var(--diamond): no error anywhere.
 *
 * Listed as `.primary, .ghost` rather than `a.primary, a.ghost` on purpose.
 * `a.ghost` scores (0,1,1) and would outrank `.ghost`'s own
 * `border-color: var(--card-stroke)` at (0,1,0), so this shorthand's
 * `transparent` would win and the ghost border would vanish again. At (0,1,0)
 * source order decides, and .ghost comes after. */
button, .primary, .ghost {
  font-family: inherit; font-size: 16px; font-weight: 600;
  border-radius: 12px; border: 1px solid transparent;
  padding: .8rem 1rem;
  cursor: pointer;
  /* Removes the 300ms tap delay without disabling pinch-zoom on the page. */
  touch-action: manipulation;
  transition: transform .12s ease, opacity .12s ease, background-color .12s ease;
}
/* An <a> is an inline box until told otherwise, and `width: 100%` does nothing
   to one — which is why the padding and the radius never made a shape. */
a.primary, a.ghost {
  display: block;
  text-align: center;
  text-decoration: none;
}
/* The press itself, which is the other half of feeling native. */
button:active:not(:disabled),
a.primary:active, a.ghost:active { transform: scale(.97); }

.primary {
  background: var(--tint-diamond);
  color: var(--canvas);
  width: 100%;
}
.primary:disabled { opacity: .4; cursor: not-allowed; }
.ghost {
  background: transparent; color: var(--secondary-text);
  border-color: var(--card-stroke); width: 100%;
}
.ghost:active:not(:disabled) { background: var(--card-fill); }

/* One slot per row, with a big target: this is used one-handed, on a phone,
   usually while doing something else. */
.slot {
  display: flex; align-items: center; gap: .8rem;
  width: 100%; text-align: left;
  background: var(--card-fill); border: 1px solid var(--card-stroke);
  border-radius: 14px; padding: .9rem; margin-bottom: .5rem;
  color: var(--primary-text); font-weight: 500;
  min-height: 56px;
}
.slot[aria-pressed="true"] {
  border-color: var(--tint-diamond);
  background: color-mix(in srgb, var(--tint-diamond) 16%, transparent);
}
.slot:disabled { opacity: .75; cursor: default; }
.slot .tick {
  width: 24px; height: 24px; border-radius: 999px; flex: none;
  border: 2px solid var(--tertiary-text); display: grid; place-items: center;
  font-size: 13px; font-weight: 700; color: var(--canvas);
  transition: background-color .12s ease, border-color .12s ease;
}
.slot[aria-pressed="true"] .tick {
  background: var(--tint-diamond); border-color: var(--tint-diamond);
}
.slot .when { flex: 1; }
/* 「確定」 beside the decided time — a pill, so it never breaks mid-word. */
.slot .pill-confirmed {
  display: inline-block; margin-left: 0.5em; padding: 0.05em 0.6em;
  border-radius: 999px; font-size: 0.8em; font-weight: 600; white-space: nowrap;
  background: color-mix(in srgb, var(--tint-forest) 18%, transparent);
  color: var(--tint-forest);
}
.slot:disabled { cursor: default; }
.slot:disabled .tick { opacity: 0.45; }
.slot .count {
  color: var(--secondary-text); font-size: .85rem; font-weight: 500;
  flex: none;
}
/* A control INSIDE a slot row — the manage page's 確定這個時間. `.ghost`'s
   `width: 100%` resolves against the whole flex line, which squeezes the time
   and the count down to their min-content floors, or pushes the row into
   overflow on a narrow phone. */
.slot .ghost { width: auto; flex: none; white-space: nowrap; }

.notice {
  border-radius: 12px; padding: .75rem .9rem; margin-bottom: .9rem;
  font-size: .9rem;
  background: color-mix(in srgb, var(--tint-sunset) 16%, transparent);
  border: 1px solid color-mix(in srgb, var(--tint-sunset) 40%, transparent);
  color: var(--primary-text);
}
.saved {
  background: color-mix(in srgb, var(--tint-forest) 16%, transparent);
  border-color: color-mix(in srgb, var(--tint-forest) 40%, transparent);
}

/* 「用 App 開啟」 — only inside Messenger/Instagram, or a LINE that ignored
   openExternalBrowser. One compact row, starting BELOW the theme toggle: it is
   the first thing on the page and full width, and `.theme-toggle` is absolutely
   positioned over exactly that corner (top .75rem + 38px = 50px; this row starts
   at 1.5rem + 2.4rem ≈ 62px). Reported from a phone as 擋住主題切換. */
.app-open {
  display: flex; align-items: center; justify-content: space-between; gap: .6rem;
  margin-top: 2.4rem;
}
.app-open .ghost {
  width: auto; flex: none; white-space: nowrap; margin: 0;
  padding: .4rem .8rem; font-size: .85rem;
}

.person {
  display: flex; justify-content: space-between; gap: .8rem;
  padding: .4rem 0; font-size: .92rem;
}
.person .reason { color: var(--tertiary-text); font-size: .85rem; }

.actions { display: grid; gap: .5rem; margin-top: 1rem; }

/* Who you are voting as, and the way out of it.
   A row rather than a stacked .actions block: this is a status line the reader
   mostly ignores, and giving it a full-width button would make changing your
   name look like the main thing to do on the page. */
/* The invite code on /j/. Big, monospaced and selectable: the whole page
   exists to hand this string over, so it is the one thing that must be
   readable across a room and copyable by hand when the clipboard is refused. */
.invite-code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 2rem; font-weight: 700; letter-spacing: .18em;
  color: var(--primary-text);
  margin: .2rem 0 .9rem;
  user-select: all;
}
.steps { margin: .4rem 0 .2rem 1.1rem; padding: 0; }
.steps li { margin-bottom: .35rem; }

.identity {
  display: flex; align-items: center; gap: .6rem;
  flex-wrap: wrap;
  margin-bottom: .9rem;
}
.identity .who { color: var(--secondary-text); font-size: .85rem; }
.identity button {
  width: auto; margin: 0; padding: .35rem .7rem;
  font-size: .8rem;
}
.spinner { color: var(--tertiary-text); text-align: center; padding: 3rem 0; }

/* Theme switch, top-right. Small on purpose — it is a preference, not an
   action anyone came here to take. */
.theme-toggle {
  position: absolute; top: calc(env(safe-area-inset-top) + .75rem); right: .9rem;
  width: 38px; height: 38px; padding: 0;
  display: grid; place-items: center;
  background: var(--card-fill); border: 1px solid var(--card-stroke);
  border-radius: 999px; color: var(--secondary-text);
  font-size: 16px; font-weight: 400;
}

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; }
  button:active:not(:disabled),
  a.primary:active, a.ghost:active { transform: none; }
  /* The toast is removed on a timer by the page, so suppressing its animation
     must not also suppress its POSITION — without this it renders at the
     element's untransformed origin, half off the left edge. */
  .toast { transform: translate(-50%, 0); opacity: 1; }
}

/* 自由拖拉 grid — days across, hours down.
 *
 * The column count is set INLINE by grid.js from the actual number of days.
 * It must not be `auto-fit` here: that creates as many columns as happen to
 * fit, which collapsed the entire grid into a single row. */
.grid-scroll {
  /* Many days on a narrow phone scroll rather than squeeze to nothing. */
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  margin-top: .6rem;
  padding-bottom: .25rem;
}

.grid {
  /* Phone: fill whatever width there is. The desktop block narrows this so a
     short poll does not get absurdly wide cells. Defaulted here because an
     undefined custom property invalidates the declaration that uses it. */
  --cell-max: 1fr;
  display: grid;
  gap: 2px;
  /* The drag belongs to the grid, not to the page scroller. */
  touch-action: none;
  user-select: none; -webkit-user-select: none;
  min-width: min-content;
}

.grid .corner { }
.grid .dayhead {
  display: flex; flex-direction: column; align-items: center; justify-content: flex-end;
  font-size: .76rem; font-weight: 600; color: var(--secondary-text);
  padding-bottom: .35rem; line-height: 1.2;
  white-space: nowrap;
}
.grid .dayhead .weekday {
  font-size: .66rem; color: var(--tertiary-text); font-weight: 500;
}
.grid .hourhead {
  font-size: .7rem; color: var(--tertiary-text);
  display: flex; align-items: center; justify-content: flex-end;
  padding-right: .45rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.grid .cell {
  height: 32px; border-radius: 6px;
  background: var(--card-fill);
  border: 1px solid var(--card-stroke);
  display: grid; place-items: center;
  font-size: .68rem; font-weight: 700;
  color: var(--secondary-text);
  transition: background-color .08s ease, border-color .08s ease;
  position: relative;
}
/* Other people's availability, scaled to the busiest slot in this poll rather
   than to a fixed ceiling — with a fixed cap, a poll where nobody exceeds two
   renders almost flat. */
.grid .cell[style*="--heat"] {
  background: color-mix(in srgb, var(--tint-forest) calc(var(--heat) * 42%), var(--card-fill));
}
/* Your own choice wins the colour: it must be readable at a glance which cells
   are YOURS and which are merely popular. */
.grid .cell.on {
  background: var(--tint-diamond);
  border-color: var(--tint-diamond);
  color: var(--canvas);
}
/* What the in-progress drag will do, before the finger lifts. */
.grid .cell.preview { outline: 2px solid var(--tint-diamond); outline-offset: -2px; }
.grid .cell.blank { background: transparent; border-color: transparent; }
.grid .cell:focus-visible { outline: 2px solid var(--tint-diamond); outline-offset: 1px; }
.grid .heat-count { pointer-events: none; }

.legend {
  display: flex; gap: 1rem; align-items: center;
  font-size: .74rem; color: var(--tertiary-text);
  margin-top: .3rem;
}
.legend span { display: inline-flex; align-items: center; gap: .35rem; }
.legend .swatch {
  width: 11px; height: 11px; border-radius: 3px; display: inline-block;
}
.legend .swatch.mine { background: var(--tint-diamond); }
.legend .swatch.others {
  background: color-mix(in srgb, var(--tint-forest) 42%, var(--card-fill));
}

/* Who can make this slot — long-press answer, as a bottom sheet because that
   is where a phone expects a transient panel to come from. */
.sheet-backdrop {
  position: fixed; inset: 0; z-index: 20;
  background: rgb(0 0 0 / 45%);
  display: flex; align-items: flex-end; justify-content: center;
  animation: fade-in .16s ease;
}
.sheet {
  width: 100%; max-width: 34rem;
  background: var(--card-fill-opaque);
  border: 1px solid var(--card-stroke);
  border-radius: 20px 20px 0 0;
  padding: .6rem 1.1rem calc(1.1rem + env(safe-area-inset-bottom));
  animation: sheet-up .22s cubic-bezier(.2, .8, .3, 1);
  max-height: 70vh; overflow-y: auto;
}
.sheet-grip {
  width: 36px; height: 4px; border-radius: 999px;
  background: var(--card-stroke);
  margin: .2rem auto .8rem;
}

/* The confirmation, as a banner from the top.
 *
 * Was a 92px tick in the middle of the screen, which was far too loud for what
 * it is — a receipt, not an event. This borrows the shape people already read
 * as "noted": it comes down, says one thing, and leaves, without covering the
 * grid the user is still looking at. */
.toast {
  position: fixed; z-index: 30;
  top: calc(env(safe-area-inset-top) + .6rem);
  left: 50%;
  display: flex; align-items: center; gap: .5rem;
  padding: .6rem .95rem;
  border-radius: 999px;
  font-size: .9rem; font-weight: 600;
  color: var(--primary-text);
  background: var(--card-fill-opaque);
  border: 1px solid var(--card-stroke);
  box-shadow: 0 8px 28px rgb(0 0 0 / 28%);
  /* Never blocks a tap: it is telling, not asking. */
  pointer-events: none;
  white-space: nowrap;
  /* Down, hold, back up. The hold is what makes it readable rather than a
     flicker; the retract is what makes it feel like it went somewhere. */
  animation: toast-in .3s cubic-bezier(.2, .9, .3, 1.15),
             toast-out .28s cubic-bezier(.4, 0, .8, .4) 1.25s forwards;
}
.toast-tick {
  width: 20px; height: 20px; border-radius: 999px; flex: none;
  display: grid; place-items: center;
  font-size: 12px; font-weight: 700;
  background: var(--tint-forest); color: var(--canvas);
}

@keyframes toast-in {
  from { transform: translate(-50%, calc(-100% - 1.2rem)); opacity: 0 }
  to   { transform: translate(-50%, 0); opacity: 1 }
}
@keyframes toast-out {
  from { transform: translate(-50%, 0); opacity: 1 }
  to   { transform: translate(-50%, calc(-100% - 1.2rem)); opacity: 0 }
}

/* The grid's date row is draggable to pan, so say so with the cursor. */
.grid .dayhead, .grid .corner { cursor: grab; }
.grid .dayhead:active, .grid .corner:active { cursor: grabbing; }

/* The outcome. A confirmed poll is the successful ending, so it reads as an
   answer rather than as one more notice among the refusals. */
.card.result {
  border-color: color-mix(in srgb, var(--tint-forest) 45%, transparent);
  background: color-mix(in srgb, var(--tint-forest) 10%, var(--card-fill-opaque));
}
.result-flag {
  display: inline-block;
  font-size: .72rem; font-weight: 700; letter-spacing: .04em;
  color: var(--tint-forest);
  margin-bottom: .35rem;
}
.result-when {
  font-size: 1.45rem; font-weight: 700; letter-spacing: -.02em;
  color: var(--primary-text);
  margin-bottom: .2rem;
}
.result-people { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .5rem; }
.chip {
  font-size: .8rem; font-weight: 500;
  padding: .22rem .6rem; border-radius: 999px;
  background: var(--card-fill); border: 1px solid var(--card-stroke);
  color: var(--secondary-text);
}

/* The place line inside a 備註 block. Tertiary rather than secondary: the note
   itself is what someone stopped to read, and the place is a label on it. */
/* The comment thread on /v/, read-only, laid out like a chat: one column of
   left-aligned bubbles (a guest has no "me" here), the author's name only above
   the first bubble of a run, and a centred time stamp after a long gap.
   Run and separator boundaries are decided by chat.js, not here. */
.chat { display: flex; flex-direction: column; }
.chat-sep {
  align-self: center; margin: .7rem 0 .35rem;
  color: var(--tertiary-text); font-size: .75rem;
}
.chat-msg { display: flex; flex-direction: column; align-items: flex-start; margin-top: 2px; }
.chat-msg.first { margin-top: .5rem; }
.chat-sep + .chat-msg.first { margin-top: 0; }
.chat-author { color: var(--tertiary-text); font-size: .75rem; margin: 0 0 2px .9rem; }
.bubble {
  max-width: 80%; padding: .5rem .85rem;
  border-radius: 18px;
  /* Filled, no outline — Messages' bubbles, not chips. */
  background: color-mix(in srgb, var(--primary-text) 8%, transparent); border: 0;
  color: var(--primary-text); font-size: .95rem; line-height: 1.35;
  white-space: pre-wrap; overflow-wrap: anywhere;
  user-select: text; -webkit-user-select: text;
}
.chat-edited { color: var(--tertiary-text); font-size: .7rem; margin: 2px 0 0 .9rem; }
.chat-foot { margin: .9rem 0 0; text-align: center; }

/* The countdown and the two calendar buttons on a confirmed poll's result. */
.result-countdown {
  font-size: 1rem; font-weight: 600; color: var(--tint-forest);
  margin: 0 0 .5rem; font-variant-numeric: tabular-nums;
}
.result-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .8rem; }
.result-actions > .ghost { flex: 1 1 8rem; width: auto; white-space: nowrap; }
.card.personal-link input { margin: .2rem 0 0; font-size: 14px; }
.card input + input { margin-top: .6rem; }
.field-hint { margin: .5rem 0 0; }
.pw-note { margin: 0 0 .9rem; }
.card.collision h2, .card.join-offer h2 { margin-bottom: .4rem; }

.note-place {
  color: var(--tertiary-text);
  margin-bottom: .4rem;
}

/* The winning slot, in both the list and the grid. */
.grid .cell.won {
  box-shadow: inset 0 0 0 2px var(--tint-forest);
  font-weight: 800;
}

/* --- the create form ------------------------------------------------ */

.field {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  margin-bottom: var(--stack-spacing);
}
.field > span { color: var(--secondary-text); font-size: 0.9rem; }
.field.row { flex-direction: row; align-items: center; gap: 0.6rem; }
.field.row > span { color: var(--primary-text); font-size: 1rem; }

/* 16px minimum, or iOS Safari zooms the page on focus. A date input also
   renders at the OS's intrinsic locale width and ignores width: 100%, which is
   why each one gets its own row rather than sharing one with a time. */
.field input,
.field textarea,
.slot-row input {
  font-size: 16px;
  padding: 0.7rem 0.8rem;
  color: var(--primary-text);
  background: var(--card-fill-opaque);
  border: var(--stroke-width) solid var(--card-stroke);
  border-radius: calc(var(--card-radius) / 2);
  min-height: 44px;
}
.field textarea { font-family: inherit; resize: vertical; }
.field input[type="checkbox"] { min-height: 0; width: 1.2rem; height: 1.2rem; }

.slot-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: var(--stack-spacing);
}
.slot-row input { flex: 1 1 auto; }

.seg { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: var(--stack-spacing); }
.seg-option {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  text-align: start;
  padding: var(--card-padding);
  color: var(--primary-text);
  background: var(--card-fill);
  border: var(--stroke-width) solid var(--card-stroke);
  border-radius: var(--card-radius);
}
.seg-option.on { border-color: var(--tint-diamond); }

.linkbox {
  font-family: ui-monospace, monospace;
  font-size: 0.85rem;
  word-break: break-all;
  padding: 0.7rem 0.8rem;
  color: var(--secondary-text);
  background: var(--card-fill-opaque);
  border-radius: calc(var(--card-radius) / 2);
  margin-bottom: var(--stack-spacing);
}

/* --- the manage page ------------------------------------------------ */

.slot.on { border-color: var(--tint-forest); }
.danger { color: var(--tint-sunset); }
.primary.danger { background: var(--tint-sunset); color: var(--canvas); }

/* The support/privacy contact line. Monospace so an address is unambiguous —
   a reader has to be able to retype it correctly. */
.contact {
  font-family: ui-monospace, monospace;
  color: var(--primary-text);
  word-break: break-all;
}

/* --- the desktop layout ---------------------------------------------
   One breakpoint. Below it, everything above this comment, unchanged.

   60rem because two readable columns need about 30rem each plus a gutter;
   splitting any earlier gives two cramped columns instead of one comfortable
   one, which is worse than not splitting.

   Four shapes come out of this block, and only one of them is two columns:

     1. the shared split, 68rem, 1.6fr/1fr with a sticky side — `/m` only;
     2. a narrow single column, 44rem — `/new`, a fixed `/v/`, and the
        wrapper-less screens (loading, 找不到這個投票, `/m`'s error and
        deleted states);
     3. a wide single column, 68rem — a freeform `/v/`, via `:has(.grid-scroll)`;
     4. the landing page's own shape — one 44rem column with its two cards
        laid side by side inside `.landing-cards`.

   `/v/` used to take shape 1 in fixed mode. It no longer splits at all: the
   side column could only ever hold the 都不行 roster, which is null on the
   common poll, and it appeared mid-session the moment the person reading the
   page became the first decliner. See the note in vote.js's `draw()`. */
@media (min-width: 60rem) {
  .wrap {
    max-width: 68rem;
    display: grid;
    /* minmax(0, …) rather than a bare fr: a grid item's default min-width is
       auto, which refuses to shrink below its content, and the drag grid's
       content is wide — without this the left column pushes the right one off
       the screen. */
    grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
    gap: 0 2.5rem;
    align-items: start;
  }

  .col-main, .col-side { display: block; }

  /* The single-column fallback: every page that emits no `.col-side` at all.
     That selector matches `/new`, BOTH modes of `/v/`, and the wrapper-less
     screens — whose bare `h1`/`p`/`a` become direct items of this grid rather
     than of a `.col-main` (benign: the row gap is 0 and every one of those
     elements already has `margin-top: 0`, so the spacing is what block flow
     gave). A freeform `/v/` keeps the one column and takes its `max-width`
     back to 68rem below, at `.wrap:has(.grid-scroll)`.

     There is deliberately NO `.col-side:empty` companion here any more. It
     existed for `/v/`'s roster, the one side column that could come back
     empty — and `/v/` no longer emits one. The two that remain cannot be
     empty: `/m`'s always holds `danger()`, which returns a card
     unconditionally, and the landing page's always holds three children. A
     selector that can never match is the same silent class of defect as a
     mistyped custom property, so it is gone rather than kept just in case. */
  .wrap:not(:has(.col-side)) {
    grid-template-columns: minmax(0, 1fr);
    max-width: 44rem;
  }

  /* A column may be wider than a LINE should be. 34rem is about a
     65-character measure, and a paragraph stretched even across the 41.5rem
     of the narrow single column above is harder to read, not easier.
     `.col-side > p` reaches the landing page's closing footnote; `/m`'s side
     column has no direct `p` child, only cards. */
  .col-main > h1,
  .col-main > p,
  .col-side > p { max-width: 34rem; }

  /* …except the one that holds a button rather than a sentence. `/privacy`
     and `/support` end on a full-width 回首頁 ghost link wrapped in a `p`,
     which took the measure above and rendered 544px wide under 664px cards —
     a ragged right edge at the bottom of the two pages an App Store reviewer
     opens. Marked by class rather than matched with `:not(:has(> a))` so the
     prose rule keeps working on a browser with no `:has()`, where every
     selector naming it drops out of the cascade entirely. */
  .col-main > p.page-foot { max-width: none; }

  /* `/m`'s controls stay in view while a long candidate-time list scrolls; a
     non-sticky second column is just a narrower page. `/m` is now the only
     route with a real second column — `/v/`'s roster moved back into
     `.col-main` — and the landing page, whose `.col-side` is a row of cards
     rather than a sidebar, opts back out below at `.landing-cards`. */
  .col-side { position: sticky; top: 1.5rem; }

  /* 72px stops a three-day poll stretching its columns to 300px; it is a
     starting value, not a measured one — and so is centring the leftover
     space rather than raising it. `minmax(46px, 72px)` tracks pack to the
     grid's start edge (`justify-content: normal`) no matter how much wider
     the container is, so a poll of 7 days or fewer renders at the SAME pixel
     width at 68rem as it did at the old 44rem and just gets more empty card
     beside it — only 8+ days actually gains width from this breakpoint.
     Centring turns that leftover space into a symmetric margin instead of a
     grid that reads as stuck to the left edge; whether 72px itself should
     rise is still an unverified guess and stays on the human's browser
     checklist rather than being guessed at twice. */
  .grid { --cell-max: 72px; justify-content: center; }

  /* The grid is the one element that genuinely wants the width — but no `/v/`
     emits a `.col-side` (see the note in vote.js's `draw()`), so without this
     a freeform poll falls into `.wrap:not(:has(.col-side))` above and lands
     on the site's NARROWEST desktop width instead of its widest. This rule
     matches the one above on specificity — both are (0,2,0) — and wins on
     source order, so it must stay AFTER it.
     `grid-column: 1 / -1` cannot do this job: it only applies to a
     grid ITEM, and `.grid-scroll` sits inside `.card` inside `.col-main`,
     which is a plain block box at this width, not a grid — the declaration
     was silently inert. Overriding `max-width` here instead — later in the
     cascade than the rule above, and now more specific than it too — gives
     the grid page the widest single column on the site.

     `[data-wide]` rather than a bare `.grid-scroll`: this fired for EVERY
     freeform poll, and a browser showed what that costs. `--cell-max` caps
     the tracks at 72px, so a span of seven days or fewer measures the same
     at 68rem as at 44rem (see the arithmetic above) — it just gained 24rem
     of empty card beside it, with a three-day grid marooned in the middle of
     a 1046px box. grid.js sets the attribute on the spans that do not fit
     the narrow column; only those take this width now. */
  .wrap:has(.grid-scroll[data-wide]) { max-width: 68rem; }

  /* `/new`'s candidate times are deliberately NOT two per row.
   *
   * There was a rule here that made `.card:has(.slot-row)` a two-column grid,
   * on the reasoning that eight candidate times should stop being a long
   * scroll. Measured, it made the rows NARROWER than a phone's: `/new` has no
   * `.col-side`, so it takes the 44rem single column — 704px, less 1.25rem of
   * `.wrap` padding each side, less `.card`'s 1.1rem padding and 1px border
   * each side, is 626.8px of content; two tracks with a 16px gutter is 305.4px
   * each, against 312.8px for the same row on an iPhone 14.
   *
   * A `.slot-row` is `flex-wrap: wrap` around a date input, a time input and a
   * 44px ✕, and a date input renders at the OS's intrinsic locale width and
   * ignores `width: 100%` — the standing rule in CLAUDE.md. At 626.8px all
   * three fit on one line; at 305.4px they wrap onto two. So eight rows took
   * roughly the same total height either way, and WHETHER they wrapped
   * depended on the reader's OS and locale. The stated benefit was not
   * delivered, so the rule is gone and each row gets the full column.
   *
   * `web_styles.test.ts` asserts its absence rather than merely not asserting
   * its presence, because "put the slot rows two per row above 60rem" is a
   * plausible-looking idea that has already shipped twice — once as width
   * arithmetic, once as a real grid — and been wrong both times. */

  /* The landing page does not share the split's proportions with `/m`: its
     `.col-main` holds only a hero (one heading, one line), while
     its `.col-side` holds both cards AND the site's only entry into `/new`
     — the 1.6fr/1fr split above would give the empty-looking column most of
     the width and cram everything actionable into the narrow one.
     landing.js marks its `.col-side` with an ADDITIONAL `.landing-cards`
     class (not instead of `.col-side` — the phone layout and document order
     are untouched) so this one page can leave the shared split for its own
     shape: hero on top in the now-single `.col-main` column, the two cards
     side by side beneath it, the footnote spanning both. Only `.col-side`
     needs a marker: once the override below collapses the split to one
     column, `.col-main` is already full width with no further rule to add. */
  .wrap:has(.landing-cards) {
    grid-template-columns: minmax(0, 1fr);
    max-width: 44rem;
  }
  /* Compounded with `:has()` again, rather than left as a bare
     `.landing-cards` selector: the rule above already drops out entirely in
     a browser without `:has()` support — an unsupported pseudo-class
     invalidates the whole selector — but a bare `.landing-cards { display:
     grid }` would NOT drop with it. That browser would keep the shared
     1.6fr/1fr split (hero in the wide left column) while ALSO laying the two
     cards side by side inside the narrow ~24.5rem right one, roughly 184px
     each — worse than before this page had its own shape at all. The two
     halves must fail together, so both carry the same `:has()` guard. */
  .wrap:has(.landing-cards) .landing-cards {
    /* Nothing here should stay pinned while the page scrolls — that reads as
       a mistake on a page with no long list to keep controls beside. */
    position: static;
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--gutter);
  }
  .wrap:has(.landing-cards) .landing-cards > .muted { grid-column: 1 / -1; text-align: center; }
  /* One closing band now (was two cards side by side) — it spans the row. */
  .wrap:has(.landing-cards) .landing-cards > .band { grid-column: 1 / -1; }
  /* Screenshot beside its sentence, alternating sides. */
  .feature { grid-template-columns: 1fr 15rem; align-items: center; text-align: left; gap: 2.5rem; }
  .feature:nth-of-type(even) { grid-template-columns: 15rem 1fr; }
  .feature:nth-of-type(even) .feature-text { order: 2; }
  .feature img { width: 15rem; }
}

/* --- The landing page -------------------------------------------------------
   A marketing page: a big headline, two calls to action, and three sections that
   each pair one sentence with one app screenshot. The screenshots are the
   app's own (dark-only), so they carry a hairline and a radius to sit on the
   light theme as well. Layout above 60rem lives in the desktop block. */
.hero-title {
  font-size: clamp(2rem, 8vw, 2.9rem); line-height: 1.12; font-weight: 800;
  letter-spacing: -.03em; text-align: center; margin: 3rem auto .8rem;
}
.hero-sub { text-align: center; font-size: 1.05rem; margin: 0 auto 1.4rem; max-width: 26rem; }
.hero-actions { display: grid; gap: .6rem; max-width: 20rem; margin: 0 auto 2.6rem; }
.pill {
  display: block; text-align: center; padding: .6rem 1rem;
  border-radius: 999px; border: 1px solid var(--card-stroke);
  background: var(--card-fill); color: var(--tertiary-text);
  font-size: .9rem; font-weight: 600;
}
.feature { display: grid; gap: 1rem; justify-items: center; text-align: center; margin: 0 0 2.8rem; }
.feature-kicker {
  margin: 0 0 .2rem; font-size: .8rem; font-weight: 700; letter-spacing: .06em;
  color: var(--tint-diamond);
}
.feature h2 { font-size: 1.6rem; font-weight: 800; letter-spacing: -.02em; margin: 0 0 .4rem; }
.feature p { margin-bottom: 0; }
.feature img {
  width: min(15rem, 70%); height: auto; display: block;
  border-radius: 28px; border: 1px solid var(--card-stroke);
}
.card.band { text-align: center; }

/* --- The /new page's day calendar -----------------------------------------
   Tap a day to choose it; tap a weekday heading to take the whole month.
   Replaced two <input type="date"> fields, which is why a poll's days used to
   have to be adjacent. Tap only — no drag; see calendar.js for why. */
.calendar { display: flex; flex-direction: column; gap: .6rem; }
.calendar-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: .6rem;
}
.calendar-head .linkish {
  background: none; border: 0; color: var(--primary-text);
  font-size: 1.2rem; line-height: 1; padding: .2rem .6rem; cursor: pointer;
}
.calendar-grid {
  display: grid; grid-template-columns: repeat(7, 1fr); gap: .25rem;
}
.calendar-weekday {
  background: none; border: 0; color: var(--secondary-text);
  font-size: .82rem; padding: .3rem 0; cursor: pointer;
}
.calendar-pad { display: block; }
.calendar-day {
  /* 44px is the smallest comfortable touch target, and these are the only
     controls on the page somebody taps repeatedly. */
  min-height: 44px;
  border: 0; border-radius: 999px; cursor: pointer;
  background: var(--card-fill); color: var(--primary-text);
  font-size: .95rem;
}
.calendar-day.on { background: var(--primary-text); color: var(--canvas); font-weight: 700; }
.calendar-day.past { color: var(--tertiary-text); cursor: default; }

/* 043 — the 吃什麼 card, 轉盤 banner, guest comment composer. Only variables
   that exist in :root. */
.food-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .5rem; }
.food-row .food-text { flex: 1; color: var(--primary-text); font-weight: 500; overflow-wrap: anywhere; }
.food-row.decided .food-text { color: var(--tint-forest); }
.stance {
  background: var(--card-fill); border: 1px solid var(--card-stroke);
  color: var(--primary-text); border-radius: 999px; padding: .4rem .8rem;
  min-height: 40px; font-variant-numeric: tabular-nums;
}
.stance[aria-pressed="true"] {
  border-color: var(--tint-diamond);
  background: color-mix(in srgb, var(--tint-diamond) 16%, transparent);
}
.stance:disabled { opacity: .6; cursor: default; }
.food-decision, .draw-banner strong { font-weight: 600; color: var(--primary-text); }
.chat-msg.mine { align-items: flex-end; }
.chat-msg.mine .bubble {
  /* Same text-on-tint rule as .primary: the canvas colour reads on the tint
     in both schemes (near-white on the darker light tint, near-black on the
     brighter dark one). */
  background: var(--tint-diamond);
  color: var(--canvas);
}
.chat-msg.mine .bubble .reply-quote {
  color: color-mix(in srgb, var(--canvas) 85%, transparent);
  border-left-color: color-mix(in srgb, var(--canvas) 55%, transparent);
}
.reply-quote {
  display: flex; flex-direction: column; gap: 1px; margin-bottom: .3rem;
  padding-left: .5rem; border-left: 3px solid var(--card-stroke);
  color: var(--secondary-text); font-size: .8rem;
}
.reply-quote.missing { font-style: italic; }
.chat-actions { display: flex; gap: .8rem; margin: 2px .9rem 0; }
.linkish {
  background: none; border: 0; padding: .2rem 0; min-height: 0;
  color: var(--tertiary-text); font-size: .75rem; font-weight: 600;
}
.linkish:hover { color: var(--secondary-text); }
.comment-form { margin-top: 1rem; }
/* One rounded field with the send button inside, like Messages. */
.composer {
  display: flex; align-items: flex-end; gap: .4rem;
  padding: .3rem .3rem .3rem .9rem;
  border: 1px solid var(--card-stroke); border-radius: 22px;
  background: var(--canvas);
}
.composer:focus-within { border-color: var(--tint-diamond); }
.composer textarea {
  flex: 1; min-height: 0; max-height: 10rem; padding: .45rem 0; margin: 0;
  border: 0; outline: none; background: transparent; resize: none;
  font: inherit; color: var(--primary-text); line-height: 1.35;
}
.composer .send {
  flex: none; width: 2.1rem; height: 2.1rem; min-height: 0; padding: 0;
  border: 0; border-radius: 50%;
  background: var(--tint-diamond); color: var(--canvas); font-size: 1.05rem; font-weight: 700;
}
.composer-foot {
  display: flex; justify-content: space-between; gap: .5rem;
  margin: .4rem .9rem 0; color: var(--tertiary-text); font-size: .75rem;
}
.comment-error { margin: .4rem .9rem 0; color: var(--tint-sunset); font-size: .8rem; }
/* 「用 App 開」 — a centred card over a dimmed page, like Google Maps'. */
.app-prompt-backdrop {
  position: fixed; inset: 0; z-index: 50;
  display: flex; align-items: center; justify-content: center; padding: var(--gutter);
  background: rgb(0 0 0 / 45%);
}
.app-prompt {
  width: min(22rem, 100%); padding: 1.6rem 1.4rem 1.2rem; text-align: center;
  border-radius: var(--card-radius); background: var(--card-fill-opaque);
  box-shadow: 0 12px 40px rgb(0 0 0 / 25%);
}
.app-prompt-icon { border-radius: 14px; margin: 0 auto .8rem; display: block; }
.app-prompt h2 { margin: 0 0 .7rem; }
.app-prompt ul { list-style: none; padding: 0; margin: 0 0 1.2rem; color: var(--secondary-text); text-align: left; display: inline-block; }
.app-prompt li::before { content: '✓  '; color: var(--tint-diamond); font-weight: 700; }
.app-prompt-actions { display: grid; gap: .6rem; }
.app-prompt-actions .primary { display: block; text-decoration: none; }
.app-prompt-hint { margin: .8rem 0 0; font-size: .75rem; }
/* A button row sitting directly above the next card needs the same gap the
   cards have between themselves (都不行 used to touch the card below it). */
.actions + .card, .actions + .notice { margin-top: var(--stack-spacing); }
.reply-banner {
  display: flex; justify-content: space-between; align-items: center; gap: .5rem;
  margin-bottom: .4rem; color: var(--secondary-text); font-size: .8rem;
}
