:root {
  --black: #111111;
  --dark: #2a2a2a;
  --mid: #6b6b6b;
  --line: #bcbcbc;
  --light: #e9e9e9;
  --peer-bg: #ffede7; /* very light coral: row + column + box of the focused cell */
  --ink: #0047ab; /* saturated cobalt: digits the player entered, vs black givens */
  --cursor-green: #7ddc96; /* light green: the "Solid Green" cursor option */
  --pad-dim: #979797; /* candidate-edit pad: digits that are not candidates */
  --hint-purple: #8b2fe0; /* hint outlines: the squares a step reasons about */
  --hint-purple-dim: #b57ae0; /* softer, for the squares it rules candidates out of */
  --hint-bg: #f2e6fd; /* light purple wash under an outlined hint square */
  --hint-bg-dim: #faf3ff; /* fainter wash for ruled-out squares */
  --conflict-bg: #fcdada; /* light red wash behind digits clashing with a peer */
  --white: #ffffff;
  --accent-bg: #d8d8d8;
  --board-size: min(92vw, 598px);
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--white);
  color: var(--black);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  -webkit-tap-highlight-color: transparent;
  overscroll-behavior: none;
}

/* Stacking order, lowest first — the whole scale in one place, since these values
   only make sense relative to each other:
     1000  burger drawer scrim
     1001  burger drawer
     1002  burger button (above its own drawer, so it still closes it)
     1100  dialogs (settings, instructions, win, reset) — above the drawer and its
           button, so a dialog is never overlapped by the burger
     1101  fireworks, above the win dialog it celebrates */

/* Win animation. Hidden until it fires so the canvas never eats a stray paint.
   Sits above the win dialog so the burst is not dimmed by its scrim;
   pointer-events:none keeps the Close button clickable through it. */
#fireworks-canvas {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 1101;
  display: none;
}
#fireworks-canvas.firing { display: block; }

#app {
  max-width: 1300px;
  margin: 0 auto;
  padding: 16px 16px 40px;
}

.topbar {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  margin-bottom: 14px;
  position: relative; /* anchors the burger to the top-left corner */
}

/* The burger only exists on phones — see the max-width: 699px block, which is
   also where .toolbar becomes the drawer it opens. */
.menu-btn { display: none; }
.menu-scrim { display: none; }

.topbar h1 {
  font-size: 1.6rem;
  letter-spacing: 0.02em;
  margin: 0;
  font-weight: 700;
}

/* The logo and its Instructions button travel together as one column, so the
   alignment that used to sit on the image now sits on the wrapper. */
.logo-col {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  align-self: flex-start;
  margin-right: 20px;
}

.logo {
  height: 192px;
  width: auto;
}

/* Narrow screens drop the artwork but keep the button — the tap gestures it
   explains are exactly the ones a touch device uses, so this is where it is most
   worth reaching. The column stops reserving space and centres over the board. */
@media (max-width: 899px) {
  .logo { display: none; }
  .logo-col { align-self: center; margin-right: 0; }
}

/* Toolbar: daily / generate / import-export */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 18px;
  margin-bottom: 20px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--line);
}

.toolbar-group {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.toolbar-label {
  font-size: 0.8rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--mid);
  margin-right: 2px;
}

#date-input {
  font-family: inherit;
  font-size: 0.9rem;
  padding: 6px 8px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--white);
  color: var(--black);
}

.difficulty-buttons {
  display: flex;
  gap: 6px;
}

.diff-btn {
  font-family: inherit;
  font-size: 0.9rem;
  padding: 7px 14px;
  border: 1px solid var(--black);
  background: var(--white);
  color: var(--black);
  border-radius: 20px;
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}

/* :hover for this one is in the hover query further down, with the other
   touch-latching buttons. */
.diff-btn.active {
  background: var(--black);
  color: var(--white);
}

/* Game layout */
.game-layout {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 28px;
}

.board-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  width: var(--board-size);
}

.board-topline {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 8px;
  width: 100%;
}

/* Pushed to the right edge so a destructive action sits away from the others,
   while the timer and its neighbours stay centred over the board. */
.board-reset { margin-left: auto; }

.timer {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: 1.1rem;
  padding: 6px 10px;
  border: 1px solid var(--black);
  border-radius: 8px;
  min-width: 4.5em;
  text-align: center;
}

.board-frame {
  transition: filter 0.2s;
}

.board-frame.paused {
  filter: blur(10px);
  pointer-events: none;
}

.board {
  width: var(--board-size);
  height: var(--board-size);
  display: grid;
  grid-template-columns: repeat(9, 1fr);
  grid-template-rows: repeat(9, 1fr);
  background: var(--black);
  border: 4px solid var(--black);
  gap: 1px;
}

