/* Placeholder styling through M1.
 *
 * Palette is the invented placeholder from the scale demo ("dragon's hoard at
 * midnight"): deep purple + gold. Tasha's <=256-colour palette supersedes it.
 * The scene/chrome scaling model arrives with the real graphics milestone —
 * this is deliberately just enough to look intentional on a phone.
 *
 * M1 adds the account surfaces: nav, forms, tables. Same rule applies — real
 * chrome sprites replace all of it, so nothing here is worth loving.
 */

:root {
    --ink: #f3ecff;
    --dim: #a693c9;
    --gold: #e8b74a;
    --night: #150c24;
    --night-2: #241640;
}

* { box-sizing: border-box; }

html, body {
    margin: 0;
    padding: 0;
    min-height: 100%;
}

body {
    background: radial-gradient(ellipse at 50% 0%, var(--night-2), var(--night) 70%);
    background-attachment: fixed;
    color: var(--ink);
    font-family: var(--cd26-font);
    display: flex;
    /* Pages that outgrow the viewport (the account and admin lists) must scroll
     * from the top, not stay pinned to the middle of the screen. */
    align-items: flex-start;
    justify-content: center;
    padding: max(1.5rem, env(safe-area-inset-top)) 1.25rem
             max(1.5rem, env(safe-area-inset-bottom));
}

.column {
    width: 100%;
    max-width: 26rem;
    text-align: center;
}

.sigil {
    font-size: 3.5rem;
    margin: 0 0 0.5rem;
    line-height: 1;
}

h1 {
    font-size: 1.5rem;
    margin: 0 0 0.5rem;
    color: var(--gold);
    letter-spacing: 0.02em;
}

.blurb {
    margin: 0 0 2rem;
    color: var(--dim);
    line-height: 1.5;
}

.diag {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 0.35rem 1rem;
    margin: 0 0 2rem;
    padding: 1rem;
    border: 1px solid rgba(232, 183, 74, 0.35);
    border-radius: 0.25rem;
    text-align: left;
    font-size: 0.85rem;
}

.diag dt { color: var(--dim); }

.diag dd {
    margin: 0;
    color: var(--gold);
    word-break: break-word;
}

.foot {
    margin: 0;
    font-size: 0.75rem;
    color: var(--dim);
}

/* --- M1: nav, forms, tables ------------------------------------------- */

.topnav {
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
    align-items: baseline;
    justify-content: center;
    margin: 0 0 1.5rem;
    padding-bottom: 0.75rem;
    border-bottom: 1px solid rgba(232, 183, 74, 0.2);
    font-size: 0.8rem;
}

.topnav a, .alt a { color: var(--gold); }

.inline { display: inline; margin: 0; }

/* A submit button that reads as a nav link — logging out has to be a POST. */
.linkish {
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    color: var(--gold);
    text-decoration: underline;
    cursor: pointer;
}

h2 {
    font-size: 1rem;
    margin: 2rem 0 0.5rem;
    color: var(--gold);
    text-align: left;
}

.card {
    display: block;
    margin: 0 0 1.25rem;
    padding: 1rem;
    border: 1px solid rgba(232, 183, 74, 0.35);
    border-radius: 0.25rem;
    text-align: left;
}

label {
    display: block;
    margin: 0.75rem 0 0.25rem;
    font-size: 0.8rem;
    color: var(--dim);
}

.card > label:first-of-type { margin-top: 0; }

.optional { color: var(--dim); opacity: 0.8; }

input[type="text"], input[type="password"], input[type="email"] {
    width: 100%;
    padding: 0.6rem;
    /* 16px keeps iOS Safari from zooming the whole page on focus. */
    font: inherit;
    font-size: 16px;
    color: var(--ink);
    background: rgba(0, 0, 0, 0.35);
    border: 1px solid rgba(232, 183, 74, 0.35);
    border-radius: 0.2rem;
}

input:focus { outline: 2px solid var(--gold); outline-offset: 1px; }

button[type="submit"] {
    margin-top: 1rem;
    padding: 0.7rem 1.2rem;
    font: inherit;
    color: var(--night);
    background: var(--gold);
    border: 0;
    border-radius: 0.2rem;
    cursor: pointer;
}

button[type="submit"].linkish { margin-top: 0; }

.hint {
    margin: 0.35rem 0 0;
    font-size: 0.75rem;
    color: var(--dim);
    line-height: 1.4;
}

.errors {
    margin: 0 0 1rem;
    padding: 0.75rem 0.75rem 0.75rem 2rem;
    border: 1px solid #c76a6a;
    border-radius: 0.25rem;
    background: rgba(199, 106, 106, 0.12);
    text-align: left;
    font-size: 0.85rem;
}

.cta {
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
    justify-content: center;
    margin: 0 0 1rem;
}

.button {
    display: inline-block;
    padding: 0.7rem 1.2rem;
    color: var(--night);
    background: var(--gold);
    border-radius: 0.2rem;
    text-decoration: none;
}

.button.quiet {
    color: var(--gold);
    background: none;
    border: 1px solid rgba(232, 183, 74, 0.5);
}

.countdown { margin: 0 0 1.5rem; color: var(--ink); }

