diff --git a/.gitignore b/.gitignore index e293eea..b7adb62 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ rust_parser/target/ # Assembled static site (tools/build_site.py output) dist/ LAUNCH.md -.wrangler \ No newline at end of file +.wrangler +# Screenshot-harness output (tools/ui_shots.py) +ui_shots/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6766e08..19125b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,8 @@ same `dist/` still serves in the browser via `serve_site.py`. | Path | Contents | | --- | --- | | `map/static/map/` | the web frontend (vanilla JS + Leaflet + WebGL layer, `worker.js`/`save_client.js` host the WASM parser) | +| `map/static/map/map.css` | design tokens (colour/type/spacing/radius scales), the app-shell grid, and per-feature styling | +| `map/static/map/ui.css` + `ui.js` | the shared UI primitives every feature builds from: buttons, fields, dialogs, list rows, bars, toggles, and the Escape-layer stack. New chrome should reuse these rather than restyle its own | | `map/static/map/icons/` | *(generated)* item/building icon PNGs | | `rust_parser/core/` | `sav_core`: the save parser + map-payload builder (pure Rust, embeds the game-data tables) | | `rust_parser/wasm/` | `sav_wasm`: the wasm-bindgen boundary the worker loads | @@ -90,7 +92,7 @@ same `dist/` still serves in the browser via `serve_site.py`. | `game_data/generated/docs/` | *(generated)* item/building/recipe/schematic/category/phase JSONs | | `game_data/generated/world/` | *(generated)* level-export tables: resource nodes, slugs, somersloops, mercer spheres, crash sites, dropped items, creature spawners, caves, world bounds | | `game_data/generated/` | *(generated)* `map_highres.png` + its tile pyramid | -| `tools/` | `build_site.py` / `serve_site.py` / `benchmark.py` / `fetch_test_saves.py` / `e2e_editor.py` / `release.py` | +| `tools/` | `build_site.py` / `serve_site.py` / `benchmark.py` / `fetch_test_saves.py` / `e2e_editor.py` / `ui_shots.py` / `ui_behaviour.py` / `release.py` | | `dist/` | *(generated)* the assembled static site | Everything marked *(generated)* is git-ignored and produced by the steps @@ -112,6 +114,21 @@ regression, needs `pip install playwright`) and the CI workflow in `.github/workflows/ci.yml`, which runs the Rust suite and the wasm build on every push to `main` and on pull requests. +The frontend has two browser-driven guards of its own — the chrome has no unit +tests, so these are what make a CSS or layout change verifiable: + +```bash +py tools/ui_shots.py --serve --out ui_shots/before # record, then make changes +py tools/ui_shots.py --serve --out ui_shots/after --baseline ui_shots/before +py tools/ui_behaviour.py --serve # dialogs, Escape layers, docks +``` + +`ui_shots.py` captures 17 UI states at three viewport widths and diffs them +pixel-wise against a previous run; `ui_behaviour.py` asserts the things a +screenshot cannot see (that dialogs are real modals, that the tooltip still +paints above one, that opening a category does not resize the map). Both run +against `dist/`, so build or copy the changed files there first. + Note: `sav_core` embeds `game_data/generated/{docs,world}/*.json` and the icon manifest at compile time, and **nothing generated is committed**, so the Rust crates do not build until you have either run `game_data/extract_all.py` or diff --git a/docs/dock-map-anchoring.md b/docs/dock-map-anchoring.md new file mode 100644 index 0000000..e0743e8 --- /dev/null +++ b/docs/dock-map-anchoring.md @@ -0,0 +1,94 @@ +# Keeping the map still when a dock opens — two failed attempts + +## The problem + +The docks are columns of the app-shell grid, so opening one changes the map's +box. Leaflet's `invalidateSize()` preserves the map's **centre**, which is the +wrong invariant when the box changes from one side: opening the ~280px layers +dock moves the map's left edge right by 280 *and* shrinks its width by 280, and +holding the centre still splits the difference. Measured drift of a fixed world +point, in viewport pixels: + +| Action | Drift | +| --- | --- | +| Show/hide the layers dock | ±141 px | +| Open/close a tool dock | ±161 px | + +This is real and worth fixing. Two attempts were made and **both were +reverted**; `panels.js` is back to a plain `invalidateSize()` in a +rAF-coalesced ResizeObserver. + +## Attempt 1 — compensate inside the ResizeObserver + +Pin the world point at viewport (0, 0), call +`invalidateSize({pan: false, animate: false})`, measure where that point ended +up, `panBy` the difference. Done synchronously in the observer, which runs +after layout and before paint. + +Geometrically correct — before/after drift went to 0 px. But the map still +visibly moved, because the object canvases are positioned relative to Leaflet's +map pane and repaint on the *next* frame via `_requestReset`'s rAF coalescing. +Re-anchoring the pane in one frame while the canvases catch up in the next +leaves a frame where every object is drawn offset from the tiles beneath it. + +## Attempt 2 — make the whole transition atomic + +Route every chrome mutation through a `withMapAnchored(mutate)` helper so the +resize, the compensation and a forced synchronous canvas repaint +(`MapApp.layer.resetNow()`, added to `map.js`) all happen in one task, and add +an idempotence guard so the helper and the ResizeObserver could not both +correct the same resize. + +Reported as **worse than either the bug or attempt 1**. Two likely reasons, +neither of which the tests could see: + +1. `resetNow()` forces a full synchronous redraw of every bucket in the click + handler. On a large save, with hardware acceleration off, that is a + main-thread stall where there used to be a coalesced repaint one frame + later. Trading a visual glitch for a freeze is a bad trade. +2. The drag path called it on every `pointermove`, i.e. a full redraw per + pointer event. + +## Why the tests said it was fine + +`tools/ui_behaviour.py` sampled `requestAnimationFrame` geometry in **headless** +Chrome against a small save. That measures whether the numbers line up. It does +not measure: + +- how long the main thread is blocked (the actual regression in attempt 2), +- what is really painted, as opposed to what the DOM says between frames, +- software rendering, which is how the app runs on at least one real machine. + +The lesson is not "add more geometry assertions". It is that this specific +problem cannot be validated headlessly: it needs a headed browser, a large +save, and a measurement of frame timing rather than element positions. + +## Resolution — don't resize the map at all + +There was no third attempt at compensating. The docks were changed to **overlay +the map** instead of taking grid columns: they are `position: fixed` against the +window's edges, and `#map` fills everything below the app bar at all times. + +The map's box is now a function of the window alone, so a dock opening, closing +or being dragged never resizes it, Leaflet is never asked to re-fit it, and +there is nothing to re-centre. The bug is gone by construction rather than by +correction — no compensation code, no forced repaint, no extra work per toggle. + +The docks still read as attached: flush to the edge, square, full height, one +border, opaque. What changed is only what is *behind* them. + +Costs, accepted: + +- A sliver of the world sits behind each dock. Panning reaches it, and closing + the docks reveals it with no movement at all. +- `#mapOverlays` (the layer holding the hint bars, the selection bar, the + active-filter banner) insets by `--dock-left-inset` / `--dock-right-inset` so + those still centre on the *visible* map rather than the full one. Those two + custom properties, and `body.has-rail` (set by altitude.js), exist only + because the layout no longer derives the dock widths from content. +- Leaflet's own controls live inside `#map`, so `.leaflet-left` / `.leaflet-right` + inset by the same values to stay clear of a dock. + +`tools/ui_behaviour.py` now asserts the CAUSE rather than the symptom: across +five actions plus a width drag, sampled every animation frame, the map's +bounding box must not change and a pinned world point must not move. diff --git a/map/static/map/altitude.js b/map/static/map/altitude.js index bc51fcb..295d4ed 100644 --- a/map/static/map/altitude.js +++ b/map/static/map/altitude.js @@ -259,6 +259,10 @@ var Altitude = {}; MapApp.setAltitudeRange(isFullRange ? -Infinity : initMin, isFullRange ? Infinity : initMax); panel.style.display = "flex"; + // The docks overlay the map, so the layout cannot derive their width from + // content any more -- this class is how the overlay layer and the Leaflet + // controls know the rail is taking room on the right (see map.css). + document.body.classList.add("has-rail"); layoutSliders(); }; @@ -267,6 +271,7 @@ var Altitude = {}; // start, unlike the save-to-save reloads build() preserves it across. Altitude.clear = function() { panel.style.display = "none"; + document.body.classList.remove("has-rail"); savedRange = null; MapApp.setAltitudeRange(-Infinity, Infinity); }; diff --git a/map/static/map/contextmenu.js b/map/static/map/contextmenu.js index fcb2839..c75ead3 100644 --- a/map/static/map/contextmenu.js +++ b/map/static/map/contextmenu.js @@ -149,11 +149,12 @@ var ContextMenu = {}; hide(); } }); - document.addEventListener("keydown", function(e) { - if (e.key === "Escape" && !e.defaultPrevented && menu.style.display !== "none") { - hide(); - e.preventDefault(); // One layer per press -- see finditem.js. + UI.onEscape(UI.LAYER.menu, function() { + if (menu.style.display === "none") { + return false; } + hide(); + return true; }); window.addEventListener("blur", hide); var mapContainer = document.getElementById("map"); diff --git a/map/static/map/data.js b/map/static/map/data.js index e2dcd28..7c33101 100644 --- a/map/static/map/data.js +++ b/map/static/map/data.js @@ -34,7 +34,10 @@ // top progress bar above was easy to miss on big operations, leaving no // clear sign that anything was happening. Shown after a short delay so // instant edits don't flash it. - var busyOverlay = document.getElementById("busyOverlay"); + // A modal rather than a plain overlay: the browser then makes the + // page genuinely inert while the worker crunches, and top-layer order (by + // promotion time) puts this above any dialog that was already open. + var busyDialog = UI.dialog("busyDialog"); var busyLabel = document.getElementById("busyLabel"); var busyFill = document.getElementById("busyFill"); var busyPhase = document.getElementById("busyPhase"); @@ -45,10 +48,10 @@ busyLabel.textContent = label || "Working…"; busyPhase.textContent = ""; busyFill.style.width = "0%"; - if (busyTimer === null && busyOverlay.style.display === "none") { + if (busyTimer === null && !busyDialog.isOpen()) { busyTimer = setTimeout(function() { busyTimer = null; - busyOverlay.style.display = "flex"; + busyDialog.open(); }, BUSY_SHOW_DELAY_MS); } } @@ -63,7 +66,7 @@ clearTimeout(busyTimer); busyTimer = null; } - busyOverlay.style.display = "none"; + busyDialog.close(); } // Load panel: always-on drop zone + hidden file input (the click target). diff --git a/map/static/map/editor.js b/map/static/map/editor.js index fe0e41e..8cddd7b 100644 --- a/map/static/map/editor.js +++ b/map/static/map/editor.js @@ -53,7 +53,7 @@ var EditorTool = (function() { // container-positioned div: the pane transform carries it through pans AND // the wheel-zoom CSS animation, so it never desyncs mid-animation. var ghostRect = null; - var offsetOverlay, offsetDx, offsetDy, offsetDz, offsetRot, offsetApply, offsetCancel; + var offsetDialog, offsetDx, offsetDy, offsetDz, offsetRot, offsetApply, offsetCancel; var pastePanel, pastePanelTitle, pastePosOriginal, pastePosCustom; var pasteX, pasteY, pasteZ, pasteDx, pasteDy, pasteDz, pasteRot, pasteResult; var pastePanelApplyBtn, pastePanelCancelBtn; @@ -924,11 +924,11 @@ var EditorTool = (function() { pasteRot.value = "0"; setPasteXYFields(p.anchorWorld); refreshPasteResult(); - pastePanel.style.display = "block"; + Panels.openTool(pastePanel); // Into the right tool dock -- see panels.js. } function closePastePanel() { - pastePanel.style.display = "none"; + Panels.closeTool(pastePanel); } // Typing X/Y switches to a custom position; the radio switch back to @@ -1003,13 +1003,12 @@ var EditorTool = (function() { offsetDy.value = "0"; offsetDz.value = "0"; offsetRot.value = "0"; - offsetOverlay.style.display = "flex"; + offsetDialog.open(); offsetDx.focus(); } function closeOffsetDialog() { - offsetOverlay.style.display = "none"; - offsetTargets = null; + offsetDialog.close(); // onClose (bound in init) drops offsetTargets. } function applyOffsetDialog() { @@ -1087,7 +1086,7 @@ var EditorTool = (function() { undoBtn = document.getElementById("editorUndoBtn"); redoBtn = document.getElementById("editorRedoBtn"); hintBar = document.getElementById("editorHint"); - offsetOverlay = document.getElementById("offsetDialogOverlay"); + offsetDialog = UI.dialog("offsetDialog"); offsetDx = document.getElementById("offsetDx"); offsetDy = document.getElementById("offsetDy"); offsetDz = document.getElementById("offsetDz"); @@ -1131,33 +1130,27 @@ var EditorTool = (function() { e.preventDefault(); } }); - offsetOverlay.addEventListener("click", function(e) { - if (e.target === offsetOverlay) { - closeOffsetDialog(); - } - }); - offsetOverlay.addEventListener("keydown", function(e) { + // The handles Escape, the backdrop click and the X itself; + // this only adds Enter-to-apply and the state teardown. + offsetDialog.el.addEventListener("keydown", function(e) { if (e.key === "Enter") { applyOffsetDialog(); } }); + offsetDialog.onClose(function() { offsetTargets = null; }); + + // Escape while a placement ghost is up cancels the placement. The offset + // dialog is a real , so its own Escape is the browser's job. + UI.onEscape(UI.LAYER.placement, function() { + if (placement === null) { + return false; + } + cancelPlacement(); + return true; + }); document.addEventListener("keydown", function(e) { var inInput = e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA"); - if (e.key === "Escape") { - // Peel one layer per press (see finditem.js): only claim the event - // if a placement or the offset dialog was actually open. - if (e.defaultPrevented) { - return; - } - var acted = placement !== null || offsetOverlay.style.display !== "none"; - cancelPlacement(); - closeOffsetDialog(); - if (acted) { - e.preventDefault(); - } - return; - } if (placement && !inInput && (e.key === "r" || e.key === "R")) { placement.rotSteps = (placement.rotSteps + 1) % 4; if (placement.mode !== "move") { diff --git a/map/static/map/filters.js b/map/static/map/filters.js index 73a80a1..34ac720 100644 --- a/map/static/map/filters.js +++ b/map/static/map/filters.js @@ -413,27 +413,8 @@ var Filters = {}; }); } - function el(tag, className, text) { - var e = document.createElement(tag); - if (className) e.className = className; - if (text !== undefined) e.textContent = text; - return e; - } - - // A checkbox wrapped in a - + + diff --git a/map/static/map/map.css b/map/static/map/map.css index d9eae28..083362e 100644 --- a/map/static/map/map.css +++ b/map/static/map/map.css @@ -1,51 +1,133 @@ +/* =========================================================================== + Design tokens + --------------------------------------------------------------------------- + Every value the UI is allowed to use. If a rule below needs a colour, size, + radius or spacing that isn't in here, the right move is almost always to + pick the nearest token rather than to add a new one -- the app used to + carry 19 distinct font sizes, 11 radii and 63 padding shorthands, which is + how two panels that were meant to look identical drifted apart. + + The primitives that consume these (buttons, inputs, dialogs, rows, bars) + live in ui.css; this file is layout + the app's own features. + ========================================================================= */ :root { - --nav-col-width: clamp(240px, 19vw, 340px); - --detail-col-width: 280px; - --altitude-width: 64px; - /* Horizontal center OF THE MAP, for anything that floats over the middle - of it -- use this instead of a plain 50%, paired with the usual - transform: translateX(-50%). - The map deliberately stops short of the right edge (the altitude rail is - docked there, see #map's right inset), but every one of those floating - panels is a body-level SIBLING of #map, so their 50% is the window's - center, not the map's -- half a rail-width too far right. That drift is - invisible on its own and obvious the moment one of them sits under the - search pill, which IS centered on the map (#topBar reserves the same - rail width in its right padding). */ - --map-center-x: calc(50% - var(--altitude-width) / 2); - /* One shared feel for the floating panels: how they slide/settle, and the - lifted-off-the-map shadow every floating control shares. */ + /* ---- Layout ------------------------------------------------------------ + The three tracks of the app grid: the left dock (layers + save file), the + map, and the right tool dock. Panels no longer float over the map, so + these widths are also literally how much room the map has -- see the + #appShell grid in this file. */ + --dock-left-width: clamp(248px, 20vw, 340px); + --dock-right-width: 320px; + /* The tool dock collapsed to just the altitude rail. */ + --rail-width: 64px; + --appbar-height: 48px; + + /* How much of the window each dock is currently covering. The docks overlay + the map rather than taking a column of the grid (see the app-shell block + below for why), so the layout no longer derives these from content and + they have to be stated. Everything that needs to sit clear of a dock -- + the map's floating overlay layer, Leaflet's own controls -- insets by + these rather than by guessing. */ + --dock-left-inset: var(--dock-left-width); + --dock-right-inset: 0px; + + /* One shared feel for anything that moves, and the lifted shadow the + genuinely floating things (hints, popovers, tooltip) share. Docked + surfaces get a border instead -- they are attached, not hovering. */ --panel-ease: cubic-bezier(0.2, 0, 0, 1); --float-shadow: 0 4px 18px rgba(0, 0, 0, 0.45); - - /* ---- Color system: slate surfaces, one blue accent for everything - interactive, FICSIT orange reserved for the brand + the "get a save - onto the map" path (logo, Load save, progress), and the existing pink - kept as the "something is hidden from view" signal. */ - --surface-0: #131519; /* deepest chrome: top bar, sidebar footer */ - --surface-1: #1a1d22; /* panels: sidebar, altitude rail */ - --surface-2: #21242b; /* popups: modals, dropdown, tooltip, menus */ - --raised: #23262c; /* controls: buttons, fields, cards */ + --popover-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); + + /* ---- Colour ------------------------------------------------------------ + Slate surfaces, one blue accent for everything interactive, FICSIT orange + reserved for the brand + the "get a save onto the map" path (logo, load, + progress), pink as the "something is hidden from view" signal, amber for + pending save edits + warnings, cyan owned by the network tool. */ + --surface-0: #131519; /* deepest chrome: app bar, dock footers */ + --surface-1: #1a1d22; /* docks */ + --surface-2: #21242b; /* popups: dialogs, dropdowns, tooltip, menus */ + --raised: #23262c; /* controls: buttons, fields, cards, stat tiles */ --raised-hover: #2b2f37; - --inset: #171a20; /* wells sunk into a popup (icon frames, bar tracks) */ - --border: #30333b; - --border-popup: #3a3f4a; + --inset: #171a20; /* wells sunk into a surface (icon frames, tracks) */ + --border: #30333b; /* inside a dock */ + --border-popup: #3a3f4a;/* around/inside a popup */ + --border-sub: #2c2f37; /* hairline between list rows */ --border-hover: #4a505c; + --text: #d9dde4; --text-bright: #fff; - --muted: #8a92a3; - --faint: #6a7180; + --text-dim: #c3c9d4; /* secondary values inside a row */ + --muted: #8a92a3; /* labels, captions */ + --faint: #6a7180; /* placeholder, disabled glyphs, section kickers */ + --accent: #5ba3e0; --accent-soft: #8ab4f8; + --accent-bright: #aecbfa; --accent-bg: #2a3545; --accent-bg-hover: #33435a; --accent-border: #3a5070; + /* Solid selection fill -- the keyboard-highlighted suggestion row and the + hovered context-menu item. Deliberately louder than --accent-bg: those + two are "the thing you are about to commit to", not a tint. */ + --select-bg: #2f6bd8; + --brand: #f2913d; /* FICSIT orange */ --brand-bright: #ffa552; + --brand-bg: #3d2d1c; + --brand-border: #8a5e2e; + --hidden-pink: #ff3b81; --hidden-pink-soft: #ff9ec2; --hidden-pink-bg: #3a2330; + --hidden-pink-bg-hover: #4a2a3c; + --hidden-pink-border: #6b2b47; + + --warn: #ffb020; /* bottleneck / mixed-mark warnings */ + --warn-soft: #ffd48a; + --warn-text: #ffd7ae; /* pending save edits */ + --ok: #58a565; + --ok-soft: #7fd18b; + --danger: #ff5f56; /* destructive actions (Delete) */ + --danger-soft: #ff9a94; + --danger-bg: #3a2222; + --danger-border: #7a3a36; + + --tool-cyan: #25e0ff; /* the network tool's own signal colour */ + --tool-cyan-bg: rgba(37, 224, 255, 0.14); + --tool-cyan-border: rgba(37, 224, 255, 0.45); + + --vehicle: #f39c12; /* matches filters.js's VEHICLE_COLOR */ + + /* ---- Type -------------------------------------------------------------- + Eight steps. --fs-md is the body default. */ + --fs-2xs: 10px; /* small-caps kickers, meta lines */ + --fs-xs: 11px; /* captions, section labels, counts */ + --fs-sm: 12px; /* dense controls, buttons, hints */ + --fs-md: 13px; /* body */ + --fs-lg: 14px; /* list rows, nav labels */ + --fs-xl: 15px; /* search field, emphasis */ + --fs-2xl: 18px; /* dialog titles */ + --fs-3xl: 21px; /* headline numbers */ --font: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif; + --font-mono: "Consolas", "SF Mono", monospace; + + /* ---- Spacing (4px base) ------------------------------------------------- */ + --sp-05: 2px; + --sp-1: 4px; + --sp-15: 6px; + --sp-2: 8px; + --sp-25: 10px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + + /* ---- Radius ------------------------------------------------------------- */ + --r-xs: 4px; /* swatches, bar tracks, tiny chips */ + --r-sm: 6px; /* inputs, list rows, compact buttons */ + --r-md: 8px; /* buttons, wells, cards */ + --r-lg: 12px; /* dialogs, popovers, docked panel corners */ + --r-pill: 999px; } html, body { @@ -53,343 +135,470 @@ html, body { height: 100%; overflow: hidden; font-family: var(--font); - font-size: 13px; + font-size: var(--fs-md); background: #000; } -/* Dark, slim scrollbars everywhere -- the browser's default light-gray bars - were the single most visibly "un-themed" element left on Windows. */ -* { - scrollbar-width: thin; - scrollbar-color: #3d424c transparent; -} - -::-webkit-scrollbar { - width: 9px; - height: 9px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: #3d424c; - border-radius: 999px; - border: 2px solid transparent; - background-clip: content-box; -} - -::-webkit-scrollbar-thumb:hover { - background-color: #4d535f; -} - -::selection { - background: rgba(91, 163, 224, 0.35); -} - -/* One consistent hover/press feel for every button in the app, instead of - each one transitioning (or snapping) on its own. */ -button { - font-family: inherit; - transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; -} - -button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; +/* =========================================================================== + App shell + --------------------------------------------------------------------------- + An app bar, and the map filling everything below it. The docks are pinned to + the window's edges ON TOP of the map: + + +--------------------------------------------------+ + | app bar | + +------------+------------------------+------------+ + | left dock | map | tool dock | + | (layers) | (full width, beneath) | (+ rail) | + +------------+------------------------+------------+ + + They still read as attached -- flush to the edge, square, full height, one + border -- but they are out of flow, which is the important part: THE MAP'S + BOX NEVER CHANGES when a dock opens or closes. + + That is a deliberate reversal. The docks used to be real grid columns, so + the map was literally the space between them. It looked right and it cost + too much: every dock toggle resized the map, and Leaflet's invalidateSize + preserves the map's CENTRE, so the world slid sideways by half the width + delta (~141px for the layers dock, ~161px for a tool dock). Two attempts at + compensating for that were written and reverted -- see + docs/dock-map-anchoring.md. Not resizing the map at all removes the problem + rather than correcting for it: nothing to compensate, nothing to repaint, + nothing to get wrong. + + What it costs: the map extends underneath the docks, so a sliver of world is + hidden behind them. Panning still reaches it, and the map keeps its full + width when both docks are closed. + ========================================================================= */ +body { + display: grid; + /* minmax(0, 1fr), not a bare 1fr: a bare `1fr` is minmax(AUTO, 1fr), so the + row floors at its content's min-content size and tall content pushes the + grid past the viewport instead of scrolling inside it. */ + grid-template-rows: var(--appbar-height) minmax(0, 1fr); + background: var(--surface-0); + color: var(--text); } -/* ---- Top controls: no bar anymore -- a transparent, non-blocking overlay - row of individually floating controls sitting directly over the map - (Spotlight-style): menu + logo top-left, the search field centered, the - per-save status buttons top-right. pointer-events:none on the row itself - so the map stays pannable between the controls; each control re-enables - its own pointer events below. Right padding leaves room for the altitude - rail docked at the right edge. */ +/* ---- App bar ------------------------------------------------------------- + A real bar across the window. The two side sections grow equally from a + zero basis, which is what centres the search field regardless of what + either side weighs; max-content floors stop the pills being squashed, and + the search field gives way first on a narrow window. */ #topBar { - position: absolute; - top: 0; - left: 0; - right: 0; - box-sizing: border-box; + grid-row: 1; display: flex; - align-items: flex-start; - gap: 10px; - /* Left padding matches #sidebar's left:12px exactly -- the brand cluster - docks onto the sidebar, so even a 2px offset between their left edges - read as a misalignment. */ - padding: 12px 14px 0 12px; - padding-right: calc(var(--altitude-width) + 12px); - z-index: 60; /* Above the sidebar overlay (40) so the suggestions dropdown paints over it. */ - pointer-events: none; - background: none; -} - -#menuButton, #logoButton, #searchBox, #statusMenuWrap, .topPillButton { - pointer-events: auto; + align-items: center; + gap: var(--sp-3); + padding: 0 var(--sp-3); + box-sizing: border-box; + background: var(--surface-0); + border-bottom: 1px solid var(--border); + /* Above both docks so the search suggestions drop over them. */ + z-index: 10; } -/* The bar's two side sections (see index.html's #topBar comment): equal - flex grow from a ZERO basis makes them the same width whenever there's - room, which is what actually centers the search pill -- with the old - single-row layout it was only centered in the leftover space, so it sat - visibly off-center whenever the sides were unequal (always, and worse in - the desktop app once the update pill appeared). max-content floors keep - the pills from being squashed on narrow windows; the search pill then - gives way instead (flex-shrink on #mainSearchWrap). */ #topBarLeft, #topBarActions { flex: 1 1 0; min-width: max-content; display: flex; - align-items: flex-start; - gap: 10px; - pointer-events: none; + align-items: center; + gap: var(--sp-2); } #topBarActions { justify-content: flex-end; } -/* Hamburger + wordmark as ONE card (see #brandCluster in index.html) -- - two separate floating buttons read as disconnected little islands. - While the sidebar is shown the card sits flush on its top edge (same - surface, square bottom corners, no bottom border) so it reads as the - panel's header; .detached (panel hidden, see panels.js) restores the - free-floating rounded-card look. */ #brandCluster { flex: none; display: flex; - align-items: stretch; - height: 42px; - background: var(--surface-1); - border: 1px solid var(--border-popup); - border-bottom: none; - border-radius: 12px 12px 0 0; - box-shadow: var(--float-shadow); - overflow: hidden; - pointer-events: auto; - box-sizing: border-box; -} - -#brandCluster.detached { - border-bottom: 1px solid var(--border-popup); - border-radius: 12px; + align-items: center; + gap: var(--sp-1); } -/* Top-left hamburger -- shows/hides the whole sidebar overlay (panels.js). */ -#menuButton { - flex: none; - width: 44px; +#mainSearchWrap { + flex: 0 1 560px; + min-width: 0; display: flex; - align-items: center; justify-content: center; - color: var(--text); - background: none; - border: none; - border-right: 1px solid var(--border); - cursor: pointer; -} - -#menuButton:hover { - background: var(--raised-hover); - color: var(--text-bright); -} - -/* While the panel is hidden, the button stays quietly accented so it's - findable as "the way to get the panel back". */ -#menuButton.panelHidden { - background: var(--accent-bg); - color: var(--accent-soft); -} - -#menuButton svg { - display: block; } -/* The one brand moment in the chrome: a two-line FICSIT-orange wordmark - (small-caps kicker over the name) instead of a generic gray button. Still - a real button -- it navigates back to mode selection. */ -#logoButton { - flex: none; +/* ---- Left dock: layers ---------------------------------------------------- */ +#sidebar { + position: fixed; + top: var(--appbar-height); + left: 0; + bottom: 0; + width: var(--dock-left-width); + z-index: 20; + box-sizing: border-box; display: flex; flex-direction: column; - align-items: flex-start; - justify-content: center; - gap: 1px; - padding: 0 14px 0 11px; - background: none; - border: none; - border-left: 3px solid var(--brand); - cursor: pointer; - box-sizing: border-box; - text-align: left; + overflow: hidden; + background: var(--surface-1); + border-right: 1px solid var(--border); } -.logoKicker { - font-size: 9px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.16em; - color: var(--faint); - line-height: 1; +body.dock-hidden { + --dock-left-inset: 0px; } -.logoName { - font-size: 14.5px; - font-weight: 700; - letter-spacing: 0.01em; - color: var(--brand); - line-height: 1.1; - white-space: nowrap; +body.dock-hidden #sidebar { + display: none; } -#logoButton:hover { - background: var(--raised-hover); - border-left-color: var(--brand-bright); +/* The two panes sit side by side in a double-width strip; showing the detail + pane slides the strip by half its width. Nothing resizes, so this is the + one layout animation the shell can afford. */ +#dockPager { + flex: 1 1 auto; + min-height: 0; + display: flex; + width: 200%; + transform: translateX(0); + transition: transform 0.22s var(--panel-ease); } -#logoButton:hover .logoName { - color: var(--brand-bright); +body.category-open #dockPager { + transform: translateX(-50%); } -#logoButton:hover .logoKicker { - color: var(--muted); +@media (prefers-reduced-motion: reduce) { + #dockPager { transition: none; } } -/* Spotlight-style search: a free-floating pill centered over the map (no - band behind it), with a search glyph inside it and a custom suggestions - dropdown (see #searchSuggestions) anchored directly beneath -- replacing - the native , whose dropdown the browser positioned and styled - itself (badly, and with no room for item icons). */ -#mainSearchWrap { - /* Fixed 560px basis (the search pill's full width) between the two - equal-width side sections -- shrinks only once both sides are down to - their max-content floors. */ - flex: 0 1 560px; +.dockPane { + width: 50%; min-width: 0; display: flex; - justify-content: center; - pointer-events: none; + flex-direction: column; + overflow: hidden; } -#searchBox { - position: relative; - width: 100%; - max-width: 560px; +/* Header of the detail pane: the way back, the category's colour, its name. */ +#detailHeader { + flex: none; display: flex; align-items: center; + gap: var(--sp-2); + padding: var(--sp-15) var(--sp-2); + border-bottom: 1px solid var(--border); + font-size: var(--fs-lg); + font-weight: 600; + color: var(--text-bright); } -#searchIcon { - position: absolute; - left: 16px; - color: #8a92a3; - pointer-events: none; +#detailSwatch { + width: 12px; + height: 12px; + border-radius: var(--r-xs); } -#mainSearchInput { - width: 100%; +#categoryNavHeader { + flex: none; + display: flex; + gap: var(--sp-2); + padding: var(--sp-25); + border-bottom: 1px solid var(--border); +} + +#categoryNavColumn { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: var(--sp-2) var(--sp-15); box-sizing: border-box; - height: 42px; - font-family: var(--font); - font-size: 15px; - padding: 0 18px 0 44px; - border-radius: 999px; - border: 1px solid var(--border-popup); - background: var(--surface-2); - color: #eee; - box-shadow: var(--float-shadow); - transition: border-color 0.12s ease, background-color 0.12s ease, box-shadow 0.12s ease; } -#mainSearchInput:hover { - border-color: var(--border-hover); +#categoryDetailPane { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: var(--sp-15) var(--sp-2); + box-sizing: border-box; } -#mainSearchInput:focus { - outline: none; - border-color: var(--accent); - background: #262a31; - box-shadow: var(--float-shadow), 0 0 0 3px rgba(91, 163, 224, 0.18); +/* Save-file controls, pinned below the pager: dock chrome, not the content of + either pane, so they stay put while the panes slide. */ +#sidebarFooter { + flex: none; + max-height: 46%; + overflow-y: auto; + padding: var(--sp-3); + box-sizing: border-box; + border-top: 1px solid var(--border); + background: var(--surface-0); } -#mainSearchInput::placeholder { - color: #888; +/* Invisible grab strip on the dock's outer edge, lighting up as a thin accent + stripe on hover/drag (see panels.js). */ +.dockResizeHandle { + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 7px; + cursor: col-resize; + touch-action: none; + z-index: 5; } -/* Custom suggestions dropdown -- anchored to #searchBox (position:relative), - so it always matches the search field's width and sits just below it. */ -#searchSuggestions { +.dockResizeHandle::before { + content: ""; position: absolute; - top: calc(100% + 6px); - left: 0; + top: 0; + bottom: 0; right: 0; - max-height: min(70vh, 440px); - overflow-y: auto; - background: #21242b; - border: 1px solid #3a3f4a; - border-radius: 12px; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); - padding: 6px; - z-index: 20; + width: 2px; + background: transparent; + transition: background-color 0.12s ease; } -.searchSuggestionRow { - display: flex; - align-items: center; - gap: 11px; - padding: 8px 10px; - border-radius: 8px; - cursor: pointer; - transition: background-color 0.08s ease; +.dockResizeHandle:hover::before, +.dockResizeHandle.dragging::before { + background: var(--accent); } -.searchSuggestionRow.active { - background: #2f6bd8; +body.dockResizing { + cursor: col-resize; + user-select: none; } -.searchSuggestionRow.active .searchSuggestionLabel { - color: #fff; +/* ---- Map ------------------------------------------------------------------ */ +/* Fills the whole area below the app bar, dock or no dock. Its box is a + function of the WINDOW only, which is the entire point of this layout. */ +#map { + grid-row: 2; + min-width: 0; + background: #000; + /* Contain Leaflet's internal stacking -- its panes/zoom controls use + z-index values up to ~1000, which would otherwise live in the root + stacking context and paint over the app bar's suggestions dropdown. */ + isolation: isolate; } -.searchSuggestionIcon { - width: 26px; - height: 26px; - flex: none; - object-fit: contain; +/* Pointer-transparent layer sharing the map's grid cell. Everything that + still floats lives in here, so "centred over the map" is left:50% of this + element and nothing has to know about dock widths. Each child re-enables + its own pointer events. */ +/* The map fills the window, but the floating hints and bars should centre on + the part of it you can actually SEE, so this layer -- unlike #map -- insets + by whatever the docks are covering. It is the only thing that needs to know + the dock widths, and it costs the map nothing to tell it. */ +#mapOverlays { + position: fixed; + top: var(--appbar-height); + left: var(--dock-left-inset); + right: var(--dock-right-inset); + bottom: 0; + pointer-events: none; + z-index: 15; + transition: left 0.12s var(--panel-ease), right 0.12s var(--panel-ease); } -/* Vehicle glyphs (icons/vehicles/*.png) are the game's white-on-transparent - monochrome icons -- backed by the same solid vehicle-orange circle their - map pins use (filters.js's VEHICLE_COLOR) so they hold up on any row state - and read as vehicles next to the full-color item/building icons. */ -.searchSuggestionVehicleIcon { - background: #f39c12; - border-radius: 50%; - padding: 4px; - box-sizing: border-box; +@media (prefers-reduced-motion: reduce) { + #mapOverlays { transition: none; } } -.searchSuggestionLabel { - flex: 1 1 auto; - min-width: 0; - font-size: 14px; - color: #eee; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +#mapOverlays > * { + pointer-events: auto; } -/* "ITEMS" / "BUILDINGS" section divider inside the dropdown -- see - finditem.js's renderSuggestions, which now groups matches by kind. */ -.searchSuggestionGroupLabel { - padding: 7px 10px 4px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; +/* ---- Right dock: tools ---------------------------------------------------- */ +#toolDock { + position: fixed; + top: var(--appbar-height); + right: 0; + bottom: 0; + z-index: 20; + display: flex; + flex-direction: row; + background: var(--surface-1); +} + +/* Its width is content-driven (rail, tool panel, both or neither) -- these + mirror it for everything that has to sit clear of it. */ +body.has-rail { + --dock-right-inset: var(--rail-width); +} + +body.tool-open { + --dock-right-inset: var(--dock-right-width); +} + +body.tool-open.has-rail { + --dock-right-inset: calc(var(--dock-right-width) + var(--rail-width)); +} + +/* Holds whichever tool panel is open (panels.js moves them in). Collapsed to + nothing when none is -- which is what makes the `auto` grid track vanish. */ +#toolPanels { + display: none; + width: var(--dock-right-width); + min-width: 0; + border-left: 1px solid var(--border); +} + +body.tool-open #toolPanels { + display: flex; + flex-direction: column; +} + +/* A tool panel is now a dock pane: it fills the dock, and drops the card + treatment (own border radius, shadow, absolute position) it needed while it + floated over the map. */ +#toolPanels > * { + position: static; + width: 100%; + max-height: none; + box-sizing: border-box; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: var(--sp-3); + background: none; + border: none; + border-radius: 0; + box-shadow: none; + color: var(--text); + font-size: var(--fs-md); +} + +/* Dark, slim scrollbars everywhere -- the browser's default light-gray bars + were the single most visibly "un-themed" element left on Windows. */ +* { + scrollbar-width: thin; + scrollbar-color: #3d424c transparent; +} + +::-webkit-scrollbar { + width: 9px; + height: 9px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #3d424c; + border-radius: var(--r-pill); + border: 2px solid transparent; + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #4d535f; +} + +::selection { + background: rgba(91, 163, 224, 0.35); +} + +/* One consistent hover/press feel for every button in the app, instead of + each one transitioning (or snapping) on its own. */ +button { + font-family: inherit; + transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.logoKicker { + font-size: var(--fs-2xs); + font-weight: 600; text-transform: uppercase; - color: #6a7180; + letter-spacing: 0.16em; + color: var(--faint); + line-height: 1; +} + +.logoName { + font-size: var(--fs-lg); + font-weight: 700; + letter-spacing: 0.01em; + color: var(--brand); + line-height: 1.1; + white-space: nowrap; +} + +#searchBox { + position: relative; + width: 100%; + max-width: 560px; + display: flex; + align-items: center; +} + +#searchIcon { + position: absolute; + left: 16px; + color: var(--muted); + pointer-events: none; +} + +#mainSearchInput { + width: 100%; + box-sizing: border-box; + height: 42px; + font-family: var(--font); + font-size: var(--fs-xl); + padding: 0 18px 0 44px; + border-radius: var(--r-pill); + border: 1px solid var(--border-popup); + background: var(--surface-2); + color: var(--text); + box-shadow: var(--float-shadow); + transition: border-color 0.12s ease, background-color 0.12s ease, box-shadow 0.12s ease; +} + +#mainSearchInput:hover { + border-color: var(--border-hover); +} + +#mainSearchInput:focus { + outline: none; + border-color: var(--accent); + background: var(--raised); + box-shadow: var(--float-shadow), 0 0 0 3px rgba(91, 163, 224, 0.18); +} + +#mainSearchInput::placeholder { + color: var(--muted); +} + +/* Custom suggestions dropdown -- anchored to #searchBox (position:relative), + so it always matches the search field's width and sits just below it. */ +#searchSuggestions { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + max-height: min(70vh, 440px); + overflow-y: auto; + background: var(--surface-2); + border: 1px solid var(--border-popup); + border-radius: var(--r-lg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); + padding: 6px; + z-index: 20; +} + +/* Vehicle glyphs (icons/vehicles/*.png) are the game's white-on-transparent + monochrome icons -- backed by the same solid vehicle-orange circle their + map pins use (filters.js's VEHICLE_COLOR) so they hold up on any row state + and read as vehicles next to the full-color item/building icons. */ +.searchSuggestionVehicleIcon { + background: var(--vehicle); + border-radius: 50%; + padding: 4px; + box-sizing: border-box; } /* Show/hide eye toggle -- on building suggestion rows AND in the building @@ -408,8 +617,8 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible display: flex; align-items: center; justify-content: center; - border: 1px solid #6b2b47; - border-radius: 8px; + border: 1px solid var(--hidden-pink-border); + border-radius: var(--r-md); background: var(--hidden-pink-bg); color: var(--hidden-pink-soft); cursor: pointer; @@ -417,7 +626,7 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible .visibilityToggle:hover { border-color: var(--hidden-pink); - color: #fff; + color: var(--text-bright); } .visibilityToggle.isShown { @@ -429,15 +638,7 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible .visibilityToggle.isShown:hover { background: var(--accent-bg-hover); border-color: var(--accent); - color: #fff; -} - -/* On the keyboard/hover-highlighted (solid blue) suggestion row, the chip's - own tints would vanish into the background -- switch to a white outline. */ -.searchSuggestionRow.active .visibilityToggle { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.55); - color: #fff; + color: var(--text-bright); } .visibilityToggle svg { @@ -452,9 +653,9 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible .searchSuggestionEmpty { padding: 10px 12px; - color: #888; + color: var(--muted); font-style: italic; - font-size: 13px; + font-size: var(--fs-md); } /* The per-save status buttons (Dimensional Depot + the progression views -- @@ -479,11 +680,11 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible padding: 0 13px; color: var(--text); font: inherit; - font-size: 13px; + font-size: var(--fs-md); font-weight: 600; background: var(--surface-2); border: 1px solid var(--border-popup); - border-radius: 10px; + border-radius: var(--r-lg); box-shadow: var(--float-shadow); cursor: pointer; } @@ -512,7 +713,7 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible #updatePill:hover { background: var(--accent-bg-hover); - color: #fff; + color: var(--text-bright); } #updatePill:disabled { @@ -537,7 +738,7 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible padding: 6px; background: var(--surface-2); border: 1px solid var(--border-popup); - border-radius: 10px; + border-radius: var(--r-lg); box-shadow: var(--float-shadow); z-index: 70; } @@ -550,11 +751,11 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible padding: 7px 9px; color: var(--text); font: inherit; - font-size: 13px; + font-size: var(--fs-md); text-align: left; background: none; border: none; - border-radius: 7px; + border-radius: var(--r-sm); cursor: pointer; } @@ -571,41 +772,6 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6)); } -/* The map owns the whole viewport (minus the altitude rail docked right) -- - the sidebar and top controls float OVER it, so showing/hiding/resizing - panels never moves or re-fits the map. */ -#map { - position: absolute; - top: 0; - left: 0; - right: var(--altitude-width); - bottom: 0; - background: #000; - /* Contain Leaflet's internal stacking -- its panes/zoom controls use - z-index values up to ~1000, which would otherwise live in the root - stacking context and paint over the top bar's search-suggestions - dropdown (z-index 20 inside #topBar's z-index:10). isolate keeps all of - that self-contained so #topBar (and thus the dropdown) sits cleanly - above the map. */ - isolation: isolate; -} - -/* No level-1 category selected -- the detail column (content pane) collapses - to zero width rather than sitting there empty. Toggled on by - filters.js's selectCategory/deselectAllCategories. Animated via the width - transitions on #sidebar/#categoryDetailColumn (the sidebar floats over the - map, so the map itself never moves). ships WITH this class already - set (see index.html) so the empty detail column never flashes on screen - during the initial load before filters.js runs -- nothing is ever selected - until a save is loaded and the user clicks a category anyway. */ -body.no-category-selected #categoryDetailColumn { - width: 0; -} - -body.no-category-selected #sidebar { - width: var(--nav-col-width); -} - .leaflet-container { background: #000; } @@ -658,10 +824,10 @@ body.no-category-selected #sidebar { background: rgba(19, 21, 25, 0.72); color: var(--faint); font-family: var(--font); - font-size: 10.5px; + font-size: var(--fs-2xs); padding: 2px 9px; margin: 0; - border-radius: 6px 0 0 0; + border-radius: var(--r-sm) 0 0 0; white-space: nowrap; } @@ -675,267 +841,78 @@ body.no-category-selected #sidebar { text-decoration: underline; } -/* ---- Sidebar: a two-column browser -- the fixed-width nav panel (level-1 - category list, plus the save-loading footer at its bottom) alongside the - detail pane (shown only when a category is selected). A flex ROW so the - two columns sit side by side and each runs full height; the footer lives - INSIDE the nav panel (not spanning the whole sidebar), so it stays at the - nav panel's width and doesn't stretch when the detail pane appears. - Floats OVER the map as a rounded card (starting below the top-left menu/ - logo cluster) -- toggling or resizing it never moves the map. The width - transition animates the detail column opening/closing; the transform/ - opacity pair is the menu button's show/hide slide. */ -#sidebar { - position: absolute; - /* Flush under the brand cluster (top 12px + 42px high, z-index above) so - the hamburger+logo card reads as this panel's header -- the cluster's - bottom edge overlaps this top border by 1px to hide the seam. */ - top: 53px; - left: 12px; - width: calc(var(--nav-col-width) + var(--detail-col-width)); - bottom: 12px; - background: var(--surface-1); - color: var(--text); - box-sizing: border-box; - display: flex; - flex-direction: row; - overflow: hidden; - border: 1px solid var(--border-popup); - /* Square top-left corner: the brand cluster docks there (straight left - edges line up); the other three corners keep the rounded-card look. */ - border-radius: 0 14px 14px 14px; - box-shadow: 0 10px 36px rgba(0, 0, 0, 0.5); - z-index: 40; - transition: width 0.18s var(--panel-ease), - transform 0.28s var(--panel-ease), - opacity 0.22s ease; -} - -#sidebar.hidden { - transform: translateX(calc(-100% - 24px)); - opacity: 0; - pointer-events: none; -} - @media (prefers-reduced-motion: reduce) { #sidebar, #categoryDetailColumn { transition: none; } } -/* Level-1 categories -- a plain list of names (see filters.js's - renderTopLevelCategory), each with its own visibility toggle switch. - Clicking a row (anywhere but the switch) selects it as the one shown in - categoryDetailPane; the switch works independently of selection. Laid out - as a column: the Check/Uncheck-all header, then the scrollable category - list, then the save-loading footer pinned at the bottom. Its width is set - by filters.js's autoSizeNavPanel (--nav-col-width) to fit the widest - category label instead of a fixed guess, so no horizontal space is - wasted. */ -#categoryNavPanel { - flex: none; - width: var(--nav-col-width); - box-sizing: border-box; +/* One row per top-level category in the nav column -- reuses .groupTitle's + existing flex/toggle/icon/label layout (see filters.js's + renderTopLevelCategory, which relocates a real .groupTitle element here + rather than rebuilding an equivalent one from scratch) with a few + nav-specific overrides layered on top. Laid out as a self-contained card + ([icon] label ............ [switch]) -- the colored icon and name sit on + the left, the visibility switch is pushed to the far right (see the + margin-left:auto below), so the row's full width is actually used + instead of everything crowding the left edge. */ +.categoryNavRow { display: flex; - flex-direction: column; + align-items: center; + cursor: pointer; + user-select: none; + padding: 11px 14px; + margin-bottom: 6px; + border-radius: var(--r-md); + background: var(--raised); + border: 1px solid transparent; + font-size: var(--fs-lg); + text-transform: none; + letter-spacing: normal; + gap: 11px; + transition: background-color 0.1s ease, border-color 0.1s ease; +} + +.categoryNavRow .groupLabel { + white-space: nowrap; overflow: hidden; - border-right: 1px solid var(--border); + text-overflow: ellipsis; + line-height: 1.3; + flex: 1 1 auto; + min-width: 0; } -/* Drag-to-resize handles (see panels.js) -- invisible 7px grab strips that - light up as a thin accent stripe on hover/drag. Positioned over the two - column right edges as direct children of #sidebar: the nav handle - straddles the nav/detail border; the detail handle hugs the sidebar's - right edge. */ -.panelResizeHandle { - position: absolute; - top: 0; - bottom: 0; - width: 7px; - cursor: col-resize; - z-index: 5; - touch-action: none; +.categoryNavRow:hover { + background: var(--raised-hover); + border-color: var(--border); } -#navResizeHandle { - left: calc(var(--nav-col-width) - 4px); +/* Disclosure chevron: the visual cue that a nav row opens the subcategory + detail column (it reads as a plain toggle row otherwise). Sits after the + switch at the far right (order chosen against .toggleSwitch's order:2), + brightens on hover, and points back left when open ("click to close"). */ +.categoryNavRow .navChevron { + order: 3; + flex: none; + margin-left: 8px; + color: var(--faint); + font-size: var(--fs-xl); + line-height: 1; + transition: transform 0.15s ease, color 0.15s ease; } -#detailResizeHandle { - right: 0; +.categoryNavRow:hover .navChevron { + color: var(--text); + transform: translateX(2px); } -/* With the detail column closed, its handle would sit exactly on top of the - nav handle at the sidebar's right edge -- there is nothing to resize, so - it goes away entirely. */ -body.no-category-selected #detailResizeHandle { - display: none; -} - -.panelResizeHandle::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 2px; - width: 3px; - background: transparent; - transition: background-color 0.12s ease; -} - -.panelResizeHandle:hover::before, -.panelResizeHandle.dragging::before { - background: var(--accent); -} - -/* Kill the width transitions while a handle is being dragged -- the panel - must track the pointer 1:1, not ease toward it. */ -body.panelResizing { - cursor: col-resize; - user-select: none; -} - -body.panelResizing #sidebar, -body.panelResizing #categoryDetailColumn { - transition: none; -} - -#categoryNavHeader { - flex: none; - display: flex; - gap: 8px; - padding: 10px; - border-bottom: 1px solid var(--border); -} - -#categoryNavHeader button { - flex: 1 1 50%; - font-size: 12px; - padding: 7px 0; -} - -/* Pink accent matches #activeFilterBanner/#activeFilterClear -- the app's - existing "something is hidden from view, here's how to undo it" color. */ -#resetHiddenButton { - flex: none; - margin: 0 10px 10px; - padding: 7px 0; - font-size: 12px; - font-weight: 600; - text-align: center; - color: #ff9ec2; - background: #3a2330; - border: 1px solid #ff3b81; - border-radius: 6px; - cursor: pointer; -} - -#resetHiddenButton:hover { - background: #4a2a3c; - color: #fff; -} - -#categoryNavColumn { - flex: 1 1 auto; - min-height: 0; - box-sizing: border-box; - overflow-y: auto; - padding: 8px 6px; -} - -#categoryDetailColumn { - flex: none; - width: var(--detail-col-width); - min-width: 0; - display: flex; - flex-direction: column; - overflow: hidden; - transition: width 0.18s var(--panel-ease); -} - -/* Fixed at the column's full open width (the column itself clips it) so the - open/close animation slides the content in as one piece instead of - reflowing the text at every frame. */ -#categoryDetailPane { - flex: 1 1 auto; - min-height: 0; - width: var(--detail-col-width); - overflow-y: auto; - padding: 6px 8px; - box-sizing: border-box; -} - -/* One row per top-level category in the nav column -- reuses .groupTitle's - existing flex/toggle/icon/label layout (see filters.js's - renderTopLevelCategory, which relocates a real .groupTitle element here - rather than rebuilding an equivalent one from scratch) with a few - nav-specific overrides layered on top. Laid out as a self-contained card - ([icon] label ............ [switch]) -- the colored icon and name sit on - the left, the visibility switch is pushed to the far right (see the - margin-left:auto below), so the row's full width is actually used - instead of everything crowding the left edge. */ -.categoryNavRow { - /* Explicit flex here because the base "display:flex" only lives on - ".filterGroup > .groupTitle", and renderTopLevelCategory relocates this - row OUT of its .filterGroup -- so that selector no longer matches and - the row would otherwise fall back to plain inline flow (which is why the - order/margin-left tricks below need this to take effect). */ - display: flex; - align-items: center; - cursor: pointer; - user-select: none; - padding: 11px 14px !important; - margin-bottom: 6px; - border-radius: 9px; - background: var(--raised); - border: 1px solid transparent; - font-size: 14.5px !important; - text-transform: none !important; - letter-spacing: normal !important; - gap: 11px !important; - transition: background-color 0.1s ease, border-color 0.1s ease; -} - -.categoryNavRow .groupLabel { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.3; - flex: 1 1 auto; - min-width: 0; -} - -.categoryNavRow:hover { - background: var(--raised-hover); - border-color: var(--border); -} - -/* Disclosure chevron: the visual cue that a nav row opens the subcategory - detail column (it reads as a plain toggle row otherwise). Sits after the - switch at the far right (order chosen against .toggleSwitch's order:2), - brightens on hover, and points back left when open ("click to close"). */ -.categoryNavRow .navChevron { - order: 3; - flex: none; - margin-left: 8px; - color: var(--faint); - font-size: 15px; - line-height: 1; - transition: transform 0.15s ease, color 0.15s ease; -} - -.categoryNavRow:hover .navChevron { - color: var(--text); - transform: translateX(2px); -} - -.categoryNavRow.active .navChevron { - color: #8ab4f8; - transform: rotate(180deg); +.categoryNavRow.active .navChevron { + color: var(--accent-soft); + transform: rotate(180deg); } .categoryNavRow.active { - background: #2a3d54; - color: #8ab4f8; - box-shadow: inset 3px 0 0 #5ba3e0; + background: var(--accent-bg); + color: var(--accent-soft); + box-shadow: inset 3px 0 0 var(--accent); } /* Bigger than the default .toggleSwitch (used everywhere else -- the @@ -969,11 +946,11 @@ body.panelResizing #categoryDetailColumn { .categoryNavRow .icon { width: 16px; height: 16px; - border-radius: 4px !important; + border-radius: var(--r-xs); } .categoryNavRow .icon-circle { - border-radius: 50% !important; + border-radius: 50%; } .categoryDetailGroup { @@ -984,43 +961,13 @@ body.panelResizing #categoryDetailColumn { display: block; } -#checkAllButton, #uncheckAllButton { - font-size: 11px; - padding: 5px 7px; - background: none; - border: 1px solid var(--border); - border-radius: 6px; - color: var(--muted); - cursor: pointer; - white-space: nowrap; -} - -#checkAllButton:hover, #uncheckAllButton:hover { - color: var(--text); - border-color: var(--border-hover); - background: var(--raised); -} - -/* ---- Sidebar footer: save file picker + game settings -- everything only - touched around load time, tucked below the category browser instead of - pinned above it. */ -#sidebarFooter { - flex: none; - max-height: 46%; - overflow-y: auto; - padding: 12px; - box-sizing: border-box; - border-top: 1px solid var(--border); - background: var(--surface-0); -} - #loadPanelHeader { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; - color: #8a92a3; - font-size: 11px; + color: var(--muted); + font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; @@ -1043,7 +990,7 @@ body.panelResizing #categoryDetailColumn { gap: 4px; color: var(--faint); text-decoration: none; - font-size: 10px; + font-size: var(--fs-2xs); font-weight: 700; letter-spacing: 0.05em; } @@ -1064,14 +1011,14 @@ body.panelResizing #categoryDetailColumn { border: none; cursor: pointer; font: inherit; - font-size: 10px; + font-size: var(--fs-2xs); font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; } #clearSaveBtn:hover { - color: #ff9ec2; + color: var(--hidden-pink-soft); } .loadPanelIcon { @@ -1089,17 +1036,17 @@ body.panelResizing #categoryDetailColumn { gap: 7px; margin-top: 8px; padding: 9px 8px; - font-size: 12px; + font-size: var(--fs-sm); color: var(--muted); - border: 1px dashed #4a4f5a; - border-radius: 6px; + border: 1px dashed var(--border-hover); + border-radius: var(--r-sm); cursor: pointer; } #uploadDropZone:hover, #uploadDropZone.drag-over { - color: #ffd7ae; - border-color: #8a5e2e; - background: #3d2d1c; + color: var(--warn-text); + border-color: var(--brand-border); + background: var(--brand-bg); } #uploadDropZone.uploading { @@ -1119,10 +1066,10 @@ body.panelResizing #categoryDetailColumn { padding: 6px 2px 0; background: none; border: none; - border-top: 1px solid #2c2f37; + border-top: 1px solid var(--border-sub); color: var(--muted); font: inherit; - font-size: 12px; + font-size: var(--fs-sm); cursor: pointer; } @@ -1147,28 +1094,11 @@ body.panelResizing #categoryDetailColumn { margin-top: 7px; } -#serverFetchForm input { - width: 100%; - box-sizing: border-box; - padding: 6px 8px; - font: inherit; - font-size: 12px; - color: var(--text); - background: var(--raised); - border: 1px solid var(--border); - border-radius: 6px; -} - -#serverFetchForm input:focus { - outline: none; - border-color: var(--border-hover); -} - #serverFetchRemember { display: flex; align-items: center; gap: 6px; - font-size: 11.5px; + font-size: var(--fs-xs); color: var(--muted); cursor: pointer; user-select: none; @@ -1181,61 +1111,11 @@ body.panelResizing #categoryDetailColumn { cursor: pointer; } -#serverFetchButton { - padding: 7px 8px; - font: inherit; - font-size: 12px; - font-weight: 600; - color: var(--muted); - background: none; - border: 1px solid var(--border); - border-radius: 6px; - cursor: pointer; -} - -#serverFetchButton:hover { - color: var(--text); - border-color: var(--border-hover); - background: var(--raised); -} - -#serverFetchButton:disabled { - opacity: 0.6; - cursor: default; -} - #loadStatus { margin-top: 8px; - color: #999; - font-size: 11.5px; - min-height: 1.2em; -} - -/* Exports the current (possibly edited) save as a new .sav -- the uploaded - file itself is never modified (see editor.js). */ -#downloadSaveBtn { - display: block; - width: 100%; - margin-top: 8px; - padding: 7px 8px; - font-size: 12px; - font-weight: 600; color: var(--muted); - background: none; - border: 1px solid var(--border); - border-radius: 6px; - cursor: pointer; -} - -#downloadSaveBtn:hover { - color: var(--text); - border-color: var(--border-hover); - background: var(--raised); -} - -#downloadSaveBtn:disabled { - opacity: 0.6; - cursor: default; + font-size: var(--fs-xs); + min-height: 1.2em; } /* The "save details" disclosure row: object-count summary chip on the left, @@ -1251,7 +1131,7 @@ body.panelResizing #categoryDetailColumn { padding: 6px 2px 0; background: none; border: none; - border-top: 1px solid #2c2f37; + border-top: 1px solid var(--border-sub); color: var(--muted); font: inherit; cursor: pointer; @@ -1284,32 +1164,15 @@ body.panelResizing #categoryDetailColumn { } .totalObjectCountValue { - color: #8ab4f8; + color: var(--accent-soft); font-weight: 700; - font-family: "Consolas", "SF Mono", monospace; - font-size: 13px; + font-family: var(--font-mono); + font-size: var(--fs-md); } .totalObjectCountLabel { - color: #777; - font-size: 11px; -} - -#loadProgressBar { - display: none; - margin-top: 8px; - height: 6px; - background: #2a2d33; - border-radius: 999px; - overflow: hidden; -} - -#loadProgressFill { - height: 100%; - width: 0%; - background: linear-gradient(to right, #c96f22, var(--brand)); - border-radius: 999px; - transition: width 0.2s ease-out; + color: var(--faint); + font-size: var(--fs-xs); } /* Game-mode settings (Power Cost Multiplier / Node Purity / Node @@ -1317,7 +1180,7 @@ body.panelResizing #categoryDetailColumn { display:none until a save with at least one of these set is loaded. */ #gameSettingsPanel { margin-top: 6px; - font-size: 12px; + font-size: var(--fs-sm); } .gameSettingRow { @@ -1328,486 +1191,65 @@ body.panelResizing #categoryDetailColumn { } .gameSettingLabel { - color: #999; + color: var(--muted); } .gameSettingValue { - color: #ddd; + color: var(--text); font-weight: 500; } -/* Shared by the item-search modal's results list and the Dimensional Depot - contents list (see finditem.js's renderLocationList) -- a scrollable, - bordered list of label/count rows. */ -.itemLocationList { - max-height: 56vh; - overflow-y: auto; - margin-bottom: 10px; - border: 1px solid #3a3a3a; - border-radius: 6px; -} - -.itemLocationRow { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - font-size: 14px; - border-bottom: 1px solid #2c2f37; - /* Long lists (a big group expanded, the Depot's hundreds of item types) - shouldn't pay layout/paint for rows far offscreen -- lets the browser - skip them entirely until scrolled near. The intrinsic-size hint keeps - the scrollbar length stable while skipped. */ - content-visibility: auto; - contain-intrinsic-size: auto 40px; -} - -.itemLocationRow:hover { - background: rgba(255, 255, 255, 0.05); -} - -.itemLocationRow:last-child { - border-bottom: none; -} - -/* Same exact->fuzzy->generic icon chain as the search dropdown (see - finditem.js's renderLocationList) -- keeps result lists scannable by - shape, not just by reading every label. */ -.itemLocationIcon { - width: 26px; - height: 26px; - flex: none; - object-fit: contain; -} - -.itemLocationLabel { - flex: 1 1 auto; - min-width: 0; - color: #ddd; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.itemLocationCount { - color: #8ab4f8; - font-weight: 600; - font-size: 14px; - flex: none; -} - -/* "Show on map" -- flies the map to this one instance and marks it (see - finditem.js's locateOnMap). It sits on every row of a list that can run to - hundreds, so it's drawn in the same muted gray as the chevrons and fallback - glyphs: plainly there to be found, but not competing with the label and - count that are the row's actual content. The hovered row's button turns - blue -- the app's "this is clickable" color everywhere else. */ -.itemLocationLocate { - flex: none; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - border: 1px solid transparent; - border-radius: 6px; - background: none; - color: #7a8190; - cursor: pointer; -} - -.itemLocationRow:hover .itemLocationLocate { - color: #8ab4f8; - border-color: #3a4356; -} - -.itemLocationLocate:hover { - background: var(--accent-bg); - border-color: var(--accent); - color: #fff; -} - -.itemLocationLocate svg { - display: block; -} - /* The Depot row has no position to fly to, so it gets no button -- this - holds the column open so its label/count still line up with every other - row's. */ -.itemLocationLocateSpacer { - flex: none; - width: 24px; -} - -/* ---- Grouped item-location list (see finditem.js's - renderGroupedLocations) -- one expandable header row per building type, - individual machines as indented child rows underneath. */ -.itemLocationGroupHeader { - cursor: pointer; - user-select: none; -} - -.itemLocationGroupHeader:hover { - background: rgba(255, 255, 255, 0.07); -} - -.itemLocationChevron { - flex: none; - display: inline-flex; - color: #7a8190; - line-height: 0; -} - -.itemLocationChevron svg { - transition: transform 0.12s ease-out; -} - -.itemLocationGroupHeader.expanded .itemLocationChevron svg { - transform: rotate(90deg); -} - -/* The label stops stretching in a group header so the badge below can sit - right against it: "Constructor × 28" is one phrase and reads as one. It - still shrinks/ellipsizes when the name is long (min-width:0 + the overflow - rules it already carries). */ -.itemLocationGroupHeader .itemLocationLabel { - flex: 0 1 auto; -} - -/* "× 1,234" -- how many individual machines this summed row stands for. - margin-right:auto takes over the gap-filling the label just gave up, so - everything after it (the count) still lands hard against the right edge, - in the same column as every non-grouped row's. */ -.itemLocationGroupBadge { - flex: none; - margin-right: auto; - background: #262a31; - border: 1px solid #333844; - border-radius: 999px; - padding: 1px 9px; - font-size: 12px; - color: #9aa0ab; -} - -.itemLocationChildren { - background: rgba(0, 0, 0, 0.18); - border-bottom: 1px solid #2c2f37; -} - -/* Child rows show only position + count (the type is the header's job); - the extra left padding indents them under the header's label column. */ -.itemLocationChildRow { - padding-left: 48px; - font-size: 13px; -} - -.itemLocationChildRow .itemLocationLabel { - color: #a7adb8; - font-family: "Consolas", "SF Mono", monospace; - font-size: 12.5px; -} - -.itemLocationShowMore { - display: block; - width: 100%; - padding: 7px 0 7px 48px; - text-align: left; - background: none; - border: none; - border-bottom: 1px solid #2c2f37; - color: #8ab4f8; - font-size: 12.5px; - font-weight: 600; - cursor: pointer; -} - -.itemLocationShowMore:hover { - background: rgba(255, 255, 255, 0.05); - color: #aecbfa; -} - -/* ---- Item search / Dimensional Depot results modal -- a single shared - dialog (see finditem.js's openModal), centered over the map, used for - both the search bar's results and the depot-icon button's contents. */ -#itemModalOverlay, #selectionModalOverlay, #buildingModalOverlay, #progressionModalOverlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - /* No backdrop-filter here on purpose: blurring the backdrop means every - repaint inside the modal (row hover, scroll, the cursor-following - tooltip) re-filters the entire viewport-sized map underneath. Fine on - the GPU, but with hardware acceleration off (user setting, RDP, - blocklisted driver) that software re-blur runs per frame and drops the - whole modal to ~10 FPS. The dim alone separates the layers well enough. */ - background: rgba(8, 10, 14, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 1500; -} - -#itemModal, #selectionModal { - width: 640px; - max-width: 92vw; - max-height: 86vh; - background: var(--surface-2); - border: 1px solid var(--border-popup); - border-radius: 12px; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6); - padding: 16px 18px; - box-sizing: border-box; - display: flex; - flex-direction: column; - color: var(--text); - transform: scale(0.96); - opacity: 0; - animation: buildingModalIn 0.16s ease-out forwards; -} - -@media (prefers-reduced-motion: reduce) { - #itemModal, #selectionModal { animation: none; transform: none; opacity: 1; } -} - -#itemModalHeader, #selectionModalHeader { - display: flex; - align-items: center; - gap: 11px; - margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-popup); - flex: none; -} - -/* Same framed-icon treatment as #buildingModalIcon, one size down -- the - item modal's list rows are denser than the building modal's stat tiles. */ -#itemModalIcon { - width: 42px; - height: 42px; - flex: none; - object-fit: contain; - background: var(--inset); - border: 1px solid #2c2f37; - border-radius: 8px; - padding: 4px; - box-sizing: border-box; -} - -#itemModalTitle, #selectionModalTitle { - font-weight: bold; - font-size: 19px; - color: #fff; - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -#itemModalClose, #selectionModalClose { - background: none; - border: none; - color: #999; - font-size: 22px; - line-height: 1; - cursor: pointer; - padding: 0 2px; -} - -#itemModalClose:hover, #selectionModalClose:hover { - color: #fff; -} - -#itemModalSummary, #selectionModalSummary { - color: #bbb; - font-size: 14px; - margin-bottom: 8px; - flex: none; -} - -#itemModalHighlightToggle { - flex: none; - width: 100%; - margin-top: 4px; - padding: 9px 0; - border-radius: 8px; - border: 1px solid var(--accent-border); - background: var(--accent-bg); - color: var(--accent-soft); - font-size: 13px; - font-weight: 600; - cursor: pointer; -} - -#itemModalHighlightToggle:hover { - background: var(--accent-bg-hover); - color: #fff; -} - -/* ---- Building info window -- see finditem.js's Building object. Opened - from a building search suggestion (row click or Enter); shares the same - overlay/backdrop rule as #itemModal above, but its own dialog -- a - building's save-wide summary (count, recipe mix, power, combined - inventory) has a richer shape than an item's plain location list, so it - gets stat tiles and a small recipe bar chart instead of a single list. */ -#buildingModal { - width: 600px; - max-width: 92vw; - max-height: 86vh; - background: #20232a; - border: 1px solid #3a3f4a; - border-radius: 12px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.65); - padding: 18px 20px 16px; - box-sizing: border-box; - display: flex; - flex-direction: column; - color: #ddd; - transform: scale(0.96); - opacity: 0; - animation: buildingModalIn 0.16s ease-out forwards; -} - -@keyframes buildingModalIn { - to { transform: scale(1); opacity: 1; } -} - -@media (prefers-reduced-motion: reduce) { - #buildingModal { animation: none; transform: none; opacity: 1; } -} - -#buildingModalHeader { - display: flex; - align-items: center; - gap: 12px; - padding-bottom: 12px; - margin-bottom: 10px; - border-bottom: 1px solid #3a3f4a; - flex: none; -} - -#buildingModalIcon { - width: 44px; - height: 44px; - flex: none; - object-fit: contain; - background: #171a20; - border: 1px solid #2c2f37; - border-radius: 8px; - padding: 4px; - box-sizing: border-box; -} - -/* Vehicle-orange circle behind the white monochrome vehicle glyphs -- same - reasoning as .searchSuggestionVehicleIcon above. */ -#buildingModalIcon.vehicleModalIcon { - background: #f39c12; - border-radius: 50%; - padding: 7px; -} - -#buildingModalHeadings { - flex: 1 1 auto; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -#buildingModalTitle { - font-weight: 700; - font-size: 18px; - color: #fff; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Background/text color set inline per-category (see finditem.js's - Filters.buildingCategoryColor) -- ties this chip back to the exact color - the same building uses in the sidebar. */ -#buildingModalCategory { - display: inline-flex; - align-self: flex-start; - padding: 2px 9px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; -} - -#buildingModalClose { - flex: none; - background: none; - border: none; - color: #999; - font-size: 22px; - line-height: 1; - cursor: pointer; - padding: 0 2px; -} - -#buildingModalClose:hover { - color: #fff; + holds the column open so its label/count still line up with every other + row's. */ +.itemLocationLocateSpacer { + flex: none; + width: 24px; } -#buildingModalSummary { - color: #bbb; - margin-bottom: 10px; - flex: none; +.itemLocationChildren { + background: rgba(0, 0, 0, 0.18); + border-bottom: 1px solid var(--border-sub); } -#buildingModalStats { - display: flex; - gap: 10px; - margin-bottom: 14px; - flex: none; +/* Child rows show only position + count (the type is the header's job); + the extra left padding indents them under the header's label column. */ +.itemLocationChildRow { + padding-left: 48px; + font-size: var(--fs-md); } -.buildingStatTile { - flex: 1 1 0; - min-width: 0; - background: #262a31; - border: 1px solid #333844; - border-radius: 8px; - padding: 10px 12px; +@media (prefers-reduced-motion: reduce) { + #itemModal, #selectionModal { animation: none; transform: none; opacity: 1; } } -.buildingStatValue { - display: block; - font-family: "Consolas", "SF Mono", monospace; - font-size: 19px; - font-weight: 700; - color: #fff; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +@keyframes buildingModalIn { + to { transform: scale(1); opacity: 1; } } -.buildingStatLabel { - display: block; - font-size: 11px; - color: #9aa0ab; - margin-top: 3px; - text-transform: uppercase; - letter-spacing: 0.04em; +@media (prefers-reduced-motion: reduce) { + #buildingModal { animation: none; transform: none; opacity: 1; } } -#buildingModalScroll { - overflow-y: auto; - flex: 1 1 auto; - min-height: 0; +/* Vehicle-orange circle behind the white monochrome vehicle glyphs -- same + reasoning as .searchSuggestionVehicleIcon above. */ +#buildingModalIcon.vehicleModalIcon { + background: var(--vehicle); + border-radius: 50%; + padding: 7px; } -.buildingModalSectionLabel { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #9aa0ab; - margin: 4px 0 8px; +/* Background/text color set inline per-category (see finditem.js's + Filters.buildingCategoryColor) -- ties this chip back to the exact color + the same building uses in the sidebar. */ +#buildingModalCategory { + display: inline-flex; + align-self: flex-start; + padding: 2px 9px; + border-radius: var(--r-pill); + font-size: var(--fs-xs); + font-weight: 600; + letter-spacing: 0.02em; } #buildingModalRecipes { @@ -1827,50 +1269,17 @@ body.panelResizing #categoryDetailColumn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: #ddd; - font-size: 12.5px; -} - -.recipeBarTrack { - flex: 1 1 auto; - height: 8px; - background: #171a20; - border-radius: 999px; - overflow: hidden; -} - -.recipeBarFill { - height: 100%; - border-radius: 999px; - transition: width 0.25s ease-out; + color: var(--text); + font-size: var(--fs-sm); } .recipeBarCount { flex: none; width: 36px; text-align: right; - color: #8ab4f8; - font-size: 12px; - font-weight: 600; -} - -#buildingModalHighlightToggle { - flex: none; - width: 100%; - margin-top: 14px; - padding: 9px 0; - border-radius: 8px; - border: 1px solid #3a5070; - background: #2a3545; - color: #8ab4f8; - font-size: 13px; + color: var(--accent-soft); + font-size: var(--fs-sm); font-weight: 600; - cursor: pointer; -} - -#buildingModalHighlightToggle:hover { - background: #33435a; - color: #fff; } /* ---- Right-click context menu -- see contextmenu.js. -------------------- */ @@ -1880,18 +1289,18 @@ body.panelResizing #categoryDetailColumn { z-index: 1700; /* Above modals (1500) and the tooltip (1000) -- the most immediate, transient UI on screen. */ min-width: 190px; max-width: 280px; - background: #20232a; - border: 1px solid #3a3f4a; - border-radius: 8px; + background: var(--surface-2); + border: 1px solid var(--border-popup); + border-radius: var(--r-md); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55); padding: 6px; - font-size: 12.5px; - color: #ddd; + font-size: var(--fs-sm); + color: var(--text); } .contextMenuItem { padding: 8px 10px; - border-radius: 6px; + border-radius: var(--r-sm); cursor: pointer; white-space: nowrap; overflow: hidden; @@ -1899,14 +1308,14 @@ body.panelResizing #categoryDetailColumn { } .contextMenuItem:hover { - background: #2f6bd8; - color: #fff; + background: var(--select-bg); + color: var(--text-bright); } /* Non-actionable hint rows (e.g. "Paste here" with an empty clipboard). */ .contextMenuItemDisabled, .contextMenuItemDisabled:hover { - color: #777; + color: var(--faint); background: none; cursor: default; } @@ -1924,7 +1333,7 @@ body.panelResizing #categoryDetailColumn { #selectionRect { position: fixed; z-index: 1400; - border: 1.5px solid #5ba3e0; + border: 1.5px solid var(--accent); background: rgba(91, 163, 224, 0.15); pointer-events: none; } @@ -1935,19 +1344,19 @@ body.panelResizing #categoryDetailColumn { #selectionPanel { position: absolute; bottom: 22px; - left: var(--map-center-x); + left: 50%; transform: translateX(-50%); z-index: 600; display: flex; align-items: center; gap: 14px; padding: 10px 10px 10px 16px; - background: #20232a; - border: 1px solid #3a3f4a; - border-radius: 10px; + background: var(--surface-2); + border: 1px solid var(--border-popup); + border-radius: var(--r-lg); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55); - color: #eee; - font-size: 13px; + color: var(--text); + font-size: var(--fs-md); } #selectionCount { @@ -1961,152 +1370,56 @@ body.panelResizing #categoryDetailColumn { align-items: center; } -#selectionPanelButtons button { - flex: none; - font-size: 12px; - padding: 6px 12px; - border-radius: 6px; - cursor: pointer; - border: 1px solid #3a5070; - background: #2a3545; - color: #8ab4f8; -} - -#selectionPanelButtons button:hover { - background: #33435a; - color: #fff; -} - -#selectionClearBtn { - padding: 4px 10px !important; - font-size: 16px !important; - line-height: 1; - color: #999 !important; - background: none !important; - border-color: #3a3f4a !important; -} - -#selectionClearBtn:hover { - color: #fff !important; - background: #2a2e36 !important; -} - -#selectionPanelButtons button:disabled { - opacity: 0.45; - cursor: default; - color: #8ab4f8; - background: #2a3545; -} - /* ---- Save editor (editor.js): pending-edit toolbar, ghost placement, offset dialog. Same surface treatment as the selection panel. */ #editorToolbar { position: absolute; top: 58px; - left: var(--map-center-x); + left: 50%; transform: translateX(-50%); z-index: 600; display: flex; align-items: center; gap: 10px; padding: 7px 12px; - background: #20232a; - border: 1px solid #3a3f4a; - border-radius: 10px; + background: var(--surface-2); + border: 1px solid var(--border-popup); + border-radius: var(--r-lg); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55); - color: #eee; - font-size: 12.5px; + color: var(--text); + font-size: var(--fs-sm); } #editorEditCount { font-weight: 600; - color: #ffd7ae; + color: var(--warn-text); white-space: nowrap; } -#editorToolbar button { - font-size: 12px; - padding: 5px 11px; - border-radius: 6px; - cursor: pointer; - border: 1px solid #3a5070; - background: #2a3545; - color: #8ab4f8; -} - -#editorToolbar button:hover:not(:disabled) { - background: #33435a; - color: #fff; -} - -#editorToolbar button:disabled { - opacity: 0.45; - cursor: default; -} - /* The placement ghost: selection bbox outline following the cursor. Inside the map pane so latLngToContainerPoint coordinates apply directly. */ #editorHint { position: absolute; bottom: 64px; - left: var(--map-center-x); + left: 50%; transform: translateX(-50%); z-index: 1401; padding: 7px 14px; - background: #20232a; - border: 1px solid #8a5e2e; - border-radius: 8px; - color: #ffd7ae; - font-size: 12.5px; + background: var(--surface-2); + border: 1px solid var(--brand-border); + border-radius: var(--r-md); + color: var(--warn-text); + font-size: var(--fs-sm); pointer-events: none; white-space: nowrap; } -#offsetDialogOverlay { - position: fixed; - inset: 0; - z-index: 2000; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.5); -} - -/* ---- Busy overlay (save-edit operations) -- see SaveLoadFlow.showBusy. - Above every dialog (offset 2000): while an edit is applying, nothing else - is interactable anyway. Plain dim, no backdrop-filter -- same - software-rendering rationale as the modal overlays above. */ -#busyOverlay { - position: fixed; - inset: 0; - z-index: 2100; - display: flex; - align-items: center; - justify-content: center; - background: rgba(8, 10, 14, 0.6); - cursor: progress; -} - -#busyBox { - display: flex; - flex-direction: column; - align-items: center; - gap: 14px; - width: 320px; - padding: 26px 28px; - background: #20232a; - border: 1px solid #3a3f4a; - border-radius: 12px; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6); - color: #eee; -} - #busySpinner { width: 34px; height: 34px; border-radius: 50%; - border: 3px solid #2a3545; - border-top-color: #5ba3e0; + border: 3px solid var(--accent-bg); + border-top-color: var(--accent); animation: busySpin 0.8s linear infinite; } @@ -2119,113 +1432,18 @@ body.panelResizing #categoryDetailColumn { } #busyLabel { - font-size: 14px; + font-size: var(--fs-lg); font-weight: 600; text-align: center; } -#busyBar { - width: 100%; - height: 6px; - border-radius: 3px; - background: #2a3545; - overflow: hidden; -} - -#busyFill { - width: 0%; - height: 100%; - border-radius: 3px; - background: #5ba3e0; - transition: width 0.15s linear; -} - #busyPhase { - font-size: 12px; - color: #9aa7b8; + font-size: var(--fs-sm); + color: var(--muted); min-height: 15px; text-align: center; } -#offsetDialog { - width: 280px; - padding: 16px; - background: #20232a; - border: 1px solid #3a3f4a; - border-radius: 10px; - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55); - color: #eee; - font-size: 13px; -} - -#offsetDialogTitle { - font-weight: 700; - margin-bottom: 12px; -} - -.offsetRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 8px; -} - -.offsetRow label { - color: #aab2c0; - white-space: nowrap; -} - -.offsetRow input, .offsetRow select { - width: 90px; - padding: 5px 7px; - font-size: 13px; - color: #eee; - background: #16181d; - border: 1px solid #3a3f4a; - border-radius: 6px; -} - -#offsetDialogButtons { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 14px; -} - -#offsetDialogButtons button { - font-size: 12px; - padding: 6px 14px; - border-radius: 6px; - cursor: pointer; - border: 1px solid #3a5070; - background: #2a3545; - color: #8ab4f8; -} - -#offsetDialogButtons button:hover { - background: #33435a; - color: #fff; -} - -/* Paste placement panel: same visual language as #offsetDialog but docked - top-right and NON-modal -- map clicks pick the position while it's open. */ -#pastePanel { - position: fixed; - top: 72px; - /* Clear the altitude rail docked at the screen's right edge. */ - right: calc(var(--altitude-width) + 12px); - z-index: 1500; - width: 300px; - padding: 16px; - background: #20232a; - border: 1px solid #3a3f4a; - border-radius: 10px; - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55); - color: #eee; - font-size: 13px; -} - #pastePanelTitle { font-weight: 700; margin-bottom: 12px; @@ -2236,7 +1454,7 @@ body.panelResizing #categoryDetailColumn { flex-direction: column; gap: 4px; margin-bottom: 10px; - color: #aab2c0; + color: var(--muted); } #pastePosModes label { @@ -2247,14 +1465,14 @@ body.panelResizing #categoryDetailColumn { } .pastePanelDivider { - border-top: 1px solid #3a3f4a; + border-top: 1px solid var(--border-popup); margin: 10px 0; } #pasteResult { margin-top: 10px; - color: #8ab4f8; - font-size: 12px; + color: var(--accent-soft); + font-size: var(--fs-sm); min-height: 15px; } @@ -2265,29 +1483,14 @@ body.panelResizing #categoryDetailColumn { margin-top: 10px; } -#pastePanelButtons button { - font-size: 12px; - padding: 6px 14px; - border-radius: 6px; - cursor: pointer; - border: 1px solid #3a5070; - background: #2a3545; - color: #8ab4f8; -} - -#pastePanelButtons button:hover { - background: #33435a; - color: #fff; -} - #pastePanelApplyBtn { font-weight: 600; } /* Download button turns amber once edits are pending. */ #downloadSaveBtn.edited { - color: #ffd7ae; - border-color: #8a5e2e; + color: var(--warn-text); + border-color: var(--brand-border); } /* Floating "a find-item filter is active" banner -- shown over the map (the @@ -2300,7 +1503,7 @@ body.panelResizing #categoryDetailColumn { #activeFilterBanner { position: absolute; top: 68px; /* Just below the floating search pill. */ - left: var(--map-center-x); + left: 50%; transform: translateX(-50%); z-index: 500; display: flex; @@ -2308,12 +1511,12 @@ body.panelResizing #categoryDetailColumn { gap: 10px; max-width: 90vw; padding: 8px 8px 8px 14px; - background: #20232a; - border: 1px solid #ff3b81; - border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--hidden-pink); + border-radius: var(--r-pill); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); - color: #eee; - font-size: 13px; + color: var(--text); + font-size: var(--fs-md); } #activeFilterLabel { @@ -2323,52 +1526,15 @@ body.panelResizing #categoryDetailColumn { text-overflow: ellipsis; } -#activeFilterBanner button { - flex: none; - font-size: 12px; - padding: 4px 12px; - border-radius: 999px; - cursor: pointer; - border: 1px solid #3a3f4a; - background: #2a2e36; - color: #ddd; -} - -#activeFilterBanner button:hover { - border-color: #666; - color: #fff; -} - #activeFilterClear { - border-color: #ff3b81 !important; - background: #3a2330 !important; - color: #ff9ec2 !important; + border-color: var(--hidden-pink); + background: var(--hidden-pink-bg); + color: var(--hidden-pink-soft); } #activeFilterClear:hover { - background: #4a2a3c !important; - color: #fff !important; -} - -/* Vertical altitude filter, docked to the right edge of the screen -- - vertical gives much more usable travel/precision than a narrow - horizontal slider would in the same sidebar width. */ -#altitudePanel { - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: var(--altitude-width); - background: var(--surface-1); - color: var(--text); - box-sizing: border-box; - padding: 4px; - border-left: 1px solid var(--border); - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - overflow: hidden; + background: var(--hidden-pink-bg-hover); + color: var(--text-bright); } .altitudeTitle { @@ -2377,19 +1543,19 @@ body.panelResizing #categoryDetailColumn { flex-direction: column; align-items: center; gap: 3px; - font-size: 10px; + font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: 0.04em; - color: #888; + color: var(--muted); } .altitudeTitleIcon { - color: #5ba3e0; + color: var(--accent); } .altitudeValue { - font-size: 11px; - color: #bbb; + font-size: var(--fs-xs); + color: var(--muted); margin: 2px 0; flex: none; } @@ -2414,8 +1580,8 @@ body.panelResizing #categoryDetailColumn { left: 50%; transform: translateX(-50%); width: 4px; - background: #3a3a3a; - border-radius: 2px; + background: var(--border); + border-radius: var(--r-xs); pointer-events: none; } @@ -2435,7 +1601,7 @@ body.panelResizing #categoryDetailColumn { padding: 0 8px; background-color: var(--accent); background-clip: content-box; - border-radius: 2px; + border-radius: var(--r-xs); pointer-events: auto; cursor: grab; touch-action: none; @@ -2499,7 +1665,7 @@ body.panelResizing #categoryDetailColumn { width: 32px; height: 23px; border: 6px solid transparent; - border-radius: 9px; + border-radius: var(--r-md); background: #ddd; background-clip: padding-box; box-shadow: inset 0 0 0 1px #555; @@ -2512,7 +1678,7 @@ body.panelResizing #categoryDetailColumn { width: 32px; height: 23px; border: 6px solid transparent; - border-radius: 9px; + border-radius: var(--r-md); background: #ddd; background-clip: padding-box; box-shadow: inset 0 0 0 1px #555; @@ -2533,101 +1699,39 @@ body.panelResizing #categoryDetailColumn { display: flex; align-items: center; justify-content: center; - color: #999; - background: #23262c; - border: 1px solid #3a3a3a; - border-radius: 7px; - cursor: pointer; -} - -#altitudeResetButton:hover { - color: #fff; - border-color: #666; -} - -#altitudeResetButton svg { - display: block; -} - -.filterGroup { - margin-bottom: 2px; -} - -.filterGroup > .groupTitle { - font-weight: bold; - cursor: pointer; - display: flex; - align-items: center; - gap: 5px; - padding: 4px 3px; - border-radius: 4px; - user-select: none; -} - -/* Animated on/off switch standing in for a native checkbox (see filters.js's - makeToggle) -- the real is visually hidden but still - drives all the existing .checked/"change" logic, so this is pure styling. */ -.toggleSwitch { - position: relative; - display: inline-block; - flex: none; - width: 26px; - height: 15px; - cursor: pointer; -} - -.toggleSwitch input { - position: absolute; - opacity: 0; - width: 100%; - height: 100%; - margin: 0; - cursor: pointer; -} - -.toggleSlider { - position: absolute; - inset: 0; - background: #4a4d54; - border-radius: 999px; - transition: background-color 0.15s ease; -} - -.toggleSlider::before { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 11px; - height: 11px; - border-radius: 50%; - background: #ddd; - transition: transform 0.15s ease; -} - -.toggleSwitch input:checked + .toggleSlider { - background: var(--accent); + color: var(--muted); + background: var(--raised); + border: 1px solid var(--border); + border-radius: var(--r-sm); + cursor: pointer; } -.toggleSwitch input:checked + .toggleSlider::before { - transform: translateX(11px); - background: #fff; +#altitudeResetButton:hover { + color: var(--text-bright); + border-color: var(--faint); } -.toggleSwitch input:focus-visible + .toggleSlider { - box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.6); +#altitudeResetButton svg { + display: block; } -/* Mixed state: a group whose children are partly hidden (see - refreshGroupCheckboxes in filters.js). Same accent family as "on" but - visibly dimmer, knob parked in the middle. */ -.toggleSwitch input:indeterminate + .toggleSlider { - background: #3a5070; +.filterGroup { + margin-bottom: 2px; } -.toggleSwitch input:indeterminate + .toggleSlider::before { - transform: translateX(5.5px); - background: #c8d4e4; +/* Not scoped to `.filterGroup >`: renderTopLevelCategory RELOCATES a group's + title row out of its .filterGroup and into the dock's category list, and a + child combinator would stop matching the moment it did -- which is exactly + why .categoryNavRow below used to re-force every property with !important. */ +.groupTitle { + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + padding: 4px 3px; + border-radius: var(--r-xs); + user-select: none; } .categoryNavRow .toggleSwitch input:indeterminate + .toggleSlider::before { @@ -2641,7 +1745,7 @@ body.panelResizing #categoryDetailColumn { .expandToggle { display: inline-block; width: 10px; - color: #888; + color: var(--muted); cursor: pointer; flex: none; } @@ -2664,7 +1768,7 @@ body.panelResizing #categoryDetailColumn { } .icon-rect { - border-radius: 2px; + border-radius: var(--r-xs); } .icon-line { @@ -2679,7 +1783,7 @@ body.panelResizing #categoryDetailColumn { .filterChildren { margin-left: 14px; padding-left: 7px; - border-left: 1px solid #35383e; + border-left: 1px solid var(--border); } .filterRow { @@ -2687,10 +1791,10 @@ body.panelResizing #categoryDetailColumn { align-items: center; gap: 5px; padding: 3px; - border-radius: 4px; + border-radius: var(--r-xs); } -.filterRow:hover, .filterGroup > .groupTitle:hover { +.filterRow:hover, .groupTitle:hover { background: rgba(255, 255, 255, 0.05); } @@ -2702,7 +1806,7 @@ body.panelResizing #categoryDetailColumn { } .filterRow .count { - color: #888; + color: var(--muted); margin-left: auto; padding-left: 4px; flex: none; @@ -2714,18 +1818,18 @@ body.panelResizing #categoryDetailColumn { the label), so the pane reads as a proper list that actually uses its width. Scoped to #categoryDetailPane so the nav column's own .categoryNavRow overrides and the modal/tooltip toggles are untouched. */ -#categoryDetailPane .filterGroup > .groupTitle { +#categoryDetailPane .groupTitle { padding: 7px 9px; - border-radius: 6px; - font-size: 13.5px; + border-radius: var(--r-sm); + font-size: var(--fs-md); gap: 9px; } #categoryDetailPane .filterRow { padding: 6px 9px; - border-radius: 6px; + border-radius: var(--r-sm); gap: 9px; - font-size: 13.5px; + font-size: var(--fs-md); } /* The label grows to fill so whatever comes after it (the switch, and for @@ -2787,24 +1891,30 @@ body.panelResizing #categoryDetailColumn { /* Hover tooltip -- a plain fixed-position div that follows the cursor (see tooltip.js); not a Leaflet popup, since popups are anchored/click-oriented. */ +/* The tooltip is a top-layer popover (see tooltip.js's ensureElement): it has + to be able to paint above a modal , and no z-index can do that. + The overrides below undo the UA's [popover] defaults -- it centres popovers + with inset:0 + margin:auto, and tooltip.js positions this one itself. */ #tt-tooltip { - display: none; position: fixed; - z-index: 1000; - background: #20232a; - color: #ddd; - border: 1px solid #3a3f4a; - border-radius: 8px; - padding: 12px 14px; - pointer-events: none; - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6); + inset: auto; + margin: 0; + padding: var(--sp-3) var(--sp-3); + width: max-content; min-width: 260px; max-width: 380px; - font-family: sans-serif; + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--border-popup); + border-radius: var(--r-md); + box-shadow: var(--popover-shadow); + pointer-events: none; + font-family: var(--font); + overflow: visible; } .tt-popup { - font-size: 13px; + font-size: var(--fs-md); line-height: 1.5; max-height: 70vh; overflow-y: auto; @@ -2812,19 +1922,11 @@ body.panelResizing #categoryDetailColumn { .tt-title { font-weight: bold; - font-size: 15px; - color: #fff; + font-size: var(--fs-xl); + color: var(--text-bright); margin-bottom: 8px; padding-bottom: 6px; - border-bottom: 1px solid #3a3f4a; -} - -/* Lifts the tooltip over the modal overlay (z 1500) when shown from a - modal's location list (see tooltip.js's Tooltip.showFloating) -- the - default 1000 only ever had to beat the map. Still under #contextMenu - (1700). */ -#tt-tooltip.tt-above-modals { - z-index: 1600; + border-bottom: 1px solid var(--border-popup); } /* The find-item "quantity in here" callout (see tooltip.js's @@ -2842,21 +1944,21 @@ body.panelResizing #categoryDetailColumn { gap: 10px; background: rgba(255, 59, 129, 0.13); border: 1px solid rgba(255, 59, 129, 0.4); - border-radius: 6px; + border-radius: var(--r-sm); padding: 5px 9px; margin-top: 4px; - font-size: 13.5px; + font-size: var(--fs-md); } .tt-highlight-row .tt-row-label { - color: #ff9ec2; + color: var(--hidden-pink-soft); font-weight: 600; } .tt-highlight-row .tt-row-value { - color: #fff; + color: var(--text-bright); font-weight: 700; - font-size: 14.5px; + font-size: var(--fs-lg); } .tt-row { @@ -2867,13 +1969,13 @@ body.panelResizing #categoryDetailColumn { } .tt-row-label { - color: #9aa0ab; + color: var(--muted); } .tt-row-value { text-align: right; overflow-wrap: anywhere; - color: #eee; + color: var(--text); font-weight: 500; } @@ -2885,7 +1987,7 @@ body.panelResizing #categoryDetailColumn { display: inline-flex; align-items: center; gap: 4px; - color: #9aa0ab; + color: var(--muted); } .tt-copy-icon-btn { @@ -2894,7 +1996,7 @@ body.panelResizing #categoryDetailColumn { border: none; padding: 0; margin: 0; - color: #8ab4f8; + color: var(--accent-soft); cursor: pointer; line-height: 0; /* Tooltips are pointer-events:none until pinned (see #tt-tooltip) -- this @@ -2903,23 +2005,23 @@ body.panelResizing #categoryDetailColumn { } .tt-copy-icon-btn:hover { - color: #aecbfa; + color: var(--accent-bright); } .tt-copy-icon-btn-done { - color: #6fcf97; + color: var(--ok-soft); } .tt-section { margin-top: 10px; padding-top: 8px; - border-top: 1px solid #2c2f37; + border-top: 1px solid var(--border-sub); } .tt-section-title { - color: #8ab4f8; + color: var(--accent-soft); font-weight: bold; - font-size: 11px; + font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; @@ -2933,17 +2035,17 @@ body.panelResizing #categoryDetailColumn { .tt-warning { background: rgba(255, 176, 32, 0.10); border: 1px solid rgba(255, 176, 32, 0.45); - border-radius: 5px; + border-radius: var(--r-sm); padding: 7px 8px 8px; } .tt-warning .tt-section-title { - color: #ffb020; + color: var(--warn); } .tt-warning-text { - color: #ffd48a; - font-size: 12.5px; + color: var(--warn-soft); + font-size: var(--fs-sm); line-height: 1.4; } @@ -2953,9 +2055,9 @@ body.panelResizing #categoryDetailColumn { padding: 4px 9px; background: rgba(255, 176, 32, 0.16); border: 1px solid rgba(255, 176, 32, 0.55); - border-radius: 4px; + border-radius: var(--r-xs); color: #ffcf7d; - font-size: 12px; + font-size: var(--fs-sm); cursor: pointer; /* Inherits the tooltip's own pointer-events (none until pinned) -- the button is only ever actually clickable once pinned, same as the copy @@ -2968,29 +2070,29 @@ body.panelResizing #categoryDetailColumn { } .tt-loading, .tt-error { - color: #999; + color: var(--muted); font-style: italic; } .tt-error { - color: #e07070; + color: var(--danger-soft); } .tt-raw { margin-top: 10px; padding-top: 8px; - border-top: 1px solid #2c2f37; + border-top: 1px solid var(--border-sub); } .tt-raw summary { cursor: pointer; - color: #8ab4f8; - font-size: 12px; + color: var(--accent-soft); + font-size: var(--fs-sm); font-weight: 500; } .tt-raw summary:hover { - color: #aecbfa; + color: var(--accent-bright); } .tt-raw-list { @@ -3002,7 +2104,7 @@ body.panelResizing #categoryDetailColumn { .tt-raw-row { padding: 5px 0; - border-bottom: 1px solid #2c2f37; + border-bottom: 1px solid var(--border-sub); } .tt-raw-row:last-child { @@ -3010,106 +2112,25 @@ body.panelResizing #categoryDetailColumn { } .tt-raw-row-label { - color: #8ab4f8; - font-size: 11px; + color: var(--accent-soft); + font-size: var(--fs-xs); font-weight: 600; margin-bottom: 2px; } .tt-raw-row-value { - color: #ccc; - font-family: "Consolas", "SF Mono", monospace; - font-size: 11px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-xs); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; } -/* ---- Progression modal (see progression.js) -- the shared dialog behind - the top bar's MAM / alternate recipes / AWESOME Shop / HUB milestones / - Space Elevator buttons. Same overlay as #itemModal (selector above); - the dialog itself is a bit wider and its body is one scroll area of - sections instead of a single flat list. */ -#progressionModal { - width: 540px; - max-width: 92vw; - max-height: 84vh; - background: var(--surface-2); - border: 1px solid var(--border-popup); - border-radius: 12px; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6); - padding: 16px 18px; - box-sizing: border-box; - display: flex; - flex-direction: column; - color: var(--text); - transform: scale(0.96); - opacity: 0; - animation: buildingModalIn 0.16s ease-out forwards; -} - @media (prefers-reduced-motion: reduce) { #progressionModal { animation: none; transform: none; opacity: 1; } } -#progressionModalHeader { - display: flex; - align-items: center; - gap: 11px; - margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-popup); - flex: none; -} - -#progressionModalIcon { - width: 36px; - height: 36px; - flex: none; - object-fit: contain; - background: var(--inset); - border: 1px solid #2c2f37; - border-radius: 8px; - padding: 4px; - box-sizing: border-box; -} - -#progressionModalTitle { - font-weight: bold; - font-size: 17px; - color: #fff; - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -#progressionModalClose { - background: none; - border: none; - color: #999; - font-size: 22px; - line-height: 1; - cursor: pointer; - padding: 0 2px; -} - -#progressionModalClose:hover { - color: #fff; -} - -#progressionModalSummary { - color: #bbb; - margin-bottom: 8px; - flex: none; -} - -#progressionModalBody { - overflow-y: auto; - min-height: 0; -} - .progressionSection { margin-bottom: 12px; /* Some views hold 500+ rows across their sections; skip layout/paint for @@ -3128,68 +2149,34 @@ body.panelResizing #categoryDetailColumn { } .progressionSectionTitle { - font-size: 13px; + font-size: var(--fs-md); font-weight: 600; - color: #eee; + color: var(--text); } /* Small "n / total" tally on a section header; turns green when complete. */ .progressionSectionCount { - font-size: 12px; - color: #8a919e; + font-size: var(--fs-sm); + color: var(--muted); flex: none; margin-left: auto; } .progressionSectionCount.complete { - color: #7fd18b; + color: var(--ok-soft); } /* e.g. the MAM's "not discovered yet" tag on a research tree the save has never unlocked (tree header still listed so the gap is visible). */ .progressionSectionTag { - font-size: 11px; + font-size: var(--fs-xs); color: #b48a5a; border: 1px solid #4a3b28; background: rgba(180, 138, 90, 0.12); - border-radius: 4px; + border-radius: var(--r-xs); padding: 1px 6px; } -/* Thin per-section completion bar under the header. */ -.progressionBarTrack { - height: 3px; - border-radius: 2px; - background: var(--inset); - overflow: hidden; - margin-bottom: 6px; -} - -.progressionBarFill { - height: 100%; - background: var(--accent); - border-radius: 2px; -} - -.progressionBarFill.complete { - background: #58a565; -} - -.progressionList { - border: 1px solid #3a3a3a; - border-radius: 4px; -} - -/* A row stacks its always-visible main line over an optional expandable - cost list (see .progressionCostList), so the row itself is a column and - .progressionRowMain is the flex line the icon/label/status sit on. */ -.progressionRow { - display: flex; - flex-direction: column; - padding: 5px 8px; - border-bottom: 1px solid #2c2f37; -} - .progressionRowMain { display: flex; align-items: center; @@ -3197,14 +2184,6 @@ body.panelResizing #categoryDetailColumn { min-width: 0; } -.progressionRow:hover { - background: rgba(255, 255, 255, 0.04); -} - -.progressionRow:last-child { - border-bottom: none; -} - /* Entries the save hasn't unlocked/purchased yet -- kept in the list (seeing what's missing is the point of a progression view) but visibly dimmed. Only the icon + name dim: the right-hand cost/status text and the cost @@ -3225,7 +2204,7 @@ body.panelResizing #categoryDetailColumn { .progressionRowLabel { flex: 1 1 auto; min-width: 0; - color: #ddd; + color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -3241,36 +2220,8 @@ body.panelResizing #categoryDetailColumn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13px; - color: #c3c9d4; -} - -/* "▸ cost" expander on rows whose requirement list is too long to show - inline -- unfolds .progressionCostList below the row's main line. */ -.progressionCostToggle { - flex: none; - background: rgba(255, 255, 255, 0.04); - border: 1px solid #4a505c; - border-radius: 5px; - color: #c3c9d4; - font-size: 12px; - padding: 3px 9px; - cursor: pointer; -} - -.progressionCostToggle:hover { - color: #fff; - border-color: #6a7180; - background: rgba(255, 255, 255, 0.08); -} - -.progressionCostToggle .chev { - display: inline-block; - transition: transform 0.12s ease; -} - -.progressionCostToggle.open .chev { - transform: rotate(90deg); + font-size: var(--fs-md); + color: var(--text-dim); } .progressionCostList { @@ -3284,8 +2235,8 @@ body.panelResizing #categoryDetailColumn { display: flex; align-items: center; gap: 7px; - font-size: 13px; - color: #d3d8e0; + font-size: var(--fs-md); + color: var(--text-dim); } .progressionCostIcon { @@ -3297,42 +2248,42 @@ body.panelResizing #categoryDetailColumn { .progressionCostAmount { flex: none; - color: #9ec4fa; + color: var(--accent-soft); font-weight: 600; } .progressionRowStatus.done { - color: #7fd18b; - font-size: 13px; + color: var(--ok-soft); + font-size: var(--fs-md); font-weight: 600; } .progressionRowStatus.countValue { - color: #8ab4f8; + color: var(--accent-soft); font-weight: 500; - font-size: 13px; + font-size: var(--fs-md); } /* Space Elevator view: headline phase status + per-part delivery rows with their own mini progress bars. */ .progressionPhaseBanner { - border: 1px solid #3a3f4a; + border: 1px solid var(--border-popup); background: var(--inset); - border-radius: 8px; + border-radius: var(--r-md); padding: 9px 12px; margin-bottom: 10px; - color: #ddd; + color: var(--text); } .progressionPhaseBanner .phaseTitle { font-weight: 600; - color: #fff; - font-size: 14px; + color: var(--text-bright); + font-size: var(--fs-lg); } .progressionPhaseBanner .phaseNote { - font-size: 12px; - color: #8a919e; + font-size: var(--fs-sm); + color: var(--muted); margin-top: 2px; } @@ -3348,51 +2299,6 @@ body.panelResizing #categoryDetailColumn { padding: 7px 8px; } -.sePartBarTrack { - height: 4px; - border-radius: 2px; - background: var(--inset); - overflow: hidden; - margin: 5px 0 1px 30px; -} - -.sePartBarFill { - height: 100%; - background: var(--accent); - border-radius: 2px; -} - -.sePartBarFill.complete { - background: #58a565; -} - -/* ---- Optimal network finder (network.js) -- the search-bar-only spanning - tree planner. Docked top-right like #pastePanel (clear of the altitude - rail) and non-modal, since clicking the map underneath is how points get - added. Cyan is this tool's own signal colour, matching the links drawn on - the map; everything else is the shared panel language. ---------------- */ -#networkPanel { - position: fixed; - top: 72px; - right: calc(var(--altitude-width) + 12px); - z-index: 1500; - width: 330px; - /* border-box, or max-height would clamp the CONTENT box and the padding - and border would push the panel past the bottom of the window. */ - box-sizing: border-box; - max-height: calc(100vh - 96px); - display: flex; - flex-direction: column; - overflow: hidden; - padding: 14px; - background: var(--surface-2); - border: 1px solid var(--border-popup); - border-radius: 12px; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); - color: var(--text); - font-size: 13px; -} - #networkPanelHeader { display: flex; align-items: center; @@ -3454,78 +2360,21 @@ body.panelResizing #categoryDetailColumn { } #networkPanelSubtitle { - font-size: 11px; + font-size: var(--fs-xs); color: var(--muted); } -#networkPanelClose { - flex: none; - border: none; - background: none; - color: var(--faint); - font-size: 20px; - line-height: 1; - padding: 0 2px; - cursor: pointer; -} - -#networkPanelClose:hover { - color: var(--text-bright); -} - -.networkSectionLabel { - font-size: 10px; - letter-spacing: 0.09em; - text-transform: uppercase; - color: var(--faint); - margin: 4px 0 6px; -} - -.networkSectionLabel #networkPointCount { +.kicker #networkPointCount { letter-spacing: 0; text-transform: none; color: var(--muted); - font-size: 11px; + font-size: var(--fs-xs); margin-left: 6px; } -/* Segmented control: the two link styles are one either/or choice, so they - share a single track rather than reading as two separate buttons. */ -#networkModes { - display: flex; - gap: 3px; - padding: 3px; - background: var(--inset); - border: 1px solid var(--border); - border-radius: 8px; -} - -.networkSegment { - flex: 1; - padding: 6px 8px; - font-size: 12px; - font-family: inherit; - border: none; - border-radius: 6px; - background: none; - color: var(--muted); - cursor: pointer; - transition: background-color 0.1s ease, color 0.1s ease; -} - -.networkSegment:hover { - color: var(--text); -} - -.networkSegment.active { - background: rgba(37, 224, 255, 0.14); - color: #25e0ff; - font-weight: 600; -} - #networkModeHint { margin: 7px 0 4px; - font-size: 11px; + font-size: var(--fs-xs); line-height: 1.45; color: var(--muted); } @@ -3536,123 +2385,28 @@ body.panelResizing #categoryDetailColumn { margin-bottom: 6px; } -.networkButtonRow button { - flex: 1; - padding: 7px 8px; - font-size: 12px; - font-family: inherit; - border-radius: 7px; - cursor: pointer; - border: 1px solid var(--accent-border); - background: var(--accent-bg); - color: var(--accent-soft); -} - -.networkButtonRow button:hover:not(:disabled) { - background: var(--accent-bg-hover); - color: var(--text-bright); -} - -.networkButtonRow button:disabled { - opacity: 0.4; - cursor: default; -} - /* The map-picking toggle is a mode, not an action -- lit in the tool's own cyan for as long as clicks are being captured, matching the hint bar at the bottom of the map. */ #networkPickBtn.active { border-color: rgba(37, 224, 255, 0.55); - background: rgba(37, 224, 255, 0.14); - color: #25e0ff; - font-weight: 600; -} - -#networkComputeBtn { - font-weight: 600; -} - -#networkClearBtn { - flex: 0 0 auto !important; - border-color: var(--border) !important; - background: var(--raised) !important; - color: var(--muted) !important; -} - -#networkClearBtn:hover:not(:disabled) { - color: var(--text-bright) !important; - background: var(--raised-hover) !important; -} - -#networkCoordRow { - display: flex; - gap: 6px; - margin-bottom: 8px; -} - -#networkCoordRow input { - flex: 1; - min-width: 0; - padding: 6px 8px; - font-size: 12px; - font-family: inherit; - background: var(--inset); - border: 1px solid var(--border); - border-radius: 7px; - color: var(--text); -} - -#networkCoordRow input:focus { - outline: none; - border-color: var(--border-hover); -} - -#networkCoordRow button { - flex: none; - padding: 6px 12px; - font-size: 12px; - font-family: inherit; - border-radius: 7px; - cursor: pointer; - border: 1px solid var(--border); - background: var(--raised); - color: var(--text); -} - -#networkCoordRow button:hover { - background: var(--raised-hover); - color: var(--text-bright); -} - -/* Both lists scroll independently and share the row shape, so points and - links read as the same kind of thing at a glance. */ -.networkList { - overflow-y: auto; - /* Grows to 190px when there is room and shrinks to a couple of rows when - there is not -- the only elastic part of the panel. */ - flex: 1 1 auto; - min-height: 56px; - max-height: 190px; - margin-bottom: 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--inset); + background: rgba(37, 224, 255, 0.14); + color: var(--tool-cyan); + font-weight: 600; } -.networkList:empty { - display: none; +#networkComputeBtn { + font-weight: 600; } -.networkRow { +#networkCoordRow { display: flex; - align-items: center; - gap: 9px; - padding: 6px 8px; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); + gap: 6px; + margin-bottom: 8px; } -.networkRow:last-child { - border-bottom: none; +.networkList:empty { + display: none; } .networkRow:hover, @@ -3678,8 +2432,8 @@ body.panelResizing #categoryDetailColumn { height: 20px; border-radius: 50%; background: rgba(37, 224, 255, 0.16); - color: #25e0ff; - font-size: 10px; + color: var(--tool-cyan); + font-size: var(--fs-2xs); font-weight: 700; display: flex; align-items: center; @@ -3693,44 +2447,11 @@ body.panelResizing #categoryDetailColumn { flex-direction: column; } -.networkRowLabel { - font-size: 12px; - color: var(--text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.networkRowMeta { - font-size: 10.5px; - color: var(--faint); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.networkRowRemove { - flex: none; - border: none; - background: none; - color: var(--faint); - font-size: 15px; - line-height: 1; - padding: 2px 4px; - cursor: pointer; - border-radius: 5px; -} - -.networkRowRemove:hover { - color: var(--hidden-pink-soft); - background: rgba(255, 59, 129, 0.12); -} - /* The point list stops at network.js's LIST_ROW_LIMIT -- this row is what says so, instead of the list just appearing to end. */ .networkOverflowRow { padding: 7px 10px; - font-size: 11px; + font-size: var(--fs-xs); color: var(--faint); font-style: italic; text-align: center; @@ -3762,37 +2483,19 @@ body.panelResizing #categoryDetailColumn { } .networkSummaryTotal { - font-size: 21px; + font-size: var(--fs-3xl); font-weight: 700; - color: #25e0ff; + color: var(--tool-cyan); line-height: 1.1; font-variant-numeric: tabular-nums; } .networkSummaryDetail { - font-size: 11px; + font-size: var(--fs-xs); color: var(--muted); margin-top: 2px; } -#networkCopyBtn { - width: 100%; - box-sizing: border-box; - padding: 7px 10px; - font-size: 12px; - font-family: inherit; - border-radius: 7px; - cursor: pointer; - border: 1px solid var(--border); - background: var(--raised); - color: var(--text); -} - -#networkCopyBtn:hover { - background: var(--raised-hover); - color: var(--text-bright); -} - /* Destination + the length/trip priority slider (network.js). Both are optional: with no destination set the tool minimises length and nothing else, so the slider is not even rendered -- there is nothing to trade. */ @@ -3804,9 +2507,9 @@ body.panelResizing #categoryDetailColumn { padding: 5px 10px; margin-bottom: 8px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--r-md); background: var(--inset); - font-size: 12px; + font-size: var(--fs-sm); } #networkDestinationNone { @@ -3818,7 +2521,7 @@ body.panelResizing #categoryDetailColumn { flex: 1; min-width: 0; align-items: center; - color: #25e0ff; + color: var(--tool-cyan); font-weight: 600; white-space: nowrap; overflow: hidden; @@ -3830,10 +2533,10 @@ body.panelResizing #categoryDetailColumn { border: none; background: none; color: var(--faint); - font-size: 15px; + font-size: var(--fs-xl); line-height: 1; padding: 2px 4px; - border-radius: 5px; + border-radius: var(--r-sm); cursor: pointer; } @@ -3842,40 +2545,14 @@ body.panelResizing #categoryDetailColumn { background: var(--raised-hover); } -/* Per-row "route everything to this one" button. Dim until hovered, like the - row's remove button -- except on the row that IS the destination, where it - stays lit as the state indicator. */ -.networkRowDestination { - flex: none; - display: flex; - align-items: center; - border: none; - background: none; - color: var(--faint); - padding: 2px 3px; - border-radius: 5px; - cursor: pointer; - opacity: 0; - transition: opacity 0.1s ease, color 0.1s ease; -} - -.networkRow:hover .networkRowDestination { - opacity: 1; -} - -.networkRowDestination:hover { - color: #25e0ff; - background: rgba(37, 224, 255, 0.12); -} - .networkRow.isDestination { background: rgba(37, 224, 255, 0.07); - box-shadow: inset 2px 0 0 #25e0ff; + box-shadow: inset 2px 0 0 var(--tool-cyan); } .networkRow.isDestination .networkRowDestination { opacity: 1; - color: #25e0ff; + color: var(--tool-cyan); } #networkPriority { @@ -3885,20 +2562,20 @@ body.panelResizing #categoryDetailColumn { #networkAlpha { width: 100%; margin: 2px 0 0; - accent-color: #25e0ff; + accent-color: var(--tool-cyan); cursor: pointer; } #networkAlphaEnds { display: flex; justify-content: space-between; - font-size: 10px; + font-size: var(--fs-2xs); color: var(--faint); } #networkAlphaNote { margin-top: 5px; - font-size: 11px; + font-size: var(--fs-xs); line-height: 1.45; color: var(--muted); } @@ -3912,14 +2589,14 @@ body.panelResizing #categoryDetailColumn { } .networkTripsTotal { - font-size: 15px; + font-size: var(--fs-xl); font-weight: 700; color: var(--text-bright); font-variant-numeric: tabular-nums; } .networkTripsLabel { - font-size: 12px; + font-size: var(--fs-sm); color: var(--muted); } @@ -3928,16 +2605,16 @@ body.panelResizing #categoryDetailColumn { #networkHint { position: absolute; bottom: 22px; - left: var(--map-center-x); + left: 50%; transform: translateX(-50%); z-index: 600; padding: 8px 16px; background: var(--surface-2); border: 1px solid rgba(37, 224, 255, 0.45); - border-radius: 999px; + border-radius: var(--r-pill); box-shadow: var(--float-shadow); color: var(--text); - font-size: 12px; + font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; } @@ -3948,11 +2625,11 @@ body.panelResizing #categoryDetailColumn { .leaflet-tooltip.networkLinkTooltip { background: var(--surface-2); border: 1px solid var(--border-popup); - border-radius: 8px; + border-radius: var(--r-md); box-shadow: var(--float-shadow); color: var(--text); font-family: var(--font); - font-size: 12px; + font-size: var(--fs-sm); padding: 7px 10px; white-space: nowrap; } @@ -3963,18 +2640,18 @@ body.panelResizing #categoryDetailColumn { .networkTooltipTitle { font-weight: 700; - color: #25e0ff; + color: var(--tool-cyan); margin-bottom: 3px; } .networkTooltipLeg { - font-size: 11px; + font-size: var(--fs-xs); color: var(--muted); margin-bottom: 3px; } .networkTooltipRow { - font-size: 11px; + font-size: var(--fs-xs); color: var(--text); } @@ -3983,3 +2660,375 @@ body.panelResizing #categoryDetailColumn { width: 34px; color: var(--faint); } + +/* =========================================================================== + Feature adjustments to the ui.css primitives + --------------------------------------------------------------------------- + Everything below tweaks a shared primitive for one specific place. The test + for whether a rule belongs here rather than in ui.css: would every other + user of that primitive want it too? If yes it goes in ui.css instead. + ========================================================================= */ + +/* Search dropdown rows are a picker, not a data table -- no hairlines between + them, and each row is its own rounded target inside the popover. */ +#searchSuggestions .row { + padding: var(--sp-2) var(--sp-25); + border-bottom: none; + border-radius: var(--r-md); + content-visibility: visible; /* At most ~20 rows; skipping costs more than it saves. */ +} + +#searchSuggestions .kicker { + padding: var(--sp-15) var(--sp-25) var(--sp-1); + margin: 0; +} + +/* A group header states "Constructor × 28" as one phrase, so the label stops + growing and the badge sits right against it; margin-right:auto on the badge + takes over the gap-filling, keeping the count in the same right-hand column + as every ungrouped row's. */ +.itemLocationGroupHeader .row-label { + flex: 0 1 auto; +} + +.itemLocationGroupBadge { + margin-right: auto; +} + +/* Child rows show position + count only (the type is the header's job). The + extra left padding indents them under the header's label column. */ +.itemLocationChildRow { + padding-left: 48px; + font-size: var(--fs-md); +} + +.itemLocationChildLabel { + color: var(--muted); + font-family: var(--font-mono); + font-size: var(--fs-sm); +} + +.itemLocationShowMore { + width: 100%; + justify-content: flex-start; + padding-left: 48px; + color: var(--accent-soft); + border: none; + border-bottom: 1px solid var(--border-sub); + border-radius: 0; +} + +/* "Show on map" sits on every row of a list that can run to hundreds, so it + stays quiet until its row is hovered rather than competing with the label + and count that are the row's actual content. */ +.itemLocationLocate { + opacity: 0; + transition: opacity 0.1s ease; +} + +.row:hover .itemLocationLocate, +.itemLocationLocate:focus-visible { + opacity: 1; +} + +/* A progression row stacks its always-visible main line over an optional + expandable cost list, so the row itself is a column and .progressionRowMain + is the flex line the icon/label/status sit on. */ +.progressionRow { + flex-direction: column; + align-items: stretch; + gap: 0; + padding: var(--sp-15) var(--sp-2); +} + +/* The tool's own cyan, not the generic hover tint -- these rows are points in + a network drawn in that colour on the map. */ +.networkRow:hover, +.networkRow.hover { + background: rgba(37, 224, 255, 0.09); +} + +.networkSegment.active { + color: var(--tool-cyan); + background: var(--tool-cyan-bg); +} + +#networkPickBtn.active { + border-color: var(--tool-cyan-border); + background: var(--tool-cyan-bg); + color: var(--tool-cyan); +} + +#networkCoordRow .field { + flex: 1; + min-width: 0; +} + +/* The busy dialog is a centred stack, not the usual head/body/foot. */ +#busyBox { + align-items: center; + gap: var(--sp-3); + padding: var(--sp-6); + text-align: center; +} + +/* align-items:center above would otherwise shrink the progress bar to its + (zero) content width. */ +#busyBox .bar { + align-self: stretch; +} + +#loadProgressBar { + margin-top: var(--sp-2); +} + +#categoryNavHeader .btn { + flex: 1 1 50%; +} + +/* =========================================================================== + Docked-layout adjustments + --------------------------------------------------------------------------- + What the panels needed once they stopped floating: the altitude rail became + a flex child of the tool dock rather than a strip pinned to the window, the + tool panels became dock panes, and the surviving floating elements are now + positioned inside #mapOverlays (which IS the map), so their offsets are + measured from the map rather than from the window. + ========================================================================= */ + +/* ---- Altitude rail: the tool dock's permanent right-hand strip ------------ + Hidden until a save is loaded (altitude.js). Because the dock's grid track + is `auto`, hiding it costs zero width -- which is what removed the dead + 64px black strip that used to sit beside the map on first load. */ +#altitudePanel { + flex: none; + width: var(--rail-width); + box-sizing: border-box; + padding: var(--sp-1); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + overflow: hidden; + background: var(--surface-1); + border-left: 1px solid var(--border); + color: var(--text); +} + +/* ---- Tool panels as dock panes ------------------------------------------- */ + +/* The network finder already split itself into header / scrolling body / + pinned footer, which is exactly the shape a dock pane wants. */ +#networkPanel { + font-size: var(--fs-md); +} + +/* The paste panel is a single short form, so it scrolls as one piece rather + than growing a pinned footer it does not need. */ +#pastePanel { + overflow-y: auto; +} + +#pastePanelTitle { + font-size: var(--fs-lg); + font-weight: 700; + color: var(--text-bright); + margin-bottom: var(--sp-3); +} + +/* ---- Floating overlays, now measured from the map ------------------------- */ + +/* Just under the app bar rather than 68px down the window. */ +#activeFilterBanner { + top: var(--sp-3); +} + +#editorToolbar { + top: var(--sp-3); +} + +/* ---- App-bar controls ---------------------------------------------------- + Everything in the bar is 34px tall so the 48px bar has an even 7px of air + above and below, and the bar reads as one row rather than a stack of + differently sized islands. */ +#mainSearchInput { + height: 34px; + font-size: var(--fs-lg); + box-shadow: none; +} + +.topPillButton { + height: 34px; + box-shadow: none; +} + +#menuButton { + width: 34px; + min-width: 34px; + height: 34px; +} + +/* The one brand moment in the chrome: a two-line FICSIT-orange wordmark + (small-caps kicker over the name). No longer a card of its own -- it is + part of the bar. */ +#logoButton { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 1px; + height: 34px; + padding: 0 var(--sp-2); + background: none; + border: none; + border-left: 3px solid var(--brand); + border-radius: 0; + cursor: pointer; + text-align: left; +} + +#logoButton:hover .logoName { color: var(--brand-bright); } +#logoButton:hover .logoKicker { color: var(--muted); } + +/* ---- Dock empty state ----------------------------------------------------- */ +#dockEmptyState { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: var(--sp-6) var(--sp-4); + text-align: center; + color: var(--faint); +} + +#dockEmptyState svg { + color: var(--brand); + opacity: 0.65; +} + +.dockEmptyTitle { + margin: 0; + font-size: var(--fs-lg); + font-weight: 600; + color: var(--muted); +} + +.dockEmptyText { + margin: 0; + max-width: 26ch; + font-size: var(--fs-sm); + line-height: 1.5; +} + +.dockEmptyText strong { + color: var(--brand); + font-weight: 600; +} + +/* Brand mark (not a control -- see index.html). */ +#logoMark { + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + height: 34px; + padding: 0 var(--sp-2); + border-left: 3px solid var(--brand); + user-select: none; +} + +/* =========================================================================== + Responsive + --------------------------------------------------------------------------- + The app had no layout media queries at all, so at 1024px the left dock ate + a third of the window and at 800px the search field was crushed to nothing. + Three steps, each removing whatever is costing the map the most room at + that width. + ========================================================================= */ + +/* The dock's remembered width is a preference, not a promise: cap it as a + share of the window so a 340px dock does not eat half a 900px screen. */ +@media (max-width: 1200px) { + #sidebar { + width: min(var(--dock-left-width), 32vw); + } +} + +/* Below this the pills lose their labels -- the icons are unambiguous next to + each other, and the search field is worth more than the words. */ +@media (max-width: 980px) { + #mainSearchWrap { + flex: 1 1 auto; + } + + .topPillButton > span:not(#updatePillLabel) { + display: none; + } + + .topPillButton { + padding: 0 var(--sp-2); + } +} + +/* Narrow enough that a dock covering a third of the window leaves the map + unusable. The docks already overlay at every size, so this only caps how + much they may cover -- and panels.js starts the left one collapsed here, so + the map is what you land on. */ +@media (max-width: 820px) { + #sidebar { + width: min(var(--dock-left-width), 78vw); + box-shadow: 8px 0 24px rgba(0, 0, 0, 0.45); + } + + #toolDock { + box-shadow: -8px 0 24px rgba(0, 0, 0, 0.45); + } + + #toolPanels { + width: min(var(--dock-right-width), 78vw); + } + + /* At this size a dock covers most of the map, so insetting the hints inside + what is left would squeeze them to nothing -- let them use the whole + window and sit under the dock instead. */ + #mapOverlays { + left: 0; + right: 0; + } + + /* Dragging a width is a pointer-and-space affordance; neither applies here. */ + .dockResizeHandle { + display: none; + } + + /* The brand wordmark is the first thing worth dropping: the favicon and the + page title already say what this is. */ + #logoMark { + display: none; + } +} + +@media (max-width: 620px) { + #mainSearchInput { + font-size: var(--fs-md); + } +} + +/* Leaflet's controls live inside #map, which now runs under the tool dock -- + push the right-hand ones (zoom, attribution) clear of it. */ +.leaflet-right { + right: var(--dock-right-inset); + transition: right 0.12s var(--panel-ease); +} + +.leaflet-left { + left: var(--dock-left-inset); + transition: left 0.12s var(--panel-ease); +} + +@media (prefers-reduced-motion: reduce) { + .leaflet-right, .leaflet-left { transition: none; } +} diff --git a/map/static/map/network.js b/map/static/map/network.js index d3955a9..7c99dab 100644 --- a/map/static/map/network.js +++ b/map/static/map/network.js @@ -741,20 +741,20 @@ var NetworkTool = {}; dom.pointList.innerHTML = ""; points.slice(0, LIST_ROW_LIMIT).forEach(function(point, index) { var isDestination = index === destination; - var row = el("div", "networkRow" + (isDestination ? " isDestination" : "")); + var row = el("div", "row row-hover networkRow" + (isDestination ? " isDestination" : "")); row.appendChild(el("span", "networkRowIndex", String(index + 1))); var text = el("div", "networkRowText"); - text.appendChild(el("span", "networkRowLabel", pointLabel(point, index))); + text.appendChild(el("span", "row-label networkRowLabel", pointLabel(point, index))); var coordinates = pointCoordinates(point) + (point.z === null ? "" : " · " + Math.round(point.z).toLocaleString() + " m up"); - text.appendChild(el("span", "networkRowMeta", coordinates)); + text.appendChild(el("span", "row-meta", coordinates)); row.appendChild(text); - var pick = el("button", "networkRowDestination"); + var pick = el("button", "btn btn-ghost btn-sm btn-icon networkRowDestination"); pick.innerHTML = TARGET_SVG; pick.title = isDestination ? "Stop routing to this point" : "Make this the destination"; pick.addEventListener("click", function() { setDestination(index); }); row.appendChild(pick); - var remove = el("button", "networkRowRemove", "×"); + var remove = el("button", "btn btn-ghost btn-sm btn-icon networkRowRemove", "×"); remove.title = "Remove this point"; remove.addEventListener("click", function() { removePoint(index); }); row.appendChild(remove); @@ -950,12 +950,11 @@ var NetworkTool = {}; NetworkTool.open = function() { ensureDom(); + Panels.openTool(dom.panel); // Into the right tool dock -- see panels.js. if (open) { - dom.panel.style.display = "flex"; return; } open = true; - dom.panel.style.display = "flex"; ensureLayers(); MapApp.map.on("click", onMapClick); // Picking starts OFF: opening a panel should not quietly take over what a @@ -974,7 +973,7 @@ var NetworkTool = {}; } setPicking(false); // Before open flips: setPicking only touches the UI while open. open = false; - dom.panel.style.display = "none"; + Panels.closeTool(dom.panel); MapApp.map.off("click", onMapClick); hoveringLink = false; // The points and the computed tree are kept in memory: reopening from the @@ -996,17 +995,21 @@ var NetworkTool = {}; } }; - document.addEventListener("keydown", function(e) { - if (e.key !== "Escape" || e.defaultPrevented || !open) { - return; + // Two layers, popped one press at a time (see ui.js's UI.onEscape): stop + // placing points first, close the tool only on a second Escape. + UI.onEscape(UI.LAYER.placement, function() { + if (!open || !picking) { + return false; } - // One layer per press, same convention as the modals: stop placing - // points first, close the tool only on a second Escape. - if (picking) { - setPicking(false); - } else { - NetworkTool.close(); + setPicking(false); + return true; + }); + + UI.onEscape(UI.LAYER.tool, function() { + if (!open) { + return false; } - e.preventDefault(); + NetworkTool.close(); + return true; }); })(); diff --git a/map/static/map/panels.js b/map/static/map/panels.js index 3b51310..17405bc 100644 --- a/map/static/map/panels.js +++ b/map/static/map/panels.js @@ -1,23 +1,32 @@ -// Floating-panel chrome: the top-left menu button that slides the sidebar -// overlay in/out, and the drag handles that resize the nav/detail columns -// (writing --nav-col-width / --detail-col-width, the same CSS variables the -// whole layout already derives from). Widths survive reloads via -// localStorage; filters.js's autoSizeNavPanel defers to a stored nav width -// (see Panels.storedNavWidth) so a save load doesn't undo a manual resize. +// Shell chrome: the two docks and the app bar. +// +// The docks are pinned to the window's edges on top of the map (see map.css): +// attached and flush, but out of flow, so showing or resizing one never +// changes the map's box. Nothing here needs to tell Leaflet about a dock. +// +// Owns: +// - collapsing/showing the left dock (the app bar's hamburger) +// - dragging its width (persisted in localStorage) +// - the left dock's two-pane push navigation (categories -> one category) +// - the right tool dock: which tool panel is in it, and whether it is open +// - the "Progression" dropdown, and the desktop app's update pill (function() { "use strict"; + var body = document.body; + var mapEl = document.getElementById("map"); var sidebar = document.getElementById("sidebar"); var menuButton = document.getElementById("menuButton"); - var navPanel = document.getElementById("categoryNavPanel"); - var detailColumn = document.getElementById("categoryDetailColumn"); - var navHandle = document.getElementById("navResizeHandle"); - var detailHandle = document.getElementById("detailResizeHandle"); + var dockHandle = document.getElementById("dockResizeHandle"); + var pagerNav = document.getElementById("dockPaneNav"); + var pagerDetail = document.getElementById("dockPaneDetail"); + var detailBackBtn = document.getElementById("detailBackBtn"); + var detailTitle = document.getElementById("detailTitle"); + var detailSwatch = document.getElementById("detailSwatch"); + var toolPanels = document.getElementById("toolPanels"); - var NAV_WIDTH_KEY = "smap.navColWidth"; - var DETAIL_WIDTH_KEY = "smap.detailColWidth"; - var NAV_MIN = 200, NAV_MAX = 480; - var DETAIL_MIN = 220, DETAIL_MAX = 560; + var DOCK_WIDTH_KEY = "smap.dockLeftWidth"; + var DOCK_MIN = 220, DOCK_MAX = 480; function readStoredWidth(key) { try { @@ -34,48 +43,231 @@ } catch (e) { /* see readStoredWidth */ } } + // Re-apply the persisted width before the first layout the user sees. + var storedWidth = readStoredWidth(DOCK_WIDTH_KEY); + if (storedWidth !== null) { + document.documentElement.style.setProperty("--dock-left-width", storedWidth + "px"); + } + window.Panels = { - storedNavWidth: function() { return readStoredWidth(NAV_WIDTH_KEY); }, + storedNavWidth: function() { return readStoredWidth(DOCK_WIDTH_KEY); }, }; - // Re-apply persisted widths before the first layout the user sees. - var storedNav = readStoredWidth(NAV_WIDTH_KEY); - if (storedNav !== null) { - document.documentElement.style.setProperty("--nav-col-width", storedNav + "px"); - } - var storedDetail = readStoredWidth(DETAIL_WIDTH_KEY); - if (storedDetail !== null) { - document.documentElement.style.setProperty("--detail-col-width", storedDetail + "px"); + // ---- Left dock: show/hide ------------------------------------------------- + + function setDockHidden(hidden) { + body.classList.toggle("dock-hidden", hidden); + menuButton.classList.toggle("is-active", hidden); + var label = hidden ? "Show the layers panel" : "Hide the layers panel"; + menuButton.title = label; + menuButton.setAttribute("aria-label", label); + menuButton.setAttribute("aria-expanded", String(!hidden)); } - // ---- Show/hide the whole sidebar overlay -------------------------------- + // Below the drawer breakpoint (see map.css) the dock stops being a column of + // the grid and overlays the map instead, so leaving it open would mean a + // screen that is mostly panel. Collapse it on the way in and restore it on + // the way out -- but only until the user expresses a preference, after which + // their choice sticks and the breakpoint stops touching it. + var DRAWER_BREAKPOINT = 820; + var drawerQuery = window.matchMedia("(max-width: " + DRAWER_BREAKPOINT + "px)"); + var userChoseDockState = false; menuButton.addEventListener("click", function() { - var hidden = sidebar.classList.toggle("hidden"); - menuButton.classList.toggle("panelHidden", hidden); - // The hamburger+logo card sits flush on the sidebar while it's shown - // (its visual header); detached it floats as its own rounded card. - var brandCluster = document.getElementById("brandCluster"); - if (brandCluster) { - brandCluster.classList.toggle("detached", hidden); + userChoseDockState = true; + setDockHidden(!body.classList.contains("dock-hidden")); + }); + + function syncDockToWidth() { + if (!userChoseDockState) { + setDockHidden(drawerQuery.matches); } - menuButton.title = hidden ? "Show the side panel" : "Hide the side panel"; - menuButton.setAttribute("aria-expanded", String(!hidden)); + } + + if (drawerQuery.addEventListener) { + drawerQuery.addEventListener("change", syncDockToWidth); + } + syncDockToWidth(); + + // ---- Left dock: two-pane push navigation ---------------------------------- + // + // Both panes are always in the DOM (the pager slides between them), so the + // one that is off-screen has to be taken out of the tab order and hidden + // from assistive tech -- otherwise Tab walks into a pane nobody can see. + // `inert` does both in one attribute. + function syncPaneInertness() { + var detailOpen = body.classList.contains("category-open"); + pagerNav.inert = detailOpen; + pagerDetail.inert = !detailOpen; + } + + Panels.showCategoryDetail = function(title, color) { + detailTitle.textContent = title || ""; + detailSwatch.style.background = color || "transparent"; + detailSwatch.style.display = color ? "inline-block" : "none"; + body.classList.add("category-open"); + syncPaneInertness(); + }; + + Panels.showCategoryList = function() { + body.classList.remove("category-open"); + syncPaneInertness(); + }; + + Panels.isCategoryDetailOpen = function() { + return body.classList.contains("category-open"); + }; + + // The back button and Escape are the two ways out. Filters owns what + // "deselect" means to the map, so route through it when it is loaded. + function goBack() { + if (window.Filters && Filters.deselectAllCategories) { + Filters.deselectAllCategories(); + } else { + Panels.showCategoryList(); + } + } + + detailBackBtn.addEventListener("click", goBack); + + UI.onEscape(UI.LAYER.view, function() { + if (!Panels.isCategoryDetailOpen()) { + return false; + } + goBack(); + return true; }); + syncPaneInertness(); + + // ---- Right dock: one tool at a time --------------------------------------- + // + // Every tool panel (paste placement, network finder) is authored as a + // body-level element in index.html and adopted into the dock here, so a tool + // does not have to know it lives in a dock -- and, more usefully, so two + // tools cannot end up drawn on the same pixels, which is exactly what the + // free-floating versions did (both were anchored top-right). + var currentTool = null; + + Panels.openTool = function(el) { + if (!el) { + return; + } + if (currentTool && currentTool !== el) { + Panels.closeTool(currentTool); + } + if (el.parentNode !== toolPanels) { + toolPanels.appendChild(el); + } + el.style.display = ""; + currentTool = el; + body.classList.add("tool-open"); + }; + + Panels.closeTool = function(el) { + if (el && el !== currentTool) { + el.style.display = "none"; // A tool that was already swapped out; the dock did not change. + return; + } + if (currentTool) { + currentTool.style.display = "none"; + currentTool = null; + } + body.classList.remove("tool-open"); + }; + + Panels.isToolOpen = function(el) { + return el ? currentTool === el : currentTool !== null; + }; + + // Pre-adopt the panels so their first open does not reparent (and reset) + // live form state. + ["pastePanel", "networkPanel"].forEach(function(id) { + var el = document.getElementById(id); + if (el) { + el.style.display = "none"; + toolPanels.appendChild(el); + } + }); + + // ---- Left dock: drag to resize -------------------------------------------- + + // The element (not the CSS variable) provides the drag's starting width: the + // variable's initial value is a clamp() expression, so only the laid-out + // element knows the real current pixel width. + dockHandle.addEventListener("pointerdown", function(e) { + if (e.button !== 0) { + return; + } + e.preventDefault(); + dockHandle.setPointerCapture(e.pointerId); + dockHandle.classList.add("dragging"); + body.classList.add("dockResizing"); + var startX = e.clientX; + var startWidth = sidebar.getBoundingClientRect().width; + var lastWidth = Math.round(startWidth); + + function onMove(ev) { + lastWidth = Math.round(Math.min(DOCK_MAX, Math.max(DOCK_MIN, startWidth + (ev.clientX - startX)))); + document.documentElement.style.setProperty("--dock-left-width", lastWidth + "px"); + } + + function onEnd() { + dockHandle.removeEventListener("pointermove", onMove); + dockHandle.removeEventListener("pointerup", onEnd); + dockHandle.removeEventListener("pointercancel", onEnd); + dockHandle.classList.remove("dragging"); + body.classList.remove("dockResizing"); + storeWidth(DOCK_WIDTH_KEY, lastWidth); + } + + dockHandle.addEventListener("pointermove", onMove); + dockHandle.addEventListener("pointerup", onEnd); + dockHandle.addEventListener("pointercancel", onEnd); + }); + + // ---- Keep Leaflet in step with the window --------------------------------- + // + // The docks overlay the map rather than taking a column of the grid (see + // map.css), so opening, closing or resizing one does NOT change the map's + // box -- which is exactly why none of this has to compensate for anything. + // The map's size is a function of the window alone, and this is here for the + // one thing that still changes it: the window itself being resized. + // + // Do not be tempted to make the docks resize the map again "so the map is + // the space between them". That is where the ~141px sideways lurch on every + // dock toggle came from; docs/dock-map-anchoring.md has the two failed + // attempts at correcting it. + if (mapEl && window.ResizeObserver) { + var pending = false; + new ResizeObserver(function() { + if (pending) { + return; + } + pending = true; + requestAnimationFrame(function() { + pending = false; + if (window.MapApp && MapApp.map) { + MapApp.map.invalidateSize({ animate: false }); + } + }); + }).observe(mapEl); + } + + // ---- Desktop-only chrome --------------------------------------------------- + // Inside the desktop app the "download the desktop app" link is noise. var desktopAppLink = document.getElementById("desktopAppLink"); if (desktopAppLink && window.__TAURI__) { desktopAppLink.style.display = "none"; } - // ---- Desktop auto-update (Tauri updater plugin) ------------------------- - // Quiet check shortly after startup; a waiting update shows the accent - // pill in the top bar. Clicking downloads + verifies the signed installer - // and runs it (passive mode) -- on Windows the app exits into the - // installer and reopens updated. Any failure (offline, no latest.json on - // the newest release yet, endpoint change) is silently ignored: updating - // is never worth an error dialog on launch. + // Desktop auto-update (Tauri updater plugin). Quiet check shortly after + // startup; a waiting update shows the accent pill in the app bar. Clicking + // downloads + verifies the signed installer and runs it (passive mode) -- on + // Windows the app exits into the installer and reopens updated. Any failure + // (offline, no latest.json on the newest release yet, endpoint change) is + // silently ignored: updating is never worth an error dialog on launch. var updatePill = document.getElementById("updatePill"); var updatePillLabel = document.getElementById("updatePillLabel"); if (updatePill && window.__TAURI__ && window.__TAURI__.updater) { @@ -110,18 +302,18 @@ }, 3000); } - // ---- "Progression" dropdown (top right) --------------------------------- + // ---- "Progression" dropdown (app bar, right) ------------------------------- // Open/close chrome only -- the rows' own click handlers live in // progression.js/finditem.js, bound by id. A row click bubbles here and - // closes the menu so the opened modal isn't sitting under it. + // closes the menu so the dialog it opens isn't sitting under it. var statusMenuButton = document.getElementById("statusMenuButton"); var statusMenu = document.getElementById("statusMenu"); if (statusMenuButton && statusMenu) { - function setStatusMenuOpen(open) { + var setStatusMenuOpen = function(open) { statusMenu.style.display = open ? "block" : "none"; statusMenuButton.classList.toggle("open", open); statusMenuButton.setAttribute("aria-expanded", String(open)); - } + }; statusMenuButton.addEventListener("click", function() { setStatusMenuOpen(statusMenu.style.display === "none"); }); @@ -136,51 +328,12 @@ setStatusMenuOpen(false); } }); - document.addEventListener("keydown", function(e) { - if (e.key === "Escape" && statusMenu.style.display !== "none") { - setStatusMenuOpen(false); + UI.onEscape(UI.LAYER.menu, function() { + if (statusMenu.style.display === "none") { + return false; } + setStatusMenuOpen(false); + return true; }); } - - // ---- Drag-to-resize handles ---------------------------------------------- - - // measureEl (not the CSS variable) provides the drag's starting width -- - // the variable's initial value is a clamp() expression, so only the laid- - // out element knows the real current pixel width. - function setupResizeHandle(handle, measureEl, cssVar, storageKey, min, max) { - handle.addEventListener("pointerdown", function(e) { - if (e.button !== 0) { - return; - } - e.preventDefault(); - handle.setPointerCapture(e.pointerId); - handle.classList.add("dragging"); - document.body.classList.add("panelResizing"); - var startX = e.clientX; - var startWidth = measureEl.getBoundingClientRect().width; - var lastWidth = Math.round(startWidth); - - function onMove(ev) { - lastWidth = Math.round(Math.min(max, Math.max(min, startWidth + (ev.clientX - startX)))); - document.documentElement.style.setProperty(cssVar, lastWidth + "px"); - } - - function onEnd() { - handle.removeEventListener("pointermove", onMove); - handle.removeEventListener("pointerup", onEnd); - handle.removeEventListener("pointercancel", onEnd); - handle.classList.remove("dragging"); - document.body.classList.remove("panelResizing"); - storeWidth(storageKey, lastWidth); - } - - handle.addEventListener("pointermove", onMove); - handle.addEventListener("pointerup", onEnd); - handle.addEventListener("pointercancel", onEnd); - }); - } - - setupResizeHandle(navHandle, navPanel, "--nav-col-width", NAV_WIDTH_KEY, NAV_MIN, NAV_MAX); - setupResizeHandle(detailHandle, detailColumn, "--detail-col-width", DETAIL_WIDTH_KEY, DETAIL_MIN, DETAIL_MAX); })(); diff --git a/map/static/map/progression.js b/map/static/map/progression.js index c9b30f5..104041b 100644 --- a/map/static/map/progression.js +++ b/map/static/map/progression.js @@ -12,10 +12,9 @@ var Progression = {}; window.Progression = Progression; - var overlay = document.getElementById("progressionModalOverlay"); + var dialog = UI.dialog("progressionModal"); var modalIcon = document.getElementById("progressionModalIcon"); var modalTitle = document.getElementById("progressionModalTitle"); - var modalClose = document.getElementById("progressionModalClose"); var modalSummary = document.getElementById("progressionModalSummary"); var modalBody = document.getElementById("progressionModalBody"); @@ -24,12 +23,7 @@ var data = null; // payload.progression for the currently loaded save (null before any load). - function el(tag, className, text) { - var e = document.createElement(tag); - if (className) e.className = className; - if (text !== undefined) e.textContent = text; - return e; - } + var el = UI.el; // Item icon with a building fallback: progression rows reference items // (research costs, unlocked recipes' products), but a "product" is often a @@ -62,26 +56,13 @@ modalIcon.src = iconUrl; modalSummary.textContent = ""; modalBody.innerHTML = ""; - overlay.style.display = "flex"; + dialog.open(); } function closeModal() { - overlay.style.display = "none"; + dialog.close(); } - modalClose.addEventListener("click", closeModal); - overlay.addEventListener("click", function(e) { - if (e.target === overlay) { - closeModal(); // Click on the backdrop, not the dialog itself. - } - }); - document.addEventListener("keydown", function(e) { - if (e.key === "Escape" && !e.defaultPrevented && overlay.style.display !== "none") { - closeModal(); - e.preventDefault(); // One layer per press -- see finditem.js. - } - }); - // One grouped block: header ("Tier 5" / "Quartz" / "Walls"), an n/total // tally, a thin completion bar, and the row list. `tag` is an optional // warning chip on the header (e.g. a MAM tree the save never discovered). @@ -98,15 +79,15 @@ } header.appendChild(count); root.appendChild(header); - var track = el("div", "progressionBarTrack"); - var fill = el("div", "progressionBarFill"); + var track = el("div", "bar bar-thin progressionBarTrack"); + var fill = el("div", "bar-fill"); fill.style.width = (total > 0 ? (100 * doneCount / total) : 0) + "%"; if (doneCount >= total && total > 0) { - fill.classList.add("complete"); + fill.classList.add("is-complete"); } track.appendChild(fill); root.appendChild(track); - var list = el("div", "progressionList"); + var list = el("div", "list progressionList"); root.appendChild(list); modalBody.appendChild(root); return list; @@ -132,7 +113,7 @@ // "25× Rotor, 200× Iron Rod, ..." strings overflowed the modal). The list // is only built on first expand, so collapsed rows cost nothing. function row(list, entry, iconItem, pending) { - var r = el("div", "progressionRow" + (entry.done ? "" : " locked")); + var r = el("div", "row row-hover progressionRow" + (entry.done ? "" : " locked")); var main = el("div", "progressionRowMain"); if (iconItem !== undefined) { main.appendChild(rowIcon(iconItem)); @@ -145,9 +126,9 @@ } else if (Array.isArray(pending) && pending.length === 1) { main.appendChild(el("span", "progressionRowStatus", costText(pending))); } else if (Array.isArray(pending) && pending.length > 1) { - var toggle = el("button", "progressionCostToggle"); - toggle.appendChild(el("span", "chev", "▸")); - toggle.appendChild(document.createTextNode(" cost")); + var toggle = el("button", "btn btn-sm progressionCostToggle"); + toggle.appendChild(UI.chevron(12, "chev-right")); + toggle.appendChild(el("span", null, "cost")); var costList = null; toggle.addEventListener("click", function() { if (costList === null) { @@ -163,7 +144,7 @@ } else { costList.style.display = costList.style.display === "none" ? "" : "none"; } - toggle.classList.toggle("open", costList.style.display !== "none"); + toggle.classList.toggle("is-open", costList.style.display !== "none"); }); main.appendChild(toggle); } @@ -330,10 +311,10 @@ }).length; modalSummary.textContent = fullyDelivered + " / " + parts.length + " parts fully delivered."; - var list = el("div", "progressionList"); + var list = el("div", "list progressionList"); parts.forEach(function(part) { var partDone = part.required !== null && part.imported >= part.required; - var r = el("div", "progressionRow sePartRow" + (partDone ? "" : " locked")); + var r = el("div", "row row-hover progressionRow sePartRow" + (partDone ? "" : " locked")); var main = el("div", "progressionRowMain"); main.appendChild(rowIcon(part.item)); main.appendChild(el("span", "progressionRowLabel", part.label)); @@ -344,8 +325,8 @@ main.appendChild(status); r.appendChild(main); if (part.required) { - var track = el("div", "sePartBarTrack"); - var fill = el("div", "sePartBarFill" + (partDone ? " complete" : "")); + var track = el("div", "bar sePartBarTrack"); + var fill = el("div", "bar-fill" + (partDone ? " is-complete" : "")); fill.style.width = Math.min(100, 100 * part.imported / part.required) + "%"; track.appendChild(fill); r.appendChild(track); diff --git a/map/static/map/selection.js b/map/static/map/selection.js index e2a310a..579fa8e 100644 --- a/map/static/map/selection.js +++ b/map/static/map/selection.js @@ -23,11 +23,10 @@ var SelectionTool = {}; var deleteBtn = document.getElementById("selectionDeleteBtn"); var clearBtn = document.getElementById("selectionClearBtn"); - var overlay = document.getElementById("selectionModalOverlay"); + var dialog = UI.dialog("selectionModal"); var modalTitle = document.getElementById("selectionModalTitle"); var modalSummary = document.getElementById("selectionModalSummary"); var modalList = document.getElementById("selectionModalList"); - var modalClose = document.getElementById("selectionModalClose"); var MIN_DRAG_PX = 4; // Below this the gesture is a stray right-click, not a drag. // Line layers whose segments are real editable actors: belts/pipes @@ -824,26 +823,13 @@ var SelectionTool = {}; function openModal(title, summary) { modalTitle.textContent = title; modalSummary.textContent = summary; - overlay.style.display = "flex"; + dialog.open(); } function closeModal() { - overlay.style.display = "none"; + dialog.close(); } - modalClose.addEventListener("click", closeModal); - overlay.addEventListener("click", function(e) { - if (e.target === overlay) { - closeModal(); - } - }); - document.addEventListener("keydown", function(e) { - if (e.key === "Escape" && !e.defaultPrevented && overlay.style.display !== "none") { - closeModal(); - e.preventDefault(); // One layer per press -- see finditem.js. - } - }); - objectsBtn.addEventListener("click", function() { if (!lastSelection) { return; diff --git a/map/static/map/tooltip.js b/map/static/map/tooltip.js index 63c38c0..ae9ee63 100644 --- a/map/static/map/tooltip.js +++ b/map/static/map/tooltip.js @@ -54,7 +54,7 @@ var Tooltip = {}; mapEventsBound = true; MapApp.map.on("zoomstart", function() { Tooltip.hide(); }); MapApp.map.on("move", function() { - if (!anchorLatLng || !tooltipEl || tooltipEl.style.display === "none") { + if (!anchorLatLng || !isShowing()) { return; } var point = MapApp.map.latLngToContainerPoint(anchorLatLng); @@ -63,15 +63,27 @@ var Tooltip = {}; }); } + // popover="manual" puts the tooltip in the TOP LAYER, which is the only + // way for it to paint above a modal -- no z-index, however large, + // can beat showModal(). Top-layer order is promotion order, so a tooltip + // shown while a dialog is open (hovering a row in the item dialog's + // location list) lands above it, and one shown over the bare map is simply + // the only thing up there. "manual" keeps light dismiss off: the tooltip is + // driven entirely by hover/pin, not by outside clicks. function ensureElement() { if (!tooltipEl) { tooltipEl = document.createElement("div"); tooltipEl.id = "tt-tooltip"; + tooltipEl.setAttribute("popover", "manual"); document.body.appendChild(tooltipEl); } return tooltipEl; } + function isShowing() { + return !!tooltipEl && tooltipEl.matches(":popover-open"); + } + function position(clientX, clientY) { lastClientX = clientX; lastClientY = clientY; @@ -97,15 +109,13 @@ var Tooltip = {}; var element = ensureElement(); element.innerHTML = ""; element.appendChild(node); - element.style.display = "block"; + // Re-promote on every show: a tooltip first shown over the map and then + // over a dialog has to be promoted AFTER the dialog to sit above it. + UI.hideAbove(element); + UI.showAbove(element); } - function el(tag, className, text) { - var e = document.createElement(tag); - if (className) e.className = className; - if (text !== undefined) e.textContent = text; - return e; - } + var el = UI.el; function row(label, value) { var r = el("div", "tt-row"); @@ -564,9 +574,8 @@ var Tooltip = {}; pendingTimer = null; } if (tooltipEl) { - tooltipEl.style.display = "none"; + UI.hideAbove(tooltipEl); tooltipEl.style.pointerEvents = "none"; - tooltipEl.classList.remove("tt-above-modals"); } }; @@ -584,9 +593,6 @@ var Tooltip = {}; // Hover preview -- ignored entirely while a tooltip is pinned (see map.js's // mousemove handler, which checks isPinned() before calling this). Tooltip.show = function(clientX, clientY, hit) { - if (tooltipEl) { - tooltipEl.classList.remove("tt-above-modals"); // Back on the map -- drop any leftover over-modal lift (see showFloating). - } renderHit(clientX, clientY, hit); }; @@ -594,12 +600,11 @@ var Tooltip = {}; // map hit -- used by finditem.js's item-location list so hovering a machine // row shows the exact same rich detail popup as hovering its map pin. // Takes a renderSpec spec directly (see renderSpec for the fields). - // "tt-above-modals" lifts the tooltip over the modal overlay (z 1500, - // higher than the tooltip's usual 1000, which only ever had to beat the - // map); Tooltip.hide() removes it again so map hovers go back under. + // Nothing extra is needed to clear the dialog it is shown over: the + // tooltip is a top-layer popover re-promoted on every show (see + // setContent), so it is always above whatever opened before it. Tooltip.showFloating = function(clientX, clientY, spec) { var element = ensureElement(); - element.classList.add("tt-above-modals"); element.style.pointerEvents = "none"; // Hover-only -- never interactive like a pinned tooltip. pinned = false; pinnedBucketKey = null; @@ -616,7 +621,6 @@ var Tooltip = {}; pinnedId = hit.id; var element = ensureElement(); element.style.pointerEvents = "auto"; - element.classList.remove("tt-above-modals"); renderHit(clientX, clientY, hit); }; diff --git a/map/static/map/ui.css b/map/static/map/ui.css new file mode 100644 index 0000000..10f658e --- /dev/null +++ b/map/static/map/ui.css @@ -0,0 +1,657 @@ +/* =========================================================================== + UI primitives + --------------------------------------------------------------------------- + The shared vocabulary every feature builds from: buttons, fields, dialogs, + list rows, bars, toggles, chips. Before this file existed the app carried + four separate modal implementations, five progress bars, six list-row + shapes and thirteen copies of the same accent-button declaration, which is + why panels that were meant to match slowly stopped matching. + + Rules: + - Only tokens from map.css's :root. No raw hex, no off-scale sizes. + - Nothing here knows about the map, saves or any specific feature; the + feature files style *content*, not chrome. + - The JS side is ui.js -- same split. + ========================================================================= */ + +/* ---- Buttons ------------------------------------------------------------- + Four intents, one shape. .btn on its own is the quiet default (an outlined + control that recedes); the modifiers say "this is the action", "this + destroys something", "this is a bare glyph". + Sizes: .btn is the standard 30px control, .btn-lg the 38px full-width + commit button at the foot of a dialog, .btn-sm the dense 24px one that sits + inside a list row. */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-15); + box-sizing: border-box; + min-height: 30px; + padding: var(--sp-15) var(--sp-3); + font: inherit; + font-size: var(--fs-sm); + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + color: var(--muted); + background: none; + border: 1px solid var(--border); + border-radius: var(--r-sm); + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.btn:hover:not(:disabled) { + color: var(--text); + background: var(--raised); + border-color: var(--border-hover); +} + +.btn:disabled { + opacity: 0.45; + cursor: default; +} + +/* The action the panel exists for. */ +.btn-primary { + color: var(--accent-soft); + background: var(--accent-bg); + border-color: var(--accent-border); +} + +.btn-primary:hover:not(:disabled) { + color: var(--text-bright); + background: var(--accent-bg-hover); + border-color: var(--accent); +} + +/* Destructive and unrecoverable-looking, because deleting objects rewrites + the save. Undo exists, but the button should not read like "Copy". */ +.btn-danger { + color: var(--danger-soft); + background: var(--danger-bg); + border-color: var(--danger-border); +} + +.btn-danger:hover:not(:disabled) { + color: var(--text-bright); + background: var(--danger); + border-color: var(--danger); +} + +/* No chrome at all until hovered -- for glyphs that sit inside another + control's row (a close X, a per-row remove). */ +.btn-ghost { + color: var(--faint); + border-color: transparent; + background: none; +} + +.btn-ghost:hover:not(:disabled) { + color: var(--text-bright); + background: var(--raised-hover); + border-color: transparent; +} + +.btn-icon { + padding: 0; + width: 30px; + min-width: 30px; +} + +.btn-sm { + min-height: 24px; + padding: var(--sp-05) var(--sp-2); + font-size: var(--fs-xs); +} + +.btn-sm.btn-icon { + width: 24px; + min-width: 24px; +} + +.btn-lg { + min-height: 38px; + width: 100%; + padding: var(--sp-2) var(--sp-3); + font-size: var(--fs-md); + border-radius: var(--r-md); +} + +.btn-block { + width: 100%; +} + +.btn svg, +.btn img { + flex: none; + display: block; +} + +/* Segmented control: N mutually exclusive options sharing one track, so they + read as one either/or choice instead of N separate buttons. */ +.segmented { + display: flex; + gap: var(--sp-05); + padding: var(--sp-05); + background: var(--inset); + border: 1px solid var(--border); + border-radius: var(--r-md); +} + +.segmented > .btn { + flex: 1; + min-height: 26px; + border-color: transparent; + background: none; + font-weight: 500; +} + +.segmented > .btn:hover:not(:disabled) { + background: none; + border-color: transparent; + color: var(--text); +} + +.segmented > .btn.active { + color: var(--accent-soft); + background: var(--accent-bg); + font-weight: 600; +} + +/* ---- Fields ------------------------------------------------------------- */ +.field { + box-sizing: border-box; + min-height: 30px; + padding: var(--sp-15) var(--sp-2); + font: inherit; + font-size: var(--fs-sm); + color: var(--text); + background: var(--inset); + border: 1px solid var(--border); + border-radius: var(--r-sm); +} + +.field:hover { + border-color: var(--border-hover); +} + +.field:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(91, 163, 224, 0.18); +} + +.field:disabled { + opacity: 0.5; + cursor: default; +} + +.field::placeholder { + color: var(--faint); +} + +/* Label + field on one line, the field right-aligned in a fixed column so a + stack of them lines up regardless of label length. */ +.fieldRow { + display: flex; + align-items: center; + gap: var(--sp-2); + margin-bottom: var(--sp-2); +} + +.fieldRow > label { + flex: 1 1 auto; + min-width: 0; + color: var(--muted); + font-size: var(--fs-sm); +} + +.fieldRow > .field { + flex: none; + width: 104px; +} + +/* ---- Dialogs ------------------------------------------------------------- + Native + showModal(). The browser then owns the focus trap, focus + restore, Escape, inert background and -- the part that actually mattered + here -- top-layer stacking, so dialogs no longer need to pick a z-index and + compete with the tool panels. + + Structure:
+ The outer element is a transparent, padding-less box filling the viewport, + which is what makes "clicked the backdrop" detectable as a click whose + target is the itself (see ui.js). */ +.dlg { + padding: 0; + border: none; + background: none; + color: var(--text); + max-width: none; + max-height: none; + width: 100%; + height: 100%; + overflow: hidden; +} + +.dlg::backdrop { + /* No backdrop-filter, deliberately: blurring means every repaint inside the + dialog (row hover, scroll, the cursor-following tooltip) re-filters the + whole viewport-sized map underneath. Free on the GPU, but with hardware + acceleration off (user setting, RDP, blocklisted driver) that runs in + software every frame and drops the dialog to ~10 FPS. The dim alone + separates the layers well enough. */ + background: rgba(8, 10, 14, 0.6); +} + +.dlg-inner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.97); + opacity: 0; + animation: dlgIn 0.16s var(--panel-ease) forwards; + + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 640px; + max-width: 92vw; + max-height: 86vh; + padding: var(--sp-4) var(--sp-4) var(--sp-3); + background: var(--surface-2); + border: 1px solid var(--border-popup); + border-radius: var(--r-lg); + box-shadow: var(--popover-shadow); +} + +@keyframes dlgIn { + to { transform: translate(-50%, -50%) scale(1); opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .dlg-inner { + animation: none; + opacity: 1; + transform: translate(-50%, -50%); + } +} + +.dlg-sm .dlg-inner { width: 340px; } +.dlg-md .dlg-inner { width: 560px; } + +.dlg-head { + flex: none; + display: flex; + align-items: center; + gap: var(--sp-3); + padding-bottom: var(--sp-3); + margin-bottom: var(--sp-25); + border-bottom: 1px solid var(--border-popup); +} + +/* Framed game icon at the head of a dialog. */ +.dlg-icon { + flex: none; + width: 42px; + height: 42px; + object-fit: contain; + padding: var(--sp-1); + box-sizing: border-box; + background: var(--inset); + border: 1px solid var(--border-sub); + border-radius: var(--r-md); +} + +.dlg-headings { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--sp-1); +} + +.dlg-title { + flex: 1 1 auto; + min-width: 0; + font-size: var(--fs-2xl); + font-weight: 700; + color: var(--text-bright); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dlg-headings .dlg-title { + flex: none; +} + +.dlg-summary { + flex: none; + margin-bottom: var(--sp-2); + color: var(--muted); + font-size: var(--fs-lg); +} + +/* The one scrolling region. Everything else in a dialog is flex:none, so the + browser has exactly one place to take space from. */ +.dlg-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +.dlg-foot { + flex: none; + display: flex; + justify-content: flex-end; + gap: var(--sp-2); + margin-top: var(--sp-3); +} + +/* ---- Lists --------------------------------------------------------------- + One row shape for every list in the app: search suggestions, item + locations, progression entries, network points, depot contents. */ +.list { + border: 1px solid var(--border); + border-radius: var(--r-sm); + overflow-y: auto; +} + +.list:empty { + display: none; +} + +.row { + display: flex; + align-items: center; + gap: var(--sp-25); + padding: var(--sp-2) var(--sp-3); + font-size: var(--fs-lg); + border-bottom: 1px solid var(--border-sub); + /* Long lists (an expanded building group, the Depot's hundreds of item + types) shouldn't pay layout/paint for rows far offscreen. The + intrinsic-size hint keeps the scrollbar length stable while skipped. */ + content-visibility: auto; + contain-intrinsic-size: auto 40px; +} + +.row:last-child { + border-bottom: none; +} + +.row-hover:hover, +.row.is-hoverable:hover { + background: rgba(255, 255, 255, 0.05); +} + +.row-clickable { + cursor: pointer; + user-select: none; +} + +.row-icon { + flex: none; + width: 26px; + height: 26px; + object-fit: contain; +} + +.row-label { + flex: 1 1 auto; + min-width: 0; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-meta { + font-size: var(--fs-2xs); + color: var(--faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row-count { + flex: none; + color: var(--accent-soft); + font-weight: 600; +} + +/* Solid selection fill -- the keyboard/pointer-highlighted row in a picker. + Louder than a hover tint on purpose: this is the row Enter will commit to. */ +.row-active { + background: var(--select-bg); +} + +.row-active .row-label, +.row-active .row-count, +.row-active .row-meta { + color: var(--text-bright); +} + +/* ---- Chips, kickers, stat tiles ------------------------------------------ */ + +/* The small-caps label that names a block of controls. */ +.kicker { + font-size: var(--fs-2xs); + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--faint); + margin: var(--sp-1) 0 var(--sp-15); +} + +.chip { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: 1px var(--sp-2); + border-radius: var(--r-pill); + background: var(--raised); + border: 1px solid var(--border-popup); + color: var(--muted); + font-size: var(--fs-sm); + font-weight: 600; + white-space: nowrap; +} + +/* A single headline number with its label underneath. */ +.statTile { + flex: 1 1 0; + min-width: 0; + padding: var(--sp-25) var(--sp-3); + background: var(--raised); + border: 1px solid var(--border-popup); + border-radius: var(--r-md); +} + +.statTiles { + display: flex; + gap: var(--sp-25); +} + +.statValue { + display: block; + font-family: var(--font-mono); + font-size: var(--fs-2xl); + font-weight: 700; + color: var(--text-bright); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statLabel { + display: block; + margin-top: var(--sp-05); + font-size: var(--fs-xs); + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* ---- Bars ---------------------------------------------------------------- + One track/fill pair behind every progress readout: save loading, the busy + overlay, per-recipe mix, progression completion, Space Elevator deliveries. + Height is set by a modifier so a 3px section rule and a 6px loading bar are + still the same component. */ +.bar { + overflow: hidden; + height: 6px; + border-radius: var(--r-pill); + background: var(--inset); +} + +.bar-thin { height: 3px; } +.bar-tall { height: 8px; } + +.bar-fill { + width: 0; + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width 0.2s ease-out; +} + +.bar-fill.is-complete { background: var(--ok); } +.bar-fill.is-brand { background: linear-gradient(to right, #c96f22, var(--brand)); } + +@media (prefers-reduced-motion: reduce) { + .bar-fill { transition: none; } +} + +/* ---- Toggle switch ------------------------------------------------------- + Stands in for a native checkbox (see ui.js's UI.toggle): the real + is still there and still drives every .checked / + "change" code path, so this is pure styling. + --sw-w/--sw-h/--sw-knob let a caller resize one instance (the dock's + category rows use a bigger switch than a nested filter row) without + redeclaring the whole component. */ +.toggleSwitch { + --sw-w: 26px; + --sw-h: 15px; + --sw-knob: 11px; + position: relative; + display: inline-block; + flex: none; + width: var(--sw-w); + height: var(--sw-h); + cursor: pointer; +} + +.toggleSwitch-md { --sw-w: 34px; --sw-h: 19px; --sw-knob: 13px; } +.toggleSwitch-lg { --sw-w: 40px; --sw-h: 22px; --sw-knob: 16px; } + +.toggleSwitch input { + position: absolute; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.toggleSlider { + position: absolute; + inset: 0; + background: #4a4d54; + border-radius: var(--r-pill); + transition: background-color 0.15s ease; +} + +.toggleSlider::before { + content: ""; + position: absolute; + top: calc((var(--sw-h) - var(--sw-knob)) / 2); + left: calc((var(--sw-h) - var(--sw-knob)) / 2); + width: var(--sw-knob); + height: var(--sw-knob); + border-radius: 50%; + background: #ddd; + transition: transform 0.15s ease; +} + +.toggleSwitch input:checked + .toggleSlider { + background: var(--accent); +} + +.toggleSwitch input:checked + .toggleSlider::before { + transform: translateX(calc(var(--sw-w) - var(--sw-h))); + background: var(--text-bright); +} + +.toggleSwitch input:focus-visible + .toggleSlider { + box-shadow: 0 0 0 2px rgba(91, 163, 224, 0.6); +} + +/* Mixed state: a group whose children are partly hidden. Same accent family + as "on" but visibly dimmer, knob parked in the middle. */ +.toggleSwitch input:indeterminate + .toggleSlider { + background: var(--accent-border); +} + +.toggleSwitch input:indeterminate + .toggleSlider::before { + transform: translateX(calc((var(--sw-w) - var(--sw-h)) / 2)); + background: #c8d4e4; +} + +@media (prefers-reduced-motion: reduce) { + .toggleSlider, .toggleSlider::before { transition: none; } +} + +/* ---- Chevron ------------------------------------------------------------- + One glyph, rotated by state, everywhere a thing expands: dock nav rows, + dropdowns, disclosure toggles, expandable list groups. Replaces the five + separate implementations (three SVGs, two text glyphs) the app had. */ +.chev { + flex: none; + display: inline-flex; + color: var(--faint); + transition: transform 0.15s ease, color 0.15s ease; +} + +.chev svg { display: block; } + +.is-open > .chev, +.chev.is-open { transform: rotate(180deg); } + +.chev-right { transform: rotate(-90deg); } +.is-open > .chev-right, +.chev-right.is-open { transform: rotate(0deg); } + +@media (prefers-reduced-motion: reduce) { + .chev { transition: none; } +} + +/* ---- Utility ------------------------------------------------------------- */ +.u-mono { font-family: var(--font-mono); } +.u-tabular { font-variant-numeric: tabular-nums; } +.u-grow { flex: 1 1 auto; min-width: 0; } +.u-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Visible only to screen readers -- the accessible name for controls whose + visible content is a bare glyph. */ +.u-srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The dialog body takes initial focus (see ui.js's open) so the browser does + not ring the close button; it is a focus target, not an interactive one. */ +.dlg-inner:focus { + outline: none; +} diff --git a/map/static/map/ui.js b/map/static/map/ui.js new file mode 100644 index 0000000..f2913b8 --- /dev/null +++ b/map/static/map/ui.js @@ -0,0 +1,333 @@ +// UI -- the shared component layer every feature builds from. +// +// It exists because the app grew six list-row shapes, five progress bars, +// four modal implementations and three private copies of el(). Each was +// reasonable on its own and collectively they meant "make the panels match" +// was a manual, never-finished job. +// +// Two rules keep it honest: +// - Nothing here knows about maps, saves or buckets. It is chrome only; +// features supply content. +// - Anything visual it emits is styled by ui.css from map.css's tokens. +// +// Loaded before every feature script (see index.html). + +var UI = {}; + +(function() { + "use strict"; + + var SVG_NS = "http://www.w3.org/2000/svg"; + + // ---- Elements ------------------------------------------------------------- + + // The one el(). filters.js, finditem.js and tooltip.js each carried their + // own identical copy before this. + UI.el = function(tag, className, text) { + var e = document.createElement(tag); + if (className) { + e.className = className; + } + if (text !== undefined && text !== null) { + e.textContent = text; + } + return e; + }; + + // Inline SVG from a path list -- `d` may be one path string or several. + // Stroke-based so `currentColor` carries the theme through. + UI.svg = function(paths, size, opts) { + opts = opts || {}; + var svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", opts.viewBox || "0 0 24 24"); + svg.setAttribute("width", size || 16); + svg.setAttribute("height", size || 16); + svg.setAttribute("aria-hidden", "true"); + (Array.isArray(paths) ? paths : [paths]).forEach(function(d) { + var path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", d); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-width", opts.width || 2); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + }); + return svg; + }; + + UI.ICONS = { + chevronDown: "M6 9 12 15 18 9", + close: "M6 6 18 18M18 6 6 18", + crosshair: "M12 3v3m0 12v3M3 12h3m12 0h3M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z", + eye: "M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6-10-6-10-6Z M12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z", + eyeOff: "M4 4 20 20 M9.5 5.4A9.7 9.7 0 0 1 12 5c6.4 0 10 6 10 6a17 17 0 0 1-3.3 3.7M6.7 7.3A17 17 0 0 0 2 11s3.6 6 10 6a9.9 9.9 0 0 0 3.3-.55", + plus: "M12 5v14M5 12h14", + reset: "M3 12a9 9 0 1 1 2.6 6.4 M3 7v5h5", + trash: "M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13", + }; + + // ---- Buttons -------------------------------------------------------------- + + // UI.button("primary", "Paste", {icon, title, onClick, id, block}) + // `kind` is one of "", "primary", "danger", "ghost" -- the .btn-* modifier. + UI.button = function(kind, label, opts) { + opts = opts || {}; + var cls = "btn"; + if (kind) { + cls += " btn-" + kind; + } + if (opts.size) { + cls += " btn-" + opts.size; + } + if (opts.block) { + cls += " btn-block"; + } + if (!label) { + cls += " btn-icon"; + } + if (opts.className) { + cls += " " + opts.className; + } + var button = UI.el("button", cls); + button.type = opts.type || "button"; + if (opts.id) { + button.id = opts.id; + } + if (opts.icon) { + button.appendChild(UI.svg(opts.icon, opts.iconSize || 15)); + } + if (label) { + button.appendChild(UI.el("span", null, label)); + } + // An icon-only button has no text for a screen reader to read; title alone + // is an unreliable accessible name, so state it outright. + if (opts.title) { + button.title = opts.title; + if (!label) { + button.setAttribute("aria-label", opts.title); + } + } + if (opts.onClick) { + button.addEventListener("click", opts.onClick); + } + return button; + }; + + UI.closeButton = function(onClick, label) { + return UI.button("ghost", null, { + icon: UI.ICONS.close, + iconSize: 16, + title: label || "Close", + className: "dlg-close", + onClick: onClick, + }); + }; + + UI.chevron = function(size, className) { + var wrap = UI.el("span", "chev" + (className ? " " + className : "")); + wrap.appendChild(UI.svg(UI.ICONS.chevronDown, size || 14)); + return wrap; + }; + + // ---- Toggle switch -------------------------------------------------------- + + // A real inside a