.cell {
  background: var(--white);
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  font-size: calc(var(--board-size) / 18);
  font-weight: 700;
  cursor: pointer;
  color: var(--black);
  user-select: none;
  /* pan-y, not none: the browser still owns vertical drags, so a swipe starting
     on the board scrolls the page. It keeps double-tap-to-zoom suppressed, which
     is what the tap gestures need — onCellPointerDown's own preventDefault stops
     the rest. */
  touch-action: pan-y;
}

.cell.given { cursor: default; }

/* Givens keep the black from .cell; anything the player enters is navy ink.
   Declared before .cell.conflict so a conflicting digit still turns red. */
.cell:not(.given) { color: var(--ink); }

.cell.selected {
  z-index: 1;
  animation: select-pulse 1.2s ease-in-out infinite;
}

@keyframes select-pulse {
  0%, 100% { box-shadow: inset 0 0 0 2px rgba(17,17,17,0.15); }
  50% { box-shadow: inset 0 0 0 4px rgba(17,17,17,0.55); }
}

/* Cursor type "Solid Green": a steady 4px inner border instead of the pulse.
   :not(.promoting) so this does not cancel the promotion shimmer — the promoted
   square is always the selected one, and this selector would otherwise win. */
.board.cursor-solid .cell.selected:not(.promoting) {
  animation: none;
  box-shadow: inset 0 0 0 4px var(--cursor-green);
}

.cell.peer { background: var(--peer-bg); }
.cell.same-value:not(.selected) { background: var(--accent-bg); }

/* Squares the current hint step reasons about. The outline sits at the cell edge
   rather than 3px in, because the candidate marks now start 1px in. A purple wash
   behind the square does most of the work of drawing the eye; the dashes then only
   have to say where the square ends. Declared after .peer and .same-value so the
   hint wins over whatever tint those would have applied. */
.cell.hint-cell { background: var(--hint-bg); }
.cell.hint-cell::after {
  content: "";
  position: absolute;
  inset: 0;
  border: 2px dashed var(--hint-purple);
  border-radius: 3px;
  pointer-events: none;
}

/* Squares the step rules candidates out of — a paler version of the same wash, so
   they read as the consequence of the squares above rather than as peers of them.
   A square can carry both classes (it is reasoned about AND loses a candidate);
   :not(.hint-cell) keeps the stronger dashed style winning there, which source
   order alone would not do. */
.cell.hint-elim:not(.hint-cell) { background: var(--hint-bg-dim); }
.cell.hint-elim:not(.hint-cell)::after {
  content: "";
  position: absolute;
  inset: 0;
  border: 2px dotted var(--hint-purple-dim);
  border-radius: 3px;
  pointer-events: none;
}

/* A clash is a mistake to fix, so it outranks every tint above — peer shading, the
   same-value highlight and the hint washes all give way to it. Last in the file for
   that reason. Background only: the wash carries it on its own, and leaving the
   digit its usual colour keeps given-vs-entered readable in a clashing square. */
.cell.conflict { background: var(--conflict-bg); }

/* thicker box borders */
.cell:nth-child(9n+1) { border-left: none; }
.board .cell:nth-child(3n+1):not(:nth-child(9n+1)) { border-left: 3px solid var(--black); }
.board .cell:nth-child(n+28):nth-child(-n+36),
.board .cell:nth-child(n+55):nth-child(-n+63) { border-top: 3px solid var(--black); }

.cell {
  border-right: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}

/* The inset is tight (1px) to buy the marks room: at /42 the glyphs are a point
   larger than the slot grid would otherwise fit. INSET in app.js's
   animatePromotion must match, or the flyer starts off its slot's centre. */
.candidate-marks {
  position: absolute;
  inset: 1px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  font-size: calc(var(--board-size) / 42);
  font-weight: 400;
  color: var(--mid);
  pointer-events: none;
}
.candidate-marks span {
  display: flex;
  align-items: center;
  justify-content: center;
}
/* Marks the user pencilled in: navy ink, like their entered digits. */
.candidate-marks.own-marks { color: var(--ink); font-weight: 600; }

/* Promoting a lone candidate to the square's value. A copy of the mark flies
   from its slot in the 3x3 grid to the centre, growing to full digit size, while
   the square shimmers. Both timings match PROMOTE_ANIM_MS in app.js. */
.promo-flyer {
  position: absolute;
  left: 0;
  top: 0;
  /* The real digit is hidden underneath until the flyer lands, so the two never
     show at once. */
  color: var(--ink);
  font-weight: 700;
  line-height: 1;
  pointer-events: none;
  z-index: 2;
  animation: promo-fly 780ms cubic-bezier(0.34, 1.4, 0.64, 1) forwards;
}