.alt { margin: 1rem 0 0; font-size: 0.85rem; color: var(--dim); }

.dim { color: var(--dim); }

.ledger {
    width: 100%;
    margin: 0 0 1rem;
    border-collapse: collapse;
    font-size: 0.8rem;
    text-align: left;
}

.ledger th, .ledger td {
    padding: 0.35rem 0.5rem;
    border-bottom: 1px solid rgba(232, 183, 74, 0.18);
}

.ledger th { color: var(--dim); font-weight: normal; }

.codes {
    margin: 0 0 1rem;
    padding: 0;
    list-style: none;
    text-align: left;
}

.codes li { margin: 0 0 0.5rem; }

.codes code {
    color: var(--gold);
    font-size: 1.05rem;
    letter-spacing: 0.08em;
}

/* Recovery code: shown once, at registration and after a recovery. Big enough
 * to read off a screen and retype on a phone, and set apart so it does not
 * read as decoration to be scrolled past. */
.recovery-code {
    margin: 0 0 0.75rem;
    padding: 1rem 0.5rem;
    border: 1px dashed var(--gold);
    border-radius: 0.25rem;
    background: rgba(232, 183, 74, 0.07);
    text-align: center;
}

.recovery-code code {
    color: var(--gold);
    font-size: 1.35rem;
    letter-spacing: 0.12em;
    word-break: break-word;
}

/* --- M3a: the scene + chrome model ------------------------------------
 *
 * GAME-DESIGN settled this in prose; here it is in numbers. A fixed 360x640
 * logical stage scales as ONE unit, integer-snapped once it is at or above
 * 1:1, so pixel art lands on whole pixels. Everything inside `.stage-body` is
 * written in logical pixels and never has to know about the factor.
 *
 * THE BLEED (GAME-DESIGN.md:157, built 2026-08-31 on the operator's ruling in
 * inbox `2026-08-31-b`, which overruled a proposal to leave it unbuilt). The
 * spec has always been a 360x640 GUARANTEED area inside a 430x800 BLEED
 * CANVAS — 35px each side, 80px top and bottom. So `.stage` is the canvas and
 * `.stage-body` is the guaranteed area, inset into it. Every coordinate inside
 * the stage is written against `.stage-body` and was unaffected by the change.
 *
 * The bleed is art that may or may not be seen: `.stage` keeps
 * `overflow: hidden` and clips at the canvas edge, and on a viewport smaller
 * than 430x800 logical the bleed simply runs off screen under `body`'s own
 * `overflow: hidden`. That is the intent — it is the margin Tasha draws into
 * so no screen shows an edge, not content anybody is required to see.
 *
 * `scale.js` computes the factor from 360x640 and MUST KEEP DOING SO. Scaling
 * to fit 430x800 would shrink the game on every phone to make room for art
 * that is optional by definition. That is the one change here that would look
 * like it worked.
 *
 * Chrome is the opposite: anchored to the real viewport and the safe-area
 * insets, sized by the SAME factor so its density matches the scene, and
 * living outside the transformed element so `position: fixed` still means the
 * screen. `--u` is "one logical pixel, scaled" and is how chrome stays in step.
 *
 * The geometry below is measured from the operator's mockups in
 * UI/example-layout-geometry/ (the table in milestones/m3a-geometry.md), and
 * the colours are HIS placeholders from those same files. Tasha's palette and
 * art supersede all of it — nothing here is worth loving.
 */

/* The site font (operator, 2026-09-03): "PixelCode", supplied as .woff/.otf
 * and shipped with the site, superseding the earlier Terminal pick — Terminal
 * could never reach a phone (raster .fon), which is why this slot existed.
 * The webfont reaches every device; the old stack demotes to fallback for the
 * moments before it loads (font-display: swap) or if it fails to. */
@font-face {
    font-family: "PixelCode";
    src: url("pixelcode.woff") format("woff"),
         url("pixelcode.otf") format("opentype");
    font-display: swap;
}

:root {
    /* "PixelCode" is the shipped webfont above. The rest of the stack is the
     * fallback chain from the Terminal era: bitmapped-looking, fixed-pitch
     * faces first, then the platform monospace defaults the stage was
     * designed against. */
    --cd26-font: "PixelCode", "Lucida Console", ui-monospace, "SFMono-Regular",
                 Menlo, Consolas, monospace;

    --cd26-scale: 1;
    --u: calc(1px * var(--cd26-scale));

    /* Mockup placeholders, the operator's, not a palette. */
    --mock-bar: #2b8133;
    --mock-body: #121010;
    --mock-tile: #bae1b8;
    --mock-level: #1a7313;
    --mock-javier: #181a62;
    --mock-tool: #dd32c6;
    --mock-score: #ecef52;
}

body[data-layout="stage"] {
    display: block;
    padding: 0;
    overflow: hidden;
    background: var(--mock-body);
}

.stage {
    position: fixed;
    top: 50%;
    left: 50%;
    width: 430px;
    height: 800px;
    margin: -400px 0 0 -215px;
    transform: scale(var(--cd26-scale));
    transform-origin: 50% 50%;
    background: var(--mock-body);
    image-rendering: pixelated;
    overflow: hidden;
}

