From 2edba020ef13488e2b53a950b146d73fbb167173 Mon Sep 17 00:00:00 2001 From: Shad Date: Sun, 14 Jun 2026 01:43:00 +0400 Subject: [PATCH] feat(canvas): reimagine ProcessGraph as FM industrial blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the actual FM06/flow-master-design-philosophy doctrine (DESIGN_PHILOSOPHY.md + SYNTHESIS.md + IMPLEMENTATION_STANDARD.md + ADR 0001/0002) and rebuilds the canvas to match: 'operations cockpit', 'industrial, instrumented, accountable'. Light paper canvas + navy frame + amber accent + 1px rules + square edges + monospace operational labels. WHAT CHANGED - ProcessGraph.tsx rewritten: square 1px navy nodes, mono uppercase labels, orthogonal step edges (ReactFlow type:'step' → MLHV only), two-layer Background (8px minor + 64px major navy hairline grid), doctrinal palette tokens via var(--bp-*). - BlueprintFrame.tsx (new): top instrument readout strip (DEF / VERSION / HUB / NODES / EDGES / SRC / MODE / TX), top + left rulers with 8/64px ticks, navy corner glyph at origin, bottom status legend. - index.css: scoped [data-canvas="blueprint"] block (~280 lines) declaring 11 doctrinal hex tokens once; opacity derivatives go through CSS color-mix(in srgb, var(--bp-navy) 13%, transparent) not raw rgba(). No box-shadow on selected node (outline instead). - LeftRail.tsx: gate toast now names the real endpoint (POST /api/runtime/transactions/{id}/actions/{submit,save_draft}) on the unsigned-in path too, restoring the convention from the prior real-mutations pass. - qa/smoke.mjs updated for the new selectors (.bp-node, .bp-readout-blueprint). Old guided-tour assertions replaced with Studio scene assertions. 27/27 PASS. - qa/smoke_blueprint.mjs (new): 15 assertions covering S1–S3 + S5. - qa/palette_audit.mjs (new): three checks — doctrinal CSS hex, no raw rgba() in blueprint scope, no hardcoded color literals in blueprint TSX. All pass. ORACLE-REVIEWED Round 1 FAIL: hardcoded TSX color literals + box-shadow + rgba + narrow palette audit. Round 2 PASS after fixing all four. CONTRACT EVIDENCE - vitest: 5 files, 24 tests, all green - main smoke: 27/27, 0 console errors - blueprint smoke: 15/15, 0 console errors - palette audit: 11 CSS doctrinal hex tokens, 0 raw rgba, 2 TSX files clean - vite build: green Confidence: high Scope-risk: narrow (scoped [data-canvas="blueprint"]) Not-tested: pixel-level visual diff (Oracle could not inspect images this round; relied on programmatic DOM + path-command assertions) --- qa/palette_audit.mjs | 56 ++++++ qa/smoke.mjs | 37 ++-- qa/smoke_blueprint.mjs | 83 +++++++++ src/components/BlueprintFrame.tsx | 107 +++++++++++ src/components/LeftRail.tsx | 10 +- src/components/ProcessGraph.tsx | 142 +++++++++------ src/index.css | 285 ++++++++++++++++++++++++++++++ 7 files changed, 646 insertions(+), 74 deletions(-) create mode 100644 qa/palette_audit.mjs create mode 100644 qa/smoke_blueprint.mjs create mode 100644 src/components/BlueprintFrame.tsx diff --git a/qa/palette_audit.mjs b/qa/palette_audit.mjs new file mode 100644 index 0000000..1dfdfa0 --- /dev/null +++ b/qa/palette_audit.mjs @@ -0,0 +1,56 @@ +// Doctrine palette audit (S6). +// 1. Every hex color inside the INDUSTRIAL BLUEPRINT CANVAS CSS section +// must be one of the doctrinal tokens. +// 2. Blueprint TSX files must not introduce hardcoded hex/rgb literals — +// they should reference --bp-* tokens via var(...). +import { readFileSync } from "node:fs"; + +const DOCTRINE = new Set([ + "#f5f7fb", "#e8edf5", "#d5dde9", + "#1a2740", "#243453", + "#4a5b80", "#7a8aa8", + "#c46a14", "#3d6a2c", "#a6342a", "#1d6f82", +]); + +function fail(msg) { console.log("✗", msg); process.exitCode = 1; } +function pass(msg) { console.log("✓", msg); } + +const css = readFileSync("src/index.css", "utf8"); +const idx = css.indexOf("INDUSTRIAL BLUEPRINT CANVAS"); +if (idx < 0) { fail("blueprint CSS block not found"); process.exit(1); } +const cssBlock = css.slice(idx); +const cssHexes = cssBlock.match(/#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}/g) || []; +const cssOffending = cssHexes.filter((h) => !DOCTRINE.has(h.toLowerCase())); +if (cssOffending.length === 0) { + pass(`CSS palette: ${cssHexes.length} hex tokens, all doctrinal`); +} else { + fail(`CSS non-doctrinal hex tokens: ${cssOffending.join(", ")}`); +} + +const cssRgba = cssBlock.match(/rgba?\([^)]*\)/g) || []; +if (cssRgba.length === 0) { + pass("CSS has no raw rgba() literals (alpha goes through color-mix tokens)"); +} else { + fail(`CSS raw rgba() literals: ${cssRgba.join(", ")}`); +} + +const tsxFiles = [ + "src/components/ProcessGraph.tsx", + "src/components/BlueprintFrame.tsx", +]; +let tsxOffending = 0; +for (const f of tsxFiles) { + const src = readFileSync(f, "utf8"); + const hex = (src.match(/#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}/g) || []).filter( + (h) => !DOCTRINE.has(h.toLowerCase()), + ); + const rgba = src.match(/rgba?\(\s*\d+/g) || []; + if (hex.length || rgba.length) { + fail(`${f}: hardcoded color literals: hex=${hex.join(",") || "none"} rgba=${rgba.join(",") || "none"}`); + tsxOffending += hex.length + rgba.length; + } +} +if (tsxOffending === 0) pass(`TSX palette: ${tsxFiles.length} blueprint TSX files clean (no hardcoded color literals)`); + +if (process.exitCode === 1) console.log("\nPalette audit FAILED"); +else console.log("\nPalette audit PASSED"); diff --git a/qa/smoke.mjs b/qa/smoke.mjs index 3fa5859..8a4e46a 100644 --- a/qa/smoke.mjs +++ b/qa/smoke.mjs @@ -46,15 +46,15 @@ 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(".node").count(); -logOk("graph rendered nodes > 0", reactFlowNodes > 0, `nodes=${reactFlowNodes}`); +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" }).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 badge (not SYNTHETIC)", await page.locator(".graph-overlay .tag-syn", { hasText: "BLUEPRINT" }).isVisible()); +logOk("AR shows BLUEPRINT readout (not SYNTHETIC)", await page.locator(".bp-readout-blueprint .bp-readout-val", { hasText: "BLUEPRINT" }).isVisible()); // Queue card → inspector update const qCards = await page.locator(".qcard").count(); @@ -82,7 +82,12 @@ if (approveCount > 0) { await approveBtn.click(); await page.waitForTimeout(300); const toastTxt = (await page.locator(".toast-msg").first().innerText()).trim(); - logOk("preview action fires toast", /preview[- ]only/i.test(toastTxt), `toast="${toastTxt.slice(0, 80)}"`); + // 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()); } @@ -95,24 +100,22 @@ 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()); -await page.locator('.cmd [cmdk-input]').fill("tour"); +// Studio command appears in palette (replaces the deprecated tour command) +await page.locator('.cmd [cmdk-input]').fill("studio"); await page.waitForTimeout(150); -logOk("tour command appears", await page.locator(".cmd").getByText(/start guided tour/i).isVisible()); +logOk("studio command appears in palette", await page.locator(".cmd").getByText(/process studio/i).isVisible()); await page.keyboard.press("Escape"); await page.waitForTimeout(100); -// Start tour -await page.locator(".link-btn", { hasText: "Tour" }).click(); -await page.waitForSelector(".tour-card"); +// Studio scene renders + shows JSON preview + node editor +await page.locator(".tab", { hasText: "Studio" }).click(); +await page.waitForSelector(".studio"); await page.waitForTimeout(250); -await page.screenshot({ path: `${OUT}/06-tour.png` }); -logOk("tour card renders", await page.locator(".tour-card").isVisible()); -const firstTourCombined = ((await page.locator(".tour-title").first().innerText()) + " " + (await page.locator(".tour-body").first().innerText())).toLowerCase(); -logOk("AR tour uses positive 'industry blueprint' framing", /industry blueprint/i.test(firstTourCombined), `text~="${firstTourCombined.slice(0, 100)}"`); -await page.keyboard.press("ArrowRight"); -await page.waitForTimeout(450); -logOk("tour advances", (await page.locator(".tour-title").first().innerText()).length > 0); -await page.keyboard.press("Escape"); +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" }).click(); +await page.waitForSelector(".bp-frame"); // Live-mode toggle (real network call to demo.flow-master.ai via vite proxy) const networkCalls = []; diff --git a/qa/smoke_blueprint.mjs b/qa/smoke_blueprint.mjs new file mode 100644 index 0000000..b14c4ee --- /dev/null +++ b/qa/smoke_blueprint.mjs @@ -0,0 +1,83 @@ +// Blueprint canvas smoke covering S1, S2, S3 from the canvas-reimagine +// scenario contract. Run after `vite dev` is up at 127.0.0.1:5173. +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 }); + +function ok(label, cond, detail = "") { + console.log(`${cond ? "✓" : "✗"} ${label}${detail ? " · " + detail : ""}`); + if (!cond) process.exitCode = 1; +} + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); +const p = await ctx.newPage(); +const errors = []; +p.on("pageerror", (e) => errors.push(`pageerror ${e.message}`)); +p.on("console", (m) => { if (m.type() === "error") errors.push(`console.error ${m.text().slice(0, 200)}`); }); + +await p.goto(URL, { waitUntil: "networkidle" }); +await p.locator(".sc-card").first().click(); +await p.waitForSelector(".mc"); +await p.waitForTimeout(800); +await p.screenshot({ path: `${OUT}/blueprint-canvas.png`, fullPage: false }); + +// S1: paper background + navy hairline grid + square nodes +const frame = p.locator(".bp-frame").first(); +ok("S1.a blueprint frame mounted", await frame.isVisible()); +const nodeStyle = await p.locator(".bp-node").first().evaluate((el) => { + const cs = getComputedStyle(el); + return { + radius: cs.borderTopLeftRadius, + borderColor: cs.borderTopColor, + fontFamily: cs.fontFamily, + }; +}); +ok("S1.b node has zero border radius", nodeStyle.radius === "0px", `was ${nodeStyle.radius}`); +ok("S1.c node uses monospace font", /mono|Fira Code/i.test(nodeStyle.fontFamily), `font=${nodeStyle.fontFamily}`); +const canvasBg = await p.locator(".bp-canvas-wrap").first().evaluate((el) => getComputedStyle(el).backgroundColor); +ok("S1.d canvas background is paper #f5f7fb", canvasBg === "rgb(245, 247, 251)", `was ${canvasBg}`); +const gridLines = await p.locator(".react-flow__background").count(); +ok("S1.e two grid backgrounds rendered (8px minor + 64px major)", gridLines >= 2, `count=${gridLines}`); + +// S2: edges use step (orthogonal) — path commands are only M/L/H/V +const pathD = await p.locator(".react-flow__edge-path").first().evaluate((el) => el.getAttribute("d") || ""); +const hasCurve = /[CQcq]/.test(pathD); +ok("S2 edges are orthogonal (no curve commands)", !hasCurve, `d=${pathD.slice(0, 60)}…`); + +// S3: ruler chrome + corner readout +ok("S3.a top ruler present", await p.locator(".bp-ruler-top").isVisible()); +ok("S3.b left ruler present", await p.locator(".bp-ruler-left").isVisible()); +const tickCount = await p.locator(".bp-tick.is-major").count(); +ok("S3.c major ticks rendered (>= 4)", tickCount >= 4, `major ticks=${tickCount}`); +ok("S3.d corner glyph present", await p.locator(".bp-corner-tl").isVisible()); +const readouts = await p.locator(".bp-readout-top .bp-readout-cell").count(); +ok("S3.e top readout has >= 6 cells", readouts >= 6, `cells=${readouts}`); +ok("S3.f legend swatches present", await p.locator(".bp-swatch").count() >= 5); + +// S5: Studio + Settings + Console still render +await p.locator(".tab", { hasText: "Studio" }).click(); +await p.waitForSelector(".studio"); +ok("S5.a studio renders", await p.locator(".studio-panel").count() >= 4); +await p.locator(".tab", { hasText: "Settings" }).click(); +await p.waitForSelector(".settings"); +ok("S5.b settings renders", await p.locator(".quick-users .link-btn").count() >= 1); +await p.locator(".tab", { hasText: "Mission" }).click(); +await p.waitForSelector(".bp-frame"); +await p.locator(".link-btn", { hasText: "Console" }).first().click(); +await p.waitForSelector(".console"); +ok("S5.c console renders", await p.locator(".console").isVisible()); + +// S6 cleanup: take a final picture of the canvas +await p.locator(".link-btn", { hasText: "Console" }).first().click(); +await p.waitForTimeout(300); +await p.screenshot({ path: `${OUT}/blueprint-canvas-clean.png`, fullPage: false }); + +console.log(`\nconsole errors: ${errors.length}`); +errors.slice(0, 5).forEach((e) => console.log(" -", e)); +if (errors.length > 0) process.exitCode = 1; + +await b.close(); diff --git a/src/components/BlueprintFrame.tsx b/src/components/BlueprintFrame.tsx new file mode 100644 index 0000000..86248b0 --- /dev/null +++ b/src/components/BlueprintFrame.tsx @@ -0,0 +1,107 @@ +// Instrument-panel chrome around the canvas: top + left rulers with tick +// marks every 64px, plus four corner readouts. Doctrinally aligned with +// FM06/flow-master-design-philosophy's "operations cockpit" language. +import type { ReactNode } from "react"; +import type { ProcessScenario } from "../data/types"; +import { useApp } from "../state/store"; + +interface Props { + scenario: ProcessScenario; + nodeCount: number; + children: ReactNode; +} + +const RULER = 18; // px, ruler thickness +const MAJOR = 64; // px, major tick pitch +const MINOR = 8; // px, minor tick pitch + +function ticks(length: number): Array<{ x: number; major: boolean; label?: string }> { + const out: Array<{ x: number; major: boolean; label?: string }> = []; + for (let x = 0; x <= length; x += MINOR) { + const major = x % MAJOR === 0; + out.push({ x, major, label: major ? String(x) : undefined }); + } + return out; +} + +export default function BlueprintFrame({ scenario, nodeCount, children }: Props) { + const mode = useApp((s) => s.mode); + const live = scenario.live; + const tickRow = ticks(2400); + + return ( +
+
+ + DEF + {scenario.defKey.slice(0, 12).toUpperCase()} + + + VERSION + {scenario.version.toUpperCase()} + + + HUB + {scenario.family.id.toUpperCase()} + + + NODES + {nodeCount.toString().padStart(3, "0")} + + + EDGES + {scenario.edges.length.toString().padStart(3, "0")} + + + + SRC + {live ? "LIVE" : "BLUEPRINT"} + + + MODE + {mode.toUpperCase()} + + + + TX + {(scenario.headlineTx || "—").slice(0, 12).toUpperCase()} + +
+ +
+
+ {tickRow.map((t, i) => ( + + {t.label && {t.label}} + + ))} +
+
+ {tickRow.map((t, i) => ( + + {t.label && {t.label}} + + ))} +
+
+ {children} +
+
+ + +
+
+ +
+ DONE + RUNNING + QUEUED + ERRORED + IDLE + + RULE GRID: 8PX MINOR · 64PX MAJOR + + ROUTING: ORTHOGONAL · 1PX +
+
+ ); +} diff --git a/src/components/LeftRail.tsx b/src/components/LeftRail.tsx index 76cb2df..e533214 100644 --- a/src/components/LeftRail.tsx +++ b/src/components/LeftRail.tsx @@ -91,12 +91,18 @@ function AgentActions({ txId }: { txId: string | null }) { const isLive = mode === "live" && !!txId && !!actor?.user_id; const [running, setRunning] = useState<"confirm" | "reject" | null>(null); const onConfirm = async () => { - if (!isLive) { pushToast("info", "Switch to LIVE mode + sign in to confirm a real agent run."); return; } + if (!isLive) { + pushToast("info", "Switch to LIVE mode + sign in to fire POST /api/runtime/transactions/{id}/actions/submit for real."); + return; + } setRunning("confirm"); try { await executeAction(txId!, "submit"); } finally { setRunning(null); } }; const onReject = async () => { - if (!isLive) { pushToast("info", "Switch to LIVE mode + sign in to reject a real agent run."); return; } + if (!isLive) { + pushToast("info", "Switch to LIVE mode + sign in to fire POST /api/runtime/transactions/{id}/actions/save_draft for real."); + return; + } setRunning("reject"); try { await executeAction(txId!, "save_draft"); } finally { setRunning(null); } }; diff --git a/src/components/ProcessGraph.tsx b/src/components/ProcessGraph.tsx index fe40355..2d75583 100644 --- a/src/components/ProcessGraph.tsx +++ b/src/components/ProcessGraph.tsx @@ -1,14 +1,20 @@ -// Cinematic process graph. dagre auto-layout, animated active edge, -// glass nodes, minimap, selection ring. +// Process graph rendered as an industrial blueprint, per +// FM06/flow-master-design-philosophy (DESIGN_PHILOSOPHY.md + SYNTHESIS.md): +// - light paper canvas (#f5f7fb) + 1px navy hairline grid (#1a2740) +// - square nodes, near-zero radius, 1px navy stroke +// - monospace uppercase labels +// - orthogonal (right-angle) edge routing — ReactFlow type: "step" +// - navy / paper / amber palette only, no glass, no shadows import { useMemo, type ReactElement } from "react"; import ReactFlow, { - Background, Controls, MiniMap, Handle, Position, + Background, BackgroundVariant, Controls, MiniMap, Handle, Position, type Edge, type Node, type NodeProps, } from "reactflow"; import { useApp, scenarioById } from "../state/store"; -import { layoutGraph, NODE_SIZE } from "../graph/layout"; +import { layoutGraph } from "../graph/layout"; import { Bot, User, Cog, Shield, Flag } from "./icons"; import type { ProcessStep, StepKind } from "../data/types"; +import BlueprintFrame from "./BlueprintFrame"; type NodeData = ProcessStep & { selected: boolean }; @@ -21,24 +27,46 @@ const KIND_ICON: Record ReactElement> = { decision: Cog, }; +const KIND_LABEL: Record = { + start: "START", + end: "END", + human: "HUMAN", + agent: "AGENT", + service: "SERVICE", + decision: "DECISION", +}; + +const STATE_GLYPH: Record = { + done: "■", + running: "▶", + queued: "◇", + blocked: "✕", + idle: "·", + errored: "✕", +}; + function StepNode({ data, id }: NodeProps) { const Icon = KIND_ICON[data.kind] ?? Cog; return ( -
- -
- - - {data.name} +
+ +
+ {STATE_GLYPH[data.state] ?? "·"} + {KIND_LABEL[data.kind]} + {data.state.toUpperCase()} +
+
+ + {data.name}
-
- {data.kind} - {data.owner} +
+ {id.toUpperCase()} + {data.owner.toUpperCase()} {data.governs.length > 0 && ( - {data.governs.length} + {data.governs.length} RULE{data.governs.length === 1 ? "" : "S"} )} -
- + +
); } @@ -46,10 +74,11 @@ function StepNode({ data, id }: NodeProps) { const nodeTypes = { step: StepNode }; const EDGE_COLOR: Record = { - done: "var(--ok)", - running: "var(--run)", - errored: "var(--block)", - default: "var(--border-strong)", + done: "var(--bp-ok)", + running: "var(--bp-amber)", + errored: "var(--bp-err)", + default: "var(--bp-muted)", + idle: "var(--bp-muted-2)", }; export default function ProcessGraph() { @@ -73,26 +102,36 @@ export default function ProcessGraph() { const edges: Edge[] = sc.edges.map((e) => { const srcStep = stepById.get(e.source); const isRunningEdge = srcStep?.state === "running"; + const isDone = srcStep?.state === "done"; + const isErrored = srcStep?.state === "errored"; const color = - srcStep?.state === "errored" - ? EDGE_COLOR.errored - : srcStep?.state === "done" - ? EDGE_COLOR.done - : srcStep?.state === "running" - ? EDGE_COLOR.running - : EDGE_COLOR.default; + isErrored ? EDGE_COLOR.errored + : isDone ? EDGE_COLOR.done + : isRunningEdge ? EDGE_COLOR.running + : srcStep?.state === "idle" ? EDGE_COLOR.idle + : EDGE_COLOR.default; return { id: e.id, source: e.source, target: e.target, - label: e.label, + label: e.label ? e.label.toUpperCase() : undefined, animated: isRunningEdge, - type: "smoothstep", - style: { stroke: color, strokeWidth: isRunningEdge ? 2.2 : 1.6, opacity: srcStep?.state === "idle" ? 0.55 : 1 }, - labelStyle: { fill: "var(--text-2)", fontSize: 11, fontFamily: "Fira Sans", fontWeight: 500 }, - labelBgPadding: [6, 4], - labelBgBorderRadius: 4, - labelBgStyle: { fill: "var(--surface)" }, + type: "step", + style: { + stroke: color, + strokeWidth: isRunningEdge ? 1.8 : 1, + strokeDasharray: srcStep?.state === "idle" ? "4 3" : undefined, + }, + labelStyle: { + fill: "var(--bp-navy)", + fontSize: 10, + fontFamily: "var(--bp-mono)", + fontWeight: 600, + letterSpacing: "0.08em", + }, + labelBgPadding: [4, 3] as [number, number], + labelBgBorderRadius: 0, + labelBgStyle: { fill: "var(--bp-paper)", stroke: "var(--bp-navy)", strokeWidth: 1 }, }; }); return { nodes, edges }; @@ -101,52 +140,45 @@ export default function ProcessGraph() { if (!sc) return
No scenario loaded
; return ( -
+ setSelectedStepId(n.id)} fitView - fitViewOptions={{ padding: 0.2, minZoom: 0.75, maxZoom: 1 }} + fitViewOptions={{ padding: 0.16, minZoom: 0.7, maxZoom: 1.05 }} proOptions={{ hideAttribution: true }} - minZoom={0.4} + minZoom={0.35} maxZoom={1.8} panOnScroll panOnDrag nodesDraggable={false} nodesConnectable={false} elementsSelectable - defaultEdgeOptions={{ type: "smoothstep" }} + defaultEdgeOptions={{ type: "step" }} style={{ width: "100%", height: "100%" }} > - + + { const d = (n.data as NodeData) || {}; switch (d.state) { - case "running": return "var(--run)"; - case "done": return "var(--ok)"; - case "errored": return "var(--block)"; - case "queued": return "var(--queue)"; - default: return "var(--border-strong)"; + case "running": return "var(--bp-amber)"; + case "done": return "var(--bp-ok)"; + case "errored": return "var(--bp-err)"; + case "queued": return "var(--bp-info)"; + default: return "var(--bp-muted)"; } }} - maskColor="rgba(8,11,20,0.7)" - style={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8 }} + maskColor="var(--bp-mask)" + className="bp-minimap" pannable zoomable /> -
-
- {sc.live ? "LIVE" : "BLUEPRINT"} - {sc.defName} - · {sc.version} - · {NODE_SIZE.width}×{NODE_SIZE.height} dagre LR -
-
-
+ ); } diff --git a/src/index.css b/src/index.css index 0692dda..fdeaaf8 100644 --- a/src/index.css +++ b/src/index.css @@ -834,3 +834,288 @@ textarea.studio-input { resize: vertical; min-height: 64px; } .settings-link { color: var(--primary); text-decoration: underline; } .quick-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .quick-users .link-btn { font-family: var(--mono); font-size: 11px; } + +/* ===================================================================== + INDUSTRIAL BLUEPRINT CANVAS + Doctrine: FM06/flow-master-design-philosophy (DESIGN_PHILOSOPHY.md, + SYNTHESIS.md). Paper + navy + amber. 1px rules. Square edges. + Monospace operational labels. Scoped to [data-canvas="blueprint"] + so the rest of the app keeps its existing chrome. + ===================================================================== */ +[data-canvas="blueprint"] { + --bp-paper: #f5f7fb; + --bp-paper-2: #e8edf5; + --bp-paper-3: #d5dde9; + --bp-navy: #1a2740; + --bp-navy-2: #243453; + --bp-muted: #4a5b80; + --bp-muted-2: #7a8aa8; + --bp-amber: #c46a14; + --bp-ok: #3d6a2c; + --bp-err: #a6342a; + --bp-info: #1d6f82; + --bp-mono: 'Fira Code', ui-monospace, monospace; + /* Derived token aliases for ReactFlow background lines + minimap mask. + These keep the navy hairline grid and mask honest about being + the doctrine navy ink at low opacity, not a separate color. */ + --bp-grid-minor: color-mix(in srgb, var(--bp-navy) 13%, transparent); + --bp-grid-major: color-mix(in srgb, var(--bp-navy) 33%, transparent); + --bp-mask: color-mix(in srgb, var(--bp-navy) 18%, transparent); + --bp-amber-soft: color-mix(in srgb, var(--bp-amber) 8%, transparent); + --bp-amber-strong: color-mix(in srgb, var(--bp-amber) 16%, transparent); + --bp-ok-soft: color-mix(in srgb, var(--bp-ok) 8%, transparent); +} + +.bp-frame { + position: relative; + width: 100%; + height: 100%; + display: grid; + grid-template-rows: 32px 1fr 24px; + background: var(--bp-paper); + border: 1px solid var(--bp-navy); + color: var(--bp-navy); + font-family: var(--bp-mono); + font-size: 11px; +} + +/* ---- top + bottom instrument readouts ---- */ +.bp-readout { + display: flex; + align-items: center; + gap: 0; + padding: 0; + background: var(--bp-paper); + border-bottom: 1px solid var(--bp-navy); + font-size: 10.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bp-navy); +} +.bp-readout-bottom { + border-bottom: 0; + border-top: 1px solid var(--bp-navy); + font-size: 10px; + padding: 0 0 0 4px; + gap: 16px; + color: var(--bp-muted); +} +.bp-readout-cell { + display: inline-flex; + align-items: center; + height: 100%; + padding: 0 10px; + border-right: 1px solid var(--bp-navy); + gap: 8px; + font-weight: 500; +} +.bp-readout-lbl { + color: var(--bp-muted); + font-size: 9.5px; + letter-spacing: 0.12em; +} +.bp-readout-val { + color: var(--bp-navy); + font-weight: 700; +} +.bp-readout-sep { + width: 0; + height: 100%; + border-left: 1px dashed var(--bp-muted-2); + margin: 0; +} +.bp-readout-status .bp-readout-val { padding: 1px 6px; border: 1px solid currentColor; } +.bp-readout-live .bp-readout-val { color: var(--bp-ok); background: var(--bp-ok-soft); } +.bp-readout-blueprint .bp-readout-val { color: var(--bp-amber); background: var(--bp-amber-soft); } +.bp-readout-live { color: var(--bp-ok); } +.bp-readout-blueprint { color: var(--bp-amber); } +.bp-readout-snapshot .bp-readout-val { color: var(--bp-muted); } +.bp-readout-tx .bp-readout-val { color: var(--bp-info); } + +/* ---- canvas surface ---- */ +.bp-canvas-wrap { + position: relative; + overflow: hidden; + background: var(--bp-paper); +} +.bp-canvas { + position: absolute; + inset: 18px 0 0 18px; + background: var(--bp-paper); +} + +/* ---- rulers ---- */ +.bp-ruler { + position: absolute; + background: var(--bp-paper-2); + color: var(--bp-muted); + font-family: var(--bp-mono); + font-size: 8.5px; + letter-spacing: 0.04em; + overflow: hidden; +} +.bp-ruler-top { + top: 0; left: 18px; right: 0; + height: 18px; + border-bottom: 1px solid var(--bp-navy); +} +.bp-ruler-left { + top: 18px; left: 0; bottom: 0; + width: 18px; + border-right: 1px solid var(--bp-navy); +} +.bp-tick { + position: absolute; + width: 1px; + background: var(--bp-muted-2); + pointer-events: none; +} +.bp-ruler-top .bp-tick { top: 12px; bottom: 0; } +.bp-ruler-top .bp-tick.is-major { top: 6px; background: var(--bp-navy); } +.bp-ruler-left .bp-tick { + width: auto; height: 1px; + left: 12px; right: 0; + top: auto; + background: var(--bp-muted-2); +} +.bp-ruler-left .bp-tick.is-major { left: 6px; background: var(--bp-navy); } +.bp-tick-label { + position: absolute; + color: var(--bp-navy); + font-weight: 600; +} +.bp-ruler-top .bp-tick-label { top: -10px; left: 2px; } +.bp-ruler-left .bp-tick-label { + left: -2px; + top: -12px; + transform: rotate(-90deg); + transform-origin: left top; +} +.bp-corner-tl { + position: absolute; + top: 0; left: 0; + width: 18px; height: 18px; + background: var(--bp-navy); + color: var(--bp-paper); + display: grid; + place-items: center; + font-family: var(--bp-mono); + font-size: 14px; + font-weight: 700; +} + +/* ---- legend ---- */ +.bp-legend { display: inline-flex; align-items: center; gap: 6px; } +.bp-swatch { display: inline-block; width: 12px; height: 4px; border: 1px solid var(--bp-navy); } +.bp-swatch-done { background: var(--bp-ok); border-color: var(--bp-ok); } +.bp-swatch-running { background: var(--bp-amber); border-color: var(--bp-amber); } +.bp-swatch-queued { background: var(--bp-info); border-color: var(--bp-info); } +.bp-swatch-errored { background: var(--bp-err); border-color: var(--bp-err); } +.bp-swatch-idle { background: transparent; border-color: var(--bp-muted-2); border-style: dashed; } + +/* ---- nodes: square, 1px navy stroke, mono uppercase ---- */ +[data-canvas="blueprint"] .bp-node { + width: 256px; + background: var(--bp-paper); + border: 1px solid var(--bp-navy); + border-radius: 0; + font-family: var(--bp-mono); + color: var(--bp-navy); + font-size: 11px; + position: relative; +} +.bp-node-head { + display: grid; + grid-template-columns: 22px 1fr auto; + align-items: center; + background: var(--bp-navy); + color: var(--bp-paper); + padding: 3px 8px 3px 6px; + border-bottom: 1px solid var(--bp-navy); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.bp-node-mark { font-size: 12px; line-height: 1; } +.bp-node-kind { font-weight: 700; } +.bp-node-state { color: var(--bp-paper-2); font-weight: 500; font-size: 9.5px; } +.bp-node-body { + display: flex; + align-items: center; + gap: 7px; + padding: 10px 10px 9px; + border-bottom: 1px solid var(--bp-navy); +} +.bp-node-name { + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--bp-navy); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bp-node-foot { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 3px 8px; + background: var(--bp-paper-2); + font-size: 9.5px; + letter-spacing: 0.08em; + color: var(--bp-muted); + text-transform: uppercase; +} +.bp-node-id { font-weight: 700; color: var(--bp-navy); } +.bp-node-gov { display: inline-flex; align-items: center; gap: 4px; color: var(--bp-amber); } + +.bp-node-running { border-color: var(--bp-amber); } +.bp-node-running .bp-node-head { background: var(--bp-amber); } +.bp-node-errored { border-color: var(--bp-err); } +.bp-node-errored .bp-node-head { background: var(--bp-err); } +.bp-node-done .bp-node-head { background: var(--bp-ok); } +.bp-node-done { border-color: var(--bp-ok); } +.bp-node-queued .bp-node-head { background: var(--bp-info); } +.bp-node-queued { border-color: var(--bp-info); } +.bp-node-idle .bp-node-head { background: var(--bp-muted); } +.bp-node-idle { border-color: var(--bp-muted-2); border-style: dashed; } + +.bp-node-sel { outline: 2px solid var(--bp-amber); outline-offset: 0; } +.bp-node-sel .bp-node-foot { background: var(--bp-amber-strong); } +.bp-handle { background: var(--bp-navy); width: 6px; height: 6px; border-radius: 0; border: 0; } + +/* ---- ReactFlow control overrides under blueprint scope ---- */ +[data-canvas="blueprint"] .react-flow__controls { + background: var(--bp-paper); + border: 1px solid var(--bp-navy); + border-radius: 0; + overflow: hidden; + box-shadow: none; +} +[data-canvas="blueprint"] .react-flow__controls-button { + background: var(--bp-paper); + border-bottom: 1px solid var(--bp-navy); + color: var(--bp-navy); + border-radius: 0; +} +[data-canvas="blueprint"] .react-flow__controls-button:hover { + background: var(--bp-paper-2); + color: var(--bp-navy); +} +[data-canvas="blueprint"] .react-flow__controls-button svg { fill: var(--bp-navy); } +[data-canvas="blueprint"] .react-flow__minimap, +[data-canvas="blueprint"] .bp-minimap { + border-radius: 0; + background: var(--bp-paper); + border: 1px solid var(--bp-navy); +} +[data-canvas="blueprint"] .react-flow__edge-text { font-family: var(--bp-mono); } +[data-canvas="blueprint"] .react-flow__edge.selected .react-flow__edge-path, +[data-canvas="blueprint"] .react-flow__edge:focus .react-flow__edge-path { stroke: var(--bp-amber); } +[data-canvas="blueprint"] .react-flow__attribution { display: none; } + +/* Override the dotted .mc-main background when blueprint is mounted inside it. */ +.mc-main:has([data-canvas="blueprint"]) { background: transparent; padding: 8px; }