@keyframes promo-fly {
  from {
    /* translate(-50%,-50%) centres the glyph on the point it is travelling between. */
    transform: translate(var(--from-x), var(--from-y)) translate(-50%, -50%) scale(0.34);
    opacity: 0.75;
  }
  /* Overshoots slightly before settling, so the landing has some weight. */
  70% {
    transform: translate(var(--to-x), var(--to-y)) translate(-50%, -50%) scale(1.22);
    opacity: 1;
  }
  to {
    transform: translate(var(--to-x), var(--to-y)) translate(-50%, -50%) scale(1);
    opacity: 1;
  }
}

/* The cell's own digit is a bare text node, so it cannot be targeted directly —
   transparent text hides it for the flight while the flyer stands in for it.
   The selector carries .board to outrank the cursor rules above, which target
   .cell.selected — always also the promoted cell — and would otherwise replace
   this animation with their own. */
.board .cell.promoting {
  color: transparent;
  animation: promo-shimmer 780ms ease-out;
  /* Lifts the shimmering square above its neighbours so the glow is not clipped
     by their backgrounds. */
  z-index: 2;
}

@keyframes promo-shimmer {
  0% {
    background: var(--white);
    box-shadow: inset 0 0 0 rgba(0, 71, 171, 0);
  }
  30% {
    background: #bcd3ff;
    box-shadow: inset 0 0 0 2px rgba(0, 71, 171, 0.55), 0 0 22px 6px rgba(0, 71, 171, 0.4);
  }
  55% {
    background: #d8e6ff;
    box-shadow: inset 0 0 0 2px rgba(0, 71, 171, 0.35), 0 0 16px 3px rgba(0, 71, 171, 0.26);
  }
  100% {
    background: var(--white);
    box-shadow: inset 0 0 0 rgba(0, 71, 171, 0);
  }
}

/* Respect a reduced-motion preference: app.js skips the flyer, and the shimmer
   is dropped here so nothing animates. */
@media (prefers-reduced-motion: reduce) {
  .promo-flyer { animation: none; }
  .board .cell.promoting { animation: none; color: var(--ink); }
}

.hint-panel {
  min-height: 1.4em;
  text-align: center;
  font-size: 0.9rem;
  color: var(--dark);
}

/* Advances a multi-step hint; only present while such a hint is open. */
#hint-next-btn.hidden { display: none; }

.action-btn {
  font-family: inherit;
  font-size: 0.85rem;
  padding: 8px 14px;
  border: 1px solid var(--black);
  background: var(--white);
  color: var(--black);
  border-radius: 20px;
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}

.action-btn:disabled { opacity: 0.5; cursor: default; }
.action-btn.active-toggle { background: var(--black); color: var(--white); }
/* See the hover query near .num-btn: a touch latches :hover on the tapped
   button, so hover feedback is limited to devices that really hover. */
@media (hover: hover) {
  .action-btn:hover:not(:disabled) { background: var(--light); }
}

/* Side panel: colors, numpad, hint */
.side-panel {
  display: flex;
  flex-direction: column;
  gap: 14px;
  width: calc(var(--board-size) * 0.9);
}

.numpad {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* Erase / Undo / Redo / Fill Candidates / Hint. Its own element so a phone can
   stand it beside the digit pad as a second column; on wider screens it is a
   transparent wrapper that keeps the panel's single-column stack. */
.panel-actions {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.num-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  gap: 8px;
}

.num-btn {
  font-family: inherit;
  font-size: 1.7rem;
  font-weight: 700;
  aspect-ratio: 1;
  border: 1px solid var(--black);
  background: var(--white);
  color: var(--black);
  border-radius: 10px;
  cursor: pointer;
  transition: background 0.15s, color 0.15s, transform 0.05s;
}

.num-btn:active { transform: scale(0.94); background: var(--black); color: var(--white); }

/* Inactive while no square is selected. */
.num-btn:disabled, .wide-btn:disabled { opacity: 0.35; cursor: default; }
.num-btn:disabled:active { transform: none; background: var(--white); color: var(--black); }

/* Candidate-edit mode: digits act as toggles, pencilled ones shaded grey. */
.num-btn.marked { background: var(--accent-bg); }

/* Hover feedback is behind a hover query because a touch latches :hover onto the
   button it tapped and never clears it. Without the guard, the digit you just
   pencilled kept the darker hover shade, reading as a different state from the
   other pencilled digits. */
@media (hover: hover) {
  .num-btn:hover { background: var(--light); }
  .num-btn.marked:hover { background: var(--line); }
  .num-btn:disabled:hover, .wide-btn:disabled:hover { background: var(--white); }
}

/* Borderless while editing candidates, so the pad reads like marks in a square
   rather than entry buttons. Transparent, not none, to keep the sizing stable.
   Digits that are not candidates of the selected square drop back to grey. */
.numpad.editing-candidates .num-btn { border-color: transparent; color: var(--pad-dim); }
.numpad.editing-candidates .num-btn.marked { color: var(--black); }

.numpad-actions {
  display: flex;
  gap: 8px;
}

.wide-btn {
  font-family: inherit;
  font-size: 1.2rem;
  font-weight: 600;
  flex: 1;
  padding: 14px 0;
  border: 1px solid var(--black);
  background: var(--white);
  color: var(--black);
  border-radius: 10px;
  cursor: pointer;
  transition: background 0.15s, color 0.15s, transform 0.05s;
}

.wide-btn:active { transform: scale(0.97); background: var(--black); color: var(--white); }

.hint-main-btn { align-self: stretch; text-align: center; }

/* Fill Candidates opts out of the black active-toggle fill: it is on for long
   stretches, and a solid black button that size pulled the eye away from the
   board. The board itself already shows whether it is on. */
#candidates-btn.active-toggle {
  background: var(--white);
  color: var(--black);
}