.stage-body {
    position: absolute;
    top: 80px;
    left: 35px;
    width: 360px;
    height: 640px;
}

/* --- Per-screen background art (inbox `2026-08-31-a`) ------------------
 *
 * The picture slot. `<body>` carries `data-bg="<slug>"` naming which screen it
 * is, and one rule per slug hangs a file on the stage. Dropping the PNG in
 * later is content; teaching the page where to put it is code, which is why
 * these rules exist before any file does.
 *
 * SIZE IS 430x800, THE BLEED CANVAS, NOT 360x640. GAME-DESIGN.md:157 -- Tasha
 * draws full scenes at 430x800 and `.stage` is now exactly that box, so the
 * art registers on it 1:1 and `overflow: hidden` clips the bleed. Sizing these
 * to 360x640 would squeeze the whole canvas into the guaranteed area: every
 * element ~16% too small, and the bleed margins visible when they are meant to
 * be cut.
 *
 * THE PATH IS `../images/`, NOT `images/`. A url() in a stylesheet resolves
 * against the STYLESHEET, and this file is in `assets/` while the images live
 * in the web root's `images/` -- which is where `ops.sh image-put` puts them
 * and what `deploy.sh` protects from deletion. `images/...` here would ask for
 * `assets/images/...`, 404 silently, and look exactly like art that had not
 * been uploaded yet.
 *
 * A missing file costs one 404 and paints nothing: `background-color` stays
 * underneath, so the `--mock-body` placeholder shows through and the screen is
 * the one we have today. So a rule for art that never arrives is free, and
 * that is deliberate -- every screen that could ever want a picture gets its
 * rule NOW, because adding one after the code freeze is a code change.
 */

body[data-bg="hub-1"] .stage { background-image: url('../images/bg-hub-1.png'); }
body[data-bg="hub-2"] .stage { background-image: url('../images/bg-hub-2.png'); }
body[data-bg="hub-3"] .stage { background-image: url('../images/bg-hub-3.png'); }
body[data-bg="challenge"] .stage { background-image: url('../images/bg-challenge.png'); }
/* DEPRECATED 2026-09-03 with the screens themselves: nothing emits these two
 * slugs any more, so neither file needs drawing. Kept beside the live rule
 * because the screens are kept too — unreachable, not deleted. */
body[data-bg="javier-select"] .stage { background-image: url('../images/bg-javier-select.png'); }
body[data-bg="javier-speak"] .stage { background-image: url('../images/bg-javier-speak.png'); }
body[data-bg="javier-shop"] .stage { background-image: url('../images/bg-javier-shop.png'); }
body[data-bg="tool"] .stage { background-image: url('../images/bg-tool.png'); }

/* `account` and `scoreboard` are on the operator's list but neither is a stage
 * screen today: account.php uses the `column` layout and has no `.stage`
 * element at all, and there IS no scoreboard page -- the chrome link points at
 * account.php until one exists. Both rules are therefore INERT: they are here
 * so that the day either becomes a stage screen, its art is already wired.
 * Left deliberately, not overlooked. */
body[data-bg="account"] .stage { background-image: url('../images/bg-account.png'); }
body[data-bg="scoreboard"] .stage { background-image: url('../images/bg-scoreboard.png'); }

/* One place, so a new slug above is one line and never four. */
body[data-bg] .stage {
    background-size: 430px 800px;
    background-position: center center;
    background-repeat: no-repeat;
}

/* Transient messages (work order item 14). The fade is the whole visual
 * effect; the element keeps its box on purpose, because the stage geometry is
 * measured and un-flowing a paragraph would shift what sits below it after the
 * player had already read the page.
 *
 * `visibility` rather than `display` for the same reason, and it also takes
 * the text out of the accessibility tree once it has been announced. With no
 * JavaScript nothing ever sets data-toast="gone", so every message persists —
 * which is the pre-item-14 behaviour and is acceptable. */
.toast {
    transition: opacity 500ms ease-out, visibility 0s linear 500ms;
}

[data-toast="gone"] {
    opacity: 0;
    visibility: hidden;
}

/* Fill-in-the-blank with N answers (operator design, 2026-09-01).
 *
 * BULLETS, not numbers, and that is the point rather than a style choice: the
 * bullet says "this is a list", while numbering would imply an order the judge
 * deliberately does not care about. The boxes carry no visible label for the
 * same reason — there is nothing to say about blank 2 that is not true of
 * blank 1. Each input keeps an aria-label, because a bullet is a visual cue
 * and an unlabelled box is silence to a screen reader. */
.multi {
    list-style: disc;
    margin: 0;
    padding-left: 16px;
}

.multi li {
    margin: 0 0 4px;
}

.multi input[type="text"] {
    width: 100%;
    /* 16px or a phone zooms the whole stage on focus, which on a scaled,
     * integer-snapped stage is not a small cosmetic problem. Same reason the
     * single-answer box sets it. */
    font-size: 16px;
}

/* The content composer (admin only — content.php). Plain and dense on
 * purpose: it is a data-entry form for one person under time pressure, not a
 * surface any player sees, so nothing here is themed. */
#composer {
    border: 1px solid var(--rule, #444);
    padding: 10px 12px;
    margin: 0 0 16px;
}

