diff --git a/README.md b/README.md index 35db2f3..55b0e6d 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ when an app only checks the button now and then. Only for the browser page itself: - `v`: switch between the flat panel and the device mockup. +- Auto / Light / Dark in the toolbar: follow the OS theme, or pin one. + Remembered per browser, and `?theme=light` or `?theme=dark` in the + URL works too. - `t`: toggle a guide line at each panel seam (see "Panel sizes"). Off by default. - Drag the device to rotate it, double-click to reset the angle. @@ -239,7 +242,9 @@ matrixbox screenshot /path/to/matrixbox/apps/clock --style device -o clock.png browser, so they need [Playwright][playwright] (`pip install 'matrixbox-simulator[screenshot]'`). An installed Chrome is used if there is one, otherwise run `playwright install chromium` once. -For these, `--scale` is the size of one LED in pixels. +For these, `--scale` is the size of one LED in pixels, and +`--theme {dark,light}` picks the backdrop and cable color behind the +device (default dark). ## Sandbox diff --git a/asset/sim-image-web.png b/asset/sim-image-web.png index 50d7f6f..fbd2930 100644 Binary files a/asset/sim-image-web.png and b/asset/sim-image-web.png differ diff --git a/matrixbox_simulator/device/run_screenshot.py b/matrixbox_simulator/device/run_screenshot.py index ee2a274..e18e9ac 100644 --- a/matrixbox_simulator/device/run_screenshot.py +++ b/matrixbox_simulator/device/run_screenshot.py @@ -28,6 +28,7 @@ from matrixbox_simulator.sizes import ROTATION_OVERRIDES, SIZE_PRESETS SCREENSHOT_STYLES = ("pixels", "panel", "device") +SCREENSHOT_THEMES = ("dark", "light") def build_parser( @@ -98,6 +99,13 @@ def build_parser( "LEDs in a dark housing, like the web renderer. device: the web " "renderer's 3D device mockup. panel and device need Playwright", ) + parser.add_argument( + "--theme", + choices=SCREENSHOT_THEMES, + default="dark", + help="page theme for --style device, the backdrop and cable color " + "(default dark)", + ) parser.add_argument( "-o", "--output", @@ -237,7 +245,14 @@ def _write_screenshot( def _write_web_screenshot( - path: Path, width: int, height: int, rgb: bytes, *, style: str, scale: int + path: Path, + width: int, + height: int, + rgb: bytes, + *, + style: str, + scale: int, + theme: str, ) -> None: # A separate, unsandboxed process: this one has open()/stat() and # friends redirected into the app's sandbox, which a browser driver @@ -257,6 +272,8 @@ def _write_web_screenshot( str(height), "--led-size", str(scale), + "--theme", + theme, "--output", str(path), ], @@ -403,7 +420,13 @@ def run_app_code() -> None: _write_screenshot(output, width, height, rgb, scale=args.scale) else: _write_web_screenshot( - output, width, height, rgb, style=args.style, scale=args.scale + output, + width, + height, + rgb, + style=args.style, + scale=args.scale, + theme=args.theme, ) print( diff --git a/matrixbox_simulator/web/capture.py b/matrixbox_simulator/web/capture.py index eec5494..929cbf1 100644 --- a/matrixbox_simulator/web/capture.py +++ b/matrixbox_simulator/web/capture.py @@ -25,6 +25,7 @@ sync_playwright = None STYLES = ("panel", "device") +THEMES = ("dark", "light") READY_TIMEOUT_MS = 15000 # Room around the panel or device; the page crops to it anyway, this @@ -33,10 +34,13 @@ class WebCapture: - def __init__(self, frame: wire.Frame, style: str, led_size: int) -> None: + def __init__( + self, frame: wire.Frame, style: str, led_size: int, theme: str = "dark" + ) -> None: self.frame = frame self.style = style self.led_size = led_size + self.theme = theme def save(self, output: Path) -> None: server = WebServer("127.0.0.1", 0) @@ -81,9 +85,13 @@ def _screenshot(self, base_url: str, output: Path) -> None: try: width, height = self.viewport_size() page = browser.new_page( - viewport={"width": width, "height": height}, color_scheme="dark" + viewport={"width": width, "height": height}, + color_scheme="light" if self.theme == "light" else "dark", + ) + page.goto( + f"{base_url}?view={self.style}&theme={self.theme}" + f"&capture=1&led={self.led_size}" ) - page.goto(f"{base_url}?view={self.style}&capture=1&led={self.led_size}") page.wait_for_selector("body[data-ready]", timeout=READY_TIMEOUT_MS) clip = page.evaluate("window.matrixboxCaptureBounds()") output.parent.mkdir(parents=True, exist_ok=True) @@ -102,6 +110,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--width", type=int, required=True) parser.add_argument("--height", type=int, required=True) parser.add_argument("--led-size", type=int, default=8) + parser.add_argument("--theme", choices=THEMES, default="dark") parser.add_argument("-o", "--output", required=True) return parser @@ -121,7 +130,7 @@ def main() -> None: pixels=rgb, tiles=panel_count_for(args.width, args.height), ) - WebCapture(frame, args.style, args.led_size).save(Path(args.output)) + WebCapture(frame, args.style, args.led_size, args.theme).save(Path(args.output)) if __name__ == "__main__": diff --git a/matrixbox_simulator/web/static/app.js b/matrixbox_simulator/web/static/app.js index f800ddc..10dca18 100644 --- a/matrixbox_simulator/web/static/app.js +++ b/matrixbox_simulator/web/static/app.js @@ -701,18 +701,18 @@ class SimulatorPage { this.deviceScreen = document.getElementById("device-screen"); this.connection = document.getElementById("connection"); this.stats = document.getElementById("stats"); - this.seamsToggle = document.getElementById("seams"); this.viewButtons = document.querySelectorAll("[data-view]"); + this.themeButtons = document.querySelectorAll("[data-theme-choice]"); this.drawPending = false; this.lastFrameSize = ""; const params = new URLSearchParams(window.location.search); this.view = params.get("view") ?? this.preferences.read("view", "panel"); + this.setTheme(params.get("theme") ?? this.preferences.read("theme", "auto"), { remember: false }); // Screenshot mode: just the panel or device at a fixed LED size, no // page chrome, flagging when it's safe to capture. this.capture = params.get("capture") === "1" ? { ledCssSize: Number(params.get("led")) || 8 } : null; this.renderer.showSeams = this.preferences.read("seams", "off") === "on"; - this.seamsToggle.checked = this.renderer.showSeams; this.renderAppControls(); this.bindControls(); this.setView(this.view); @@ -785,7 +785,10 @@ class SimulatorPage { button.addEventListener("click", () => this.setView(button.dataset.view)); } - this.seamsToggle.addEventListener("change", () => this.setSeams(this.seamsToggle.checked)); + for (const button of this.themeButtons) { + button.addEventListener("click", () => this.setTheme(button.dataset.themeChoice)); + } + window.addEventListener("resize", () => this.relayout()); window.addEventListener("keydown", (event) => { if (event.metaKey || event.ctrlKey || event.altKey) { @@ -822,9 +825,26 @@ class SimulatorPage { } } + // "auto" follows the OS; light or dark pins it regardless. + setTheme(theme, { remember = true } = {}) { + const choice = ["light", "dark"].includes(theme) ? theme : "auto"; + if (choice === "auto") { + delete document.documentElement.dataset.theme; + } else { + document.documentElement.dataset.theme = choice; + } + + for (const button of this.themeButtons) { + button.setAttribute("aria-pressed", String(button.dataset.themeChoice === choice)); + } + + if (remember) { + this.preferences.write("theme", choice); + } + } + setSeams(enabled) { this.renderer.showSeams = enabled; - this.seamsToggle.checked = enabled; this.preferences.write("seams", enabled ? "on" : "off"); this.scheduleDraw(); } diff --git a/matrixbox_simulator/web/static/index.html b/matrixbox_simulator/web/static/index.html index 4834a14..fe3f81c 100644 --- a/matrixbox_simulator/web/static/index.html +++ b/matrixbox_simulator/web/static/index.html @@ -17,10 +17,11 @@ - +
+ + + +
diff --git a/matrixbox_simulator/web/static/style.css b/matrixbox_simulator/web/static/style.css index f4eed17..7ca23d6 100644 --- a/matrixbox_simulator/web/static/style.css +++ b/matrixbox_simulator/web/static/style.css @@ -10,12 +10,21 @@ --live: #2f9e5b; --offline: #b54848; --floor-shadow: rgba(0, 0, 0, 0.45); + --cable-shade: #b3b3ba; + --cable-body: #e2e2e7; + --cable-highlight: #ffffff; + --plug-end: #e9e9ed; + --plug-upper: linear-gradient(90deg, #dcdce1, #ffffff); + --plug-side: linear-gradient(90deg, #c9c9cf, #f4f4f6); + --plug-lower: linear-gradient(90deg, #9d9da5, #c2c2c8); --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; --sans: system-ui, -apple-system, "Segoe UI", sans-serif; } +/* Dark follows the OS unless the page's own theme toggle picked one; + the same tokens again for an explicit dark pick. */ @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]) { --page: #1d1d22; --page-edge: #0c0c0f; --ink: #e6e6ea; @@ -23,9 +32,38 @@ --chrome: rgba(30, 30, 36, 0.75); --chrome-border: rgba(255, 255, 255, 0.08); --floor-shadow: rgba(0, 0, 0, 0.8); + --cable-shade: #050506; + --cable-body: #1c1c20; + --cable-highlight: #45454c; + --plug-end: #26262b; + --plug-upper: linear-gradient(90deg, #2a2a30, #3c3c43); + --plug-side: linear-gradient(90deg, #17171a, #2a2a2f); + --plug-lower: linear-gradient(90deg, #0b0b0d, #17171a); } } +:root[data-theme="dark"] { + color-scheme: dark; + --page: #1d1d22; + --page-edge: #0c0c0f; + --ink: #e6e6ea; + --ink-muted: #8d8d98; + --chrome: rgba(30, 30, 36, 0.75); + --chrome-border: rgba(255, 255, 255, 0.08); + --floor-shadow: rgba(0, 0, 0, 0.8); + --cable-shade: #050506; + --cable-body: #1c1c20; + --cable-highlight: #45454c; + --plug-end: #26262b; + --plug-upper: linear-gradient(90deg, #2a2a30, #3c3c43); + --plug-side: linear-gradient(90deg, #17171a, #2a2a2f); + --plug-lower: linear-gradient(90deg, #0b0b0d, #17171a); +} + +:root[data-theme="light"] { + color-scheme: light; +} + * { box-sizing: border-box; } @@ -127,18 +165,6 @@ body.capture-panel { color: #fff; } -.switch { - display: inline-flex; - gap: 6px; - align-items: center; - color: var(--ink-muted); - cursor: pointer; -} - -.switch input { - accent-color: var(--accent); -} - .stage { position: relative; min-height: 0; @@ -360,20 +386,20 @@ body.capture-panel { .plug-side { position: absolute; - background: linear-gradient(90deg, #c9c9cf, #f4f4f6); + background: var(--plug-side); } .plug-side.end { border-radius: calc(var(--led) * 0.6); - background: #e9e9ed; + background: var(--plug-end); } .plug-side.upper { - background: linear-gradient(90deg, #dcdce1, #ffffff); + background: var(--plug-upper); } .plug-side.lower { - background: linear-gradient(90deg, #9d9da5, #c2c2c8); + background: var(--plug-lower); } .cable-anchor { @@ -404,15 +430,15 @@ body.capture-panel { } .cable-shade { - stroke: #b3b3ba; + stroke: var(--cable-shade); } .cable-body { - stroke: #e2e2e7; + stroke: var(--cable-body); } .cable-highlight { - stroke: #ffffff; + stroke: var(--cable-highlight); } .footer { diff --git a/tests/test_web_capture.py b/tests/test_web_capture.py index c2eb6f3..4e908ae 100644 --- a/tests/test_web_capture.py +++ b/tests/test_web_capture.py @@ -17,10 +17,10 @@ def lit_frame(width: int, height: int) -> wire.Frame: return wire.Frame(width=width, height=height, pixels=bytes(pixels)) -def capture(style: str, tmp_path: Path) -> Image.Image: - output = tmp_path / f"{style}.png" +def capture(style: str, tmp_path: Path, theme: str = "dark") -> Image.Image: + output = tmp_path / f"{style}-{theme}.png" try: - WebCapture(lit_frame(64, 32), style, led_size=4).save(output) + WebCapture(lit_frame(64, 32), style, led_size=4, theme=theme).save(output) except SystemExit as exit_error: pytest.skip(f"no headless browser available: {exit_error}") @@ -47,6 +47,15 @@ def test_device_style_crops_to_the_device(tmp_path: Path) -> None: assert 32 * 4 < height < (32 + 90) * 4 +def test_device_backdrop_follows_the_theme(tmp_path: Path) -> None: + dark = capture("device", tmp_path, theme="dark").convert("L") + light = capture("device", tmp_path, theme="light").convert("L") + + corner = (2, 2, 3, 3) + assert dark.crop(corner).tobytes()[0] < 60 + assert light.crop(corner).tobytes()[0] > 180 + + def test_screenshot_defaults_to_raw_pixels() -> None: args = run_screenshot.build_parser().parse_args(["clock"])