@media (hover: hover) {
  .wide-btn:hover { background: var(--light); }
  .diff-btn:hover { background: var(--light); }
  #candidates-btn.active-toggle:hover:not(:disabled) { background: var(--light); }
}

.status-bar {
  text-align: center;
  margin-top: 16px;
  font-size: 0.9rem;
  color: var(--mid);
  min-height: 1.2em;
}

/* Padding on the overlay, not the card, so a card capped at 100% of the free height
   still clears the screen edges. */
.win-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
  z-index: 1100; /* above the burger and its drawer — see the scale at the top */
}
.win-overlay.hidden { display: none; }

/* Every dialog shares this shell, so the height cap and scrolling live here rather
   than on each one: a card taller than the viewport — a short landscape phone, most
   of all — scrolls internally instead of running off the top and bottom with its
   buttons out of reach. dvh, not vh, so mobile browser chrome is accounted for. */
.win-card {
  background: var(--white);
  padding: 32px 40px;
  border-radius: 14px;
  text-align: center;
  box-shadow: 0 10px 40px rgba(0,0,0,0.3);
  max-height: calc(100dvh - 32px); /* the overlay's padding, both sides */
  overflow-y: auto;
  overscroll-behavior: contain; /* scrolling to the end must not pan the page behind */
}
.win-card h2 { margin: 0 0 8px; font-size: 1.6rem; }
.win-card p { margin: 0 0 18px; color: var(--mid); }

/* Corner dismiss on the two long dialogs. `sticky` rather than `absolute` so it rides
   along as the card scrolls instead of scrolling off the top. Floated with a negative
   top margin so it tucks into the card's padding without pushing the heading down,
   while keeping a 34px box — a real tap target, unlike the glyph alone. The white
   background stops content scrolling visibly underneath it. */
.dialog-x {
  position: sticky;
  top: -8px;
  z-index: 1;
  float: right;
  width: 34px;
  height: 34px;
  margin: -14px -22px -18px 0;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--white);
  font-family: inherit;
  font-size: 1.5rem;
  line-height: 1;
  color: var(--mid);
  cursor: pointer;
}
@media (hover: hover) {
  .dialog-x:hover { color: var(--black); background: var(--light); }
}

/* Confirmation dialog: the two choices sit side by side, destructive one last. */
.confirm-actions { display: flex; gap: 10px; justify-content: center; }
.danger-btn {
  background: #953232;
  border-color: #953232;
  color: var(--white);
}
.danger-btn:hover:not(:disabled) { background: #7d2a2a; }

/* Instructions dialog: the modal shell with a gesture/meaning list. Wider than the
   settings card because the entries are prose. The height cap and scrolling come
   from .win-card, which every dialog now shares. */
.instructions-card {
  text-align: left;
  width: min(90vw, 520px);
}
.instructions-card h2 { text-align: center; }

.instruction-list { margin: 0 0 22px; }
.instruction-list dt {
  font-size: 0.8rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--mid);
}
.instruction-list dd {
  margin: 4px 0 16px;
  font-size: 0.9rem;
  line-height: 1.45;
}
.instruction-list dd:last-of-type { margin-bottom: 0; }

/* Settings dialog reuses the modal shell, with left-aligned option rows.
   The width is definite, not shrink-to-fit: the help paragraph under each row
   changes as options are picked, and a shrink-to-fit card would resize itself to
   each new sentence, taking the flex:1 choice buttons with it. */