#composer summary { cursor: pointer; font-weight: bold; }

#composer label { display: block; margin-top: 6px; }
#composer input[type="text"],
#composer select,
#composer textarea { width: 100%; }

.compose-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
    gap: 0 10px;
}

.compose-row {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    margin-bottom: 4px;
}

.compose-row > label,
.compose-row > input[type="text"] { flex: 1; }

/* Fixed width so a column of them lines up however long the labels are. */
.compose-row > label:has(> input[type="checkbox"]) { flex: 0 0 6.5em; }

.compose-drop {
    flex: 0 0 auto;
    align-self: center;
    line-height: 1;
    padding: 2px 8px;
}

.compose-extras { margin: 10px 0; }
.compose-extras summary { font-weight: normal; }

#composer .hint { display: block; font-size: 11px; color: var(--dim, #999); }

/* Chrome: screen edges, not stage edges. */
.chrome {
    position: fixed;
    left: 0;
    right: 0;
    background: var(--mock-bar);
    color: var(--mock-body);
    font-size: calc(11 * var(--u));
    line-height: 1.15;
    z-index: 2;
}

.chrome-top {
    top: 0;
    height: calc(50 * var(--u) + env(safe-area-inset-top, 0px));
    padding: calc(7 * var(--u)) calc(15 * var(--u)) 0;
    padding-top: calc(7 * var(--u) + env(safe-area-inset-top, 0px));
    display: flex;
    align-items: flex-start;
    gap: calc(9 * var(--u));
}

.chrome-foot {
    /* Two buttons now (work order item 15): Scoreboard keeps the position it
     * was measured into, and "Completed Puzzles" takes the bottom RIGHT, which
     * is where the operator's 08-30 mockup puts it. */
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: calc(9 * var(--u));
    bottom: 0;
    height: calc(50 * var(--u) + env(safe-area-inset-bottom, 0px));
    padding: calc(10 * var(--u)) calc(12 * var(--u));
    padding-bottom: calc(10 * var(--u) + env(safe-area-inset-bottom, 0px));
}

.wallet {
    display: grid;
    grid-template-columns: auto auto;
    justify-content: start;
    gap: 0 calc(8 * var(--u));
    margin: 0;
    margin-right: auto;
}

.wallet dt, .wallet dd { margin: 0; }

.slot {
    display: flex;
    align-items: center;
    justify-content: center;
    width: calc(36 * var(--u));
    height: calc(36 * var(--u));
    background: var(--mock-tool);
    color: var(--mock-body);
    font-size: calc(9 * var(--u));
    text-align: center;
    text-decoration: none;
    flex: none;
}

/* The back button sits between the wallet and the tools (challenge-layout.png)
 * and pushes nothing around: `auto` margins already parked the wallet left. */
.slot-back { margin-right: calc(33 * var(--u)); }

.slots {
    display: flex;
    gap: calc(9 * var(--u));
    margin: 0;
    padding: 0;
    list-style: none;
}

.scoreboard {
    display: flex;
    align-items: center;
    justify-content: center;
    width: calc(121 * var(--u));
    height: calc(29 * var(--u));
    background: var(--mock-score);
    color: var(--mock-body);
    text-decoration: none;
}

/* --- the hub, at the mockup's geometry (logical px inside the stage) --- */

/* The hub fills the stage. Its children are positioned absolutely, so without
 * this it collapses to nothing — which is not merely invisible to a player,
 * it is invisible to the suite as well. */
.hub {
    position: absolute;
    inset: 0;
}

/* A message on the hub has to sit ABOVE the board, and everything else on the
 * hub is absolutely positioned — so in-flow text paints underneath. The
 * operator hit this the first time he solved a puzzle: the notice was there,
 * behind the challenge tiles. Parked in the gap between the nav row and the
 * board (y 149..175), where the mockup has nothing.
 */
.hub .notice,
.hub .errors {
    position: absolute;
    left: 20px;
    right: 20px;
    top: 152px;
    z-index: 1;
    margin: 0;
    padding: 3px 6px;
    background: rgba(0, 0, 0, 0.82);
    font-size: 11px;
    text-align: center;
}

