diff --git a/README.md b/README.md
index fd8ca55..0711fda 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,9 @@ The assertion side offers both `assert.*` functions and a Chai-style `expect(x).
dependencies to a minimum so that a dependency update elsewhere cannot break your test run -- which
is how the project started, when the frameworks we tested Worker code with stopped working.
-**New here?** Read [Why tripwire](https://nevware21.github.io/tripwire/why-tripwire.html) for what is automatic, what is fast, how it
+**New here?** See [Tripwire in action](https://nevware21.github.io/tripwire/tour.html) -- tripwire's own suite run once on node, in
+Chrome and in a web worker, with coverage and the interactive browser page -- then read
+[Why tripwire](https://nevware21.github.io/tripwire/why-tripwire.html) for what is automatic, what is fast, how it
compares with Mocha, Karma, Jest and Vitest, and when it is not the right tool. Coming from one of
those? There is a [migration guide](#documentation) for each.
diff --git a/common/config/rush/npm-shrinkwrap.json b/common/config/rush/npm-shrinkwrap.json
index 26b54b5..26e730e 100644
--- a/common/config/rush/npm-shrinkwrap.json
+++ b/common/config/rush/npm-shrinkwrap.json
@@ -1920,9 +1920,9 @@
]
},
"node_modules/baseline-browser-mapping": {
- "version": "2.11.33",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz",
- "integrity": "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w==",
+ "version": "2.11.34",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz",
+ "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
@@ -2877,9 +2877,9 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/electron-to-chromium": {
- "version": "1.5.465",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.465.tgz",
- "integrity": "sha512-zg4iUpbeGw7YNZsswc2xKr2uiBex2d8dELAXo92Icw3but1ULbrvK8USe6wVLaWjgJnKBARv0pchx9Owktx6wQ=="
+ "version": "1.5.467",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.467.tgz",
+ "integrity": "sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw=="
},
"node_modules/elliptic": {
"version": "6.6.1",
@@ -5944,9 +5944,9 @@
}
},
"node_modules/qs": {
- "version": "6.16.1",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
- "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
+ "version": "6.16.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
+ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
diff --git a/docs/README.md b/docs/README.md
index 15be45c..cf04c32 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -14,6 +14,9 @@
### Documentation
+- [Tripwire in action](tour.md) - tripwire's own suite run once on node, in Chrome and in a web
+ worker, coverage from the command line, and the interactive browser page, with screenshots of
+ each
- [Why tripwire](why-tripwire.md) - what is automatic across node, browser and worker, what is
fast, how it compares with Mocha, Karma, Jest and Vitest, and when not to use it
- [Performance](performance.md) - the runner's mechanisms, measured benchmark results against
diff --git a/docs/images/tour/browser-dark.png b/docs/images/tour/browser-dark.png
new file mode 100644
index 0000000..c0e4ff4
Binary files /dev/null and b/docs/images/tour/browser-dark.png differ
diff --git a/docs/images/tour/browser-failure.png b/docs/images/tour/browser-failure.png
new file mode 100644
index 0000000..4cd83a6
Binary files /dev/null and b/docs/images/tour/browser-failure.png differ
diff --git a/docs/images/tour/browser-phone.png b/docs/images/tour/browser-phone.png
new file mode 100644
index 0000000..ebb79e0
Binary files /dev/null and b/docs/images/tour/browser-phone.png differ
diff --git a/docs/images/tour/browser-runner.png b/docs/images/tour/browser-runner.png
new file mode 100644
index 0000000..14694b9
Binary files /dev/null and b/docs/images/tour/browser-runner.png differ
diff --git a/docs/images/tour/browser-scope.png b/docs/images/tour/browser-scope.png
new file mode 100644
index 0000000..e475e5b
Binary files /dev/null and b/docs/images/tour/browser-scope.png differ
diff --git a/docs/images/tour/coverage.svg b/docs/images/tour/coverage.svg
new file mode 100644
index 0000000..6fef8f5
--- /dev/null
+++ b/docs/images/tour/coverage.svg
@@ -0,0 +1,33 @@
+
diff --git a/docs/images/tour/one-file-node.svg b/docs/images/tour/one-file-node.svg
new file mode 100644
index 0000000..cf3aae3
--- /dev/null
+++ b/docs/images/tour/one-file-node.svg
@@ -0,0 +1,30 @@
+
diff --git a/docs/images/tour/one-file-three-targets.svg b/docs/images/tour/one-file-three-targets.svg
new file mode 100644
index 0000000..d6be04a
--- /dev/null
+++ b/docs/images/tour/one-file-three-targets.svg
@@ -0,0 +1,37 @@
+
diff --git a/docs/images/tour/whole-suite.svg b/docs/images/tour/whole-suite.svg
new file mode 100644
index 0000000..c93a213
--- /dev/null
+++ b/docs/images/tour/whole-suite.svg
@@ -0,0 +1,24 @@
+
diff --git a/docs/llms.txt b/docs/llms.txt
index aa38057..838a9de 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -6,13 +6,14 @@ Conventions worth knowing before reading the rest: `expect(x).to.be.true()` term
## Start here
+- [Tripwire in action](https://nevware21.github.io/tripwire/tour.html): tripwire's own core suite run on node, in Chrome and in a web worker from one set of test files, coverage with --coverage (browser/worker) or nyc (node), and the interactive browser page, with screenshots from real runs
- [Why tripwire](https://nevware21.github.io/tripwire/why-tripwire.html): what is automatic across node/browser/worker, what is fast, comparison with Mocha, Karma, Jest and Vitest, and when not to use it
- [AGENTS.md](https://github.com/nevware21/tripwire/blob/main/AGENTS.md): entry points for agents -- migrating a project, using the runner, changing the repository
- [Documentation index](https://nevware21.github.io/tripwire/): every guide, feature table and quick start
## Using the runner
-- [Test Runner, CLI & Configuration](https://nevware21.github.io/tripwire/test-runner.html): every CLI flag and every `.tripwirerc` field in two tables, targets, the interactive browser page (scope box, run again, Failed only, Auto-reload, actions menu, URL-hash scope), file discovery, coverage, compile caching, hook failures
+- [Test Runner, CLI & Configuration](https://nevware21.github.io/tripwire/test-runner.html): every CLI flag and every `.tripwirerc` field in two tables, targets, the interactive browser page (scope box, run again, Failed only, Auto-reload, actions menu, the Appearance panel with a searchable theme, color-blind safe colors and a zoom of the page alone, URL-hash scope), file discovery, coverage, compile caching, hook failures
- [Reporters](https://nevware21.github.io/tripwire/reporters.html): the 14 built-in reporters with sample output, selecting and combining them, custom reporter modules
- [Test Execution Flow](https://nevware21.github.io/tripwire/test-execution-flow.html): execution order, timeouts, hook failure handling, runner metrics
- [Runner Troubleshooting](https://nevware21.github.io/tripwire/runner-troubleshooting.html): known issues, opting in to the global `describe`/`it` names, the `@types/mocha` / `@types/jest` global type clash
diff --git a/docs/migration/migrating-from-mocha.md b/docs/migration/migrating-from-mocha.md
index 6a3e50c..dfbe6a1 100644
--- a/docs/migration/migrating-from-mocha.md
+++ b/docs/migration/migrating-from-mocha.md
@@ -425,6 +425,7 @@ For automated migrations. Every entry is derived from the tables above.
"configPrecedence": {
"listValuedFlagsAppendToConfig": ["--spec", "--ignore", "--grep", "--fgrep", "--require", "--file", "--coverage-reporter", "--coverage-src"],
"replacesConfigList": ["--reporter"],
+ "replacesOnlyTheConfigPartsItNames": ["--browser-theme"],
"configKeysConcatenatedAcrossDirectories": ["grep", "ignore", "spec", "require"],
"otherSettings": "the CLI flag replaces the config value; a config file closer to the cwd replaces one higher up"
},
diff --git a/docs/scripts/genTourImages.js b/docs/scripts/genTourImages.js
new file mode 100644
index 0000000..fd7b29e
--- /dev/null
+++ b/docs/scripts/genTourImages.js
@@ -0,0 +1,330 @@
+/*
+ * @nevware21/tripwire
+ * https://github.com/nevware21/tripwire
+ *
+ * Copyright (c) 2026 NevWare21 Solutions LLC
+ * Licensed under the MIT license.
+ */
+
+/*
+ * Regenerates the images used by docs/tour.md.
+ *
+ * node docs/scripts/genTourImages.js
+ *
+ * Every image is a real run, nothing is mocked up:
+ *
+ * - the terminal images run the built CLI over tripwire's own core test suite -- one test file on
+ * each target, the whole suite on each target as core's own npm scripts run it, and coverage on
+ * the browser and node targets -- with FORCE_COLOR=1, and render the captured ANSI to SVG with
+ * genReporterImages.js's renderer;
+ * - the browser images drive the interactive page with puppeteer, over the small project the
+ * runner's own interactive-page tests use (internal/runner/test/fixtures/interactive).
+ *
+ * Pass `terminal` or `browser` to make just that set: `node docs/scripts/genTourImages.js browser`.
+ *
+ * The terminal captures are cleaned before they are drawn: lines that only report how the run was
+ * set up (server ports, absolute paths, module counts) are dropped or made relative, so the images
+ * carry nothing of the machine they were made on. Timings are left as measured. The browser images
+ * show the page as it is, loopback address and port included.
+ *
+ * Needs the runner built (core/bin/tripwire.js) and puppeteer's Chrome installed (see
+ * internal/runner's install script); it overwrites core/coverage, which is generated output.
+ */
+
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { spawn, spawnSync } = require("child_process");
+const { createRequire } = require("module");
+const { pathToFileURL } = require("url");
+const { toSvg } = require("./genReporterImages");
+
+const REPO = path.resolve(__dirname, "..", "..");
+const CORE = path.join(REPO, "core");
+const CLI = path.join(CORE, "bin", "tripwire.js");
+const OUT_DIR = path.join(REPO, "docs", "images", "tour");
+const FIXTURE = path.join(REPO, "internal", "runner", "test", "fixtures", "interactive");
+const RUNNER = path.join(REPO, "internal", "runner");
+
+// One of core's own test files: small enough to read, and run as-is on every target
+const DEMO_FILE = "test/src/assert/assert.isString.test.ts";
+const PROJECT = [ "-p", "test/tsconfig.test.json" ];
+
+// The globs core's own test:node, test:browser and test:worker scripts run: everything but the two
+// folders that hold the other targets' own tests
+const SUITE_GLOBS = {
+ node: "test/src/!(browser|worker)/**/*.test.ts",
+ browser: "test/src/!(node|worker)/**/*.test.ts",
+ worker: "test/src/!(node|browser)/**/*.test.ts"
+};
+
+const PROMPT = "\x1b[90m$\x1b[39m ";
+
+// The window the wide browser images start from; each is then made as tall as the page (fitWindow)
+const WIDE = { width: 1200, height: 720 };
+
+// Lines that say nothing about the run itself: how it was set up on this machine
+const DROP = [
+ /^Matched \d+ file/,
+ /^\s*Running tests\.\.\.\s*$/,
+ /^Run summary:/,
+ /^Server URL:/,
+ /^\s*Module graph:/,
+ /^Ready to run tests after/,
+ /^Compile cache:/,
+ /^Type-check(ing TypeScript project:| completed in)/,
+ /^Coverage (written|report)/
+];
+
+/** `args` as they would be typed, quoting the ones a shell would expand. */
+function displayCommand(command, args) {
+ return [ command ].concat(args.map((arg) => (/[*!(|)]/.test(arg) ? "\"" + arg + "\"" : arg))).join(" ");
+}
+
+/**
+ * The captured output, ready to draw: the set-up chatter in DROP gone, `cwd` (in either slash
+ * direction) shown as ".", and blank lines trimmed at the ends and never more than one in a row.
+ */
+function cleanOutput(raw, cwd) {
+ const roots = [ cwd, cwd.replace(/\\/g, "/") ];
+ const lines = [];
+
+ String(raw).split(/\r?\n/).forEach((line) => {
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
+ if (DROP.some((re) => re.test(plain))) {
+ return;
+ }
+
+ let shown = line;
+ roots.forEach((root) => {
+ shown = shown.split(root).join(".");
+ });
+
+ // A path made relative reads the same on every platform
+ shown = shown.replace(/\.\\[^\s]*/g, (found) => found.replace(/\\/g, "/"));
+
+ if (!shown.trim() && (!lines.length || !lines[lines.length - 1].trim())) {
+ return;
+ }
+
+ lines.push(shown.replace(/\s+$/, ""));
+ });
+
+ while (lines.length && !lines[lines.length - 1].trim()) {
+ lines.pop();
+ }
+
+ return lines;
+}
+
+/** Each step's command after a prompt, then its cleaned output, with a blank line between steps. */
+function transcript(steps) {
+ const lines = [];
+ steps.forEach((step, idx) => {
+ if (idx) {
+ lines.push("");
+ }
+
+ lines.push(PROMPT + step.display);
+ Array.prototype.push.apply(lines, cleanOutput(step.output, step.cwd));
+ });
+
+ return lines;
+}
+
+/** Runs the built CLI from core/, or through nyc when `nyc` is set, and returns what it printed. */
+function runCore(args, nyc) {
+ const command = nyc
+ ? [ require.resolve("nyc/bin/nyc.js", { paths: [ CORE ] }), "--reporter=text-summary", process.execPath, CLI ].concat(args)
+ : [ CLI ].concat(args);
+ const result = spawnSync(process.execPath, command, {
+ cwd: CORE,
+ encoding: "utf8",
+ env: Object.assign({}, process.env, { FORCE_COLOR: "1" }),
+ maxBuffer: 64 * 1024 * 1024
+ });
+
+ return (result.stdout || "") + (result.stderr || "");
+}
+
+function writeSvg(name, lines, title) {
+ fs.writeFileSync(path.join(OUT_DIR, name), toSvg(lines, title), "utf8");
+ console.log(" wrote " + name);
+}
+
+function terminalImages() {
+ // One file, spec reporter, on node: what the tests are
+ const nodeArgs = PROJECT.concat([ DEMO_FILE ]);
+ writeSvg("one-file-node.svg", transcript([
+ { display: displayCommand("npx tripwire", nodeArgs), output: runCore(nodeArgs), cwd: CORE }
+ ]), "One of tripwire's own test files, run on node");
+
+ // The same file on all three targets
+ writeSvg("one-file-three-targets.svg", transcript([ "node", "browser", "worker" ].map((target) => {
+ const args = (target === "node" ? [] : [ "--target", target ]).concat(PROJECT, [ "--reporter", "dot", DEMO_FILE ]);
+ return { display: displayCommand("npx tripwire", args), output: runCore(args), cwd: CORE };
+ })), "The same test file on node, in Chrome and in a web worker");
+
+ // The whole suite, as core's own scripts run it
+ writeSvg("whole-suite.svg", transcript([ "node", "browser", "worker" ].map((target) => {
+ const args = (target === "node" ? [] : [ "--target", target ]).concat([ "--type-check" ], PROJECT, [ "--reporter", "min", SUITE_GLOBS[target] ]);
+ return { display: displayCommand("npx tripwire", args), output: runCore(args), cwd: CORE };
+ })), "tripwire's core suite on node, in Chrome and in a web worker");
+
+ // Coverage: a flag on the browser target, the nyc wrapper on node
+ const browserCoverage = [ "--target", "browser", "--coverage", "--coverage-reporter", "text-summary" ].concat(PROJECT, [ "--reporter", "min", SUITE_GLOBS.browser ]);
+ const nodeCoverage = PROJECT.concat([ "--reporter", "min", SUITE_GLOBS.node ]);
+ writeSvg("coverage.svg", transcript([
+ { display: displayCommand("npx tripwire", browserCoverage), output: runCore(browserCoverage), cwd: CORE },
+ { display: displayCommand("npx nyc --reporter=text-summary tripwire", nodeCoverage), output: runCore(nodeCoverage, true), cwd: CORE }
+ ]), "Coverage of tripwire's core: --coverage in Chrome, nyc on node");
+}
+
+/** Starts the CLI in manual launch mode on the fixture, resolving with its runner URL and a stop(). */
+function startRunner() {
+ const child = spawn(process.execPath, [ CLI, "--target", "browser", "--browser-launch", "manual", "-p", "./tsconfig.json", "./specs/**/*.spec.ts" ], {
+ cwd: FIXTURE,
+ stdio: [ "ignore", "pipe", "pipe" ],
+ windowsHide: true
+ });
+ let output = "";
+
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ child.kill();
+ reject(new Error("The runner printed no URL:\n" + output));
+ }, 60000);
+
+ child.stdout.on("data", (data) => {
+ output += data;
+ const match = /Runner URL:\s*(http:\/\/\S+\/__tripwire__\/browser)/.exec(output.replace(/\x1b\[[0-9;]*m/g, ""));
+ if (match) {
+ clearTimeout(timer);
+ resolve({ url: match[1], stop: () => child.kill() });
+ }
+ });
+ });
+}
+
+function wait(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Makes the window as tall as the page, scrolled to its top. The page follows a run down to its
+ * last result and its background is fixed to the window, so a capture of more than the window (a
+ * fullPage screenshot) shows a blank band wherever the window was not.
+ */
+async function fitWindow(page) {
+ const height = await page.evaluate(() => {
+ window.scrollTo(0, 0);
+ return document.documentElement.scrollHeight;
+ });
+ await page.setViewport({ width: WIDE.width, height: height });
+ await wait(300);
+}
+
+async function browserImages() {
+ const runnerRequire = createRequire(path.join(RUNNER, "package.json"));
+ const module = await import(pathToFileURL(runnerRequire.resolve("puppeteer")).href);
+ const puppeteer = module.default || module;
+ const runner = await startRunner();
+ const browser = await puppeteer.launch({ headless: true });
+
+ const shot = (name) => path.join(OUT_DIR, name);
+ const loaded = async (page) => {
+ await page.waitForFunction(() => /Run complete/.test((document.getElementById("status") || {}).textContent || ""), { timeout: 60000 });
+ await wait(400);
+ };
+
+ try {
+ const page = await browser.newPage();
+ await page.emulateMediaFeatures([ { name: "prefers-color-scheme", value: "light" } ]);
+ await page.setViewport(WIDE);
+ await page.goto(runner.url, { waitUntil: "domcontentloaded" });
+ await loaded(page);
+ await fitWindow(page);
+
+ await page.screenshot({ path: shot("browser-runner.png") });
+ console.log(" wrote browser-runner.png");
+
+ const failed = await page.$("#results .test-node.test-failed");
+ await failed.screenshot({ path: shot("browser-failure.png") });
+ console.log(" wrote browser-failure.png");
+
+ await page.click("#scopeSelect");
+ await page.type("#scopeSelect", "add");
+ await wait(300);
+ await page.screenshot({ path: shot("browser-scope.png"), clip: { x: 0, y: 0, width: WIDE.width, height: 250 } });
+ console.log(" wrote browser-scope.png");
+ await page.keyboard.press("Escape");
+ await page.evaluate(() => {
+ const input = document.getElementById("scopeSelect");
+ input.value = "";
+ input.blur();
+ });
+
+ // Dark, then a phone in low light with the color-blind palette, listing the themes in the
+ // Appearance panel
+ await page.evaluate(() => localStorage.setItem("tripwire.theme", "dark"));
+ await page.setViewport(WIDE);
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await loaded(page);
+ await fitWindow(page);
+ await page.screenshot({ path: shot("browser-dark.png") });
+ console.log(" wrote browser-dark.png");
+
+ await page.evaluate(() => {
+ localStorage.setItem("tripwire.theme", "dim");
+ localStorage.setItem("tripwire.vision", "colorblind");
+ });
+ await page.setViewport({ width: 390, height: 760 });
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await loaded(page);
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await page.click("#actionsBtn");
+ await page.click("#appearanceItem");
+ await page.waitForFunction(() => document.activeElement && document.activeElement.id === "themeInput");
+ await page.keyboard.press("ArrowDown");
+ await wait(300);
+ await page.screenshot({ path: shot("browser-phone.png") });
+ console.log(" wrote browser-phone.png");
+ } finally {
+ await browser.close();
+ runner.stop();
+ }
+}
+
+async function main() {
+ if (!fs.existsSync(CLI)) {
+ console.error("Built CLI not found at " + CLI + " -- run the runner build first.");
+ process.exitCode = 1;
+ return;
+ }
+
+ const only = process.argv[2];
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+ if (only !== "browser") {
+ terminalImages();
+ }
+
+ if (only !== "terminal") {
+ await browserImages();
+ }
+ console.log("Done -> " + path.relative(REPO, OUT_DIR));
+}
+
+// Only regenerate the images when executed as a script, so the tests can load the helpers without
+// overwriting the committed images.
+if (require.main === module) {
+ main().catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ });
+}
+
+module.exports = {
+ REPO, CORE, CLI, OUT_DIR, DEMO_FILE, SUITE_GLOBS, PROMPT, DROP,
+ displayCommand, cleanOutput, transcript
+};
diff --git a/docs/test-runner.md b/docs/test-runner.md
index 1cc1c02..eaf49ad 100644
--- a/docs/test-runner.md
+++ b/docs/test-runner.md
@@ -60,35 +60,70 @@ opened (default: `puppeteer`):
| `open` | Starts the embedded server and opens it in the OS's default browser (`start`/`open`/`xdg-open`). Requires the server to stay running, so `--no-server` is ignored (with a warning) in this mode. |
| `manual` | Starts the server and prints its URL; you open it yourself. Useful for switching between the `browser`/`worker` tabs from the same session: nothing is built up front -- each target's module graph is built the first time that target is requested from the page, and rebuilt on a later request if its sources have changed. |
-An optional `:style` suffix (`classic` or `slate`, default `classic`) picks the UI theme for
-`open`/`manual` sessions -- `--browser-style` sets the same thing independently.
+An optional `:style` suffix (`classic` or `slate`, default `classic`) picks the UI style for
+`open`/`manual` sessions -- `--browser-style` sets the same thing independently. Both styles come
+in every theme -- light, dark, low light and color-blind safe, at any zoom from 50% to 300% --
+which `--browser-theme` picks and the page's own Appearance panel changes: see
+[Themes and accessibility](#themes-and-accessibility).
### The interactive page
`open` and `manual` launches serve a page from which you run and re-run tests without restarting
the CLI. One session serves both targets -- `/browser` and `/worker` -- and the page is the same
-on each. Everything on it is keyboard-reachable; the controls, left to right:
+on each. Everything on it is keyboard-reachable; the **Target** picker and the **…** (More actions)
+menu sit at the top right, beside the title, and the controls below them are, left to right:
| Control | What it does |
|---|---|
-| **Scope** box | Type to search every suite and test at once; terms match in any order, and each row says whether it is a suite or a test. Pick a row (click, or ArrowUp/ArrowDown then Enter; Escape closes the list) to stage it as the scope for the next run. Blank, or text naming no suite or test, means run everything. |
-| **All tests** | Clears the scope without running, then moves focus to Run. Disabled when there is nothing to clear. |
| **Target** | Switches the page between the browser and worker targets. It is a navigation to the other target's page, not a CLI restart, and the current scope travels with it. |
-| **Failed only** | Hides passing results. A test that is running stays visible until it passes; a suite stays visible while anything inside it does. |
-| **Auto-reload** | On by default. The page asks the server whether the current target's test modules have changed on disk and, when they have, reloads the test frame and re-runs with the current scope. Turn it off to run on demand only; it is a per-page setting, not saved. (`--watch` has no effect on these launches -- this is what replaces it.) |
-| **Run** | Runs the staged scope. The status line reports progress and the assertion count updates live. |
-| **…** (More actions) | A menu: **Reload Tests** discards and reloads the test frame, then re-runs; **Clear Results**; **Clear Logs**; **Finish & Stop Server** sends the last run's result back to the CLI, which prints its report, exits with that run's exit code and stops the server (the page then says it can be closed). Finish is disabled until a run has produced a result. Keyboard: ArrowUp/ArrowDown move and wrap, Home/End jump to the ends, Escape closes and returns focus to the button, and the whole menu is one tab stop. |
-
-Below the controls: the status line and assertion count, the results tree, and a collapsible
-**Runner Log** panel. Every suite and test row in the results has a **run again** control that
-scopes the page to that node and runs it immediately -- the quickest way to iterate on one failing
-test. To get back, use **All tests**.
+| **…** (More actions) | A menu: **Reload Tests** discards and reloads the test frame, then re-runs; **Auto-reload**, ticked while it is on (by default): the page asks the server whether the current target's test modules have changed on disk and, when they have, reloads the test frame and re-runs with the current scope -- untick it to run on demand only; it is a per-page setting, not saved (`--watch` has no effect on these launches -- this is what replaces it); **Clear Logs**; **Appearance…** opens the panel for the theme, color-blind safe colors and zoom (see [Themes and accessibility](#themes-and-accessibility)); **Finish & Stop Server** sends the last run's result back to the CLI, which prints its report, exits with that run's exit code and stops the server (the page then says it can be closed). Finish is disabled until a run has produced a result. Keyboard: ArrowUp/ArrowDown move and wrap, Home/End jump to the ends, Escape closes and returns focus to the button, and the whole menu is one tab stop. |
+| **Scope** box | Type to search every suite and test at once; terms match in any order, and each row says whether it is a suite or a test. Pick a row (click, or ArrowUp/ArrowDown then Enter; Escape closes the list) to stage it as the scope for the next run. Blank, or text naming no suite or test, means run everything. |
+| **✕** (Clear scope) | At the end of the Scope box while there is a scope: clears it without running, then moves focus to Run. |
+| **Failed only** | Hides passing and skipped results, and says above the results how many it is hiding. A test that is running stays visible until it passes; a suite stays visible while anything inside it does. The failed count in the run summary turns it on and off too. |
+| **Run** | Runs the staged scope. The status line reports progress, the run summary beside it counts the passed, failed and skipped tests as they finish (and adds the run's duration once it ends), and the assertion count updates live. The icon at the end of the status line clears the results. |
+
+When the window is too narrow for a row, its controls fold into the **…** menu rather than the row
+wrapping: **Failed only** from the Scope row, and **Target** from the title row. Each comes back as
+the window widens. In the menu they work as they do on the page -- **Failed only** as a ticked
+entry, **Target** as a choice of the two targets.
+
+The Scope row and the status line stay at the top of the window as the results scroll. Below them:
+the results tree and a collapsible **Runner Log** panel, which shows the reporter's output in the
+colours the CLI's terminal shows it in. Each suite says how many of its tests
+passed, failed and were skipped; a failed assertion shows its message with the expected and actual
+values it compared, and the rest of its description folded under **Details**. Every suite and test
+row in the results has a **run again** control that scopes the page to that node and runs it
+immediately -- the quickest way to iterate on one failing test. To get back, use the **✕** in the Scope box.
The scope lives in the URL hash (`#suite=&test=`, as short hashed ids rather than the
titles), so a scoped page can be bookmarked, shared, or simply reloaded, and the scope is restored
once the page has discovered the suite. The hash is replaced in place rather than pushed, so
picking scopes does not fill the browser's history.
+#### Themes and accessibility
+
+The page works from the keyboard alone and with a screen reader. **Tab** moves through everything
+in the order it appears -- the first stop is a **Skip to the results** link -- and results rows open
+and close with **Enter** or **Space**. Every icon button is named; the Scope box announces each
+suggestion as the arrow keys highlight it; the status line announces each change; and an
+assertion is announced as passed or failed, not by its ✓ or ✗.
+
+How the page looks is three settings, in the **Appearance** panel that **…** › **Appearance…**
+opens. The panel is a small dialog rather than more menu entries, so that the theme can be a box
+you search: it opens with focus on the theme, Tab moves through the rest, and Escape (or a click
+outside it) closes it, putting focus back on the **…** button.
+
+| Setting | Choices | `--browser-theme` words |
+|---|---|---|
+| **Theme** | A searchable box: type any part of a theme's name, in any order, or press ArrowDown to list them all, then pick one with Enter or a click. **System** follows the operating system's light or dark setting; **Light**; **Dark**; **Low light** is dark and dimmer, with lower contrast and warmer colors, for a dark room. | `system`, `light`, `dark`, `dim` |
+| **Color-blind safe** | A checkbox: shows passed and failed in blue and orange -- a pair every kind of color blindness tells apart -- in place of green and red: in the results, the run summary and the log. Works with every theme. | `colorblind` (on), `standard` (off) |
+| **Zoom** | **−** and **+** step through 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250 and 300%, and **Reset** goes back to the run's zoom. It scales the page's own controls, results and log, and nothing else: it is not the browser's zoom, and never scales a test's own frame or content. A page zoomed past what the window holds takes its narrow layout, as a smaller window would. | Any whole percentage from `50%` to `300%`, eg. `125%` |
+
+`--browser-theme` (or `browserTheme` in a config file) sets how the page opens -- eg.
+`tripwire --target browser --manual-browser --browser-theme dim,colorblind,125%` -- naming any of
+the three settings it wants to change. A choice made in the panel is remembered in that browser and
+wins over the run's setting from then on; choosing what the run set forgets it again.
+
## CLI Reference
```
@@ -116,6 +151,7 @@ argument before a `--`, prints the help text and exits 0.
| `--target `, `--env` | Runtime target. Default: `node`. |
| `--browser-launch ` | Browser launch mode + optional style for `browser`/`worker` targets. See [Test Targets](#test-targets). |
| `--browser-style ` | Browser UI style for interactive sessions. Default: `classic`. |
+| `--browser-theme ` | How the interactive page opens: a comma separated list of a color scheme (`system`, `light`, `dark`, or `dim` for low light), a color vision setting (`standard`, `colorblind`) and a zoom, as a whole percentage from `50%` to `300%`, eg. `dim,colorblind,125%`. Replaces only the parts of the config file's `browserTheme` it names. See [Themes and accessibility](#themes-and-accessibility). |
| `--open-browser` | Alias for `--browser-launch open`. |
| `--manual-browser` | Alias for `--browser-launch manual`. |
| `--ux-provider ` | Custom browser UX provider module, overriding the built-in one. |
@@ -555,6 +591,7 @@ All fields are optional; every default matches the CLI defaults documented above
| `target` | `"node" \| "browser" \| "worker"` | Default runtime target. |
| `browserLaunch` | `"puppeteer" \| "open" \| "manual"` | Default browser launch mode. |
| `browserStyle` | `"classic" \| "slate"` | Default browser UI style. |
+| `browserTheme` | `string \| string[]` | How the interactive page opens, in the words `--browser-theme` takes: `"dim,colorblind,125%"` or `["dim", "colorblind", "125%"]`. An unknown word, or a zoom outside 50% to 300%, is an error. |
| `uxProvider` | `string` | Browser UX provider module name. |
| `project` | `string` | Path to the TypeScript project file. |
| `tsconfig` | `string \| object` | As a string: an alternate way to set `project` from config (still loses to an explicit CLI `--project`). As an object: compiler-option overrides layered on top of whichever project loads, applied even when `project` came from the CLI. |
diff --git a/docs/tour.md b/docs/tour.md
new file mode 100644
index 0000000..512c164
--- /dev/null
+++ b/docs/tour.md
@@ -0,0 +1,179 @@
+# Tripwire in action
+
+Tripwire's promise is short: **write your tests once, and run them on node, in a real browser and
+inside a web worker** -- with TypeScript, coverage and an interactive page for debugging built in,
+and without a bundler, a TypeScript loader or a browser launcher to configure.
+
+This page shows that on a real project: tripwire's own. Every terminal image below is a run of
+tripwire's `core` test suite (2,900+ tests), and every browser image is the interactive page the
+runner's own tests drive. Nothing is mocked up -- the images are regenerated from actual runs by
+[`docs/scripts/genTourImages.js`](#regenerating-these-images).
+
+- [Write the tests once](#write-the-tests-once)
+- [Run them on node, in a browser and in a web worker](#run-them-on-node-in-a-browser-and-in-a-web-worker)
+- [A real project: tripwire's own suite](#a-real-project-tripwires-own-suite)
+- [Coverage from the command line](#coverage-from-the-command-line)
+- [Debug in a real browser](#debug-in-a-real-browser)
+- [And it is fast](#and-it-is-fast)
+- [Regenerating these images](#regenerating-these-images)
+
+## Write the tests once
+
+A tripwire test file is a plain `describe`/`it` suite, with `assert` and `expect` built in. This is
+the start of one of tripwire's own, `core/test/src/assert/assert.isString.test.ts`:
+
+```ts
+import { assert } from "../../../src/assert/assertClass";
+import { expect } from "../../../src/assert/expect";
+import { checkError } from "../support/checkError";
+
+describe("isString", () => {
+ it("should pass when the value is a string", () => {
+ assert.isString("test");
+ expect(() => assert.isString("test")).to.not.throw();
+ });
+
+ it("should throw an error when the value is not a string", () => {
+ checkError(() => {
+ assert.isString(123);
+ }, "expected 123 to be a string");
+
+ expect(() => assert.isString(123)).to.throw();
+ });
+```
+
+(It imports from `src/` because it tests tripwire itself; your tests import
+`@nevware21/tripwire`.) There is nothing in it about where it runs: no environment checks, no
+browser or worker adapter, no build step. Run it:
+
+
+
+## Run them on node, in a browser and in a web worker
+
+The same file, unchanged, on all three targets -- the only difference is `--target`:
+
+
+
+- **node** runs it in-process, compiling the TypeScript against your `tsconfig.json` on the fly.
+- **browser** builds the module graph, serves it from the runner's own loopback server and runs it
+ in **real headless Chrome**, launched by puppeteer -- not a simulated DOM.
+- **worker** runs it inside a **real `Worker`** in that Chrome, and reports back over the runner's
+ own channel.
+
+Same tests, same 17 results. Nothing was configured for the browser or the worker: no webpack or
+rollup, no karma, no launcher plugins, no `ts-node` or `ts-jest`, no worker adapter -- the runner
+reads the `tsconfig.json` and does the rest. See [Test Targets](test-runner.md#test-targets) for
+the launch modes, including `--browser-launch open` (your own browser) and `manual` (a URL for any
+browser or device).
+
+## A real project: tripwire's own suite
+
+Tripwire tests itself this way. These are `core/package.json`'s own scripts -- one glob per target,
+and the same test files in each:
+
+```json
+"test:node": "nyc --cache node ./bin/tripwire.js --type-check -p ./test/tsconfig.test.json \"./test/src/!(browser|worker)/**/*.test.ts\" ...",
+"test:browser": "node ./bin/tripwire.js --target browser --coverage --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|worker)/**/*.test.ts\"",
+"test:worker": "node ./bin/tripwire.js --target worker --coverage --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|browser)/**/*.test.ts\""
+```
+
+The globs only leave out the two small folders that hold each *other* target's own checks
+(`test/src/node`, `test/src/browser` and `test/src/worker` hold two files each, such as
+`isBrowser.test.ts`). Everything else -- 120-odd files -- runs everywhere:
+
+
+
+2,921 tests passing on each target, from one set of files. The `shim/chai` package (tripwire's
+Chai-compatible API) does the same with its own suite.
+
+## Coverage from the command line
+
+On the **browser and worker** targets, coverage is one flag: `--coverage`. The runner instruments
+the module graph itself, also loads the sources no test imported (`--coverage-src`, default
+`src/**/*.ts`) so an untested file is reported at 0% rather than silently missing, writes
+`coverage//coverage-final.json`, and prints each `--coverage-reporter` you ask for.
+
+On the **node** target, coverage is the standard `nyc` wrapper -- `nyc tripwire ...` -- so it is one
+command rather than one flag, and uses your existing `.nycrc`.
+
+
+
+Each report covers the code that ran on its target, which is why the two differ: code that only
+runs in a browser counts towards the browser's figure, and node-only code towards node's. Both are
+what `core`'s own `npm test` produces. See [Coverage](test-runner.md#coverage) for the reporters,
+`--coverage-src` and how files TypeScript erased entirely are counted.
+
+## Debug in a real browser
+
+`--browser-launch open` (or `--open-browser`) runs the browser or worker target in your own browser
+and keeps the page open; `--browser-launch manual` prints its URL for any browser or device. The
+page runs the suite, shows the results as they arrive, and sends the final result back to the CLI
+when you choose **Finish & Stop Server**:
+
+```bash
+npx tripwire --target browser --open-browser "test/**/*.test.ts"
+```
+
+
+
+A failure shows its message once, the values it compared, and the rest of the error folded under
+**Details**:
+
+
+
+The **Scope** box searches every suite and test, in any order of words, and the next run covers
+just that; each result also has a **run again** button for exactly that suite or test, and the
+scope is kept in the URL so a reload or a bookmark brings it back:
+
+
+
+**Auto-reload** re-runs when a test file changes on disk, **Failed only** hides the noise, the
+**Runner Log** shows the reporter's output in its terminal colours, and the page switches between
+the browser and worker targets in place. It follows your system's light or dark setting:
+
+
+
+and it is built for everyone: usable from the keyboard alone and with a screen reader, with a low
+light theme, a color-blind safe palette (blue and orange in place of green and red) and a zoom from
+50% to 300% that scales the page's own controls and nothing else -- never the browser, or a test's
+own content. All three are in the **Appearance** panel, whose theme is a box you search, or set
+with `--browser-theme dim,colorblind,125%` on the command line. On a narrow window, controls fold
+into the menu rather than crowding the page:
+
+
+
+Everything on the page is described in [The interactive page](test-runner.md#the-interactive-page)
+and [Themes and accessibility](test-runner.md#themes-and-accessibility).
+
+## And it is fast
+
+Measured on this repository's own suites (2026-10-18; full method, machine and caveats on the
+[performance page](performance.md)):
+
+| Target | Suite | tripwire | Alternative |
+|---|---|---|---|
+| node | `core` | **6.07 s** | 9.13 s (ts-mocha) |
+| node | `shim/chai` | **1.06 s** | 2.70 s (ts-mocha) |
+| browser | `core` | **14.00 s** | 19.42 s (karma) |
+| browser | `shim/chai` | **10.07 s** | 11.19 s (karma) |
+| worker | `core` | **12.57 s** | 19.24 s (karma) |
+| worker | `shim/chai` | **9.69 s** | 10.98 s (karma) |
+
+Where it comes from: one TypeScript program per run, emitting only the files the run loads; a
+`--type-check` that is a walk over that same program rather than a `tsc` process; a compile cache
+that takes a repeat node run over an unchanged tree from 9.3 s to 5.2 s; and Chrome launched while
+the module graph is still building. Coverage on keeps tripwire ahead in all six comparisons. The
+[performance history](performance/history.md) charts every recorded run.
+
+## Regenerating these images
+
+Every image here comes from a real run, so it can be made again whenever the runner changes:
+
+```bash
+npm run docs-tour-images
+```
+
+This runs the built CLI over `core`'s suite for the terminal images and drives the interactive
+page with puppeteer for the browser ones; it needs the runner built and puppeteer's Chrome
+installed. Timings in the images are as measured on the machine that made them. Add
+`-- terminal` or `-- browser` to make just that set.
diff --git a/docs/why-tripwire.md b/docs/why-tripwire.md
index 004ec7b..f70e06d 100644
--- a/docs/why-tripwire.md
+++ b/docs/why-tripwire.md
@@ -11,6 +11,8 @@ npx tripwire --target worker "test/**/*.test.ts" # a real Worker inside Chr
```
This page says what that buys you, what it costs, and when another tool is the better choice.
+[Tripwire in action](tour.md) shows it on tripwire's own suite, with screenshots of each target,
+coverage and the interactive browser page.
## What is automatic
@@ -89,6 +91,8 @@ dependencies, and can be used under any runner.
## Where to go next
+- See it run: [Tripwire in action](tour.md) -- tripwire's own suite on all three targets, coverage
+ and the interactive page, with screenshots.
- Coming from somewhere else: [Mocha](migration/migrating-from-mocha.md),
[Karma](migration/migrating-from-karma.md), [Jest or Vitest](migration/migrating-from-jest.md),
[Chai](migration/migrating-from-chai.md), [node's `assert`](migration/migrating-from-node-assert.md).
diff --git a/internal/runner/rollup.config.js b/internal/runner/rollup.config.js
index b079068..70194a6 100644
--- a/internal/runner/rollup.config.js
+++ b/internal/runner/rollup.config.js
@@ -101,10 +101,16 @@ function _doMinify(code, filename, outputOptions, inputMap, preamble) {
var minifyOptions = {
ie8: false,
- toplevel: true,
+ // Only a CommonJS bundle's top level is its own, as node wraps each module in a function.
+ // An iife bundle is a classic script in a page or a worker, sharing the global scope with
+ // the tests: with toplevel, uglify unwraps the iife into a bare block, whose `var`s and
+ // functions are globals under two-letter names -- such as `it`, which the tests' own `it`
+ // then replaces.
+ toplevel: !outputOptions || outputOptions.format !== "iife",
+ // Not `unsafe`: among other things it writes String(x) as ""+x, which throws for a Symbol --
+ // and these bundles turn the tests' own values into text, symbols included
compress: {
- passes: 3,
- unsafe: true
+ passes: 3
},
output: {
// Must go through uglify rather than being concatenated onto the result: uglify
diff --git a/internal/runner/src/cli/parseArgs.ts b/internal/runner/src/cli/parseArgs.ts
index 4ccb774..9c866d8 100644
--- a/internal/runner/src/cli/parseArgs.ts
+++ b/internal/runner/src/cli/parseArgs.ts
@@ -15,6 +15,8 @@
import { loadConfig } from "../config/loader";
import { ITripwireConfig } from "../interfaces/ITripwireConfig";
+import { IBrowserUiTheme } from "../ux/types/BrowserUiTheme";
+import { mergeBrowserThemes, parseBrowserTheme } from "../shared/browserTheme";
import { REPORTER_NAMES } from "../reporters";
import { _REPORTER_MODULE_PREFIX, _isReporterModuleSpec } from "../internal/reporterSpec";
@@ -132,6 +134,20 @@ export function _parseArgs(args: string[], config?: ITripwireConfig) {
let target = fileConfig.target || "node";
let browserLaunch = fileConfig.browserLaunch || "puppeteer";
let browserStyle = fileConfig.browserStyle || "classic";
+ // How the interactive page looks: the config file's, with --browser-theme's parts in place of
+ // the ones it names. An unknown word is an error rather than silently ignored.
+ let browserTheme: IBrowserUiTheme | undefined;
+ if (fileConfig.browserTheme !== undefined && fileConfig.browserTheme !== null) {
+ const configTheme = parseBrowserTheme(fileConfig.browserTheme);
+ if (configTheme.error) {
+ return {
+ error: "Invalid browserTheme value in the config file: " + configTheme.error
+ };
+ }
+
+ browserTheme = configTheme.theme;
+ }
+
let uxProvider = fileConfig.uxProvider;
let serverRoot = fileConfig.serverRoot || "";
let bindAddress = fileConfig.bindAddress || "";
@@ -624,6 +640,26 @@ export function _parseArgs(args: string[], config?: ITripwireConfig) {
continue;
}
+ if (value === "--browser-theme" || value.indexOf("--browser-theme=") === 0) {
+ const consumed = _consumeValue("browser-theme", value, lp);
+ if (consumed.error) {
+ return {
+ error: consumed.error
+ };
+ }
+
+ const cliTheme = parseBrowserTheme(consumed.value || "");
+ if (cliTheme.error) {
+ return {
+ error: "Invalid value for --browser-theme: " + cliTheme.error
+ };
+ }
+
+ browserTheme = mergeBrowserThemes(browserTheme, cliTheme.theme);
+ lp = consumed.nextIndex;
+ continue;
+ }
+
if (value === "--ux-provider" || value.indexOf("--ux-provider=") === 0) {
const consumed = _consumeValue("ux-provider", value, lp);
if (consumed.error) {
@@ -856,6 +892,7 @@ export function _parseArgs(args: string[], config?: ITripwireConfig) {
target,
browserLaunch,
browserStyle,
+ browserTheme,
uxProvider,
compile: compile !== undefined ? compile : false,
strictTypeCheck,
diff --git a/internal/runner/src/client/workerRunner.ts b/internal/runner/src/client/workerRunner.ts
index 101c415..d9ddc83 100644
--- a/internal/runner/src/client/workerRunner.ts
+++ b/internal/runner/src/client/workerRunner.ts
@@ -9,6 +9,7 @@
import { isNumber } from "@nevware21/ts-utils";
import { createReporter } from "../reporters";
import { getSuitePartsFromNode, createAssertionLabel } from "../shared/suiteNames";
+import { addAssertionValues } from "../shared/assertionValues";
import { installDefineRegistry, createRequire, getWorkerLoaderContext, setTripwireGlobals, buildSuite } from "../shared/moduleLoader";
import { createSettingsGrepFilter, createSelectionFilter, applyFilters } from "../shared/filters";
import { createBailListener } from "../shared/bailListener";
@@ -204,14 +205,14 @@ export function startWorkerRunner(): void {
},
onAssert: function(context: any) {
const actx = context && context.v;
- (self as any).postMessage({
+ (self as any).postMessage(addAssertionValues({
kind: "assert",
suiteParts: currentTestRef ? currentTestRef.suiteParts : [],
testName: currentTestRef ? currentTestRef.testName : "",
passed: !!(actx && actx.passed),
label: createAssertionLabel(actx),
errorText: actx && actx.error ? String(actx.error.message || actx.error) : ""
- });
+ }, actx && actx.error));
}
});
}
diff --git a/internal/runner/src/execute/browserRunner.ts b/internal/runner/src/execute/browserRunner.ts
index a917520..d7a40e1 100644
--- a/internal/runner/src/execute/browserRunner.ts
+++ b/internal/runner/src/execute/browserRunner.ts
@@ -164,7 +164,7 @@ export async function _startBrowserRunnerSession(graphBuilders: IGraphBuilders,
}
payload.settings.server = server ? { url: server.url, port: server.port, isRunning: !!server.isRunning } : undefined;
- const html = uxProvider.createBrowserRunnerHtml(target as any, parsed.browserStyle || "classic");
+ const html = uxProvider.createBrowserRunnerHtml(target as any, parsed.browserStyle || "classic", parsed.browserTheme);
// Only the "browser" target's frame executes test modules directly in its own document
// -- the "worker" target's frame just hosts the postMessage bridge to a real Worker,
diff --git a/internal/runner/src/index.ts b/internal/runner/src/index.ts
index ed85be7..4b1544d 100644
--- a/internal/runner/src/index.ts
+++ b/internal/runner/src/index.ts
@@ -37,6 +37,7 @@
export { IFsEntry } from "./ux/interfaces/IFsEntry";
export { BrowserUiStyle } from "./ux/types/BrowserUiStyle";
+export { BrowserUiColorScheme, BrowserUiVision, IBrowserUiTheme } from "./ux/types/BrowserUiTheme";
export { IRunnerUxProvider } from "./interfaces/IRunnerUxProvider";
export { ITripwireModule } from "./interfaces/ITripwireModule";
export { IParsedArgs } from "./interfaces/IParsedArgs";
diff --git a/internal/runner/src/interfaces/IParsedArgs.ts b/internal/runner/src/interfaces/IParsedArgs.ts
index 3afb644..dacc870 100644
--- a/internal/runner/src/interfaces/IParsedArgs.ts
+++ b/internal/runner/src/interfaces/IParsedArgs.ts
@@ -8,6 +8,7 @@
import { BrowserLaunchMode, TestTarget } from "@nevware21/tripwire";
import { BrowserUiStyle } from "../ux/types/BrowserUiStyle";
+import { IBrowserUiTheme } from "../ux/types/BrowserUiTheme";
/**
* The parsed command-line arguments for the runner.
@@ -55,6 +56,12 @@ export interface IParsedArgs {
*/
browserStyle?: BrowserUiStyle;
+ /**
+ * How the interactive browser page looks when it opens (`--browser-theme`, over the config file's
+ * `browserTheme`).
+ */
+ browserTheme?: IBrowserUiTheme;
+
/**
* Whether to compile the tests before running.
*/
diff --git a/internal/runner/src/interfaces/IRunnerUxProvider.ts b/internal/runner/src/interfaces/IRunnerUxProvider.ts
index 91076ec..8f294f9 100644
--- a/internal/runner/src/interfaces/IRunnerUxProvider.ts
+++ b/internal/runner/src/interfaces/IRunnerUxProvider.ts
@@ -8,13 +8,15 @@
import { TestTarget } from "@nevware21/tripwire";
import { BrowserUiStyle } from "../ux/types/BrowserUiStyle";
+import { IBrowserUiTheme } from "../ux/types/BrowserUiTheme";
import { IFsEntry } from "../ux/interfaces/IFsEntry";
/**
* Browser UX provider contract consumed by the runner package.
*/
export interface IRunnerUxProvider {
- createBrowserRunnerHtml(target: TestTarget, style: BrowserUiStyle): string;
+ // `theme` is how the page looks when it opens (`--browser-theme`); a provider may ignore it
+ createBrowserRunnerHtml(target: TestTarget, style: BrowserUiStyle, theme?: IBrowserUiTheme): string;
createFsDirListingHtml(style: BrowserUiStyle, relativePath: string, entries: IFsEntry[], extraHtml?: string): string;
createTestFrameHtml?: (target: TestTarget, style: BrowserUiStyle, moduleScriptUrls: string[]) => string;
faviconIco?: string;
diff --git a/internal/runner/src/interfaces/ITripwireConfig.ts b/internal/runner/src/interfaces/ITripwireConfig.ts
index da538a9..866c1b8 100644
--- a/internal/runner/src/interfaces/ITripwireConfig.ts
+++ b/internal/runner/src/interfaces/ITripwireConfig.ts
@@ -49,6 +49,15 @@ export interface ITripwireConfig {
*/
browserStyle?: BrowserUiStyle;
+ /**
+ * How the interactive browser page looks when it opens: a comma separated list (or an array) of
+ * words, each naming one part -- a color scheme (`system`, `light`, `dark`, `dim`), a color
+ * vision setting (`standard`, `colorblind`) or the page's zoom, as a whole percentage from 50% to
+ * 300% -- eg. `"dim,colorblind,125%"`. `--browser-theme` replaces the parts it names. A choice
+ * made in the page's own Appearance panel takes precedence in that browser.
+ */
+ browserTheme?: string | string[];
+
/**
* Path to the TypeScript project file (default: "tsconfig.json").
*/
diff --git a/internal/runner/src/shared/assertionValues.ts b/internal/runner/src/shared/assertionValues.ts
new file mode 100644
index 0000000..5f456cf
--- /dev/null
+++ b/internal/runner/src/shared/assertionValues.ts
@@ -0,0 +1,186 @@
+/*
+ * @nevware21/tripwire
+ * https://github.com/nevware21/tripwire
+ *
+ * Copyright (c) 2026 NevWare21 Solutions LLC
+ * Licensed under the MIT license.
+ */
+
+/**
+ * The expected and actual values of a failed assertion, as text the interactive page can show side by
+ * side. Only the values the error carries are present.
+ */
+export interface IAssertionValues {
+ expected?: string;
+ actual?: string;
+}
+
+// Long enough for a typical object, short enough that one huge value cannot swamp the page
+const _MAX_VALUE_LENGTH = 1000;
+
+// How many levels into nested objects and arrays a value is written before the rest reads [Object]
+// or [Array]
+const _MAX_DEPTH = 4;
+
+function _limit(text: string): string {
+ return text.length > _MAX_VALUE_LENGTH ? text.substring(0, _MAX_VALUE_LENGTH) + "..." : text;
+}
+
+/** `target`'s own property `key`, read without running a getter on it -- or undefined. */
+function _ownProp(target: any, key: string): PropertyDescriptor | undefined {
+ try {
+ return Object.getOwnPropertyDescriptor(target, key);
+ } catch (e) {
+ // A proxy whose trap throws
+ return undefined;
+ }
+}
+
+/** A property as {@link _format} writes it: its value, or what kind of accessor it is. */
+function _formatProp(desc: PropertyDescriptor | undefined, depth: number, seen: any[]): string {
+ if (!desc) {
+ return "undefined";
+ }
+
+ if (desc.get || desc.set) {
+ return desc.get && desc.set ? "[Getter/Setter]" : (desc.get ? "[Getter]" : "[Setter]");
+ }
+
+ return _format(desc.value, depth, seen);
+}
+
+/** Each entry `write` gives, joined by commas, ending in "..." once the text is already too long to show. */
+function _entries(count: number, write: (idx: number) => string): string {
+ var parts: string[] = [];
+ var length = 0;
+ for (var lp = 0; lp < count; lp++) {
+ if (length > _MAX_VALUE_LENGTH) {
+ parts.push("...");
+ break;
+ }
+
+ var part = write(lp);
+ length += part.length + 1;
+ parts.push(part);
+ }
+
+ return parts.join(",");
+}
+
+function _format(value: any, depth: number, seen: any[]): string {
+ var type = typeof value;
+ if (type === "string") {
+ return JSON.stringify(value);
+ }
+
+ if (type === "symbol") {
+ // Explicitly, as Symbol(...) concatenated into a string throws
+ return Symbol.prototype.toString.call(value);
+ }
+
+ if (value === null || value === undefined || type === "number" || type === "boolean" || type === "bigint") {
+ return String(value);
+ }
+
+ if (type === "function") {
+ var name = _ownProp(value, "name");
+ return "[Function" + (name && typeof name.value === "string" && name.value ? ": " + name.value : "") + "]";
+ }
+
+ if (seen.indexOf(value) !== -1) {
+ return "[Circular]";
+ }
+
+ var isArray = Array.isArray(value);
+ if (depth >= _MAX_DEPTH) {
+ return isArray ? "[Array]" : "[Object]";
+ }
+
+ if (value instanceof Date) {
+ var time = Date.prototype.getTime.call(value);
+ return time === time ? Date.prototype.toISOString.call(value) : "Invalid Date";
+ }
+
+ if (value instanceof RegExp) {
+ return RegExp.prototype.toString.call(value);
+ }
+
+ var inner = seen.concat([ value ]);
+ if (isArray) {
+ return "[" + _entries(value.length, function(idx) {
+ return _formatProp(_ownProp(value, String(idx)), depth + 1, inner);
+ }) + "]";
+ }
+
+ var keys = Object.keys(value);
+ return "{" + _entries(keys.length, function(idx) {
+ return JSON.stringify(keys[idx]) + ":" + _formatProp(_ownProp(value, keys[idx]), depth + 1, inner);
+ }) + "}";
+}
+
+/**
+ * A value as it reads in code: strings quoted, numbers, booleans, symbols and the like as written, and
+ * objects and arrays in JSON's shape. It runs nothing of the value's own -- no getter (an accessor
+ * reads [Getter]), no toJSON, no toString -- and never throws: it runs inside the assertion it
+ * describes, which must fail with its own error, and leave the test's values as they were.
+ */
+export function formatAssertionValue(value: any): string {
+ try {
+ return _limit(_format(value, 0, []));
+ } catch (e) {
+ // A revoked proxy, or one whose traps throw
+ return "[object]";
+ }
+}
+
+/**
+ * The expected and actual values a failed assertion's error carries, formatted for display, or `null`
+ * when it carries neither -- as with an error the code under test threw rather than an assertion. An
+ * error whose values are all `undefined` carries nothing worth showing either: `assert.fail()` has an
+ * `actual`, but it is never set.
+ */
+export function getAssertionValues(error: any): IAssertionValues | null {
+ try {
+ if (!error || typeof error !== "object") {
+ return null;
+ }
+
+ var hasExpected = "expected" in error;
+ var hasActual = "actual" in error;
+ if (!(hasExpected && error.expected !== undefined) && !(hasActual && error.actual !== undefined)) {
+ return null;
+ }
+
+ var values: IAssertionValues = {};
+ if (hasExpected) {
+ values.expected = formatAssertionValue(error.expected);
+ }
+
+ if (hasActual) {
+ values.actual = formatAssertionValue(error.actual);
+ }
+
+ return values;
+ } catch (e) {
+ // An error object of the test's own whose properties throw: there is still its message
+ return null;
+ }
+}
+
+/**
+ * Adds the values a failed assertion's `error` carries (see {@link getAssertionValues}) to the assert
+ * event the test frame or worker sends the page, and returns the event.
+ */
+export function addAssertionValues(event: T, error: any): T & IAssertionValues {
+ var target: any = event;
+ var values = getAssertionValues(error);
+ if (values && values.expected !== undefined) {
+ target.expected = values.expected;
+ }
+
+ if (values && values.actual !== undefined) {
+ target.actual = values.actual;
+ }
+
+ return target;
+}
diff --git a/internal/runner/src/shared/browserTheme.ts b/internal/runner/src/shared/browserTheme.ts
new file mode 100644
index 0000000..9325f3b
--- /dev/null
+++ b/internal/runner/src/shared/browserTheme.ts
@@ -0,0 +1,100 @@
+/*
+ * @nevware21/tripwire
+ * https://github.com/nevware21/tripwire
+ *
+ * Copyright (c) 2026 NevWare21 Solutions LLC
+ * Licensed under the MIT license.
+ */
+
+import { IBrowserUiTheme } from "../ux/types/BrowserUiTheme";
+
+/** The smallest and largest zoom the interactive page takes, as a whole percentage. */
+export const BROWSER_ZOOM_MIN = 50;
+export const BROWSER_ZOOM_MAX = 300;
+
+/** Which part of the theme each word names. */
+const _PARTS: { [word: string]: { part: keyof IBrowserUiTheme, value: string } } = {
+ "system": { part: "scheme", value: "system" },
+ "light": { part: "scheme", value: "light" },
+ "dark": { part: "scheme", value: "dark" },
+ "dim": { part: "scheme", value: "dim" },
+ "standard": { part: "vision", value: "standard" },
+ "colorblind": { part: "vision", value: "colorblind" }
+};
+
+const _PART_NAMES: { [part: string]: string } = {
+ scheme: "color scheme",
+ vision: "color vision setting",
+ zoom: "zoom"
+};
+
+const _ZOOM = /^(\d+)%$/;
+
+/** Every word a browser theme can be made of, besides a zoom, for an error message or the help text. */
+export const BROWSER_THEME_WORDS = Object.keys(_PARTS);
+
+/**
+ * Reads a browser theme -- `--browser-theme`'s value, or `browserTheme` from the config file -- as a
+ * comma separated list of words (or, from the config file, an array of them), each naming one part:
+ * a color scheme (`system`, `light`, `dark`, `dim`), a color vision setting (`standard`,
+ * `colorblind`) or a zoom, as a whole percentage from {@link BROWSER_ZOOM_MIN} to
+ * {@link BROWSER_ZOOM_MAX} (`125%`). Case and spaces are ignored. Returns an error for a word it
+ * does not know, a zoom out of range, or two words for the same part.
+ */
+export function parseBrowserTheme(value: string | string[]): { theme?: IBrowserUiTheme, error?: string } {
+ var words = (Array.isArray(value) ? value : String(value).split(","))
+ .map(function(word) {
+ return String(word).trim().toLowerCase();
+ })
+ .filter(function(word) {
+ return !!word;
+ });
+
+ var theme: any = {};
+ for (var lp = 0; lp < words.length; lp++) {
+ var found: { part: keyof IBrowserUiTheme, value: any } | null = _PARTS.hasOwnProperty(words[lp]) ? _PARTS[words[lp]] : null;
+ var zoom = _ZOOM.exec(words[lp]);
+ if (zoom) {
+ var percent = parseInt(zoom[1], 10);
+ if (percent < BROWSER_ZOOM_MIN || percent > BROWSER_ZOOM_MAX) {
+ return { error: "zoom \"" + words[lp] + "\" is outside " + BROWSER_ZOOM_MIN + "% to " + BROWSER_ZOOM_MAX + "%" };
+ }
+
+ found = { part: "zoom", value: percent };
+ }
+
+ if (!found) {
+ return {
+ error: "unknown word \"" + words[lp] + "\" (expected any of " + BROWSER_THEME_WORDS.join(", ") +
+ ", or a zoom from " + BROWSER_ZOOM_MIN + "% to " + BROWSER_ZOOM_MAX + "%)"
+ };
+ }
+
+ if (theme[found.part] !== undefined && theme[found.part] !== found.value) {
+ return { error: "\"" + _word(found.part, theme[found.part]) + "\" and \"" + _word(found.part, found.value) + "\" are both a " + _PART_NAMES[found.part] };
+ }
+
+ theme[found.part] = found.value;
+ }
+
+ return { theme: theme };
+}
+
+/** A part's value as it was written: a zoom with its "%". */
+function _word(part: keyof IBrowserUiTheme, value: any): string {
+ return part === "zoom" ? value + "%" : String(value);
+}
+
+/** `override`'s parts in place of `base`'s, and `base`'s for the parts `override` leaves out. */
+export function mergeBrowserThemes(base: IBrowserUiTheme | undefined, override: IBrowserUiTheme | undefined): IBrowserUiTheme {
+ var merged: IBrowserUiTheme = {};
+ [ base, override ].forEach(function(theme) {
+ if (theme) {
+ (Object.keys(theme) as Array).forEach(function(part) {
+ (merged as any)[part] = theme[part];
+ });
+ }
+ });
+
+ return merged;
+}
diff --git a/internal/runner/src/tripwire.ts b/internal/runner/src/tripwire.ts
index aac978c..e892880 100644
--- a/internal/runner/src/tripwire.ts
+++ b/internal/runner/src/tripwire.ts
@@ -147,6 +147,7 @@ function _printUsage() {
console.log(" --env Alias for --target");
console.log(" --browser-launch Browser launch mode for browser/worker targets (mode: puppeteer|open|manual; style: classic|slate)");
console.log(" --browser-style Browser UI style for interactive mode (default: classic)");
+ console.log(" --browser-theme How the interactive page opens: any of system|light|dark|dim, standard|colorblind, and a zoom from 50% to 300% (eg. 125%), comma separated");
console.log(" --ux-provider Custom browser UX provider module, overriding the built-in one");
console.log(" --open-browser Alias for --browser-launch open");
console.log(" --manual-browser Alias for --browser-launch manual");
diff --git a/internal/runner/src/ux/browser/resources/runner.css b/internal/runner/src/ux/browser/resources/runner.css
deleted file mode 100644
index ddad984..0000000
--- a/internal/runner/src/ux/browser/resources/runner.css
+++ /dev/null
@@ -1,39 +0,0 @@
-:root { --bg: #f3f4f6; --panel: #ffffff; --ink: #0f172a; --muted: #475569; --accent: #0b6bcb; --border: #d0d7de; --ok: #0f7b37; --warn: #946200; --bad: #b42318; }
-* { box-sizing: border-box; }
-body { margin: 0; padding: 24px; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(160deg, #f8fafc 0%, #e2e8f0 100%); color: var(--ink); }
-.shell { max-width: 1200px; margin: 0 auto; background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; box-shadow: 0 10px 26px rgba(15, 23, 42, 0.08); }
-h1 { margin: 0 0 10px; font-size: 1.3rem; letter-spacing: 0.02em; }
-p { margin: 0 0 12px; color: var(--muted); }
-.row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
-.controls { display: grid; grid-template-columns: 1fr 1fr auto auto auto auto; gap: 8px; margin-bottom: 12px; }
-.controls select, .controls button { min-height: 34px; }
-select { border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; background: #fff; }
-button { border: 1px solid var(--accent); background: var(--accent); color: #fff; border-radius: 8px; padding: 7px 12px; font-size: 0.95rem; cursor: pointer; }
-.ghost { background: #fff; color: var(--ink); border-color: var(--border); }
-.tiny-btn { padding: 4px 8px; font-size: 0.78rem; background: #fff; color: var(--ink); border: 1px solid var(--border); border-radius: 6px; }
-button:disabled { opacity: 0.7; cursor: default; }
-#status { font-weight: 600; color: #0f5132; }
-.layout { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
-.panel { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #fff; }
-.panel-head { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; background: #f8fafc; border-bottom: 1px solid var(--border); font-weight: 600; }
-#logs { background: #0b1020; color: #d8e4ff; min-height: 280px; max-height: 68vh; overflow: auto; font-family: Consolas, 'Courier New', monospace; font-size: 12px; line-height: 1.35; padding: 10px; }
-.log-line { white-space: pre-wrap; word-break: break-word; }
-.log-error { color: #ffd7d7; }
-.log-warn { color: #ffe7b3; }
-#results { min-height: 280px; max-height: 68vh; overflow: auto; padding: 8px; background: #fbfdff; }
-.test-card { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; background: #fff; }
-.test-card-header { display: flex; gap: 8px; align-items: center; padding: 8px 10px; border-bottom: 1px solid #eef2f6; }
-.test-title { flex: 1; font-size: 0.9rem; word-break: break-word; }
-.status-dot { width: 10px; height: 10px; border-radius: 999px; background: #94a3b8; }
-.status-running { background: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
-.status-passed { background: var(--ok); }
-.status-failed { background: var(--bad); }
-.status-skipped { background: var(--warn); }
-.assert-list { list-style: none; margin: 0; padding: 6px 10px 8px; font-family: Consolas, 'Courier New', monospace; font-size: 12px; }
-.assert-list li { padding: 2px 0; word-break: break-word; }
-.assert-pass { color: #0f7b37; }
-.assert-fail { color: #b42318; }
-@media (max-width: 980px) {
- .controls { grid-template-columns: 1fr 1fr; }
- .layout { grid-template-columns: 1fr; }
-}
diff --git a/internal/runner/src/ux/browser/resources/runner.html b/internal/runner/src/ux/browser/resources/runner.html
deleted file mode 100644
index 69294da..0000000
--- a/internal/runner/src/ux/browser/resources/runner.html
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
- __RUNNER_TITLE__
-
-
-
-
-
__RUNNER_TITLE__
-
__RUNNER_DESCRIPTION__
-
-
-
-
-
-
-
-
-
- Initializing...
-
-
-
-
Runner LogSuite/Test level on CLI
-
-
-
-
Assertions0 assertions
-
-
-
-
-
-
-
diff --git a/internal/runner/src/ux/browser/resources/styles/base.css b/internal/runner/src/ux/browser/resources/styles/base.css
index ad45f87..dede932 100644
--- a/internal/runner/src/ux/browser/resources/styles/base.css
+++ b/internal/runner/src/ux/browser/resources/styles/base.css
@@ -1,24 +1,113 @@
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-width: 320px; }
+/*
+ * The background is fixed to the viewport, so a page shorter or longer than the window is one
+ * gradient rather than a repeat with a seam where the first copy ends.
+ */
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.4;
color: var(--ink);
background: var(--bg);
+ background-attachment: fixed;
+ min-height: 100vh;
+}
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+/* Text for a screen reader only: what a glyph or a colour says to everyone else */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+/* The first thing Tab reaches: past the header and the controls, straight to the results */
+.skip-link {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ z-index: 40;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: var(--accent);
+ color: var(--on-accent);
+ transform: translateY(-200%);
+}
+.skip-link:focus {
+ transform: none;
+}
+.results-tree:focus {
+ outline: none;
+}
+/*
+ * The zoom chosen in the Appearance panel (the body's --ui-zoom, see ui/theme.ts) scales the page's
+ * own controls, results and log -- and nothing outside them, such as a test's own frame, so it is
+ * never the browser's zoom. Inside, a viewport unit is zoomed too: divide one by --ui-zoom to keep
+ * it to the window.
+ */
+.app-shell,
+.skip-link {
+ zoom: var(--ui-zoom, 1);
}
.app-shell {
width: 100%;
min-width: 320px;
padding: 14px;
}
+/*
+ * The title on the left, and the Target picker and the actions menu on the right: which target this
+ * page runs is part of what the page is, and the menu holds what is not part of a run, rather than
+ * either being one of the controls for a run.
+ */
+.app-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0 16px;
+}
+.app-title {
+ min-width: 0;
+}
+/* Kept on the right, as one group, when a narrow window wraps it under the title */
+.app-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: auto;
+}
.app-header h1 {
margin: 0;
font-size: 1.25rem;
}
.app-header p {
- margin: 6px 0 10px;
+ margin: 4px 0 8px;
color: var(--muted);
}
+.app-meta {
+ font-size: 0.85rem;
+}
+/*
+ * The controls and the status line stay at the top of the window while the results scroll under
+ * them, so Run, Stop and the run's totals are never a scroll away.
+ */
+.toolbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ margin: 0 -14px 10px;
+ padding: 8px 14px 0;
+ background: var(--toolbar-bg);
+ border-bottom: 1px solid var(--border-subtle);
+ backdrop-filter: blur(6px);
+}
.controls {
display: flex;
flex-direction: column;
@@ -75,7 +164,7 @@ body {
border-radius: 8px;
background: var(--panel);
color: var(--ink);
- padding: 6px 10px;
+ padding: 6px 32px 6px 10px;
min-height: 34px;
width: 100%;
}
@@ -92,13 +181,34 @@ body {
flex: 0 0 auto;
}
/*
- * Resets the Scope box back to "every test". Sits outside the input rather than as an "x" inside it
- * so that it says what it does -- the case it exists for is a scope arrived at by clicking a
- * result's "run again" button, where the question being asked is "how do I get back?".
+ * Resets the Scope box back to "every test": the x at the end of the box, as in a search field,
+ * named "Clear scope" for a screen reader and in its tooltip. It is only there while there is a
+ * scope to clear -- the case it exists for is a scope arrived at by clicking a result's "run again"
+ * button, where the question being asked is "how do I get back?".
*/
-.scope-clear {
- flex: 0 0 auto;
- white-space: nowrap;
+.filter-input-wrap .scope-clear {
+ position: absolute;
+ top: 50%;
+ right: 5px;
+ transform: translateY(-50%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ min-height: 0;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--muted);
+}
+.filter-input-wrap .scope-clear:hover {
+ background: var(--state-running-bg);
+ color: var(--ink);
+}
+.filter-input-wrap .scope-clear:disabled {
+ visibility: hidden;
}
.control-select {
border: 1px solid var(--border);
@@ -112,8 +222,8 @@ body {
opacity: 0.65;
}
/*
- * The "..." actions menu. Everything that is not "what do I run" and "run it" lives in here, so
- * the controls stay one row at any width rather than reflowing into two.
+ * The "..." actions menu, at the top right after the Target picker. Everything that is not "what do
+ * I run" and "run it" lives in here, along with the controls a narrow window folds away.
*/
.menu-wrap {
position: relative;
@@ -121,6 +231,7 @@ body {
}
button.icon-btn {
min-width: 34px;
+ min-height: 34px;
padding: 6px 9px;
font-size: 1.05rem;
line-height: 1;
@@ -137,6 +248,9 @@ button.icon-btn {
border-radius: 8px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.22);
z-index: 30;
+ /* Scrolls rather than running off the bottom of a short window */
+ max-height: calc(100vh / var(--ui-zoom, 1) - 80px);
+ overflow-y: auto;
}
/*
* The menu is shown and hidden with the `hidden` attribute so it leaves the accessibility tree
@@ -185,6 +299,99 @@ button.menu-item[aria-disabled="true"]:hover {
margin: 4px 2px;
background: var(--border);
}
+/* Sets the folded Target apart from the folded settings above it, when both are in the menu */
+#foldedControls .menu-check:not([hidden]) ~ .menu-group::before {
+ content: "";
+ display: block;
+ height: 1px;
+ margin: 4px 2px;
+ background: var(--border);
+}
+.menu-group-label {
+ padding: 4px 10px 2px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--muted);
+}
+/*
+ * Choices and settings -- the target and settings folded in from a narrow window, and Auto-reload:
+ * the one in use, or a setting that is on, carries a tick, and the others leave its space empty
+ */
+button.menu-radio,
+button.menu-check {
+ position: relative;
+ padding-left: 28px;
+}
+button.menu-radio[aria-checked="true"] {
+ font-weight: 600;
+}
+button.menu-radio[aria-checked="true"]::before,
+button.menu-check[aria-checked="true"]::before {
+ content: "✓";
+ position: absolute;
+ left: 10px;
+ color: var(--accent);
+}
+/*
+ * The Appearance panel (ui/appearance.ts): a dialog the actions menu opens, in the menu's place and
+ * drawn like it. The theme's list overlays the rows below it, so the panel itself never scrolls.
+ */
+.appearance-panel {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ z-index: 30;
+ display: grid;
+ gap: 10px;
+ width: 280px;
+ max-width: calc(100vw / var(--ui-zoom, 1) - 28px);
+ margin-top: 4px;
+ padding: 6px 12px 12px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.22);
+}
+.appearance-panel[hidden] {
+ display: none;
+}
+/* It takes focus itself only to keep a click on its background inside it, not to be seen */
+.appearance-panel:focus {
+ outline: none;
+}
+.appearance-panel .menu-group-label {
+ padding: 4px 0 0;
+}
+.appearance-field {
+ display: grid;
+ gap: 4px;
+}
+.appearance-panel .filter-input {
+ padding-right: 10px;
+}
+.appearance-check {
+ justify-self: start;
+}
+.zoom-control {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.zoom-value {
+ min-width: 3.5em;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+.zoom-reset {
+ margin-left: auto;
+}
+/* At either end of the zoom: still focusable, so a keyboard on it stays put (see ui/appearance.ts) */
+.zoom-control button[aria-disabled="true"] {
+ opacity: 0.5;
+ cursor: default;
+}
/*
* Custom-styled suggestion dropdown, deliberately not a native