.settings-card {
  text-align: left;
  width: min(88vw, 380px);
}
.settings-card h2 { text-align: center; margin-bottom: 22px; }
.setting-row { display: flex; flex-direction: column; gap: 8px; margin-bottom: 22px; }
.setting-label {
  font-size: 0.8rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--mid);
}
.setting-choices { display: flex; gap: 8px; }
.setting-choices .action-btn { flex: 1; }
/* Reserves the tallest help string's height, so switching an option swaps the text
   without moving the rows below it. Three lines at the card's full 380px; a narrow
   phone shrinks the card to 88vw, where the longest strings wrap to four. */
.setting-help {
  margin: 2px 0 0;
  font-size: 0.8rem;
  line-height: 1.35;
  color: var(--mid);
  min-height: 4.05em; /* 3 x line-height */
}

@media (max-width: 432px) {
  .setting-help { min-height: 5.4em; } /* 4 x line-height */
}
.settings-done { width: 100%; }

/* Timer "Hide": keep its space so the toolbar does not jump. */
.timer.timer-hidden { visibility: hidden; }

/* Desktop: side panel to the right of the board */
@media (min-width: 900px) {
  .game-layout {
    flex-direction: row;
    align-items: flex-start;
    justify-content: center;
  }
  .side-panel {
    width: calc(var(--board-size) / 2 * 0.9);
    margin-top: 0;
  }
}

/* The phone's duplicate Instructions button lives in the drawer, so it must not
   also appear in the always-laid-out toolbar on wider screens. */
.menu-only { display: none; }

/* The phone treatment — burger drawer, controls on the title line, two-column
   panel. Keyed on width OR on a short landscape viewport, because a phone turned
   sideways is ~850px wide and would otherwise get the desktop layout. The JS
   media query in app.js must match this list exactly. */
@media (max-width: 699px), (orientation: landscape) and (max-height: 500px) {
  :root { --board-size: min(94vw, 552px); }

  /* Phone: the toolbar row and Instructions collapse into a burger drawer, so
     the board starts near the top of the screen instead of below two rows of
     controls. The drawer is display:flex throughout and moved off-screen by
     transform, rather than toggled with display, so it can slide. */
  .menu-btn {
    display: flex;
    position: absolute;
    left: 0;
    top: 50%;
    transform: translateY(-50%);
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    padding: 0;
    border: 1px solid var(--black);
    border-radius: 10px;
    background: var(--white);
    cursor: pointer;
    z-index: 1002; /* above the drawer, so it stays tappable to close */
  }

  /* Three bars from one element: the middle is the box, the outer two are its
     pseudo-elements offset above and below. */
  .burger, .burger::before, .burger::after {
    display: block;
    width: 18px;
    height: 2px;
    background: var(--black);
    border-radius: 2px;
  }
  .burger { position: relative; }
  .burger::before, .burger::after { content: ""; position: absolute; left: 0; }
  .burger::before { top: -6px; }
  .burger::after { top: 6px; }

  /* Turns into an X while the drawer is open. */
  .menu-btn[aria-expanded="true"] .burger { background: transparent; }
  .menu-btn[aria-expanded="true"] .burger::before { top: 0; transform: rotate(45deg); }
  .menu-btn[aria-expanded="true"] .burger::after { top: 0; transform: rotate(-45deg); }

  /* One row: burger, title, then the relocated timer/Pause/Check/Reset. The
     burger is in the flow here rather than absolutely positioned, so it takes
     part in the row instead of overlapping the title. */
  .topbar {
    flex-direction: row;
    align-items: center;
    flex-wrap: nowrap;
    gap: 8px;
    margin-bottom: 10px;
  }
  .menu-btn {
    position: static;
    transform: none;
    flex: 0 0 auto;
    width: 34px;
    height: 34px;
  }
  /* The title is the only thing on this line that may shrink: when the controls
     need the room it truncates rather than letting them collide. */
  .topbar h1 {
    font-size: 1.05rem;
    white-space: nowrap;
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* The relocated controls hug the right edge and never shrink — Reset keeps its
     margin-left:auto, which does nothing here, so it is cleared below. */
  .board-topline.in-topbar {
    flex: 0 0 auto;
    flex-wrap: nowrap;
    justify-content: flex-end;
    gap: 6px;
    width: auto;
    margin-left: auto;
  }
  /* Everything shrinks a step so the whole line fits a 320px screen. */
  .board-topline.in-topbar .timer {
    font-size: 0.9rem;
    padding: 4px 6px;
    min-width: 0;
    flex: 0 0 auto;
  }
  .board-topline.in-topbar .action-btn {
    font-size: 0.72rem;
    padding: 5px 8px;
    white-space: nowrap;
    flex: 0 0 auto;
  }
  /* Reset already sits apart via margin-left:auto; with the group right-aligned
     the extra push would only add a gap, so drop it. */
  .board-topline.in-topbar .board-reset { margin-left: 0; }

  #menu-panel {
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    width: min(80vw, 300px);
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: stretch;
    justify-content: flex-start;
    gap: 22px;
    margin: 0;
    padding: 64px 18px 18px;
    background: var(--white);
    border-bottom: none;
    border-right: 1px solid var(--line);
    box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18);
    overflow-y: auto;
    transform: translateX(-100%);
    transition: transform 0.22s ease;
    z-index: 1001;
  }
  #menu-panel.open { transform: translateX(0); }

  #menu-panel .toolbar-group {
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }
  #menu-panel .difficulty-buttons .diff-btn,
  #menu-panel .action-btn { flex: 1; }
  #menu-panel .menu-only { display: block; }

  /* Fill Candidates keeps the drawer's full-width button shape, dropping the
     side panel's align-self:stretch which means nothing in a column here. */
  #candidates-btn.in-menu { align-self: auto; width: 100%; }

  /* Sits below the last button, pushed to the bottom of the drawer when there is
     spare height so it reads as a footnote rather than another option. */
  .menu-warning {
    margin: auto 0 0;
    padding-top: 16px;
    border-top: 1px solid var(--line);
    font-size: 0.78rem;
    line-height: 1.4;
    color: var(--mid);
  }

  .menu-scrim {
    display: block;
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.4);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.22s ease;
    z-index: 1000;
  }
  .menu-scrim.open { opacity: 1; pointer-events: auto; }

  /* The logo column's own Instructions button is redundant now the drawer has
     one; the column has nothing else left at this width. */
  .logo-col { display: none; }

  /* Two columns under the board — digit pad on the left, the action stack beside
     it — so the panel fits without scrolling. The pad is sized in em so the
     squares stay thumb-sized, and the actions take whatever is left. */
  .side-panel {
    flex-direction: row;
    align-items: flex-start;
    gap: 12px;
    width: var(--board-size);
  }
  .numpad { flex: 0 0 min(58%, 13.5em); }
  .panel-actions {
    flex: 1 1 0;
    min-width: 0;
    gap: 10px;
  }

  /* The pad's own buttons shrink with it, so the action column's labels come
     down to match rather than towering over them. */
  .wide-btn { font-size: 1rem; padding: 11px 0; }
  .num-btn { font-size: 1.45rem; }

  /* Sole source of the space between the board and the controls under it — the
     board-wrap has no trailing gap once its topline has moved to the topbar. */
  .game-layout { gap: 9px; }
}