.hub .errors { border-color: #c76a6a; padding-left: 6px; list-style: none; }

/* A panel INSIDE the hub (the locked-level notice) starts below the nav row
 * rather than on top of it. `.panel` on its own is for screens that have no
 * nav — a challenge, a tool — and those still start at the top of the stage. */
.hub .panel { top: 175px; }

.hub-nav, .board {
    position: absolute;
    left: 37px;
    display: grid;
    grid-template-columns: repeat(3, 72px);
    column-gap: 35px;
}

.hub-nav { top: 77px; }

/* Row pitch is 110 and a tile is 72, so the gap is 38. */
.board {
    top: 175px;
    row-gap: 38px;
    margin: 0;
    padding: 0;
    list-style: none;
}

.hub-nav a, .hub-nav span, .cell {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 72px;
    height: 72px;
    padding: 4px;
    font-size: 11px;
    line-height: 1.25;
    text-align: center;
    text-decoration: none;
    overflow: hidden;
}

.hub-nav a, .hub-nav span {
    background: var(--mock-level);
    color: var(--mock-score);
}

.hub-nav .javier { background: var(--mock-javier); color: var(--ink); }

/* A level you cannot go to is drawn, not hidden: the player can see the shape
 * of the game. Which levels are open stays a server-side decision. */
.hub-nav [aria-disabled="true"] { opacity: 0.35; }

.cell {
    background: var(--mock-tile);
    color: var(--mock-body);
}

.cell a { color: inherit; text-decoration: none; }

/* Chrome and hub icons (operator, 2026-09-03).
 *
 * All of them work the way the backgrounds do: a `background-image` on a box
 * whose size M3a already measured, `background-size: 100% 100%` so the PNG is
 * a 1:1 blit, and the label under it going transparent so the art stands
 * alone. A file that is not there changes nothing a player can see — the flat
 * placeholder colour simply stays.
 *
 * Draw each at its box's 1x size. The stage integer-scales and sets
 * `image-rendering: pixelated`, so 1x pixel art survives scaling exactly.
 *
 *   icon-back.png        36x36   the chrome's back-to-hub slot
 *   icon-javier.png      72x72   the hub's Javier button
 *   icon-level-prev.png  72x72   previous level
 *   icon-level-next.png  72x72   next level
 */
.slot-back,
#hub-javier,
#hub-prev,
#hub-next {
    background-size: 100% 100%;
    background-repeat: no-repeat;
    background-position: center;
    color: transparent;
}

/* The words stay in the DOM for screen readers and the suite, exactly as the
 * tile titles do — GAME-DESIGN mandates real HTML text. */
.slot-back::selection, #hub-javier::selection,
#hub-prev::selection, #hub-next::selection { background: transparent; }

.slot-back  { background-image: url('../images/icon-back.png'); }
#hub-javier { background-image: url('../images/icon-javier.png'); }
#hub-prev   { background-image: url('../images/icon-level-prev.png'); }
#hub-next   { background-image: url('../images/icon-level-next.png'); }

/* A level you cannot reach keeps its art and stays dimmed by the existing
 * [aria-disabled] rule — the shape of the game stays visible. */

/* Tool art, in both places a tool appears. Unlike the four above, these cannot
 * be named in CSS: a tool is DATA and its id is whatever the operator authored,
 * so the rule would need rewriting every time the roster changes — on the wrong
 * side of the freeze. The filename is built from the tool id at render time
 * instead (cd26_tool_icon()), which is why there is no url() here.
 *
 *   icon-tool-<id>.png        70x70   the shop shelf swatch
 *   icon-tool-<id>-small.png  36x36   the chrome slot, top right
 *
 * e.g. icon-tool-cipher-wheel.png and icon-tool-cipher-wheel-small.png. */
.swatch[style*="url"],
.slot-tool[style*="url"] {
    background-size: 100% 100%;
    background-repeat: no-repeat;
    background-position: center;
}

/* Only once art is actually behind it does the label get out of the way. */
.slot-tool[style*="url"] a { color: transparent; }

/* Flavor tile art (operator, 09-03). One icon per category, drawn at 72x72 —
 * the tile's exact size — so `background-size: 100% 100%` is a 1:1 blit and
 * the stage's integer scaling keeps the pixels square.
 *
 * **This is the first thing in the game that names a player's branch.** Flavors
 * were internal-only by the 08-24 ruling and the branching is deliberately
 * silent; the operator chose this knowingly on 09-03. Nothing else reads
 * data-flavor, so reverting is deleting this block.
 *
 * An unclassified challenge carries no data-flavor and keeps the flat tile —
 * no icon rather than a guessed one. */
.cell[data-flavor] {
    background-size: 100% 100%;
    background-repeat: no-repeat;
    background-position: center;
}

.cell[data-flavor="R"] { background-image: url('../images/icon-flavor-r.png'); }
.cell[data-flavor="G"] { background-image: url('../images/icon-flavor-g.png'); }
.cell[data-flavor="B"] { background-image: url('../images/icon-flavor-b.png'); }

/* The title goes invisible, not away: three things still read it — screen
 * readers (GAME-DESIGN mandates real HTML text), the smoke suite via
 * textContent, and the operator finding his own copy in the PHP.
 *
 * Scoped to the LINK rather than the whole cell, and that is load-bearing:
 * `color: transparent` on .cell would be inherited by .cell-solvers and take
 * the per-challenge solve counter with it. The operator ruled that counter
 * visible and permanent on 08-29 — "there is no option to hide them" — so it
 * keeps its own colour below. */
.cell[data-flavor] a { color: transparent; }
.cell[data-flavor] a::selection { background: transparent; }

.cell[data-solved="yes"] { opacity: 0.4; }

/* Anything that is not the hub — the challenge screen, the level-locked
 * notice — gets the stage's whole body under the chrome. */
.panel {
    position: absolute;
    left: 20px;
    right: 20px;
    top: 70px;
    bottom: 20px;
    overflow-y: auto;
    color: var(--ink);
    font-size: 12px;
    text-align: left;
}

.panel h1, .panel h2 { font-size: 13px; margin: 0 0 8px; }

