Shad dba1eb3328
Some checks failed
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
feat(theme): promote industrial blueprint to the whole shell
Every scene now reads as the same FM doctrine: paper canvas + navy
frame + amber accent, 1px hairlines, square edges, monospace
uppercase labels, no glass, no shadows, no gradients.

WHAT CHANGED
- src/index.css rewritten end-to-end. Doctrinal hex tokens declared
  at :root, legacy --bg/--surface/--text/--primary/--border aliases
  repointed at doctrine values so existing component classes inherit
  the blueprint palette without per-component churn. Global
  * { border-radius: 0 } + box-shadow strip-out. All low-opacity
  tints via color-mix(in srgb, var(--bp-navy) X%, transparent) so no
  rgba() literals survive.
- Topbar redone as a 44px instrument strip: navy brand-lock with
  amber mark, uppercase mono tabs, amber-on-paper selected tab,
  square link buttons, mode pill with currentColor border.
- Mission Control hero + scenario tab strip + KPI cards retoken
  with amber underline on selected.
- Left rail: 1px-bordered KPI grid, queue cards stack as a single
  bordered list, agent supervision actions span the full width.
- Inspector: tabbed nav with amber selected, hairline-separated
  fields, square rule + run + evidence cards.
- Command palette: paper bg, amber-bordered selected item, mono
  caps headings.
- Live API console: paper drawer, mono call rows, amber filter chip,
  color-tokenised METHOD_COLOR map.
- Toaster: left-border accent on paper surface.
- Telemetry: navy gauges, mono row, square tick.
- Studio + Settings: shared studio-grid layout — paper panels in a
  1px navy grid with no margins between, mono inputs.
- Run History: mono table rows with amber selected filter chip.
- Landing: mono hero, square brand mark, stats strip as one
  bordered row, scenario cards in a 1px grid (no glow, no shadow,
  no gradient).
- Family accents in synthetic.ts and buildScenarios.ts retoken to
  doctrine hex. src/scenarios.json snapshot patched in place.
- Console.tsx METHOD_COLOR map → var(--bp-muted/info/amber/err).

AUDIT
- qa/palette_audit.mjs upgraded: scans 31 source files (.ts/.tsx/
  .json + index.css), catches rgb()/rgba()/hsl()/hsla() literals,
  and refuses named CSS colors (white/red/blue/...) as background/
  color/fill/stroke/border values. Hex regex uses (?![0-9a-fA-F])
  lookahead so deploy-id text like '#d3f1a' is not a false positive.
  Result: 0 non-doctrinal literals anywhere in src/.
- qa/smoke.mjs + qa/smoke_blueprint.mjs hasText matches converted
  to case-insensitive regex because the doctrine uppercases every
  user-visible label via text-transform: uppercase.
- qa/snap_all_scenes.mjs captures 9 fresh 1440x900 screenshots
  in qa/screenshots/v4/ (landing, mission procurement, mission AR
  blueprint, inspector raw, command palette, studio, settings,
  run history, mission live with console).

VERIFY
- tsc -b clean
- vite build green (CSS 41 KB / 8 KB gz, JS 851 KB / 230 KB gz)
- vitest 5 files / 24 tests green
- main smoke 27/27, 0 console errors
- blueprint smoke 15/15, 0 console errors
- palette audit clean (31 files)

ORACLE-REVIEWED
Round 1 PASS with <promise>VERIFIED</promise>. Two non-blocking
audit hardening notes landed in this same commit: scan JSON,
catch rgb/hsl/named CSS colors.

Confidence: high
Scope-risk: moderate (theme sweep, all scenes touched)
Not-tested: pixel-level visual diff vs prior theme (Oracle could
not inspect images this round; relied on programmatic checks)
2026-06-14 02:12:11 +04:00

193 lines
8.9 KiB
JavaScript

