From a63939c54aa3eb3812bb6db5f8ed6dbf90cda8a9 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 2 Aug 2026 02:44:07 +0200 Subject: [PATCH 1/3] tools: browser-driven UI screenshot and behaviour harnesses The map's chrome has no unit tests, so "did that CSS change break the building dialog at 1024px" was only answerable by looking, which meant in practice it went unanswered. ui_shots.py captures 17 UI states (empty, loaded, each dialog, each tool panel, three narrow viewports) and pixel-diffs them against a previous run, so a refactor can be checked state by state: py tools/ui_shots.py --serve --out ui_shots/before ...change things, rebuild dist... py tools/ui_shots.py --serve --out ui_shots/after --baseline ui_shots/before ui_behaviour.py covers what a screenshot cannot: that dialogs are real modals, that the hover tooltip still paints above one, that Escape peels exactly one layer, that opening a dock never resizes the map. Both run against dist/ and take --serve and --headed. Their output is gitignored; the baselines are local, since committing PNGs of the whole UI would bloat the repo for little gain. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 +- tools/ui_behaviour.py | 309 +++++++++++++++++++++++++++++++++++++++ tools/ui_shots.py | 329 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 tools/ui_behaviour.py create mode 100644 tools/ui_shots.py 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/tools/ui_behaviour.py b/tools/ui_behaviour.py new file mode 100644 index 0000000..94f464f --- /dev/null +++ b/tools/ui_behaviour.py @@ -0,0 +1,309 @@ +"""Behavioural checks for the app shell -- the things a screenshot cannot see. + +tools/ui_shots.py catches "does it still LOOK right". This catches "does it +still WORK right": that dialogs are real modals, that the hover tooltip can +still paint above one, that Escape peels exactly one layer, that opening a +category does not resize the map, that only one tool can occupy the right dock. + + py tools/ui_behaviour.py --serve + +Requires `pip install playwright` (uses system Chrome, no browser download) +and a save in map/uploads/. Exit code is non-zero if any check fails. + +Runs against dist/, so build (or copy the changed files into dist/) first. +""" + +import argparse +import glob +import os +import subprocess +import sys +import time + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_PORT = 8791 + +fails = [] + + +def check(name, ok, detail=""): + print((" PASS " if ok else " FAIL ") + name + ((" -- " + str(detail)) if detail else "")) + if not ok: + fails.append(name) + + +def pickSave(explicit): + """Prefer a real factory save: several checks need populated inventories + (the item dialog's location list) and a full category tree.""" + if explicit: + return explicit + uploads = os.path.join(REPO, "map", "uploads") + for pattern in ("solo_*.sav", "*.sav"): + found = sorted(glob.glob(os.path.join(uploads, pattern))) + if found: + return found[0] + sys.exit("No save in map/uploads/ -- pass --save (see tools/fetch_test_saves.py).") + + +def run(page, save): + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.on("console", lambda m: errors.append(m.text) + if m.type == "error" and "404" not in m.text + and "Failed to load resource" not in m.text else None) + + page.goto("http://localhost:%d/index.html" % PORT) + page.wait_for_timeout(1500) + + # ---- Empty state --------------------------------------------------------- + check("no JS errors on load", not errors, errors[:3]) + check("empty state shown", page.is_visible("#dockEmptyState")) + check("nav header hidden while empty", not page.is_visible("#categoryNavHeader")) + # The old layout reserved the altitude rail's width unconditionally, which + # showed as a dead black strip beside the map before any save was loaded. + check("tool dock takes no width when empty", page.evaluate( + "Math.round(document.getElementById('toolDock').getBoundingClientRect().width)") == 0) + + print("loading %s ..." % os.path.basename(save)) + page.set_input_files("#uploadFileInput", save) + page.wait_for_function( + "window.MapApp && MapApp.layer && MapApp.layer.buckets && MapApp.layer.buckets.length > 0", + timeout=300000) + page.wait_for_timeout(3000) + + check("empty state hidden after load", not page.is_visible("#dockEmptyState")) + check("nav header shown after load", page.is_visible("#categoryNavHeader")) + check("altitude rail docked", page.is_visible("#altitudePanel")) + + # ---- Push navigation ----------------------------------------------------- + # The whole point of push-navigation over a second column: browsing + # categories must not take space away from the map. + widthBefore = page.evaluate("document.getElementById('map').getBoundingClientRect().width") + for row in page.query_selector_all(".categoryNavRow"): + if "Production" in row.inner_text(): + row.click() + break + page.wait_for_timeout(600) + widthAfter = page.evaluate("document.getElementById('map').getBoundingClientRect().width") + check("opening a category does not resize the map", widthBefore == widthAfter, + "%s -> %s" % (widthBefore, widthAfter)) + check("detail pane is titled", page.inner_text("#detailTitle").strip() != "") + check("off-screen pane is inert", + page.evaluate("document.getElementById('dockPaneNav').inert") is True) + + page.keyboard.press("Escape") + page.wait_for_timeout(500) + check("Escape returns to the category list", + not page.evaluate("document.body.classList.contains('category-open')")) + + # ---- Dialogs ------------------------------------------------------------- + page.click("#mainSearchInput") + page.fill("#mainSearchInput", "iron ore") + page.wait_for_timeout(900) + for row in page.query_selector_all(".searchSuggestionRow"): + if "iron ore" in row.inner_text().lower(): + row.click() + break + page.wait_for_timeout(2500) + check("item dialog is a native modal dialog", + page.evaluate("document.getElementById('itemModal').open") is True) + check("page is inert behind the dialog", + page.evaluate("document.body.matches(':has(dialog[open])')")) + + # The riskiest change in the refactor: showModal() puts the dialog in the + # top layer, where no z-index can reach it, so the hover tooltip had to + # become a top-layer popover to keep working over a dialog's location list. + # Group HEADERS carry no tooltip (they stand for many machines), so expand + # one and hover an individual location. + header = page.query_selector("#itemModalList .itemLocationGroupHeader") + if header: + header.click() + page.wait_for_timeout(600) + rows = page.query_selector_all("#itemModalList .itemLocationChildRow") + if rows: + rows[0].hover() + page.wait_for_timeout(600) + page.mouse.move(0, 0) # Force a fresh mouseenter. + rows[0].hover() + page.wait_for_timeout(900) + shown = page.evaluate("""() => { + const t = document.getElementById('tt-tooltip'); + if (!t) return {exists: false}; + const r = t.getBoundingClientRect(); + return {exists: true, open: t.matches(':popover-open'), h: Math.round(r.height)}; + }""") + check("tooltip paints above an open dialog", + bool(shown.get("open")) and shown.get("h", 0) > 20, shown) + else: + print(" (no expandable locations in this save -- tooltip check skipped)") + + page.keyboard.press("Escape") + page.wait_for_timeout(500) + check("Escape closes the dialog", + page.evaluate("document.getElementById('itemModal').open") is False) + + # ---- Escape layering ----------------------------------------------------- + page.click("#statusMenuButton") + page.wait_for_timeout(300) + check("status menu opens", page.is_visible("#statusMenu")) + page.keyboard.press("Escape") + page.wait_for_timeout(300) + check("Escape closes the status menu", not page.is_visible("#statusMenu")) + + # ---- Tool dock ----------------------------------------------------------- + page.evaluate("window.NetworkTool.open()") + page.wait_for_timeout(700) + check("network tool opens inside the dock", + page.evaluate("document.getElementById('networkPanel').parentElement.id") == "toolPanels") + mapWidth = page.evaluate("document.getElementById('map').getBoundingClientRect().width") + check("opening a tool does not resize the map", mapWidth == widthAfter, + "%s == %s" % (mapWidth, widthAfter)) + + # The floating versions of these two shared an anchor and could land on the + # same pixels; the dock holds exactly one. + page.evaluate("window.Panels.openTool(document.getElementById('pastePanel'))") + page.wait_for_timeout(400) + check("opening a second tool hides the first", + page.evaluate("getComputedStyle(document.getElementById('networkPanel')).display") == "none") + page.evaluate("window.Panels.closeTool()") + page.wait_for_timeout(400) + check("closing the tool collapses the dock", + page.evaluate("document.getElementById('toolPanels').getBoundingClientRect().width") == 0) + + # ---- The map's box never changes ----------------------------------------- + # + # This is the whole reason the docks overlay the map instead of taking grid + # columns. If the map's box is constant, Leaflet is never asked to re-fit + # it, so it can never re-centre and slide the world sideways -- the ~141px + # lurch that two rounds of compensation code failed to fix (see + # docs/dock-map-anchoring.md). Assert the cause, not the symptom: box + # unchanged AND a pinned world point unmoved, sampled every frame. + page.evaluate("window.__probeLatLng = MapApp.map.getCenter()") + SWEEP = """(frames) => { + const out = []; let n = 0; + const mapEl = document.getElementById('map'); + window.__sweep = new Promise(resolve => { + function tick() { + const r = mapEl.getBoundingClientRect(); + const p = MapApp.map.latLngToContainerPoint(window.__probeLatLng); + out.push({ box: [Math.round(r.left), Math.round(r.top), + Math.round(r.width), Math.round(r.height)], + probeX: Math.round(r.left + p.x), + probeY: Math.round(r.top + p.y) }); + if (++n < frames) requestAnimationFrame(tick); else resolve(out); + } + requestAnimationFrame(tick); + }); + return null; // Do not hand the promise back, or the call awaits it. + }""" + FRAMES = 40 + + def holdsStill(label, action): + page.evaluate(SWEEP, FRAMES) + page.wait_for_timeout(60) + action() + page.wait_for_timeout(FRAMES * 20 + 400) + rows = page.evaluate("window.__sweep") + ref = rows[0] + boxChanged = [r for r in rows if r["box"] != ref["box"]] + moved = [r for r in rows + if abs(r["probeX"] - ref["probeX"]) > 1 or abs(r["probeY"] - ref["probeY"]) > 1] + detail = "" + if boxChanged: + detail = "map box changed %s -> %s" % (ref["box"], boxChanged[0]["box"]) + elif moved: + worst = max(moved, key=lambda r: abs(r["probeX"] - ref["probeX"])) + detail = "%d/%d frames moved, worst %+dpx" % ( + len(moved), len(rows), worst["probeX"] - ref["probeX"]) + check("map never moves: " + label, not boxChanged and not moved, detail) + + holdsStill("hiding the layers dock", + lambda: page.evaluate("document.getElementById('menuButton').click()")) + holdsStill("showing the layers dock", + lambda: page.evaluate("document.getElementById('menuButton').click()")) + holdsStill("opening a tool dock", lambda: page.evaluate("NetworkTool.open()")) + holdsStill("closing a tool dock", lambda: page.evaluate("NetworkTool.close()")) + holdsStill("opening a category", + lambda: page.evaluate("document.querySelectorAll('.categoryNavRow')[4].click()")) + page.keyboard.press("Escape") + page.wait_for_timeout(400) + + # Dragging the dock's edge is the densest version of the same thing: many + # width changes in a row, none of which may reach the map. + handle = page.query_selector("#dockResizeHandle") + if handle: + box = handle.bounding_box() + page.evaluate(SWEEP, FRAMES) + page.wait_for_timeout(60) + page.mouse.move(box["x"] + 3, box["y"] + 200) + page.mouse.down() + for step in range(10): + page.mouse.move(box["x"] + 3 + step * 12, box["y"] + 200) + page.wait_for_timeout(30) + page.mouse.up() + page.wait_for_timeout(FRAMES * 20 + 400) + rows = page.evaluate("window.__sweep") + ref = rows[0] + bad = [r for r in rows if r["box"] != ref["box"] + or abs(r["probeX"] - ref["probeX"]) > 1] + check("map never moves: dragging the dock's width", not bad, + "%d/%d frames" % (len(bad), len(rows))) + + # ---- Search -------------------------------------------------------------- + page.click("#mainSearchInput") + page.fill("#mainSearchInput", "constructor") + page.wait_for_timeout(800) + check("combobox announces its listbox", + page.get_attribute("#mainSearchInput", "aria-expanded") == "true") + rows = page.query_selector_all(".searchSuggestionRow") + if rows: + rows[0].click() + page.wait_for_timeout(1500) + check("search field clears once a result is committed", + page.input_value("#mainSearchInput") == "") + page.keyboard.press("Escape") + page.wait_for_timeout(400) + + check("no JS errors overall", not errors, errors[:5]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--save", help="the .sav to load") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--serve", action="store_true", + help="start tools/serve_site.py for the run") + parser.add_argument("--headed", action="store_true") + args = parser.parse_args() + + global PORT + PORT = args.port + save = pickSave(args.save) + + server = None + if args.serve: + server = subprocess.Popen([sys.executable, os.path.join(REPO, "tools", "serve_site.py"), + str(PORT)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(2) + + try: + from playwright.sync_api import sync_playwright + with sync_playwright() as p: + browser = p.chromium.launch(channel="chrome", headless=not args.headed) + page = browser.new_page(viewport={"width": 1600, "height": 900}) + run(page, save) + browser.close() + finally: + if server: + server.terminate() + + print("\n%d check(s) failed" % len(fails)) + for name in fails: + print(" " + name) + sys.exit(1 if fails else 0) + + +if __name__ == "__main__": + main() diff --git a/tools/ui_shots.py b/tools/ui_shots.py new file mode 100644 index 0000000..9eb3cf6 --- /dev/null +++ b/tools/ui_shots.py @@ -0,0 +1,329 @@ +"""Capture (and optionally diff) screenshots of every major UI state. + +This is the guard rail for frontend refactors: the map's chrome has no unit +tests, so "did that CSS change break the building modal at 1024px" is only +answerable by looking. Run it once before a change to record baselines, once +after to compare. + + py tools/ui_shots.py --serve --out ui_shots/before + ...make changes, rebuild dist... + py tools/ui_shots.py --serve --out ui_shots/after --baseline ui_shots/before + +Exit code is non-zero if any state differs from its baseline by more than +--tolerance percent of pixels, so it can also be wired into CI. + +Requires `pip install playwright` (uses system Chrome, no browser download) +and, for --baseline, `pip install pillow`. + +The states are captured against dist/, so run tools/build_site.py (or copy the +changed files into dist/) first. --serve starts tools/serve_site.py itself and +shuts it down at the end. +""" + +import argparse +import glob +import os +import subprocess +import sys +import time + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_PORT = 8791 + +# The state list. Each entry is (name, callable(page)); the callable leaves the +# page in the state to be shot. They run in order and share one page, so a +# state may rely on the previous one having been dismissed. +DESKTOP = {"width": 1600, "height": 900} + + +def pickSave(explicit): + """The save every state is captured against. + + All_*.sav holds one of every buildable, which is what makes the sidebar + render every category -- a small factory save would leave half the UI + unexercised. + """ + if explicit: + return explicit + candidates = sorted(glob.glob(os.path.join(REPO, "map", "uploads", "All_*.sav"))) + if not candidates: + sys.exit("No map/uploads/All_*.sav found -- pass --save explicitly " + "(see tools/fetch_test_saves.py).") + return candidates[-1] + + +def waitForParse(page): + page.wait_for_function( + "window.MapApp && MapApp.layer && MapApp.layer.buckets " + "&& MapApp.layer.buckets.length > 0", + timeout=300000) + page.wait_for_timeout(2500) # Tiles + the first canvas draw settle after the buckets land. + + +def clickSuggestion(page, query, contains=None): + """Type into the search bar and click the first (or first matching) row.""" + page.click("#mainSearchInput") + page.fill("#mainSearchInput", query) + page.wait_for_timeout(700) + rows = page.query_selector_all(".searchSuggestionRow") + for row in rows: + if contains is None or contains.lower() in row.inner_text().lower(): + row.click() + return True + return False + + +def dismiss(page): + """Back to a bare loaded map: close whatever is open, clear the search.""" + for _ in range(3): + page.keyboard.press("Escape") + page.wait_for_timeout(150) + page.fill("#mainSearchInput", "") + page.wait_for_timeout(300) + + +# The editor/busy states below are forced open rather than driven, so they also +# have to be forced shut -- a page.reload() would drop the parsed save (it only +# ever lives in the tab's memory) and cost another full parse. +FORCED_PANELS = ["selectionPanel", "editorToolbar", "editorHint", "pastePanel"] + + +def hideForced(page): + page.evaluate("""(ids) => { + if (window.Panels) window.Panels.closeTool(); + ids.forEach(id => { + const e = document.getElementById(id); + if (e) e.style.display = 'none'; + }); + document.querySelectorAll('dialog[open]').forEach(d => d.close()); + }""", FORCED_PANELS) + page.wait_for_timeout(300) + + +def capture(page, save, outDir, only=None): + shots = [] + + def shot(name): + if only and name not in only: + return + path = os.path.join(outDir, name + ".png") + page.screenshot(path=path) + shots.append(name) + print(" " + name) + + page.set_viewport_size(DESKTOP) + page.goto("http://localhost:%d/index.html" % PORT) + page.wait_for_timeout(1500) + shot("01-empty") + + page.set_input_files("#uploadFileInput", save) + waitForParse(page) + shot("02-loaded") + + # Sidebar with a category open (the master/detail state). + for row in page.query_selector_all(".categoryNavRow"): + if "Production" in row.inner_text(): + row.click() + break + page.wait_for_timeout(800) + shot("03-category-open") + + # Search suggestions dropdown. + page.click("#mainSearchInput") + page.fill("#mainSearchInput", "iron") + page.wait_for_timeout(700) + shot("04-search-suggestions") + dismiss(page) + + # Item modal (grouped location list). + if clickSuggestion(page, "iron ingot", "iron ingot"): + page.wait_for_timeout(1800) + shot("05-item-modal") + dismiss(page) + + # Building modal (stat tiles + recipe bars). + if clickSuggestion(page, "constructor", "constructor"): + page.wait_for_timeout(1800) + shot("06-building-modal") + dismiss(page) + + # Depot contents (same dialog, different filler). + page.click("#depotIconButton") + page.wait_for_timeout(1500) + shot("07-depot-modal") + dismiss(page) + + # Progression: the dropdown, then one of its views. + page.click("#statusMenuButton") + page.wait_for_timeout(300) + shot("08-status-menu") + page.click("#hubIconButton") + page.wait_for_timeout(1500) + shot("09-progression-modal") + dismiss(page) + + # Network finder (search-bar-only tool panel). + if clickSuggestion(page, "optimal network", "network"): + page.wait_for_timeout(900) + shot("10-network-panel") + page.evaluate("window.NetworkTool && NetworkTool.close && NetworkTool.close()") + dismiss(page) + + # Editor surfaces. Driving a real rectangle selection needs objects under a + # known screen rect, which is brittle across zoom changes -- these panels + # are pure chrome, so they are shown directly with representative content. + page.evaluate("""() => { + const set = (id, text) => { const e = document.getElementById(id); if (e && text) e.textContent = text; }; + const show = (id, how) => { const e = document.getElementById(id); if (e) e.style.display = how; }; + show('selectionPanel', 'flex'); + set('selectionCount', '1,284 objects selected'); + show('editorToolbar', 'flex'); + set('editorEditCount', '3 pending edits'); + show('editorHint', 'block'); + // Tool panels live in the right dock -- go through the same API the + // editor does, or the dock stays collapsed and nothing shows. + window.Panels.openTool(document.getElementById('pastePanel')); + set('pastePanelTitle', 'Paste 1,284 objects'); + }""") + page.wait_for_timeout(500) + shot("11-editor-paste") + page.evaluate("""() => { + window.Panels.closeTool(); + const hint = document.getElementById('editorHint'); + if (hint) hint.style.display = 'none'; + const dlg = document.getElementById('offsetDialog'); + if (dlg && !dlg.open) { + dlg.showModal(); + // Match what openOffsetDialog does, or the shot shows a focus ring on + // the close button that never appears in real use. + document.getElementById('offsetDx').focus(); + } + }""") + page.wait_for_timeout(400) + shot("12-offset-dialog") + hideForced(page) + + # Busy overlay (shown during save edits). + page.evaluate("""() => { + const dlg = document.getElementById('busyDialog'); + if (!dlg) return; + if (!dlg.open) dlg.showModal(); + document.getElementById('busyLabel').textContent = 'Pasting 1,284 objects…'; + document.getElementById('busyPhase').textContent = 'Rewriting level data'; + document.getElementById('busyFill').style.width = '62%'; + }""") + page.wait_for_timeout(400) + shot("13-busy-overlay") + hideForced(page) + + # Narrow viewports: the states most likely to break when the layout changes. + for name, size in (("14-narrow-1280", {"width": 1280, "height": 800}), + ("15-narrow-1024", {"width": 1024, "height": 700}), + ("16-small-800", {"width": 800, "height": 600})): + page.set_viewport_size(size) + page.wait_for_timeout(900) + shot(name) + page.set_viewport_size(DESKTOP) + page.wait_for_timeout(600) + + # Sidebar hidden (the map with no chrome but the top bar). + page.click("#menuButton") + page.wait_for_timeout(700) + shot("17-sidebar-hidden") + page.click("#menuButton") + page.wait_for_timeout(500) + + return shots + + +def compare(outDir, baselineDir, tolerance): + try: + from PIL import Image, ImageChops + except ImportError: + sys.exit("--baseline needs Pillow: pip install pillow") + + failures = [] + diffDir = os.path.join(outDir, "diff") + for path in sorted(glob.glob(os.path.join(outDir, "*.png"))): + name = os.path.basename(path) + basePath = os.path.join(baselineDir, name) + if not os.path.exists(basePath): + print(" NEW %s (no baseline)" % name) + continue + after = Image.open(path).convert("RGB") + before = Image.open(basePath).convert("RGB") + if after.size != before.size: + failures.append((name, "size %s -> %s" % (before.size, after.size))) + print(" RESIZED %s %s -> %s" % (name, before.size, after.size)) + continue + diff = ImageChops.difference(after, before) + changed = sum(1 for px in diff.getdata() if px != (0, 0, 0)) + pct = 100.0 * changed / (after.size[0] * after.size[1]) + if pct > tolerance: + failures.append((name, "%.2f%% of pixels" % pct)) + os.makedirs(diffDir, exist_ok=True) + # Amplify so a subtle shift is actually visible in the diff image. + ImageChops.multiply(diff, diff).save(os.path.join(diffDir, name)) + print(" CHANGED %-24s %.2f%% of pixels" % (name, pct)) + else: + print(" same %-24s %.2f%%" % (name, pct)) + return failures + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--out", default=os.path.join(REPO, "ui_shots", "current"), + help="directory to write PNGs into") + parser.add_argument("--baseline", help="directory to compare against") + parser.add_argument("--save", help="the .sav to load (default: newest map/uploads/All_*.sav)") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--serve", action="store_true", + help="start tools/serve_site.py for the run") + parser.add_argument("--headed", action="store_true") + parser.add_argument("--only", nargs="*", help="capture only these state names") + parser.add_argument("--tolerance", type=float, default=0.05, + help="percent of differing pixels tolerated (default 0.05)") + args = parser.parse_args() + + global PORT + PORT = args.port + save = pickSave(args.save) + outDir = os.path.abspath(args.out) + os.makedirs(outDir, exist_ok=True) + + server = None + if args.serve: + server = subprocess.Popen([sys.executable, os.path.join(REPO, "tools", "serve_site.py"), + str(PORT)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(2) + + try: + from playwright.sync_api import sync_playwright + print("save: %s" % os.path.basename(save)) + print("out: %s" % outDir) + with sync_playwright() as p: + browser = p.chromium.launch(channel="chrome", headless=not args.headed) + page = browser.new_page(viewport=DESKTOP) + shots = capture(page, save, outDir, set(args.only) if args.only else None) + browser.close() + print("%d states captured" % len(shots)) + finally: + if server: + server.terminate() + + if args.baseline: + print("\ncompared against %s" % os.path.abspath(args.baseline)) + failures = compare(outDir, os.path.abspath(args.baseline), args.tolerance) + if failures: + print("\n%d state(s) changed beyond %.2f%%:" % (len(failures), args.tolerance)) + for name, why in failures: + print(" %s -- %s" % (name, why)) + print("diff images in %s" % os.path.join(outDir, "diff")) + sys.exit(1) + print("\nno state changed beyond %.2f%%" % args.tolerance) + + +if __name__ == "__main__": + main() From 9000485f441786fd1f22acaaa08e67b75d15c344 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 2 Aug 2026 02:44:28 +0200 Subject: [PATCH 2/3] frontend: docked app shell, shared UI primitives, native dialogs The chrome had grown one implementation per feature: four separate modal dialogs, five progress bars, six list-row shapes, thirteen copies of the same accent-button declaration, three private copies of el(). Each was reasonable alone; together they meant "make the panels match" was a manual job that was never finished. Everything also floated over the map as free-standing cards, so a working session looked like a scatter of unrelated windows. Design tokens (map.css :root) The whole colour/type/spacing/radius vocabulary in one block. 19 distinct font sizes -> 8 steps, 11 radii -> 5, 25 !important -> 0, and ~180 raw hex literals down to 17 genuine one-offs. Primitives (ui.css + ui.js) .btn/.field/.dlg/.row/.bar/.toggleSwitch/.chev and UI.el/UI.dialog/ UI.onEscape. ui.css loads BEFORE map.css so a feature rule of equal specificity can override a primitive without !important. Native The four modals use showModal(), which hands the browser the focus trap, focus restore, Escape, background inertness and top-layer stacking -- the last of which removed the hand-maintained z-index ladder entirely. Escape is no longer six document handlers coordinated by e.defaultPrevented with priority set by + + 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