/* Squat portrait phones — 16:9 and boxier, where the controls below a full-width
   board would not fit the remaining height.

   The board keeps its full width unconditionally: candidate marks are ~1/42 of the
   board, so shrinking it to buy vertical room makes them unreadable and the puzzle
   unplayable. All of the compression therefore lands on the controls, whose heights
   are measured in dvh so they take only what the board leaves. The digit buttons
   give up their 1:1 ratio and go wide-and-short rather than push the pad off-screen.

   min-aspect-ratio is width/height, so 9/16 is exactly 16:9 portrait; 0.56 sits a
   hair below it so a 375x667 screen (1.7787 tall, nominally "16:9") is caught too. */
@media (max-width: 699px) and (orientation: portrait) and (min-aspect-ratio: 0.56 / 1) {
  /* Full width, with no absolute cap: the base rule stops at 552px, which on a wide
     boxy screen left the board smaller than the width allowed and the page ending in
     dead space. Width is the only limit here — a square board cannot exceed it. */
  :root { --board-size: 94vw; }

  #app { padding: 10px 12px 12px; }
  .topbar { margin-bottom: 6px; }
  .game-layout { gap: 7px; }
  .status-bar { margin-top: 7px; font-size: 0.8rem; }

  /* aspect-ratio gives way to an explicit height, so three rows of digits always
     fit whatever the full-width board leaves. */
  .num-btn {
    aspect-ratio: auto;
    height: max(30px, min(7.6dvh, 4rem));
    font-size: 1.2rem;
    border-radius: 8px;
  }
  .num-grid { gap: 6px; }
  .numpad { gap: 6px; }

  .wide-btn {
    font-size: 0.9rem;
    padding: 0;
    height: max(28px, min(6.2dvh, 3rem));
    border-radius: 8px;
  }
  .numpad-actions { gap: 6px; }
  .panel-actions { gap: 6px; }
  #edit-candidates-btn, .panel-actions .action-btn { padding: 5px 10px; font-size: 0.78rem; }
  .hint-panel { min-height: 1.2em; font-size: 0.8rem; }
}