// Playwright smoke + screenshot capture against the running dev server.
// Covers: landing, MC procurement, MC blueprint (AR), inspector tabs,
// command palette, tour, run history, live-mode toggle, toast on preview action.
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const URL = process.env.URL || "http://127.0.0.1:5173";
const OUT = "qa/screenshots";
mkdirSync(OUT, { recursive: true });
const VP = { width: 1440, height: 900 };
function logOk(label, ok, detail = "") {
console.log(`${ok ? "✓" : "✗"} ${label}${detail ? " · " + detail : ""}`);
if (!ok) process.exitCode = 1;
}
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: VP, deviceScaleFactor: 1 });
const page = await ctx.newPage();
const errors = [];
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
page.on("console", (m) => { if (m.type() === "error") errors.push(`console.error: ${m.text()}`); });
await page.goto(URL, { waitUntil: "networkidle" });
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/01-landing.png` });
logOk("landing renders", await page.locator(".landing").isVisible());
logOk("hero title present", await page.locator(".hero-title").isVisible());
const cards = await page.locator(".sc-card").count();
logOk("scenario cards >= 7", cards >= 7, `count=${cards}`);
logOk("Go live button visible on landing", await page.getByRole("button", { name: /go live/i }).isVisible());
// → Mission Control via first card (live procurement)
await page.locator(".sc-card").first().click();
await page.waitForSelector(".mc");
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/02-mission-procurement.png` });
logOk("mission control loaded", await page.locator(".mc-strip").isVisible());
// Topbar mode pill is SNAPSHOT by default
const modeBefore = (await page.locator(".mode-toggle .mode-pill").innerText()).trim();
logOk("default mode is SNAPSHOT", modeBefore === "SNAPSHOT", `was=${modeBefore}`);
// Tab strip + graph
const tabCount = await page.locator(".mc-tab").count();
logOk("tab strip has >= 7 scenarios", tabCount >= 7, `count=${tabCount}`);
const reactFlowNodes = await page.locator(".bp-node").count();
logOk("graph rendered blueprint nodes > 0", reactFlowNodes > 0, `nodes=${reactFlowNodes}`);
// Switch to AR blueprint
await page.locator(".mc-tab", { hasText: /accounts receivable/i }).click();
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/03-mission-ar.png` });
logOk("AR scenario active", (await page.locator(".mc-hero-title").innerText()).toLowerCase().includes("refund"));
logOk("AR shows BLUEPRINT readout (not SYNTHETIC)", await page.locator(".bp-readout-blueprint .bp-readout-val", { hasText: /blueprint/i }).isVisible());
// Queue card → inspector update
const qCards = await page.locator(".qcard").count();
logOk("queue cards present", qCards > 0, `count=${qCards}`);
if (qCards > 0) {
await page.locator(".qcard").first().click();
await page.waitForTimeout(200);
}
// Inspector tab switching
await page.locator(".itab", { hasText: /evidence/i }).click();
await page.waitForTimeout(150);
logOk("evidence tab opens", await page.locator(".evt, .empty").first().isVisible());
await page.locator(".itab", { hasText: /^raw/i }).click();
await page.waitForTimeout(150);
logOk("raw tab opens", await page.locator(".raw-json").isVisible());
await page.screenshot({ path: `${OUT}/04-inspector-raw.png` });
// Preview-only action toast
await page.locator(".itab", { hasText: /overview/i }).click();
await page.waitForTimeout(150);
const approveBtn = page.locator(".i-actions .btn", { hasText: /approve/i }).first();
const approveCount = await approveBtn.count();
if (approveCount > 0) {
await approveBtn.click();
await page.waitForTimeout(300);
const toastTxt = (await page.locator(".toast-msg").first().innerText()).trim();
// After the real-mutations pass, the toast now names the LIVE-mode gate and the real endpoint.
logOk(
"action toast names live-mode gate + real endpoint",
/(LIVE mode|preview-only|\/api\/runtime\/transactions)/i.test(toastTxt),
`toast="${toastTxt.slice(0, 100)}"`,
);
} else {
logOk("approve button preview-marker present", await page.locator(".preview-marker").first().isVisible());
}
// Command palette
await page.keyboard.press("Meta+k");
await page.waitForSelector(".cmd", { state: "visible" });
await page.waitForTimeout(150);
await page.screenshot({ path: `${OUT}/05-command-palette.png` });
logOk("command palette opens with ⌘K", await page.locator(".cmd").isVisible());
logOk("Data mode group present in palette", await page.locator(".cmd").getByText(/data mode/i).isVisible());
// Studio command appears in palette (replaces the deprecated tour command)
await page.locator('.cmd [cmdk-input]').fill("studio");
await page.waitForTimeout(150);
logOk("studio command appears in palette", await page.locator(".cmd").getByText(/process studio/i).isVisible());
await page.keyboard.press("Escape");
await page.waitForTimeout(100);
// Studio scene renders + shows JSON preview + node editor
await page.locator(".tab", { hasText: /studio/i }).click();
await page.waitForSelector(".studio");
await page.waitForTimeout(250);
await page.screenshot({ path: `${OUT}/06-studio.png` });
logOk("studio scene renders", await page.locator(".studio-panel").count() >= 4);
logOk("studio JSON preview shows config payload", /\"nodes\"/.test(await page.locator(".raw-json").first().innerText()));
await page.locator(".tab", { hasText: /mission/i }).click();
await page.waitForSelector(".bp-frame");
// Live-mode toggle (real network call to demo.flow-master.ai via vite proxy)
const networkCalls = [];
page.on("request", (req) => {
if (req.url().includes("/api/ea2/work-items")) networkCalls.push(req.url());
});
const toggleBtn = page.locator(".mode-toggle").first();
await toggleBtn.click();
const liveOk = await page.waitForFunction(
() => Array.from(document.querySelectorAll(".mode-pill")).some((el) => el.textContent?.trim() === "LIVE"),
{ timeout: 12000 },
).then(() => true).catch(() => false);
logOk("toggle to LIVE mode resolves and updates pill", liveOk);
if (liveOk) {
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/09-live-mode.png` });
logOk("Refresh button appears in live mode", await page.locator(".link-btn", { hasText: /refresh/i }).isVisible());
// Refresh must trigger ANOTHER /api/ea2/work-items request (Oracle round 2 fix).
// Wait for the Refresh button to become enabled (initial fetch may still be settling).
await page.locator(".link-btn", { hasText: /refresh/i }).waitFor({ state: "visible" });
await page.waitForFunction(
() => {
const btns = Array.from(document.querySelectorAll(".link-btn"));
const btn = btns.find((b) => /refresh/i.test(b.textContent || ""));
return btn && !btn.disabled;
},
{ timeout: 10000 },
);
const before = networkCalls.length;
await page.locator(".link-btn", { hasText: /refresh/i }).click();
await page.waitForTimeout(2500);
logOk("Refresh re-fetches /api/ea2/work-items", networkCalls.length > before,
`before=${before} after=${networkCalls.length}`);
}
// LeftRail Confirm button: toast must name /api/runtime/transactions/{id}/actions
await page.locator(".mc-tab", { hasText: /accounts receivable/i }).click();
await page.waitForTimeout(400);
const confirmBtn = page.locator(".agent-acts .btn-primary").first();
if (await confirmBtn.count() > 0) {
await confirmBtn.click();
await page.waitForTimeout(300);
const lastToast = (await page.locator(".toast-msg").last().innerText()).trim();
logOk("LeftRail Confirm toast names /api/runtime/transactions endpoint",
/\/api\/runtime\/transactions\/\{id\}\/actions/.test(lastToast),
`toast="${lastToast.slice(0, 80)}"`);
}
// Telemetry honesty (check before leaving MC because RH has no telemetry strip)
const tel = await page.locator(".telemetry").innerText();
logOk("telemetry has no hardcoded 97.4%", !/97\.4%/.test(tel), `tel="${tel.replace(/\s+/g, " ").slice(0, 80)}"`);
logOk("telemetry labels SLA as derived", /derived/i.test(tel));
// Run history scene
await page.locator(".tab", { hasText: /runs/i }).click();
await page.waitForSelector(".rh");
await page.waitForTimeout(250);
await page.screenshot({ path: `${OUT}/07-run-history.png` });
const rhRows = await page.locator(".rh-row").count();
logOk("run-history rows present", rhRows > 0, `rows=${rhRows}`);
await page.locator(".rh-chip", { hasText: /^running/i }).click();
await page.waitForTimeout(200);
const rhRunning = await page.locator(".rh-row").count();
logOk("run-history filter shrinks list", rhRunning > 0 && rhRunning <= rhRows, `running=${rhRunning} of ${rhRows}`);
console.log(`\nconsole errors: ${errors.length}`);
for (const e of errors.slice(0, 8)) console.log(" -", e);
if (errors.length > 0) process.exitCode = 1;
await browser.close();
const fs = await import("node:fs/promises");
const list = await fs.readdir(OUT);
console.log(`\n${OUT}/ has ${list.length} screenshots: ${list.sort().join(", ")}`);