feat: add workbench hardware evidence panel contract

Refs #99 #78 #102
This commit is contained in:
Lyon
2026-05-22 17:46:12 +08:00
committed by GitHub
parent 166a7763a4
commit f3ce52bb84
4 changed files with 797 additions and 3 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".
const distDir = path.resolve(rootDir, "dist");
fs.mkdirSync(distDir, { recursive: true });
for (const file of ["index.html", "styles.css", "app.mjs", "gate-summary.mjs", "runtime.mjs"]) {
for (const file of ["index.html", "styles.css", "app.mjs", "gate-summary.mjs", "runtime.mjs", "workbench-hardware-panel.mjs"]) {
fs.copyFileSync(path.resolve(rootDir, file), path.resolve(distDir, file));
}
+11 -2
View File
@@ -5,10 +5,18 @@ import { fileURLToPath } from "node:url";
import { loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs";
import { gateSummary } from "../gate-summary.mjs";
import { runCloudWebM3ReadonlyContract } from "./m3-readonly-contract.mjs";
import { runWorkbenchHardwarePanelContract } from "./workbench-hardware-panel-contract.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(rootDir, "../..");
const requiredFiles = ["index.html", "styles.css", "app.mjs", "gate-summary.mjs", "runtime.mjs"];
const requiredFiles = [
"index.html",
"styles.css",
"app.mjs",
"gate-summary.mjs",
"runtime.mjs",
"workbench-hardware-panel.mjs"
];
for (const file of requiredFiles) {
const filePath = path.resolve(rootDir, file);
@@ -99,5 +107,6 @@ assert.equal(gateSummary.evidenceRecords.length, 2);
assert.equal(gateSummary.safety.allowNetwork, false);
runCloudWebM3ReadonlyContract();
runWorkbenchHardwarePanelContract();
console.log("hwlab-cloud-web check ok: workbench shell is present and read-only diagnostics contract is preserved");
console.log("hwlab-cloud-web check ok: workbench shell, hardware evidence panel, and read-only diagnostics contract are present");
@@ -0,0 +1,229 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
M3_TRUSTED_CHAIN,
createHardwareEvidencePanelModel,
defaultHardwareEvidencePanelData,
hardwareEvidencePanelSourceLegend,
renderHardwareEvidencePanel
} from "../workbench-hardware-panel.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const panelPath = path.resolve(rootDir, "workbench-hardware-panel.mjs");
const panelSource = fs.readFileSync(panelPath, "utf8");
const REQUIRED_SOURCE_KINDS = Object.freeze(["SOURCE", "DRY-RUN", "DEV-LIVE", "BLOCKED"]);
const FORBIDDEN_MUTATION_PATTERNS = Object.freeze([
/hardware\.operation\.request/u,
/callRpc\(\s*["']hardware\./u,
/hardware\.invoke\.shell/u,
/audit\.event\.write/u,
/evidence\.record\.write/u,
/\/wiring\/apply/u,
/\/wiring\/reload/u,
/fetch\(/u,
/XMLHttpRequest/u,
/sendBeacon/u
]);
export function runWorkbenchHardwarePanelContract() {
assert.equal(typeof renderHardwareEvidencePanel, "function", "renderer export must exist");
assert.equal(typeof createHardwareEvidencePanelModel, "function", "model export must exist");
assert.deepEqual(M3_TRUSTED_CHAIN, {
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
patchPanelServiceId: "hwlab-patch-panel",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
});
const sourceKinds = hardwareEvidencePanelSourceLegend.map((item) => item.sourceKind);
assert.deepEqual(sourceKinds, REQUIRED_SOURCE_KINDS, "panel must expose SOURCE/DRY-RUN/DEV-LIVE/BLOCKED tags");
for (const sourceKind of REQUIRED_SOURCE_KINDS) {
assert.match(panelSource, new RegExp(`["']${sourceKind}["']`, "u"), `${sourceKind} label must be rendered by source`);
}
const blockedModel = createHardwareEvidencePanelModel(defaultHardwareEvidencePanelData);
assert.equal(blockedModel.trustedLoop.status, "blocked", "default model must stay blocked without DEV-LIVE evidence");
assert.equal(blockedModel.trustedLoop.tone, "degraded", "default model must render degraded, not green");
assert.equal(blockedModel.sourceKind, "BLOCKED", "missing DEV-LIVE evidence must force BLOCKED source kind");
assert.equal(blockedModel.chain.fromResourceId, "res_boxsimu_1");
assert.equal(blockedModel.chain.fromPort, "DO1");
assert.equal(blockedModel.chain.patchPanelServiceId, "hwlab-patch-panel");
assert.equal(blockedModel.chain.toResourceId, "res_boxsimu_2");
assert.equal(blockedModel.chain.toPort, "DI1");
assert.match(blockedModel.chain.detail, /BLOCKED/u);
assert.match(blockedModel.chain.detail, /waiting for DEV-LIVE M3 loop/u);
assert.ok(
blockedModel.statuses.some((item) => item.kind === "BOX-SIMU" && item.id === "res_boxsimu_1"),
"BOX-SIMU source status must be present"
);
assert.ok(
blockedModel.statuses.some((item) => item.kind === "Gateway-SIMU"),
"Gateway-SIMU status must be present"
);
assert.ok(
blockedModel.statuses.some((item) => item.kind === "Patch Panel" && item.id === "hwlab-patch-panel"),
"Patch Panel status must be present"
);
assert.ok(blockedModel.records.operations.length >= 1, "operation summary must render");
assert.ok(blockedModel.records.audit.length >= 1, "audit summary must render");
assert.ok(blockedModel.records.evidence.length >= 1, "evidence summary must render");
assert.ok(
blockedModel.records.evidence.some((record) => record.sourceKind === "DRY-RUN"),
"fixture evidence must stay DRY-RUN labeled"
);
assert.ok(blockedModel.controls.length >= 1, "placeholder controls must exist");
for (const control of blockedModel.controls) {
assert.equal(control.disabled, true, `${control.id} must be disabled`);
assert.match(control.reason, /not wired \/ readonly \/ waiting for DEV-LIVE M3 loop/u);
}
const misleadingGreenModel = createHardwareEvidencePanelModel({
statuses: [
{
id: "res_boxsimu_1",
label: "BOX-SIMU 1",
kind: "BOX-SIMU",
status: "healthy",
sourceKind: "SOURCE"
}
],
chain: {
...M3_TRUSTED_CHAIN,
status: "healthy",
sourceKind: "SOURCE"
},
records: {
operations: [],
audit: [],
evidence: []
},
controls: []
});
assert.equal(misleadingGreenModel.trustedLoop.status, "blocked", "green-looking source data cannot promote M3");
assert.equal(misleadingGreenModel.chain.status, "blocked", "chain must be blocked without DEV-LIVE evidence");
assert.notEqual(misleadingGreenModel.statuses[0].status, "healthy", "healthy source-only status must be downgraded");
const trustedModel = createHardwareEvidencePanelModel({
chain: {
...M3_TRUSTED_CHAIN,
status: "verified",
sourceKind: "DEV-LIVE",
detail: "DEV-LIVE M3 loop observed and attached."
},
records: {
operations: [
{
id: "op_m3_dev_live_readonly_summary",
status: "succeeded",
sourceKind: "DEV-LIVE",
summary: "Existing trusted operation summary only."
}
],
audit: [
{
id: "aud_m3_dev_live_readonly_summary",
status: "recorded",
sourceKind: "DEV-LIVE",
summary: "Existing trusted audit summary only."
}
],
evidence: [
{
id: "evi_m3_dev_live_loop",
status: "verified",
sourceKind: "DEV-LIVE",
summary: "Trusted M3 loop evidence is already recorded."
}
]
},
controls: [
{
id: "dev-live-safe-placeholder",
label: "Readonly",
disabled: false,
reason: ""
}
]
});
assert.equal(trustedModel.trustedLoop.status, "verified", "DEV-LIVE evidence plus exact chain can promote M3");
assert.equal(trustedModel.sourceKind, "DEV-LIVE");
assert.equal(trustedModel.controls[0].disabled, true, "controls must remain disabled even with DEV-LIVE evidence");
assert.match(trustedModel.controls[0].reason, /not wired \/ readonly \/ waiting for DEV-LIVE M3 loop/u);
const dom = createFakeDom();
const rendered = renderHardwareEvidencePanel(dom.container, defaultHardwareEvidencePanelData);
assert.equal(rendered.model.sourceKind, "BLOCKED", "renderer must use guarded model");
assert.equal(dom.container.children.length, 1, "renderer must replace the container with one panel root");
assert.equal(dom.container.children[0].dataset.sourceKind, "BLOCKED", "rendered panel must expose sourceKind");
const buttons = dom.container.querySelectorAll("button");
assert.equal(buttons.length, blockedModel.controls.length, "renderer must include all placeholder controls");
for (const button of buttons) {
assert.equal(button.disabled, true, "rendered placeholder controls must be disabled");
}
for (const pattern of FORBIDDEN_MUTATION_PATTERNS) {
assert.doesNotMatch(panelSource, pattern, `panel must not include mutation or network path ${pattern}`);
}
}
function createFakeDom() {
const document = {
createElement(tagName) {
return new FakeElement(tagName, document);
}
};
return {
document,
container: new FakeElement("div", document)
};
}
class FakeElement {
constructor(tagName, ownerDocument) {
this.tagName = tagName.toUpperCase();
this.ownerDocument = ownerDocument;
this.children = [];
this.dataset = {};
this.attributes = {};
this.className = "";
this.disabled = false;
this.textContent = "";
this.type = "";
}
append(...children) {
this.children.push(...children);
}
replaceChildren(...children) {
this.children = children;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
querySelectorAll(tagName) {
const normalizedTagName = tagName.toUpperCase();
const matches = [];
for (const child of this.children) {
if (child.tagName === normalizedTagName) {
matches.push(child);
}
matches.push(...child.querySelectorAll(tagName));
}
return matches;
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
runWorkbenchHardwarePanelContract();
console.log("Workbench hardware evidence panel contract passed");
}
@@ -0,0 +1,556 @@
const SOURCE_KIND_ORDER = Object.freeze(["SOURCE", "DRY-RUN", "DEV-LIVE", "BLOCKED"]);
export const M3_TRUSTED_CHAIN = Object.freeze({
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
patchPanelServiceId: "hwlab-patch-panel",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
});
export const hardwareEvidencePanelSourceLegend = Object.freeze([
{
sourceKind: "SOURCE",
label: "SOURCE",
detail: "Static contract, topology, or checked-in source material."
},
{
sourceKind: "DRY-RUN",
label: "DRY-RUN",
detail: "Fixture or local validation that never touched the live hardware path."
},
{
sourceKind: "DEV-LIVE",
label: "DEV-LIVE",
detail: "Trusted DEV observation from the M3 closed loop."
},
{
sourceKind: "BLOCKED",
label: "BLOCKED",
detail: "Cannot promote to trusted evidence until DEV-LIVE M3 exists."
}
]);
export const defaultHardwareEvidencePanelData = Object.freeze({
title: "Hardware Evidence",
subtitle: "Right-side workbench panel for the M3 trusted loop",
sourceKind: "SOURCE",
observedAt: null,
statuses: [
{
id: "res_boxsimu_1",
label: "BOX-SIMU 1",
kind: "BOX-SIMU",
role: "source DO1",
status: "degraded",
sourceKind: "BLOCKED",
detail: "No DEV-LIVE DO1 transition evidence is attached yet."
},
{
id: "gateway_simu_1",
label: "Gateway-SIMU",
kind: "Gateway-SIMU",
role: "read-only route",
status: "degraded",
sourceKind: "SOURCE",
detail: "Gateway shape is visible, but the M3 live loop is not proven."
},
{
id: "hwlab-patch-panel",
label: "Patch Panel",
kind: "Patch Panel",
role: "wiring authority",
status: "blocked",
sourceKind: "BLOCKED",
detail: "Waiting for a DEV-LIVE active link through hwlab-patch-panel."
},
{
id: "res_boxsimu_2",
label: "BOX-SIMU 2",
kind: "BOX-SIMU",
role: "target DI1",
status: "degraded",
sourceKind: "BLOCKED",
detail: "No DEV-LIVE DI1 observation is attached yet."
}
],
chain: {
...M3_TRUSTED_CHAIN,
status: "blocked",
sourceKind: "BLOCKED",
detail: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 is not DEV-LIVE proven."
},
records: {
operations: [
{
id: "op_m3_waiting_dev_live",
status: "blocked",
sourceKind: "BLOCKED",
summary: "Readonly placeholder; no hardware operation is issued from this panel."
}
],
audit: [
{
id: "audit_m3_panel_contract",
status: "recorded",
sourceKind: "SOURCE",
summary: "Workbench panel contract requires read-only evidence before trust promotion."
}
],
evidence: [
{
id: "evi_m3_dry_run_fixture",
status: "dry-run",
sourceKind: "DRY-RUN",
summary: "Local smoke proves shape only; it is not DEV-LIVE trusted evidence."
}
]
},
controls: [
{
id: "m3-do1-toggle",
label: "DO1 trigger",
disabled: true,
reason: "not wired / readonly / waiting for DEV-LIVE M3 loop"
},
{
id: "patch-panel-apply",
label: "Apply wiring",
disabled: true,
reason: "not wired / readonly / waiting for DEV-LIVE M3 loop"
}
]
});
export function createHardwareEvidencePanelModel(data = {}) {
const statuses = normalizeStatuses(data.statuses ?? defaultHardwareEvidencePanelData.statuses);
const records = normalizeRecords(data.records ?? defaultHardwareEvidencePanelData.records);
const controls = normalizeControls(data.controls ?? defaultHardwareEvidencePanelData.controls);
const chain = normalizeChain(data.chain ?? defaultHardwareEvidencePanelData.chain);
const hasTrustedDevLiveEvidence = records.evidence.some(
(record) =>
record.sourceKind === "DEV-LIVE" &&
["pass", "passed", "verified", "succeeded", "trusted"].includes(record.status)
);
const chainMatches = isRequiredM3Chain(chain);
const trustStatus = hasTrustedDevLiveEvidence && chainMatches ? "verified" : "blocked";
const trustTone = trustStatus === "verified" ? "trusted" : "degraded";
const sourceKind = trustStatus === "verified" ? "DEV-LIVE" : "BLOCKED";
const guardedChain = {
...chain,
status: trustStatus === "verified" ? normalizeStatus(chain.status, "verified") : "blocked",
sourceKind,
detail:
trustStatus === "verified"
? chain.detail
: "BLOCKED: waiting for DEV-LIVE M3 loop evidence for res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1."
};
return {
title: data.title ?? defaultHardwareEvidencePanelData.title,
subtitle: data.subtitle ?? defaultHardwareEvidencePanelData.subtitle,
observedAt: data.observedAt ?? defaultHardwareEvidencePanelData.observedAt,
sourceKind,
sourceLegend: hardwareEvidencePanelSourceLegend,
trustedLoop: {
milestone: "M3",
status: trustStatus,
tone: trustTone,
sourceKind,
reason:
trustStatus === "verified"
? "DEV-LIVE evidence is attached to the required M3 chain."
: "Current data lacks real DEV-LIVE evidence; the workbench must render BLOCKED/degraded, never green."
},
statuses: statuses.map((status) => ({
...status,
status: trustStatus === "verified" || status.status === "blocked" ? status.status : "degraded"
})),
chain: guardedChain,
records,
controls: controls.map((control) => ({
...control,
disabled: true,
reason: control.reason || "not wired / readonly / waiting for DEV-LIVE M3 loop"
}))
};
}
export function renderHardwareEvidencePanel(container, data = {}) {
if (!container || typeof container.replaceChildren !== "function") {
throw new TypeError("renderHardwareEvidencePanel requires a DOM container with replaceChildren().");
}
const model = createHardwareEvidencePanelModel(data);
const doc = container.ownerDocument ?? globalThis.document;
const root = element(doc, "aside", "hwlab-workbench-hardware-panel");
root.dataset.status = model.trustedLoop.status;
root.dataset.sourceKind = model.sourceKind;
root.setAttribute("aria-label", "Workbench hardware evidence panel");
root.append(panelStyles(doc));
root.append(renderHeader(doc, model));
root.append(renderStatusSection(doc, model));
root.append(renderChainSection(doc, model.chain));
root.append(renderRecordsSection(doc, model.records));
root.append(renderControlsSection(doc, model.controls));
root.append(renderLegendSection(doc, model.sourceLegend));
container.replaceChildren(root);
return { element: root, model };
}
function normalizeStatuses(statuses) {
return arrayFrom(statuses).map((status) => ({
id: stringOr(status.id, "unknown-resource"),
label: stringOr(status.label, status.id ?? "Unknown resource"),
kind: stringOr(status.kind, "resource"),
role: stringOr(status.role, "status"),
status: normalizeStatus(status.status, "degraded"),
sourceKind: normalizeSourceKind(status.sourceKind),
detail: stringOr(status.detail, "No detail provided.")
}));
}
function normalizeRecords(records) {
return {
operations: normalizeRecordList(records.operations),
audit: normalizeRecordList(records.audit),
evidence: normalizeRecordList(records.evidence)
};
}
function normalizeRecordList(records) {
return arrayFrom(records).map((record) => ({
id: stringOr(record.id ?? record.operationId ?? record.auditId ?? record.evidenceId, "record"),
status: normalizeStatus(record.status ?? record.outcome, "recorded"),
sourceKind: normalizeSourceKind(record.sourceKind),
summary: stringOr(record.summary ?? record.uri ?? record.kind, "No summary provided.")
}));
}
function normalizeControls(controls) {
return arrayFrom(controls).map((control) => ({
id: stringOr(control.id, "readonly-control"),
label: stringOr(control.label, "Readonly control"),
disabled: true,
reason: stringOr(control.reason, "not wired / readonly / waiting for DEV-LIVE M3 loop")
}));
}
function normalizeChain(chain) {
return {
fromResourceId: stringOr(chain.fromResourceId, M3_TRUSTED_CHAIN.fromResourceId),
fromPort: stringOr(chain.fromPort, M3_TRUSTED_CHAIN.fromPort),
patchPanelServiceId: stringOr(chain.patchPanelServiceId, M3_TRUSTED_CHAIN.patchPanelServiceId),
toResourceId: stringOr(chain.toResourceId, M3_TRUSTED_CHAIN.toResourceId),
toPort: stringOr(chain.toPort, M3_TRUSTED_CHAIN.toPort),
status: normalizeStatus(chain.status, "blocked"),
sourceKind: normalizeSourceKind(chain.sourceKind),
detail: stringOr(chain.detail, "Waiting for M3 evidence.")
};
}
function isRequiredM3Chain(chain) {
return (
chain.fromResourceId === M3_TRUSTED_CHAIN.fromResourceId &&
chain.fromPort === M3_TRUSTED_CHAIN.fromPort &&
chain.patchPanelServiceId === M3_TRUSTED_CHAIN.patchPanelServiceId &&
chain.toResourceId === M3_TRUSTED_CHAIN.toResourceId &&
chain.toPort === M3_TRUSTED_CHAIN.toPort
);
}
function normalizeStatus(status, fallback) {
const value = String(status ?? fallback).trim().toLowerCase();
return value || fallback;
}
function normalizeSourceKind(sourceKind) {
const value = String(sourceKind ?? "SOURCE").trim().toUpperCase();
return SOURCE_KIND_ORDER.includes(value) ? value : "SOURCE";
}
function arrayFrom(value) {
return Array.isArray(value) ? value : [];
}
function stringOr(value, fallback) {
const text = value === undefined || value === null ? "" : String(value).trim();
return text || fallback;
}
function renderHeader(doc, model) {
const header = element(doc, "header", "hwlab-panel-header");
const titleWrap = element(doc, "div", "hwlab-panel-title-wrap");
titleWrap.append(textElement(doc, "p", "hwlab-panel-kicker", "M3 trusted loop"));
titleWrap.append(textElement(doc, "h2", "hwlab-panel-title", model.title));
titleWrap.append(textElement(doc, "p", "hwlab-panel-subtitle", model.subtitle));
header.append(titleWrap);
header.append(badge(doc, model.sourceKind, model.trustedLoop.tone));
header.append(textElement(doc, "p", `hwlab-panel-trust tone-${toneClass(model.trustedLoop.tone)}`, model.trustedLoop.reason));
return header;
}
function renderStatusSection(doc, model) {
const section = panelSection(doc, "Hardware status", "BOX-SIMU / Gateway-SIMU / Patch Panel");
const grid = element(doc, "div", "hwlab-status-grid");
for (const item of model.statuses) {
const card = element(doc, "article", `hwlab-status-card tone-${toneClass(item.status)}`);
card.dataset.sourceKind = item.sourceKind;
const head = element(doc, "div", "hwlab-status-card-head");
head.append(textElement(doc, "strong", "hwlab-status-label", item.label));
head.append(badge(doc, item.sourceKind, item.sourceKind));
card.append(head);
card.append(textElement(doc, "span", "hwlab-status-kind", `${item.kind} / ${item.role}`));
card.append(textElement(doc, "span", `hwlab-status-value tone-${toneClass(item.status)}`, item.status.toUpperCase()));
card.append(textElement(doc, "p", "hwlab-status-detail", item.detail));
grid.append(card);
}
section.append(grid);
return section;
}
function renderChainSection(doc, chain) {
const section = panelSection(doc, "M3 chain", "Required route");
const chainLine = element(doc, "div", `hwlab-chain-line tone-${toneClass(chain.status)}`);
chainLine.append(chainNode(doc, `${chain.fromResourceId}:${chain.fromPort}`, "BOX-SIMU DO1"));
chainLine.append(textElement(doc, "span", "hwlab-chain-arrow", "->"));
chainLine.append(chainNode(doc, chain.patchPanelServiceId, "Patch Panel"));
chainLine.append(textElement(doc, "span", "hwlab-chain-arrow", "->"));
chainLine.append(chainNode(doc, `${chain.toResourceId}:${chain.toPort}`, "BOX-SIMU DI1"));
section.append(chainLine);
const detail = element(doc, "div", "hwlab-chain-detail");
detail.append(badge(doc, chain.sourceKind, chain.status));
detail.append(textElement(doc, "p", "hwlab-chain-copy", chain.detail));
section.append(detail);
return section;
}
function renderRecordsSection(doc, records) {
const section = panelSection(doc, "Records", "operation / audit / evidence summary");
const columns = element(doc, "div", "hwlab-record-columns");
for (const [label, items] of [
["operation", records.operations],
["audit", records.audit],
["evidence", records.evidence]
]) {
const column = element(doc, "article", "hwlab-record-column");
column.append(textElement(doc, "h4", "hwlab-record-heading", `${label} (${items.length})`));
const list = element(doc, "ul", "hwlab-record-list");
for (const item of items) {
const row = element(doc, "li", "hwlab-record-item");
const head = element(doc, "div", "hwlab-record-head");
head.append(textElement(doc, "span", "hwlab-record-id", item.id));
head.append(badge(doc, item.sourceKind, item.sourceKind));
row.append(head);
row.append(textElement(doc, "span", `hwlab-record-status tone-${toneClass(item.status)}`, item.status));
row.append(textElement(doc, "p", "hwlab-record-summary", item.summary));
list.append(row);
}
column.append(list);
columns.append(column);
}
section.append(columns);
return section;
}
function renderControlsSection(doc, controls) {
const section = panelSection(doc, "Controls", "readonly placeholders");
const list = element(doc, "div", "hwlab-control-list");
for (const control of controls) {
const row = element(doc, "div", "hwlab-control-row");
const button = element(doc, "button", "hwlab-control-button");
button.type = "button";
button.disabled = true;
button.dataset.controlId = control.id;
button.textContent = control.label;
row.append(button);
row.append(textElement(doc, "span", "hwlab-control-reason", control.reason));
list.append(row);
}
section.append(list);
return section;
}
function renderLegendSection(doc, legend) {
const section = panelSection(doc, "Source labels", "promotion boundary");
const list = element(doc, "dl", "hwlab-source-legend");
for (const item of legend) {
list.append(textElement(doc, "dt", "hwlab-source-kind", item.label));
list.append(textElement(doc, "dd", "hwlab-source-detail", item.detail));
}
section.append(list);
return section;
}
function panelSection(doc, title, caption) {
const section = element(doc, "section", "hwlab-panel-section");
const head = element(doc, "div", "hwlab-section-head");
head.append(textElement(doc, "h3", "hwlab-section-title", title));
head.append(textElement(doc, "span", "hwlab-section-caption", caption));
section.append(head);
return section;
}
function chainNode(doc, value, label) {
const node = element(doc, "span", "hwlab-chain-node");
node.append(textElement(doc, "strong", "hwlab-chain-value", value));
node.append(textElement(doc, "small", "hwlab-chain-label", label));
return node;
}
function badge(doc, value, tone) {
const item = element(doc, "span", `hwlab-source-badge tone-${toneClass(tone ?? value)}`);
item.textContent = value;
return item;
}
function textElement(doc, tagName, className, text) {
const item = element(doc, tagName, className);
item.textContent = text;
return item;
}
function element(doc, tagName, className) {
const item = doc.createElement(tagName);
if (className) item.className = className;
return item;
}
function toneClass(value) {
return String(value ?? "unknown")
.trim()
.replace(/[^a-z0-9_-]/giu, "-")
.toLowerCase();
}
function panelStyles(doc) {
const style = element(doc, "style");
style.textContent = `
.hwlab-workbench-hardware-panel {
box-sizing: border-box;
display: grid;
gap: 12px;
min-width: 320px;
max-width: 420px;
padding: 14px;
color: #d9e2df;
background: #151a1d;
border-left: 1px solid #364246;
font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.hwlab-workbench-hardware-panel *, .hwlab-workbench-hardware-panel *::before, .hwlab-workbench-hardware-panel *::after {
box-sizing: border-box;
}
.hwlab-panel-header, .hwlab-panel-section {
display: grid;
gap: 10px;
padding: 12px;
background: #1d2427;
border: 1px solid #364246;
border-radius: 6px;
}
.hwlab-panel-title-wrap {
display: grid;
gap: 2px;
}
.hwlab-section-head, .hwlab-status-card-head, .hwlab-record-head, .hwlab-control-row, .hwlab-chain-detail {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.hwlab-panel-title, .hwlab-section-title, .hwlab-record-heading, .hwlab-panel-kicker, .hwlab-panel-subtitle, .hwlab-panel-trust, .hwlab-status-detail, .hwlab-chain-copy, .hwlab-record-summary {
margin: 0;
}
.hwlab-panel-title {
font-size: 18px;
font-weight: 700;
}
.hwlab-panel-kicker, .hwlab-section-caption, .hwlab-status-kind, .hwlab-chain-label, .hwlab-panel-subtitle, .hwlab-source-detail, .hwlab-control-reason {
color: #95a6a3;
}
.hwlab-source-badge {
flex: 0 0 auto;
padding: 2px 7px;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.hwlab-status-grid, .hwlab-record-columns {
display: grid;
gap: 8px;
}
.hwlab-status-card, .hwlab-record-column, .hwlab-record-item, .hwlab-chain-line {
display: grid;
gap: 6px;
padding: 10px;
background: #101416;
border: 1px solid #2c373a;
border-radius: 6px;
}
.hwlab-status-value, .hwlab-record-status, .hwlab-record-id, .hwlab-chain-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.hwlab-chain-line {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
}
.hwlab-chain-node {
display: grid;
gap: 2px;
min-width: 0;
}
.hwlab-chain-value {
overflow-wrap: anywhere;
}
.hwlab-chain-arrow {
color: #95a6a3;
}
.hwlab-record-list, .hwlab-source-legend {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
}
.hwlab-record-list {
list-style: none;
}
.hwlab-source-legend {
grid-template-columns: auto minmax(0, 1fr);
}
.hwlab-source-kind {
margin: 0;
font-weight: 700;
}
.hwlab-source-detail {
margin: 0;
}
.hwlab-control-list {
display: grid;
gap: 8px;
}
.hwlab-control-button {
min-width: 104px;
padding: 6px 9px;
color: #95a6a3;
background: #222a2d;
border: 1px solid #3a474b;
border-radius: 4px;
cursor: not-allowed;
}
.tone-blocked, .tone-degraded {
color: #ffb86b;
}
.tone-source, .tone-dry-run {
color: #8fc7ff;
}
.tone-trusted, .tone-dev-live, .tone-verified {
color: #74d99f;
}
`;
return style;
}