/* Boxy portrait, where a full-width board leaves too little underneath for the normal
   two-column panel: the digit pad keeps its 3x3 grid but with short, wide buttons, and
   the action column beside it packs Erase / Undo / Redo / Fill / Hint into the same
   strip. Edit Candidates turns sideways down the pad's edge, since a row of its own
   would cost a third of the height available.

   Keyed on the ratio at which the board (94vw) plus a usable panel stops fitting, not
   on 3:4 itself: 0.62 is just below 5:8, which measurement puts at the crossover. The
   699px cap matches the phone block, whose two-column .side-panel this builds on —
   above it the panel stacks and these rules would leave the pad hanging off-screen. */
@media (max-width: 699px) and (orientation: portrait) and (min-aspect-ratio: 0.62 / 1) {
  #app { padding: 6px 10px 4px; }
  .status-bar { display: none; } /* the hint panel carries anything important */
  .game-layout { gap: 5px; }
  .topbar { margin-bottom: 4px; }

  .side-panel { gap: 5px; }

  /* Edit Candidates becomes a vertical stub down the pad's left edge, so all three
     digit rows get the strip's full height. */
  .numpad {
    flex: 0 0 auto;
    width: 68%;
    flex-direction: row;
    align-items: stretch;
    gap: 4px;
  }
  #edit-candidates-btn {
    flex: 0 0 auto;
    width: 1.35rem;
    padding: 4px 0;
    font-size: 0.58rem;
    line-height: 1.05;
    /* Sideways, reading bottom-to-top, so a long label fits a narrow strip. */
    writing-mode: vertical-rl;
    transform: rotate(180deg);
    text-align: center;
    white-space: nowrap;
    overflow: hidden;
  }

  /* Three rows in whatever the board leaves. Sized against the height the board does
     NOT take — the board is a 94vw square, so the strip below it is roughly
     100dvh - 94vw - chrome — which lets the buttons grow to fill a screen with slack
     instead of stopping at a fixed cap and leaving dead space at the bottom. The
     100dvh term keeps them from ever exceeding the strip. */
  .num-grid { flex: 1 1 auto; gap: 4px; }
  .num-btn {
    aspect-ratio: auto;
    /* The calc is the real limit — a third of the strip below the board — with the
       3.6rem cap only stopping the buttons from getting comically tall on a very
       wide screen. Bounded below at 26px so they stay tappable. */
    height: max(26px, min(3.6rem, (100dvh - 94vw - 4rem) / 3));
    font-size: 0.95rem;
    border-radius: 6px;
  }

  /* Erase, Undo+Redo, Fill and Hint — four rows matching the pad's three. */
  .panel-actions { flex: 1 1 auto; min-width: 0; gap: 4px; }
  .numpad-actions { gap: 4px; }
  .wide-btn {
    font-size: 0.72rem;
    padding: 0;
    height: max(22px, min(2.6rem, (100dvh - 94vw - 5rem) / 4));
    border-radius: 6px;
  }
  .panel-actions .action-btn { padding: 2px 6px; font-size: 0.64rem; }
  .hint-panel { display: none; } /* nothing to spare; status messages still show */
}

/* Phone in landscape. Height is the scarce dimension, so the board is sized from it
   and takes almost the whole height on the left; everything else stacks in a column
   to its right — the burger and title row on top (relocated back out of .topbar by
   app.js, which keeps the topline in place here), then the digit pad beside the
   remaining buttons.

   Scoped to short viewports rather than a width, since a sideways phone is ~850px
   wide and would otherwise read as a desktop.

   Sized in lvh — the viewport with the browser's URL bar retracted — not dvh. dvh is
   whatever is visible right now, which while the bar is showing is ~56px less on a
   Pixel; sizing to that made the board short of the real screen AND made the page fit
   exactly, so there was no scroll left for the swipe that retracts the bar. Being
   deliberately a little taller than the current viewport is what breaks that deadlock:
   the page scrolls, the bar goes away, and the layout then fits the full height.
   min-height on #app guarantees that scroll room even if the content alone would fit.
   The plain-vh line ahead of each is a fallback for browsers without lvh; on mobile
   they agree, since 100vh has always meant the bar-retracted height. */