/* Challenge images are the ONE exception to the stage's pixel-art rendering
 * (operator ruling 2026-09-14): they are photos, diagrams and puzzle
 * material that must be as easy to read as possible, not chrome. They scale
 * smoothly and fit the panel's width instead of rendering at natural size
 * and scrolling sideways. The full-resolution link stays byte-identical. */
.challenge-image img {
    max-width: 100%;
    height: auto;
    image-rendering: auto;
}
.panel input[type="text"] { font-size: 16px; }

/* Portrait lock (operator, 08-13). The test is a SHORT viewport, not merely a
 * landscape one: a desktop landscape window is fine and must keep working. */
.rotate { display: none; }

@media (orientation: landscape) and (max-height: 500px) {
    body[data-layout="stage"] .rotate {
        display: flex;
        position: fixed;
        inset: 0;
        z-index: 3;
        align-items: center;
        justify-content: center;
        padding: 1rem;
        background: var(--mock-body);
        color: var(--ink);
        text-align: center;
    }

    body[data-layout="stage"] .stage,
    body[data-layout="stage"] .chrome { display: none; }
}

/* --- M3c: Javier, the shop, and a tool in your hand -------------------
 *
 * Geometry from Javier-select-layout.png and Javier-shop-layout.png, decoded
 * rather than eyeballed (the table is in milestones/m3c-javier.md). Colours
 * are the operator's mockup placeholders again; Tasha's palette supersedes.
 */

.portrait {
    position: absolute;
    left: 30px;
    top: 82px;
    width: 300px;
    height: 167px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 12px;
    background: #b8895e;
    color: var(--mock-body);
    font-size: 13px;
    text-align: center;
}

/* SPEAK / BUY: 254x43, at y 305 and y 404. */
.javier-choices {
    position: absolute;
    left: 53px;
    top: 305px;
    display: grid;
    gap: 56px;
}

.bigbutton {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 254px;
    height: 43px;
    background: #ff7e09;
    color: var(--mock-body);
    font-size: 20px;
    letter-spacing: 0.04em;
    text-decoration: none;
}

/* The shop and the tool screen sit in the stage below the portrait. */
.shop, .tool { top: 262px; }

.shelf {
    margin: 0;
    padding: 0;
    list-style: none;
}

/* Swatch 70x70 at x 44, rows pitched 107 — the mockup's shelf. */
.shelf-item {
    display: flex;
    gap: 14px;
    align-items: flex-start;
    min-height: 70px;
    margin: 0 0 37px;
}

.swatch {
    flex: none;
    width: 70px;
    height: 70px;
    background: var(--mock-tile);
}

