Spaces:
Running
Running
| /* Page shell for the in-browser player. | |
| * | |
| * The table itself — tiles, the two boards, the status band, the log, the inline | |
| * scoring panels and the tile flights — lives in ui/board.css, which is a copy of | |
| * web/play/ui/board.css (see web/play/ui/PORTING.md). The hosted player and the | |
| * local GUI therefore draw the *same* table from two different back ends: a Flask | |
| * server there, a Web Worker running ONNX in the tab here. | |
| * | |
| * This file is only what belongs to *this* page: the linen ground, the header and | |
| * its setup bar, the engine (model download) strip, the column layout, the supply | |
| * and "how this works" boxes, and a harder look at small screens — this one is | |
| * meant to be playable on a phone. | |
| * | |
| * Layout, top to bottom: header · engine strip · status band · the playfield | |
| * (the factories on the left, the two identical boards — you above, AI below — | |
| * beside them; <body data-boards="stack">, the + in settings, restores the big | |
| * boards under the factories) · inline scoring · the move log and the about | |
| * box · the bottom bar (the AI's clock and the tile totals). Nothing is ever | |
| * covered by anything: there is no overlay and no toast on this page. | |
| */ | |
| @import url("../ui/board.css"); | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| color: var(--ink); | |
| font: 15px/1.5 var(--font-body); | |
| background-color: var(--linen); | |
| /* woven linen: two hairline threads plus a warm vignette */ | |
| background-image: | |
| linear-gradient(var(--linen-thread) 1px, rgba(0, 0, 0, 0) 1px), | |
| linear-gradient(90deg, var(--linen-thread) 1px, rgba(0, 0, 0, 0) 1px), | |
| radial-gradient(120% 90% at 50% 0%, var(--linen-warm) 0%, var(--linen) 55%, var(--linen-dark) 100%); | |
| background-size: 7px 7px, 7px 7px, 100% 100%; | |
| } | |
| h1, h2, h3 { margin: 0; font-weight: 400; } | |
| button, input, select { font: inherit; color: inherit; } | |
| /* ------------------------------------------------------------------ top bar */ | |
| .topbar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: flex-end; | |
| gap: 14px 24px; | |
| padding: 12px clamp(14px, 3vw, 34px); | |
| background: linear-gradient(var(--topbar-top), var(--topbar-bottom)); | |
| border-bottom: 1px solid var(--grout); | |
| box-shadow: 0 6px 18px -14px rgba(60, 44, 20, .6); | |
| } | |
| .brand { display: flex; align-items: baseline; gap: 12px; margin-right: auto; } | |
| .brand-name { | |
| font-family: var(--font-display); | |
| font-size: 28px; | |
| letter-spacing: .16em; | |
| text-transform: uppercase; | |
| /* the title is glazed like a tile: cobalt with a highlight along the top */ | |
| background: linear-gradient(var(--c0-hi), var(--c0) 55%, var(--c0-lo)); | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| color: transparent; | |
| } | |
| .brand-sub { font-family: var(--font-label); font-size: 12.5px; letter-spacing: .08em; color: var(--ink-soft); } | |
| .setup { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 14px; } | |
| .field { display: flex; flex-direction: column; gap: 4px; } | |
| .field-label { | |
| font-family: var(--font-label); | |
| font-size: 11px; | |
| letter-spacing: .16em; | |
| text-transform: uppercase; | |
| color: var(--ink-faint); | |
| } | |
| .field select, .field input { | |
| padding: 6px 9px; | |
| min-width: 132px; | |
| background: var(--field-bg); | |
| border: 1px solid var(--grout); | |
| border-radius: 2px; | |
| box-shadow: 0 1px 0 rgba(255, 255, 255, .8) inset; | |
| } | |
| .field-narrow input { min-width: 78px; width: 88px; } | |
| .field-narrow select { min-width: 132px; } | |
| /* the opponent's glaze, as a small tile beside the menu and on its board */ | |
| .bot-pick { display: flex; align-items: center; gap: 7px; } | |
| .bot-pick select { min-width: 150px; } | |
| .bot-chip { | |
| width: 14px; | |
| height: 14px; | |
| border-radius: 3px; | |
| flex: none; | |
| box-shadow: 0 0 0 1px rgba(0, 0, 0, .35), 0 1px 0 rgba(255, 255, 255, .45) inset; | |
| } | |
| .check { display: flex; align-items: center; gap: 7px; padding-bottom: 6px; font-size: 14px; } | |
| .check.coach { padding: 5px 10px 5px 8px; border: 1px solid var(--grout); border-radius: 2px; background: var(--field-bg); } | |
| .check.coach.off { opacity: .55; } | |
| .btn { | |
| padding: 7px 15px; | |
| border: 1px solid var(--grout); | |
| border-radius: 2px; | |
| background: linear-gradient(var(--btn-top), var(--btn-bottom)); | |
| cursor: pointer; | |
| font-family: var(--font-label); | |
| font-size: 13px; | |
| letter-spacing: .1em; | |
| text-transform: uppercase; | |
| transition: transform .12s ease, box-shadow .12s ease, background .12s ease; | |
| } | |
| .btn:hover { background: linear-gradient(var(--btn-top-hover), var(--btn-bottom-hover)); } | |
| .btn:active { transform: translateY(1px); } | |
| .btn-primary { | |
| color: var(--btn-primary-ink); | |
| border-color: var(--c0-lo); | |
| background: linear-gradient(var(--btn-primary-top), var(--c0) 60%, var(--btn-primary-bottom)); | |
| box-shadow: 0 1px 0 rgba(255, 255, 255, .35) inset, 0 2px 6px -3px rgba(20, 40, 80, .8); | |
| } | |
| .btn-primary:hover { background: linear-gradient(var(--btn-primary-top-hover), var(--btn-primary-bottom-hover)); } | |
| .btn:disabled { opacity: .45; cursor: not-allowed; transform: none; } | |
| :where(a, button, input, select, [tabindex]):focus-visible { | |
| outline: 2px solid var(--c4-lo); | |
| outline-offset: 2px; | |
| } | |
| /* ------------------------------------------------------- engine status bar */ | |
| /* The net is a 13 MB download, so its state is not a detail to hide in a corner: | |
| * it gets its own strip under the header, and the Deal button stays disabled | |
| * until the worker says it is ready. */ | |
| .engine-bar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 8px 12px; | |
| padding: 8px clamp(14px, 3vw, 34px); | |
| background: linear-gradient(var(--engine-top), var(--engine-bottom)); | |
| border-bottom: 1px solid var(--grout); | |
| font-family: var(--font-label); | |
| font-size: 12px; | |
| letter-spacing: .06em; | |
| color: var(--ink-soft); | |
| } | |
| .engine-dot { | |
| width: 9px; | |
| height: 9px; | |
| border-radius: 50%; | |
| background: var(--c1); | |
| box-shadow: 0 0 0 3px var(--c1-wash); | |
| animation: kiln 1.2s ease-in-out infinite; | |
| } | |
| @keyframes kiln { | |
| 0%, 100% { opacity: .35; } | |
| 50% { opacity: 1; } | |
| } | |
| .engine-bar.ready .engine-dot { background: var(--c4); box-shadow: 0 0 0 3px var(--c4-wash); animation: none; } | |
| .engine-bar.failed .engine-dot { background: var(--c2); box-shadow: 0 0 0 3px var(--c2-wash); animation: none; } | |
| .engine-text { flex: 1 1 auto; min-width: 200px; } | |
| /* what's new: one dismissible line, edged in cobalt so it reads as news */ | |
| .news-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 8px clamp(14px, 3vw, 34px); | |
| background: linear-gradient(var(--plaster-hi), var(--plaster)); | |
| border-bottom: 1px solid var(--grout); | |
| border-left: 4px solid var(--c0); | |
| font-family: var(--font-label); | |
| font-size: 13px; | |
| letter-spacing: .03em; | |
| color: var(--ink); | |
| } | |
| .news-badge { | |
| padding: 2px 8px; | |
| border-radius: 999px; | |
| background: var(--c0); | |
| color: var(--plaster-hi); | |
| font-size: 10.5px; | |
| letter-spacing: .14em; | |
| text-transform: uppercase; | |
| white-space: nowrap; | |
| } | |
| .news-bar[hidden] { display: none; } | |
| .news-text { flex: 1 1 auto; } | |
| .news-line { display: block; line-height: 1.55; } | |
| .news-close { | |
| border: 1px solid var(--badge-edge); | |
| background: none; | |
| border-radius: 999px; | |
| width: 22px; | |
| height: 22px; | |
| line-height: 1; | |
| cursor: pointer; | |
| color: var(--ink-soft); | |
| } | |
| .news-close:hover { color: var(--ink); } | |
| .engine-badge { | |
| padding: 3px 9px; | |
| border-radius: 999px; | |
| background: var(--badge-bg); | |
| border: 1px solid var(--badge-edge); | |
| text-transform: uppercase; | |
| letter-spacing: .12em; | |
| font-size: 11px; | |
| color: var(--c0-ink); | |
| white-space: nowrap; | |
| } | |
| /* -------------------------------------------------------------------- table */ | |
| .table { | |
| display: flex; | |
| flex-direction: column; | |
| gap: clamp(10px, 1.4vw, 18px); | |
| padding: clamp(12px, 1.8vw, 22px) clamp(12px, 3vw, 34px) 44px; | |
| max-width: 1560px; | |
| margin: 0 auto; | |
| } | |
| .middle-actions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 12px; | |
| margin-top: 10px; | |
| } | |
| /* The playfield. Two ways to draw it, picked in the settings panel and | |
| * defaulted per device (see ui/layout.js): | |
| * stack (the +, a desktop's default) — the classic table: big boards, side | |
| * by side below the factories; | |
| * side (the −, a phone's default) — the whole game on one screen: the | |
| * factories on the left, both boards, smaller, stacked beside them. | |
| * The base rules below are the stack look, so a desktop paints correctly | |
| * before any script runs; the phone media query flips its own default. */ | |
| .playfield { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr); | |
| gap: clamp(10px, 1.4vw, 18px); | |
| align-items: start; | |
| } | |
| .boards { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: clamp(10px, 1.4vw, 18px); | |
| align-items: start; | |
| } | |
| body[data-boards="side"] .playfield { | |
| grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); | |
| } | |
| body[data-boards="side"] .boards { | |
| grid-template-columns: minmax(0, 1fr); | |
| gap: clamp(8px, 1vw, 14px); | |
| } | |
| /* side layout: both boards must fit beside the factories, so they shrink */ | |
| body[data-boards="side"] .board { --tile: clamp(13px, 2.1vw, 36px); } | |
| .below { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1.7fr) minmax(240px, .8fr); | |
| gap: clamp(10px, 1.4vw, 18px); | |
| align-items: start; | |
| } | |
| .below-side { display: flex; flex-direction: column; gap: clamp(10px, 1.4vw, 18px); } | |
| /* ------------------------------------------------------------- game record */ | |
| /* Shown only once a game is over: the finished game, offered as a file. Quiet | |
| * by construction, because it is an invitation and not a demand. */ | |
| .record-bar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 10px 18px; | |
| } | |
| .record-bar[hidden] { display: none; } | |
| .record-note { | |
| flex: 1 1 320px; | |
| margin: 0; | |
| font-size: 13.5px; | |
| color: var(--ink-soft); | |
| } | |
| .record-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; } | |
| .record-link { | |
| font-family: var(--font-label); | |
| font-size: 12px; | |
| letter-spacing: .06em; | |
| color: var(--c0); | |
| } | |
| .record-bar.done .record-note { color: var(--ink); } | |
| /* --------------------------------------------------------------- bottom bar */ | |
| /* The quiet end of the page: the AI's clock lives here (moved off the top bar, | |
| * where it crowded a phone), next to one line of totals. #lid-row inside the | |
| * totals is where discarded tiles fly — a target, so it keeps a real box. */ | |
| .bottom-bar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: flex-end; | |
| gap: 12px 22px; | |
| } | |
| .counts { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 6px 16px; | |
| padding-bottom: 8px; | |
| font-family: var(--font-label); | |
| font-size: 12px; | |
| letter-spacing: .06em; | |
| color: var(--ink-soft); | |
| } | |
| #lid-row { border-radius: 3px; padding: 2px 4px; transition: background .3s ease; } | |
| #lid-row.receiving { background: var(--lid-receiving); } | |
| /* --------------------------------------------------------------- about box */ | |
| .about p { margin: 0 0 8px; font-size: 13.5px; color: var(--ink-soft); } | |
| .about p:last-child { margin-bottom: 0; } | |
| .about a { color: var(--c0); } | |
| .about-meta { | |
| font-family: var(--font-label); | |
| font-size: 11.5px; | |
| letter-spacing: .04em; | |
| color: var(--ink-faint); | |
| } | |
| body.locked .board-grid, body.locked .factories, body.locked .center-dish { cursor: progress; } | |
| .hidden { display: none ; } | |
| /* -------------------------------------------------------------- responsive */ | |
| @media (max-width: 1080px) { | |
| .below { grid-template-columns: minmax(0, 1fr); } | |
| .status { flex-wrap: wrap; } | |
| } | |
| @media (max-width: 720px) { | |
| /* Phone layout: the boards are the page, one above the other so neither of | |
| * them has to shrink below a playable tile. Every control keeps a 44px target. */ | |
| .topbar { gap: 6px 10px; padding: 8px 10px; } | |
| .brand { width: 100%; margin-right: 0; gap: 8px; } | |
| .brand-name { font-size: 19px; } | |
| .brand-sub { font-size: 11px; } | |
| /* seed, "you go first" and Deal share one row: the band, smaller */ | |
| .setup { width: 100%; gap: 6px 8px; align-items: center; flex-wrap: nowrap; } | |
| .setup .btn { flex: 0 0 auto; padding: 10px 11px; white-space: nowrap; } | |
| .setup .field { flex: 0 1 100px; } | |
| .setup .field .field-label { display: none; } | |
| .setup .check { flex: 0 0 auto; } | |
| .field select, .field input { min-width: 0; padding: 9px 8px; } | |
| .field-narrow select, .field-narrow input { width: auto; min-width: 0; } | |
| .btn { padding: 10px 14px; min-height: 44px; } | |
| .engine-bar { padding: 5px 10px; font-size: 11px; gap: 4px 10px; } | |
| .engine-text { min-width: 0; } | |
| /* the settings and the navigator are controls, so they get a tap target too */ | |
| .gear, .speed, .nav-btn { min-height: 44px; } | |
| .settings-panel { gap: 8px 12px; } | |
| .check { padding-bottom: 0; min-height: 44px; } | |
| .check input { width: 20px; height: 20px; } | |
| .table { padding: 8px 8px 32px; gap: 8px; } | |
| .panel { padding: 11px 11px 13px; } | |
| .scoring-grid { grid-template-columns: minmax(0, 1fr); } | |
| .log { max-height: 190px; } | |
| /* the status band earns its keep in one small line, not a headline */ | |
| .status { padding: 8px 12px 10px; gap: 10px; } | |
| .status-headline { font-size: 15px; } | |
| .status-detail { font-size: 10.5px; margin-top: 2px; min-height: 0; } | |
| .status-side { min-width: 40px; } | |
| .status-side-score { font-size: 19px; } | |
| .status-dash { font-size: 15px; } | |
| /* stack (+): the old phone table — one big board under the other */ | |
| body[data-boards="stack"] .boards { grid-template-columns: minmax(0, 1fr); } | |
| body[data-boards="stack"] .board { --tile: clamp(26px, 9vw, 38px); } | |
| body[data-boards="stack"] .factories { gap: 10px 12px; } | |
| body[data-boards="stack"] .factory { --dish-size: calc(var(--tile) * 2 + 22px); } | |
| body[data-boards="stack"] .middle-table { --tile: clamp(24px, 8vw, 32px); } | |
| body[data-boards="stack"] .board-grid { column-gap: 8px; } | |
| body[data-boards="stack"] .line { padding: 0 2px; } | |
| body[data-boards="stack"] .line .row-note { font-size: 10.5px; line-height: 1.15; } | |
| /* side (−, the default): factories left, both boards beside them. Small | |
| * tiles are the accepted price of seeing the whole game at once. */ | |
| body:not([data-boards="stack"]) .playfield { | |
| grid-template-columns: minmax(0, 1.04fr) minmax(0, .96fr); | |
| gap: 6px; | |
| } | |
| body:not([data-boards="stack"]) .boards { grid-template-columns: minmax(0, 1fr); gap: 6px; } | |
| body:not([data-boards="stack"]) .board { | |
| --tile: clamp(11px, calc((48vw - 50px) / 10), 38px); | |
| padding: 7px 6px 8px; | |
| gap: 6px; | |
| } | |
| body:not([data-boards="stack"]) .board-grid { column-gap: 3px; } | |
| body:not([data-boards="stack"]) .line { padding: 0 1px; gap: 2px; } | |
| body:not([data-boards="stack"]) .line .row-note { display: none; } | |
| body:not([data-boards="stack"]) .wall-row { gap: 2px; padding: 0 3px; } | |
| body:not([data-boards="stack"]) .board-head { column-gap: 6px; padding-bottom: 5px; } | |
| body:not([data-boards="stack"]) .board-who { font-size: 10px; letter-spacing: .1em; } | |
| body:not([data-boards="stack"]) .board-score { font-size: 18px; } | |
| body:not([data-boards="stack"]) .board-notes { font-size: 9.5px; min-height: 12px; } | |
| body:not([data-boards="stack"]) .floor { padding: 3px 2px; gap: 2px; } | |
| body:not([data-boards="stack"]) .floor .pen { font-size: 8.5px; } | |
| body:not([data-boards="stack"]) .middle-panel { padding: 8px 6px 10px; } | |
| body:not([data-boards="stack"]) .middle-table { --tile: clamp(17px, 5.6vw, 32px); } | |
| body:not([data-boards="stack"]) .factories { gap: 8px 8px; } | |
| body:not([data-boards="stack"]) .factory { --dish-size: calc(var(--tile) * 2 + 16px); } | |
| body:not([data-boards="stack"]) .center-dish { padding: 8px 10px; margin-top: 8px; } | |
| } | |
| @media (max-width: 420px) { | |
| body[data-boards="stack"] .board { --tile: clamp(23px, 10vw, 34px); } | |
| body[data-boards="stack"] .middle-table { --tile: clamp(21px, 8.5vw, 28px); } | |
| .engine-badge { font-size: 10px; letter-spacing: .08em; } | |
| .status { gap: 8px; } | |
| } | |
| /* A coarse pointer never hovers, so the hover lift just makes taps feel laggy. */ | |
| @media (hover: none) { | |
| button.tile:hover { transform: none; } | |
| button.line.open:hover { background: var(--c4-wash); filter: none; } | |
| } | |
| /* ========================================================== corner buttons == | |
| * Two small round buttons pinned to the bottom-right, the way every board-game | |
| * site pins its "?" — one scrolls to the What-this-is panel, one opens the | |
| * public tally. Fixed, but tiny: they float over the linen, never the table. */ | |
| .corner { | |
| position: fixed; | |
| right: 14px; | |
| bottom: 14px; | |
| z-index: 20; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .corner-btn { | |
| width: 42px; | |
| height: 42px; | |
| display: grid; | |
| place-items: center; | |
| padding: 0; | |
| border: 1px solid var(--grout); | |
| border-radius: 50%; | |
| background: linear-gradient(var(--btn-top), var(--btn-bottom)); | |
| box-shadow: var(--shadow-tile); | |
| cursor: pointer; | |
| color: var(--ink-soft); | |
| font-family: var(--font-display); | |
| font-size: 21px; | |
| line-height: 1; | |
| text-decoration: none; | |
| } | |
| .corner-btn:hover { | |
| background: linear-gradient(var(--btn-top-hover), var(--btn-bottom-hover)); | |
| color: var(--ink); | |
| } | |
| .corner-btn svg { width: 18px; height: 18px; fill: currentColor; display: block; } | |
| /* the "?" button's landing: the panel it scrolled to glows for a breath */ | |
| .about.lit { box-shadow: var(--shadow-panel), 0 0 0 3px var(--c4-wash); } | |
| .about { transition: box-shadow .5s ease; } | |