diff --git a/.github/workflows/dashapp-perf.yml b/.github/workflows/dashapp-perf.yml
new file mode 100644
index 0000000..0b3987a
--- /dev/null
+++ b/.github/workflows/dashapp-perf.yml
@@ -0,0 +1,32 @@
+name: dashapp-perf
+on:
+ pull_request:
+ paths:
+ - 'dash-apps/**'
+ - '.github/workflows/dashapp-perf.yml'
+permissions:
+ contents: read
+jobs:
+ gate:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { app: web-retro, root: '' }
+ - { app: web-analog, root: '' }
+ - { app: web-ambient, root: '' }
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: dash-apps/_perf-gate/package.json
+ - run: npm ci
+ working-directory: dash-apps/_perf-gate
+ - run: node gate.js "../${{ matrix.app }}${{ matrix.root && format('/{0}', matrix.root) || '' }}" --name "${{ matrix.app }}"
+ working-directory: dash-apps/_perf-gate
+ env:
+ GITHUB_STEP_SUMMARY: ${{ github.step_summary }}
diff --git a/dash-apps/README.md b/dash-apps/README.md
index 79344dc..e5263d8 100644
--- a/dash-apps/README.md
+++ b/dash-apps/README.md
@@ -2,3 +2,63 @@
Dash apps are the sandboxed applications running inside the Dashpilot android app.
They can be either web apps running in the `WebDashView` Composable, or Rive apps running in `RiveDashView`.
+
+## Performance requirements
+
+Dash-apps render live driving data - even one second of stale speed is unacceptable. Web
+dash-apps are benchmarked by an automated performance gate before merging.
+
+**Which apps are gated** is declared in the CI matrix at
+[`.github/workflows/dashapp-perf.yml`](../.github/workflows/dashapp-perf.yml) — the matrix
+is the single source of truth. Currently gated: web-retro, web-analog, web-ambient. Not
+gated: web-vanilla (WASM/Bevy 3D renderer — invisible to the jsdom tripwire) and web-expo
+(React Native's bridge — not the `window.onCarStateUpdate` contract the gate replays). Adding
+a new dash-app to the matrix is a one-line edit in the workflow file.
+
+**The gate:** your app must define `window.onCarStateUpdate` and read values from `window.NativeCarState`. CI replays a deterministic 6 s drive (150 frames at 25 Hz) in
+jsdom (no browser) and enforces one per-tick budget documented inline in
+[`_perf-gate/gate.js`](./_perf-gate/gate.js). The gate fails when a budget is broken **three
+ticks in a row** (after a 2-second warmup):
+
+| budget | default | catches |
+|---|---|---|
+| child updates per parent | `max 6` | per-child update loops — apps that write attributes/text to many siblings sharing one parent every tick (the actual web-retro regression: `tape.cells.forEach(c => c.className = next)` = 8 hits on one parent); in-place single-element updates cost 1-2 |
+
+The gate is a regression **tripwire**, not a device-fidelity measurement: it budgets the
+DOM-mutation churn of each `onCarStateUpdate` tick in Node. Layout,
+paint, and canvas work are invisible — a canvas- or CSS-animation-heavy regression will
+pass, and the budget is an absolute jsdom record count, not head-unit time. The verdict is a
+consecutive-streak (3-in-a-row) check on per-parent mutation concentration, so a single isolated stall
+(one multi-hundred-mutation tick in an otherwise-clean run) does not fail the gate —
+deliberately no per-tick max, because a lone tick preempted by a busy CI runner would make
+the gate flaky. The streak is stricter than a p95 on clustered regressions (three bad
+ticks back-to-back trips immediately) and looser on scattered spikes. It
+exists to catch the failure mode that actually occurred: per-child update loops rewriting attributes on every sibling, every tick (e.g. web-retro's blind-spot tape).
+
+**What this gate does not catch:** a wholesale `el.innerHTML = "..."` rebuild fires ONE batched MutationObserver record regardless of child count, so neither per-parent concentration nor a total record count sees it. If a wholesale-rebuild regression ever occurs, count `addedNodes.length + removedNodes.length` inside childList records — out of scope until it shows up. Also invisible: synchronous CPU cost (the previous CPU budget was removed — only a synthetic fixture ever tripped it).
+
+`window.NativeCarState` is injected as a `Proxy` whose methods read a shared state object
+the gate mutates per tick. State keys are derived from method name by convention
+(`getEgoSpeed`→`egoSpeed`, `isAdasOn`→`adasOn`); the one exception (`isImperial`→`useImperial`)
+lives in `gate.js`. Apps reading an unknown method get `undefined`, so their own `readNative`
+fallback runs. No getter list to keep in sync with `CarStateBridge.kt` — the gate drifts with
+the Kotlin naming convention automatically.
+
+## Run the gate locally
+
+```bash
+cd dash-apps/_perf-gate
+npm ci
+node gate.js ../web-yourapp
+```
+
+> Exit codes: `0` PASS, `1` budget violation (a `why:` line names the failing metric),
+> `2` APP_ERROR (missing `window.onCarStateUpdate` contract within 10 s, or an uncaught
+> error the app tried to swallow), `3` INFRA (bad args / no `index.html`).
+
+## Tuning map
+
+- Change pass/fail thresholds: edit the `BUDGET_PER_PARENT` constant near the top of [`_perf-gate/gate.js`](./_perf-gate/gate.js) (it has a rationale comment).
+- Change the simulated drive data: edit `buildScenario()` in [`_perf-gate/gate.js`](./_perf-gate/gate.js).
+- Change `NativeCarState` injection or the key-derivation special cases: edit `keyFromMethod()` / `beforeParse` in [`_perf-gate/gate.js`](./_perf-gate/gate.js).
+- Change the CI matrix (which apps run on every PR): edit `matrix.include` in [`.github/workflows/dashapp-perf.yml`](../.github/workflows/dashapp-perf.yml).
diff --git a/dash-apps/_perf-gate/.gitignore b/dash-apps/_perf-gate/.gitignore
new file mode 100644
index 0000000..3c3629e
--- /dev/null
+++ b/dash-apps/_perf-gate/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/dash-apps/_perf-gate/fixtures/churn-app/index.html b/dash-apps/_perf-gate/fixtures/churn-app/index.html
new file mode 100644
index 0000000..16a4393
--- /dev/null
+++ b/dash-apps/_perf-gate/fixtures/churn-app/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/dash-apps/_perf-gate/fixtures/fast-app/index.html b/dash-apps/_perf-gate/fixtures/fast-app/index.html
new file mode 100644
index 0000000..d1264f0
--- /dev/null
+++ b/dash-apps/_perf-gate/fixtures/fast-app/index.html
@@ -0,0 +1,13 @@
+
+
+ 0
+
+
+
diff --git a/dash-apps/_perf-gate/gate.js b/dash-apps/_perf-gate/gate.js
new file mode 100644
index 0000000..0b4a9ac
--- /dev/null
+++ b/dash-apps/_perf-gate/gate.js
@@ -0,0 +1,114 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const { parseArgs } = require("node:util");
+const { JSDOM } = require("jsdom");
+
+// 25 Hz drive cadence; 150 frames = 6 s wall, fast for CI; first 50 (2 s) are warmup.
+const STEP_MS = 40, TOTAL_FRAMES = 150, WARMUP_FRAMES = 50;
+// Per-parent concentration: 8 sibling attribute writes (per-cell loop, the web-retro regression) = 8 attributed to one parent. in-place single-element updates = 1-2. Threshold 6 sits between.
+const BUDGET_PER_PARENT = 6, STREAK = 3;
+const CONTRACT_TIMEOUT_MS = 10000;
+
+// derives key by convention; add a special case here only when Kotlin breaks the get/is naming pattern.
+const keyFromMethod = (m) => m === "isImperial" ? "useImperial" : m.startsWith("get") ? m[3].toLowerCase() + m.slice(4) : m.startsWith("is") ? m[2].toLowerCase() + m.slice(3) : null;
+
+// minimal scenario — oscillate speed, toggle blinkers, flip ADAS once. Render path runs every tick regardless of input variety; full signal sweep is not needed for a tripwire.
+const buildScenario = () => Array.from({ length: TOTAL_FRAMES }, (_, i) => {
+ const t = (i * STEP_MS) / 1000;
+ return {
+ egoSpeed: 60 * (1 - Math.cos(2 * Math.PI * t / 13)),
+ leftBlinker: t % 4 < 1 ? 1 : 0, rightBlinker: (t + 2) % 4 < 1 ? 1 : 0,
+ gear: 4, adasOn: t >= 4 ? 1 : 0, madsActive: t >= 5 && t < 10 ? 1 : 0,
+ leftBlindSpot: t % 5 < 1 ? 1 : 0, rightBlindSpot: (t + 2.5) % 5 < 1 ? 1 : 0,
+ accSetSpeed: t >= 4 ? (Math.floor((t - 4) / 3) % 2 === 0 ? 90 : 110) : 0,
+ fusedSpeedLimit: t < 8 ? 50 : 100, useImperial: 0, darkMode: 1,
+ laneDepartureWarning: t % 7 < 0.2 ? 1 : 0,
+ };
+});
+
+// inline ; widen if a future app uses type="module" or conditional loaders.
+function inlineScripts(html, appDir) {
+ return html.replace(/"; }
+ catch { return match; }
+ });
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function gate(appDir) {
+ const html = inlineScripts(fs.readFileSync(path.join(appDir, "index.html"), "utf8"), appDir);
+ const state = { ...buildScenario()[0] };
+ const errors = [];
+
+ const dom = new JSDOM(html, {
+ runScripts: "dangerously", pretendToBeVisual: true,
+ beforeParse(window) {
+ // Proxy NativeCarState: every method call returns current state[derivedKey]; unknown methods return undefined so app readNative fallback runs.
+ window.NativeCarState = new Proxy({}, { get(_t, p) { const k = keyFromMethod(p); return k === null ? undefined : () => state[k]; } });
+ if (!window.matchMedia) window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} });
+ // jsdom fires 'error' even when app's window.onerror returns true (swallowed-crash detection).
+ window.addEventListener("error", (e) => errors.push("uncaught error: " + ((e && e.error && e.error.message) || (e && e.message) || "unknown error")));
+ },
+ });
+
+ try {
+ const deadline = Date.now() + CONTRACT_TIMEOUT_MS;
+ while (typeof dom.window.onCarStateUpdate !== "function") {
+ if (errors.length) return { error: errors[0] };
+ if (Date.now() > deadline) return { error: "contract timeout" };
+ await sleep(100);
+ }
+
+ const observer = new dom.window.MutationObserver(() => {});
+ observer.observe(dom.window.document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
+ observer.takeRecords();
+
+ let streak = 0, worstPerParent = 0, fail = null;
+ const wallStart = Date.now();
+ for (let i = 0; i < TOTAL_FRAMES; i++) {
+ Object.assign(state, buildScenario()[i]);
+ try { dom.window.onCarStateUpdate(); } catch (e) { errors.unshift("onCarStateUpdate threw: " + (e.message || e)); break; }
+ const records = observer.takeRecords();
+ if (i < WARMUP_FRAMES) continue;
+ const byParent = new Map();
+ for (const r of records) {
+ const p = r.target.parentNode;
+ const key = p && p.tagName ? (p.id ? "#" + p.id : p.className ? "." + p.className.split(" ")[0] : p.tagName.toLowerCase()) : "";
+ byParent.set(key, (byParent.get(key) || 0) + 1);
+ }
+ let maxPerParent = 0, hotParent = "";
+ for (const [k, n] of byParent) if (n > maxPerParent) { maxPerParent = n; hotParent = k; }
+ if (maxPerParent > worstPerParent) worstPerParent = maxPerParent;
+ streak = maxPerParent >= BUDGET_PER_PARENT ? streak + 1 : 0;
+ if (streak >= STREAK) { fail = "may cause dashboard lag (driver sees stale speed/blinkers)\n " + hotParent + ": " + maxPerParent + " child updates per frame (max " + BUDGET_PER_PARENT + ")\n Fix: update " + hotParent + " once instead of each child"; break; }
+ await sleep(Math.max(0, (i + 1) * STEP_MS - (Date.now() - wallStart)));
+ }
+ if (errors.length) return { error: errors[0] };
+ if (fail) return { fail };
+ return { pass: true, worstPerParent };
+ } finally {
+ dom.window.close();
+ }
+}
+
+async function main() {
+ const { values, positionals } = parseArgs({ args: process.argv.slice(2), options: { name: { type: "string" } }, allowPositionals: true });
+ if (positionals.length !== 1) { console.error("usage: node gate.js [--name ]"); process.exit(3); }
+ const appDir = path.resolve(positionals[0]);
+ if (!fs.existsSync(path.join(appDir, "index.html"))) { console.error("infra: no index.html in " + appDir); process.exit(3); }
+ // derive display name from dir; if basename is 'dist' (web-expo), use the parent dir name.
+ const appName = values.name || (path.basename(appDir) === "dist" ? path.basename(path.dirname(appDir)) : path.basename(appDir));
+ const r = await gate(appDir);
+ if (r.error) { console.error("APP_ERROR " + appName + ": " + r.error); process.exit(2); }
+ const line = r.fail
+ ? "FAIL " + appName + " — " + r.fail
+ : "PASS " + appName + " maxPerParent=" + r.worstPerParent + " budget=" + BUDGET_PER_PARENT + " streak<" + STREAK;
+ console.log(line);
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, line + "\n");
+ process.exit(r.fail ? 1 : 0);
+}
+
+main().catch((e) => { console.error("infra:", e.stack || e); process.exit(3); });
diff --git a/dash-apps/_perf-gate/package-lock.json b/dash-apps/_perf-gate/package-lock.json
new file mode 100644
index 0000000..54fb368
--- /dev/null
+++ b/dash-apps/_perf-gate/package-lock.json
@@ -0,0 +1,519 @@
+{
+ "name": "dashpilot-perf-gate",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dashpilot-perf-gate",
+ "dependencies": {
+ "jsdom": "^29.1.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "license": "MIT"
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
+ "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.0",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
+ "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "license": "MIT"
+ },
+ "node_modules/tldts": {
+ "version": "7.4.9",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
+ "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.9"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.9",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
+ "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/dash-apps/_perf-gate/package.json b/dash-apps/_perf-gate/package.json
new file mode 100644
index 0000000..dd6aa4c
--- /dev/null
+++ b/dash-apps/_perf-gate/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "dashpilot-perf-gate",
+ "private": true,
+ "description": "Performance gate for dash-apps. See dash-apps/README.md.",
+ "engines": { "node": ">=20" },
+ "scripts": { "test": "node --test" },
+ "dependencies": { "jsdom": "^29.1.1" }
+}
diff --git a/dash-apps/_perf-gate/test/gate.test.js b/dash-apps/_perf-gate/test/gate.test.js
new file mode 100644
index 0000000..8548a9b
--- /dev/null
+++ b/dash-apps/_perf-gate/test/gate.test.js
@@ -0,0 +1,46 @@
+"use strict";
+
+const { describe, it } = require("node:test");
+const assert = require("node:assert/strict");
+const { spawnSync } = require("node:child_process");
+const fs = require("node:fs");
+const path = require("node:path");
+
+const gateDir = path.resolve(__dirname, "..");
+
+describe("perf gate", () => {
+ it("fast-app passes", () => {
+ const r = spawnSync("node", ["gate.js", "fixtures/fast-app", "--name", "fast"], {
+ cwd: gateDir,
+ encoding: "utf-8",
+ });
+ assert.strictEqual(r.status, 0, "exit code 0, stderr: " + r.stderr);
+ assert.ok(r.stdout.startsWith("PASS"), "stdout starts with PASS: " + r.stdout);
+ });
+
+ it("churn-app FAILS with clear message", () => {
+ const r = spawnSync("node", ["gate.js", "fixtures/churn-app", "--name", "churn"], {
+ cwd: gateDir,
+ encoding: "utf-8",
+ });
+ assert.strictEqual(r.status, 1, "exit code 1, stdout: " + r.stdout);
+ assert.ok(r.stdout.startsWith("FAIL"), "stdout starts with FAIL: " + r.stdout);
+ assert.ok(r.stdout.includes("may cause dashboard lag"), "stdout mentions user impact: " + r.stdout);
+ assert.ok(r.stdout.includes("child updates per frame"), "stdout mentions child updates per frame: " + r.stdout);
+ assert.ok(r.stdout.includes("Fix: update #tape"), "stdout has fix hint with element name: " + r.stdout);
+ });
+
+ it("no-contract APP_ERROR", () => {
+ const tmpDir = fs.mkdtempSync("perf-gate-no-contract-");
+ try {
+ fs.writeFileSync(path.join(tmpDir, "index.html"), "");
+ const r = spawnSync("node", ["gate.js", tmpDir, "--name", "no-contract"], {
+ cwd: gateDir,
+ encoding: "utf-8",
+ });
+ assert.strictEqual(r.status, 2, "exit code 2, stderr: " + r.stderr + ", stdout: " + r.stdout);
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
+});