@media (orientation: landscape) and (max-height: 500px) {
  :root {
    --board-size: min(92vh, 88vw);
    --board-size: min(92lvh, 88vw);
  }

  #app {
    padding: 6px 10px;
    max-width: none;
    min-height: 100vh;
    min-height: 100lvh;
  }
  .status-bar { display: none; } /* no room for it; the hint panel carries messages */

  /* The board and the whole right-hand stack, side by side. */
  .game-layout {
    flex-direction: row;
    align-items: flex-start;
    justify-content: flex-start;
    gap: 10px;
  }
  .board-wrap { flex: 0 0 auto; width: var(--board-size); }

  /* The header row is moved in here by app.js and leads the right-hand column.
     It wraps rather than truncating: on a panel too narrow for burger + title +
     four controls, the timer/Pause/Check/Reset group drops to a second line
     instead of squeezing the title away. */
  .topbar {
    order: -1;
    flex-direction: row;
    align-items: center;
    flex-wrap: wrap;
    row-gap: 5px;
    column-gap: 6px;
    margin: 0 0 6px;
    width: 100%;
  }
  .topbar h1 { font-size: 0.9rem; white-space: nowrap; }
  .menu-btn { width: 30px; height: 30px; }

  /* Everything to the right of the board: header row, then pad + actions. */
  .side-panel {
    flex: 1 1 auto;
    min-width: 0;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: flex-start;
    gap: 8px;
    width: auto;
  }
  /* The pad is capped so its buttons stay squarish instead of stretching into wide
     bars across a roomy landscape panel. */
  .numpad { flex: 1 1 0; min-width: 0; max-width: 21rem; gap: 6px; }
  .num-grid { gap: 6px; }
  /* Sized off the height left under the header row: three rows plus the two gaps
     and Edit Candidates, so the pad reaches the bottom without overflowing it. lvh
     for the same reason as the board above — these must match its scale, or the pad
     would shrink while the URL bar shows and never grow back once it retracts. */
  .num-btn {
    aspect-ratio: auto;
    height: max(28px, min(21vh, 4.4rem));
    height: max(28px, min(21lvh, 4.4rem));
    font-size: 1.2rem;
    border-radius: 8px;
  }
  .panel-actions { flex: 1 1 0; min-width: 0; max-width: 21rem; gap: 6px; }
  .numpad-actions { gap: 6px; }
  .wide-btn {
    font-size: 0.85rem;
    padding: 0;
    height: max(26px, min(15vh, 3.2rem));
    height: max(26px, min(15lvh, 3.2rem));
    border-radius: 8px;
  }
  #edit-candidates-btn, .panel-actions .action-btn { padding: 6px 10px; font-size: 0.75rem; }
  .hint-panel { min-height: 0; font-size: 0.72rem; }

  /* Shares the header line when it fits, and takes a full second line when it does
     not. The basis is its natural width (fit-content) and it may not shrink, so
     flex-wrap moves the whole group down rather than compressing its buttons; once
     wrapped, flex-grow spreads it across the line it now owns. */
  .board-topline {
    flex: 1 0 fit-content;
    flex-wrap: nowrap;
    justify-content: flex-end;
    gap: 5px;
    width: auto;
  }
  .board-topline .timer { font-size: 0.8rem; padding: 3px 5px; min-width: 0; }
  .board-topline .action-btn { font-size: 0.66rem; padding: 4px 7px; white-space: nowrap; }
  .board-topline .board-reset { margin-left: 0; }

  /* A full-height drawer is fine, but its generous portrait padding is not. */
  #menu-panel { padding: 46px 14px 14px; gap: 12px; width: min(62vw, 260px); }
  #menu-panel .toolbar-group { gap: 7px; }
  .menu-warning { padding-top: 10px; font-size: 0.7rem; }

  /* Dialogs scroll inside .win-card's height cap here; trimming their padding and
     row spacing means less of a ~390px-tall viewport goes to whitespace. */
  .win-overlay { padding: 10px; }
  .win-card { padding: 16px 20px; max-height: calc(100dvh - 20px); }
  .win-card h2 { font-size: 1.15rem; margin-bottom: 4px; }
  .settings-card h2, .instructions-card h2 { margin-bottom: 12px; }
  .setting-row { margin-bottom: 12px; }
  .instruction-list dd { margin: 3px 0 10px; }

  /* Very short landscape: the header's wrapped second line and three rows of digits
     cannot both have their preferred height, so the pad's rows and the board each
     give a little back. Last in the block so it overrides the sizes set above. */
  @media (max-height: 340px) {
    :root {
      --board-size: min(80vh, 88vw);
      --board-size: min(80lvh, 88vw);
    }
    .num-btn {
      height: max(20px, min(16vh, 2.6rem));
      height: max(20px, min(16lvh, 2.6rem));
      font-size: 0.9rem;
    }
    .wide-btn {
      height: max(18px, min(11vh, 1.9rem));
      height: max(18px, min(11lvh, 1.9rem));
      font-size: 0.7rem;
    }
    .num-grid, .numpad, .panel-actions, .numpad-actions, .side-panel { gap: 4px; }
    #edit-candidates-btn, .panel-actions .action-btn { padding: 2px 6px; font-size: 0.64rem; }
    .topbar { margin-bottom: 4px; }
  }
}

@media (max-width: 699px) and (prefers-reduced-motion: reduce) {
  #menu-panel, .menu-scrim { transition: none; }
}
@media (orientation: landscape) and (max-height: 500px) and (prefers-reduced-motion: reduce) {
  #menu-panel, .menu-scrim { transition: none; }
}