.shelf-text { display: block; }
.shelf-text strong { color: #b8895e; font-size: 14px; }
.shelf-text .blurb { display: block; margin: 2px 0 4px; color: var(--dim); font-size: 11px; }
.shelf-text .owned { color: var(--gold); font-size: 11px; }

/* The selector is deliberately specific: M1's generic button[type="submit"]
 * rule is gold-on-night and would otherwise win, which put a gold button in
 * the middle of a screen the mockup paints orange. */
.shelf-item button.buy {
    margin: 0;
    padding: 4px 8px;
    font: inherit;
    font-size: 11px;
    color: var(--mock-body);
    background: #ff7e09;
    border: 0;
    cursor: pointer;
}

.tool textarea {
    width: 100%;
    padding: 0.5rem;
    font: inherit;
    /* 16px keeps iOS Safari from zooming the page when the field is focused. */
    font-size: 16px;
    color: var(--ink);
    background: rgba(0, 0, 0, 0.35);
    border: 1px solid rgba(232, 183, 74, 0.35);
}

/* A tool's output is often columns of shifted alphabets: it has to keep its
 * shape, and it has to be able to overflow without taking the stage with it. */
.tool-output {
    max-height: 180px;
    margin: 10px 0 0;
    padding: 8px;
    overflow: auto;
    background: rgba(0, 0, 0, 0.45);
    color: var(--ink);
    font-size: 11px;
    line-height: 1.35;
    white-space: pre;
}

.tool-broken { margin: 10px 0 0; color: #c76a6a; font-size: 12px; }

/* The level-unlock popup — shown exactly once per account (operator, 08-26).
 *
 * Above everything on the hub (the board is z-index 0, notices are 1), and
 * centred on the stage rather than pinned to a corner, because it is the one
 * thing on the screen at the moment it appears.
 *
 * BUILT FOR THE ART SWAP: this box is a fixed rectangle whose only visual is
 * its background, so replacing the placeholder fill with
 * `background-image: url(...)` here — and nothing else — turns it into
 * Tasha's graphic. `.unlock-text` is a separate child so animated text can
 * live on top of the picture instead of being baked into it.
 */
.unlock-popup {
    position: absolute;
    left: 40px;
    right: 40px;
    top: 240px;
    z-index: 4;
    padding: 14px 10px;
    background: var(--mock-javier);
    color: var(--ink);
    text-align: center;
    background-size: 100% 100%;
    background-repeat: no-repeat;
}

.unlock-text {
    margin: 0 0 10px;
    font-size: 15px;
}

.unlock-dismiss {
    color: inherit;
    font-size: 11px;
    text-decoration: underline;
}

/* ---------------------------------------------------------------------------
 * `.art` — the element's graphic CONTAINS its words.
 *
 * Most chrome sprites Tasha delivers will have the label drawn into the
 * picture: the level buttons, the tool and scoreboard bars, the unlock popup.
 * When that art lands, the HTML text must stop being painted — otherwise it
 * prints on top of the same word already in the PNG.
 *
 * **The text is not removed, only unpainted.** `color: transparent` keeps it
 * in the DOM, which three separate things depend on:
 *   - screen readers, which read it normally (GAME-DESIGN: "real HTML text
 *     for accessibility", whose only stated exception is cipher content);
 *   - the smoke suite, which reads `textContent` — deleting the words would
 *     turn an art change into a red suite for no reason;
 *   - the operator's own copy review, since the string still lives in the PHP
 *     where he can find and change it.
 *
 * Applying it is one rule here per element, and no markup change at all —
 * the ids already exist because M3a gave every chrome box one:
 *
 *     #hub-javier {
 *         background-image: url("art/javier.png");
 *         color: transparent;
 *     }
 *
 * Use the `.art` class instead when a whole family of elements takes art at
 * once (`class="cell art"`), so the shared properties are written once.
 *
 * **The one trap, and it is worth knowing before art day:** `cd26_tbd()`
 * placeholders render as ⟦TBD …⟧ so unreviewed copy is obvious on the page.
 * Under `.art` that marker is invisible too. An element that carries BOTH a
 * graphic and unreviewed placeholder copy therefore looks finished when it is
 * not. `./test.sh` against a prod target still fails while any cd26_tbd()
 * call remains, so nothing ships that way — but do not read a green-looking
 * screen as evidence the copy landed.
 */
.art {
    color: transparent;
    background-size: 100% 100%;
    background-repeat: no-repeat;
    background-position: center;
}

/* Selection would still highlight the invisible words; on a portrait-locked
 * game that is only ever an accident of a long press. */
.art::selection { background: transparent; }

/* The crossword (work order item 4).
 *
 * A real board: numbered squares, black squares, clues under it. Sized off the
 * column count the renderer puts in `--cw-cols`, so a 5x5 and a 12x12 both fit
 * the 360-wide stage without the PHP knowing anything about pixels.
 *
 * `aspect-ratio` keeps the squares square at any count, and the board scrolls
 * sideways rather than shrinking a 15-column puzzle into unusable slivers.
 */
.crossword {
    display: grid;
    grid-template-columns: repeat(var(--cw-cols, 5), 1fr);
    gap: 1px;
    max-width: 100%;
    margin: 0 0 10px;
    overflow-x: auto;
    background: var(--mock-body);
}

.cw-cell {
    position: relative;
    aspect-ratio: 1;
    background: var(--mock-tile);
}

/* A black square is not an input and is not focusable — tabbing through a
 * crossword must land only on squares a player can fill. */
.cw-blocked { background: var(--mock-body); }

.cw-number {
    position: absolute;
    top: 1px;
    left: 2px;
    font-size: 7px;
    line-height: 1;
    color: var(--mock-body);
    pointer-events: none;
}

/* No border, no outline offset: the square IS the box. 16px keeps iOS from
 * zooming the whole stage when a cell takes focus. */
.cw-cell input {
    width: 100%;
    height: 100%;
    padding: 0;
    border: 0;
    background: transparent;
    color: var(--mock-body);
    font-size: 16px;
    text-align: center;
    text-transform: uppercase;
}

.cw-cell input:focus { background: var(--mock-score); outline: none; }

.cw-clues { margin: 0 0 8px; font-size: 11px; }
.cw-clues h2 { margin: 0 0 3px; font-size: 11px; }
.cw-clues ol { margin: 0; padding-left: 18px; }
.cw-clues li { margin-bottom: 2px; }

/* Drag-to-reorder (work order item 5). The list you drag; the selects it
 * writes into are hidden by the script, never by this file — with no JS they
 * must stay visible and usable. */
.reorder { margin: 0 0 10px; }

.reorder-item {
    margin-bottom: 4px;
    padding: 8px 10px;
    background: var(--mock-tile);
    color: var(--mock-body);
    font-size: 12px;
    /* A drag target on a phone in a crowd. Also stops the browser treating the
     * gesture as a scroll, which is what makes touch dragging work at all. */
    touch-action: none;
    cursor: grab;
    user-select: none;
}

.reorder-item.dragging { opacity: 0.6; cursor: grabbing; }
.reorder-item:focus { outline: 2px solid var(--mock-score); }

/* The restore banner (resilience.md). Seen once, by each account, after a
 * rollback — and on a good week, never.
 *
 * Fixed and ABOVE the chrome, because in the document flow it was measured
 * sitting underneath `chrome-top` on the stage: present in the DOM, reported
 * visible, and covered by the fixed chrome bar on the one screen players
 * actually use. An apology nobody can read is worse than none.
 *
 * `pointer-events: none` is what makes that safe. The banner has no dismiss
 * control — it is claimed as it renders, so the next navigation is the
 * dismissal — and an overlay that swallowed taps would make the chrome
 * underneath it unusable for exactly the one page load a player is being
 * apologised to on. Taps pass straight through; only the words are on top. */
.restore-banner {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 10;
    margin: 0;
    padding: 10px 12px;
    background: var(--mock-score);
    color: var(--mock-body);
    font-size: 13px;
    line-height: 1.4;
    text-align: center;
    pointer-events: none;
}

/* Connections (connections-spec.md section 8).
 *
 * The tile IS the checkbox's label. There is no separate tile DOM, so nothing
 * here can drift out of step with the control: with no JS the `.enhanced` class
 * is never added, the checkbox shows, and the board is a plain readable list.
 * Everything that hides the input is scoped under `.enhanced` for that reason. */
.connections-found {
    margin: 0 0 8px;
    padding: 0;
    list-style: none;
}

/* A found group is a receipt, not a control: it is captioned with the author's
 * category name, which on this format is most of the satisfaction. */
.connections-found li {
    margin-bottom: 3px;
    padding: 5px 7px;
    background: var(--mock-level);
    color: var(--mock-tile);
    font-size: 10px;
    line-height: 1.3;
}

.connections-found strong { display: block; text-transform: uppercase; }

.connections {
    display: grid;
    /* Four across is the NYT board and what people expect; on a 360px stage
     * that is 80px a tile, which fits two short words. `minmax(0, 1fr)` rather
     * than `1fr` so a long label wraps instead of widening its column and
     * pushing the grid off the stage. */
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 4px;
    margin: 0 0 8px;
    padding: 0;
    list-style: none;
}

.connections .tile {
    display: flex;
    align-items: center;
    justify-content: center;
    /* Square-ish and thumb-sized. A tile a player misses on a crowded con floor
     * costs an attempt on a format where attempts are the difficulty curve. */
    min-height: 46px;
    padding: 4px 3px;
    background: var(--mock-tile);
    color: var(--mock-body);
    font-size: 10px;
    line-height: 1.15;
    text-align: center;
    word-break: break-word;
    cursor: pointer;
    user-select: none;
}

/* Only once the script has run. Before that the checkbox is the visible,
 * operable control and must not be hidden by anything. */
.connections.enhanced .tile input {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

/* Focus has to be drawn on the tile, because the input it belongs to is the
 * thing that is no longer visible. Keyboard play stays possible. */
.connections.enhanced .tile:focus-within { outline: 2px solid var(--mock-score); }

.connections .tile.picked {
    background: var(--mock-level);
    color: var(--mock-tile);
}

/* Capped out: this tile is not one of the four currently selected and cannot
 * be added. Dimmed rather than removed — the word on it is still part of the
 * puzzle a player is reading.
 *
 * Driven by a class the script sets, NOT by `:has(input:disabled)`. `:has` is
 * the obvious way to write this and it is not safe here: ./test.sh --legacy
 * runs pinned older engine builds, and Firefox only shipped `:has` in 121. A
 * selector that silently does nothing on one engine is exactly the class of
 * bug the three-engine suite exists to catch, so it is not written. */
.connections .tile.capped { opacity: 0.45; cursor: default; }

.connections-tools { margin: 0 0 8px; }

.connections-shuffle {
    padding: 5px 10px;
    border: 0;
    background: var(--mock-bar);
    color: var(--mock-body);
    font: inherit;
    font-size: 10px;
    cursor: pointer;
}

/* The logic grid (work order item 8). Scrolls sideways rather than shrinking:
 * a 6x6 matrix of selects does not fit 360px, and an unreadable grid is a
 * worse answer than one you swipe. */
.logicgrid-wrap { overflow-x: auto; margin: 0 0 10px; }

.logicgrid { border-collapse: collapse; font-size: 10px; }
.logicgrid th, .logicgrid td {
    border: 1px solid var(--mock-body);
    padding: 1px;
    background: var(--mock-tile);
    color: var(--mock-body);
}
.logicgrid thead th { vertical-align: bottom; }
.logicgrid th[scope="row"] { text-align: left; white-space: nowrap; }

/* A crossing the author did not include: drawn, so the matrix keeps its
 * shape, but not fillable. */
.logicgrid .lg-none { background: var(--mock-body); }

/* 16px stops iOS zooming the stage when a select takes focus. */
.logicgrid select {
    width: 100%;
    border: 0;
    background: transparent;
    font-size: 16px;
}

/* Per-challenge solve counts (M4 3b). Operator ruled them visible and
 * permanent — "I want to egg on people who want to solve it early and solve
 * the ones few people have" — so there is no hidden state here, only a corner
 * of the tile. Zero is shown, and is the loudest thing it ever says. */
.cell-solvers {
    position: absolute;
    right: 2px;
    bottom: 1px;
    /* Explicit, not inherited: the tile art hides the TITLE with a transparent
     * colour, and this counter must survive that (operator, 08-29 — visible
     * and permanent, no option to hide). */
    color: var(--mock-body);
    font-size: 8px;
    line-height: 1;
    opacity: 0.75;
    pointer-events: none;
}

/* The tile is already a flex box for its title; the count is positioned
 * against it. */
.cell { position: relative; }

.challenge .solvers { margin: 0 0 8px; font-size: 10px; opacity: 0.8; }
